diff --git a/app/[character]/[page]/loading.tsx b/app/[character]/[page]/loading.tsx new file mode 100644 index 0000000..a03ed88 --- /dev/null +++ b/app/[character]/[page]/loading.tsx @@ -0,0 +1,17 @@ +import { SiteHeader } from "@/components/public/site-header"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function GuideLoading() { + return ( +
+ +
+
+ + + + +
+
+ ); +} diff --git a/app/[character]/[page]/page.tsx b/app/[character]/[page]/page.tsx new file mode 100644 index 0000000..52e0e17 --- /dev/null +++ b/app/[character]/[page]/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import { notFound, permanentRedirect } from "next/navigation"; +import { connection } from "next/server"; + +import { GuideShell } from "@/components/public/guide-shell"; +import { loadPublicPage } from "@/lib/content/loaders"; + +type RouteParams = Promise<{ character: string; page: string }>; + +export async function generateMetadata({ params }: { params: RouteParams }): Promise { + await connection(); + const { character, page } = await params; + const result = await loadPublicPage(character, page); + if (result.type !== "page") return { title: "ไม่พบคู่มือ" }; + return { + title: `${result.snapshot.page.title} — ${result.snapshot.character.nameTh}`, + description: result.snapshot.character.description ?? undefined, + }; +} + +export default async function GuidePage({ params }: { params: RouteParams }) { + await connection(); + const { character, page } = await params; + const result = await loadPublicPage(character, page); + if (result.type === "redirect") permanentRedirect(result.location); + if (result.type === "not-found") notFound(); + return ; +} diff --git a/app/[character]/page.tsx b/app/[character]/page.tsx new file mode 100644 index 0000000..5203054 --- /dev/null +++ b/app/[character]/page.tsx @@ -0,0 +1,16 @@ +import { notFound, permanentRedirect } from "next/navigation"; +import { connection } from "next/server"; + +import { loadPublicPage } from "@/lib/content/loaders"; + +export default async function CharacterPage({ + params, +}: { + params: Promise<{ character: string }>; +}) { + await connection(); + const { character } = await params; + const result = await loadPublicPage(character); + if (result.type === "redirect") permanentRedirect(result.location); + notFound(); +} diff --git a/app/loading.tsx b/app/loading.tsx new file mode 100644 index 0000000..c54c244 --- /dev/null +++ b/app/loading.tsx @@ -0,0 +1,19 @@ +import { DirectorySkeleton } from "@/components/public/directory-skeleton"; +import { SiteHeader } from "@/components/public/site-header"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+ +
+
+ + + +
+ +
+
+ ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..92b1ab5 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,35 @@ +import Link from "next/link"; +import { MapPinOffIcon } from "lucide-react"; + +import { SiteHeader } from "@/components/public/site-header"; +import { Button } from "@/components/ui/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; + +export default function NotFound() { + return ( +
+ +
+ + + + ไม่พบคู่มือนี้ + + หน้านี้อาจถูกซ่อน เปลี่ยนชื่อ หรือยังไม่มีเนื้อหาที่เผยแพร่ + + + + + + +
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index c887311..8e99031 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,68 +1,27 @@ -import Image from "next/image"; +import { connection } from "next/server"; + +import { CharacterDirectory } from "@/components/public/character-directory"; +import { SiteHeader } from "@/components/public/site-header"; +import { loadPublicDirectory } from "@/lib/content/loaders"; + +export default async function HomePage() { + await connection(); + const characters = await loadPublicDirectory(); -export default function Home() { return ( -
-
- Next.js logo -
-

- To get started, edit the{" "} - - page.tsx - {" "} - file. +
+ +
+
+

คลังคู่มือตัวละคร

+

+ หาคู่มือที่ต้องการ แล้วเริ่มจัดทีมได้เลย

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. +

+ ค้นหาตามชื่อ ธาตุ หรือบทบาท ทุกหน้าจัดรูปแบบมาให้อ่านง่ายทั้งมือถือและเดสก์ท็อป

-
- + +

); diff --git a/components/public/block-renderer.tsx b/components/public/block-renderer.tsx new file mode 100644 index 0000000..d048b11 --- /dev/null +++ b/components/public/block-renderer.tsx @@ -0,0 +1,540 @@ +import Decimal from "decimal.js"; +import Link from "next/link"; +import { + AlertCircleIcon, + ArrowUpRightIcon, + Clock3Icon, + InfoIcon, + LightbulbIcon, + TriangleAlertIcon, +} from "lucide-react"; +import type { z } from "zod"; + +import { ComparisonChart } from "@/components/public/comparison-chart"; +import { MediaImage } from "@/components/public/media-image"; +import { TermDetail } from "@/components/public/term-detail"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "@/components/ui/carousel"; +import { Separator } from "@/components/ui/separator"; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { blockSchemas, resolveBlock } from "@/lib/blocks"; +import { prepareComparisonChart } from "@/lib/charts/comparison"; +import { evaluateDataSource } from "@/lib/content/data-source"; +import type { + DataSourceSnapshot, + PageBlockSnapshot, + PageSnapshot, +} from "@/lib/content/types"; +import { cn } from "@/lib/utils"; + +type Config = 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 | null { + if (!source) return null; + try { + return evaluateDataSource(source); + } catch { + return null; + } +} + +function displayCell( + evaluated: ReturnType, + 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 ( +
+ {snapshot.blocks.map((serialized) => { + const resolution = resolveBlock(serialized); + if (resolution.kind !== "ready") { + return ( +
+ + +
+ ); + } + + const layout = resolution.block.responsiveLayout as { + width: keyof typeof widthClasses; + hideOnMobile: boolean; + }; + return ( +
+ +
+ ); + })} +
+ ); +} + +function ReadyBlock({ + block, + dataSources, +}: { + block: ReturnType extends infer Resolution + ? Resolution extends { kind: "ready"; block: infer Ready } + ? Ready + : never + : never; + dataSources: PageSnapshot["dataSources"]; +}) { + switch (block.type) { + case "profile": + return ; + case "rich-text": + return ; + 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 ( + + {config.text} + + ); + } + case "callout": + return ; + case "stat-cards": + return ; + case "key-value": + return ; + case "ranking": + return ( + ).dataSourceId]} + /> + ); + case "team-composition": + return ( + ).dataSourceId]} + /> + ); + case "rotation": + return ; + case "table": + return ( + ).dataSourceId]} + /> + ); + case "comparison-chart": { + const config = blockSchemas["comparison-chart"].parse(block.config); + return ; + } + case "image": { + const config = blockSchemas.image.parse(block.config); + return ( +
+ + {config.caption ? ( +
+ {config.caption} +
+ ) : null} +
+ ); + } + case "gallery": + return ; + case "tabs": + return ; + case "accordion": + return ; + case "section": { + const config = blockSchemas.section.parse(block.config); + return ( +
+

{config.title}

+ {config.description ?

{config.description}

: null} +
+ ); + } + case "columns": { + const config = blockSchemas.columns.parse(block.config); + return ( + + + ); + } + case "links": + return ; + case "separator": { + const config = blockSchemas.separator.parse(block.config); + return ( +
+ + {config.label ? {config.label} : null} + {config.label ? : null} +
+ ); + } + } +} + +function ProfileBlock({ config }: { config: Config<"profile"> }) { + return ( + +
+ {config.eyebrow ? ( +

{config.eyebrow}

+ ) : null} +
+

+ {config.title} +

+ {config.subtitle ?

{config.subtitle}

: null} +
+ {config.summary ?

{config.summary}

: null} +
+ {config.badges.map((badge) => ( + + {badge.label}{badge.value ? ` · ${badge.value}` : ""} + + ))} +
+
+ +
+ ); +} + +function RichTextBlock({ config }: { config: Config<"rich-text"> }) { + return ( +
+ {config.content.map((node, index) => { + if (node.type === "paragraph") return

{node.text}

; + if (node.type === "quote") { + return
{node.text}
; + } + const List = node.type === "numbered-list" ? "ol" : "ul"; + return ( + + {node.items.map((item, itemIndex) =>
  • {item}
  • )} +
    + ); + })} +
    + ); +} + +function CalloutBlock({ config }: { config: Config<"callout"> }) { + const icon = { + info: