feat(public): render responsive character guides
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { SiteHeader } from "@/components/public/site-header";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function GuideLoading() {
|
||||
return (
|
||||
<div className="min-h-svh">
|
||||
<SiteHeader />
|
||||
<div className="border-b p-4"><Skeleton className="mx-auto h-16 max-w-7xl" /></div>
|
||||
<main className="mx-auto max-w-5xl space-y-5 px-4 py-10">
|
||||
<Skeleton className="h-72 w-full" />
|
||||
<Skeleton className="h-8 w-1/3" />
|
||||
<Skeleton className="h-28 w-full" />
|
||||
<Skeleton className="h-80 w-full" />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, permanentRedirect } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
|
||||
import { GuideShell } from "@/components/public/guide-shell";
|
||||
import { loadPublicPage } from "@/lib/content/loaders";
|
||||
|
||||
type RouteParams = Promise<{ character: string; page: string }>;
|
||||
|
||||
export async function generateMetadata({ params }: { params: RouteParams }): Promise<Metadata> {
|
||||
await connection();
|
||||
const { character, page } = await params;
|
||||
const result = await loadPublicPage(character, page);
|
||||
if (result.type !== "page") return { title: "ไม่พบคู่มือ" };
|
||||
return {
|
||||
title: `${result.snapshot.page.title} — ${result.snapshot.character.nameTh}`,
|
||||
description: result.snapshot.character.description ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function GuidePage({ params }: { params: RouteParams }) {
|
||||
await connection();
|
||||
const { character, page } = await params;
|
||||
const result = await loadPublicPage(character, page);
|
||||
if (result.type === "redirect") permanentRedirect(result.location);
|
||||
if (result.type === "not-found") notFound();
|
||||
return <GuideShell snapshot={result.snapshot} />;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { notFound, permanentRedirect } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
|
||||
import { loadPublicPage } from "@/lib/content/loaders";
|
||||
|
||||
export default async function CharacterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ character: string }>;
|
||||
}) {
|
||||
await connection();
|
||||
const { character } = await params;
|
||||
const result = await loadPublicPage(character);
|
||||
if (result.type === "redirect") permanentRedirect(result.location);
|
||||
notFound();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { DirectorySkeleton } from "@/components/public/directory-skeleton";
|
||||
import { SiteHeader } from "@/components/public/site-header";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-svh">
|
||||
<SiteHeader />
|
||||
<main className="mx-auto w-full max-w-7xl space-y-8 px-4 py-10 sm:px-6 sm:py-14">
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-10 w-full max-w-2xl" />
|
||||
<Skeleton className="h-6 w-full max-w-xl" />
|
||||
</div>
|
||||
<DirectorySkeleton />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Link from "next/link";
|
||||
import { MapPinOffIcon } from "lucide-react";
|
||||
|
||||
import { SiteHeader } from "@/components/public/site-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-svh">
|
||||
<SiteHeader />
|
||||
<main className="mx-auto grid min-h-[calc(100svh-4rem)] max-w-3xl place-items-center px-4 py-16">
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon"><MapPinOffIcon aria-hidden="true" /></EmptyMedia>
|
||||
<EmptyTitle>ไม่พบคู่มือนี้</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
หน้านี้อาจถูกซ่อน เปลี่ยนชื่อ หรือยังไม่มีเนื้อหาที่เผยแพร่
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button render={<Link href="/" />}>กลับไปหน้ารวมตัวละคร</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+20
-61
@@ -1,68 +1,27 @@
|
||||
import Image from "next/image";
|
||||
import { connection } from "next/server";
|
||||
|
||||
import { CharacterDirectory } from "@/components/public/character-directory";
|
||||
import { SiteHeader } from "@/components/public/site-header";
|
||||
import { loadPublicDirectory } from "@/lib/content/loaders";
|
||||
|
||||
export default async function HomePage() {
|
||||
await connection();
|
||||
const characters = await loadPublicDirectory();
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert h-5 w-[100px]"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the{" "}
|
||||
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
|
||||
page.tsx
|
||||
</code>{" "}
|
||||
file.
|
||||
<div className="min-h-svh bg-background">
|
||||
<SiteHeader />
|
||||
<main className="mx-auto w-full max-w-7xl px-4 py-10 sm:px-6 sm:py-14">
|
||||
<header className="mb-8 max-w-3xl space-y-3">
|
||||
<p className="text-sm font-medium text-primary">คลังคู่มือตัวละคร</p>
|
||||
<h1 className="font-heading text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||
หาคู่มือที่ต้องการ แล้วเริ่มจัดทีมได้เลย
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
<p className="text-base leading-7 text-muted-foreground sm:text-lg">
|
||||
ค้นหาตามชื่อ ธาตุ หรือบทบาท ทุกหน้าจัดรูปแบบมาให้อ่านง่ายทั้งมือถือและเดสก์ท็อป
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert h-[14px] w-4"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={14}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<CharacterDirectory characters={characters} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
import Decimal from "decimal.js";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
ArrowUpRightIcon,
|
||||
Clock3Icon,
|
||||
InfoIcon,
|
||||
LightbulbIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { ComparisonChart } from "@/components/public/comparison-chart";
|
||||
import { MediaImage } from "@/components/public/media-image";
|
||||
import { TermDetail } from "@/components/public/term-detail";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from "@/components/ui/carousel";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { blockSchemas, resolveBlock } from "@/lib/blocks";
|
||||
import { prepareComparisonChart } from "@/lib/charts/comparison";
|
||||
import { evaluateDataSource } from "@/lib/content/data-source";
|
||||
import type {
|
||||
DataSourceSnapshot,
|
||||
PageBlockSnapshot,
|
||||
PageSnapshot,
|
||||
} from "@/lib/content/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Config<Type extends keyof typeof blockSchemas> = z.output<
|
||||
(typeof blockSchemas)[Type]
|
||||
>;
|
||||
|
||||
const widthClasses = {
|
||||
content: "mx-auto max-w-3xl",
|
||||
wide: "mx-auto max-w-6xl",
|
||||
full: "w-full",
|
||||
} as const;
|
||||
|
||||
function evaluatedSource(
|
||||
source: DataSourceSnapshot | undefined,
|
||||
): ReturnType<typeof evaluateDataSource> | null {
|
||||
if (!source) return null;
|
||||
try {
|
||||
return evaluateDataSource(source);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function displayCell(
|
||||
evaluated: ReturnType<typeof evaluateDataSource>,
|
||||
rowId: string,
|
||||
field: string,
|
||||
): string {
|
||||
const cell = evaluated.rows.find((row) => row.id === rowId)?.cells[field];
|
||||
return cell && !cell.error && cell.display != null ? cell.display : "—";
|
||||
}
|
||||
|
||||
export function collectGuideSections(
|
||||
blocks: PageBlockSnapshot[],
|
||||
): Array<{ id: string; label: string }> {
|
||||
return blocks.flatMap((serialized) => {
|
||||
const resolution = resolveBlock(serialized);
|
||||
if (resolution.kind !== "ready") return [];
|
||||
if (resolution.block.type === "section") {
|
||||
const config = blockSchemas.section.parse(resolution.block.config);
|
||||
return [{ id: config.anchor ?? `section-${serialized.id}`, label: config.title }];
|
||||
}
|
||||
if (resolution.block.type === "heading") {
|
||||
const config = blockSchemas.heading.parse(resolution.block.config);
|
||||
return [{ id: config.anchor ?? `section-${serialized.id}`, label: config.text }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export function PublicBlockRenderer({ snapshot }: { snapshot: PageSnapshot }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{snapshot.blocks.map((serialized) => {
|
||||
const resolution = resolveBlock(serialized);
|
||||
if (resolution.kind !== "ready") {
|
||||
return (
|
||||
<div key={serialized.id} className="mx-auto max-w-3xl">
|
||||
<Alert variant={resolution.kind === "invalid" ? "destructive" : "default"}>
|
||||
<AlertCircleIcon aria-hidden="true" />
|
||||
<AlertTitle>ไม่สามารถแสดงบล็อกนี้ได้</AlertTitle>
|
||||
<AlertDescription>
|
||||
เนื้อหาส่วนนี้ใช้รูปแบบที่ไม่รองรับ โปรดลองใหม่ภายหลัง
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const layout = resolution.block.responsiveLayout as {
|
||||
width: keyof typeof widthClasses;
|
||||
hideOnMobile: boolean;
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={serialized.id}
|
||||
className={cn(
|
||||
widthClasses[layout.width],
|
||||
layout.hideOnMobile && "hidden sm:block",
|
||||
)}
|
||||
data-block-id={serialized.id}
|
||||
data-block-type={resolution.block.type}
|
||||
>
|
||||
<ReadyBlock
|
||||
block={resolution.block}
|
||||
dataSources={snapshot.dataSources}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadyBlock({
|
||||
block,
|
||||
dataSources,
|
||||
}: {
|
||||
block: ReturnType<typeof resolveBlock> extends infer Resolution
|
||||
? Resolution extends { kind: "ready"; block: infer Ready }
|
||||
? Ready
|
||||
: never
|
||||
: never;
|
||||
dataSources: PageSnapshot["dataSources"];
|
||||
}) {
|
||||
switch (block.type) {
|
||||
case "profile":
|
||||
return <ProfileBlock config={blockSchemas.profile.parse(block.config)} />;
|
||||
case "rich-text":
|
||||
return <RichTextBlock config={blockSchemas["rich-text"].parse(block.config)} />;
|
||||
case "heading": {
|
||||
const config = blockSchemas.heading.parse(block.config);
|
||||
const id = config.anchor ?? `section-${block.id}`;
|
||||
const Heading = `h${config.level}` as "h2" | "h3" | "h4";
|
||||
return (
|
||||
<Heading id={id} className="scroll-mt-36 font-heading text-2xl font-semibold tracking-tight">
|
||||
{config.text}
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
case "callout":
|
||||
return <CalloutBlock config={blockSchemas.callout.parse(block.config)} />;
|
||||
case "stat-cards":
|
||||
return <StatCardsBlock config={blockSchemas["stat-cards"].parse(block.config)} />;
|
||||
case "key-value":
|
||||
return <KeyValueBlock config={blockSchemas["key-value"].parse(block.config)} />;
|
||||
case "ranking":
|
||||
return (
|
||||
<RankingBlock
|
||||
config={blockSchemas.ranking.parse(block.config)}
|
||||
source={dataSources[(block.config as Config<"ranking">).dataSourceId]}
|
||||
/>
|
||||
);
|
||||
case "team-composition":
|
||||
return (
|
||||
<TeamBlock
|
||||
config={blockSchemas["team-composition"].parse(block.config)}
|
||||
source={dataSources[(block.config as Config<"team-composition">).dataSourceId]}
|
||||
/>
|
||||
);
|
||||
case "rotation":
|
||||
return <RotationBlock config={blockSchemas.rotation.parse(block.config)} />;
|
||||
case "table":
|
||||
return (
|
||||
<DataTableBlock
|
||||
config={blockSchemas.table.parse(block.config)}
|
||||
source={dataSources[(block.config as Config<"table">).dataSourceId]}
|
||||
/>
|
||||
);
|
||||
case "comparison-chart": {
|
||||
const config = blockSchemas["comparison-chart"].parse(block.config);
|
||||
return <ComparisonBlock config={config} source={dataSources[config.dataSourceId]} />;
|
||||
}
|
||||
case "image": {
|
||||
const config = blockSchemas.image.parse(block.config);
|
||||
return (
|
||||
<figure className="space-y-2">
|
||||
<MediaImage
|
||||
mediaId={config.mediaId}
|
||||
alt={config.alt}
|
||||
fit={config.fit}
|
||||
className="aspect-video rounded-xl border"
|
||||
/>
|
||||
{config.caption ? (
|
||||
<figcaption className="text-center text-sm text-muted-foreground">
|
||||
{config.caption}
|
||||
</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
case "gallery":
|
||||
return <GalleryBlock config={blockSchemas.gallery.parse(block.config)} />;
|
||||
case "tabs":
|
||||
return <TabsBlock config={blockSchemas.tabs.parse(block.config)} />;
|
||||
case "accordion":
|
||||
return <AccordionBlock config={blockSchemas.accordion.parse(block.config)} />;
|
||||
case "section": {
|
||||
const config = blockSchemas.section.parse(block.config);
|
||||
return (
|
||||
<header id={config.anchor ?? `section-${block.id}`} className="scroll-mt-36 space-y-1 pt-5">
|
||||
<h2 className="font-heading text-2xl font-semibold tracking-tight">{config.title}</h2>
|
||||
{config.description ? <p className="text-muted-foreground">{config.description}</p> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
case "columns": {
|
||||
const config = blockSchemas.columns.parse(block.config);
|
||||
return (
|
||||
<Alert>
|
||||
<InfoIcon aria-hidden="true" />
|
||||
<AlertTitle>เลย์เอาต์ {config.columns} คอลัมน์</AlertTitle>
|
||||
<AlertDescription>
|
||||
เนื้อหาถัดไปจัดวางตามพื้นที่หน้าจอและจะเรียงเป็นคอลัมน์เดียวบนมือถือ
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
case "links":
|
||||
return <LinksBlock config={blockSchemas.links.parse(block.config)} />;
|
||||
case "separator": {
|
||||
const config = blockSchemas.separator.parse(block.config);
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<Separator className="flex-1" />
|
||||
{config.label ? <span className="text-xs text-muted-foreground">{config.label}</span> : null}
|
||||
{config.label ? <Separator className="flex-1" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ProfileBlock({ config }: { config: Config<"profile"> }) {
|
||||
return (
|
||||
<Card className="overflow-hidden bg-card/80 md:grid md:grid-cols-[minmax(0,1fr)_18rem] md:py-0">
|
||||
<div className="order-2 space-y-4 px-5 py-6 md:order-1 md:px-8 md:py-10">
|
||||
{config.eyebrow ? (
|
||||
<p className="text-sm font-medium text-primary">{config.eyebrow}</p>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<h1 className="font-heading text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||
{config.title}
|
||||
</h1>
|
||||
{config.subtitle ? <p className="text-lg text-muted-foreground">{config.subtitle}</p> : null}
|
||||
</div>
|
||||
{config.summary ? <p className="max-w-2xl leading-7">{config.summary}</p> : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{config.badges.map((badge) => (
|
||||
<Badge
|
||||
key={`${badge.label}-${badge.value ?? ""}`}
|
||||
variant={badge.tone === "rarity" ? "secondary" : badge.tone === "neutral" ? "outline" : "default"}
|
||||
>
|
||||
{badge.label}{badge.value ? ` · ${badge.value}` : ""}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<MediaImage
|
||||
mediaId={config.mediaId}
|
||||
alt={`ภาพประกอบ ${config.title}`}
|
||||
priority
|
||||
className="order-1 aspect-video min-h-0 md:order-2 md:aspect-auto md:min-h-72 md:rounded-r-xl"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RichTextBlock({ config }: { config: Config<"rich-text"> }) {
|
||||
return (
|
||||
<div className="space-y-4 text-base leading-8 text-foreground/90">
|
||||
{config.content.map((node, index) => {
|
||||
if (node.type === "paragraph") return <p key={index}>{node.text}</p>;
|
||||
if (node.type === "quote") {
|
||||
return <blockquote key={index} className="border-l-2 border-primary pl-4 text-muted-foreground">{node.text}</blockquote>;
|
||||
}
|
||||
const List = node.type === "numbered-list" ? "ol" : "ul";
|
||||
return (
|
||||
<List key={index} className={cn("space-y-2 pl-6", node.type === "numbered-list" ? "list-decimal" : "list-disc")}>
|
||||
{node.items.map((item, itemIndex) => <li key={itemIndex}>{item}</li>)}
|
||||
</List>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalloutBlock({ config }: { config: Config<"callout"> }) {
|
||||
const icon = {
|
||||
info: <InfoIcon aria-hidden="true" />,
|
||||
tip: <LightbulbIcon aria-hidden="true" />,
|
||||
warning: <TriangleAlertIcon aria-hidden="true" />,
|
||||
danger: <AlertCircleIcon aria-hidden="true" />,
|
||||
}[config.tone];
|
||||
return (
|
||||
<Alert variant={config.tone === "danger" ? "destructive" : "default"} className={cn(config.tone === "tip" && "border-success/40", config.tone === "warning" && "border-warning/40")}>
|
||||
{icon}
|
||||
{config.title ? <AlertTitle>{config.title}</AlertTitle> : null}
|
||||
<AlertDescription>{config.body}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCardsBlock({ config }: { config: Config<"stat-cards"> }) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
{config.title ? <h3 className="font-heading text-lg font-medium">{config.title}</h3> : null}
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{config.items.map((item) => (
|
||||
<Card key={item.id} size="sm">
|
||||
<CardHeader>
|
||||
<CardDescription>{item.label}</CardDescription>
|
||||
<CardTitle className="font-mono text-2xl tabular-nums">
|
||||
{item.value}<span className="ml-1 font-sans text-sm text-muted-foreground">{item.suffix}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{item.description ? <CardContent className="text-muted-foreground">{item.description}</CardContent> : null}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyValueBlock({ config }: { config: Config<"key-value"> }) {
|
||||
return (
|
||||
<Card>
|
||||
{config.title ? <CardHeader><CardTitle>{config.title}</CardTitle></CardHeader> : null}
|
||||
<CardContent className={cn("grid gap-x-6 gap-y-4", config.columns === 2 && "sm:grid-cols-2", config.columns === 3 && "sm:grid-cols-2 lg:grid-cols-3")}>
|
||||
{config.items.map((item) => (
|
||||
<div key={item.id} className="space-y-1 border-b pb-3 last:border-b-0">
|
||||
<dt className="text-sm text-muted-foreground">{item.label}</dt>
|
||||
<dd className="font-medium">{item.value}</dd>
|
||||
{item.description ? <p className="text-sm text-muted-foreground">{item.description}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RankingBlock({ config, source }: { config: Config<"ranking">; source?: DataSourceSnapshot }) {
|
||||
const evaluated = evaluatedSource(source);
|
||||
if (!evaluated) return <DataEmpty title={config.title} />;
|
||||
const rows = evaluated.rows.filter((row) => !row.hidden).map((row) => {
|
||||
const cell = row.cells[config.valueField];
|
||||
let numeric: Decimal | null = null;
|
||||
try { if (!cell?.error && cell?.canonical != null) numeric = new Decimal(cell.canonical); } catch { numeric = null; }
|
||||
return { row, numeric };
|
||||
});
|
||||
const valid = rows.filter((row): row is typeof row & { numeric: Decimal } => row.numeric !== null);
|
||||
valid.sort((left, right) => (config.higherIsBetter ? right.numeric.comparedTo(left.numeric) : left.numeric.comparedTo(right.numeric)));
|
||||
const max = Decimal.max(...valid.map((row) => row.numeric.abs()), new Decimal(1));
|
||||
if (valid.length === 0) return <DataEmpty title={config.title} />;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{config.title}</CardTitle>
|
||||
{config.description ? <CardDescription>{config.description}</CardDescription> : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{valid.map(({ row, numeric }, index) => {
|
||||
const label = displayCell(evaluated, row.id, config.labelField);
|
||||
const detail = config.detailField ? displayCell(evaluated, row.id, config.detailField) : null;
|
||||
return (
|
||||
<div key={row.id} className="grid gap-2 sm:grid-cols-[2rem_minmax(9rem,1fr)_minmax(8rem,2fr)_auto] sm:items-center">
|
||||
<span className="font-mono text-sm text-muted-foreground">#{index + 1}</span>
|
||||
<div className="min-w-0">
|
||||
{detail && detail !== "—" ? <TermDetail label={label} detail={detail} /> : <span className="font-medium">{label}</span>}
|
||||
{config.badgeField ? <Badge variant="outline" className="ml-2">{displayCell(evaluated, row.id, config.badgeField)}</Badge> : null}
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted" aria-hidden="true">
|
||||
<div className="h-full rounded-full bg-chart-1" style={{ width: `${numeric.abs().div(max).times(100).toDecimalPlaces(2).toString()}%` }} />
|
||||
</div>
|
||||
<span className="font-mono text-sm tabular-nums">{row.cells[config.valueField].canonical}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamBlock({ config, source }: { config: Config<"team-composition">; source?: DataSourceSnapshot }) {
|
||||
const evaluated = evaluatedSource(source);
|
||||
if (!evaluated) return <DataEmpty title={config.title} />;
|
||||
const rows = evaluated.rows.filter((row) => !row.hidden);
|
||||
if (rows.length === 0) return <DataEmpty title={config.title} />;
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-heading text-lg font-medium">{config.title}</h3>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{rows.map((row) => (
|
||||
<Card key={row.id} size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle>{displayCell(evaluated, row.id, config.teamField)}</CardTitle>
|
||||
{config.totalField ? <CardAction><Badge>{displayCell(evaluated, row.id, config.totalField)}</Badge></CardAction> : null}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
{config.memberFields.map((field) => <Badge key={field} variant="secondary">{displayCell(evaluated, row.id, field)}</Badge>)}
|
||||
{config.notesField ? <p className="w-full pt-2 text-sm text-muted-foreground">{displayCell(evaluated, row.id, config.notesField)}</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RotationBlock({ config }: { config: Config<"rotation"> }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>{config.title}</CardTitle>{config.description ? <CardDescription>{config.description}</CardDescription> : null}</CardHeader>
|
||||
<CardContent><ol className="space-y-5">{config.steps.map((step, index) => (
|
||||
<li key={step.id} className="grid grid-cols-[2.25rem_1fr] gap-3">
|
||||
<span className="grid size-9 place-items-center rounded-full bg-primary font-mono text-sm font-semibold text-primary-foreground">{index + 1}</span>
|
||||
<div className="min-w-0 space-y-1 pt-1">
|
||||
<div className="flex flex-wrap items-center gap-2"><span className="font-medium">{step.action}</span>{step.actor ? <Badge variant="secondary">{step.actor}</Badge> : null}{step.duration ? <Badge variant="outline"><Clock3Icon aria-hidden="true" />{step.duration}</Badge> : null}</div>
|
||||
{step.detail ? <p className="text-sm leading-6 text-muted-foreground">{step.detail}</p> : null}
|
||||
</div>
|
||||
</li>
|
||||
))}</ol></CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTableBlock({ config, source }: { config: Config<"table">; source?: DataSourceSnapshot }) {
|
||||
const evaluated = evaluatedSource(source);
|
||||
if (!evaluated) return <DataEmpty title={config.title ?? "ตารางข้อมูล"} />;
|
||||
const rows = evaluated.rows.filter((row) => !row.hidden);
|
||||
if (rows.length === 0) return <DataEmpty title={config.title ?? "ตารางข้อมูล"} />;
|
||||
return (
|
||||
<Card>
|
||||
{config.title ? <CardHeader><CardTitle>{config.title}</CardTitle></CardHeader> : null}
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableCaption className="sr-only">{config.title ?? source?.name ?? "ตารางข้อมูล"}</TableCaption>
|
||||
<TableHeader><TableRow>{config.columns.map((column) => <TableHead key={column.field} scope="col" className={column.align === "end" ? "text-right" : column.align === "center" ? "text-center" : undefined}>{column.label}</TableHead>)}</TableRow></TableHeader>
|
||||
<TableBody>{rows.map((row) => <TableRow key={row.id}>{config.columns.map((column) => <TableCell key={column.field} className={cn(column.align === "end" && "text-right", column.align === "center" && "text-center", source?.fields.find((field) => field.key === column.field)?.type === "number" && "font-mono tabular-nums")}>{displayCell(evaluated, row.id, column.field)}</TableCell>)}</TableRow>)}</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonBlock({ config, source }: { config: Config<"comparison-chart">; source?: DataSourceSnapshot }) {
|
||||
const prepared = prepareComparisonChart(config, source);
|
||||
if (prepared.status === "empty") return <DataEmpty title={config.title ?? "กราฟเปรียบเทียบ"} description={prepared.message} />;
|
||||
if (prepared.status === "error") return <DataError title={config.title ?? "กราฟเปรียบเทียบ"} />;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>{config.title ?? "กราฟเปรียบเทียบ"}</CardTitle>{config.description ? <CardDescription>{config.description}</CardDescription> : null}</CardHeader>
|
||||
<CardContent><ComparisonChart prepared={prepared} /></CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function GalleryBlock({ config }: { config: Config<"gallery"> }) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
{config.title ? <h3 className="font-heading text-lg font-medium">{config.title}</h3> : null}
|
||||
<Carousel opts={{ loop: config.images.length > 1 }} className="px-11">
|
||||
<CarouselContent>{config.images.map((image) => <CarouselItem key={image.mediaId} className="md:basis-1/2"><figure className="space-y-2"><MediaImage mediaId={image.mediaId} alt={image.alt} className="aspect-video min-h-0 rounded-xl border" />{image.caption ? <figcaption className="text-sm text-muted-foreground">{image.caption}</figcaption> : null}</figure></CarouselItem>)}</CarouselContent>
|
||||
<CarouselPrevious className="left-1" /><CarouselNext className="right-1" />
|
||||
</Carousel>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsBlock({ config }: { config: Config<"tabs"> }) {
|
||||
return (
|
||||
<Card>
|
||||
{config.title ? <CardHeader><CardTitle>{config.title}</CardTitle></CardHeader> : null}
|
||||
<CardContent><Tabs defaultValue={config.tabs[0].id}><TabsList className="max-w-full overflow-x-auto">{config.tabs.map((tab) => <TabsTrigger key={tab.id} value={tab.id}>{tab.label}</TabsTrigger>)}</TabsList>{config.tabs.map((tab) => <TabsContent key={tab.id} value={tab.id} className="pt-4 leading-7">{tab.content}</TabsContent>)}</Tabs></CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionBlock({ config }: { config: Config<"accordion"> }) {
|
||||
return (
|
||||
<Card>
|
||||
{config.title ? <CardHeader><CardTitle>{config.title}</CardTitle></CardHeader> : null}
|
||||
<CardContent><Accordion>{config.items.map((item) => <AccordionItem key={item.id} value={item.id}><AccordionTrigger>{item.title}</AccordionTrigger><AccordionContent className="leading-7 text-muted-foreground">{item.content}</AccordionContent></AccordionItem>)}</Accordion></CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LinksBlock({ config }: { config: Config<"links"> }) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
{config.title ? <h3 className="font-heading text-lg font-medium">{config.title}</h3> : null}
|
||||
<div className="grid gap-3 sm:grid-cols-2">{config.links.map((link) => <Card key={link.id} size="sm"><CardHeader><CardTitle><Link href={link.href} className="inline-flex items-center gap-2 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">{link.label}<ArrowUpRightIcon aria-hidden="true" className="size-4" /></Link></CardTitle>{link.description ? <CardDescription>{link.description}</CardDescription> : null}</CardHeader></Card>)}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DataEmpty({ title, description = "ยังไม่มีข้อมูลสำหรับส่วนนี้" }: { title: string; description?: string }) {
|
||||
return <Alert><InfoIcon aria-hidden="true" /><AlertTitle>{title}</AlertTitle><AlertDescription>{description}</AlertDescription></Alert>;
|
||||
}
|
||||
|
||||
function DataError({ title }: { title: string }) {
|
||||
return <Alert variant="destructive"><AlertCircleIcon aria-hidden="true" /><AlertTitle>{title}</AlertTitle><AlertDescription>ข้อมูลบางรายการไม่สามารถแสดงผลได้ กรุณากลับมาตรวจสอบภายหลัง</AlertDescription></Alert>;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { SearchIcon, SparklesIcon, XIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { MediaImage } from "@/components/public/media-image";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group";
|
||||
import type { PublicCharacterCard } from "@/lib/content/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CharacterDirectoryProps {
|
||||
characters: PublicCharacterCard[];
|
||||
}
|
||||
|
||||
function unique(values: Array<string | null>): string[] {
|
||||
return [...new Set(values.filter((value): value is string => Boolean(value)))];
|
||||
}
|
||||
|
||||
export function CharacterDirectory({ characters }: CharacterDirectoryProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [element, setElement] = useState<string | null>(null);
|
||||
const [role, setRole] = useState<string | null>(null);
|
||||
const elements = useMemo(
|
||||
() => unique(characters.map((character) => character.element)),
|
||||
[characters],
|
||||
);
|
||||
const roles = useMemo(
|
||||
() => unique(characters.map((character) => character.role)),
|
||||
[characters],
|
||||
);
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase("th");
|
||||
const filtered = characters.filter((character) => {
|
||||
const searchable = `${character.nameTh} ${character.nameEn ?? ""}`.toLocaleLowerCase("th");
|
||||
return (
|
||||
(!normalizedQuery || searchable.includes(normalizedQuery)) &&
|
||||
(!element || character.element === element) &&
|
||||
(!role || character.role === role)
|
||||
);
|
||||
});
|
||||
const hasFilters = Boolean(query || element || role);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<InputGroup className="h-11 max-w-2xl bg-card">
|
||||
<InputGroupAddon>
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="ค้นหาชื่อตัวละคร…"
|
||||
aria-label="ค้นหาตัวละคร"
|
||||
/>
|
||||
{query ? (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="ล้างคำค้นหา"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</InputGroupAddon>
|
||||
) : null}
|
||||
</InputGroup>
|
||||
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-3 text-sm">
|
||||
{elements.length > 0 ? (
|
||||
<FilterGroup
|
||||
label="ธาตุ"
|
||||
values={elements}
|
||||
selected={element}
|
||||
onChange={setElement}
|
||||
/>
|
||||
) : null}
|
||||
{roles.length > 0 ? (
|
||||
<FilterGroup
|
||||
label="บทบาท"
|
||||
values={roles}
|
||||
selected={role}
|
||||
onChange={setRole}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length > 0 ? (
|
||||
<div
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
aria-live="polite"
|
||||
>
|
||||
{filtered.map((character) => (
|
||||
<Link
|
||||
key={character.id}
|
||||
href={`/${character.slug}/${character.firstPageSlug}`}
|
||||
className="group rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Card className="h-full gap-0 py-0 transition-transform group-hover:-translate-y-0.5 group-focus-visible:ring-2 group-focus-visible:ring-ring">
|
||||
<MediaImage
|
||||
mediaId={character.portraitMediaId}
|
||||
alt={`ภาพ ${character.nameTh}`}
|
||||
className="aspect-[4/3] min-h-0 rounded-t-xl"
|
||||
/>
|
||||
<CardHeader className="pt-4">
|
||||
<CardTitle className="text-lg">{character.nameTh}</CardTitle>
|
||||
<CardDescription>{character.nameEn ?? "คู่มือตัวละคร"}</CardDescription>
|
||||
{character.rarity ? (
|
||||
<CardAction>
|
||||
<Badge variant="secondary">{character.rarity}★</Badge>
|
||||
</CardAction>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2 pb-4">
|
||||
{character.element ? <Badge>{character.element}</Badge> : null}
|
||||
{character.role ? (
|
||||
<Badge variant="outline">{character.role}</Badge>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty className="border bg-card/40" aria-live="polite">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SparklesIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>ไม่พบตัวละครที่ตรงกัน</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
ลองใช้ชื่ออื่นหรือเอาตัวกรองบางรายการออก
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
{hasFilters ? (
|
||||
<EmptyContent>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setElement(null);
|
||||
setRole(null);
|
||||
}}
|
||||
>
|
||||
ล้างตัวกรอง
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
) : null}
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterGroup({
|
||||
label,
|
||||
values,
|
||||
selected,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
values: string[];
|
||||
selected: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2" role="group" aria-label={label}>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
{values.map((value) => (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
size="xs"
|
||||
variant={selected === value ? "secondary" : "ghost"}
|
||||
aria-pressed={selected === value}
|
||||
className={cn(selected === value && "text-primary")}
|
||||
onClick={() => onChange(selected === value ? null : value)}
|
||||
>
|
||||
{value}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
LabelList,
|
||||
Line,
|
||||
LineChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { PreparedComparisonChart } from "@/lib/charts/comparison";
|
||||
|
||||
interface ComparisonChartProps {
|
||||
prepared: PreparedComparisonChart;
|
||||
}
|
||||
|
||||
function suffix(normalization: PreparedComparisonChart["config"]["normalization"]) {
|
||||
return normalization === "percent-of-total" ? "%" : "";
|
||||
}
|
||||
|
||||
export function ComparisonChart({ prepared }: ComparisonChartProps) {
|
||||
const { config, rows } = prepared;
|
||||
const chartConfig: ChartConfig = Object.fromEntries(
|
||||
config.series.map((series) => [
|
||||
series.field,
|
||||
{ label: series.label, color: `var(--${series.color})` },
|
||||
]),
|
||||
);
|
||||
const data = rows.map((row) => ({
|
||||
category: row.category,
|
||||
...row.values,
|
||||
}));
|
||||
const valueSuffix = suffix(config.normalization);
|
||||
const chartHeight = Math.max(
|
||||
config.presentation === "horizontal-bar" ? rows.length * 54 : 320,
|
||||
280,
|
||||
);
|
||||
const common = {
|
||||
data,
|
||||
accessibilityLayer: true,
|
||||
margin: { top: 20, right: 28, left: 8, bottom: 12 },
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="min-h-72 w-full aspect-auto"
|
||||
style={{ height: chartHeight }}
|
||||
aria-label={config.title ?? "กราฟเปรียบเทียบ"}
|
||||
>
|
||||
{config.presentation === "line" ? (
|
||||
<LineChart {...common}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="category" tickLine={false} axisLine={false} minTickGap={16} />
|
||||
<YAxis tickLine={false} axisLine={false} width={48} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{config.showLegend ? (
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
) : null}
|
||||
{config.series.map((series) => (
|
||||
<Line
|
||||
key={series.field}
|
||||
dataKey={series.field}
|
||||
type="monotone"
|
||||
stroke={`var(--color-${series.field})`}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4, fill: `var(--color-${series.field})` }}
|
||||
activeDot={{ r: 6 }}
|
||||
>
|
||||
{config.showValues ? (
|
||||
<LabelList
|
||||
position="top"
|
||||
className="fill-foreground font-mono text-[10px]"
|
||||
formatter={(value: unknown) => `${String(value)}${valueSuffix}`}
|
||||
/>
|
||||
) : null}
|
||||
</Line>
|
||||
))}
|
||||
</LineChart>
|
||||
) : config.presentation === "horizontal-bar" ? (
|
||||
<BarChart {...common} layout="vertical">
|
||||
<CartesianGrid horizontal={false} />
|
||||
<XAxis type="number" tickLine={false} axisLine={false} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="category"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={112}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{config.showLegend ? (
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
) : null}
|
||||
{config.series.map((series) => (
|
||||
<Bar
|
||||
key={series.field}
|
||||
dataKey={series.field}
|
||||
fill={`var(--color-${series.field})`}
|
||||
radius={4}
|
||||
>
|
||||
{config.showValues ? (
|
||||
<LabelList
|
||||
position="right"
|
||||
className="fill-foreground font-mono text-[10px]"
|
||||
formatter={(value: unknown) => `${String(value)}${valueSuffix}`}
|
||||
/>
|
||||
) : null}
|
||||
</Bar>
|
||||
))}
|
||||
</BarChart>
|
||||
) : (
|
||||
<BarChart {...common}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="category" tickLine={false} axisLine={false} minTickGap={16} />
|
||||
<YAxis tickLine={false} axisLine={false} width={48} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{config.showLegend ? (
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
) : null}
|
||||
{config.series.map((series) => (
|
||||
<Bar
|
||||
key={series.field}
|
||||
dataKey={series.field}
|
||||
stackId={config.presentation === "stacked-bar" ? "total" : undefined}
|
||||
fill={`var(--color-${series.field})`}
|
||||
radius={config.presentation === "stacked-bar" ? 0 : 4}
|
||||
>
|
||||
{config.showValues ? (
|
||||
<LabelList
|
||||
position="top"
|
||||
className="fill-foreground font-mono text-[10px]"
|
||||
formatter={(value: unknown) => `${String(value)}${valueSuffix}`}
|
||||
/>
|
||||
) : null}
|
||||
</Bar>
|
||||
))}
|
||||
</BarChart>
|
||||
)}
|
||||
</ChartContainer>
|
||||
|
||||
<Table>
|
||||
<TableCaption className="sr-only">
|
||||
ค่าที่ใช้ในกราฟ {config.title ?? "เปรียบเทียบ"}
|
||||
</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="col">หมวดหมู่</TableHead>
|
||||
{config.series.map((series) => (
|
||||
<TableHead key={series.field} scope="col" className="text-right">
|
||||
{series.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableHead scope="row">{row.category}</TableHead>
|
||||
{config.series.map((series) => (
|
||||
<TableCell key={series.field} className="text-right font-mono tabular-nums">
|
||||
{row.exactValues[series.field]}
|
||||
{config.normalization !== "raw" ? (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
({row.normalizedValues[series.field]}
|
||||
{valueSuffix})
|
||||
</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export function DirectorySkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{Array.from({ length: 8 }, (_, index) => (
|
||||
<Card key={index} className="gap-0 py-0">
|
||||
<Skeleton className="aspect-[4/3] rounded-b-none" />
|
||||
<CardHeader className="pt-4">
|
||||
<Skeleton className="h-5 w-2/3" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</CardHeader>
|
||||
<CardContent className="flex gap-2 pb-4">
|
||||
<Skeleton className="h-5 w-14" />
|
||||
<Skeleton className="h-5 w-20" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Link from "next/link";
|
||||
import { ChevronLeftIcon, ScrollTextIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
collectGuideSections,
|
||||
PublicBlockRenderer,
|
||||
} from "@/components/public/block-renderer";
|
||||
import { SectionDrawer } from "@/components/public/section-drawer";
|
||||
import { SiteHeader } from "@/components/public/site-header";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import type { PageSnapshot } from "@/lib/content/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function GuideShell({ snapshot }: { snapshot: PageSnapshot }) {
|
||||
const sections = collectGuideSections(snapshot.blocks);
|
||||
|
||||
return (
|
||||
<div className="min-h-svh bg-background">
|
||||
<SiteHeader />
|
||||
<div className="sticky top-0 z-30 border-b bg-background/95 supports-backdrop-filter:backdrop-blur">
|
||||
<div className="mx-auto w-full max-w-7xl px-4 sm:px-6">
|
||||
<div className="flex min-h-14 items-center gap-3 py-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="grid size-10 shrink-0 place-items-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="กลับไปหน้ารวมตัวละคร"
|
||||
>
|
||||
<ChevronLeftIcon aria-hidden="true" />
|
||||
</Link>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-heading font-semibold">{snapshot.character.nameTh}</p>
|
||||
{snapshot.character.nameEn ? (
|
||||
<p className="truncate text-xs text-muted-foreground">{snapshot.character.nameEn}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{snapshot.character.element ? <Badge>{snapshot.character.element}</Badge> : null}
|
||||
{snapshot.character.role ? (
|
||||
<Badge variant="outline" className="hidden sm:inline-flex">
|
||||
{snapshot.character.role}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<nav
|
||||
aria-label={`หน้าคู่มือ ${snapshot.character.nameTh}`}
|
||||
className="flex min-h-11 gap-1 overflow-x-auto border-t [scrollbar-width:none]"
|
||||
>
|
||||
{snapshot.pages.map((page) => (
|
||||
<Link
|
||||
key={page.id}
|
||||
href={`/${snapshot.character.slug}/${page.slug}`}
|
||||
aria-current={page.id === snapshot.page.id ? "page" : undefined}
|
||||
className={cn(
|
||||
"relative flex min-h-11 shrink-0 items-center rounded-md px-3 text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
|
||||
page.id === snapshot.page.id &&
|
||||
"font-medium text-foreground after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:bg-primary",
|
||||
)}
|
||||
>
|
||||
{page.title}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto grid w-full max-w-7xl gap-8 px-4 py-6 sm:px-6 lg:grid-cols-[13rem_minmax(0,1fr)] lg:py-10">
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-32 space-y-3">
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
<ScrollTextIcon aria-hidden="true" className="size-4 text-primary" />
|
||||
สารบัญหน้านี้
|
||||
</p>
|
||||
<Separator />
|
||||
{sections.length > 0 ? (
|
||||
<nav aria-label="สารบัญหน้านี้" className="grid gap-1">
|
||||
{sections.map((section) => (
|
||||
<a
|
||||
key={section.id}
|
||||
href={`#${section.id}`}
|
||||
className="rounded-md px-2 py-2 text-sm leading-6 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{section.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
) : (
|
||||
<p className="text-sm leading-6 text-muted-foreground">หน้านี้ยังไม่มีหัวข้อย่อย</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0 space-y-6">
|
||||
{sections.length > 0 ? <SectionDrawer sections={sections} /> : null}
|
||||
{snapshot.page.publicNote ? (
|
||||
<Alert>
|
||||
<ScrollTextIcon aria-hidden="true" />
|
||||
<AlertTitle>หมายเหตุจากผู้เขียน</AlertTitle>
|
||||
<AlertDescription>{snapshot.page.publicNote}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<PublicBlockRenderer snapshot={snapshot} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { ImageOffIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MediaImageProps {
|
||||
mediaId: string | null | undefined;
|
||||
alt: string;
|
||||
className?: string;
|
||||
fit?: "cover" | "contain";
|
||||
priority?: boolean;
|
||||
}
|
||||
|
||||
export function MediaImage({
|
||||
mediaId,
|
||||
alt,
|
||||
className,
|
||||
fit = "cover",
|
||||
priority = false,
|
||||
}: MediaImageProps) {
|
||||
const [failedId, setFailedId] = useState<string | null>(null);
|
||||
const failed = !mediaId || failedId === mediaId;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative grid min-h-32 overflow-hidden bg-muted text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{failed ? (
|
||||
<div className="grid place-items-center gap-2 p-6 text-center">
|
||||
<ImageOffIcon aria-hidden="true" className="size-8 opacity-60" />
|
||||
<span className="text-xs">ไม่มีภาพ</span>
|
||||
</div>
|
||||
) : (
|
||||
<Image
|
||||
src={`/media/${mediaId}`}
|
||||
alt={alt}
|
||||
fill
|
||||
priority={priority}
|
||||
unoptimized
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
className={cn(
|
||||
fit === "cover" ? "object-cover" : "object-contain",
|
||||
)}
|
||||
onError={() => setFailedId(mediaId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { ListTreeIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
|
||||
export interface GuideSectionLink {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function SectionDrawer({ sections }: { sections: GuideSectionLink[] }) {
|
||||
return (
|
||||
<div className="lg:hidden">
|
||||
<Drawer showSwipeHandle>
|
||||
<DrawerTrigger render={<Button variant="outline" className="w-full" />}>
|
||||
<ListTreeIcon aria-hidden="true" />
|
||||
สารบัญหน้านี้
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>สารบัญหน้านี้</DrawerTitle>
|
||||
<DrawerDescription>เลือกหัวข้อเพื่อเลื่อนไปยังเนื้อหา</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<nav aria-label="สารบัญบนมือถือ" className="grid gap-1 overflow-y-auto p-4">
|
||||
{sections.map((section) => (
|
||||
<DrawerClose
|
||||
key={section.id}
|
||||
render={
|
||||
<a
|
||||
href={`#${section.id}`}
|
||||
className="flex min-h-11 items-center rounded-lg px-3 text-left text-sm hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{section.label}
|
||||
</DrawerClose>
|
||||
))}
|
||||
</nav>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Link from "next/link";
|
||||
import { BookOpenIcon, SettingsIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<header className="border-b bg-background/95 supports-backdrop-filter:backdrop-blur">
|
||||
<div className="mx-auto flex h-16 w-full max-w-7xl items-center justify-between px-4 sm:px-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex min-h-11 items-center gap-3 rounded-lg font-heading text-base 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" className="size-5" />
|
||||
</span>
|
||||
<span>
|
||||
Buzz Sheet
|
||||
<span className="ml-2 hidden font-normal text-muted-foreground sm:inline">
|
||||
คู่มือที่อ่านง่าย
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" render={<Link href="/admin" />}>
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
จัดการเนื้อหา
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
|
||||
const finePointerQuery = "(hover: hover) and (pointer: fine)";
|
||||
|
||||
function subscribe(callback: () => void) {
|
||||
const query = window.matchMedia(finePointerQuery);
|
||||
query.addEventListener("change", callback);
|
||||
return () => query.removeEventListener("change", callback);
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return window.matchMedia(finePointerQuery).matches;
|
||||
}
|
||||
|
||||
function Trigger({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="inline-flex min-h-11 cursor-help items-center gap-1 rounded-md text-left font-medium underline decoration-border underline-offset-4 outline-none hover:text-primary focus-visible:ring-2 focus-visible:ring-ring md:min-h-0">
|
||||
{label}
|
||||
<InfoIcon aria-hidden="true" className="size-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function TermDetail({
|
||||
label,
|
||||
detail,
|
||||
}: {
|
||||
label: string;
|
||||
detail: string;
|
||||
}) {
|
||||
const finePointer = useSyncExternalStore(subscribe, getSnapshot, () => false);
|
||||
|
||||
if (finePointer) {
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger aria-label={`ดูรายละเอียด ${label}`}>
|
||||
<Trigger label={label} />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start">
|
||||
<p className="font-medium">{label}</p>
|
||||
<p className="mt-1 text-muted-foreground">{detail}</p>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer showSwipeHandle>
|
||||
<DrawerTrigger aria-label={`เปิดรายละเอียด ${label}`}>
|
||||
<Trigger label={label} />
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{label}</DrawerTitle>
|
||||
<DrawerDescription>{detail}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="h-6" />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user