feat : remove legacy feature
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user