idk
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
@@ -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 }) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -69,7 +69,7 @@ import { RulesDialog } from "./rules";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "รับกรรมแทนทางบ้าน",
|
||||
description: "รับเล่นคอนเท้น Endgame เกนชินแทนคนดู",
|
||||
description: "รับเล่นคอนเทนต์เอนเกมเกนชินแทนคนดู",
|
||||
};
|
||||
|
||||
export default async function EndgamePage() {
|
||||
|
||||
@@ -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 ผ่านช่อง ยมบาลตามตัว
|
||||
|
||||
Vendored
+2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: () => {},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Executable
+80
@@ -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";
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+29
-4
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}}
|
||||
>
|
||||
|
||||
Executable
+25
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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]);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
@@ -47,7 +47,7 @@ export default async function generate({
|
||||
<div>{title}</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 32,
|
||||
fontSize: 24,
|
||||
marginTop: "-20",
|
||||
color: "#aaa",
|
||||
}}
|
||||
|
||||
@@ -17,6 +17,9 @@ const nextConfig: NextConfig = withBundleAnalyzer({
|
||||
{
|
||||
hostname: "cdn.dgnr.us",
|
||||
},
|
||||
{
|
||||
hostname: "cdn.gunshiz.top",
|
||||
},
|
||||
{
|
||||
hostname: "i.ytimg.com",
|
||||
},
|
||||
|
||||
Executable
BIN
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 |
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user