fix(admin): repair layout and character artwork
This commit is contained in:
@@ -41,7 +41,7 @@ formula fixtures. Their guide text and media are not imported or published.
|
||||
| `/register` | Temporary administrator account bootstrap |
|
||||
| `/admin` | Character and page overview |
|
||||
| `/admin/[character]/[page]` | Visual page editor |
|
||||
| `/admin/templates` | Apply reusable guide templates |
|
||||
| `/admin/create` | Create a structured guide from the synced character catalog |
|
||||
| `/media/[id]` | Authorized same-origin media response |
|
||||
| `/api/health` | Liveness response |
|
||||
| `/api/health?ready=1` | PostgreSQL and Redis readiness response |
|
||||
|
||||
@@ -4,6 +4,7 @@ import { notFound, redirect } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
|
||||
import { GuideSectionEditor } from "@/components/admin/guide-section-editor";
|
||||
import { AdminHeader } from "@/components/admin/admin-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getAdminSession } from "@/lib/auth/server";
|
||||
import { getActiveCatalog, getGuideByCharacterKey, getPublicGuide } from "@/lib/guides/queries";
|
||||
@@ -19,5 +20,5 @@ export default async function StructuredEditorPage({ params }: { params: Promise
|
||||
if (!guide) notFound();
|
||||
const [catalog, content] = await Promise.all([getActiveCatalog(), getPublicGuide(guide.slug)]);
|
||||
if (!catalog || !content) notFound();
|
||||
return <main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} initial={{ weapons: content.weapons, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent })) })), artifactProfile: content.artifactProfile, constellations: content.constellations, teams: content.teams.map((team) => ({ title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, damage: member.damage })) })), extras: content.extras.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} /></main>;
|
||||
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} initial={{ weapons: content.weapons, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent })) })), artifactProfile: content.artifactProfile, constellations: content.constellations, teams: content.teams.map((team) => ({ title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, damage: member.damage })) })), extras: content.extras.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} /></main></div>;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { connection } from "next/server";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminHeader } from "@/components/admin/admin-header";
|
||||
import { GuideOverviewForm } from "@/components/admin/guide-overview-form";
|
||||
import { getAdminSession } from "@/lib/auth/server";
|
||||
import { getGuideByCharacterKey } from "@/lib/guides/queries";
|
||||
@@ -23,5 +24,5 @@ export default async function GuideOverviewPage({ params }: { params: Promise<{
|
||||
const { character } = await params;
|
||||
const guide = await getGuideByCharacterKey(character);
|
||||
if (!guide) notFound();
|
||||
return <main className="mx-auto flex w-full max-w-6xl flex-col gap-8 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href="/admin" />} nativeButton={false} aria-label="กลับ Dashboard"><ArrowLeftIcon /></Button><div className="relative size-16 overflow-hidden rounded-lg border"><Image src={guide.coverUrl} alt={guide.name} fill className="object-cover" /></div><div><p className="text-sm text-muted-foreground">Where do you want to edit?</p><h1 className="font-heading text-3xl font-semibold">{guide.name}</h1></div></header><GuideOverviewForm guide={guide} /><div className="grid gap-4 md:grid-cols-2">{sections.map(({ slug, title, description, icon: Icon }) => <Link key={slug} href={`/admin/${guide.characterKey}/${slug}`} className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"><Card className="h-full"><CardHeader><Icon className="size-6 text-primary" /><CardTitle>{title}</CardTitle><CardDescription>{description}</CardDescription></CardHeader></Card></Link>)}<Link href={`/admin/${guide.characterKey}/extra`} className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"><Card className="h-full"><CardHeader><BoxesIcon className="size-6 text-primary" /><CardTitle>Extra sections</CardTitle><CardDescription>ส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์</CardDescription></CardHeader></Card></Link></div></main>;
|
||||
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-8 p-4 sm:p-6 lg:p-8"><header className="flex flex-wrap items-center gap-4"><Button variant="outline" size="icon" render={<Link href="/admin" />} nativeButton={false} aria-label="กลับ Dashboard"><ArrowLeftIcon /></Button><div className="relative size-16 overflow-hidden rounded-lg border"><Image src={guide.coverUrl} alt={guide.name} fill className="object-cover" /></div><div><p className="text-sm text-muted-foreground">เลือกส่วนที่ต้องการแก้ไข</p><h1 className="font-heading text-3xl font-semibold">{guide.name}</h1></div></header><GuideOverviewForm guide={guide} /><div className="grid gap-4 md:grid-cols-2">{sections.map(({ slug, title, description, icon: Icon }) => <Link key={slug} href={`/admin/${guide.characterKey}/${slug}`} className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"><Card className="h-full transition-colors hover:bg-muted/50"><CardHeader><Icon className="size-6 text-primary" /><CardTitle>{title}</CardTitle><CardDescription>{description}</CardDescription></CardHeader></Card></Link>)}<Link href={`/admin/${guide.characterKey}/extra`} className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"><Card className="h-full transition-colors hover:bg-muted/50"><CardHeader><BoxesIcon className="size-6 text-primary" /><CardTitle>Extra sections</CardTitle><CardDescription>ส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์</CardDescription></CardHeader></Card></Link></div></main></div>;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
|
||||
import { CreateGuideFlow } from "@/components/admin/create-guide-flow";
|
||||
import { AdminHeader } from "@/components/admin/admin-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getAdminSession } from "@/lib/auth/server";
|
||||
import { getActiveCatalog, listGuideCards } from "@/lib/guides/queries";
|
||||
@@ -14,6 +15,8 @@ export default async function CreateGuidePage() {
|
||||
const [catalog, guides] = await Promise.all([getActiveCatalog(), listGuideCards()]);
|
||||
if (!catalog) redirect("/admin?sync=required");
|
||||
return (
|
||||
<div className="min-h-svh">
|
||||
<AdminHeader />
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-col gap-8 p-4 sm:p-6 lg:p-8">
|
||||
<header className="flex items-center gap-4">
|
||||
<Button variant="outline" size="icon" render={<Link href="/admin" />} nativeButton={false} aria-label="กลับ Dashboard"><ArrowLeftIcon /></Button>
|
||||
@@ -21,5 +24,6 @@ export default async function CreateGuidePage() {
|
||||
</header>
|
||||
<CreateGuideFlow characters={catalog.characters} existingKeys={guides.map((guide) => guide.characterKey)} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function AdminLoading() {
|
||||
return (
|
||||
<div className="flex min-h-svh">
|
||||
<Skeleton className="hidden w-64 rounded-none md:block" />
|
||||
<div className="flex-1 space-y-5 p-6">
|
||||
<div className="min-h-svh">
|
||||
<Skeleton className="h-16 w-full rounded-none" />
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5 p-6">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-36 w-full" />
|
||||
<Skeleton className="h-72 w-full" />
|
||||
|
||||
+9
-1
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
|
||||
import { StructuredDashboard } from "@/components/admin/structured-dashboard";
|
||||
import { AdminHeader } from "@/components/admin/admin-header";
|
||||
import { getAdminSession } from "@/lib/auth/server";
|
||||
import { getActiveCatalog, getLatestCatalogSyncJob, listGuideCards } from "@/lib/guides/queries";
|
||||
|
||||
@@ -15,9 +16,16 @@ export default async function AdminPage() {
|
||||
getActiveCatalog(),
|
||||
]);
|
||||
return (
|
||||
<div className="min-h-svh">
|
||||
<AdminHeader />
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-col gap-8 p-4 sm:p-6 lg:p-8">
|
||||
<header><p className="text-sm font-medium text-primary">Buzz Sheet Admin</p><h1 className="font-heading text-3xl font-semibold">Guide Dashboard</h1><p className="mt-2 text-muted-foreground">จัดการ Guide แบบภาพ-first และบันทึกขึ้นเว็บไซต์ทันที</p></header>
|
||||
<header>
|
||||
<p className="text-sm font-medium text-primary">Content workspace</p>
|
||||
<h1 className="font-heading text-3xl font-semibold">Guide Dashboard</h1>
|
||||
<p className="mt-2 text-muted-foreground">จัดการ Guide แบบภาพ-first และบันทึกขึ้นเว็บไซต์ทันที</p>
|
||||
</header>
|
||||
<StructuredDashboard guides={guides} trash={trash} syncJob={syncJob} catalogVersion={catalog?.version ?? null} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
|
||||
import { AdminSidebar } from "@/components/admin/admin-sidebar";
|
||||
import { LiveRefresh } from "@/components/live-refresh";
|
||||
import { TemplateGallery } from "@/components/admin/template-gallery";
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { getAdminSession } from "@/lib/auth/server";
|
||||
import { loadAdminNavigation } from "@/lib/content/loaders";
|
||||
import { builtInTemplates } from "@/lib/templates";
|
||||
|
||||
export default async function TemplatesPage() {
|
||||
await connection();
|
||||
if (!(await getAdminSession())) redirect("/admin/login");
|
||||
const navigation = await loadAdminNavigation();
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
{process.env.BUZZ_DEMO_MODE === "true" ? null : (
|
||||
<LiveRefresh endpoint="/api/events/admin" />
|
||||
)}
|
||||
<AdminSidebar navigation={navigation} />
|
||||
<SidebarInset>
|
||||
<header className="flex min-h-14 items-center gap-3 border-b px-4">
|
||||
<SidebarTrigger />
|
||||
<div>
|
||||
<h1 className="font-heading font-semibold">เทมเพลตคู่มือ</h1>
|
||||
<p className="text-xs text-muted-foreground">โครงสร้างเริ่มต้นที่คัดมาจากรูปแบบคู่มือทั้งห้า</p>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto w-full max-w-6xl p-4 sm:p-6 lg:p-8">
|
||||
<TemplateGallery templates={builtInTemplates} />
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
redirect("/admin/create");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
BookOpenIcon,
|
||||
ExternalLinkIcon,
|
||||
LayoutDashboardIcon,
|
||||
LogOutIcon,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
|
||||
export function AdminHeader() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<header className="border-b bg-background/95 supports-backdrop-filter:backdrop-blur">
|
||||
<div className="mx-auto flex min-h-16 w-full max-w-7xl flex-wrap items-center gap-2 px-4 py-2 sm:px-6">
|
||||
<Link
|
||||
href="/admin"
|
||||
className="mr-auto flex min-h-11 items-center gap-3 rounded-lg font-heading font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span className="grid size-9 place-items-center rounded-lg bg-primary text-primary-foreground">
|
||||
<BookOpenIcon aria-hidden="true" />
|
||||
</span>
|
||||
<span className="hidden sm:inline">Buzz Sheet Admin</span>
|
||||
</Link>
|
||||
<nav aria-label="Admin navigation" className="flex items-center gap-1">
|
||||
<Button
|
||||
variant={pathname === "/admin" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<Link href="/admin" />}
|
||||
>
|
||||
<LayoutDashboardIcon data-icon="inline-start" />
|
||||
<span className="hidden sm:inline">Dashboard</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={pathname === "/admin/create" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<Link href="/admin/create" />}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
<span className="hidden sm:inline">New Guide</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
nativeButton={false}
|
||||
render={<Link href="/" target="_blank" />}
|
||||
aria-label="เปิดหน้าเว็บไซต์"
|
||||
>
|
||||
<ExternalLinkIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="ออกจากระบบ"
|
||||
onClick={() =>
|
||||
authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
router.push("/admin/login");
|
||||
router.refresh();
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<LogOutIcon />
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export function GuideSectionEditor(props: GuideSectionEditorProps) {
|
||||
{props.section === "constellations" ? <ConstellationEditor {...props} version={version} pending={pending} save={save} /> : null}
|
||||
{props.section === "team" ? <TeamEditor {...props} version={version} pending={pending} save={save} /> : null}
|
||||
{props.section === "extra" ? <ExtraEditor {...props} version={version} pending={pending} save={save} /> : null}
|
||||
<Pagination><PaginationContent><PaginationItem>{index > 0 ? <PaginationPrevious href={`/admin/${props.guide.characterKey}/${SECTION_ORDER[index - 1]}`} text={SECTION_LABELS[SECTION_ORDER[index - 1]]} /> : <PaginationLink href={`/admin/${props.guide.characterKey}`}>Overview</PaginationLink>}</PaginationItem><PaginationItem><PaginationLink href="#" isActive>{SECTION_LABELS[props.section]}</PaginationLink></PaginationItem>{index < SECTION_ORDER.length - 1 ? <PaginationItem><PaginationNext href={`/admin/${props.guide.characterKey}/${SECTION_ORDER[index + 1]}`} text={SECTION_LABELS[SECTION_ORDER[index + 1]]} /></PaginationItem> : null}</PaginationContent></Pagination>
|
||||
<Pagination><PaginationContent className="flex-wrap"><PaginationItem>{index > 0 ? <PaginationPrevious href={`/admin/${props.guide.characterKey}/${SECTION_ORDER[index - 1]}`} text={SECTION_LABELS[SECTION_ORDER[index - 1]]} /> : <PaginationLink href={`/admin/${props.guide.characterKey}`} size="default">Overview</PaginationLink>}</PaginationItem><PaginationItem><PaginationLink href={`/admin/${props.guide.characterKey}/${props.section}`} size="default" isActive>{SECTION_LABELS[props.section]}</PaginationLink></PaginationItem>{index < SECTION_ORDER.length - 1 ? <PaginationItem><PaginationNext href={`/admin/${props.guide.characterKey}/${SECTION_ORDER[index + 1]}`} text={SECTION_LABELS[SECTION_ORDER[index + 1]]} /></PaginationItem> : null}</PaginationContent></Pagination>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ export function MediaUploader({
|
||||
className="w-full"
|
||||
disabled={busy}
|
||||
render={<label htmlFor={inputId} />}
|
||||
nativeButton={false}
|
||||
>
|
||||
{busy ? <Spinner /> : <ImagePlusIcon />}
|
||||
{state === "uploading" && "กำลังอัปโหลด…"}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function SiteHeader() {
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" render={<Link href="/admin" />}>
|
||||
<Button variant="ghost" size="sm" nativeButton={false} render={<Link href="/admin" />}>
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
จัดการเนื้อหา
|
||||
</Button>
|
||||
|
||||
+4
-4
@@ -20,7 +20,7 @@ const versionSchema = z.object({
|
||||
});
|
||||
const characterSchema = z.record(z.string(), z.object({
|
||||
enName: z.string(),
|
||||
CardImg: z.string(),
|
||||
GachaImg: z.string(),
|
||||
element: z.string().optional(),
|
||||
qualityType: z.string().optional(),
|
||||
weaponType: z.string().optional(),
|
||||
@@ -98,10 +98,10 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
|
||||
const weaponData = weaponSchema.parse(weaponsRaw);
|
||||
const artifactData = artifactSchema.parse(artifactsRaw);
|
||||
|
||||
let characters = Object.entries(characterData).filter(([, value]) => value.enName && value.enName !== "???" && value.CardImg).map(([key, value]) => ({
|
||||
let characters = Object.entries(characterData).filter(([, value]) => value.enName && value.enName !== "???" && value.GachaImg).map(([key, value]) => ({
|
||||
version, key, name: value.enName, element: value.element ?? null, rarity: rarity(value.qualityType), weaponType: value.weaponType ?? null,
|
||||
imageKey: `catalog/${version}/characters/${webpName(value.CardImg)}`,
|
||||
sourceImage: `${API_ROOT}/assets/gachaicon/${encodeURIComponent(webpName(value.CardImg))}`,
|
||||
imageKey: `catalog/${version}/characters/${webpName(value.GachaImg)}`,
|
||||
sourceImage: `${API_ROOT}/assets/gachaimg/${encodeURIComponent(webpName(value.GachaImg))}`,
|
||||
}));
|
||||
let weapons = Object.entries(weaponData).filter(([, value]) => value.enName && value.enName !== "???" && value.weaponIcon).map(([key, value]) => ({
|
||||
version, key, name: value.enName, rarity: rarity(value.qualityType), weaponType: value.weaponType ?? null,
|
||||
|
||||
Reference in New Issue
Block a user