文章列表・詳情

部落格的文章列表和文章頁面。可以依分類篩選,點擊卡片就會打開文章。

部落格

重點

  1. 1

    打開的文章,只記住 id

    放進 state 的不是文章本身,而是打開的文章的 id。內容用 find 從列表中找出來。這樣即使列表改變,也不會對不上。

  2. 2

    打開文章時,先回傳詳細內容

    先寫 if (opened) { return 詳細內容 },後面再寫列表。這樣一個元件就能在兩個畫面之間切換。

  3. 3

    讓讀完的人看到下一篇文章

    在文章下方顯示同一分類的其他文章。用 filter 選出「同一分類,而且不是目前這篇」的文章。

使用的工具

點擊查看第 2 部分的比較

修改程式碼

修改程式碼,右邊的預覽會跟著變化。

import { useState } from "react"
import { ArrowLeftIcon, ClockIcon } from "lucide-react"
import { Badge } from "./components/ui/badge"
import { Button } from "./components/ui/button"

// 文章列表(實際專案會從伺服器或 CMS 取得)
const articles = [
  {
    id: 1,
    title: "用 3 個問題寫出 useState",
    category: "React",
    date: "2026-10-02",
    minutes: 5,
    color: "from-sky-400 to-blue-600",
    excerpt: "回答「要改什麼?」「用哪個 state?」「新的值是什麼?」這 3 個問題,就能寫出修改 state 的那一行。",
    body: [
      "不知道修改 state 的那一行怎麼寫時,就依序回答 3 個問題。",
      "第一,想改變畫面上的什麼?第二,記住它的是哪個 state?第三,新的值是什麼?",
      "答案都有了,接下來只要寫 setXxx(新的值) 就好。",
    ],
  },
  {
    id: 2,
    title: "間距統一用 4 的倍數",
    category: "設計",
    date: "2026-09-28",
    minutes: 3,
    color: "from-violet-400 to-fuchsia-500",
    excerpt: "只使用 4、8、12、16… 這種固定的間距,整個畫面會更有一致感。",
    body: [
      "間距如果憑感覺隨手決定,就會一點一點跑掉,畫面看起來很亂。",
      "在 Tailwind 中,p-1 是 4px,p-2 是 8px。只用這些級距,自然就會是 4 的倍數。",
    ],
  },
  {
    id: 3,
    title: "map 和 filter 的差別",
    category: "React",
    date: "2026-09-20",
    minutes: 4,
    color: "from-cyan-400 to-sky-600",
    excerpt: "map 是逐一改寫,filter 是決定留不留。看數量會不會變,就能分辨它們。",
    body: [
      "map 會把列表中的每一項改寫成別的東西,產生一個新列表。數量不變。",
      "filter 只留下符合條件的項目,產生一個新列表。數量可能會減少。",
    ],
  },
  {
    id: 4,
    title: "零經驗的第一份作品集",
    category: "職涯",
    date: "2026-09-12",
    minutes: 6,
    color: "from-amber-400 to-orange-500",
    excerpt: "比起作品數量,更重要的是「你是怎麼想的」。就算只有一個,能說清楚理由的作品就很有說服力。",
    body: [
      "與其做很多,不如能把一個作品「為什麼這樣做」說清楚。",
      "在截圖旁簡單寫上你花心思的地方,以及當時猶豫的問題。",
    ],
  },
  {
    id: 5,
    title: "顏色最多用 3 種",
    category: "設計",
    date: "2026-09-05",
    minutes: 3,
    color: "from-rose-400 to-pink-600",
    excerpt: "主色、強調色、文字顏色。有這 3 種,大部分的畫面都夠用了。",
    body: [
      "顏色越多,越讓人不知道該看哪裡。",
      "先決定一種主色,只在想強調的地方使用強調色。",
    ],
  },
  {
    id: 6,
    title: "每天持續學習 30 分鐘的訣竅",
    category: "職涯",
    date: "2026-08-30",
    minutes: 4,
    color: "from-emerald-400 to-teal-600",
    excerpt: "先決定「停在哪裡」,而不是「什麼時候學」。停在清楚下一步的地方,隔天就能馬上開始。",
    body: [
      "學不下去,多半是因為每次開始時都得先想「要做什麼」。",
      "每天結束前,用一行字記下接下來要做的事,隔天直接從那裡開始就好。",
    ],
  },
]

const categories = ["全部", "React", "設計", "職涯"]

// 文章的彩色橫幅(代替圖片)
function Cover({ article, className }) {
  return <div className={"bg-linear-to-br " + article.color + " " + className} />
}

export default function App() {
  // ① 目前選擇的分類(一開始是 "全部"),放在 state 裡
  //const category = "全部"
  const [category, setCategory] = useState("全部")
  // ① 目前開啟的文章 id(瀏覽列表時為 null),放在 state 裡
  //const openId = null
  const [openId, setOpenId] = useState(null)

  // ⑤ 目前開啟的文章:在 articles 中找出 id 等於 openId 的文章(find)
  //const opened = undefined
  const opened = articles.find((article) => article.id === openId)
  //const opened = undefined
  //const shown = articles.filter((article) => category === "全部" || article.category === category)


  // ⑥ 有開啟文章時,先回傳詳細頁面
  //    條件:是否有開啟的文章
  if (opened) {
    // 同一分類的其他文章
    const related = articles.filter((article) => article.category === opened.category && article.id !== opened.id)
    return (
      <div className="mx-auto my-6 w-full max-w-2xl px-4 text-foreground">
        {/* ⑦ 點擊後回到列表(把開啟的文章設為「無」) */}
        <Button variant="ghost" size="sm" className="-ml-2" onClick={() => setOpenId(null)}
        >
          <ArrowLeftIcon />
          回到文章列表
        </Button>
        <Cover article={opened} className="mt-4 h-48 rounded-xl" />
        <div className="mt-6 flex items-center gap-3 text-xs text-muted-foreground">
          <Badge variant="secondary">{opened.category}</Badge>
          <span>{opened.date}</span>
          <span className="flex items-center gap-1">
            <ClockIcon className="size-3" />
            閱讀時間約 {opened.minutes} 分鐘
          </span>
        </div>
        <h1 className="mt-3 text-2xl font-bold tracking-tight">{opened.title}</h1>
        <div className="mt-5 flex flex-col gap-4 leading-relaxed text-foreground/80">
          {opened.body.map((paragraph) => (
            <p key={paragraph}>{paragraph}</p>
          ))}
        </div>
        {related.length > 0 && (
          <div className="mt-10 border-t pt-6">
            <p className="text-sm font-semibold">同分類的文章</p>
            <div className="mt-3 flex flex-col gap-2">
              {related.map((article) => (
                <button
                  key={article.id}
                  type="button"
                  onClick={() => setOpenId(article.id)}
                  className="flex items-center gap-3 rounded-lg p-2 text-left text-sm hover:bg-muted"
                >
                  <Cover article={article} className="size-10 shrink-0 rounded-md" />
                  {article.title}
                </button>
              ))}
            </div>
          </div>
        )}
      </div>
    )
  }

  // ③ 要顯示的文章:只顯示所選分類的文章(選「全部」時顯示所有文章)
  const shown = articles.filter((article) => category === "全部" || article.category === category)

  //const filtered = products.filter((product) => product.name.includes(query) && (category === "全部" || product.category === category))

  return (
    <div className="mx-auto my-6 w-full max-w-4xl px-4 text-foreground">
      <h1 className="text-xl font-semibold">部落格</h1>
      <div className="mt-4 flex flex-wrap gap-2">
        {/* // ② 點擊後選擇該分類。選中的按鈕是實心(default),其他是外框(outline) */}
        {
          categories.map((name) => (
            <Button key={name} size="sm" variant={category === name ? "default" : "outline"} onClick={() => setCategory(name)}>
              {name}
            </Button>
          ))
        }
      </div>
      <div className="mt-6 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
        {/* ④ 用 map 排列 shown,點擊後開啟那篇文章 */}
        {shown.map((article) => (
          <button key={article.id}
            type="button"
            className="group flex flex-col overflow-hidden rounded-xl border bg-card text-left transition hover:-translate-y-0.5 hover:shadow-md" onClick={() => setOpenId(article.id)}
          >
            <Cover article={article} className="h-32" />
            <div className="flex flex-1 flex-col p-4">
              <div className="flex items-center gap-2 text-xs text-muted-foreground">
                <Badge variant="secondary">{article.category}</Badge>
                <span>{article.date}</span>
              </div>
              <p className="mt-2 font-semibold leading-snug group-hover:text-primary">{article.title}</p>
              <p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{article.excerpt}</p>
            </div>
          </button>
        ))}
      </div>
    </div >
  )
}