This commit is contained in:
2026-03-11 00:07:47 +07:00 Unverified
52 changed files with 1543 additions and 585 deletions
@@ -14,7 +14,7 @@ import {
} from "@/components/form";
import { ModalBase } from "@/components/modal";
import { Button } from "@/components/ui/button";
import { DialogFooter } from "@/components/ui/dialog";
import { DialogClose, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import {
@@ -178,9 +178,9 @@ export default async function TlVersionCreatePage({
</FormInput>
</FormRow>
<DialogFooter>
<FormAction type="clear" asChild>
<Button variant="outline">Clear</Button>
</FormAction>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button asChild>
<FormAction
type="submit"
@@ -39,12 +39,13 @@ export default async function TlTypeEditPage({
id: string;
order: string;
image: string;
mode: string;
}>,
) {
"use server";
if (!(await adminCheck())) redirect("/login");
for (const field of ["name", "id", "order"] as const) {
for (const field of ["name", "id", "order", "mode"] as const) {
if (!form.get(field)) {
return { error: `Field "${field}" is required.` };
}
@@ -58,6 +59,7 @@ export default async function TlTypeEditPage({
name: form.get("name")!,
image: form.get("image")!,
order: parseInt(form.get("order")!, 10),
mode: form.get("mode")!,
};
await db
.update(tierlistTypes)
@@ -104,6 +106,9 @@ export default async function TlTypeEditPage({
<FormInput name="order" label="Order">
<Input placeholder="1" />
</FormInput>
<FormInput name="mode" label="Mode">
<Input placeholder="ชั้น 12" />
</FormInput>
<FormInput name="image" label="Image" subLabel="optional">
<CdnChooser />
+10 -5
View File
@@ -7,7 +7,7 @@ import { CdnChooser } from "@/components/chooser";
import { FormAction, FormInput, FormProvider } from "@/components/form";
import { ModalBase } from "@/components/modal";
import { Button } from "@/components/ui/button";
import { DialogFooter } from "@/components/ui/dialog";
import { DialogClose, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { actionLog } from "@/lib/api";
@@ -27,12 +27,13 @@ export default async function TlTypeCreatePage() {
id: string;
order: string;
image: string;
mode: string;
}>,
) {
"use server";
if (!(await adminCheck())) redirect("/login");
for (const field of ["name", "id", "order"] as const) {
for (const field of ["name", "id", "order", "mode"] as const) {
if (!form.get(field)) {
return { error: `Field "${field}" is required.` };
}
@@ -45,6 +46,7 @@ export default async function TlTypeCreatePage() {
id: form.get("id")!,
name: form.get("name")!,
image: form.get("image")!,
mode: form.get("mode")!,
order: parseInt(form.get("order")!, 10),
};
await db.insert(tierlistTypes).values(data);
@@ -81,13 +83,16 @@ export default async function TlTypeCreatePage() {
<FormInput name="order" label="Order">
<Input placeholder="1" defaultValue={maxOrder + 10} />
</FormInput>
<FormInput name="mode" label="Mode">
<Input placeholder="ชั้น 12" />
</FormInput>
<FormInput name="image" label="Image" subLabel="optional">
<CdnChooser />
</FormInput>
<DialogFooter>
<FormAction type="clear" asChild>
<Button variant="outline">Clear</Button>
</FormAction>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button asChild>
<FormAction
type="submit"
+7
View File
@@ -76,6 +76,7 @@ const columns: ColumnDef<{
export function CdnTable({
files,
onChoose,
onChange,
}: {
files: {
id: string;
@@ -89,6 +90,7 @@ export function CdnTable({
size: string;
} | null,
) => void;
onChange?: () => void;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
@@ -108,6 +110,7 @@ export function CdnTable({
toast.promise(cdnify(file), {
loading: `Uploading ${file.name}...`,
success: () => {
onChange?.();
return `${file.name} has been uploaded to CDN.`;
},
error: `Error uploading ${file.name}.`,
@@ -130,6 +133,7 @@ export function CdnTable({
throw "";
}
setRowSelection({});
onChange?.();
return `${selectedIds.length} files has been deleted.`;
},
error: `Error deleting the files.`,
@@ -145,6 +149,7 @@ export function CdnTable({
toast.promise(fetchToCdn(urlList), {
loading: `Fetching ${urlList.length} files...`,
success: () => {
onChange?.();
return `${urlList.length} URLs has been uploaded to CDN.`;
},
error: "Error fetching the files.",
@@ -201,6 +206,7 @@ export function CdnTable({
toast.promise(rename(row.id, newName), {
success() {
onChange?.();
return "เปลี่ยนชื่อสำเร็จ";
},
error() {
@@ -219,6 +225,7 @@ export function CdnTable({
const file = files.find((f) => f.id === res.id);
throw `${file ? file.name || `[${res.id}]` : `[${res.id}]`} ยังถูกใช้โดย ${res.refs.join(", ")}`;
}
onChange?.();
return "ลบสำเร็จ";
},
error(err: { message: string; description: string }) {
+2
View File
@@ -61,6 +61,7 @@ import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { tierlistTypes, tierlistVersions, versions } from "@/lib/db/schema";
import { SidebarLink, VersionCreateDialogForm } from "./client";
import { AdminNavbar } from "./navbar";
export default async function AdminLayout({
children,
@@ -229,6 +230,7 @@ export default async function AdminLayout({
</SidebarFooter>
</Sidebar>
<SidebarInset className="bg-transparent">
<AdminNavbar />
{modal}
{children}
</SidebarInset>
+346
View File
@@ -0,0 +1,346 @@
"use client";
import {
AlignJustify,
Badge,
BadgeDollarSign,
ChevronRight,
Columns3Cog,
ExternalLink,
Form,
GitGraph,
Grid3x3,
Home,
IdCard,
List,
Package,
Plus,
ScrollText,
Server,
Settings,
Table,
UserSquare2,
X,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { ComponentType } from "react";
import { useEffect, useRef, useState } from "react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
type NavbarEntry = {
name: string;
icon: ComponentType<{ size?: number; name?: string; className?: string }>;
divide?: boolean;
last?: boolean;
} & ({ href: string; sub?: never } | { sub: NavbarEntry[]; href?: never });
function VersionBadge({
name,
className,
}: {
name?: string;
className?: string;
}) {
return (
<span className={cn("text-[10px] font-bold leading-none", className)}>
{name}
</span>
);
}
function isActive(entry: NavbarEntry, pathname: string): boolean {
if ("href" in entry && entry.href)
return pathname === entry.href || pathname.startsWith(`${entry.href}/`);
if ("sub" in entry && entry.sub)
return entry.sub.some((s) => isActive(s, pathname));
return false;
}
function findActiveParent(
items: NavbarEntry[],
pathname: string,
): NavbarEntry | null {
for (const item of items) {
if ("sub" in item && item.sub) {
if (item.sub.some((s) => isActive(s, pathname))) return item;
}
}
return null;
}
function DropdownMenu({
items,
showLabels,
onClose,
depth = 0,
}: {
items: NavbarEntry[];
showLabels: boolean;
onClose: () => void;
depth?: number;
}) {
const pathname = usePathname();
const [openSub, setOpenSub] = useState<string | null>(null);
return (
<div
className={cn(
"flex flex-col gap-0.5 min-w-40",
depth > 0 && "ml-2 pl-2 border-l border-white/10",
)}
>
{items.map((item) => {
const Icon = item.icon;
const active = isActive(item, pathname);
const hasSub = "sub" in item && item.sub;
const isOpen = openSub === item.name;
return (
<div key={item.name}>
{hasSub ? (
<>
<button
type="button"
onClick={() => setOpenSub(isOpen ? null : item.name)}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-left",
"transition-colors text-xs",
active
? "bg-white/15 text-white"
: "text-white/60 hover:text-white hover:bg-white/10",
)}
>
<Icon size={14} name={item.name} className="shrink-0" />
<span className="flex-1">{item.name}</span>
<ChevronRight
size={12}
className={cn(
"transition-transform",
isOpen && "rotate-90",
)}
/>
</button>
{isOpen && (
<DropdownMenu
items={item.sub!}
showLabels={showLabels}
onClose={onClose}
depth={depth + 1}
/>
)}
</>
) : (
<Link
href={item.href!}
onClick={onClose}
className={cn(
"flex items-center gap-2 px-2 py-1.5 rounded-md",
"transition-colors text-xs",
active
? "bg-white/15 text-white"
: "text-white/60 hover:text-white hover:bg-white/10",
)}
>
<Icon size={14} name={item.name} className="shrink-0" />
<span>{item.name}</span>
</Link>
)}
</div>
);
})}
</div>
);
}
export function AdminNavbar() {
const pathname = usePathname();
const isMobile = useIsMobile();
const [showLabels, setShowLabels] = useState(false);
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
const navRef = useRef<HTMLDivElement>(null);
const items: NavbarEntry[] = [
{ name: "หน้าหลัก", href: "/admin/", icon: Home, divide: true },
{
name: "Tierlist",
icon: Table,
sub: [
{ name: "Versions", icon: GitGraph, href: "/admin/tl/ver" },
{ name: "Badges", icon: Badge, href: "/admin/tl/badges" },
{ name: "Layout", icon: Columns3Cog, href: "/admin/tl/layout" },
],
},
{
name: "Versions",
icon: List,
sub: [
{ name: "Types", icon: Form, href: "/admin/ver/types" },
{ name: "Create", icon: Plus, href: "/admin/ver/create" },
{ name: "6.4", icon: VersionBadge, href: "/admin/ver/64" },
{ name: "6.3", icon: VersionBadge, href: "/admin/ver/63" },
],
},
{
name: "Global",
icon: Server,
sub: [
{
name: "Admin Pages",
icon: ExternalLink,
sub: [
{ name: "Artifact", icon: IdCard, href: "/artifact/admin" },
{ name: "Rubgram", icon: BadgeDollarSign, href: "/rubgram/admin" },
{
name: "Tierlist",
icon: Grid3x3,
sub: [
{
name: "Abyss 6.4",
icon: VersionBadge,
href: "/tl/aby/64/admin",
},
{
name: "Abyss 6.3",
icon: VersionBadge,
href: "/tl/aby/63/admin",
},
],
},
],
},
{ name: "Characters", icon: UserSquare2, href: "/admin/char" },
{ name: "CDN", icon: Package, href: "/admin/cdn" },
{ name: "Audit Log", icon: ScrollText, href: "/admin/log" },
{ name: "Settings", icon: Settings, href: "/admin/settings" },
],
},
];
const [scopeOverride, setScopeOverride] = useState<"root" | null>(null);
// Reset override whenever pathname changes (navigating resets scope)
// biome-ignore lint/correctness/useExhaustiveDependencies: used for other purepose
useEffect(() => {
setScopeOverride(null);
}, [pathname]);
const activeParent =
scopeOverride === "root" ? null : findActiveParent(items, pathname);
const prefixParent =
scopeOverride === "root" ? findActiveParent(items, pathname) : activeParent;
useEffect(() => {
function handleClick(e: MouseEvent) {
if (navRef.current && !navRef.current.contains(e.target as Node)) {
setOpenDropdown(null);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
if (!isMobile) return "";
return (
<div ref={navRef} className="sticky top-0 z-49">
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-white/10 bg-card/50 backdrop-blur-sm">
<div className="flex items-center gap-1 shrink-0">
{prefixParent && (
<button
type="button"
onClick={() =>
setScopeOverride(scopeOverride === "root" ? null : "root")
}
className={cn(
"flex items-center justify-center w-6 h-6 rounded transition-colors opacity-50 hover:opacity-100",
scopeOverride === "root" && "opacity-100 bg-white/15",
)}
>
<prefixParent.icon size={14} name={prefixParent.name} />
</button>
)}
<button
type="button"
onClick={() => setShowLabels((v) => !v)}
className={cn(
"flex items-center justify-center w-6 h-6 rounded transition-colors",
showLabels
? "bg-white/15 text-white"
: "text-white/40 hover:text-white/70",
)}
>
{showLabels ? <X size={13} /> : <AlignJustify size={13} />}
</button>
<div className="w-px h-4 bg-white/10 mx-0.5" />
</div>
<div className="flex items-center gap-0.5 overflow-x-auto scrollbar-none flex-1 min-w-0">
{(activeParent ? activeParent.sub! : items).map((item) => {
const Icon = item.icon;
const active = isActive(item, pathname);
const hasSub = "sub" in item && item.sub;
const isOpen = openDropdown === item.name;
const buttonContent = (
<>
<Icon size={15} name={item.name} className="shrink-0" />
{showLabels && (
<span className="text-[10px] whitespace-nowrap leading-none">
{item.name}
</span>
)}
</>
);
const buttonClass = cn(
"flex items-center gap-1 px-1.5 py-1 rounded transition-colors shrink-0",
active || isOpen
? "bg-white/15 text-white"
: "text-white/50 hover:text-white hover:bg-white/10",
);
if (hasSub) {
return (
<button
key={item.name}
onClick={() => setOpenDropdown(isOpen ? null : item.name)}
className={buttonClass}
type="button"
>
{buttonContent}
</button>
);
}
return (
<Link key={item.name} href={item.href!} className={buttonClass}>
{buttonContent}
</Link>
);
})}
</div>
</div>
{openDropdown &&
(() => {
const entry = (activeParent ? activeParent.sub! : items).find(
(i) => i.name === openDropdown,
);
if (!entry || !("sub" in entry) || !entry.sub) return null;
return (
<div className="absolute left-0 right-0 top-full mt-1 z-50">
<div className="rounded-lg border border-white/10 bg-card/95 backdrop-blur-sm p-2 shadow-xl">
<DropdownMenu
items={entry.sub}
showLabels={showLabels}
onClose={() => setOpenDropdown(null)}
/>
</div>
</div>
);
})()}
</div>
);
}
+48 -25
View File
@@ -7,26 +7,34 @@ import {
ImageIcon,
OctagonAlert,
ScanSearch,
SquircleDashed,
} from "lucide-react";
import Image from "next/image";
import { useEffect, useState } from "react";
import { SimpleTooltip } from "@/components/tooltip";
import { Button } from "@/components/ui/button";
import { getCardStatus } from "@/lib/api";
import { cn } from "@/lib/utils";
export function EnkaBrowser({ sub, uid }: { sub: string; uid: string }) {
const [ready, setReady] = useState(false);
const [error, setError] = useState(false);
const [error, setError] = useState<string | null>(null);
const [useWeb, setUseWeb] = useState(false);
const [isCached, setIsCached] = useState(true);
// biome-ignore lint/correctness/useExhaustiveDependencies: for update
useEffect(() => {
setReady(false);
setError(false);
setError(null);
setIsCached(true);
getCardStatus(sub).then((s) => {
setIsCached(s.cached);
if (!s.cached) setError(s.error);
});
}, [sub, uid]);
return (
<div className="h-full w-full relative">
{!ready && !error && (
{!ready && !error && (isCached || useWeb) && (
<ScanSearch
className={cn(
"animate-pulse absolute left-1/2 -translate-x-1/2 -translate-y-1/2 size-8 text-muted-foreground z-45",
@@ -36,29 +44,44 @@ export function EnkaBrowser({ sub, uid }: { sub: string; uid: string }) {
)}
{error && (
<div className="flex flex-col items-center gap-2 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-40">
<OctagonAlert className="size-8 text-red-500" />
<span>ไม่สามารถโหลดข้อมูลตัวละคร</span>
{error.includes("ใหม่") ? (
<SquircleDashed className="size-8 text-orange-500" />
) : (
<OctagonAlert className="size-8 text-red-500" />
)}
<span>{error}</span>
</div>
)}
{(ready || error) && (
<SimpleTooltip text={useWeb ? "สลับเป็นรูปภาพ" : "สลับเป็น Enka"}>
<Button
variant="outline"
className={cn(
"absolute left-1 z-45 bg-[#222a]! backdrop-blur-sm opacity-50 md:opacity-20 hover:opacity-100",
useWeb ? "-bottom-19" : "bottom-1",
)}
size="icon"
onClick={() => {
setReady(false);
setError(false);
setUseWeb((x) => !x);
}}
>
{useWeb ? <ImageIcon /> : <Dock />}
</Button>
</SimpleTooltip>
{!error && !ready && !isCached && !useWeb && (
<div
className={cn(
"flex flex-col items-center gap-2 absolute left-1/2 -translate-x-1/2 -translate-y-1/2 z-40",
useWeb ? "top-[calc(50%+80px)]" : "top-1/2",
)}
>
<ScanSearch className="size-8 animate-pulse" />
<span>ยังไม่ได้เตรียมการ์ดไว้ล่วงหน้า จะใช้เวลาสักพัก</span>
</div>
)}
<SimpleTooltip text={useWeb ? "สลับเป็นรูปภาพ" : "สลับเป็น Enka"}>
<Button
variant="outline"
className={cn(
"absolute left-1 z-45 bg-[#222a]! backdrop-blur-sm opacity-50 md:opacity-20 hover:opacity-100",
useWeb ? "-bottom-19" : "bottom-1",
)}
size="icon"
onClick={() => {
setReady(false);
setError(null);
setUseWeb((x) => !x);
}}
>
{useWeb ? <ImageIcon /> : <Dock />}
</Button>
</SimpleTooltip>
{useWeb ? (
<iframe
src={`https://enka.network/u/${uid}`}
@@ -85,9 +108,9 @@ export function EnkaBrowser({ sub, uid }: { sub: string; uid: string }) {
title="Enka Network"
onLoad={() => {
setReady(true);
setError(false);
setError(null);
}}
onError={() => setError(true)}
onError={() => error || setError("ไม่สามารถโหลดข้อมูลตัวละคร")}
/>
</div>
)}
+1 -1
View File
@@ -48,7 +48,7 @@ export default async function AdminLayout({
queue: submissions.queue,
})
.from(submissions)
.orderBy(submissions.id);
.orderBy(submissions.queue);
const config = await getArtifactConfig();
return (
<SidebarProvider>
+26 -7
View File
@@ -44,15 +44,19 @@ import { RulesDialog } from "./rules";
export function CharacterChooser({
clist,
uid: defaultUid = "",
char: defaultChar,
}: {
clist: {
value: string;
label: string;
}[];
uid?: string;
char?: string;
}) {
const [uid, setUid] = useState("");
const [uid, setUid] = useState(defaultUid);
const [chars, setChars] = useState<(typeof characters.$inferSelect)[]>([]);
const [selected, setSelected] = useState<string | undefined>();
const [selected, setSelected] = useState<string | undefined>(defaultChar);
const [isError, setIsError] = useState<boolean | string>(false);
const [isLoading, setIsLoading] = useState(false);
const [manual, setManual] = useState(false);
@@ -60,6 +64,10 @@ export function CharacterChooser({
const [guideDialogOpen, setGuideDialogOpen] = useState(false);
const [cacheTtl, setCacheTtl] = useState(new Date());
useEffect(() => {
if (defaultUid) setUid(defaultUid);
}, [defaultUid]);
useEffect(() => {
setIsLoading(true);
setIsError(false);
@@ -83,10 +91,15 @@ export function CharacterChooser({
c.avatarId.toString(),
);
const chars = await getCharacters(charIds);
setChars(
charIds.map((e) => chars.find((c) => c.amber === e)!).filter(Boolean),
const userChars = charIds
.map((e) => chars.find((c) => c.amber === e)!)
.filter(Boolean);
setChars(userChars);
setSelected(
userChars.some((c) => c.name === defaultChar)
? defaultChar
: undefined,
);
setSelected(undefined);
} catch (e) {
console.error(e);
setIsError("ข้อผิดพลาดภายในระบบ");
@@ -95,7 +108,7 @@ export function CharacterChooser({
fetchChars()
.catch(() => setIsError(true))
.finally(() => setIsLoading(false));
}, [uid]);
}, [uid, defaultChar]);
return (
<>
@@ -108,6 +121,7 @@ export function CharacterChooser({
required
placeholder="814006303"
onChange={(ev) => setUid(ev.target.value)}
defaultValue={defaultUid}
/>
</div>
<Label htmlFor="character">
@@ -329,7 +343,12 @@ export function Disclaimer() {
export function ClearCookie() {
// biome-ignore lint/suspicious/noTsIgnore: typescript issue
// @ts-ignore
cookieStore.delete("sid");
try {
cookieStore.delete("sid");
} catch {
// not in browser(im too lazy to check)
}
return "";
}
+5 -2
View File
@@ -4,10 +4,13 @@ import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { submitArtifact } from "@/lib/api";
export function ArtifactFormWrapper(props: React.ComponentProps<"form">) {
export function ArtifactFormWrapper({
edit,
...props
}: React.ComponentProps<"form"> & { edit?: { sub: string; token: string } }) {
const router = useRouter();
async function submit(data: FormData) {
submitArtifact(data)
submitArtifact(data, edit)
.then((q) => {
if (typeof q === "string") return toast.error(q);
try {
+108 -18
View File
@@ -1,6 +1,12 @@
import { TooltipTrigger } from "@radix-ui/react-tooltip";
import { eq } from "drizzle-orm";
import { BookAlert, CircleDollarSign, SendHorizonal } from "lucide-react";
import {
BookAlert,
CircleDollarSign,
PencilIcon,
SendHorizonal,
SquarePlay,
} from "lucide-react";
import type { Metadata } from "next";
import { cookies } from "next/headers";
import Link from "next/link";
@@ -18,6 +24,12 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
@@ -35,33 +47,63 @@ export const metadata: Metadata = {
description: "ระบบลงคิวดูอาร์ติแฟกต์เกนชินในไลฟ์สตรีม",
};
export default async function ArtifactFormPage() {
export default async function ArtifactFormPage({
searchParams,
}: PageProps<"/artifact">) {
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))
? await db.select().from(submissions).where(eq(submissions.id, sid.value))
: [];
const clist = await db
.select({
label: characters.name,
value: characters.name,
})
.from(characters);
.from(characters)
.orderBy(characters.name);
const config = await getArtifactConfig();
const count = await db.$count(submissions);
const { edit: searchEdit } = await searchParams;
const editing = q && searchEdit === q.editToken && q.edits < 5;
return (
<div className="flex flex-col justify-around items-center h-svh">
<div className="flex justify-around items-center h-svh">
<div className="hidden md:block p-5">
<div className="aspect-video w-110"></div>
</div>
<Card className="w-full max-w-md">
{q ? (
{editing ? (
""
) : 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>
{q.edits < 5 && !q.checked ? (
<SimpleTooltip text="แก้ไข">
<Button
className="absolute bottom-0 right-0 m-2"
variant="outline"
size="icon"
asChild
>
<Link href={`?edit=${q.editToken}`}>
<PencilIcon />
</Link>
</Button>
</SimpleTooltip>
) : (
<Button
className="absolute bottom-0 right-0 m-2 bg-red-500/50!"
variant="outline"
disabled
>
<PencilIcon />
แก้ไม่ได้แล้ว
</Button>
)}
</Blocker>
) : config.locked ? (
<Blocker>
@@ -96,7 +138,10 @@ export default async function ArtifactFormPage() {
</CardTitle>
</CardHeader>
<CardContent>
<ArtifactFormWrapper id="mainform">
<ArtifactFormWrapper
id="mainform"
edit={q ? { token: q.editToken, sub: q.id } : undefined}
>
<div className="flex flex-col gap-3">
<div className="grid gap-2">
<Label htmlFor="name">ชื่อ*</Label>
@@ -106,12 +151,17 @@ export default async function ArtifactFormPage() {
type="text"
placeholder="Mr.Buzz"
autoComplete="name"
maxLength={32}
maxLength={64}
required
defaultValue={editing ? q.name : undefined}
/>
</div>
{config.enka ? (
<CharacterChooser clist={clist} />
editing ? (
<CharacterChooser clist={clist} uid={q.uid} char={q.char} />
) : (
<CharacterChooser clist={clist} />
)
) : (
<>
<div className="grid gap-2">
@@ -123,6 +173,7 @@ export default async function ArtifactFormPage() {
required
placeholder="814006303"
maxLength={10}
defaultValue={editing ? q.uid : undefined}
/>
</div>
<div className="grid gap-2">
@@ -133,6 +184,7 @@ export default async function ArtifactFormPage() {
name="character"
data={clist}
className="w-full bg-transparent! hover:bg-accent!"
defaultValue={editing ? q.char : undefined}
/>
</div>
</>
@@ -144,7 +196,8 @@ export default async function ArtifactFormPage() {
name="comment"
placeholder="เช่น Er พอไหมครับ, คริสวยยังครับ (ไม่บังคับ)"
className="bg-card!"
maxLength={512}
maxLength={1024}
defaultValue={editing ? q.comment : undefined}
/>
</div>
</div>
@@ -181,9 +234,15 @@ export default async function ArtifactFormPage() {
</RulesDialog>
<TooltipContent>อ่านกฏการลงคิว</TooltipContent>
</Tooltip>
<Suspense>
<LiveButton />
</Suspense>
{editing ? (
<Button variant="destructive" asChild>
<Link href="?">ยกเลิก</Link>
</Button>
) : (
<Suspense>
<LiveButton />
</Suspense>
)}
</div>
<div className="flex gap-2 items-center">
<span className="p-1">
@@ -194,17 +253,48 @@ export default async function ArtifactFormPage() {
type="submit"
form="mainform"
disabled={
!!q ||
(!!q && !editing) ||
config.locked ||
(config.limit >= 0 && count >= config.limit)
}
>
<SendHorizonal />
{editing ? <PencilIcon /> : <SendHorizonal />}
</Button>
</SimpleTooltip>
</div>
</CardFooter>
</Card>
<div className="hidden md:block bg-card p-5 rounded border border-border relative">
<span className="absolute font-semibold text-7xl -translate-y-3/4 w-110 text-center text-shadow-lg/80">
Guide
</span>
<video
src="https://cdn.gunshiz.top/buzz/artifact/guide.mp4"
controls
className="aspect-video w-110"
>
<track kind="captions" />
</video>
</div>
<Dialog>
<DialogTrigger asChild>
<div className="md:hidden absolute w-full bottom-0 p-1 font-semibold div flex justify-center items-center gap-2 text-xl bg-input/30 border">
<SquarePlay />
วิธีลงทะเบียน
</div>
</DialogTrigger>
<DialogContent className="p-1">
<DialogTitle className="hidden">Guide</DialogTitle>
<video
src="https://cdn.gunshiz.top/buzz/artifact/guide.mp4"
controls
autoPlay
className="aspect-video w-110"
>
<track kind="captions" />
</video>
</DialogContent>
</Dialog>
{sid && !q && <ClearCookie />}
</div>
);
+2 -1
View File
@@ -9,7 +9,8 @@ import { cn } from "@/lib/utils";
export function Navbar() {
const pathname = usePathname();
if (!["/tl", "/artifact", "/rubgram"].includes(pathname)) return "";
if (!/^\/(?:tl(?:\/[a-zA-Z0-9]+)?|rubgram|artifact)$/gm.test(pathname))
return "";
return (
<div className="flex gap-2 p-2 rounded-lg absolute top-3 left-3 border bg-card">
<Item icon={<Home size={16} />} name="หน้าหลัก" href="/" divide />
+2 -19
View File
@@ -39,7 +39,7 @@ export default function Home() {
name="รับกรรมแทนคนดู"
desc={
<>
รับเล่นคอนเท้น Endgame แทนคนดู{" "}
รับเล่นคอนเทนต์เอนเกมแทนคนดู{" "}
<span className="text-red-500">ไม่ฟรีนะคับ</span>
</>
}
@@ -48,7 +48,7 @@ export default function Home() {
/>
<HomeLink
name="จัดเทียร์ลิสต์อบิส"
desc="ระบบจัดเทียร์ลิสต์ตัวละครของคอนเท้น Abyss และ Stygain"
desc="ระบบจัดเทียร์ลิสต์ตัวละครของคอนเทนต์ Abyss และ Stygian"
href="/tl"
logo={TierlistLogo}
/>
@@ -93,23 +93,6 @@ export default function Home() {
</svg>
Discord
</Link>
<div className="flex flex-col text-xs justify-around mb-1.5">
<span>Made with:</span>
<div className="flex gap-1 justify-evenly">
<Link href="https://nextjs.org" target="_blank">
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="fill-white w-5"
>
<title>Next.js</title>
<path d="M18.665 21.978C16.758 23.255 14.465 24 12 24 5.377 24 0 18.623 0 12S5.377 0 12 0s12 5.377 12 12c0 3.583-1.574 6.801-4.067 9.001L9.219 7.2H7.2v9.596h1.615V9.251l9.85 12.727Zm-3.332-8.533 1.6 2.061V7.2h-1.6v6.245Z" />
</svg>
</Link>
</div>
</div>
</div>
</div>
</footer>
View File
View File
View File
View File
View File
+3 -26
View File
@@ -5,38 +5,15 @@ import {
Copy,
CopyCheck,
ImageOff,
Loader2,
MessageSquareWarning,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import { debugUploadSlip, discordCall } from "../../api";
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 Evernight() {
return <div className="h-full w-full relative"></div>;
}
export function CopyButton({ text }: { text: string }) {
+4 -2
View File
@@ -24,7 +24,7 @@ import {
endgameSubmissions,
endgameTypes,
} from "@/lib/db/schema";
import { CallButton, CopyButton, DebugSlipUpload } from "./client";
import { CallButton, CopyButton, DebugSlipUpload, Evernight } from "./client";
export default async function AdminSubmissionView({
params,
@@ -128,7 +128,9 @@ export default async function AdminSubmissionView({
<DebugSlipUpload sid={sub.id} />
)}
</div>
<div className="border rounded-md h-full overflow-hidden"></div>
<div className="border rounded-md h-full overflow-hidden">
<Evernight />
</div>
</div>
</div>
);
+15 -15
View File
@@ -304,20 +304,20 @@ export function SlipButton() {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => {
window.location.href = "/rubgram/admin/slip";
}}
>
<Logs size={24} className="size-6" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>All time คิว</p>
</TooltipContent>
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => {
window.location.href = "/rubgram/admin/slip";
}}
>
<Logs size={24} className="size-6" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>All time คิว</p>
</TooltipContent>
</Tooltip>
);
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { Dice3, Trash2 } from "lucide-react";
import { Dice5, Trash2 } from "lucide-react";
import { redirect } from "next/navigation";
import {
AlertDialog,
@@ -60,7 +60,7 @@ export default async function AdminLayout({
className="size-8"
onClick={random}
>
<Dice3 size={24} className="size-6" />
<Dice5 size={24} className="size-6" />
</Button>
<SlipButton />
<AlertDialog>
+1 -1
View File
@@ -69,7 +69,7 @@ import { RulesDialog } from "./rules";
export const metadata: Metadata = {
title: "รับกรรมแทนทางบ้าน",
description: "รับเล่นคอนเท้น Endgame เกนชินแทนคนดู",
description: "รับเล่นคอนเทนต์เอนเกมเกนชินแทนคนดู",
};
export default async function EndgamePage() {
+1 -1
View File
@@ -26,7 +26,7 @@ export function RulesDialog({
</DialogHeader>
<p>
{" "}
1. เมื่อจ่ายเงิน แล้วจะ<u className="text-red-500">ไม่สามารถขอคืนเงิน</u>
1. เมื่อจ่ายเงินแล้วจะ<u className="text-red-500">ไม่สามารถขอคืนเงิน</u>
ได้ทุกกรณีนะคับ
<br />
2. เมื่อถึงคิวแล้วจะมีการเรียกตัวผ่าน Discord ผ่านช่อง ยมบาลตามตัว
+2
View File
@@ -24,6 +24,8 @@ export interface TypedFormData<T extends TypedFormDataShape> {
has<K extends keyof T>(key: K): boolean;
entries(): T;
/** underlying raw FormData */
readonly raw: FormData;
}
+214 -146
View File
@@ -1,5 +1,6 @@
"use client";
import { MessageSquareText, Trash } from "lucide-react";
import Link from "next/link";
import { useContext, useEffect, useState } from "react";
import AmberIcon from "#/amber.png";
@@ -21,13 +22,22 @@ export function Draggable({
char,
state,
tier,
cid,
}: {
char: typeof characters.$inferSelect;
state?: typeof tierlistStates.$inferInsert;
tier?: string;
cid?: string;
}) {
const { tileSize, badgeSize, badges, setState, editable } =
useContext(TierListContext);
const {
tileSize,
badgeSize,
badges,
setState,
editable,
deleteMode,
removeChar,
} = useContext(TierListContext);
const [panelOpen, setPanelOpen] = useState(false);
@@ -43,12 +53,12 @@ export function Draggable({
useEffect(() => {
const timeout = setTimeout(() => {
if (!dirty) return;
if (!dirty || !cid) return;
setDirty(0);
setState(char.id, { comment, badges: assignedBadges });
setState(cid, { comment, badges: assignedBadges });
}, dirty);
return () => clearTimeout(timeout);
}, [dirty, comment, setState, char, assignedBadges]);
}, [dirty, comment, setState, cid, assignedBadges]);
function toggleBadge(badge: string) {
setDirty(200);
@@ -60,152 +70,210 @@ export function Draggable({
}
return (
<SortableDraggable id={char.id}>
{({ listeners, attributes, setNodeRef, style }) => (
<Popover open={panelOpen} onOpenChange={setPanelOpen}>
<PopoverTrigger asChild>
<button
ref={setNodeRef}
style={style}
suppressHydrationWarning
{...listeners}
{...attributes}
onContextMenu={(ev) => {
ev.preventDefault();
setPanelOpen((x) => !x);
}}
>
<div className="relative">
<SimpleTooltip text={char.name} delayDuration={1000}>
<Image
src={`/cdn/${char.image}`}
style={{
background: `rgba(${
char.stars === 5
? "200,124,36"
: char.stars === 4
? "148,112,187"
: "100,100,100"
}) linear-gradient(136deg,rgba(49,43,71,.5294117647058824),transparent)`,
}}
className="rounded hover:brightness-110"
alt={char.name}
height={tileSize}
width={tileSize}
/>
</SimpleTooltip>
{[
"bottom-0.5 right-0.5",
"bottom-0.5 left-0.5",
"top-0.5 right-0.5",
"top-0.5 left-0.5",
].map((p, i) => {
if (!assignedBadges[i]) return "";
const badge = badges.find((e) => e.id === assignedBadges[i]);
if (!badge) return "";
return badge.image ? (
<Image
key={p}
src={`/cdn/${badge.image}`}
className={`bg-[#2228] ${p} absolute rounded border`}
alt="badge"
width={badgeSize}
height={badgeSize}
/>
) : (
<div
key={p}
style={{ width: badgeSize, height: badgeSize }}
className={`bg-[#2228] ${p} absolute rounded border font-bold`}
>
{badge.name}
</div>
);
})}
</div>
</button>
</PopoverTrigger>
<PopoverContent
side="right"
className="bg-[#2225] backdrop-blur-md p-1"
<SortableDraggable id={cid || char.id}>
{({ listeners, attributes, setNodeRef, style }) =>
editable && deleteMode ? (
<button
ref={setNodeRef}
style={style}
suppressHydrationWarning
{...listeners}
{...attributes}
onContextMenu={(ev) => {
ev.preventDefault();
if (cid) removeChar(cid);
}}
>
<div className="flex gap-2">
{editable && (
<div className="flex flex-col justify-between shrink-0 gap-2">
<div className="grid grid-cols-2 gap-1 h-min items-stretch">
{badges
.filter(
(b) =>
assignedBadges.includes(b.id) ||
!b.tier.length ||
!tier ||
b.tier.includes(tier),
)
.map((e) =>
e.image ? (
<Image
src={`/cdn/${e.image}`}
alt={e.name}
key={`B${e.id}`}
width={32}
height={32}
className={cn(
"border rounded hover:brightness-110 hover:backdrop-brightness-300 cursor-pointer",
assignedBadges.includes(e.id) &&
"backdrop-brightness-500",
)}
onClick={() => toggleBadge(e.id)}
/>
) : (
<button
key={`B${e.id}`}
className={cn(
"border rounded hover:brightness-110 hover:backdrop-brightness-300 flex justify-center items-center font-bold cursor-pointer min-w-8 min-h-8",
assignedBadges.includes(e.id) &&
"backdrop-brightness-500",
)}
onClick={() => toggleBadge(e.id)}
type="button"
>
{e.name}
</button>
),
)}
</div>
<Link
href={`https://gi.yatta.moe/en/archive/avatar/${char.amber}/${char.name.replace(/ /g, "-").toLowerCase()}`}
target="_blank"
rel="noreferrer noopener"
>
<Button variant="outline" className="p-1 w-full">
<Image
src={AmberIcon}
alt="Amber"
width={16}
height={16}
/>
<div className="flex flex-col items-start">
<span className="text-[8px]">เปิดใน</span>
<span className="text-[10px]">Amber</span>
</div>
</Button>
</Link>
<div className="relative">
<SimpleTooltip text="คลิกขวาเพื่อลบ">
<div className="absolute justify-center items-center top-0 left-0 bottom-0 right-0 rounded bg-[#0005] flex opacity-0 hover:opacity-100 transition-opacity text-red-500">
<Trash />
</div>
)}
<Textarea
placeholder="Comment..."
className="aspect-square bg-card resize-none disabled:opacity-90"
value={comment}
onChange={(ev) => {
setDirty(500);
setComment(ev.target.value);
</SimpleTooltip>
<Image
src={`/cdn/${char.image}`}
style={{
background: `rgba(${
char.stars === 5
? "200,124,36"
: char.stars === 4
? "148,112,187"
: "100,100,100"
}) linear-gradient(136deg,rgba(49,43,71,.5294117647058824),transparent)`,
}}
disabled={!editable}
className="rounded hover:brightness-110"
alt={char.name}
height={tileSize}
width={tileSize}
/>
</div>
</PopoverContent>
</Popover>
)}
</button>
) : (
<Popover open={panelOpen} onOpenChange={setPanelOpen}>
<PopoverTrigger asChild>
<button
ref={setNodeRef}
style={style}
suppressHydrationWarning
{...listeners}
{...attributes}
onContextMenu={(ev) => {
ev.preventDefault();
setPanelOpen((x) => !x);
}}
>
<div className="relative">
<SimpleTooltip text={char.name} delayDuration={500}>
<Image
src={`/cdn/${char.image}`}
style={{
background: `rgba(${
char.stars === 5
? "200,124,36"
: char.stars === 4
? "148,112,187"
: "100,100,100"
}) linear-gradient(136deg,rgba(49,43,71,.5294117647058824),transparent)`,
}}
className="rounded hover:brightness-110"
alt={char.name}
height={tileSize}
width={tileSize}
/>
</SimpleTooltip>
{[
"bottom-0.5 right-0.5",
"bottom-0.5 left-0.5",
"top-0.5 right-0.5",
"top-0.5 left-0.5",
].map((p, i) => {
const items = [
...assignedBadges,
...(comment ? ["__comment__"] : []),
];
if (!items[i]) return "";
if (items[i] === "__comment__") {
return (
<div
key={p}
style={{ width: badgeSize, height: badgeSize }}
className={`bg-[#2228] ${p} absolute rounded border flex items-center justify-center`}
>
<MessageSquareText
style={{
width: badgeSize * 0.7,
height: badgeSize * 0.7,
}}
/>
</div>
);
}
const badge = badges.find((e) => e.id === items[i]);
if (!badge) return "";
return badge.image ? (
<Image
key={p}
src={`/cdn/${badge.image}`}
className={`bg-[#2228] ${p} absolute rounded border`}
alt="badge"
width={badgeSize}
height={badgeSize}
/>
) : (
<div
key={p}
style={{ width: badgeSize, height: badgeSize }}
className={`bg-[#2228] ${p} absolute rounded border font-bold`}
>
{badge.name}
</div>
);
})}
</div>
</button>
</PopoverTrigger>
<PopoverContent
side="right"
className="bg-[#2225] backdrop-blur-md p-1"
>
<div className="flex gap-2">
{editable && (
<div className="flex flex-col justify-between shrink-0 gap-2">
<div className="grid grid-cols-2 gap-1 h-min items-stretch">
{badges
.filter(
(b) =>
assignedBadges.includes(b.id) ||
!b.tier.length ||
!tier ||
b.tier.includes(tier),
)
.map((e) =>
e.image ? (
<Image
src={`/cdn/${e.image}`}
alt={e.name}
key={`B${e.id}`}
width={32}
height={32}
className={cn(
"border rounded hover:brightness-110 hover:backdrop-brightness-300 cursor-pointer",
assignedBadges.includes(e.id) &&
"backdrop-brightness-500",
)}
onClick={() => toggleBadge(e.id)}
/>
) : (
<button
key={`B${e.id}`}
className={cn(
"border rounded hover:brightness-110 hover:backdrop-brightness-300 flex justify-center items-center font-bold cursor-pointer min-w-8 min-h-8",
assignedBadges.includes(e.id) &&
"backdrop-brightness-500",
)}
onClick={() => toggleBadge(e.id)}
type="button"
>
{e.name}
</button>
),
)}
</div>
<Link
href={`https://gi.yatta.moe/en/archive/avatar/${char.amber}/${char.name.replace(/ /g, "-").toLowerCase()}`}
target="_blank"
rel="noreferrer noopener"
>
<Button variant="outline" className="p-1 w-full">
<Image
src={AmberIcon}
alt="Amber"
width={16}
height={16}
/>
<div className="flex flex-col items-start">
<span className="text-[8px]">เปิดใน</span>
<span className="text-[10px]">Amber</span>
</div>
</Button>
</Link>
</div>
)}
<Textarea
placeholder="Comment..."
className="aspect-square bg-card resize-none disabled:opacity-90"
value={comment}
onChange={(ev) => {
setDirty(500);
setComment(ev.target.value);
}}
disabled={!editable}
/>
</div>
</PopoverContent>
</Popover>
)
}
</SortableDraggable>
);
}
+4
View File
@@ -16,6 +16,8 @@ export const TierListContext = createContext<{
data: Partial<typeof tierlistStates.$inferInsert>,
) => void;
editable: boolean;
deleteMode: boolean;
removeChar: (cid: string) => void;
}>({
chars: [],
badges: [],
@@ -23,4 +25,6 @@ export const TierListContext = createContext<{
badgeSize: 24,
setState: () => {},
editable: false,
deleteMode: false,
removeChar: () => {},
});
+305 -164
View File
@@ -1,3 +1,4 @@
/** biome-ignore-all lint/a11y/noStaticElementInteractions: i dont know */
"use client";
import {
@@ -7,7 +8,20 @@ import {
useDroppable,
} from "@dnd-kit/core";
import { rectSortingStrategy, SortableContext } from "@dnd-kit/sortable";
import { Calculator, ChevronDown, ChevronUp, X } from "lucide-react";
import {
Calculator,
ChevronDown,
ChevronUp,
CopyPlus,
FileQuestionMark,
Home,
TriangleAlert,
Pencil,
Settings,
Settings2,
Trash2,
X,
} from "lucide-react";
import Link from "next/link";
import {
Fragment,
@@ -26,7 +40,9 @@ import Qiqi from "#/assets/qiqi.webp";
import BadgeSP from "#/assets/sp.webp";
import BadgeSSS from "#/assets/sss.webp";
import { Blocker } from "@/components/blocker";
import { ComboBox } from "@/components/combobox";
import Image from "@/components/image";
import { SimpleTooltip } from "@/components/tooltip";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -58,10 +74,11 @@ import type {
tierlistTypes,
tierlistVersions,
} from "@/lib/db/schema";
import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
import { TierListCell } from "./cell";
import { Draggable } from "./character";
import { TierListContext } from "./context";
import { TlStatusOverlay } from "./overlay";
function Untiered({
children,
@@ -115,15 +132,19 @@ export function TierList({
const [badgeSize, setBadgeSize] = useLocalStorage<number>("tl_badgeSize", 24);
const [tileSizeAuto, setTileSizeAuto] = useState(0);
const tileSize = tileSizeSetting || tileSizeAuto;
const [deleteMode, setDeleteMode] = shared.state("tl.deleteMode");
const [states, setStates] = useState<(typeof tierlistStates.$inferSelect)[]>(
[],
);
const [evStatus, setEvStatus] = useState<{
const [_evStatus, setEvStatus] = useState<{
upload: boolean;
download: boolean;
ev: "unknown" | "connecting" | "ready";
}>({ upload: false, download: false, ev: "unknown" });
const [updated] = shared.state("updated");
const [_updated] = shared.state("updated");
const session = authClient.useSession();
const user = session.data?.user;
//#region error handling
useEffect(() => {
@@ -131,7 +152,12 @@ export function TierList({
toast.error("Unexpected Error Occured", {
description: `${ev?.error?.message || ev?.error || "*unknown error*"}`,
});
const promiseHandler = (ev: PromiseRejectionEvent) =>
toast.error("Unexpected Error Occured", {
description: `${ev.reason || ev || "*unknown error*"}`,
});
window.addEventListener("error", handler);
window.addEventListener("unhandledrejection", promiseHandler);
return () => window.removeEventListener("error", handler);
});
//#endregion
@@ -257,8 +283,8 @@ export function TierList({
let newPlacements = { ...placements };
// Check if dropped on another character (sorting within same cell)
if (chars.some((ch) => ch.id === targetId)) {
if (chars.some((ch) => ch.id === targetId.split("#")[0])) {
// Check if dropped on another character (sorting within same cell)
// Find target cell
let targetCell: string | undefined;
for (const cell in placements) {
@@ -323,20 +349,32 @@ export function TierList({
tileSize,
badgeSize,
editable,
async setState(char, data) {
deleteMode: !!deleteMode,
async setState(ref, data) {
const newEntry = {
...states.find((e) => e.char === char),
...states.find((e) => e.ref === ref),
...(Object.fromEntries(
Object.entries(data).filter(([_, v]) => v !== undefined),
) as typeof tierlistStates.$inferSelect),
list: version.id,
char,
char: ref.split("#")[0],
ref,
};
setStates((s) => [...s.filter((e) => e.char !== char), newEntry]);
setStates((s) => [...s.filter((e) => e.ref !== ref), newEntry]);
tlState(newEntry).catch((e) =>
toast.error(`Sync ล้มเหลว: ${e.message || e}`),
);
},
async removeChar(cid: string) {
setPlacements((s) =>
Object.fromEntries(
Object.entries(s).map(([k, v]) => [
k,
v.filter((id) => id !== cid),
]),
),
);
},
}}
>
<DndContext
@@ -350,7 +388,7 @@ export function TierList({
โปรดปรับจอเป็นแนวนอน
</div>
</Blocker>*/}
{version.disclaimer && disclaimer && !updated ? (
{version.disclaimer && disclaimer ? (
<Blocker inner className=" not-portrait:block md:block">
<Image
src={`/cdn/${version.disclaimer}`}
@@ -370,152 +408,198 @@ export function TierList({
</Button>
</Blocker>
) : (
<TlStatusOverlay ev={evStatus} deprecates={version.deprecates} />
""
)}
<div className="flex-1 min-h-0 overflow-auto">
<div
className={`grid w-full *:border`}
style={{
gridTemplateColumns: `min-content repeat(${columns.length}, 1fr)`,
gridTemplateRows: `min-content repeat(${tiers.length}, minmax(0, min-content))`,
gridTemplateRows: `min-content min-content repeat(${tiers.length}, minmax(0, min-content))`,
}}
>
<Dialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex flex-col font-bold items-center justify-center aspect-square w-min h-min whitespace-nowrap bg-[#2225] cursor-pointer">
<span>{type.name}</span>
<span>{version.name}</span>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<Link href="/tl">
<DropdownMenuItem>หน้าหลัก</DropdownMenuItem>
</Link>
<DialogTrigger asChild>
<DropdownMenuItem>การตั้งค่า</DropdownMenuItem>
</DialogTrigger>
<DropdownMenuItem onClick={() => showDisclaimer(true)}>
แสดงเงื่อนไข
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent>
<DialogHeader>
<DialogTitle>การตั้งค่า</DialogTitle>
</DialogHeader>
<div className="flex justify-between">
<div className="flex flex-col gap-2">
<div className="grid gap-2 group">
<span className="flex gap-1">
ขนาดตัวละคร{" "}
<span className="flex gap-1 text-muted-foreground md:opacity-0 group-hover:opacity-100 transition-opacity">
(px){" "}
{tileSizeSetting ? (
""
) : (
<Tooltip>
<TooltipTrigger asChild>
<Calculator className="text-emerald-400" />
</TooltipTrigger>
<TooltipContent>คำนวณอัตโนมัติ</TooltipContent>
</Tooltip>
)}
<div
className={cn(
"text-center font-semibold py-1 relative transition-colors duration-200",
deleteMode ? "bg-red-500/30" : "bg-[#0005]",
)}
style={{ gridColumn: "1 / -1" }}
>
<span>
{type.name} เวอร์ชั่น {version.name} ระดับ
<span className="text-yellow-400">{type.mode}</span> ใช้ได้ถึง{" "}
</span>
<span className="text-green-400">{version.deprecates}</span>
{deleteMode && (
<span className="text-red-500 font-semibold ml-2">
<TriangleAlert className="inline" /> (คุณอยู่ในโหมดลบตัวละคร)
</span>
)}
<span className="font-normal absolute right-0">
{user && !editable && (
<SimpleTooltip text="ไปหน้าแก้ไข (Admin)">
<Link href={`/tl/${type.id}/${version.id}/admin`}>
<Pencil className="text-gray-400 size-4" />
</Link>
</SimpleTooltip>
)}
</span>
</div>
<div className="grid place-items-center bg-[#2225] cursor-pointer">
<Dialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Settings className="size-8" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<Link href="/tl">
<DropdownMenuItem>
{" "}
<Home className="size-4" />
หน้าหลัก
</DropdownMenuItem>
</Link>
{user && !editable && (
<DropdownMenuItem asChild>
<Link href={`/tl/${type.id}/${version.id}/admin`}>
<Pencil className="size-4" />
ไปหน้าแก้ไข
</Link>
</DropdownMenuItem>
)}
<DialogTrigger asChild>
<DropdownMenuItem>
{" "}
<Settings2 className="size-4" />
การตั้งค่า
</DropdownMenuItem>
</DialogTrigger>
<DropdownMenuItem onClick={() => showDisclaimer(true)}>
<FileQuestionMark className="size-4" />
แสดงเงื่อนไข
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent>
<DialogHeader>
<DialogTitle>การตั้งค่า</DialogTitle>
</DialogHeader>
<div className="flex justify-between">
<div className="flex flex-col gap-2">
<div className="grid gap-2 group">
<span className="flex gap-1">
ขนาดตัวละคร{" "}
<span className="flex gap-1 text-muted-foreground md:opacity-0 group-hover:opacity-100 transition-opacity">
(px){" "}
{tileSizeSetting ? (
""
) : (
<Tooltip>
<TooltipTrigger asChild>
<Calculator className="text-emerald-400" />
</TooltipTrigger>
<TooltipContent>คำนวณอัตโนมัติ</TooltipContent>
</Tooltip>
)}
</span>
</span>
</span>
<div className="flex gap-1">
<Input
value={tileSizeSetting || Math.round(tileSizeAuto)}
onChange={(ev) =>
setTileSize(Number(ev.target.value))
}
type="number"
/>
<Button
onClick={() =>
setTileSize((x) => (x || tileSize) + 1)
}
variant="outline"
size="icon"
>
<ChevronUp />
</Button>
<Button
onClick={() =>
setTileSize((x) => (x || tileSize) - 1)
}
variant="outline"
size="icon"
>
<ChevronDown />
</Button>
<div className="flex gap-1">
<Input
value={
tileSizeSetting || Math.round(tileSizeAuto)
}
onChange={(ev) =>
setTileSize(Number(ev.target.value))
}
type="number"
/>
<Button
onClick={() =>
setTileSize((x) => (x || tileSize) + 1)
}
variant="outline"
size="icon"
>
<ChevronUp />
</Button>
<Button
onClick={() =>
setTileSize((x) => (x || tileSize) - 1)
}
variant="outline"
size="icon"
>
<ChevronDown />
</Button>
</div>
</div>
<div className="grid gap-2 group">
<span>
ขนาดเครื่องหมาย{" "}
<span className="text-muted-foreground md:opacity-0 group-hover:opacity-100 transition-opacity">
(px)
</span>
</span>
<div className="flex gap-1">
<Input
value={badgeSize}
onChange={(ev) =>
setBadgeSize(Number(ev.target.value))
}
type="number"
/>
<Button
onClick={() =>
setBadgeSize((x) => (x || tileSize) + 1)
}
variant="outline"
size="icon"
>
<ChevronUp />
</Button>
<Button
onClick={() =>
setBadgeSize((x) => (x || tileSize) - 1)
}
variant="outline"
size="icon"
>
<ChevronDown />
</Button>
</div>
</div>
</div>
<div className="grid gap-2 group">
<span>
ขนาดเครื่องหมาย{" "}
<span className="text-muted-foreground md:opacity-0 group-hover:opacity-100 transition-opacity">
(px)
</span>
</span>
<div className="flex gap-1">
<Input
value={badgeSize}
onChange={(ev) =>
setBadgeSize(Number(ev.target.value))
}
type="number"
<div>
<div
style={{
background:
"rgba(200,124,36) linear-gradient(136deg,rgba(49,43,71,.5294117647058824),transparent)",
width: tileSize,
}}
className="rounded aspect-square relative"
>
<Image src={Qiqi} alt="Qiqi" fill />
<Image
className="bg-[#2225] backdrop-blur-sm bottom-0.5 left-0.5 absolute rounded border"
src={BadgeSSS}
alt="Test Badge"
width={badgeSize}
height={badgeSize}
/>
<Image
className="bg-[#2225] backdrop-blur-sm bottom-0.5 right-0.5 absolute border rounded"
src={BadgeSP}
alt="Test Badge"
width={badgeSize}
height={badgeSize}
/>
<Button
onClick={() =>
setBadgeSize((x) => (x || tileSize) + 1)
}
variant="outline"
size="icon"
>
<ChevronUp />
</Button>
<Button
onClick={() =>
setBadgeSize((x) => (x || tileSize) - 1)
}
variant="outline"
size="icon"
>
<ChevronDown />
</Button>
</div>
</div>
</div>
<div>
<div
style={{
background:
"rgba(200,124,36) linear-gradient(136deg,rgba(49,43,71,.5294117647058824),transparent)",
width: tileSize,
}}
className="rounded aspect-square relative"
>
<Image src={Qiqi} alt="Qiqi" fill />
<Image
className="bg-[#2225] backdrop-blur-sm bottom-0.5 left-0.5 absolute rounded border"
src={BadgeSSS}
alt="Test Badge"
width={badgeSize}
height={badgeSize}
/>
<Image
className="bg-[#2225] backdrop-blur-sm bottom-0.5 right-0.5 absolute border rounded"
src={BadgeSP}
alt="Test Badge"
width={badgeSize}
height={badgeSize}
/>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
</div>
{columns.map((c) => (
<div
key={`C${c.id}`}
@@ -526,8 +610,9 @@ export function TierList({
<Image
src={`/cdn/${c.image}`}
alt={c.name}
fill
className="p-2 object-cover"
width={200}
height={100}
className="p-2 object-contain w-full h-auto max-h-13"
/>
) : (
c.name
@@ -543,6 +628,8 @@ export function TierList({
alt={t.name}
height={100}
width={100}
className="object-contain max-w-12"
sizes="4"
/>
) : (
t.name
@@ -551,8 +638,14 @@ export function TierList({
{columns.map((c) => {
const cellId = `${t.id}-${c.id}`;
const cellChars = placements[cellId]
.map((id) => chars.find((ch) => ch.id === id))
.filter((x): x is typeof characters.$inferSelect => !!x);
.map((id) => [
id,
chars.find((ch) => ch.id === id.split("#")[0]),
])
.filter(
(x): x is [string, typeof characters.$inferSelect] =>
!!x[1],
);
return (
<TierListCell
key={cellId}
@@ -560,12 +653,13 @@ export function TierList({
tier={t}
items={placements[cellId]}
>
{cellChars.map((ch) => (
{cellChars.map(([id, ch]) => (
<Draggable
key={ch.id}
key={id}
cid={id}
char={ch}
tier={t.id}
state={states.find((s) => s.char === ch.id)}
state={states.find((s) => s.ref === id)}
/>
))}
</TierListCell>
@@ -579,13 +673,62 @@ export function TierList({
<Button
onClick={() => setUntieredOpen(!untieredOpen)}
variant="outline"
className="rounded-none"
className="rounded-none flex"
>
({placements.untiered.length}) ตัวละครที่ยังไม่ได้จัดเทียร์
{untieredOpen ? (
<ChevronDown className="ml-1" />
) : (
<ChevronUp className="ml-1" />
{editable && untieredOpen && <div className="w-9 h-4" />}
<span className="w-full flex gap-1 items-center justify-center">
({placements.untiered.length}) ตัวละครที่ไม่ได้อยู่ในเทียร์
{untieredOpen ? (
<ChevronDown className="ml-1" />
) : (
<ChevronUp className="ml-1" />
)}
</span>
{editable && untieredOpen && (
<div
className="flex gap-2 items-center justify-center"
onClick={(ev) => ev.stopPropagation()}
>
<SimpleTooltip text="เพิ่มตัวละคร">
<span>
<ComboBox
placeholder="ค้นหาตัวละคร"
id="character"
name="character"
data={chars
.map((c) => ({ label: c.name, value: c.id }))
.sort((a, b) => a.label.localeCompare(b.label))}
className="w-full bg-transparent! hover:bg-accent!"
trigger={
<CopyPlus className="text-emerald-400 pointer-events-auto!" />
}
onValueSelect={(v) => {
setPlacements((x) => ({
...x,
untiered: [`${v}#${Date.now()}`, ...x.untiered],
}));
toast.success(`เพิ่ม ${v} เข้าเทียร์ลิสต์แล้ว`);
}}
/>
</span>
</SimpleTooltip>
<SimpleTooltip
text={
deleteMode ? "คลิกอีกครั้งเพื่อออกจากโหมดลบ" : "ลบตัวละครออก"
}
>
<Trash2
className={cn(
"pointer-events-auto! text-red-500",
deleteMode && "animate-pulse",
)}
onClick={(ev) => {
ev.stopPropagation();
setDeleteMode((x) => !x);
}}
/>
</SimpleTooltip>
</div>
)}
</Button>
<SortableContext
@@ -596,15 +739,10 @@ export function TierList({
{untieredOpen &&
placements.untiered
.map((id) => {
const ch = chars.find((c) => c.id === id);
const cid = id.split("#")[0];
const ch = chars.find((c) => c.id === cid);
if (!ch) return null;
return (
<Draggable
key={ch.id}
char={ch}
state={states.find((s) => s.char === ch.id)}
/>
);
return <Draggable key={id} cid={id} char={ch} />;
})
.filter(Boolean)}
</Untiered>
@@ -613,7 +751,10 @@ export function TierList({
</div>
<DragOverlay>
{dragging && (
<Draggable char={chars.find((c) => c.id === dragging)!} />
<Draggable
char={chars.find((c) => c.id === dragging.split("#")[0])!}
cid={dragging}
/>
)}
</DragOverlay>
</DndContext>
+80
View File
@@ -0,0 +1,80 @@
import { desc, eq, not } from "drizzle-orm";
import type { Metadata } from "next";
import Link from "next/link";
import Image from "@/components/image";
import { db } from "@/lib/db";
import { tierlistTypes, tierlistVersions } from "@/lib/db/schema";
import { ArrowLeft } from "lucide-react";
export const metadata: Metadata = {
title: "จัดเทียร์ลิสต์",
description: "ระบบจัดเทียร์ลิสต์ตัวละครของคอนเทนต์เอนเกม",
};
export default async function TierlistSelectionPage({
params,
}: {
params: Promise<{ type: string }>;
}) {
const { type: typeId } = await params;
const types = await db
.select()
.from(tierlistTypes)
.where(eq(tierlistTypes.id, typeId));
const versionsList = await db
.select()
.from(tierlistVersions)
.orderBy(desc(tierlistVersions.order))
.where(not(tierlistVersions.hidden));
const vers = types.map((t) => ({
...t,
versions: versionsList.filter((v) => v.type === t.id),
}));
return (
<div className="max-w-7xl min-h-full flex flex-col gap-6 mx-auto py-8 px-4">
{vers.map((t) => (
<div className="flex flex-col gap-1" key={t.id}>
<Link
href="/tl"
className="flex hover:underline cursor-default text-muted-foreground"
>
<ArrowLeft /> กลับไปหน้าแรก
</Link>
<div className="font-bold text-4xl">
<div className="px-2 py-1 border w-fit rounded-md bg-[#2228]">
{t.name}
<span className="text-sm text-muted-foreground ml-2">
{t.mode}
</span>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
{t.versions.map((e) => (
<Link href={`/tl/${t.id}/${e.id}`} key={e.id}>
{e.image ? (
<div className="relative aspect-video w-full rounded-lg border overflow-hidden">
<Image
src={`/cdn/${e.image}`}
alt={e.name}
fill
className="rounded-sm border object-cover bg-primary"
/>
<div className="flex absolute w-full justify-center bottom-0 py-1 bg-black/50 rounded-b-sm">
{e.name}
</div>
</div>
) : (
<div className="flex items-center justify-center aspect-video w-60 border rounded-sm bg-[#1118] backdrop-blur-xl font-bold text-4xl">
{e.name}
</div>
)}
</Link>
))}
</div>
</div>
))}
</div>
);
}
export const dynamic = "force-dynamic";
+1 -1
View File
@@ -10,7 +10,7 @@ export const contentType = "image/png";
// Image generation
export default async function Image() {
return generate({ title: "จัดเทียร์ลิสต์", sub: "" });
return generate({ title: "เทียร์ลิสต์", sub: "" });
}
export const dynamic = "force-dynamic";
+32 -20
View File
@@ -1,15 +1,17 @@
import { desc, not } from "drizzle-orm";
import type { Metadata } from "next";
import Link from "next/link";
import TierlistLogo from "#/logos/tierlist.webp";
import { HorizontalDiv } from "@/components/horizontal";
import { SimpleTooltip } from "@/components/tooltip";
import Image from "@/components/image";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { ScrollBar } from "@/components/ui/scroll-area";
import { db } from "@/lib/db";
import { tierlistTypes, tierlistVersions } from "@/lib/db/schema";
import TierlistLogo from "#/logos/tierlist.webp";
export const metadata: Metadata = {
title: "จัดเทียร์ลิสต์",
description: "ระบบจัดเทียร์ลิสต์ตัวละครของคอนเท้นเอนเกม",
description: "ระบบจัดเทียร์ลิสต์ตัวละครของคอนเทนต์เอนเกม",
};
export default async function TierlistSelectionPage() {
@@ -25,26 +27,36 @@ export default async function TierlistSelectionPage() {
}));
return (
<div className="max-w-full min-h-full flex flex-col justify-center gap-2 mx-2">
<center className="text-6xl font-bold mb-2">
<Link href="/">
<Image
src={TierlistLogo}
alt="Tierlist"
className="rounded-t-x w-3/4 sm:w-96"
width={500}
height={100}
fetchPriority="high"
/>
</Link>
<center className="mb-2">
<Link href="/">
<Image
src={TierlistLogo}
alt="Tierlist"
className="w-1/2 sm:w-96"
width={200}
height={100}
fetchPriority="high"
/>
</Link>
</center>
{vers.map((t) => (
<div className="flex flex-col gap-1" key={t.id}>
<div className="font-bold text-4xl">
<div className="px-2 py-1 border w-min rounded-md bg-[#2228]">
{t.name}
<Link href={`/tl/${t.id}`} className="font-bold text-4xl w-fit">
<div className="flex items-center gap-2">
<div className="px-2 py-1 border rounded-md bg-[#2228]">
{t.name}
<span className="text-sm text-muted-foreground ml-2">
{t.mode}
</span>
</div>
<SimpleTooltip text="ดูเทียร์ลิสต์ของคอนเทนต์นี้ทั้งหมด">
<span className="text-sm font-normal text-muted-foreground ml-2 hover:underline ">
ดูทั้งหมด
</span>
</SimpleTooltip>
</div>
</div>
<ScrollArea>
</Link>
<HorizontalDiv>
<div className="flex gap-2 max-w-full">
{t.versions.map((e) => (
<Link href={`/tl/${t.id}/${e.id}`} key={e.id}>
@@ -69,7 +81,7 @@ export default async function TierlistSelectionPage() {
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</HorizontalDiv>
</div>
))}
</div>
+2 -2
View File
@@ -61,7 +61,7 @@ export function VersionCheck({ headless = false }: { headless?: boolean }) {
if (headless) window.location.reload();
else
toast("มีอัปเดตใหม่พร้อมใช้งาน", {
description: "กดรีโหลดเพื่ออัปเดตเวอร์ชันล่าสุด",
description: "รีโหลดเพื่ออัปเดตเป็นเวอร์ชันล่าสุด",
action: {
label: "รีโหลด",
// Do a full reload
@@ -80,7 +80,7 @@ export function VersionCheck({ headless = false }: { headless?: boolean }) {
}),
{
loading: "กำลังพยายามเชื่อมต่อใหม่...",
success: () => "เชื่อมต่อแล้ว",
success: () => "เชื่อมต่อสำเร็จ",
duration: 500,
},
);
+9 -20
View File
@@ -12,7 +12,7 @@ import {
} from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import Qiqi from "#/assets/qiqi.webp";
import Dottore from "#/assets/dottore.webp";
import Background from "#/bg.jpg";
import Image from "@/components/image";
import { SimpleTooltip } from "@/components/tooltip";
@@ -43,22 +43,11 @@ export default function ErrorPage({
console.error(error);
const messages = [
"หลุดเรทอีกแล้ว...",
"stamina หมด จมน้ำตาย...",
"โดน Hilichurl(s) ดับ...",
"Paimon กินหน้านี้ไปแล้ว...",
"ไม่เหลือเพชรให้สุ่มแล้ว...",
"ไม่ติดคริอีกแล้ว...",
"ลงแต่ Def อีกแล้ว...",
"เกลือแฟลกอีกแล้ว...",
"น้ำแช่แข็งหน้านี้ไปแล้ว...",
"กดผิด เผลอลบหน้านี้ทิ้ง...",
"Zhongli ไม่อนุญาตให้ผ่าน...",
"ได้ไม่ครบ 36 ดาวอีกแล้ว...",
"Venti ไม่อนุญาตให้ผ่าน...",
"Aether เดินสะดุดสายเซิพ...",
"กด E แทน Q อีกแล้ว...",
"บุสทำลายหน้านี้ไปแล้ว...",
"หมอวิจัยหน้านี้จนพังแล้ว...",
"หมอไม่ชอบหน้านี้...",
"หมอเตรียมพื้นที่ตรงนี้สำหรับการทดลอง...",
"หมอกำลังศึกษาหน้านี้อยู่...",
"หมอจะทดลองกับหน้านี้...",
];
setMessage(messages[Math.floor(Math.random() * messages.length)]);
@@ -78,9 +67,9 @@ export default function ErrorPage({
<div className="h-full flex flex-col gap-2 justify-center items-center">
<div className="p-4 pb-12 bg-white shadow-lg rounded-sm rotate-1 max-sm:landscape:hidden">
<Image
src={Qiqi}
alt="Qiqi"
className="bg-gray-300 w-full vintage-filter"
src={Dottore}
alt="Dottore"
className="bg-gray-300 w-64 h-64 vintage-filter"
/>
</div>
<h2 className="font-bold text-2xl">เกิดข้อผิดพลาดขึ้น</h2>
+8 -19
View File
@@ -3,7 +3,7 @@
import { Activity, Home } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import Lost from "#/assets/lost_traveler.webp";
import Dottore from "#/assets/dottore.webp";
import Background from "#/bg.jpg";
import Image from "@/components/image";
import { Button } from "@/components/ui/button";
@@ -16,22 +16,11 @@ export default function ErrorPage() {
useEffect(() => {
const messages = [
"หลุดเรทอีกแล้ว...",
"stamina หมด จมน้ำตาย...",
"โดน Hilichurl(s) ดับ...",
"Paimon กินหน้านี้ไปแล้ว...",
"ไม่เหลือเพชรให้สุ่มแล้ว...",
"ไม่ติดคริอีกแล้ว...",
"ลงแต่ Def อีกแล้ว...",
"เกลือแฟลกอีกแล้ว...",
"น้ำแช่แข็งหน้านี้ไปแล้ว...",
"กดผิด เผลอลบหน้านี้ทิ้ง...",
"Zhongli ไม่อนุญาตให้ผ่าน...",
"ได้ไม่ครบ 36 ดาวอีกแล้ว...",
"Venti ไม่อนุญาตให้ผ่าน...",
"Aether เดินสะดุดสายเซิพ...",
"กด E แทน Q อีกแล้ว...",
"บุสทำลายหน้านี้ไปแล้ว...",
"ตรงนี้ไม่มีที่หลบหมอนะ...",
"ระวังหมอมาเจอตรงนี้นะ...",
"ไม่มีที่ตรงนี้นะ ลองถามหมอด้านบนดูสิ...",
"หมอกำลังมองหาหน้านี้อยู่...",
"หน้านี้อาจโดนหมอทำลายไปแล้วนะ...",
];
setMessage(messages[Math.floor(Math.random() * messages.length)]);
@@ -47,8 +36,8 @@ export default function ErrorPage() {
<div className="h-full w-full bg-[#2225] backdrop-grayscale backdrop-blur-md flex flex-col gap-2 justify-center items-center">
<div className="p-4 pb-12 bg-white shadow-lg rounded-sm rotate-1 max-sm:landscape:hidden">
<Image
src={Lost}
alt="Lost Traveler"
src={Dottore}
alt="Dottore"
className="bg-gray-300 w-64 h-64 vintage-filter"
width={256}
height={256}
Regular → Executable
View File
+29 -4
View File
@@ -1,7 +1,7 @@
import { env } from "node:process";
import { redis } from "bun";
import { formatDistanceToNow } from "date-fns";
import { and, asc, eq, isNull, lt, not, sql } from "drizzle-orm";
import { and, asc, eq, gt, isNull, lt, not, sql } from "drizzle-orm";
import { removeExpiredSubmissions } from "@/app/(ui)/rubgram/api";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
@@ -203,7 +203,7 @@ async function seedDatabase() {
}
async function cacheCards() {
const { log, schedule } = logger("cacheCards");
const { log, error, schedule } = logger("cacheCards");
const [notCached] = await db
.select({
id: submissions.id,
@@ -228,19 +228,44 @@ async function cacheCards() {
return;
}
log(`Generating card for ${notCached.id}`);
const res = await fetch(`http://app:3000/api/card/${notCached.id}`);
const res = await fetch(`http://app:3000/api/card/${notCached.id}`).catch(
() => ({
ok: false,
status: 502,
text: () => "เกิดข้อผิดพลาดภายในระบบ กำลังพยายามลองใหม่",
}),
);
if (!res.ok) {
log(`Wasn't okay, queueing for retry.`);
let text = await res.text();
error(text.includes("502") ? "502" : text);
if (text.includes("findIndex") || text === "Showcase is not public")
text = "ผู้เล่นนี้ไม่มีโชว์เคส มองไม่เห็นตัวละครใดๆ";
else if (text === "Invalid character provided")
text = "ตัวละครที่ผู้เล่นเลือก ไม่ได้อยู่ในโชว์เคส";
else if (text === "Failed to find card." || text === "Invalid UID Provided")
text = "ผู้เล่นนี้ไม่มีอยู่จริง โดนแบนไปแล้วรีเปล่า";
else if (text.length > 2000 || res.status === 502)
text = "ไม่สามารถสร้างการ์ดได้ กำลังพยายามลองใหม่";
text = text.split("\n")[0];
await db
.insert(cards)
.values({
submission: notCached.id,
tries: 1,
error: text,
})
.onConflictDoUpdate({
target: cards.submission,
set: {
tries: res.status === 400 ? 20 : sql<number>`${cards.tries} + 1`,
tries:
res.status === 400 || text === "ผู้เล่นนี้ไม่มีโชว์เคส มองไม่เห็นตัวละครใดๆ"
? 21
: sql<number>`${cards.tries} + 1`,
error: sql<string>`case when ${gt(cards.tries, 15)}
then ${text.replace(" กำลังพยายามลองใหม่", "")}
else ${text}
end`,
},
});
schedule(60, cacheCards);
Regular → Executable
+4 -4
View File
@@ -19,8 +19,8 @@ export function rubgramWebhookTemplate(
type: 10,
content:
type === "submit"
? "## :wave: New Submission"
: "## :money_with_wings: Submission Paid",
? "## :incoming_envelope: ลูกค้าลงคิว"
: "## :money_with_wings: จ่ายเงินแล้ว",
},
],
accessory: {
@@ -37,13 +37,13 @@ export function rubgramWebhookTemplate(
},
{
type: 10,
content: `**Name**: ${sub.name}\n**Service**:\n${sub.service
content: `**ชื่อ**: ${sub.name}\n**บริการ**:\n${sub.service
.map((s) => types.find((t) => t.id === s)?.display)
.filter(Boolean)
.map((s) => `- ${s}`)
.join(
"\n",
)}\n**Server**: ${{ us: "America", eu: "Europe", as: "Asia", tw: "Taiwan" }[sub.server]}\n-# ${type === "submit" ? "**Not** p" : "P"}aid: **${sub.price} ฿** | <@${sub.user}>`,
)}\n**เซิพ**: ${{ us: "America", eu: "Europe", as: "Asia", tw: "Taiwan" }[sub.server]}\n-# ${type === "submit" ? "**Not** p" : "P"}aid: **${sub.price} ฿** | <@${sub.user}>`,
},
],
accent_color: type === "paid" ? 0x0ef06d : 15714574,
+1 -2
View File
@@ -9,6 +9,5 @@ export async function listFiles() {
return await db
.select({ id: cdn.id, name: cdn.name, size: cdn.size, type: cdn.type })
.from(cdn)
.orderBy(cdn.name);
.from(cdn);
}
+56 -15
View File
@@ -1,9 +1,10 @@
/** biome-ignore-all lint/a11y/noStaticElementInteractions: yes maam */
"use client";
import { FileSearch2, X } from "lucide-react";
import { FileSearch2, Search, X } from "lucide-react";
import {
type ComponentProps,
useCallback,
useContext,
useEffect,
useRef,
@@ -15,6 +16,11 @@ import { cn } from "@/lib/utils";
import { SimpleTooltip } from "../tooltip";
import { Button } from "../ui/button";
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "../ui/input-group";
import { Kbd } from "../ui/kbd";
import { Spinner } from "../ui/spinner";
import { listFiles } from "./api";
@@ -29,20 +35,40 @@ export function CdnChooserProvider({
const [files, setFiles] = useState<
Omit<typeof cdn.$inferSelect, "data">[] | null
>(null);
const [options, setOptions] = useState<{ type?: string }>();
const callback = useRef<((value: CdnFile | null) => void) | null>(null);
const [search, setSearch] = useState("");
async function call(options?: { type?: string }): Promise<CdnFile | null> {
setOpen(true);
const list = await listFiles().catch(() => []);
setFiles(
options?.type
? list.filter((file) => file.type.startsWith(options.type!))
: list,
);
return new Promise<CdnFile | null>((resolve) => {
callback.current = resolve;
}).finally(() => setOpen(false));
}
const filtered = search
? files?.filter(
(f) => f.name?.toLowerCase().includes(search) || f.id === search,
)
: files;
const updateList = useCallback(
async (opt = options) => {
const list = await listFiles().catch(() => []);
setFiles(
opt?.type
? list.toReversed().filter((file) => file.type.startsWith(opt.type!))
: list.toReversed(),
);
},
[options],
);
const call = useCallback(
async (options?: { type?: string }): Promise<CdnFile | null> => {
setSearch("");
setOpen(true);
await updateList(options);
setOptions(options);
return new Promise<CdnFile | null>((resolve) => {
callback.current = resolve;
}).finally(() => setOpen(false));
},
[updateList],
);
return (
<CdnChooserContext value={{ call }}>
@@ -55,12 +81,27 @@ export function CdnChooserProvider({
}}
>
<DialogContent className="max-h-[85vh] flex flex-col gap-4 md:max-w-[50svw]">
<DialogTitle className="px-6 pt-6">Choose File</DialogTitle>
<DialogTitle className="px-6 flex justify-between items-center">
Choose File{" "}
<InputGroup className="max-w-xs">
<InputGroupInput
placeholder="Search..."
onChange={(ev) => setSearch(ev.target.value.toLowerCase())}
/>
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
{filtered?.length || 0} results
</InputGroupAddon>
</InputGroup>
</DialogTitle>
{files ? (
<div className="flex-1 min-h-0 overflow-y-auto">
<CdnTable
files={files}
files={filtered || []}
onChoose={(value) => callback.current?.(value)}
onChange={updateList}
/>
</div>
) : (
+27 -15
View File
@@ -24,32 +24,43 @@ export function ComboBox({
id,
name,
className,
trigger,
onValueSelect,
defaultValue = "",
...props
}: React.ComponentProps<"button"> & {
data: { value: string; label: string }[];
placeholder?: string;
trigger?: React.ReactNode;
onValueSelect?: (val: string) => void;
}) {
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState("");
const [value, setValue] = React.useState(defaultValue);
React.useEffect(() => {
if (defaultValue) setValue(defaultValue);
}, [defaultValue]);
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("w-[200px] justify-between", className)}
{...props}
>
{value
? data.find((ent) => ent.value === value)?.label
: placeholder}
<ChevronsUpDown className="opacity-50" />
</Button>
{trigger || (
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("w-50 justify-between", className)}
{...props}
>
{value
? data.find((ent) => ent.value === value)?.label
: placeholder}
<ChevronsUpDown className="opacity-50" />
</Button>
)}
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<PopoverContent className="w-100 p-0">
<Command>
<CommandInput placeholder={placeholder} className="h-9" />
<CommandList>
@@ -60,7 +71,8 @@ export function ComboBox({
key={ent.value}
value={ent.value}
onSelect={(currentValue) => {
setValue(currentValue === value ? "" : currentValue);
if (onValueSelect) onValueSelect(currentValue);
else setValue(currentValue === value ? "" : currentValue);
setOpen(false);
}}
>
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
import { type ComponentProps, useRef } from "react";
export function HorizontalDiv(
props: ComponentProps<typeof ScrollAreaPrimitive.Viewport>,
) {
const ref = useRef<HTMLDivElement | null>(null);
return (
<ScrollAreaPrimitive.Root data-slot="scroll-area" className="relative">
<ScrollAreaPrimitive.Viewport
ref={ref}
{...props}
onWheel={(ev) => {
if (ref.current)
ref.current.scrollTo({
left: ref.current.scrollLeft + ev.deltaY * 4,
behavior: "smooth",
});
}}
/>
</ScrollAreaPrimitive.Root>
);
}
Regular → Executable
View File
+5
View File
@@ -76,6 +76,11 @@ export function NavUser() {
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<span className="text-xs">
Fun fact: nothing in this is working btw
</span>
</div>
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
{/* <AvatarImage src={user.avatar} alt={user.name} /> */}
+13 -6
View File
@@ -2,8 +2,10 @@ services:
# services
app:
depends_on:
- db
- redis
db:
condition: service_healthy
redis:
condition: service_healthy
build:
context: ..
dockerfile: dev.dockerfile
@@ -18,8 +20,10 @@ services:
restart: on-failure
backend:
depends_on:
- db
- redis
db:
condition: service_healthy
redis:
condition: service_healthy
build:
context: ..
dockerfile: backend.dockerfile
@@ -46,6 +50,7 @@ services:
interval: 1s
timeout: 5s
retries: 10
start_period: 10s
restart: on-failure
ports:
- 5432:5432
@@ -67,7 +72,8 @@ services:
# tools
redis-commander:
depends_on:
- redis
redis:
condition: service_healthy
hostname: redis-commander
image: ghcr.io/joeferner/redis-commander:latest
environment:
@@ -78,7 +84,8 @@ services:
restart: on-failure
drizzle:
depends_on:
- db
db:
condition: service_healthy
build:
context: ..
dockerfile: dev.dockerfile
+102 -36
View File
@@ -1,10 +1,11 @@
"use server";
import { and, eq, inArray, not, or, sql } from "drizzle-orm";
import { randomUUIDv7 } from "bun";
import { and, eq, inArray, isNotNull, lt, not, or, sql } from "drizzle-orm";
import type { PgDatabase } from "drizzle-orm/pg-core";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import type { TypedFormData } from "@/app/(ui)/rubgram/type";
import z from "zod";
import { adminCheck } from "./auth";
import { uidRegex } from "./const";
import { db } from "./db";
@@ -13,6 +14,7 @@ import { cdnReferences } from "./db/references";
import {
artifactSettings,
auditLog,
cards,
cdn,
characters,
settings,
@@ -36,52 +38,113 @@ export async function getArtifactConfig() {
return { locked: false, limit: -1, enka: false, ...art, ...glob };
}
export async function submitArtifact(formData: FormData) {
const form = formData as unknown as TypedFormData<{
name: string;
uid: string;
character: string;
comment: string;
}>;
const ArtifactSubmission = (editToken?: string) =>
z.object(
{
name: z.string().max(64, "ชื่อยาวเกินไป ต้องไม่เกิน 64 ตัวอักษร"),
uid: z
.string()
.regex(uidRegex, "UID ไม่ถูกต้อง ต้องเป็นเลข 9 หรือ 10 หลัก เท่านั้น")
.refine(
(uid) =>
db
.select()
.from(submissions)
.where(
and(
eq(submissions.uid, uid),
editToken
? not(eq(submissions.editToken, editToken))
: undefined,
),
)
.limit(1)
.then((r) => !r.length),
"คุณลงทะเบียนไปแล้ว",
),
character: z.string().refine(
(char) =>
db
.select()
.from(characters)
.where(eq(characters.name, char))
.limit(1)
.then((r) => !!r.length),
"ไม่พบตัวละครที่เลือก",
),
comment: z
.string()
.max(1024, "ข้อความเพิ่มเติมยาวเกินไป ต้องไม่เกิน 1024 ตัวอักษร"),
},
"กรุณากรอกข้อมูลให้ครบถ้วน",
);
export async function submitArtifact(
formData: FormData,
edit?: { sub: string; token: string },
) {
const config = await getArtifactConfig();
const name = form.get("name")?.toString();
const uid = form.get("uid")?.toString();
const character = form.get("character")?.toString();
const comment = form.get("comment")?.toString() || "";
if (!name || !uid || !character) return "กรุณากรอกข้อมูลให้ครบถ้วน";
if (name.length > 32) return "ชื่อยาวเกินไป ต้องไม่เกิน 32 ตัวอักษร";
if (!uidRegex.test(uid)) return "UID ไม่ถูกต้อง ต้องเป็นเลข 9 หลัก";
if (comment.length > 256) return "ข้อความเพิ่มเติมยาวเกินไป ต้องไม่เกิน 512 ตัวอักษร";
if (config.locked) return "ปิดรับลงทะเบียนชั่วคราว เนื่องจากมีผู้ลงจำนวนมาก";
const count = await db.$count(submissions);
if (config.limit !== -1 && count >= config.limit)
return `คิวลงทะเบียนเต็มแล้ว (${config.limit} ครั้ง)`;
const [char] = await db
.select()
.from(characters)
.where(eq(characters.name, character))
.limit(1);
if (!char) return "ไม่พบตัวละครที่เลือก";
const [existing] = await db
.select()
.from(submissions)
.where(eq(submissions.uid, uid))
.limit(1);
if (existing) return "คุณลงทะเบียนไปแล้ว";
const { success, data, error } = await ArtifactSubmission(
edit?.token,
).safeParseAsync(Object.fromEntries(formData.entries()));
if (!success) return z.prettifyError(error);
if (edit) {
const [existing] = await db
.delete(submissions)
.where(
and(
eq(submissions.id, edit.sub),
eq(submissions.editToken, edit.token),
lt(submissions.edits, 5),
not(submissions.checked),
),
)
.returning();
if (!existing) return "คิวนี้แก้ไม่ได้แล้ว";
const [queue] = await db
.insert(submissions)
.values({
...data,
char: data.character,
queue: existing.queue,
edits: existing.edits + 1,
editToken: randomUUIDv7(),
})
.returning({ queue: submissions.queue, id: submissions.id });
revalidatePath("/artifact/admin");
ps.publish({ type: "submit" }, { topic: "artifact", event: "update" });
return queue;
}
const [queue] = await db
.insert(submissions)
.values({
uid,
name,
comment,
char: char.name,
...data,
char: data.character,
})
.returning({ queue: submissions.queue, id: submissions.id });
revalidatePath("/artifact/admin");
ps.publish({}, { topic: "artifact", event: "update" });
ps.publish({ type: "submit" }, { topic: "artifact", event: "update" });
return queue;
}
export async function getCardStatus(submissionId: string) {
if (!(await adminCheck())) throw "Unauthorized";
const [res] = await db
.select({
cached: sql<boolean>`${isNotNull(cards.image)}`,
error: cards.error,
})
.from(cards)
.where(eq(cards.submission, submissionId));
return res;
}
export async function toggleCheck(submissionId: string) {
if (!(await adminCheck())) throw "Unauthorized";
await db
@@ -93,6 +156,7 @@ export async function toggleCheck(submissionId: string) {
revalidatePath("/artifact/admin");
revalidatePath("/artifact");
ps.publish({ type: "toggleCheck" }, { topic: "artifact", event: "update" });
await actionLog(`Toggled an artifact submission check mark`);
}
@@ -109,6 +173,7 @@ export async function toggleLock() {
revalidatePath("/artifact/admin");
revalidatePath("/artifact");
ps.publish({ type: "toggleLock" }, { topic: "artifact", event: "update" });
await actionLog(
`${(existing.length ? existing[0].locked : true) ? "Locked" : "Unlocked"} artifact submission`,
);
@@ -131,6 +196,7 @@ export async function setLimit(limit: number) {
revalidatePath("/artifact/admin");
revalidatePath("/artifact");
ps.publish({ type: "setLimit" }, { topic: "artifact", event: "update" });
await actionLog(
`Set artifact submit limit to ${limit < 0 ? "unlimited" : limit}`,
);
@@ -172,7 +238,7 @@ export async function tlState(
or(
eq(tierlistStates.uuid, `${data.uuid}`),
and(
eq(tierlistStates.char, `${data.char}`),
eq(tierlistStates.ref, `${data.ref}`),
eq(tierlistStates.list, `${data.list}`),
),
),
@@ -181,7 +247,7 @@ export async function tlState(
await db
.update(tierlistStates)
.set(data)
.where(eq(tierlistStates.uuid, `${data.uuid}`));
.where(eq(tierlistStates.uuid, existing.uuid));
else
await db
.insert(tierlistStates)
+21
View File
@@ -15,6 +15,8 @@ type SharedStates = Partial<{
connected: boolean;
updated: boolean;
"rubgram.services": string[];
"tl.deleteMode": boolean;
inspect: Record<string, string>;
_raw: Record<string, unknown>;
}>;
@@ -164,4 +166,23 @@ export const shared = {
raw() {
return [shared.state("_raw")[0]];
},
inspect(k: string, v: Parameters<typeof console.log>[0]) {
const comms = use(IccContext);
// unmount handler
useEffect(
() => () => {
const { [k]: removed, ...rest } = comms.get("inspect") || {};
comms.set("inspect", rest);
},
[],
);
// value updater
useEffect(() => {
const obj = comms.get("inspect") || {};
comms.set("inspect", {
...obj,
[k]: typeof v === "string" ? v : JSON.stringify(v),
});
}, [v]);
},
};
+5
View File
@@ -94,6 +94,8 @@ export const submissions = artifact.table("submissions", {
onUpdate: "cascade",
}),
queue: serial(),
editToken: text().notNull().$defaultFn(uuidv7),
edits: integer().notNull().default(0),
checked: boolean().notNull().default(false),
});
@@ -104,6 +106,7 @@ export const cards = artifact.table("cards", {
.notNull()
.unique(),
tries: integer().notNull().default(0),
error: text(),
image: bytea(),
});
@@ -224,6 +227,7 @@ export const tierlistTypes = tierlist.table("types", {
onUpdate: "cascade",
}),
order: integer().notNull(),
mode: text().notNull(),
});
export const tierlistTiers = tierlist.table("tiers", {
@@ -303,6 +307,7 @@ export const tierlistStates = tierlist.table("states", {
onDelete: "cascade",
onUpdate: "cascade",
}),
ref: text().notNull(), // for manually created tiles, the id would be char#Date.now() instead
list: text()
.notNull()
.references(() => tierlistVersions.id, {
+1 -1
View File
@@ -47,7 +47,7 @@ export default async function generate({
<div>{title}</div>
<div
style={{
fontSize: 32,
fontSize: 24,
marginTop: "-20",
color: "#aaa",
}}
+3
View File
@@ -17,6 +17,9 @@ const nextConfig: NextConfig = withBundleAnalyzer({
{
hostname: "cdn.dgnr.us",
},
{
hostname: "cdn.gunshiz.top",
},
{
hostname: "i.ytimg.com",
},
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 29 KiB

+1
View File
@@ -119,6 +119,7 @@ async function main() {
char: chars.find((c) => c.name === char)!.id!,
comment: dat.comment,
badges: dat.badges,
ref: chars.find((c) => c.name === char)!.id!,
}
: false,
)