feat(art): #3 add edit button

This commit is contained in:
2026-03-09 00:12:50 +07:00 Unverified
parent 3e3ef549d4
commit d91f36fc52
15 changed files with 218 additions and 85 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ export default async function AdminLayout({
queue: submissions.queue,
})
.from(submissions)
.orderBy(submissions.id);
.orderBy(submissions.queue);
const config = await getArtifactConfig();
return (
<SidebarProvider>
+20 -6
View File
@@ -44,15 +44,19 @@ import { RulesDialog } from "./rules";
export function CharacterChooser({
clist,
uid: defaultUid = "",
char: defaultChar,
}: {
clist: {
value: string;
label: string;
}[];
uid?: string;
char?: string;
}) {
const [uid, setUid] = useState("");
const [uid, setUid] = useState(defaultUid);
const [chars, setChars] = useState<(typeof characters.$inferSelect)[]>([]);
const [selected, setSelected] = useState<string | undefined>();
const [selected, setSelected] = useState<string | undefined>(defaultChar);
const [isError, setIsError] = useState<boolean | string>(false);
const [isLoading, setIsLoading] = useState(false);
const [manual, setManual] = useState(false);
@@ -60,6 +64,10 @@ export function CharacterChooser({
const [guideDialogOpen, setGuideDialogOpen] = useState(false);
const [cacheTtl, setCacheTtl] = useState(new Date());
useEffect(() => {
if (defaultUid) setUid(defaultUid);
}, [defaultUid]);
useEffect(() => {
setIsLoading(true);
setIsError(false);
@@ -83,10 +91,15 @@ export function CharacterChooser({
c.avatarId.toString(),
);
const chars = await getCharacters(charIds);
setChars(
charIds.map((e) => chars.find((c) => c.amber === e)!).filter(Boolean),
const userChars = charIds
.map((e) => chars.find((c) => c.amber === e)!)
.filter(Boolean);
setChars(userChars);
setSelected(
userChars.some((c) => c.name === defaultChar)
? defaultChar
: undefined,
);
setSelected(undefined);
} catch (e) {
console.error(e);
setIsError("ข้อผิดพลาดภายในระบบ");
@@ -95,7 +108,7 @@ export function CharacterChooser({
fetchChars()
.catch(() => setIsError(true))
.finally(() => setIsLoading(false));
}, [uid]);
}, [uid, defaultChar]);
return (
<>
@@ -108,6 +121,7 @@ export function CharacterChooser({
required
placeholder="814006303"
onChange={(ev) => setUid(ev.target.value)}
defaultValue={defaultUid}
/>
</div>
<Label htmlFor="character">
+5 -2
View File
@@ -4,10 +4,13 @@ import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { submitArtifact } from "@/lib/api";
export function ArtifactFormWrapper(props: React.ComponentProps<"form">) {
export function ArtifactFormWrapper({
edit,
...props
}: React.ComponentProps<"form"> & { edit?: { sub: string; token: string } }) {
const router = useRouter();
async function submit(data: FormData) {
submitArtifact(data)
submitArtifact(data, edit)
.then((q) => {
if (typeof q === "string") return toast.error(q);
try {
+57 -13
View File
@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
import {
BookAlert,
CircleDollarSign,
PencilIcon,
SendHorizonal,
SquarePlay,
} from "lucide-react";
@@ -46,14 +47,13 @@ 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({
@@ -64,6 +64,8 @@ export default async function ArtifactFormPage() {
.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 justify-around items-center h-svh">
@@ -71,12 +73,37 @@ export default async function ArtifactFormPage() {
<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>
@@ -111,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>
@@ -123,10 +153,15 @@ export default async function ArtifactFormPage() {
autoComplete="name"
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">
@@ -138,6 +173,7 @@ export default async function ArtifactFormPage() {
required
placeholder="814006303"
maxLength={10}
defaultValue={editing ? q.uid : undefined}
/>
</div>
<div className="grid gap-2">
@@ -148,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>
</>
@@ -160,6 +197,7 @@ export default async function ArtifactFormPage() {
placeholder="เช่น Er พอไหมครับ, คริสวยยังครับ (ไม่บังคับ)"
className="bg-card!"
maxLength={1024}
defaultValue={editing ? q.comment : undefined}
/>
</div>
</div>
@@ -196,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">
@@ -209,12 +253,12 @@ 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>
+2 -1
View File
@@ -9,7 +9,8 @@ import { cn } from "@/lib/utils";
export function Navbar() {
const pathname = usePathname();
if (!/^\/(?:tl(?:\/[a-zA-Z0-9]+)?|rubgram|artifact)$/gm.test(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 />
+1 -4
View File
@@ -13,10 +13,7 @@ import { Spinner } from "@/components/ui/spinner";
import { debugUploadSlip, discordCall } from "../../api";
export function Evernight() {
return (
<div className="h-full w-full relative">
</div>
);
return <div className="h-full w-full relative"></div>;
}
export function CopyButton({ text }: { text: string }) {
+1 -1
View File
@@ -129,7 +129,7 @@ export default async function AdminSubmissionView({
)}
</div>
<div className="border rounded-md h-full overflow-hidden">
<Evernight/>
<Evernight />
</div>
</div>
</div>
+2
View File
@@ -24,6 +24,8 @@ export interface TypedFormData<T extends TypedFormDataShape> {
has<K extends keyof T>(key: K): boolean;
entries(): T;
/** underlying raw FormData */
readonly raw: FormData;
}
+6 -2
View File
@@ -666,7 +666,7 @@ export function TierList({
className="flex gap-2 items-center justify-center"
onClick={(ev) => ev.stopPropagation()}
>
<SimpleTooltip text="เพิ่มตัวละคร">
<SimpleTooltip text="เพิ่มตัวละคร">
<span>
<ComboBox
placeholder="ค้นหาตัวละคร"
@@ -689,7 +689,11 @@ export function TierList({
/>
</span>
</SimpleTooltip>
<SimpleTooltip text={deleteMode ? "คลิกอีกครั้งเพื่อออกจากโหมดลบ" : "ลบตัวละครออก"}>
<SimpleTooltip
text={
deleteMode ? "คลิกอีกครั้งเพื่อออกจากโหมดลบ" : "ลบตัวละครออก"
}
>
<Trash2
className={cn(
"pointer-events-auto! text-red-500",
+8 -7
View File
@@ -12,7 +12,9 @@ export const metadata: Metadata = {
export default async function TierlistSelectionPage({
params,
}: { params: Promise<{ type: string }> }) {
}: {
params: Promise<{ type: string }>;
}) {
const { type: typeId } = await params;
const types = await db
.select()
@@ -38,25 +40,24 @@ export default async function TierlistSelectionPage({
{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}>
<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"
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 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">
<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>
)}
+14 -14
View File
@@ -41,21 +41,21 @@ export default async function TierlistSelectionPage() {
</center>
{vers.map((t) => (
<div className="flex flex-col gap-1" key={t.id}>
<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>
<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 text-muted-foreground ml-2">
ดูทั้งหมด
</span>
</SimpleTooltip>
</div>
</Link>
<span className="text-sm text-muted-foreground ml-2">
ดูทั้งหมด
</span>
</SimpleTooltip>
</div>
</Link>
<HorizontalDiv>
<div className="flex gap-2 max-w-full">
{t.versions.map((e) => (
+6 -1
View File
@@ -26,6 +26,7 @@ export function ComboBox({
className,
trigger,
onValueSelect,
defaultValue = "",
...props
}: React.ComponentProps<"button"> & {
data: { value: string; label: string }[];
@@ -34,7 +35,11 @@ export function ComboBox({
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 (
<>
+73 -33
View File
@@ -1,10 +1,11 @@
"use server";
import { and, eq, inArray, isNotNull, 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";
@@ -37,45 +38,84 @@ 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 = z.object(
{
name: z.string().max(64, "ชื่อยาวเกินไป ต้องไม่เกิน 64 ตัวอักษร"),
uid: z
.string()
.regex(uidRegex, "UID ไม่ถูกต้อง ต้องเป็นเลข 9 หรือ 10 หลัก เท่านั้น")
.refine(
(uid) =>
db
.select()
.from(submissions)
.where(eq(submissions.uid, uid))
.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 > 64) return "ชื่อยาวเกินไป ต้องไม่เกิน 64 ตัวอักษร";
if (!uidRegex.test(uid)) return "UID ไม่ถูกต้อง ต้องเป็นเลข 9 หรือ 10 หลัก เท่านั้น";
if (comment.length > 1024) return "ข้อความเพิ่มเติมยาวเกินไป ต้องไม่เกิน 1024 ตัวอักษร";
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.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");
+20
View File
@@ -16,6 +16,7 @@ type SharedStates = Partial<{
updated: boolean;
"rubgram.services": string[];
"tl.deleteMode": boolean;
inspect: Record<string, string>;
_raw: Record<string, unknown>;
}>;
@@ -165,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]);
},
};
+2
View File
@@ -94,6 +94,8 @@ export const submissions = artifact.table("submissions", {
onUpdate: "cascade",
}),
queue: serial(),
editToken: text().notNull().$defaultFn(uuidv7),
edits: integer().notNull().default(0),
checked: boolean().notNull().default(false),
});