906 lines
28 KiB
TypeScript
906 lines
28 KiB
TypeScript
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>
|
|
);
|
|
}
|