feat : remove legacy feature
CI / Verify (push) Successful in 1m21s
CI / Build immutable images and deploy (push) Successful in 4m32s

This commit is contained in:
2026-09-10 19:45:06 +07:00 Unverified
parent cc0757d0a0
commit 249af29e9b
56 changed files with 5491 additions and 9393 deletions
-137
View File
@@ -1,137 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import {
BarChart3Icon,
FileTextIcon,
Heading2Icon,
ImageIcon,
LayoutGridIcon,
ListOrderedIcon,
MessageSquareIcon,
Rows3Icon,
SeparatorHorizontalIcon,
Table2Icon,
TextIcon,
UsersIcon,
type LucideIcon,
} from "lucide-react";
import type { AdminNavigation } from "@/components/admin/admin-sidebar";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import type { SerializedBlock } from "@/lib/blocks";
import type { DataSourceSnapshot } from "@/lib/content/types";
import { createEditorBlock } from "@/lib/editor/draft";
const catalog: Array<{
type: string;
label: string;
icon: LucideIcon;
source?: boolean;
media?: boolean;
}> = [
{ type: "profile", label: "โปรไฟล์", icon: LayoutGridIcon },
{ type: "rich-text", label: "ข้อความ", icon: TextIcon },
{ type: "heading", label: "หัวข้อ", icon: Heading2Icon },
{ type: "callout", label: "กล่องหมายเหตุ", icon: MessageSquareIcon },
{ type: "stat-cards", label: "การ์ดค่าสถานะ", icon: LayoutGridIcon },
{ type: "key-value", label: "ข้อมูลกำหนดเอง", icon: Rows3Icon },
{ type: "ranking", label: "อันดับเปรียบเทียบ", icon: ListOrderedIcon, source: true },
{ type: "team-composition", label: "องค์ประกอบทีม", icon: UsersIcon, source: true },
{ type: "rotation", label: "ลำดับคอมโบ", icon: ListOrderedIcon },
{ type: "table", label: "ตารางข้อมูล", icon: Table2Icon, source: true },
{ type: "comparison-chart", label: "กราฟเปรียบเทียบ", icon: BarChart3Icon, source: true },
{ type: "tabs", label: "แท็บ", icon: LayoutGridIcon },
{ type: "accordion", label: "แอคคอร์เดียน", icon: Rows3Icon },
{ type: "section", label: "ส่วนเนื้อหา", icon: Heading2Icon },
{ type: "columns", label: "คอลัมน์", icon: LayoutGridIcon },
{ type: "links", label: "ลิงก์", icon: FileTextIcon },
{ type: "separator", label: "เส้นคั่น", icon: SeparatorHorizontalIcon },
{ type: "image", label: "รูปภาพ", icon: ImageIcon, media: true },
{ type: "gallery", label: "แกลเลอรี", icon: ImageIcon, media: true },
];
export function AdminCommandDialog({
open,
onOpenChange,
navigation,
dataSources,
onInsert,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
navigation: AdminNavigation;
dataSources: Record<string, DataSourceSnapshot>;
onInsert: (block: SerializedBlock) => void;
}) {
const router = useRouter();
const source = Object.values(dataSources)[0];
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title="คำสั่งตัวแก้ไข"
description="ค้นหาหน้าหรือเพิ่มบล็อก"
className="max-w-xl"
>
<Command>
<CommandInput placeholder="ค้นหาหน้าหรือชนิดบล็อก…" />
<CommandList>
<CommandEmpty>ไม่พบคำสั่งที่ตรงกัน</CommandEmpty>
<CommandGroup heading="ไปยังหน้า">
{navigation.characters.flatMap((character) =>
character.pages.map((page) => (
<CommandItem
key={page.id}
value={`${character.nameTh} ${page.title}`}
onSelect={() => {
onOpenChange(false);
router.push(`/admin/${character.slug}/${page.slug}`);
}}
>
<FileTextIcon aria-hidden="true" />
<span>{character.nameTh} / {page.title}</span>
</CommandItem>
)),
)}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="เพิ่มบล็อก">
{catalog.map((item) => {
const disabled = (item.source && !source) || item.media;
return (
<CommandItem
key={item.type}
value={`เพิ่ม ${item.label} ${item.type}`}
disabled={disabled}
onSelect={() => {
const block = createEditorBlock(item.type, source);
if (!block) return;
onInsert(block);
onOpenChange(false);
}}
>
<item.icon aria-hidden="true" />
<span>{item.label}</span>
{item.media ? <CommandShortcut>เลือกจากคลังสื่อ</CommandShortcut> : null}
{item.source && !source ? <CommandShortcut>ต้องมีแหล่งข้อมูล</CommandShortcut> : null}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
);
}
-148
View File
@@ -1,148 +0,0 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
BookOpenIcon,
BoxesIcon,
ChevronRightIcon,
LayoutTemplateIcon,
LogOutIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarRail,
} from "@/components/ui/sidebar";
import type { CharacterIdentity, PageIdentity } from "@/lib/content/types";
import { authClient } from "@/lib/auth/client";
export interface AdminNavigation {
characters: Array<CharacterIdentity & { pages: PageIdentity[] }>;
templates: Array<{
id: string;
slug: string;
name: string;
description: string | null;
category: string;
builtIn: boolean;
}>;
}
export function AdminSidebar({
navigation,
currentCharacter,
currentPage,
}: {
navigation: AdminNavigation;
currentCharacter?: string;
currentPage?: string;
}) {
const router = useRouter();
return (
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
size="lg"
tooltip="Buzz Sheet"
render={<Link href="/admin" />}
>
<span className="grid size-8 place-items-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<BookOpenIcon aria-hidden="true" />
</span>
<span className="grid flex-1 text-left leading-tight">
<span className="font-semibold">Buzz Sheet</span>
<span className="text-xs text-sidebar-foreground/60">ตัวแก้ไขคู่มือ</span>
</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>ตัวละครและหน้า</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{navigation.characters.map((character) => (
<SidebarMenuItem key={character.id}>
<SidebarMenuButton
tooltip={character.nameTh}
isActive={character.slug === currentCharacter && !currentPage}
>
<BoxesIcon aria-hidden="true" />
<span>{character.nameTh}</span>
<ChevronRightIcon aria-hidden="true" className="ml-auto" />
</SidebarMenuButton>
<SidebarMenuSub>
{character.pages.map((page) => (
<SidebarMenuSubItem key={page.id}>
<SidebarMenuSubButton
render={<Link href={`/admin/${character.slug}/${page.slug}`} />}
isActive={character.slug === currentCharacter && page.slug === currentPage}
>
<span>{page.title}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>โครงสร้าง</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
tooltip="เทมเพลต"
render={<Link href="/admin/templates" />}
>
<LayoutTemplateIcon aria-hidden="true" />
<span>เทมเพลต</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<Button
variant="ghost"
className="w-full justify-start"
onClick={() =>
authClient.signOut({
fetchOptions: {
onSuccess: () => {
router.push("/admin/login");
router.refresh();
},
},
})
}
>
<LogOutIcon aria-hidden="true" />
<span className="group-data-[collapsible=icon]:hidden">ออกจากระบบ</span>
</Button>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}
-273
View File
@@ -1,273 +0,0 @@
"use client";
import {
ArrowDownIcon,
ArrowUpIcon,
CopyIcon,
GripVerticalIcon,
MoreHorizontalIcon,
PlusIcon,
Trash2Icon,
} from "lucide-react";
import { useState } from "react";
import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type { SerializedBlock } from "@/lib/blocks";
import {
duplicateBlock,
moveBlock,
moveBlockTo,
type EditorDraft,
} from "@/lib/editor/draft";
import { cn } from "@/lib/utils";
export const blockLabels: Record<string, string> = {
profile: "โปรไฟล์",
"rich-text": "ข้อความ",
heading: "หัวข้อ",
callout: "กล่องหมายเหตุ",
"stat-cards": "การ์ดค่าสถานะ",
"key-value": "ข้อมูลกำหนดเอง",
ranking: "อันดับเปรียบเทียบ",
"team-composition": "องค์ประกอบทีม",
rotation: "ลำดับคอมโบ",
table: "ตารางข้อมูล",
"comparison-chart": "กราฟเปรียบเทียบ",
image: "รูปภาพ",
gallery: "แกลเลอรี",
tabs: "แท็บ",
accordion: "แอคคอร์เดียน",
section: "ส่วนเนื้อหา",
columns: "คอลัมน์",
links: "ลิงก์",
separator: "เส้นคั่น",
};
interface BlockCanvasProps {
draft: EditorDraft;
selectedId: string | null;
onSelect: (id: string) => void;
onChange: (draft: EditorDraft) => void;
onOpenInsert: () => void;
}
export function BlockCanvas({
draft,
selectedId,
onSelect,
onChange,
onOpenInsert,
}: BlockCanvasProps) {
const changeBlocks = (blocks: SerializedBlock[]) => onChange({ ...draft, blocks });
const patchBlock = (blockId: string, config: Record<string, unknown>) => {
changeBlocks(
draft.blocks.map((block) =>
block.id === blockId ? { ...block, config: { ...block.config as object, ...config } } : block,
),
);
};
return (
<div className="mx-auto w-full max-w-4xl space-y-5 p-4 sm:p-6 lg:p-8">
<Card className="border-dashed bg-card/70">
<CardContent className="space-y-4 pt-1">
<FieldGroup>
<Field>
<FieldLabel htmlFor="page-title">ชื่อหน้า</FieldLabel>
<Input
id="page-title"
value={draft.title}
className="h-auto border-0 bg-transparent px-0 font-heading text-2xl font-semibold shadow-none focus-visible:ring-0"
onChange={(event) => onChange({ ...draft, title: event.target.value })}
/>
</Field>
<Field>
<FieldLabel htmlFor="public-note">หมายเหตุสาธารณะ</FieldLabel>
<Textarea
id="public-note"
value={draft.publicNote ?? ""}
placeholder="เพิ่มหมายเหตุที่จะแสดงบนหน้าสาธารณะ"
onChange={(event) => onChange({ ...draft, publicNote: event.target.value || null })}
/>
</Field>
</FieldGroup>
</CardContent>
</Card>
<div className="space-y-3" aria-label="บล็อกในหน้า">
{draft.blocks.map((block, index) => (
<EditableBlock
key={block.id}
block={block}
index={index}
total={draft.blocks.length}
selected={selectedId === block.id}
onSelect={() => onSelect(block.id)}
onPatch={(config) => patchBlock(block.id, config)}
onMove={(direction) => changeBlocks(moveBlock(draft.blocks, block.id, direction))}
onDrop={(draggedId) => changeBlocks(moveBlockTo(draft.blocks, draggedId, block.id))}
onDuplicate={() => changeBlocks(duplicateBlock(draft.blocks, block.id))}
onDelete={() => {
changeBlocks(draft.blocks.filter((candidate) => candidate.id !== block.id));
if (selectedId === block.id) onSelect("");
}}
/>
))}
</div>
<Button type="button" variant="outline" className="w-full border-dashed" onClick={onOpenInsert}>
<PlusIcon aria-hidden="true" />
เพิ่มบล็อก
</Button>
</div>
);
}
function EditableBlock({
block,
index,
total,
selected,
onSelect,
onPatch,
onMove,
onDrop,
onDuplicate,
onDelete,
}: {
block: SerializedBlock;
index: number;
total: number;
selected: boolean;
onSelect: () => void;
onPatch: (config: Record<string, unknown>) => void;
onMove: (direction: -1 | 1) => void;
onDrop: (draggedId: string) => void;
onDuplicate: () => void;
onDelete: () => void;
}) {
const [deleteOpen, setDeleteOpen] = useState(false);
const config = block.config as Record<string, unknown>;
return (
<>
<Card
tabIndex={0}
draggable
onClick={onSelect}
onFocus={onSelect}
onKeyDown={(event) => {
if (!event.altKey) return;
if (event.key === "ArrowUp") { event.preventDefault(); onMove(-1); }
if (event.key === "ArrowDown") { event.preventDefault(); onMove(1); }
}}
onDragStart={(event) => {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/buzz-block-id", block.id);
}}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
const draggedId = event.dataTransfer.getData("text/buzz-block-id");
if (draggedId) onDrop(draggedId);
}}
className={cn(
"group/editor-block scroll-mt-24 outline-none transition-shadow",
selected && "ring-2 ring-primary",
"focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-label={`${blockLabels[block.type] ?? block.type} ลำดับ ${index + 1}`}
>
<CardHeader className="border-b pb-3">
<div className="flex min-w-0 items-center gap-2">
<GripVerticalIcon aria-hidden="true" className="size-4 shrink-0 cursor-grab text-muted-foreground" />
<CardTitle className="truncate">{blockLabels[block.type] ?? block.type}</CardTitle>
<Badge variant="outline" className="ml-auto">{index + 1}</Badge>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={index === 0}
onClick={(event) => { event.stopPropagation(); onMove(-1); }}
aria-label="ย้ายบล็อกขึ้น"
><ArrowUpIcon /></Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={index === total - 1}
onClick={(event) => { event.stopPropagation(); onMove(1); }}
aria-label="ย้ายบล็อกลง"
><ArrowDownIcon /></Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type="button" variant="ghost" size="icon-sm" aria-label="การทำงานของบล็อก" />}
onClick={(event) => event.stopPropagation()}
><MoreHorizontalIcon /></DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onDuplicate}><CopyIcon />ทำสำเนา</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => setDeleteOpen(true)}><Trash2Icon />ลบบล็อก</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent onClick={(event) => event.stopPropagation()}>
<InlineBlockFields type={block.type} config={config} onPatch={onPatch} />
</CardContent>
</Card>
<DeleteConfirmationDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title="ลบบล็อกนี้หรือไม่"
description={`บล็อก “${blockLabels[block.type] ?? block.type}” จะถูกนำออกจากหน้า คุณยังย้อนกลับได้ก่อนประวัติการแก้ไขถูกรีเซ็ต`}
confirmLabel="ลบบล็อก"
onConfirm={onDelete}
/>
</>
);
}
function InlineBlockFields({
type,
config,
onPatch,
}: {
type: string;
config: Record<string, unknown>;
onPatch: (config: Record<string, unknown>) => void;
}) {
if (type === "profile") {
return <div className="space-y-3"><Input value={String(config.title ?? "")} aria-label="หัวข้อโปรไฟล์" onChange={(event) => onPatch({ title: event.target.value })} /><Textarea value={String(config.summary ?? "")} aria-label="คำอธิบายโปรไฟล์" placeholder="คำอธิบายสั้น ๆ" onChange={(event) => onPatch({ summary: event.target.value })} /></div>;
}
if (type === "heading" || type === "section") {
const key = type === "heading" ? "text" : "title";
return <Input value={String(config[key] ?? "")} aria-label="ข้อความหัวข้อ" className="border-0 px-0 font-heading text-xl shadow-none focus-visible:ring-0" onChange={(event) => onPatch({ [key]: event.target.value })} />;
}
if (type === "callout") {
return <div className="space-y-3"><Input value={String(config.title ?? "")} aria-label="ชื่อกล่องหมายเหตุ" onChange={(event) => onPatch({ title: event.target.value })} /><Textarea value={String(config.body ?? "")} aria-label="เนื้อหากล่องหมายเหตุ" onChange={(event) => onPatch({ body: event.target.value })} /></div>;
}
if (type === "rich-text") {
const content = Array.isArray(config.content) ? config.content : [];
const first = content[0] as { type?: string; text?: string } | undefined;
return <Textarea value={first?.text ?? ""} aria-label="เนื้อหาข้อความ" className="min-h-28 leading-7" onChange={(event) => onPatch({ content: [{ type: "paragraph", text: event.target.value }, ...content.slice(1)] })} />;
}
if (typeof config.title === "string") {
return <div className="space-y-2"><Input value={config.title} aria-label="ชื่อบล็อก" onChange={(event) => onPatch({ title: event.target.value })} /><p className="text-sm text-muted-foreground">เลือกบล็อกเพื่อแก้ไขโครงสร้างและแหล่งข้อมูลใน Inspector</p></div>;
}
return <p className="text-sm text-muted-foreground">เลือกบล็อกเพื่อแก้ไขรายละเอียดใน Inspector</p>;
}
-457
View File
@@ -1,457 +0,0 @@
"use client";
import { FunctionSquareIcon, PlusIcon, SaveIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { PublicBlockRenderer } from "@/components/public/block-renderer";
import { blockLabels } from "@/components/admin/block-canvas";
import {
MediaUploader,
type UploadedMedia,
} from "@/components/admin/media-uploader";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldTitle,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { evaluateDataSource } from "@/lib/content/data-source";
import type { DataSourceSnapshot, PageSnapshot } from "@/lib/content/types";
import type { EditorDraft } from "@/lib/editor/draft";
export function EditorInspector({
snapshot,
draft,
selectedId,
dataSources,
onDraftChange,
onDataSourceChange,
onSaveDataSource,
sourceSaving,
onMediaReady,
}: {
snapshot: PageSnapshot;
draft: EditorDraft;
selectedId: string | null;
dataSources: Record<string, DataSourceSnapshot>;
onDraftChange: (draft: EditorDraft) => void;
onDataSourceChange: (source: DataSourceSnapshot) => void;
onSaveDataSource: (source: DataSourceSnapshot) => void;
sourceSaving: boolean;
onMediaReady: (media: UploadedMedia) => void;
}) {
const selected = draft.blocks.find((block) => block.id === selectedId);
const preview = {
...snapshot,
page: {
...snapshot.page,
title: draft.title,
slug: draft.slug,
visible: draft.visible,
publicNote: draft.publicNote,
},
blocks: draft.blocks,
dataSources,
};
const patchSelected = (patch: Partial<(typeof draft.blocks)[number]>) => {
if (!selected) return;
onDraftChange({
...draft,
blocks: draft.blocks.map((block) =>
block.id === selected.id ? { ...block, ...patch } : block,
),
});
};
return (
<Tabs defaultValue="inspector" className="flex h-full min-h-0 flex-col">
<div className="border-b p-3">
<TabsList className="w-full">
<TabsTrigger value="inspector" className="flex-1">
Inspector
</TabsTrigger>
<TabsTrigger value="preview" className="flex-1">
Live Preview
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="inspector" className="min-h-0 overflow-y-auto p-4">
{selected ? (
<FieldGroup>
<div>
<Badge variant="outline">
{blockLabels[selected.type] ?? selected.type}
</Badge>
<p className="mt-2 font-mono text-xs text-muted-foreground">
{selected.id}
</p>
</div>
<MediaUploader onReady={onMediaReady} />
<Field>
<FieldLabel htmlFor="block-width">ความกว้าง</FieldLabel>
<Select
value={String(
(
selected.responsiveLayout as
| Record<string, unknown>
| undefined
)?.width ?? "content",
)}
onValueChange={(width) =>
patchSelected({
responsiveLayout: {
...((selected.responsiveLayout as object) ?? {}),
width,
},
})
}
>
<SelectTrigger id="block-width" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="content">คอลัมน์อ่านง่าย</SelectItem>
<SelectItem value="wide">พื้นที่กว้าง</SelectItem>
<SelectItem value="full">เต็มพื้นที่</SelectItem>
</SelectContent>
</Select>
</Field>
<Field orientation="horizontal">
<div className="flex-1">
<FieldTitle>ซ่อนบนมือถือ</FieldTitle>
<FieldDescription>
เนื้อหานี้จะเริ่มแสดงตั้งแต่หน้าจอแท็บเล็ต
</FieldDescription>
</div>
<Switch
checked={Boolean(
(
selected.responsiveLayout as
| Record<string, unknown>
| undefined
)?.hideOnMobile,
)}
onCheckedChange={(hideOnMobile) =>
patchSelected({
responsiveLayout: {
...((selected.responsiveLayout as object) ?? {}),
hideOnMobile,
},
})
}
/>
</Field>
<AdvancedBlockSettings
block={selected}
sources={dataSources}
onConfigChange={(config) => patchSelected({ config })}
/>
{Object.values(dataSources).map((source) => (
<DataSourceEditor
key={source.id}
source={source}
onChange={onDataSourceChange}
onSave={() => onSaveDataSource(source)}
saving={sourceSaving}
/>
))}
</FieldGroup>
) : (
<div className="grid min-h-48 place-items-center text-center text-sm text-muted-foreground">
เลือกบล็อกบนแคนวาสเพื่อแก้ไขการจัดวางและข้อมูลขั้นสูง
</div>
)}
</TabsContent>
<TabsContent
value="preview"
className="min-h-0 overflow-y-auto bg-muted/30 p-4"
>
<div className="origin-top scale-[0.92] rounded-xl bg-background p-4 shadow-sm ring-1 ring-border">
<PublicBlockRenderer snapshot={preview} />
</div>
</TabsContent>
</Tabs>
);
}
function AdvancedBlockSettings({
block,
sources,
onConfigChange,
}: {
block: EditorDraft["blocks"][number];
sources: Record<string, DataSourceSnapshot>;
onConfigChange: (config: Record<string, unknown>) => void;
}) {
const config = block.config as Record<string, unknown>;
if (!("dataSourceId" in config)) return null;
return (
<Field>
<FieldLabel htmlFor="data-source">แหล่งข้อมูล</FieldLabel>
<Select
value={String(config.dataSourceId)}
onValueChange={(dataSourceId) =>
onConfigChange({ ...config, dataSourceId })
}
>
<SelectTrigger id="data-source" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(sources).map((source) => (
<SelectItem key={source.id} value={source.id}>
{source.name} · v{source.version}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}
function DataSourceEditor({
source,
onChange,
onSave,
saving,
}: {
source: DataSourceSnapshot;
onChange: (source: DataSourceSnapshot) => void;
onSave: () => void;
saving: boolean;
}) {
const [rowId, setRowId] = useState<string | null>(null);
const evaluated = useMemo(() => {
try {
return evaluateDataSource(source);
} catch {
return null;
}
}, [source]);
const selectedRow = source.rows.find((row) => row.id === rowId);
const formulaFields = source.fields.filter((field) => field.formula);
const previewRow = evaluated?.rows[0];
function updateField(key: string, formula: string) {
onChange({
...source,
fields: source.fields.map((field) =>
field.key === key ? { ...field, formula: formula || null } : field,
),
});
}
return (
<div className="space-y-3 border-t pt-5">
<div className="flex items-center justify-between gap-2">
<div>
<FieldTitle>{source.name}</FieldTitle>
<FieldDescription>
แหล่งข้อมูลทั่วโลก · เวอร์ชัน {source.version}
</FieldDescription>
</div>
<Button size="sm" variant="outline" onClick={onSave} disabled={saving}>
<SaveIcon />
{saving ? "กำลังบันทึก" : "บันทึกข้อมูล"}
</Button>
</div>
{formulaFields.map((field) => {
const cell = previewRow?.cells[field.key];
return (
<Field key={field.key} data-invalid={Boolean(cell?.error)}>
<FieldLabel htmlFor={`formula-${field.key}`}>
{field.label}
</FieldLabel>
<InputGroup>
<InputGroupAddon>
<InputGroupText className="font-mono text-primary">
fx
</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id={`formula-${field.key}`}
className="font-mono"
value={field.formula ?? ""}
list={`fields-${source.id}`}
onChange={(event) => updateField(field.key, event.target.value)}
/>
<InputGroupAddon align="inline-end">
<FunctionSquareIcon aria-hidden="true" />
</InputGroupAddon>
</InputGroup>
<datalist id={`fields-${source.id}`}>
{source.fields
.filter((candidate) => candidate.key !== field.key)
.map((candidate) => (
<option key={candidate.key} value={candidate.key}>
{candidate.label}
</option>
))}
</datalist>
{cell?.error ? (
<FieldError>
สูตรนี้ยังคำนวณไม่ได้: ตรวจสอบชื่อฟิลด์และตัวหาร
</FieldError>
) : (
<FieldDescription>
ตัวอย่างผลลัพธ์:{" "}
<span className="font-mono text-foreground">
{cell?.display ?? "-"}
</span>
</FieldDescription>
)}
</Field>
);
})}
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow>
{source.fields.slice(0, 3).map((field) => (
<TableHead key={field.key}>{field.label}</TableHead>
))}
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{source.rows.slice(0, 10).map((row) => (
<TableRow key={row.id}>
{source.fields.slice(0, 3).map((field) => (
<TableCell key={field.key} className="max-w-28 truncate">
{row.values[field.key] ??
previewRow?.cells[field.key]?.display ??
"-"}
</TableCell>
))}
<TableCell>
<Button
size="xs"
variant="ghost"
onClick={() => setRowId(row.id)}
>
แก้ไข
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
const id = crypto.randomUUID();
onChange({
...source,
rows: [
...source.rows,
{
id,
values: Object.fromEntries(
source.fields.map((field) => [field.key, null]),
),
},
],
});
setRowId(id);
}}
>
<PlusIcon />
เพิ่มแถว
</Button>
<Dialog
open={Boolean(selectedRow)}
onOpenChange={(open) => {
if (!open) setRowId(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>แก้ไขแถวข้อมูล</DialogTitle>
<DialogDescription>
กรอกค่าต้นทาง ระบบจะคำนวณฟิลด์สูตรให้อัตโนมัติ
</DialogDescription>
</DialogHeader>
{selectedRow ? (
<FieldGroup className="max-h-[60vh] overflow-y-auto pr-1">
{source.fields.map((field) => (
<Field key={field.key}>
<FieldLabel htmlFor={`row-${selectedRow.id}-${field.key}`}>
{field.label}
</FieldLabel>
<Input
id={`row-${selectedRow.id}-${field.key}`}
value={selectedRow.values[field.key] ?? ""}
disabled={Boolean(field.formula)}
inputMode={field.type === "number" ? "decimal" : undefined}
onChange={(event) =>
onChange({
...source,
rows: source.rows.map((row) =>
row.id === selectedRow.id
? {
...row,
values: {
...row.values,
[field.key]: event.target.value || null,
},
}
: row,
),
})
}
/>
</Field>
))}
</FieldGroup>
) : null}
<DialogFooter>
<Button onClick={() => setRowId(null)}>เสร็จสิ้น</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
-439
View File
@@ -1,439 +0,0 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
CommandIcon,
EyeIcon,
MonitorIcon,
PanelRightIcon,
Redo2Icon,
SmartphoneIcon,
TabletIcon,
Undo2Icon,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import {
saveEditorDataSource,
saveEditorPage,
type EditorSaveInput,
} from "@/app/admin/actions";
import { AdminCommandDialog } from "@/components/admin/admin-command";
import {
AdminSidebar,
type AdminNavigation,
} from "@/components/admin/admin-sidebar";
import { BlockCanvas } from "@/components/admin/block-canvas";
import { EditorInspector } from "@/components/admin/editor-inspector";
import type { UploadedMedia } from "@/components/admin/media-uploader";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import type { SerializedBlock } from "@/lib/blocks";
import type { DataSourceSnapshot, PageSnapshot } from "@/lib/content/types";
import {
AutosaveCoordinator,
type AutosaveStatus,
} from "@/lib/editor/autosave";
import {
draftFromSnapshot,
normalizeBlockOrder,
type EditorDraft,
} from "@/lib/editor/draft";
import { cn } from "@/lib/utils";
type Viewport = "mobile" | "tablet" | "desktop";
interface HistoryState {
items: EditorDraft[];
index: number;
}
export function EditorWorkspace({
snapshot,
navigation,
}: {
snapshot: PageSnapshot;
navigation: AdminNavigation;
}) {
const router = useRouter();
const initialDraft = draftFromSnapshot(snapshot);
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 [commandOpen, setCommandOpen] = useState(false);
const [viewport, setViewport] = useState<Viewport>("desktop");
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);
useEffect(() => {
const coordinator = new AutosaveCoordinator<
EditorDraft,
EditorSaveInput,
PageSnapshot
>({
initialVersion: snapshot.page.version,
createPayload: (next, expectedVersion) => ({ ...next, expectedVersion }),
save: saveEditorPage,
onStatus: setAutosaveStatus,
onSaved: (savedSnapshot) => {
if (savedSnapshot) setDataSources(savedSnapshot.dataSources);
},
});
coordinatorRef.current = coordinator;
return () => {
coordinator.dispose();
coordinatorRef.current = null;
};
}, [snapshot.page.id, snapshot.page.version]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setCommandOpen(true);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
function commit(next: EditorDraft) {
setHistory((current) => ({
items: [
...current.items.slice(0, current.index + 1),
structuredClone(next),
].slice(-80),
index: Math.min(current.index + 1, 79),
}));
coordinatorRef.current?.schedule(next);
}
function travel(direction: -1 | 1) {
setHistory((current) => {
const index = current.index + direction;
if (index < 0 || index >= current.items.length) return current;
coordinatorRef.current?.schedule(current.items[index]);
return { ...current, index };
});
}
function insert(block: SerializedBlock) {
const blocks = normalizeBlockOrder([...draft.blocks, block]);
commit({ ...draft, blocks });
setSelectedId(block.id);
}
async function saveSource(source: DataSourceSnapshot) {
setSourceSaving(true);
const result = await saveEditorDataSource(source);
setSourceSaving(false);
if (result.status === "saved") {
if (result.snapshot) {
setDataSources((current) => ({
...current,
[source.id]: result.snapshot!,
}));
}
toast.success("บันทึกแหล่งข้อมูลแล้ว");
} else if (result.status === "conflict") {
toast.error("แหล่งข้อมูลมีการแก้ไขจากที่อื่น กรุณาโหลดหน้าใหม่");
} else {
toast.error("บันทึกแหล่งข้อมูลไม่สำเร็จ");
}
}
function addUploadedMedia(media: UploadedMedia) {
const block: SerializedBlock = {
id: crypto.randomUUID(),
type: "image",
schemaVersion: 1,
config: {
mediaId: media.id,
alt: media.fileName.replace(/\.[^.]+$/u, ""),
fit: "cover",
},
responsiveLayout: {
width: "wide",
mobileSpan: 4,
tabletSpan: 8,
desktopSpan: 12,
hideOnMobile: false,
},
sortOrder: draft.blocks.length,
};
insert(block);
}
const inspector = (
<EditorInspector
snapshot={snapshot}
draft={draft}
selectedId={selectedId}
dataSources={dataSources}
onDraftChange={commit}
onDataSourceChange={(source) =>
setDataSources((current) => ({ ...current, [source.id]: source }))
}
onSaveDataSource={saveSource}
sourceSaving={sourceSaving}
onMediaReady={addUploadedMedia}
/>
);
return (
<SidebarProvider>
<AdminSidebar
navigation={navigation}
currentCharacter={snapshot.character.slug}
currentPage={snapshot.page.slug}
/>
<SidebarInset className="h-svh min-h-0 overflow-hidden">
<header className="z-20 flex min-h-14 shrink-0 flex-wrap items-center gap-2 border-b bg-background/95 px-3 py-2 supports-backdrop-filter:backdrop-blur">
<SidebarTrigger />
<Breadcrumb className="min-w-0 flex-1">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem className="truncate">
{snapshot.character.nameTh}
</BreadcrumbItem>
<BreadcrumbSeparator />
<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>
<SaveStatus status={autosaveStatus} />
<label className="hidden min-h-8 items-center gap-2 text-sm sm:flex">
<Switch
size="sm"
checked={draft.visible}
onCheckedChange={(visible) => commit({ ...draft, visible })}
/>
{draft.visible ? "เผยแพร่" : "ซ่อน"}
</label>
<ToggleGroup
value={[viewport]}
onValueChange={(values) => {
const next = values.at(-1) as Viewport | undefined;
if (next) setViewport(next);
}}
variant="outline"
size="sm"
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>
</ToggleGroup>
<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>
<Drawer swipeDirection="right">
<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>
</DrawerHeader>
{inspector}
</DrawerContent>
</Drawer>
</header>
<div className="min-h-0 flex-1">
<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",
)}
>
<BlockCanvas
draft={draft}
selectedId={selectedId}
onSelect={(id) => setSelectedId(id || null)}
onChange={commit}
onOpenInsert={() => setCommandOpen(true)}
/>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle className="hidden lg:flex" />
<ResizablePanel
defaultSize="30%"
minSize="22%"
maxSize="48%"
className="hidden lg:block"
>
{inspector}
</ResizablePanel>
</ResizablePanelGroup>
</div>
</SidebarInset>
<AdminCommandDialog
open={commandOpen}
onOpenChange={setCommandOpen}
navigation={navigation}
dataSources={dataSources}
onInsert={insert}
/>
<AlertDialog open={autosaveStatus.state === "conflict"}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>พบการแก้ไขที่ใหม่กว่า</AlertDialogTitle>
<AlertDialogDescription>
เวอร์ชันบนเซิร์ฟเวอร์เปลี่ยนไประหว่างที่คุณกำลังทำงาน
ฉบับในเครื่องยังไม่ถูกเขียนทับ
คุณสามารถกลับไปตรวจเทียบหรือโหลดฉบับล่าสุด
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>กลับไปตรวจเทียบ</AlertDialogCancel>
<AlertDialogAction onClick={() => router.refresh()}>
โหลดเวอร์ชันล่าสุด
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SidebarProvider>
);
}
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>;
return <Badge variant="outline">พร้อมแก้ไข</Badge>;
}
-104
View File
@@ -1,104 +0,0 @@
"use client";
import { LayoutTemplateIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { applyEditorTemplate } from "@/app/admin/actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import type { TemplateBlueprint } from "@/lib/templates";
export function TemplateGallery({ templates }: { templates: readonly TemplateBlueprint[] }) {
const router = useRouter();
const [selected, setSelected] = useState<TemplateBlueprint | null>(null);
const [pending, startTransition] = useTransition();
function submit(formData: FormData) {
if (!selected) return;
startTransition(async () => {
const result = await applyEditorTemplate({
templateSlug: selected.slug,
characterSlug: String(formData.get("slug") ?? ""),
nameTh: String(formData.get("nameTh") ?? ""),
nameEn: String(formData.get("nameEn") ?? ""),
description: String(formData.get("description") ?? ""),
});
if (result.status === "created") {
toast.success("สร้างคู่มือจากเทมเพลตแล้ว");
router.push(result.location);
router.refresh();
} else if (result.status === "validation-error") {
toast.error(result.issues[0] ?? "ข้อมูลไม่ถูกต้อง");
} else {
toast.error("สร้างคู่มือไม่สำเร็จ โปรดตรวจสอบ slug ที่ซ้ำกัน");
}
});
}
return (
<>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{templates.map((template) => (
<Card key={template.slug}>
<CardHeader>
<div className="mb-2 grid size-9 place-items-center rounded-lg bg-primary/15 text-primary"><LayoutTemplateIcon /></div>
<CardTitle>{template.name}</CardTitle>
<CardDescription>{template.description}</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Badge variant="outline">{template.pages.length} หน้า</Badge>
<Badge variant="outline">{template.dataSources.length} แหล่งข้อมูล</Badge>
<Badge variant="secondary">{template.category}</Badge>
</CardContent>
<CardFooter>
<Button className="w-full" onClick={() => setSelected(template)}>ใช้เทมเพลต</Button>
</CardFooter>
</Card>
))}
</div>
<Dialog open={Boolean(selected)} onOpenChange={(open) => { if (!open) setSelected(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>สร้างจาก {selected?.name}</DialogTitle>
<DialogDescription>ระบบจะสร้างตัวละคร หน้า บล็อก และแหล่งข้อมูลแยกอิสระ โดยเริ่มต้นในสถานะซ่อน</DialogDescription>
</DialogHeader>
<form action={submit} className="space-y-5">
<FieldGroup>
<Field><FieldLabel htmlFor="template-name-th">ชื่อภาษาไทย</FieldLabel><Input id="template-name-th" name="nameTh" required /></Field>
<Field><FieldLabel htmlFor="template-name-en">ชื่อภาษาอังกฤษ</FieldLabel><Input id="template-name-en" name="nameEn" /></Field>
<Field><FieldLabel htmlFor="template-slug">Slug</FieldLabel><Input id="template-slug" name="slug" required pattern="[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*" placeholder="character-name" /><FieldDescription>ใช้ใน URL และเปลี่ยนภายหลังได้โดยลิงก์เดิมยังเปลี่ยนเส้นทางให้</FieldDescription></Field>
<Field><FieldLabel htmlFor="template-description">คำอธิบาย</FieldLabel><Textarea id="template-description" name="description" /></Field>
</FieldGroup>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setSelected(null)}>ยกเลิก</Button>
<Button type="submit" disabled={pending}>{pending ? <Spinner /> : null}{pending ? "กำลังสร้าง…" : "สร้างคู่มือ"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}
-905
View File
@@ -1,905 +0,0 @@
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>
);
}
-210
View File
@@ -1,210 +0,0 @@
"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>
);
}
-198
View File
@@ -1,198 +0,0 @@
"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>
);
}
-116
View File
@@ -1,116 +0,0 @@
import Image from "next/image";
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 { ScrollableNav } from "@/components/public/scrollable-nav";
import { SiteFooter } from "@/components/public/site-footer";
import { SiteHeader } from "@/components/public/site-header";
import { LiveRefresh } from "@/components/live-refresh";
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";
import { navigationIconPath } from "@/lib/navigation";
export function GuideShell({ snapshot }: { snapshot: PageSnapshot }) {
const sections = collectGuideSections(snapshot.blocks);
return (
<div className="flex min-h-svh flex-col bg-background">
{process.env.BUZZ_DEMO_MODE === "true" ? null : (
<LiveRefresh endpoint={`/api/events/page/${snapshot.page.id}`} />
)}
<SiteHeader />
<div className="sticky top-0 z-30 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>
<ScrollableNav
ariaLabel={`หน้าคู่มือ ${snapshot.character.nameTh}`}
className="min-h-11 rounded-xl border bg-card p-2"
>
{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 gap-2 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",
)}
>
{navigationIconPath(page.slug) ? <Image src={navigationIconPath(page.slug)!} alt="" width={20} height={20} className="size-5 object-contain" /> : null}
{page.title}
</Link>
))}
</ScrollableNav>
</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>
<SiteFooter />
</div>
);
}
-55
View File
@@ -1,55 +0,0 @@
"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>
);
}