fix : i think its ready to production
This commit is contained in:
@@ -97,18 +97,33 @@ export function EditorWorkspace({
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const initialDraft = draftFromSnapshot(snapshot);
|
||||
const [history, setHistory] = useState<HistoryState>({ items: [initialDraft], index: 0 });
|
||||
const [history, setHistory] = useState<HistoryState>({
|
||||
items: [initialDraft],
|
||||
index: 0,
|
||||
});
|
||||
const draft = history.items[history.index];
|
||||
const [selectedId, setSelectedId] = useState<string | null>(draft.blocks[0]?.id ?? null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(
|
||||
draft.blocks[0]?.id ?? null,
|
||||
);
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
const [viewport, setViewport] = useState<Viewport>("desktop");
|
||||
const [autosaveStatus, setAutosaveStatus] = useState<AutosaveStatus>({ state: "idle" });
|
||||
const [autosaveStatus, setAutosaveStatus] = useState<AutosaveStatus>({
|
||||
state: "idle",
|
||||
});
|
||||
const [dataSources, setDataSources] = useState(snapshot.dataSources);
|
||||
const [sourceSaving, setSourceSaving] = useState(false);
|
||||
const coordinatorRef = useRef<AutosaveCoordinator<EditorDraft, EditorSaveInput, PageSnapshot> | null>(null);
|
||||
const coordinatorRef = useRef<AutosaveCoordinator<
|
||||
EditorDraft,
|
||||
EditorSaveInput,
|
||||
PageSnapshot
|
||||
> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const coordinator = new AutosaveCoordinator<EditorDraft, EditorSaveInput, PageSnapshot>({
|
||||
const coordinator = new AutosaveCoordinator<
|
||||
EditorDraft,
|
||||
EditorSaveInput,
|
||||
PageSnapshot
|
||||
>({
|
||||
initialVersion: snapshot.page.version,
|
||||
createPayload: (next, expectedVersion) => ({ ...next, expectedVersion }),
|
||||
save: saveEditorPage,
|
||||
@@ -137,7 +152,10 @@ export function EditorWorkspace({
|
||||
|
||||
function commit(next: EditorDraft) {
|
||||
setHistory((current) => ({
|
||||
items: [...current.items.slice(0, current.index + 1), structuredClone(next)].slice(-80),
|
||||
items: [
|
||||
...current.items.slice(0, current.index + 1),
|
||||
structuredClone(next),
|
||||
].slice(-80),
|
||||
index: Math.min(current.index + 1, 79),
|
||||
}));
|
||||
coordinatorRef.current?.schedule(next);
|
||||
@@ -164,7 +182,10 @@ export function EditorWorkspace({
|
||||
setSourceSaving(false);
|
||||
if (result.status === "saved") {
|
||||
if (result.snapshot) {
|
||||
setDataSources((current) => ({ ...current, [source.id]: result.snapshot! }));
|
||||
setDataSources((current) => ({
|
||||
...current,
|
||||
[source.id]: result.snapshot!,
|
||||
}));
|
||||
}
|
||||
toast.success("บันทึกแหล่งข้อมูลแล้ว");
|
||||
} else if (result.status === "conflict") {
|
||||
@@ -203,7 +224,9 @@ export function EditorWorkspace({
|
||||
selectedId={selectedId}
|
||||
dataSources={dataSources}
|
||||
onDraftChange={commit}
|
||||
onDataSourceChange={(source) => setDataSources((current) => ({ ...current, [source.id]: source }))}
|
||||
onDataSourceChange={(source) =>
|
||||
setDataSources((current) => ({ ...current, [source.id]: source }))
|
||||
}
|
||||
onSaveDataSource={saveSource}
|
||||
sourceSaving={sourceSaving}
|
||||
onMediaReady={addUploadedMedia}
|
||||
@@ -222,13 +245,35 @@ export function EditorWorkspace({
|
||||
<SidebarTrigger />
|
||||
<Breadcrumb className="min-w-0 flex-1">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
<BreadcrumbItem className="truncate">{snapshot.character.nameTh}</BreadcrumbItem>
|
||||
<BreadcrumbItem className="truncate">
|
||||
{snapshot.character.nameTh}
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem className="min-w-0"><BreadcrumbPage className="truncate">{draft.title}</BreadcrumbPage></BreadcrumbItem>
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="truncate">
|
||||
{draft.title}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => travel(-1)} disabled={history.index === 0} aria-label="ย้อนกลับ"><Undo2Icon /></Button>
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => travel(1)} disabled={history.index === history.items.length - 1} aria-label="ทำซ้ำ"><Redo2Icon /></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => travel(-1)}
|
||||
disabled={history.index === 0}
|
||||
aria-label="ย้อนกลับ"
|
||||
>
|
||||
<Undo2Icon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => travel(1)}
|
||||
disabled={history.index === history.items.length - 1}
|
||||
aria-label="ทำซ้ำ"
|
||||
>
|
||||
<Redo2Icon />
|
||||
</Button>
|
||||
<SaveStatus status={autosaveStatus} />
|
||||
<label className="hidden min-h-8 items-center gap-2 text-sm sm:flex">
|
||||
<Switch
|
||||
@@ -249,21 +294,56 @@ export function EditorWorkspace({
|
||||
spacing={0}
|
||||
className="hidden lg:flex"
|
||||
>
|
||||
<ToggleGroupItem value="mobile" aria-label="ตัวอย่างมือถือ"><SmartphoneIcon /></ToggleGroupItem>
|
||||
<ToggleGroupItem value="tablet" aria-label="ตัวอย่างแท็บเล็ต"><TabletIcon /></ToggleGroupItem>
|
||||
<ToggleGroupItem value="desktop" aria-label="ตัวอย่างเดสก์ท็อป"><MonitorIcon /></ToggleGroupItem>
|
||||
<ToggleGroupItem value="mobile" aria-label="ตัวอย่างมือถือ">
|
||||
<SmartphoneIcon />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="tablet" aria-label="ตัวอย่างแท็บเล็ต">
|
||||
<TabletIcon />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="desktop" aria-label="ตัวอย่างเดสก์ท็อป">
|
||||
<MonitorIcon />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Button variant="outline" size="sm" onClick={() => setCommandOpen(true)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCommandOpen(true)}
|
||||
>
|
||||
<CommandIcon />
|
||||
<span className="hidden xl:inline">คำสั่ง</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" render={<Link href={`/${snapshot.character.slug}/${draft.slug}`} target="_blank" />} aria-label="เปิดหน้าสาธารณะ"><EyeIcon /></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
render={
|
||||
<Link
|
||||
href={`/${snapshot.character.slug}/${draft.slug}`}
|
||||
target="_blank"
|
||||
/>
|
||||
}
|
||||
aria-label="เปิดหน้าสาธารณะ"
|
||||
>
|
||||
<EyeIcon />
|
||||
</Button>
|
||||
<Drawer swipeDirection="right">
|
||||
<DrawerTrigger render={<Button variant="outline" size="icon-sm" className="lg:hidden" aria-label="เปิด Inspector" />}><PanelRightIcon /></DrawerTrigger>
|
||||
<DrawerTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="lg:hidden"
|
||||
aria-label="เปิด Inspector"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PanelRightIcon />
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="[--drawer-content-width:min(92vw,28rem)]">
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>Inspector และ Live Preview</DrawerTitle>
|
||||
<DrawerDescription>แก้ไขคุณสมบัติบล็อกและดูตัวอย่าง</DrawerDescription>
|
||||
<DrawerDescription>
|
||||
แก้ไขคุณสมบัติบล็อกและดูตัวอย่าง
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
{inspector}
|
||||
</DrawerContent>
|
||||
@@ -274,12 +354,14 @@ export function EditorWorkspace({
|
||||
<ResizablePanelGroup orientation="horizontal" className="h-full">
|
||||
<ResizablePanel defaultSize="70%" minSize="45%">
|
||||
<div className="h-full overflow-y-auto bg-muted/25">
|
||||
<div className={cn(
|
||||
"mx-auto min-h-full transition-[max-width]",
|
||||
viewport === "mobile" && "max-w-[390px] border-x",
|
||||
viewport === "tablet" && "max-w-[768px] border-x",
|
||||
viewport === "desktop" && "max-w-none",
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto min-h-full transition-[max-width]",
|
||||
viewport === "mobile" && "max-w-[390px] border-x",
|
||||
viewport === "tablet" && "max-w-[768px] border-x",
|
||||
viewport === "desktop" && "max-w-none",
|
||||
)}
|
||||
>
|
||||
<BlockCanvas
|
||||
draft={draft}
|
||||
selectedId={selectedId}
|
||||
@@ -291,7 +373,12 @@ export function EditorWorkspace({
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className="hidden lg:flex" />
|
||||
<ResizablePanel defaultSize="30%" minSize="22%" maxSize="48%" className="hidden lg:block">
|
||||
<ResizablePanel
|
||||
defaultSize="30%"
|
||||
minSize="22%"
|
||||
maxSize="48%"
|
||||
className="hidden lg:block"
|
||||
>
|
||||
{inspector}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
@@ -311,12 +398,16 @@ export function EditorWorkspace({
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>พบการแก้ไขที่ใหม่กว่า</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
เวอร์ชันบนเซิร์ฟเวอร์เปลี่ยนไประหว่างที่คุณกำลังทำงาน ฉบับในเครื่องยังไม่ถูกเขียนทับ คุณสามารถกลับไปตรวจเทียบหรือโหลดฉบับล่าสุด
|
||||
เวอร์ชันบนเซิร์ฟเวอร์เปลี่ยนไประหว่างที่คุณกำลังทำงาน
|
||||
ฉบับในเครื่องยังไม่ถูกเขียนทับ
|
||||
คุณสามารถกลับไปตรวจเทียบหรือโหลดฉบับล่าสุด
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>กลับไปตรวจเทียบ</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => router.refresh()}>โหลดเวอร์ชันล่าสุด</AlertDialogAction>
|
||||
<AlertDialogAction onClick={() => router.refresh()}>
|
||||
โหลดเวอร์ชันล่าสุด
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
@@ -325,10 +416,24 @@ export function EditorWorkspace({
|
||||
}
|
||||
|
||||
function SaveStatus({ status }: { status: AutosaveStatus }) {
|
||||
if (status.state === "saving") return <Badge variant="secondary"><Spinner />กำลังบันทึก</Badge>;
|
||||
if (status.state === "dirty") return <Badge variant="outline">รอบันทึก</Badge>;
|
||||
if (status.state === "saved") return <Badge variant="outline" className="text-success">บันทึกแล้ว · v{status.version}</Badge>;
|
||||
if (status.state === "conflict") return <Badge variant="destructive">ข้อมูลชนกัน</Badge>;
|
||||
if (status.state === "error") return <Badge variant="destructive">บันทึกไม่สำเร็จ</Badge>;
|
||||
if (status.state === "saving")
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
<Spinner />
|
||||
กำลังบันทึก
|
||||
</Badge>
|
||||
);
|
||||
if (status.state === "dirty")
|
||||
return <Badge variant="outline">รอบันทึก</Badge>;
|
||||
if (status.state === "saved")
|
||||
return (
|
||||
<Badge variant="outline" className="text-success">
|
||||
บันทึกแล้ว · v{status.version}
|
||||
</Badge>
|
||||
);
|
||||
if (status.state === "conflict")
|
||||
return <Badge variant="destructive">ข้อมูลชนกัน</Badge>;
|
||||
if (status.state === "error")
|
||||
return <Badge variant="destructive">บันทึกไม่สำเร็จ</Badge>;
|
||||
return <Badge variant="outline">พร้อมแก้ไข</Badge>;
|
||||
}
|
||||
|
||||
@@ -95,11 +95,18 @@ export function collectGuideSections(
|
||||
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 }];
|
||||
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 [
|
||||
{ id: config.anchor ?? `section-${serialized.id}`, label: config.text },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
@@ -113,7 +120,11 @@ export function PublicBlockRenderer({ snapshot }: { snapshot: PageSnapshot }) {
|
||||
if (resolution.kind !== "ready") {
|
||||
return (
|
||||
<div key={serialized.id} className="mx-auto max-w-3xl">
|
||||
<Alert variant={resolution.kind === "invalid" ? "destructive" : "default"}>
|
||||
<Alert
|
||||
variant={
|
||||
resolution.kind === "invalid" ? "destructive" : "default"
|
||||
}
|
||||
>
|
||||
<AlertCircleIcon aria-hidden="true" />
|
||||
<AlertTitle>ไม่สามารถแสดงบล็อกนี้ได้</AlertTitle>
|
||||
<AlertDescription>
|
||||
@@ -164,13 +175,18 @@ function ReadyBlock({
|
||||
case "profile":
|
||||
return <ProfileBlock config={blockSchemas.profile.parse(block.config)} />;
|
||||
case "rich-text":
|
||||
return <RichTextBlock config={blockSchemas["rich-text"].parse(block.config)} />;
|
||||
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">
|
||||
<Heading
|
||||
id={id}
|
||||
className="scroll-mt-36 font-heading text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
{config.text}
|
||||
</Heading>
|
||||
);
|
||||
@@ -178,9 +194,15 @@ function ReadyBlock({
|
||||
case "callout":
|
||||
return <CalloutBlock config={blockSchemas.callout.parse(block.config)} />;
|
||||
case "stat-cards":
|
||||
return <StatCardsBlock config={blockSchemas["stat-cards"].parse(block.config)} />;
|
||||
return (
|
||||
<StatCardsBlock
|
||||
config={blockSchemas["stat-cards"].parse(block.config)}
|
||||
/>
|
||||
);
|
||||
case "key-value":
|
||||
return <KeyValueBlock config={blockSchemas["key-value"].parse(block.config)} />;
|
||||
return (
|
||||
<KeyValueBlock config={blockSchemas["key-value"].parse(block.config)} />
|
||||
);
|
||||
case "ranking":
|
||||
return (
|
||||
<RankingBlock
|
||||
@@ -192,11 +214,17 @@ function ReadyBlock({
|
||||
return (
|
||||
<TeamBlock
|
||||
config={blockSchemas["team-composition"].parse(block.config)}
|
||||
source={dataSources[(block.config as Config<"team-composition">).dataSourceId]}
|
||||
source={
|
||||
dataSources[
|
||||
(block.config as Config<"team-composition">).dataSourceId
|
||||
]
|
||||
}
|
||||
/>
|
||||
);
|
||||
case "rotation":
|
||||
return <RotationBlock config={blockSchemas.rotation.parse(block.config)} />;
|
||||
return (
|
||||
<RotationBlock config={blockSchemas.rotation.parse(block.config)} />
|
||||
);
|
||||
case "table":
|
||||
return (
|
||||
<DataTableBlock
|
||||
@@ -206,7 +234,12 @@ function ReadyBlock({
|
||||
);
|
||||
case "comparison-chart": {
|
||||
const config = blockSchemas["comparison-chart"].parse(block.config);
|
||||
return <ComparisonBlock config={config} source={dataSources[config.dataSourceId]} />;
|
||||
return (
|
||||
<ComparisonBlock
|
||||
config={config}
|
||||
source={dataSources[config.dataSourceId]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "image": {
|
||||
const config = blockSchemas.image.parse(block.config);
|
||||
@@ -231,13 +264,22 @@ function ReadyBlock({
|
||||
case "tabs":
|
||||
return <TabsBlock config={blockSchemas.tabs.parse(block.config)} />;
|
||||
case "accordion":
|
||||
return <AccordionBlock config={blockSchemas.accordion.parse(block.config)} />;
|
||||
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
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -260,7 +302,11 @@ function ReadyBlock({
|
||||
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 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{config.label}
|
||||
</span>
|
||||
) : null}
|
||||
{config.label ? <Separator className="flex-1" /> : null}
|
||||
</div>
|
||||
);
|
||||
@@ -279,16 +325,27 @@ function ProfileBlock({ config }: { config: Config<"profile"> }) {
|
||||
<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}
|
||||
{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}
|
||||
{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"}
|
||||
variant={
|
||||
badge.tone === "rarity"
|
||||
? "secondary"
|
||||
: badge.tone === "neutral"
|
||||
? "outline"
|
||||
: "default"
|
||||
}
|
||||
>
|
||||
{badge.label}{badge.value ? ` · ${badge.value}` : ""}
|
||||
{badge.label}
|
||||
{badge.value ? ` · ${badge.value}` : ""}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -309,12 +366,27 @@ function RichTextBlock({ config }: { config: Config<"rich-text"> }) {
|
||||
{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>;
|
||||
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
|
||||
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>
|
||||
);
|
||||
})}
|
||||
@@ -330,7 +402,13 @@ function CalloutBlock({ config }: { config: Config<"callout"> }) {
|
||||
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")}>
|
||||
<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>
|
||||
@@ -341,17 +419,26 @@ function CalloutBlock({ config }: { config: Config<"callout"> }) {
|
||||
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}
|
||||
{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>
|
||||
{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}
|
||||
{item.description ? (
|
||||
<CardContent className="text-muted-foreground">
|
||||
{item.description}
|
||||
</CardContent>
|
||||
) : null}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
@@ -362,13 +449,30 @@ function StatCardsBlock({ config }: { config: Config<"stat-cards"> }) {
|
||||
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.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">
|
||||
<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}
|
||||
{item.description ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
@@ -376,40 +480,89 @@ function KeyValueBlock({ config }: { config: Config<"key-value"> }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RankingBlock({ config, source }: { config: Config<"ranking">; source?: DataSourceSnapshot }) {
|
||||
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));
|
||||
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}
|
||||
{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;
|
||||
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
|
||||
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}
|
||||
{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
|
||||
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>
|
||||
<span className="font-mono text-sm tabular-nums">
|
||||
{row.cells[config.valueField].canonical}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -418,7 +571,13 @@ function RankingBlock({ config, source }: { config: Config<"ranking">; source?:
|
||||
);
|
||||
}
|
||||
|
||||
function TeamBlock({ config, source }: { config: Config<"team-composition">; source?: DataSourceSnapshot }) {
|
||||
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);
|
||||
@@ -430,12 +589,28 @@ function TeamBlock({ config, source }: { config: Config<"team-composition">; sou
|
||||
{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}
|
||||
<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}
|
||||
{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>
|
||||
))}
|
||||
@@ -447,47 +622,142 @@ function TeamBlock({ config, source }: { config: Config<"team-composition">; sou
|
||||
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>
|
||||
<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 }) {
|
||||
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 ?? "ตารางข้อมูล"} />;
|
||||
if (rows.length === 0)
|
||||
return <DataEmpty title={config.title ?? "ตารางข้อมูล"} />;
|
||||
return (
|
||||
<Card>
|
||||
{config.title ? <CardHeader><CardTitle>{config.title}</CardTitle></CardHeader> : null}
|
||||
{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>
|
||||
<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 }) {
|
||||
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 ?? "กราฟเปรียบเทียบ"} />;
|
||||
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>
|
||||
<CardHeader>
|
||||
<CardTitle>{config.title ?? "กราฟเปรียบเทียบ"}</CardTitle>
|
||||
{config.description ? (
|
||||
<CardDescription>{config.description}</CardDescription>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ComparisonChart prepared={prepared} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -495,10 +765,30 @@ function ComparisonBlock({ config, source }: { config: Config<"comparison-chart"
|
||||
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}
|
||||
{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" />
|
||||
<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>
|
||||
);
|
||||
@@ -507,8 +797,27 @@ function GalleryBlock({ config }: { config: Config<"gallery"> }) {
|
||||
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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -516,8 +825,23 @@ function TabsBlock({ config }: { config: Config<"tabs"> }) {
|
||||
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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -525,16 +849,57 @@ function AccordionBlock({ config }: { config: Config<"accordion"> }) {
|
||||
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>
|
||||
{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 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>;
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircleIcon aria-hidden="true" />
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
<AlertDescription>
|
||||
ข้อมูลบางรายการไม่สามารถแสดงผลได้ กรุณากลับมาตรวจสอบภายหลัง
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ export function CharacterDirectory({ characters }: CharacterDirectoryProps) {
|
||||
<MediaImage
|
||||
mediaId={character.portraitMediaId}
|
||||
alt={`ภาพ ${character.nameTh}`}
|
||||
className="aspect-[4/3] min-h-0 rounded-t-xl"
|
||||
className="aspect-4/3 min-h-0 rounded-t-xl"
|
||||
/>
|
||||
<CardHeader className="pt-4">
|
||||
<CardTitle className="text-lg">{character.nameTh}</CardTitle>
|
||||
|
||||
@@ -78,7 +78,7 @@ export function StructuredGuideShell({
|
||||
<div className="flex min-w-0 flex-1 flex-col items-start gap-1">
|
||||
<p className="truncate font-heading font-semibold">{guide.name}</p>
|
||||
{guide.character.element ? (
|
||||
<Badge>
|
||||
<Badge variant="secondary">
|
||||
{guide.character.elementIconUrl ? (
|
||||
<ElementIcon element={guide.character.element} url={guide.character.elementIconUrl} />
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user