405 lines
12 KiB
TypeScript
Executable File
405 lines
12 KiB
TypeScript
Executable File
"use client";
|
|
|
|
import { useProgress } from "@bprogress/next";
|
|
import { Calendar, Lock, Trash2, Unlock } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { useParams, useRouter } from "next/navigation";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
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 { shared } from "@/lib/comms";
|
|
import { sse } from "@/lib/db/sse-endpoints";
|
|
import { cn } from "@/lib/utils";
|
|
import type { getEndgameConfig } from "../api";
|
|
import { bulkDelete, setFree, setLimit, toggleCheck, toggleLock } from "../api";
|
|
|
|
export function SidebarLink({
|
|
submission,
|
|
className,
|
|
prefetch = true,
|
|
}: {
|
|
submission: { id: string; name: string; checked: boolean; queue: number };
|
|
className?: string;
|
|
prefetch?: boolean;
|
|
}) {
|
|
const { stop } = useProgress();
|
|
const { id } = useParams();
|
|
const [checked, setChecked] = useState(submission.checked);
|
|
|
|
useEffect(() => {
|
|
setChecked(submission.checked);
|
|
}, [submission.checked]);
|
|
|
|
return (
|
|
<Link
|
|
href={`/rubgram/admin/${submission.id}`}
|
|
className={cn(
|
|
className,
|
|
id === submission.id && "bg-accent text-accent-foreground",
|
|
)}
|
|
prefetch={prefetch}
|
|
onClick={(ev) => {
|
|
if ((ev.target as HTMLButtonElement).type === "button") {
|
|
ev.preventDefault();
|
|
}
|
|
}}
|
|
>
|
|
{submission.checked ? <div className="px-1" /> : `${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 getEndgameConfig>>;
|
|
length: number;
|
|
}) {
|
|
const [dialog, setDialog] = useState(1);
|
|
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();
|
|
setDialog(1);
|
|
}}
|
|
>
|
|
<span>ตั้งจำกัดคิว</span>
|
|
</DropdownMenuItem>
|
|
</DialogTrigger>
|
|
<DropdownMenuItem
|
|
onClick={async () => {
|
|
await toggleLock();
|
|
toast.success("บันทึกการเปลี่ยนแปลงแล้ว");
|
|
}}
|
|
>
|
|
<span>{config.locked && "เ"}ปิดรับ</span>
|
|
</DropdownMenuItem>
|
|
<DialogTrigger asChild>
|
|
<DropdownMenuItem
|
|
onSelect={(e) => {
|
|
e.preventDefault();
|
|
setDialog(2);
|
|
}}
|
|
>
|
|
<span>ตั้งจำนวนคิวฟรี</span>
|
|
</DropdownMenuItem>
|
|
</DialogTrigger>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
{dialog === 1 ? (
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>ตั้งจำกัดคิว</DialogTitle>
|
|
</DialogHeader>
|
|
<form
|
|
id="limit-form"
|
|
action={async (data: FormData) => {
|
|
await setLimit(Number(data.get("limit")) || -1);
|
|
toast.success("บันทึกการเปลี่ยนแปลงแล้ว");
|
|
}}
|
|
>
|
|
<Input
|
|
id="limit"
|
|
name="limit"
|
|
type="number"
|
|
placeholder="ไม่จำกัด"
|
|
defaultValue={config.limit >= 0 ? config.limit : ""}
|
|
autoFocus
|
|
/>
|
|
</form>
|
|
<DialogFooter>
|
|
<DialogClose asChild>
|
|
<Button variant="outline">ยกเลิก</Button>
|
|
</DialogClose>
|
|
<DialogClose asChild>
|
|
<Button type="submit" form="limit-form">
|
|
บันทึกการเปลี่ยนแปลง
|
|
</Button>
|
|
</DialogClose>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
) : (
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>ตั้งจำนวนคิวฟรี</DialogTitle>
|
|
</DialogHeader>
|
|
<form
|
|
id="free-form"
|
|
action={async (data: FormData) => {
|
|
await setFree(Number(data.get("free")) || 0);
|
|
toast.success("บันทึกการเปลี่ยนแปลงแล้ว");
|
|
}}
|
|
>
|
|
<Input
|
|
id="free"
|
|
name="free"
|
|
type="number"
|
|
placeholder="ไม่ฟรีเลย"
|
|
defaultValue={config.free >= 0 ? config.free : ""}
|
|
autoFocus
|
|
/>
|
|
</form>
|
|
<DialogFooter>
|
|
<DialogClose asChild>
|
|
<Button variant="outline">ยกเลิก</Button>
|
|
</DialogClose>
|
|
<DialogClose asChild>
|
|
<Button type="submit" form="free-form">
|
|
บันทึกการเปลี่ยนแปลง
|
|
</Button>
|
|
</DialogClose>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
)}
|
|
</Dialog>
|
|
<SidebarMenuBadge>
|
|
{length}
|
|
{config.limit >= 0 ? `/${config.limit}` : ""}
|
|
{config.free > 0 ? ` (เหลือคิวฟรี ${config.free})` : ""}
|
|
</SidebarMenuBadge>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
);
|
|
}
|
|
|
|
export function SubmissionList({
|
|
subs,
|
|
}: {
|
|
subs: {
|
|
id: string;
|
|
name: string;
|
|
checked: boolean;
|
|
publicQueue: number;
|
|
paid: boolean;
|
|
deleted: boolean;
|
|
}[];
|
|
}) {
|
|
const [query, setQuery] = useState("");
|
|
const [debug] = shared.state("debug");
|
|
const [selIds, setSelIds] = shared.state("rubgram.sel_id");
|
|
const bottomRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
void debug;
|
|
// Scrolls to the dummy div whenever 'items' changes
|
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, [debug]);
|
|
|
|
return (
|
|
<>
|
|
<Input
|
|
placeholder="ค้นหา..."
|
|
type="search"
|
|
onChange={(ev) => setQuery(ev.target.value.toLowerCase())}
|
|
/>
|
|
{subs
|
|
.filter(
|
|
(s) =>
|
|
(debug || (s.paid && !s.deleted)) &&
|
|
(s.publicQueue + s.name).toLowerCase().includes(query),
|
|
)
|
|
.map((s) =>
|
|
debug ? (
|
|
<div key={s.id} className="flex items-center gap-0">
|
|
<Checkbox
|
|
className="mx-1 shrink-0"
|
|
checked={selIds?.includes(s.id) ?? false}
|
|
onCheckedChange={(ch) =>
|
|
setSelIds((prev) =>
|
|
ch
|
|
? [...(prev || []), s.id]
|
|
: (prev || []).filter((i) => i !== s.id),
|
|
)
|
|
}
|
|
/>
|
|
<SidebarMenuButton
|
|
asChild
|
|
className={cn(
|
|
((debug && !s.paid) || s.deleted) && "opacity-50",
|
|
)}
|
|
>
|
|
<SidebarLink
|
|
submission={{ ...s, queue: s.publicQueue }}
|
|
prefetch={!s.deleted}
|
|
/>
|
|
</SidebarMenuButton>
|
|
</div>
|
|
) : (
|
|
<SidebarMenuButton
|
|
key={s.id}
|
|
asChild
|
|
className={cn(((debug && !s.paid) || s.deleted) && "opacity-50")}
|
|
>
|
|
<SidebarLink
|
|
submission={{ ...s, queue: s.publicQueue }}
|
|
prefetch={!s.deleted}
|
|
/>
|
|
</SidebarMenuButton>
|
|
),
|
|
)}
|
|
<div ref={bottomRef} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function BulkDeleteButton() {
|
|
const [selIds, setSelIds] = shared.state("rubgram.sel_id");
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
if (!selIds?.length) return null;
|
|
|
|
return (
|
|
<SidebarMenu>
|
|
<SidebarMenuItem>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
className="w-full"
|
|
disabled={loading}
|
|
onClick={async () => {
|
|
setLoading(true);
|
|
try {
|
|
await bulkDelete(selIds);
|
|
setSelIds([]);
|
|
toast.success(`ลบ ${selIds.length} รายการแล้ว`);
|
|
} catch (e) {
|
|
toast.error(`${e}`);
|
|
}
|
|
setLoading(false);
|
|
}}
|
|
>
|
|
{loading ? "กำลังลบ..." : <Trash2 />}
|
|
ลบ {selIds.length} รายการ
|
|
</Button>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
);
|
|
}
|
|
|
|
export function Watcher() {
|
|
const router = useRouter();
|
|
const count = useRef<number | undefined>(undefined);
|
|
// Reload after connection restored
|
|
shared.signal("sync", () => router.refresh());
|
|
|
|
useEffect(() => {
|
|
const { clean } = sse.rubgram.sub("update", () => router.refresh());
|
|
const interval = setInterval(() => {
|
|
fetch("/api/rubgram/count")
|
|
.then((r) => r.json())
|
|
.then((r) => {
|
|
if (typeof count.current === "undefined") count.current = r;
|
|
if (count.current !== r) router.refresh();
|
|
count.current = r;
|
|
})
|
|
.catch((e) => console.error("Fetching rubgram count failed", e));
|
|
}, 60000);
|
|
return () => {
|
|
clean();
|
|
clearInterval(interval);
|
|
};
|
|
}, [router]);
|
|
|
|
return <div></div>;
|
|
}
|
|
|
|
export function CalendarButton() {
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<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>ปฏิทินคิว</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|