🧰 ตรวจด้วยอะไร เวอร์ชันไหน — ทุกบรรทัดผลรันในหน้านี้มาจาก
next 16.3.5 # Turbopack
react / react-dom 19.2.8
typescript 5.9.3
tailwindcss 4.3.3
shadcn 4.21.0 # npx shadcn@latest --version
@base-ui/react 1.8.0
class-variance-authority 0.7.1
cn 0.3.0 # ตัวที่ทำ cn() — ไม่ใช่ clsx + tailwind-merge แล้ว
eslint 9.39.5
node v24.14.0
npm 11.9.0
# โปรเจกต์ตั้งต้นของทุกบท:
create-next-app → npx shadcn init (preset base-nova) → npx shadcn add button card badge skeleton
โค้ดที่ไม่ได้ติดป้ายว่า “พัง” ผ่านครบสามด่าน: npx next build · npx tsc --noEmit · npx eslint .
ส่วนเรื่องที่ต้องดูด้วยตา (สี · ตำแหน่ง · เมนูหาย) วัดในเบราว์เซอร์จริงด้วย getComputedStyle() / getBoundingClientRect() ·
ถ้าเวอร์ชันบนเครื่องคุณไม่ตรง ข้อความและหน้าตาไฟล์อาจต่างกันได้จริง (โดยเฉพาะ shadcn) — ให้เชื่อผลรันบนเครื่องคุณ
สารบัญ
โครงโฟลเดอร์ — อะไรอยู่ไหน ทำไม
คำสั่ง shadcn ที่ใช้จริง
cn() — ตัวรวม class
design tokens สองชั้น
cva — variants ของปุ่ม
Layout · Container · Navbar
RecipeCard + 4 สถานะของหน้า
คำสั่งพิสูจน์ว่าทำงานจริง
ตารางแปล error ที่เจอจริง
ตารางกับดักรวมทั้งกลุ่ม
อ้างอิง
1 · โครงโฟลเดอร์ — อะไรอยู่ไหน ทำไม
.
├── app/
│ ├── layout.tsx # กรอบที่ห่อทุกหน้า — วาง Navbar / Footer ที่นี่
│ ├── page.tsx # หน้า /
│ ├── globals.css # Tailwind + token ทั้งชุด (shadcn init เติมให้)
│ ├── _components/ # ของที่ใช้หน้าเดียว · ขีดล่าง = ไม่เป็น URL
│ ├── _containers/
│ └── recipes/page.tsx # หน้า /recipes
├── components/
│ ├── RecipeCard/ # หนึ่งโฟลเดอร์ต่อหนึ่งตัว
│ │ ├── RecipeCard.tsx
│ │ └── index.ts # barrel
│ └── ui/ # ไฟล์ที่ shadcn add ก็อปมา — อย่าแก้ตรง ๆ
├── containers/
│ └── RecipeList/
│ ├── RecipeList.tsx
│ └── index.ts
├── lib/utils.ts # export { cn } from "cn" (บรรทัดเดียว)
└── components.json # ตั้งค่าที่ shadcn CLI อ่านทุกครั้งที่ add
ที่อยู่ ใส่อะไร ทำไม · ที่ต้องรู้
app/
หน้าจอแต่ละ URL (page.tsx) · กรอบร่วม (layout.tsx) · globals.css
โฟลเดอร์ใต้ app/ ที่มี page.tsx = URL ทันที · ไม่มี page.tsx = 404
app/_components · app/_containers
ของที่ใช้หน้าเดียว เช่นแบนเนอร์หน้าแรก
ขีดล่าง = Next.js ข้ามทั้งโฟลเดอร์ แม้มี page.tsx ข้างใน (/_components ได้ 404 จริง) ·
แต่ยัง import ได้ — และ import ข้ามหน้าก็ build ผ่าน (กฎ “ใช้หน้าเดียว” เป็นข้อตกลง ไม่ใช่ที่ Next.js บังคับ)
components/
pure · presentational — รับ props แล้ววาดอย่างเดียว ใช้ได้หลายหน้า
ไม่มี state ของงาน ไม่รู้ว่าข้อมูลมาจากไหน · ปกติไม่ต้องมี "use client"
(ยกเว้นมี event จริง เช่น onClick — บท 84)
containers/
stateful composition — ถือ state + รู้ business rule แล้วประกอบ component หลายตัว
มักต้องมี "use client" เมื่อมี hook จริง ·
ไม่มี hook / event → ไม่จำเป็น (บท 85 build ผ่านทั้งสองแบบ) ·
Next.js ไม่รู้จักชื่อ components/ containers/ เลย — วินัยของทีมล้วน ๆ
components/ui/
ไฟล์ที่ npx shadcn@latest add เขียนลงมา (button · card · badge · skeleton · input …)
เป็น source ของเราแล้ว แต่ add --overwrite เขียนทับได้เงียบ ๆ →
อยากได้แบบใหม่ให้ห่อเป็น component ใหม่ใน components/ (หัวข้อ 2)
lib/utils.ts
ที่อยู่ของ cn()
shadcn 4.21.0 สร้างเป็น export { cn } from "cn" บรรทัดเดียว ·
สไลด์และบทความเก่าเป็น clsx + tailwind-merge — คนละรุ่น ไม่มีใครผิด ·
import { cn } from "@/lib/utils" ยังใช้ได้
components.json
สร้างโดย init — style · ไฟล์ CSS · aliases
aliases.ui = ที่ add จะวางไฟล์ · tailwind.config ว่าง = Tailwind v4 ·
ไม่ได้จดรายชื่อ component ที่ติดตั้ง — โฟลเดอร์ components/ui/ คือบันทึกเดียวที่มี
ตัดสินว่าโค้ดชิ้นนี้อยู่ไหน — ถามสองข้อตามลำดับ
คำถาม ใช่ ไม่
1 · มันจำ state ของงาน หรือรู้ business rule ไหม? container component
2 · ใช้กี่หน้า? หน้าเดียว → app/<หน้า>/_components หรือ _containers หลายหน้า → components/ · containers/ ระดับบนสุด
⚠ สัญลักษณ์ในชื่อโฟลเดอร์ใต้ app/ มีความหมาย
[ชื่อ] = dynamic segment รับค่าอะไรก็ได้ · _ชื่อ = ไม่ใช่ route ·
สไลด์หน้า 7 วาดโฟลเดอร์ [another-routes]/ ไว้ — ถ้าสร้างจริงทั้งวงเล็บ ทุก URL ชั้นเดียวได้ 200 หมด
(curl /recipes ได้ <p>dynamic: <!-- -->recipes</p>) ·
ตั้งชื่อธรรมดาอย่าง app/recipes/page.tsx แล้วเก็บวงเล็บไว้ใช้ตอนตั้งใจรับค่า เช่น app/recipes/[id]/ ·
Next.js 16 ส่ง params มาเป็น Promise ต้อง await ก่อน
2 · คำสั่ง shadcn ที่ใช้จริง
อยากทำอะไร พิมพ์ ผลจริง · ที่ต้องรู้
เตรียมโปรเจกต์ (ครั้งเดียว)
npx shadcn@latest init -t next
สไลด์หน้า 11 · ถาม component library (เลือก Base UI) แล้วถาม preset ·
ไม่ได้ก็อป component ให้สักตัว — สร้าง components.json · แก้ app/globals.css ·
สร้าง lib/utils.ts · เพิ่ม dependency ใน package.json
ก็อป component มาใส่โปรเจกต์
npx shadcn@latest add input
✔ Created 1 file: - components/ui/input.tsx ·
git status --short เห็นแค่ ?? components/ui/input.tsx ·
package.json กับ components.json ไม่เปลี่ยน
ลงหลายตัวพร้อมกัน
npx shadcn@latest add button card badge skeleton
ชุดที่โปรเจกต์ตรวจของทุกบทใช้
ดูก่อนว่าจะเขียนทับอะไร
npx shadcn@latest add button --diff components/ui/button.tsx
โชว์ - / + ทีละบรรทัด โดยไม่เขียนไฟล์ · ใช้ทุกครั้งก่อน overwrite
ลองโดยไม่เขียนไฟล์
--dry-run
ดู flag ครบด้วย npx shadcn@latest add --help
เขียนทับไม่ถาม
npx shadcn@latest add button --overwrite
ℹ Updated 1 file · ของที่แก้ไว้หายเกลี้ยง ไม่มีคำเตือน ไม่มีไฟล์ .bak ·
git status ว่างถ้าไฟล์กลับไปเหมือน commit เดิม
เช็คเวอร์ชัน
npx shadcn@latest --version
ไฟล์ที่ได้ไม่ตรงสไลด์/เพื่อน → ถามเรื่องนี้ก่อน ส่วนใหญ่เป็นเรื่องรุ่น
⚠ สไลด์หน้า 14 กล่องแรกพิมพ์ผิด — ขาดตัว x
$ npm shadcn@latest add button # ← ตามสไลด์ ใช้ไม่ได้
Unknown command: "shadcn@latest"
To see a list of supported npm commands, run:
npm help
$ npx shadcn@latest add button # ← ที่ถูก (เหมือน init ในหน้า 11)
npm ต้องการชื่อคำสั่งย่อย (install · run) · npx ต้องการชื่อโปรแกรม ·
pnpm dlx / yarn dlx ในสองกล่องล่างของสไลด์คือ npx ของค่ายอื่น ถูกทั้งคู่ ·
npm ตอบ Unknown command แล้วจบ ไม่มีไฟล์ไหนเปลี่ยน
สั่ง add ซ้ำกับไฟล์ที่มีอยู่แล้ว — ได้ข้อความไม่เหมือนกัน
สถานการณ์ ข้อความที่เห็นจริง ไฟล์
ไฟล์มีอยู่ ไม่ได้แก้
ℹ Skipped 1 file: (files might be identical, use --overwrite to overwrite)ไม่เปลี่ยน · แบบในภาพสไลด์หน้า 14
ไฟล์มีอยู่ แก้ไว้แล้ว
? The file button.tsx already exists. Would you like to overwrite? › (y/N)ถามครั้งเดียว · ใส่ --yes ก็ยังถาม
ใส่ --overwrite
ℹ Updated 1 file: - components/ui/button.tsxกลับเป็นต้นฉบับ งานที่แก้หาย
“อย่าแก้ไฟล์ตรง ๆ ให้สร้างใหม่ตามแบบ shadcn” (สไลด์หน้า 16) — ห่อแทนแก้
// components/ActionButton/ActionButton.tsx
import type { ComponentProps } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type ActionButtonProps = ComponentProps<typeof Button> & { // รับทุก prop ที่ Button รับ + ของเรา
loading?: boolean;
};
const ActionButton = ({ loading = false, disabled, className, children, ...restProps }: ActionButtonProps) => {
return (
<Button
className={cn("bg-orange-500 text-white hover:bg-orange-600", className)} // className คนเรียกไว้ท้าย → ทับได้
disabled={disabled || loading}
{...restProps}
>
{loading ? "กำลังบันทึก..." : children}
</Button>
);
};
export default ActionButton;
// components/ActionButton/index.ts
export { default } from "./ActionButton";
พิสูจน์แล้ว: สั่ง add button --overwrite หลังมี ActionButton → button.tsx กลับเป็นต้นฉบับ
แต่ HTML ของ ActionButton ยังลงท้าย bg-orange-500 text-white hover:bg-orange-600 ครบ ·
อยากตัด children ออกจาก type ใช้ Omit<ComponentProps<typeof Badge>, "children"> (ภารกิจ DifficultyBadge บท 80)
3 · cn() — ตัวรวม class ที่รู้ว่า class ไหนชนกัน
ปัญหาที่มันแก้: ต่อสตริง "px-4 px-2" เองแล้วหวังว่าตัวหลังชนะ —
วัดจริงได้ padding 16px (px-4 ชนะ) เพราะ CSS ตัดสินจากลำดับ rule ในไฟล์ CSS ที่ Tailwind เรียงเอง
(.px-2 มาก่อน .px-4) ไม่ใช่ลำดับใน attribute class
เรียก ผลรันจริง อ่านว่า
cn("px-2", "px-4")"px-4"เรื่องเดียวกัน ตัวหลังชนะ
cn("px-2 py-1", "p-4")"p-4"p-4 คุมสี่ด้าน มาทีหลังจึงกลืน px/py
cn("p-4", "px-2")"p-4 px-2"px-2 ทับได้แค่ซ้ายขวา · p-4 ยังต้องอยู่
cn("text-red-500", "text-lg")"text-red-500 text-lg"สีกับขนาด คนละเรื่อง เก็บทั้งคู่
cn("rounded-lg", isActive && "bg-blue-500", { "text-white": isActive, "border": true }) · isActive = false"rounded-lg border"รับ string · false (ทิ้ง) · object { class: เงื่อนไข }
"px-2" + " " + "px-4""px-2 px-4"ต่อสตริงเอง ไม่ตัดอะไร
cn("text-lg", "text-brand")"text-lg text-brand"class ที่ตั้งเอง cn ไม่รู้ว่าชนกับอะไร — เก็บทั้งคู่
cn(Container ของบท 83, "px-8 flex h-16")"mx-auto w-full max-w-7xl sm:px-6 lg:px-8 px-8 flex h-16"px-4 หาย · sm:px-6 อยู่ต่อ (คนละเงื่อนไข)
cn(buttonVariants({ size: "sm" }), "h-12 bg-accent")"… px-3 text-sm text-primary-foreground hover:bg-primary/90 h-12 bg-accent"h-8.5 กับ bg-primary หาย · hover:bg-primary/90 อยู่ (คนละเงื่อนไข)
กติกาที่ใช้ได้ทุก component ที่รับ className: cn(class ของเรา, className) — ใส่ของคนเรียกไว้ท้าย
คนใช้จะทับได้ · ต่อสตริงเองหรือส่ง className ให้ cva ตรง ๆ โดยไม่ผ่าน cn → มีทั้ง h-8.5 และ h-12 อยู่ด้วยกัน
ผลขึ้นกับลำดับไฟล์ CSS ทายจากโค้ดไม่ได้
4 · design tokens — ค่าจริงใน :root → ชื่อใน @theme inline → class
:root { --primary: #a80689 } ← ชั้น 1: ค่าจริง (browser อ่าน)
│
▼
@theme inline { --color-primary: var(--primary) } ← ชั้น 2: ตั้งชื่อให้ Tailwind
│ └──┬──┘
│ "--color-" + "primary"
▼
class ที่ใช้ได้: bg-primary · text-primary · border-primary · ring-primary · hover:bg-primary …
│
▼ (npx next build)
CSS จริงที่ได้: .bg-primary{background-color:var(--primary)}
ชั้น 1 (:root · .dark) ชั้น 2 (@theme inline) class ที่ได้
--primary--color-primary: var(--primary)bg-primary · text-primary · border-primary
--primary-foreground--color-primary-foregroundtext-primary-foreground
--border--color-borderborder-border
--radius--radius-lg: var(--radius)rounded-lg (ตระกูล --radius- ไม่ใช่ --color-)
--primary-hover อยู่ใน :root อย่างเดียว— ไม่มี — ไม่มี class · build ผ่าน · ไม่มี warning
ขั้นตอนเพิ่ม token ใหม่ (ทำครบทุกข้อ)
ใส่ค่าจริงใน :root — และใน .dark ถ้ามีธีมมืด
ลงทะเบียน --color-ชื่อ: var(--ชื่อ); ในบล็อก @theme inline เดิม ของ shadcn — อย่าเปิดบล็อก @theme ใหม่
ใช้ใน JSX เช่น bg-accent-subtle · text-accent-strong
npx next build แล้ว grep CSS ที่ build ออกมา ให้เจอทั้ง rule และค่า (หัวข้อ 8) — “เปิดหน้าแล้วสวย” ไม่นับว่าพิสูจน์
/* app/globals.css — ผลจาก git diff จริงของภารกิจบท 81 (ตัดบริบทออก) */
@theme inline {
--color-accent-subtle: var(--accent-subtle);
--color-accent-strong: var(--accent-strong);
}
:root {
--accent-subtle: #fff1dc;
--accent-strong: #b36a00;
}
@utility wongnok-text-h1 {
font-size: 2.25rem;
line-height: 2.5rem;
font-weight: 700;
}
ลงทะเบียนแบบ CSS ที่ build ได้ ใต้ <div class="dark"> (วัดจริง)
@theme inline { --color-primary-hover: var(--primary-hover) }
.bg-primary-hover{background-color:var(--primary-hover)}
rgb(232, 121, 201) = ค่าใน .dark ✅
@theme { --color-brand-noinline: var(--primary-hover) }
.bg-brand-noinline{background-color:var(--color-brand-noinline)} + ตัวแปรถูกคำนวณที่ :root
rgb(138, 5, 112) = ค่าธีมสว่าง ❌
แบบไม่ inline จะ “ดูเหมือนใช้ได้” ตอนใส่ dark ที่ <html> (ได้ rgb(232, 121, 201) ทั้งสองแบบ)
แล้วค่อยพังตอนมีคนทำกล่องธีมมืดเฉพาะส่วน · dark: ในโปรเจกต์นี้ผูกกับ class ด้วย @custom-variant dark (&:is(.dark *));
class ของเราเอง (เช่น wongnok-text-h2) — ใช้ @utility
เขียนแบบ wongnok-…md:wongnok-…
class ธรรมดาใน @layer components { .wongnok-plain-h2 {…} } ได้ rule ✅ ไม่มี rule เลย (grep นับได้ 0) ❌
@utility wongnok-text-h2 {…}ได้ rule ✅ ได้ใน @media (min-width:48rem) ✅ · ใช้กับ hover: dark: ได้ · ไม่มีใครใช้ = ไม่ใส่ลงไฟล์
⚠ ก่อนก็อป token จากสไลด์หน้า 25–27
ชั้น @theme inline ไม่ได้อยู่ในภาพ — ก็อปแค่ :root = class ไม่เกิด
ค่าสีปนสองแบบ: ส่วนใหญ่ hex แต่ --popover กับ --chart-1..4 ยังเป็น oklch(…) ค่าเดิมของ shadcn
สไลด์เปลี่ยน --accent เป็นส้ม #ff9f1c — ใน shadcn accent มีความหมายเดิมอยู่แล้ว · ก่อนเปลี่ยนให้ grep -rn accent components/ui
ตั้งชื่อตามหน้าที่ (--primary) ไม่ใช่ตามสี (--purple) · ค่าจริงอยู่ชั้น 1 เท่านั้น ห้ามใส่ hex ตรง ๆ ใน @theme inline (สายกับ .dark จะขาด)
ในไฟล์ CSS ที่ build แล้ว oklch(0.205 0 0) กลายเป็น #171717 — ตัวบีบไฟล์แปลงให้ สีเดียวกัน
5 · cva — ปุ่มหลายหน้าตาในที่เดียว
แพ็กเกจชื่อ class-variance-authority (หัวสไลด์หน้า 31 สะกด “Variant” — ตอนค้นเอกสาร/พิมพ์ import ใช้ variance ) ·
shadcn init ติดตั้งให้แล้ว · cva ไม่สร้าง CSS มันแค่ประกอบ string class — Tailwind ยังเป็นคนสร้าง CSS
// components/Bases/button-variants.ts (ย่อ — ไฟล์เต็มอยู่บท 82 หัวข้อ 3)
import { cva, type VariantProps } from "class-variance-authority";
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors disabled:pointer-events-none disabled:opacity-50", // ① class ฐาน
{
variants: { // ② เมนูแต่ละแกน
variant: { contained: "", outlined: "border-2 bg-transparent" },
color: { primary: "", accent: "", error: "", gray: "" }, // ว่าง: สีขึ้นกับ variant ด้วย
size: { sm: "h-8.5 px-3 text-sm", md: "h-10 px-4 text-sm", lg: "h-12 px-6 text-base" },
},
compoundVariants: [ // ③ ต่อท้ายเมื่อทุกเงื่อนไขตรง
{ variant: "contained", color: "primary", className: "bg-primary text-primary-foreground hover:bg-primary/90" },
{ variant: "outlined", color: "primary", className: "border-primary text-primary hover:bg-primary/10" },
// … ครบ 8 คู่ (2 variant × 4 color)
],
defaultVariants: { variant: "contained", color: "primary", size: "md" }, // ④ ใช้เมื่อเป็น undefined
},
);
export type ButtonVariantProps = VariantProps<typeof buttonVariants>; // ⑤ type มาจากเมนูเอง
ส่วน ทำอะไร ผลจริงที่ต้องจำ
ลำดับใน string class ฐาน → variant → color → size → compoundVariants ท้ายสุด string ว่างไม่ทิ้งช่องว่างซ้อน
variantsเมนูแต่ละแกนที่เป็นอิสระต่อกัน 2 × 4 × 3 = 24 หน้าตา โดยไม่ต้องตั้งชื่อก้อนอย่าง destructive-outline · แกน boolean ได้: block: { true: "w-full", false: "" }
compoundVariantsต่อ class เมื่อทุกช่องในกฎตรงพร้อมกัน (นับค่าจาก default ด้วย) ไม่มีกฎที่ตรง = ไม่ต่อ ไม่มี error · compound ไม่ลบ class ของแกนอื่น (ใส่ border-3 แล้ว string มีทั้ง border-2 และ border-3 — cn ค่อยตัดทิ้ง)
defaultVariantsค่าเมื่อแกนนั้นเป็น undefined null = “ไม่เอาค่าไหนเลย” → ไม่ใช้ default
VariantProps<typeof …>สร้าง type ของ props จากเมนู เพิ่มค่าในเมนู type เปลี่ยนตาม · ส่งค่าผิดได้ TS2322 พร้อมรายการค่าที่ถูก
disabledไม่ต้องมีแกนของตัวเอง disabled:opacity-50 disabled:pointer-events-none ในคลาสฐาน ทำงานเมื่อแท็กมี attribute disabled จริง
compound ต่อท้ายเมื่อไหร่ — cva ย่อ ("btn" + สองกฎของสไลด์หน้า 32 ที่ต้องการ contained)
เรียก ผลรันจริง เพราะ
{ variant: "contained", color: "primary" }"btn bg-primary text-primary-foreground hover:bg-primary/90"ครบสองเงื่อนไข
{ variant: "outlined", color: "primary" }"btn border-2"variant ไม่ตรง → ได้แค่ของแกน variant
{ variant: "contained", color: "error" }"btn"ไม่มีกฎของคู่นี้ · ไม่เตือน
{ color: "accent" }"btn bg-accent text-accent-foreground hover:bg-accent/90"default เติม contained ให้ → ครบ
{ variant: undefined }"btn bg-primary text-primary-foreground hover:bg-primary/90"undefined = ใช้ default
{ variant: null }"btn"null ปิด default — ข้อมูลจาก API ที่ส่ง null มา ปุ่มเสียสีเงียบ ๆ
ห่อเป็น Button ที่มี loading
// components/Bases/Button.tsx
import { Loader2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
import { buttonVariants, type ButtonVariantProps } from "./button-variants";
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
ButtonVariantProps & {
loading?: boolean;
};
const Button = (props: ButtonProps) => {
const { variant, color, size, loading = false, disabled, className, children, ...restProps } = props;
// ↑ ดึงของตัวเองออกให้ครบก่อน ...restProps ไม่งั้นไหลไปเป็น attribute บน <button>
return (
<button
className={cn(buttonVariants({ variant, color, size }), className)}
disabled={disabled || loading} // กันกดซ้ำ + จางให้ฟรี
aria-busy={loading || undefined} // ปุ่มปกติจะไม่มี attribute นี้เลย
{...restProps}
>
{loading && <Loader2Icon className="size-4 animate-spin" aria-hidden="true" />}
{children}
</button>
);
};
export default Button;
เรื่อง Button ของเรา (craft version หน้า 30–32) components/ui/button.tsx ของ shadcn
แกน variant × color × size (2 × 4 × 3) · md = h-10 = 40px variant × size (6 × 8) · ไม่มี compoundVariants เพราะรวมสีในชื่อ (destructive) · default h-8 = 32px
type ButtonHTMLAttributes & VariantProps<typeof buttonVariants>ButtonPrimitive.Props & VariantProps<typeof buttonVariants> (ปุ่มของ @base-ui/react)
รวม className cn(buttonVariants({…}), className)cn(buttonVariants({ variant, size, className })) — ผลเท่ากัน
export export default Buttonexport { Button, buttonVariants } — เอา class ปุ่มไปแปะบน <a> ได้
ขนาดตามป้าย 34 / 40 / 48px ในสไลด์หน้า 30 ใช้ h-8.5 / h-10 / h-12 ได้เลย ไม่ต้อง h-[34px] —
CSS จริงคือ .h-8\.5{height:calc(var(--spacing) * 8.5)} กับ --spacing:.25rem → 2.125rem = 34px ·
ชื่อในสไลด์ไม่ตรงกันเอง: หน้า 29 ซ้ายเขียน outlined ขวาเขียน variant="outline" · ชุดหน้า 28–29 เป็นสไตล์ shadcn ส่วนหน้า 30–32 เป็นชุด craft — อย่าก็อปปนกัน
6 · Layout · Container · Navbar
Container — สไลด์หน้า 39 เรียกใช้ แต่ไม่มีหน้าไหนนิยามให้
// components/layout/Container.tsx (บท 83 เขียนเอง — สไลด์ไม่ได้ให้มา · shadcn ก็ไม่มี)
import { cn } from "@/lib/utils";
export function Container({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8", className)}
{...props}
/>
);
}
class CSS จริงหลัง build หน้าที่
max-w-7xlmax-width:var(--container-7xl) · --container-7xl:80remกว้างไม่เกิน 1280px · จอแคบกว่านั้นกว้างเท่าจอ (w-full)
mx-automargin-inline:autoอยู่กลางจอ → ขอบซ้ายของโลโก้ · เนื้อหา · footer ตรงกัน
px-4 sm:px-6 lg:px-8padding-inline:calc(var(--spacing) * 4)ตัวหนังสือไม่ชิดขอบจอ · ขอบกว้างขึ้นตามจอ
พื้นหลังเต็มจอต้องใส่ที่กล่องแม่ชั้นนอก (<header> · <footer>) ไม่ใช่ที่ Container
Navbar กับ active link ที่ถูกต้อง
// components/layout/Navbar.tsx (ตัดส่วนโลโก้ออก)
"use client"; // ← บรรทัดแรกสุด เพราะใช้ usePathname
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Container } from "@/components/layout/Container";
import { cn } from "@/lib/utils";
const NAV_LINKS = [
{ href: "/", label: "Home" }, // ไม่มี active: true ตายตัว
{ href: "/recipes", label: "Recipes" },
];
function Navbar() {
const pathname = usePathname();
return (
<header className="fixed inset-x-0 top-0 z-50 border-b bg-card">
<Container className="flex h-16 items-center justify-between gap-6">
<nav aria-label="Main" className="hidden items-center gap-1 lg:flex">
{NAV_LINKS.map((link) => {
const active = pathname === link.href;
return (
<Link
key={link.href}
href={link.href}
aria-current={active ? "page" : undefined}
className={cn("rounded-md px-3.5 py-2 font-medium", active ? "text-primary" : "hover:bg-muted")}
>
{link.label}
</Link>
);
})}
</nav>
</Container>
</header>
);
}
export default Navbar;
// app/layout.tsx — ใน <body className="min-h-full flex flex-col"> ที่ create-next-app สร้างให้
<Navbar />
<main className="pt-16">{children}</main> // เว้นที่ให้ Navbar แบบ fixed
<Footer /> // Footer ใช้ className="mt-auto" → ดันลงล่างสุดของจอ
เรื่อง ผลที่วัด/รันจริง จำไว้ว่า
<Link href> vs <a href>
HTML ออกมาเป็น <a href="/"> เหมือนกันเป๊ะ · กด <Link> แล้ว window.__marker ยังอยู่ ("still-here") ·
กด <a> แล้วหาย ("GONE (page reloaded)")
ลิงก์ในแอปใช้ <Link> เสมอ — <a> โหลดใหม่ทั้งหน้า state ใน layout กลับเป็นค่าเริ่มต้น ·
build ผ่านทั้งคู่ ตัวที่ฟ้องคือ eslint · ลิงก์ไปเว็บข้างนอกใช้ <a> ได้ · <Link> ใช้ใน Server Component ได้
usePathname() + 'use client'
ไม่มี "use client" → build พัง ชี้ ./components/layout/Navbar.tsx:2:10 ·
ใส่แล้ว curl /recipes ได้ aria-current="page" ที่ Recipes ถูกต้อง
ใส่ที่ไฟล์ Navbar ไม่ใช่ app/layout.tsx (layout มี export const metadata → พังอีกแบบ) ·
<header> ยังเป็น element ตัวเดิมหลังเปลี่ยนหน้า (sameHeaderNode: true)
active: true เขียนตายตัว
อยู่หน้า /recipes แต่ aria-current="page" ยังอยู่ที่ Home
<Link> ไม่ได้ใส่ aria-current ให้เอง · สิ่งที่ขึ้นกับ URL ต้องคำนวณจาก URL
fixed top-0 ไม่มี padding
จอ 1280px: header 0–65 · h1 0–24 · elementFromPoint ตรงหัวข้อ = "inside HEADER"
fixed หลุดจากลำดับหน้า ไม่กินที่ → หัวข้อแรกทุกหน้าโดนบัง คลิกไม่โดน
fixed + <main className="pt-16">
h1 เริ่มที่ 64 แต่ header จบที่ 65 — ยังบัง 1px
1px มาจาก border-b ของ header (h-16 อยู่ที่ Container ข้างใน) · ต้องวัด ไม่ใช่คิดเลข
sticky top-0 (เอา pt-16 ออก)
"headerPos": "sticky" · header จบ 65 · h1 เริ่ม 65 พอดี
sticky ยังกินที่ตามปกติ แต่ติดขอบบนตอนเลื่อน · แบบร่างหน้า 36 เขียน “sticky shadow” ส่วนโค้ดหน้า 39 ใช้ fixed
hidden lg:flex
1280px → display: "flex" · 375px → "none" · CSS: @media (min-width:64rem){ .lg\:flex{display:flex} }
ต่ำกว่า 1024px เมนูหายหมด ไป Recipes ไม่ได้ · mobile drawer ต้องเขียนเอง (สไลด์มีแค่ในแบบร่าง)
Footer ไม่ลงล่างสุด
จอ 1280×800 · มี mt-auto → footer 616–800 · เอาออก → 88–272
mt-auto ดันได้เฉพาะในกล่อง flex — เช็คว่า <body> ยังมี flex flex-col · Footer ไม่มี hook ไม่ต้อง "use client"
7 · RecipeCard + 4 สถานะของหน้า
props type — อ่านจาก design properties (สไลด์หน้า 45–46)
// components/RecipeCard/RecipeCard.tsx (ส่วน type · บท 84)
export type RecipeLevel = "EASY" | "MEDIUM" | "HARD"; // union ไม่ใช่ string
export type RecipeSummary = {
id: number;
name: string;
imageUrl?: string; // ยังไม่มีรูปจริง → วาด placeholder
level: RecipeLevel;
ownerName: string;
ownerImageUrl?: string; // เพิ่มในภารกิจท้ายบท · ไม่มีรูป → วงกลมตัวย่อชื่อ
rating: number;
ratingCount: number;
};
export type RecipeCardProps = {
recipe: RecipeSummary; // ส่งสูตรทั้งก้อน ตามโค้ดสไลด์หน้า 45
onClick?: (id: number) => void;
};
const levelLabel: Record<RecipeLevel, string> = { EASY: "Easy", MEDIUM: "Medium", HARD: "Hard" };
// ค่าที่เก็บ (EASY) แยกจากข้อความที่โชว์ (Easy)
ในสไลด์ field ต้องมี? เหตุผล
Recipe name name: string✅ ไม่มีชื่อ = ไม่รู้ว่าเมนูอะไร
image imageUrl?: string⭕ ดีไซน์ยังเป็นลาย dish photo ทุกใบ · ไม่มีก็วาดกล่องเทา aspect-4/3
level level: RecipeLevel✅ มีได้แค่สามค่า → union
owner name ownerName: string✅ ทุกสูตรมีคนเขียน
rating / rating count rating · ratingCount: number✅ สูตรใหม่ก็เป็น 0 ได้ · ลืมใส่ได้ TS2741
onClick onClick?: (id: number) => void⭕ เรียกด้วย onClick?.(recipe.id) — ไม่มีก็ข้าม
owner image · Is favorite? · onFavorite — ⏸ สไลด์หน้า 46 (FOCUSED) ทำให้จาง = รอบนี้ยังไม่ทำ · favorite ต้องมี login ก่อน
4 สถานะ — เก็บเป็น discriminated union บน status
// components/RecipeList/RecipeList.tsx (โครง)
export type RecipeListState =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "success"; recipes: RecipeSummary[] }; // ไม่มี "empty" — ว่าง = success ที่ยาว 0
const RecipeList = ({ state }: { state: RecipeListState }) => {
if (state.status === "loading") return /* Skeleton 4 กล่อง · aria-busy="true" */ ;
if (state.status === "error") return /* role="alert" · state.message · Try again */ ;
if (state.recipes.length === 0) return /* No recipes match your search · Clear filters */ ;
return /* state.recipes.map((recipe) => <RecipeCard key={recipe.id} recipe={recipe} />) */ ;
};
สถานะ เกิดตอนไหน ไม่ทำไว้ ผู้ใช้เห็น HTML จริงที่ได้ (curl /recipes?state=…)
Default ข้อมูลมาครบ ≥ 1 สูตร — data-slot="card" 3 ใบ
Loading รอ API · เน็ตช้า หน้าขาว ไม่รู้ว่าค้างหรือโหลด <div aria-busy="true"> + data-slot="skeleton" 4 กล่อง · การ์ด 0
Empty API สำเร็จแต่ลิสต์ว่าง · ค้นไม่เจอ หน้าขาว แยกไม่ออกจาก Loading <h3>No recipes match your search</h3><a href="/recipes">Clear filters</a>
Error API ล่ม · เน็ตหลุด หน้าขาว หรือพังทั้งหน้า <div role="alert"><h3>Something went wrong</h3><p>Could not load recipes</p>…
ท่า tsc ผลจริง
{ isLoading: boolean; error?: string; recipes: RecipeSummary[] } แล้วใส่ครบสามอย่างพร้อมกันผ่าน impossible state — หน้าควรโชว์อะไร ไม่มีใครตอบได้
union แล้วเขียน { status: "loading", recipes: mockRecipes } TS2353 เขียนผิดไม่ได้ตั้งแต่แรก
union แล้วเพิ่ม | { status: "idle" } แต่ลืม if TS2339 ชี้บรรทัดที่ใช้ state.recipes บอกทุกจุดที่ต้องแก้
union แต่ลบก้อน if (state.recipes.length === 0) ผ่าน <div class="grid grid-cols-4 gap-4"></div> เปล่า · ลิสต์ว่างเป็นเรื่องของค่า ไม่ใช่ type → ต้องเปิดดูด้วยข้อมูลว่างจริง
level: string + ข้อมูล "EASYY"ผ่าน (exit 0) ป้าย level ว่างเปล่า (levelLabel["EASYY"] = undefined)
level: RecipeLevel + ข้อมูล "EASYY"TS2820 + Did you mean '"EASY"'? ไม่มีทางไปถึงหน้าเว็บ
หน้า All Recipes (บท 85) — responsive + ข้อความหัวจาก state
// app/recipes/page.tsx (เฉลยภารกิจบท 85 · ส่วนสำคัญ)
const subtitle = (state: RecipeListState) => {
if (state.status === "loading") return "Loading recipes…";
if (state.status === "error") return "Could not load recipes";
return `${state.recipes.length} recipes found`; // แทน "8 recipes · page 1 of 7" ที่ hardcode จาก mock
};
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mt-7"> // มือถือ 1 · แท็บเล็ต 2 · จอใหญ่ 4
<RecipeList state={state} />
</div>
// ใน RecipeList: กล่อง empty / error ใส่ className="col-span-full …" ไม่งั้นอัดอยู่ช่องแรก
// (RecipeList คืน Fragment <> — grid อยู่ที่ page.tsx ตามสไลด์หน้า 49)
sm: = @media (min-width:40rem) = 640px · lg: = @media (min-width:64rem) = 1024px ·
จอ 375px ได้ 1 คอลัมน์ · 700px ได้ 2 · 1280px ได้ 4 · prefix แปลว่า “ตั้งแต่ขนาดนี้ขึ้นไป” ลำดับใน className ไม่มีผล ·
ผล curl ของเฉลย: /recipes การ์ด 8 · /recipes-empty การ์ด 0 skeleton 0 + 0 recipes found · /recipes-loading skeleton 8
คำถาม คำตอบจากผลรันจริง
RecipeCard ต้องมี "use client" ไหมต้อง ถ้ามี onClick (event เกิดในเบราว์เซอร์) · ไม่มี hook/event ก็ไม่ต้อง
Server Component ส่ง onClick={() => …} ให้ Client Component ได้ไหม ไม่ได้ — tsc ผ่าน · build ผ่าน · เปิดหน้าได้ 500 · ฟังก์ชันไม่ serializable
RecipeList (ไม่มี state / event) ต้องมี "use client" ไหมไม่จำเป็น — build ผ่านทั้ง 4 แบบ route เป็น ○ Static ทุกแบบ ·
การ์ดเป็น server: เอาออกแล้วรวม gzip เล็กลง 12,872 ไบต์ · การ์ดเป็น client: เล็กลงแค่ 243 ไบต์ (mock ย้ายจาก JS ไปอยู่ใน HTML)
เมื่อไหร่ container ต้องมี "use client" จริง ตอนเริ่ม stateful จริง เช่นเก็บคำค้นด้วย useState · หรือต้องเป็นคนถือฟังก์ชัน onClick ที่ส่งลงการ์ด
key ใช้อะไรkey={recipe.id} · index ใช้ได้เฉพาะรายการที่ไม่เปลี่ยนลำดับและลูกไม่มี state (เช่น skeleton)
8 · คำสั่งพิสูจน์ว่าทำงานจริง
อยากรู้ว่า พิมพ์ ที่ต้องรู้
ผ่านสามด่าน npx next buildnpx tsc --noEmit; echo "tsc rc=$?"npx eslint .; echo "eslint rc=$?"
tsc / eslint ที่ผ่านไม่พิมพ์อะไรเลย ต้องต่อ echo rc=$? ·
build ผ่าน ≠ ถูก : class ที่ไม่มีจริง · <a> แทน <Link> · ลืม key · onClick ข้ามฝั่ง ผ่าน build ทั้งหมด
class ถูกสร้างจริงไหม grep -o '\.bg-primary-hover{[^}]*}' .next/static/chunks/*.css; echo "rc=$?"
rc=1 = ไม่มี rule → กลับไปดู @theme inline ก่อนอย่างอื่น
ค่า token อยู่ใน CSS ไหม grep -oE -- '--accent-(subtle|strong):[^;]*' .next/static/chunks/*.css
ค่าอยู่แต่ class ไม่มี = ลืมชั้น 2 · class มีแต่ค่าไม่มี = ลืมชั้น 1
breakpoint อยู่ที่เท่าไหร่ grep -oE '@media[^@]*lg\\:grid-cols-4' .next/static/chunks/*.css | grep -oE '^@media \([^)]*\)'
ได้ @media (min-width:64rem) — ไม่ต้องจำ ดูจากไฟล์ ·
ใน CSS จริง .lg\:grid-cols-4 อยู่ในบล็อกเดียวกับ class lg: ตัวอื่น (เช่น .lg\:flex{display:flex}) จึงต้องค้นข้าม { ได้
server ส่ง HTML อะไรมา npx next start แล้ว curl -s http://localhost:3000/recipes
นับ data-slot="card" / data-slot="skeleton" · เช็ค aria-current="page"
status code curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/_components
หน้า dynamic (ƒ) ถูกวาดตอนมีคนขอ — build ผ่านแล้วยังได้ 500 ได้ ต้อง curl ทุกครั้ง
shadcn ทำอะไรกับโปรเจกต์ git status --short · git diff --stat
commit ให้สะอาดก่อน add · ?? = ไฟล์ใหม่ที่ git ยังไม่รู้จัก
สี / ตำแหน่ง / เมนูหายไหม ใน browser: getComputedStyle(el).display · el.getBoundingClientRect()
ใช้แทนการกะด้วยตา — 1px ที่ header บังหัวข้อ จับได้ด้วยวิธีนี้เท่านั้น
9 · ตารางแปล error ที่เจอจริงในบท 79–85
Next.js / Turbopack — ตอน next build หรือเปิดหน้า
ข้อความ แปลว่า · ทางแก้
You're importing a module that depends on `useState` into a React Server Component module. This API is only available in Client Components. (หรือ `usePathname`)
ไฟล์ใช้ hook แต่ไม่มี "use client" · ใส่เป็นบรรทัดแรกสุดของไฟล์ที่ path ใต้ Turbopack build failed ชี้
(บรรทัดบนของ Import trace = ไฟล์ที่ต้องแก้ · บรรทัดล่าง = คนที่เรียกมันมา) · เจอใน container (บท 79) และ Navbar (บท 83)
You are attempting to export "metadata" from a component marked with "use client", which is disallowed.
ไปใส่ "use client" ที่ app/layout.tsx · เอาออก แล้วใส่ที่ไฟล์ที่ใช้ hook จริง (Navbar)
⨯ Error: Event handlers cannot be passed to Client Component props.<... recipe={{...}} onClick={function onClick}>
Server Component ส่งฟังก์ชันให้ Client Component · tsc และ build ผ่าน แต่หน้าได้ 500 (เห็นใน terminal ของ next dev) ·
ให้ component ที่ถือฟังก์ชันเป็น Client ด้วย
Each child in a list should have a unique "key" prop.Check the render method of `RecipeList`.
ลืม key ใน .map() · build ผ่าน หน้าได้ 200 · ขึ้นใน log ของ next dev ก่อน บรรทัด GET ของหน้านั้น ·
เวอร์ชัน client บอกชื่อ component ให้ ส่วนเวอร์ชัน server ไม่บอก
npm / shadcn
ข้อความ แปลว่า · ทางแก้
Unknown command: "shadcn@latest"พิมพ์ npm ตามสไลด์หน้า 14 · แก้เป็น npx shadcn@latest …
? The file button.tsx already exists. Would you like to overwrite? › (y/N)ไม่ใช่ error — ไฟล์ถูกแก้ไว้แล้ว · ตอบ y = งานที่แก้หาย · ดู --diff ก่อน
TypeScript — จาก npx tsc --noEmit
โค้ด ข้อความ · แปลว่าอะไร
TS2305Module '"shadcn"' has no exported member 'Button'. — package shadcn คือ CLI ไม่มี component ·
import จาก @/components/ui/button
TS2304Cannot find name 'LayoutProps'. — โปรเจกต์ใหม่ยังไม่เคย build · type นี้ Next.js 16 สร้างให้ตอน next dev/next build ·
รัน npx next build หนึ่งรอบแล้ว tsc ใหม่ ไม่ต้องแก้โค้ด
TS2322Type '"ghost"' is not assignable to type '"contained" | "outlined" | null | undefined'. — ส่ง variant ที่ไม่มีในเมนู cva
(ก็อปชื่อชุด shadcn หน้า 28 มาใช้กับ craft version) · error บอกค่าที่ถูกมาให้แล้ว
TS2322Type '"red"' is not assignable to type '"primary" | "accent" | "error" | "gray" | undefined'. —
สังเกตว่า ไม่มี null เพราะชนกับ attribute HTML color?: string ของ ButtonHTMLAttributes
TS2322Type 'string' is not assignable to type 'boolean | null | undefined'. — เขียน <Button block="yes"> กับแกน boolean · ใช้ block หรือ block={true}
TS2322Type '"spicy"' is not assignable to type 'Difficulty'. — ส่งค่านอก union · ตัวอย่างว่า type กันพลาดได้จริง
TS2322Property 'favorited' does not exist on type 'IntrinsicAttributes & RecipeCardProps'. — ส่ง prop ที่ไม่ได้ประกาศ ·
แต่ชื่อที่มีขีด - (เช่น sc-camel-on-toggle-favorite) TypeScript ไม่ตรวจ
TS2741Property 'ratingCount' is missing in type '{ … }' but required in type 'RecipeSummary'. — ข้อมูลขาดช่องบังคับ · ใส่ให้ครบ หรือทำ field เป็น ? ถ้ามันไม่มีจริง
TS2820Type '"EASYY"' is not assignable to type 'RecipeLevel'. Did you mean '"EASY"'? — พิมพ์ผิดในข้อมูล · ได้เพราะใช้ union (ถ้าเป็น string จะไม่ฟ้อง)
TS7053Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Record<RecipeLevel, string>'. —
เปลี่ยน level กลับเป็น string แต่ levelLabel ยังเป็น Record<RecipeLevel, …> · กลับไปใช้ union
TS2353Object literal may only specify known properties, and 'recipes' does not exist in type '{ status: "loading"; }'. — ใส่ข้อมูลในสถานะที่ไม่มีช่องให้ใส่ · union ทำงานถูกแล้ว
TS2339Property 'recipes' does not exist on type '{ status: "idle"; } | { status: "success"; … }'. — เพิ่มสถานะใหม่ในรายการ union แต่ยังไม่มี if รองรับ · เพิ่ม if ก่อนบรรทัดที่ใช้ state.recipes
TS7006Parameter 'recipe' implicitly has an 'any' type. — ผลพ่วงจาก TS2339 ข้างบน (map บน type ที่ไม่รู้จัก) · แก้ตัวบนแล้วหายเอง
ESLint — npx eslint .
ข้อความ · กฎ แปลว่า · ทางแก้
Do not use an `<a>` element to navigate to `/`. Use `<Link />` from `next/link` instead.@next/next/no-html-link-for-pages
ลิงก์ในแอปใช้ <a> · เปลี่ยนเป็น <Link> · next build ไม่ฟ้อง
Missing "key" prop for element in iteratorreact/jsx-key
ลืม key ใน .map() · จับไม่ได้ว่าใช้ index เป็น key
@next/next/no-img-element
ใช้ <img> แทน <Image> · บท 84 ปิดด้วย // eslint-disable-next-line @next/next/no-img-element เพราะยังไม่มีรูปจริงให้ตั้งค่า
10 · ตารางกับดักรวมทั้งกลุ่ม
อาการที่เห็น สาเหตุ ทางแก้
ทุก URL ชั้นเดียวได้ 200 · ไม่มี 404 อีกเลย
สร้างโฟลเดอร์ app/[another-routes]/ ตามรูปต้นไม้สไลด์หน้า 7 จริงทั้งวงเล็บ
ตั้งชื่อธรรมดา (app/recipes/) · วงเล็บใช้เมื่อตั้งใจรับค่า
ได้ /_components เป็น 200
พิมพ์ขีดกลาง - แทนขีดล่าง · หรือมีโฟลเดอร์ [ชื่อ] อยู่ข้าง ๆ รับไปแทน
เช็คสองอย่างนี้ · ขีดล่างถูกต้องได้ 404 แม้มี page.tsx
ใส่ useState ใน components/ หรือ import ของ app/_components ข้ามหน้า — build ผ่านสบาย
components/containers/_components เป็นข้อตกลงของทีม ไม่มีเครื่องมือตรวจ
รักษาด้วยวินัย · ไฟล์ที่เริ่มต้องการ "use client" เพราะ state ของงาน = ถามว่าควรย้ายบ้านหรือยัง
lib/utils.ts / button.tsx หน้าตาไม่เหมือนสไลด์หน้า 15
shadcn 4.21.0 ใช้ export { cn } from "cn" · button ได้ rounded-lg + import { cn } from "cn" (สไลด์เป็น rounded-4xl + @/lib/utils)
ไม่ต้องแก้ — คนละรุ่น · เช็คด้วย npx shadcn@latest --version
ปุ่มที่แก้สีไว้ใน components/ui/button.tsx กลับเป็นสีเดิม
มีคน (หรือ AI) สั่ง add button --overwrite
อย่าเก็บของที่อยากคงไว้ใน components/ui/ — ห่อเป็น component ใหม่ · ดู --diff ก่อน overwrite
ส่ง className="px-2" ต่อท้าย px-4 แล้วไม่ติด (ได้ 16px)
ต่อสตริงเอง · CSS ตัดสินจากลำดับ rule ในไฟล์ ไม่ใช่ลำดับใน class
รวมด้วย cn(ของเรา, className) เสมอ
ผ่าน cn() แล้ว class ที่ตั้งเองยังชนกันอยู่
cn รู้จักแค่ชื่อ class ของ Tailwind (cn("text-lg", "text-brand") เก็บทั้งคู่)
ทำ class ของเราเป็น token/utility ของ Tailwind หรือเลี่ยงไม่ให้ชน
เพิ่มสีใน globals.css แล้วปุ่มไม่มีสี · build ขึ้น ✓ Compiled successfully
ใส่แค่ :root ไม่ได้ลงทะเบียน --color-… ใน @theme inline · Tailwind ข้าม class ที่ไม่รู้จักเงียบ ๆ
เพิ่มชั้น 2 แล้ว grep CSS ที่ build (หัวข้อ 8) · ติดตั้ง Tailwind CSS IntelliSense ช่วยจับชื่อผิด
ก็อป Navbar สไลด์หน้า 39 แล้วเส้นขอบเทาธรรมดา · โลโก้ไม่ไล่สี · ตัวหนังสือไม่มีสไตล์
wongnok-text-base wongnok-text-sm wongnok-text-h3 border-border-subtle to-primary-hover = NOT FOUND ใน CSS
ประกาศ token ของ Wongnok ก่อน: สี 2 ชั้น · wongnok-text-* ด้วย @utility
สีไม่เปลี่ยนตามกล่อง .dark ที่อยู่ข้างในหน้า (แต่สลับทั้งหน้าได้)
ลงทะเบียนใน @theme ไม่มี inline → ตัวแปรถูกคำนวณเสร็จที่ :root
ใส่ใน @theme inline บล็อกเดิมของ shadcn
md:wongnok-plain-h2 ไม่มีผล แต่ wongnok-plain-h2 มีผล
เขียนเป็น class ธรรมดาใน @layer components — variant ไม่ได้ rule
เปลี่ยนเป็น @utility wongnok-plain-h2 { … }
component ที่ add มาทีหลังเปลี่ยนเป็นสีส้มแปลก ๆ
สไลด์เปลี่ยนความหมาย --accent ของ shadcn
grep -rn accent components/ui ก่อนเปลี่ยน token เดิมทุกครั้ง
ปุ่ม outlined ไม่มีขอบสี · ได้ class bg-transparentborder-destructive
ต่อ className ด้วย + แล้วลืมเว้นวรรค · tsc ไม่ฟ้อง (เป็น string ถูก type) · พังแค่ 4 ใน 24 หน้าตา
ใช้ cva() แทน ternary ซ้อน
ปุ่มบางแบบได้แค่ class ฐาน ไม่มีสีเลย
ไม่มีกฎ compoundVariants ของคู่นั้น · หรือค่าเป็น null (ปิด default)
เขียนกฎให้ครบทุกคู่ · แปลง null จาก API เป็น undefined ก่อนส่งเข้า cva
<button color="primary"> โผล่ใน DOM จริง
ลืมดึง color ออกจาก props · มันผ่าน type เพราะ HTML มี attribute color อยู่แล้ว
destructure variant color size loading ออกให้ครบก่อน ...restProps
string มีทั้ง h-8.5 กับ h-12 / border-2 กับ border-3 · ขนาดจริงทายไม่ได้
ส่ง className ให้ cva ตรง ๆ หรือต่อสตริง · compound ไม่ลบ class แกนอื่น
ห่อ buttonVariants(…) ด้วย cn() ทุกครั้ง
ผู้ใช้กด “บันทึก” สามครั้ง ส่งซ้ำสามรอบ
loading โชว์ spinner แต่ไม่ได้ใส่ disabled
disabled={disabled || loading} + spinner + aria-busy
ก็อปโค้ดสไลด์หน้า 39 แล้วหา Container ไม่เจอ
สไลด์หน้า 33–40 ไม่มีหน้าไหนนิยาม Container · shadcn ก็ไม่มี
เขียนเอง (หัวข้อ 6) · link.active ในสไลด์ก็ไม่มีที่มาเหมือนกัน
หัวข้อแรกทุกหน้าหายไปใต้แถบเมนู / โดนบัง 1px
fixed ไม่กินที่ · ใส่ pt-16 (64px) แต่ header สูง 65px เพราะ border-b
เปลี่ยนเป็น sticky top-0 แล้วเอา padding ออก · หรือวัดความสูงจริงก่อนตั้ง padding
บนมือถือไม่มีเมนูให้กดเลย
hidden lg:flex = ซ่อนทุกจอที่แคบกว่า 1024px · สไลด์ไม่มีโค้ด mobile drawer
นับเป็นงานค้าง — ซ่อนอะไรบนมือถือต้องมีทางอื่นให้ไปถึงเสมอ
อยู่หน้า Recipes แต่เมนู Home ยังเป็นสีหลัก · screen reader บอกผิด
active: true เขียนตายตัวในอาร์เรย์
usePathname() + "use client" ที่ Navbar · ตั้ง aria-current="page" ตามผลเทียบ
state ใน layout (เช่นคำค้นใน Navbar) หายทุกครั้งที่กดลิงก์
ลิงก์เป็น <a href> ธรรมดา → โหลดใหม่ทั้งหน้า
ใช้ <Link> · รัน eslint ด้วย ไม่ใช่แค่ build
ป้าย level ว่างเปล่า ไม่มี error ที่ไหน
level: string + ข้อมูลพิมพ์ผิด → levelLabel[…] ได้ undefined · React วาดเป็นความว่าง
ประกาศเป็น union "EASY" | "MEDIUM" | "HARD" · (ค่าจาก API ตอนรันจริง TS ยังมองไม่เห็น)
ค้นหาไม่เจอแล้วได้หน้าขาวโล่ง ใต้หัวข้อ All Recipes
ไม่มีก้อน Empty · recipes: [] ถูก type สมบูรณ์ tsc จึงไม่ฟ้อง
เช็ค state.recipes.length === 0 · เปิดดูด้วยข้อมูลว่างจริงทุกครั้ง
ข้อความ empty/error อัดอยู่ช่องแรกของ grid
กล่องเป็นลูกของ grid เหมือนการ์ด
ใส่ col-span-full
build ผ่าน แต่เปิดหน้าได้ 500
Server Component ส่ง onClick ให้ Client Component · หน้า dynamic (ƒ) ถูกวาดตอนมีคนขอ
ย้ายฟังก์ชันไปอยู่ใน Client Component · หน้า dynamic ต้อง curl ทุกครั้ง
ใส่ prop ชื่อแปลกจากโค้ดสไลด์หน้า 45 (sc-camel-on-toggle-favorite) แล้ว tsc ไม่ว่าอะไร
TypeScript ไม่ตรวจชื่อ prop ที่มีขีด - (เปิดทางให้ data-* / aria-*)
ใช้ camelCase เช่น onToggleFavorite · อย่าก็อปบรรทัดนั้น
map การ์ดปลอมแล้วหน้าว่าง ไม่มี error
เขียน new Array(12).map(…) — ได้ array ที่มีแต่ช่องโหว่ (new Array(3) พิมพ์ออกมาเป็น [ <3 empty items> ]) map ข้ามช่องโหว่ทั้งหมด
ใช้ Array.from({ length: 12 }).map((_, index) => …) ตามสไลด์หน้า 48
“8 recipes · page 1 of 7” อยู่เหนือการ์ด 12 ใบ
ข้อความจากภาพออกแบบถูกพิมพ์ลงโค้ดตรง ๆ (hardcode) ติดมาถึงสไลด์หน้า 50
คำนวณจาก state: `${state.recipes.length} recipes found` · ตัด page ทิ้งจนมี pagination จริง
บนมือถือการ์ดกว้างไม่ถึง 74px
grid-cols-4 ไม่มี prefix = 4 คอลัมน์ทุกจอ
grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 (ตรง prompt สไลด์หน้า 23)
กดหัวใจเมนูหนึ่ง ลบ/กรอง/เรียงแล้วหัวใจไปโผล่อีกเมนู · ไม่มี warning
key ทำจาก index → state ผูกกับตำแหน่ง · ทั้ง React และ eslint ไม่ฟ้อง
key={recipe.id} · ต้องจับเองตอนอ่านโค้ด
ใส่ "use client" ใน container ที่ไม่มี hook เพราะคิดว่า “container ต้องเป็น client”
ไม่จำเป็น — ไฟล์นั้นกับทุกอย่างที่ import ถูกส่งเป็น JS ไปเบราว์เซอร์ด้วย
ปล่อยเป็น Server จนกว่าจะมี state/event จริง · วัดทั้ง JS และ HTML ก่อนสรุปว่าคุ้ม
หา next.config.js / .env ตามรูปสไลด์ไม่เจอ
create-next-app สร้าง next.config.ts และไม่มี .env มาให้
ไม่ต้องทำอะไร · .env สร้างเองระดับบนสุดเมื่อต้องใช้
← กลับไปบทเรียน 79–85
🏠 หน้าแรก
11 · อ้างอิง