Restructure layout and add widget
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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 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 rounded-md">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { useProgress } from "@bprogress/next";
|
||||
import { Lock, Unlock } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
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 { stop } = useProgress();
|
||||
const { id } = useParams();
|
||||
const [checked, setChecked] = useState(submission.checked);
|
||||
|
||||
useEffect(() => {
|
||||
setChecked(submission.checked);
|
||||
}, [submission.checked]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/artifact/admin/${submission.id}`}
|
||||
className={cn(
|
||||
className,
|
||||
id === submission.id && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
prefetch
|
||||
onClick={(ev) => {
|
||||
if ((ev.target as HTMLButtonElement).type === "button") {
|
||||
ev.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{submission.queue}. {submission.name}
|
||||
<SidebarMenuBadge className="pointer-events-auto">
|
||||
<Checkbox
|
||||
className="mr-2"
|
||||
checked={checked}
|
||||
onCheckedChange={async () => {
|
||||
setChecked((x) => !x);
|
||||
await toggleCheck(submission.id);
|
||||
stop();
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubmissionList({
|
||||
subs,
|
||||
}: {
|
||||
subs: { id: string; name: string; checked: boolean; queue: number }[];
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
type="search"
|
||||
onChange={(ev) => setQuery(ev.target.value.toLowerCase())}
|
||||
/>
|
||||
{subs
|
||||
.filter((s) => (s.queue + s.name).toLowerCase().includes(query))
|
||||
.map((s) => (
|
||||
<SidebarMenuButton key={s.id} asChild>
|
||||
<SidebarLink submission={s} />
|
||||
</SidebarMenuButton>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Watcher() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
const es = new EventSource(`/api/artifact/ev`);
|
||||
es.addEventListener("update", () => router.refresh());
|
||||
return () => es.close();
|
||||
}, [router]);
|
||||
|
||||
return <div></div>;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Dice3, Trash2 } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarProvider,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { getArtifactConfig, random, wipe } from "@/lib/api";
|
||||
import { apiAuthCheck } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { submissions } from "@/lib/db/schema";
|
||||
import { LimitManager, SubmissionList, Watcher } from "./client";
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (!(await apiAuthCheck())) redirect("/");
|
||||
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="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={random}
|
||||
>
|
||||
<Dice3 size={24} className="size-6" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-8">
|
||||
<Trash2 size={24} className="size-6 text-red-500" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="z-101">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you absolutely sure?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete
|
||||
your account and remove your data from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={wipe}>
|
||||
Continue
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Submissions</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SubmissionList subs={subs} />
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>การตั้งค่า</SidebarGroupLabel>
|
||||
<LimitManager config={config} length={subs.length} />
|
||||
</SidebarGroup>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<SidebarInset className="bg-transparent">
|
||||
{children}
|
||||
<Watcher />
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import generate from "@/lib/og";
|
||||
|
||||
// Image metadata
|
||||
export const size = {
|
||||
width: 1222,
|
||||
height: 560,
|
||||
};
|
||||
|
||||
export const contentType = "image/png";
|
||||
|
||||
// Image generation
|
||||
export default async function Image() {
|
||||
return generate({ title: "เสือกไอดีชาวบ้าน", sub: "(ฝั่งแอดมิน)" });
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const metadata = {
|
||||
title: "เสือกไอดีชาวบ้าน (แอดมิน)",
|
||||
};
|
||||
|
||||
export default function AdminPage() {
|
||||
return <div></div>;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/** biome-ignore-all lint/a11y/noStaticElementInteractions: Span is needed for double triggering */
|
||||
/** biome-ignore-all lint/a11y/useKeyWithClickEvents: Not needed */
|
||||
"use client";
|
||||
|
||||
import { BookAlert, CircleX, Loader2, Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Avatar from "@/components/avatar";
|
||||
import { Blocker } from "@/components/blocker";
|
||||
import { ComboBox } from "@/components/combobox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { getCharacters } from "@/lib/api";
|
||||
import { comms } from "@/lib/comms";
|
||||
import { uidRegex } from "@/lib/const";
|
||||
import type { characters } from "@/lib/db/schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { EnkaNetworkUser } from "@/types/enka";
|
||||
import { RulesDialog } from "./rules";
|
||||
|
||||
export function CharacterChooser({
|
||||
clist,
|
||||
}: {
|
||||
clist: {
|
||||
value: string;
|
||||
label: string;
|
||||
}[];
|
||||
}) {
|
||||
const [uid, setUid] = useState("");
|
||||
const [chars, setChars] = useState<(typeof characters.$inferSelect)[]>([]);
|
||||
const [selected, setSelected] = useState<string | undefined>();
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [manual, setManual] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
setIsError(false);
|
||||
async function fetchChars() {
|
||||
if (!uidRegex.test(uid)) return setChars([]);
|
||||
const res = await fetch(`/api/enka/${uid}`);
|
||||
if (!res.ok) return setIsError(true);
|
||||
const data: EnkaNetworkUser = await res.json();
|
||||
if (!data.playerInfo.showAvatarInfoList) return setIsError(true);
|
||||
const charIds = data.playerInfo.showAvatarInfoList.map((c) =>
|
||||
c.avatarId.toString(),
|
||||
);
|
||||
const chars = await getCharacters(charIds);
|
||||
setChars(
|
||||
charIds.map((e) => chars.find((c) => c.amber === e)!).filter(Boolean),
|
||||
);
|
||||
}
|
||||
fetchChars()
|
||||
.catch(() => setIsError(true))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [uid]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="uid">UID</Label>
|
||||
<Input
|
||||
id="uid"
|
||||
name="uid"
|
||||
type="number"
|
||||
required
|
||||
placeholder="814006303"
|
||||
onChange={(ev) => setUid(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Label htmlFor="character">
|
||||
เลือกตัวละครที่ต้องการ
|
||||
{manual ? (
|
||||
<u
|
||||
className="ml-auto cursor-pointer"
|
||||
onClick={() => setManual(false)}
|
||||
>
|
||||
เลือกจากลิสต์
|
||||
</u>
|
||||
) : (
|
||||
<span className="ml-auto">
|
||||
หาไม่เจอ?{" "}
|
||||
<u className="cursor-pointer" onClick={() => setManual(true)}>
|
||||
เลือกเอง
|
||||
</u>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
{manual ? (
|
||||
<ComboBox
|
||||
placeholder="ค้นหาตัวละคร"
|
||||
id="character"
|
||||
name="character"
|
||||
data={clist}
|
||||
className="w-full bg-transparent! hover:bg-accent!"
|
||||
/>
|
||||
) : (
|
||||
<ScrollArea>
|
||||
{isError ? (
|
||||
<Placeholder>
|
||||
<CircleX className="text-red-500" /> เกิดข้อผิดพลาดในการดึงข้อมูล
|
||||
</Placeholder>
|
||||
) : isLoading ? (
|
||||
<Placeholder>
|
||||
<Loader2 className="animate-spin" /> กำลังโหลดตัวละคร...
|
||||
</Placeholder>
|
||||
) : !chars.length ? (
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Placeholder />
|
||||
<Placeholder />
|
||||
<Placeholder />
|
||||
<Placeholder />
|
||||
<Placeholder />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Placeholder
|
||||
className="w-[76.8px] cursor-pointer rounded-sm"
|
||||
onClick={() => setManual(true)}
|
||||
>
|
||||
<Search />
|
||||
</Placeholder>
|
||||
{chars.map((c) => (
|
||||
<Button key={c.id} asChild onClick={() => setSelected(c.name)}>
|
||||
<Avatar
|
||||
scale={0.6}
|
||||
char={c}
|
||||
selected={selected ? selected === c.name : 0}
|
||||
/>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ScrollBar orientation="horizontal" />
|
||||
<input
|
||||
id="character"
|
||||
name="character"
|
||||
type="hidden"
|
||||
value={selected}
|
||||
/>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Placeholder({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-[100.8px] flex gap-2 justify-center items-center rounded-md bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
></div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Disclaimer() {
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
if (agreed) return;
|
||||
return (
|
||||
<Blocker>
|
||||
<RulesDialog onOpenChange={(set) => !set && setAgreed(true)}>
|
||||
<Button variant="destructive" type="button">
|
||||
<BookAlert /> อ่านกฎ
|
||||
</Button>
|
||||
</RulesDialog>
|
||||
</Blocker>
|
||||
);
|
||||
}
|
||||
|
||||
export async function ClearCookie() {
|
||||
// biome-ignore lint/suspicious/noTsIgnore: typescript issue
|
||||
// @ts-ignore
|
||||
cookieStore.delete("sid");
|
||||
return "";
|
||||
}
|
||||
|
||||
export function WarningDialog() {
|
||||
const [manual, setManual] = comms.var("manual");
|
||||
|
||||
comms.event("beforeSubmit", () => {
|
||||
manual;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { submitArtifact } from "@/lib/api";
|
||||
|
||||
export function ArtifactFormWrapper(props: React.ComponentProps<"form">) {
|
||||
const router = useRouter();
|
||||
async function submit(data: FormData) {
|
||||
submitArtifact(data)
|
||||
.then((q) => {
|
||||
if (typeof q === "string") return toast.error(q);
|
||||
try {
|
||||
// biome-ignore lint/suspicious/noTsIgnore: typescript issue
|
||||
// @ts-ignore
|
||||
cookieStore.set("sid", q.id);
|
||||
} catch {}
|
||||
router.refresh();
|
||||
})
|
||||
.catch((e) => toast.error(`${e.message || e}`));
|
||||
}
|
||||
return (
|
||||
<form
|
||||
{...props}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit(new FormData(e.target as HTMLFormElement));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { TooltipContent, TooltipTrigger } from "@radix-ui/react-tooltip";
|
||||
import { TvMinimalPlay } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { YoutubeLiveInfo } from "@/app/api/live/route";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip } from "@/components/ui/tooltip";
|
||||
|
||||
export async function LiveButton() {
|
||||
const live: YoutubeLiveInfo = await fetch(`http://localhost:3000/api/live`)
|
||||
.then((e) => e.json())
|
||||
.catch(() => "none");
|
||||
if (live === "none") return;
|
||||
const { url, thumbnails, title } = live;
|
||||
|
||||
const targetWidth = 220;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link href={url}>
|
||||
<Button variant="outline" type="button">
|
||||
<TvMinimalPlay className="text-red-500 animate-pulse" /> LIVE
|
||||
</Button>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="m-2 p-2 flex flex-col gap-2 rounded border bg-card">
|
||||
<Image
|
||||
height={thumbnails.height * (targetWidth / thumbnails.width)}
|
||||
width={targetWidth}
|
||||
src={thumbnails.url}
|
||||
alt="yt thumbnail"
|
||||
className="rounded border"
|
||||
/>
|
||||
<div
|
||||
className="truncate line-clamp-2"
|
||||
style={{ maxWidth: targetWidth }}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import generate from "@/lib/og";
|
||||
|
||||
// Image metadata
|
||||
export const size = {
|
||||
width: 1222,
|
||||
height: 560,
|
||||
};
|
||||
|
||||
export const contentType = "image/png";
|
||||
|
||||
// Image generation
|
||||
export default async function Image() {
|
||||
return generate({ title: "เสือกไอดีชาวบ้าน", sub: "" });
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -0,0 +1,212 @@
|
||||
import { TooltipTrigger } from "@radix-ui/react-tooltip";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { BookAlert, CircleDollarSign, SendHorizonal } from "lucide-react";
|
||||
import { cookies } from "next/headers";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import banner from "#/logos/artifact.webp";
|
||||
import { Blocker } from "@/components/blocker";
|
||||
import { ComboBox } from "@/components/combobox";
|
||||
import { SimpleTooltip } from "@/components/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { getArtifactConfig } from "@/lib/api";
|
||||
import { db } from "@/lib/db";
|
||||
import { characters, submissions } from "@/lib/db/schema";
|
||||
import { PageTransition } from "../../transition";
|
||||
import { CharacterChooser, ClearCookie, Disclaimer } from "./client";
|
||||
import { ArtifactFormWrapper } from "./form";
|
||||
import { LiveButton } from "./live";
|
||||
import { RulesDialog } from "./rules";
|
||||
|
||||
export const metadata = {
|
||||
title: "เสือกไอดีชาวบ้าน",
|
||||
};
|
||||
|
||||
export default async function ArtifactFormPage() {
|
||||
const cookie = await cookies();
|
||||
const sid = cookie.get("sid");
|
||||
const [q] = sid?.value
|
||||
? await db
|
||||
.select({ queue: submissions.queue })
|
||||
.from(submissions)
|
||||
.where(eq(submissions.id, sid.value))
|
||||
: [];
|
||||
const clist = await db
|
||||
.select({
|
||||
label: characters.name,
|
||||
value: characters.name,
|
||||
})
|
||||
.from(characters);
|
||||
const config = await getArtifactConfig();
|
||||
const count = await db.$count(submissions);
|
||||
|
||||
return (
|
||||
<PageTransition>
|
||||
<div className="flex flex-col justify-around items-center h-svh">
|
||||
<Card className="w-full max-w-md">
|
||||
{q ? (
|
||||
<Blocker>
|
||||
<div className="flex gap-1 flex-col items-center">
|
||||
<span className="font-bold text-3xl">คิวของคุณคือหมายเลข</span>
|
||||
<span className="font-bold text-5xl">{q.queue}</span>
|
||||
</div>
|
||||
</Blocker>
|
||||
) : config.locked ? (
|
||||
<Blocker>
|
||||
<span className="font-bold text-3xl">ยังไม่เปิดรับคิว</span>
|
||||
</Blocker>
|
||||
) : config.limit >= 0 && count >= config.limit ? (
|
||||
<Blocker>
|
||||
<div className="flex gap-1 flex-col items-center">
|
||||
<span className="font-bold text-3xl">คิวเต็มแล้ว</span>
|
||||
<span className="font-bold text-2xl">ต้องโดเนทลัดคิวแล้วล่ะ</span>
|
||||
</div>
|
||||
</Blocker>
|
||||
) : (
|
||||
<Disclaimer />
|
||||
)}
|
||||
<CardHeader className="justify-center">
|
||||
<CardTitle>
|
||||
<div className="w-[276.5px]">
|
||||
<Link href="/">
|
||||
<Image
|
||||
style={{
|
||||
transform: "translateY(-70%)",
|
||||
}}
|
||||
className="absolute z-50"
|
||||
height={137.5}
|
||||
width={276.5}
|
||||
src={banner}
|
||||
alt="เสือกไอดีชาวบ้าน"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ArtifactFormWrapper id="mainform">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">ชื่อ*</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="Mr.Buzz"
|
||||
autoComplete="name"
|
||||
maxLength={32}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{config.enka ? (
|
||||
<CharacterChooser clist={clist} />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="uid">UID*</Label>
|
||||
<Input
|
||||
id="uid"
|
||||
name="uid"
|
||||
type="number"
|
||||
required
|
||||
placeholder="814006303"
|
||||
maxLength={10}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="character">ตัวละคร*</Label>
|
||||
<ComboBox
|
||||
placeholder="ค้นหาตัวละคร"
|
||||
id="character"
|
||||
name="character"
|
||||
data={clist}
|
||||
className="w-full bg-transparent! hover:bg-accent!"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="comment">ข้อความเพิ่มเติม</Label>
|
||||
<Textarea
|
||||
id="comment"
|
||||
name="comment"
|
||||
placeholder="เช่น Er พอไหมครับ, คริสวยยังครับ (ไม่บังคับ)"
|
||||
className="bg-card!"
|
||||
maxLength={512}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ArtifactFormWrapper>
|
||||
</CardContent>
|
||||
<CardFooter className="flex gap-2 justify-between">
|
||||
<div className="flex gap-2">
|
||||
<SimpleTooltip text="โดเนทลัดคิว ขั้นต่ำ 10 บาท">
|
||||
<Link
|
||||
href="https://tipme.in.th/536d969652666273c2fa85ad"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={
|
||||
config.limit >= 0 && count >= config.limit
|
||||
? "bg-emerald-600! border-white! animate-pulse"
|
||||
: ""
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<CircleDollarSign />
|
||||
</Button>
|
||||
</Link>
|
||||
</SimpleTooltip>
|
||||
<Tooltip>
|
||||
<RulesDialog>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="destructive" type="button">
|
||||
<BookAlert />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</RulesDialog>
|
||||
<TooltipContent>อ่านกฏการลงคิว</TooltipContent>
|
||||
</Tooltip>
|
||||
<Suspense>
|
||||
<LiveButton />
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className="p-1">
|
||||
{count} / {config.limit < 0 ? "∞" : config.limit}
|
||||
</span>
|
||||
<SimpleTooltip text="ส่งเลยจัฟลูกพี่">
|
||||
<Button
|
||||
type="submit"
|
||||
form="mainform"
|
||||
disabled={
|
||||
!!q ||
|
||||
config.locked ||
|
||||
(config.limit >= 0 && count >= config.limit)
|
||||
}
|
||||
>
|
||||
<SendHorizonal />
|
||||
</Button>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
{sid && !q && <ClearCookie />}
|
||||
</div>
|
||||
</PageTransition>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function RulesDialog({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root> & {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>กฏการลงคิว</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p>
|
||||
{" "}
|
||||
1. คิวจะใช้ระบบเรียง หรืออาจสุ่มบางครั้ง แต่ถ้ามีโดเนทลัดคิวมาจะดูไอดีที่โดเนทมาก่อน
|
||||
<br />
|
||||
2. คิวจะมีการลบใหม่หมดทุกครั้งหลังจบสตรีม (ต้องลงใหม่ทุกครั้งนะคับ)
|
||||
<br /> 3. สามารถดูให้ได้แค่
|
||||
<u className="text-red-500">ตัวละครเดียว</u>เท่านั้น แต่ถ้าโดเนทลัดคิว
|
||||
สามารถดูได้สูงสุด 8 ตัว
|
||||
<br />
|
||||
4. โดเนทขั้นต่ำ 10 บาท หากโดเนทมา แล้วลงคิวไปแล้ว แจ้งชื่อที่ลงไปในคิวมาด้วย
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">ตกลง</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user