文章列表・詳情
部落格的文章列表和文章頁面。可以依分類篩選,點擊卡片就會打開文章。
部落格
重點
- 1
打開的文章,只記住 id
放進 state 的不是文章本身,而是打開的文章的 id。內容用 find 從列表中找出來。這樣即使列表改變,也不會對不上。
- 2
打開文章時,先回傳詳細內容
先寫 if (opened) { return 詳細內容 },後面再寫列表。這樣一個元件就能在兩個畫面之間切換。
- 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 > ) }