feat: add guide page

This commit is contained in:
2026-03-19 01:03:23 +07:00 Unverified
parent c39675e184
commit 04058d1776
17 changed files with 480 additions and 25 deletions
+136
View File
@@ -0,0 +1,136 @@
import { eq } from "drizzle-orm";
import { SaveIcon, Trash2 } from "lucide-react";
import { revalidatePath } from "next/cache";
import { notFound, redirect } from "next/navigation";
import type { TypedFormData } from "@/app/(ui)/rubgram/type";
import { CdnChooser } from "@/components/chooser";
import {
FormAction,
FormInput,
FormProvider,
FormRow,
} from "@/components/form";
import { ModalBase } from "@/components/modal";
import { Button } from "@/components/ui/button";
import { DialogClose, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { actionLog } from "@/lib/api";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { guides } from "@/lib/db/schema";
export default async function GuideEditPage({
params,
}: PageProps<"/admin/guide/[id]">) {
const { id } = await params;
const [guide] = await db.select().from(guides).where(eq(guides.id, id));
if (!guide) notFound();
async function submit(
form: TypedFormData<{
name: string;
link: string;
image: string;
order: string;
}>,
) {
"use server";
if (!(await adminCheck())) redirect("/login");
for (const field of ["name", "link"] as const) {
if (!form.get(field)) {
return { error: `Field "${field}" is required.` };
}
}
let data: Partial<typeof guides.$inferSelect>;
try {
data = {
name: form.get("name")!,
image: form.get("image")!,
link: form.get("link")!,
order: parseInt(form.get("order")!, 10),
};
try {
new URL(data.link!);
} catch {
return { error: "Invalid link." };
}
await db.update(guides).set(data).where(eq(guides.id, guide.id));
} catch (e) {
console.error(e);
const err = e as Error & { cause: { detail: string; message: string } };
return {
error:
err?.cause?.detail ||
err?.cause?.message ||
"Failed to update guide.",
};
}
await actionLog(`Updated guide ${data.name}`, data);
revalidatePath("/admin/guide");
return { toast: "Guide updated successfully.", close: true };
}
async function deleteGuide() {
"use server";
if (!(await adminCheck())) redirect("/login");
await db.delete(guides).where(eq(guides.id, id));
await actionLog(`Deleted guide ${id}`);
revalidatePath("/admin/guide");
return { toast: "Guide deleted.", close: true };
}
return (
<ModalBase title="Add Guide">
<FormProvider id="guide-create" onSubmit={submit} values={guide}>
<FormRow>
<FormInput name="name" label="Name">
<Input placeholder="[6.3] Flins Guide" autoFocus />
</FormInput>
<FormInput name="order" label="Order">
<Input placeholder={`${guide.order}`} />
</FormInput>
</FormRow>
<FormInput name="link" label="Link">
<Input placeholder="https://docs.google.com/spreadsheets/..." />
</FormInput>
<FormInput name="image" label="Image" subLabel="optional">
<CdnChooser />
</FormInput>
<DialogFooter>
<Button asChild variant="destructive">
<FormAction type="action" action={deleteGuide}>
<Trash2 />
Delete
</FormAction>
</Button>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button asChild>
<FormAction
type="submit"
loading={
<>
<Spinner />
Saving...
</>
}
>
<SaveIcon />
Save
</FormAction>
</Button>
</DialogFooter>
</FormProvider>
</ModalBase>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { sql } from "drizzle-orm";
import { PlusIcon } from "lucide-react";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import type { TypedFormData } from "@/app/(ui)/rubgram/type";
import { CdnChooser } from "@/components/chooser";
import {
FormAction,
FormInput,
FormProvider,
FormRow,
} from "@/components/form";
import { ModalBase } from "@/components/modal";
import { Button } from "@/components/ui/button";
import { DialogClose, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { actionLog } from "@/lib/api";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { guides } from "@/lib/db/schema";
export default async function GuideCreatePage() {
const [{ maxOrder }] = await db
.select({
maxOrder: sql<number>`
MAX(${guides.order})`,
})
.from(guides);
async function submit(
form: TypedFormData<{
name: string;
link: string;
image: string;
order: string;
}>,
) {
"use server";
if (!(await adminCheck())) redirect("/login");
for (const field of ["name", "link"] as const) {
if (!form.get(field)) {
return { error: `Field "${field}" is required.` };
}
}
let data: typeof guides.$inferInsert;
try {
data = {
name: form.get("name")!,
image: form.get("image")!,
link: form.get("link")!,
order: parseInt(form.get("order")!, 10),
};
try {
new URL(data.link);
} catch {
return { error: "Invalid link." };
}
await db.insert(guides).values(data);
} catch (e) {
console.error(e);
const err = e as Error & { cause: { detail: string; message: string } };
return {
error:
err?.cause?.detail || err?.cause?.message || "Failed to add guide.",
};
}
await actionLog(`Added guide ${data.name}`, data);
revalidatePath("/admin/guide");
return { toast: "Guide added successfully.", close: true };
}
return (
<ModalBase title="Add Guide">
<FormProvider id="guide-create" onSubmit={submit}>
<FormRow>
<FormInput name="name" label="Name">
<Input placeholder="[6.3] Flins Guide" autoFocus />
</FormInput>
<FormInput name="order" label="Order">
<Input
placeholder={`${maxOrder + 10}`}
defaultValue={maxOrder + 10}
/>
</FormInput>
</FormRow>
<FormInput name="link" label="Link">
<Input placeholder="https://docs.google.com/spreadsheets/..." />
</FormInput>
<FormInput name="image" label="Image" subLabel="optional">
<CdnChooser />
</FormInput>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button asChild>
<FormAction
type="submit"
loading={
<>
<Spinner />
Creating...
</>
}
>
<PlusIcon />
Create
</FormAction>
</Button>
</DialogFooter>
</FormProvider>
</ModalBase>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { Pencil, PlusIcon } from "lucide-react";
import Link from "next/link";
import Image from "@/components/image";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
import { searchGuide } from "../../guide/api";
export default async function AdminGuidePage() {
const list = await searchGuide();
return (
<div className="grow-0 flex flex-col pt-5 gap-2 mx-4">
<Button className="w-fit" asChild>
<Link href={`/admin/guide/create`}>
<PlusIcon />
<span>Add guide</span>
</Link>
</Button>
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(min(300px,calc(100svw-2rem-2px)),1fr))]">
{list.map((card) => (
<Card
key={card.id}
className="bg-card/50 hover:bg-border backdrop-blur-sm transition-colors py-3 sm:py-6 rounded-sm sm:rounded-xl group relative"
>
<Button
size="icon"
variant="outline"
className="absolute opacity-0 group-hover:opacity-100 transition-opacity top-0 right-0 m-2"
asChild
>
<Link href={`/admin/guide/${card.id}`}>
<Pencil />
</Link>
</Button>
<CardHeader className="px-3 sm:px-6">
<CardTitle>{card.name}</CardTitle>
<div className="relative w-full aspect-square mt-2 rounded-sm sm:rounded-lg border overflow-hidden">
{card.image && (
<Image
src={`/cdn/${card.image}`}
alt={card.name}
fill
className="object-cover"
/>
)}
</div>
</CardHeader>
</Card>
))}
</div>
</div>
);
}
+14
View File
@@ -5,6 +5,7 @@ import {
BadgeDollarSign,
BookUser,
Columns3Cog,
Compass,
Computer,
Database,
ExternalLink,
@@ -151,6 +152,19 @@ export default async function AdminLayout({
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Guide</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarLink href="/admin/guide">
<Compass />
Editor
</SidebarLink>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Versions [WIP]</SidebarGroupLabel>
<Dialog>
+2
View File
@@ -6,6 +6,7 @@ import {
BadgeDollarSign,
ChevronRight,
Columns3Cog,
Compass,
ExternalLink,
Form,
GitGraph,
@@ -184,6 +185,7 @@ export function AdminNavbar({
{ name: "Layout", icon: Columns3Cog, href: "/admin/tl/layout" },
],
},
{ name: "Guide", icon: Compass, href: "/admin/guide" },
{
name: "Versions",
icon: List,
-7
View File
@@ -5,7 +5,6 @@ import {
CircleDollarSign,
PencilIcon,
SendHorizonal,
SquarePlay,
} from "lucide-react";
import type { Metadata } from "next";
import { cookies } from "next/headers";
@@ -24,12 +23,6 @@ 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";
+13
View File
@@ -0,0 +1,13 @@
"use server";
import { ilike } from "drizzle-orm";
import { db } from "@/lib/db";
import { guides } from "@/lib/db/schema";
export async function searchGuide(search: string = "") {
return await db
.select()
.from(guides)
.where(ilike(guides.name, `%${search}%`))
.orderBy(guides.id);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { Compass, Search } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useDebounce } from "react-use";
import Image from "@/components/image";
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { Spinner } from "@/components/ui/spinner";
import type { guides } from "@/lib/db/schema";
import { cn } from "@/lib/utils";
import { searchGuide } from "./api";
export function GuideList({
initialList,
}: {
initialList: (typeof guides.$inferSelect)[];
}) {
const [list, setList] = useState(initialList);
const [loading, setLoading] = useState(2);
const [search, setSearch] = useState("");
useDebounce(
() => {
if (loading === 2) return setLoading(0);
setLoading(1);
searchGuide(search)
.then(setList)
.finally(() => setLoading(0));
},
200,
[search],
);
return (
<div className="flex justify-center h-svh">
<div className="w-full xl:max-w-2/3 border flex flex-col">
<div className="border-b p-3 text-lg font-semibold leading-none flex items-center gap-2">
<Compass className="opacity-50" />
ไกด์ตัวละคร
</div>
<div className="p-4 pb-2">
<InputGroup className="w-full h-12">
<InputGroupInput
placeholder="Search..."
className="text-xl!"
onChange={(ev) => setSearch(ev.target.value)}
/>
<InputGroupAddon>
{loading === 1 ? <Spinner /> : <Search />}
</InputGroupAddon>
</InputGroup>
</div>
<div
className={cn(
"grid gap-4 p-4 grid-cols-[repeat(auto-fill,minmax(min(300px,calc(100svw-2rem-2px)),1fr))] overflow-auto transition-opacity",
loading === 1 && "opacity-50",
)}
>
{list.map((card) => (
<Link href={card.link} key={card.id} target="_blank">
<Card className="bg-card/50 hover:bg-border backdrop-blur-sm transition-colors py-3 sm:py-6 rounded-sm sm:rounded-xl">
<CardHeader className="px-3 sm:px-6">
<CardTitle>{card.name}</CardTitle>
<div className="relative w-full aspect-square mt-2 rounded-sm sm:rounded-lg border overflow-hidden">
{card.image && (
<Image
src={`/cdn/${card.image}`}
alt={card.name}
fill
className="object-cover"
/>
)}
</div>
</CardHeader>
</Card>
</Link>
))}
</div>
</div>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { searchGuide } from "./api";
import { GuideList } from "./client";
export default async function GuidePage() {
const initialData = await searchGuide();
return <GuideList initialList={initialData} />;
}
+9 -2
View File
@@ -4,15 +4,22 @@ import { Feather, Home, LoaderPinwheel, StickyNote, Table } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
export function Navbar() {
const pathname = usePathname();
const mobile = useIsMobile();
if (!/^\/(?:tl(?:\/[a-zA-Z0-9]+)?|rubgram|artifact)$/gm.test(pathname))
if (!/^\/(?:tl(?:\/[a-zA-Z0-9]+)?|rubgram|artifact|guide)$/gm.test(pathname))
return "";
return (
<div className="flex gap-2 p-2 rounded-lg absolute top-3 left-3 border bg-card">
<div
className={cn(
"flex gap-2 p-2 rounded-lg absolute border bg-card",
mobile && pathname === "/guide" ? "right-2 top-2" : "left-3 top-3",
)}
>
<Item icon={<Home size={16} />} name="หน้าหลัก" href="/" divide />
<Item
icon={<Feather size={16} />}
@@ -20,15 +20,15 @@ import {
} from "@/components/ui/empty";
import { db } from "@/lib/db";
import { endgameArchive, endgameDiscord, endgameSlips } from "@/lib/db/schema";
import { parseParamNumber } from "@/lib/utils";
import { parseSearchNumber } from "@/lib/utils";
import { DataViewer } from "./client";
export default async function ({
params,
}: PageProps<"/rubgram/admin/slip/[round]/[queue]">) {
const { round: r, queue: q } = await params;
const round = parseParamNumber(r) || 1;
const queue = parseParamNumber(q) || 1;
const round = parseSearchNumber(r) || 1;
const queue = parseSearchNumber(q) || 1;
const { slip, ...slipColumns } = getTableColumns(endgameSlips);
const [entry] = await db
.select({
@@ -29,7 +29,7 @@ import {
} from "@/components/ui/sidebar";
import { db } from "@/lib/db";
import { endgameArchive } from "@/lib/db/schema";
import { parseParamNumber } from "@/lib/utils";
import { parseSearchNumber } from "@/lib/utils";
import { Watcher } from "../../../../admin/client";
export default async function ({
@@ -40,7 +40,7 @@ export default async function ({
children: React.ReactNode;
}) {
const { round: r } = await params;
const round = parseParamNumber(r);
const round = parseSearchNumber(r);
return (
<SidebarProvider>
<Suspense fallback={<Sidebar />}>
@@ -9,7 +9,7 @@ import {
} from "@/components/ui/empty";
import { db } from "@/lib/db";
import { endgameArchive } from "@/lib/db/schema";
import { parseParamNumber } from "@/lib/utils";
import { parseSearchNumber } from "@/lib/utils";
export default async function ({
params,
@@ -17,7 +17,7 @@ export default async function ({
params: Promise<{ round: string }>;
}) {
const { round: r } = await params;
const round = parseParamNumber(r) || 1;
const round = parseSearchNumber(r) || 1;
const [first] = await db
.select({ queue: endgameArchive.queue })
.from(endgameArchive)