記事一覧・詳細
ブログの記事一覧と、記事のページです。カテゴリで絞り込み、カードを押すと記事が開きます。
ブログ
ここがポイント
- 1
開いている記事は、id だけ覚える
記事そのものではなく、開いている記事の id を state に持つ。中身は一覧から find で探す。一覧が変わっても、ずれない
- 2
記事を開いているときは、先に詳細を返す
if (opened) { return 詳細 } と先に書き、そのあとに一覧を書く。2つの画面を1つの部品で切り替えられる
- 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: "useState を3つの質問で書く", category: "React", date: "2026-10-02", minutes: 5, color: "from-sky-400 to-blue-600", excerpt: "「何を変える?」「どの state?」「新しい値は?」の3つに答えれば、state を変える1行が書けます。", body: [ "state を変える1行で迷ったら、3つの質問に順に答えます。", "1つ目は「画面の何を変えたいか」。2つ目は「それを覚えている state はどれか」。3つ目は「新しい値は何か」です。", "答えがそろえば、あとは set〇〇(新しい値) と書くだけです。", ], }, { 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 は1つずつ書き換える。filter は残すか決める。人数が変わるかどうかが見分け方です。", body: [ "map は、一覧の1つずつを別のものに書き換えた、新しい一覧を作ります。数は変わりません。", "filter は、条件に合うものだけを残した、新しい一覧を作ります。数が減ることがあります。", ], }, { id: 4, title: "未経験からの最初のポートフォリオ", category: "キャリア", date: "2026-09-12", minutes: 6, color: "from-amber-400 to-orange-500", excerpt: "作品の数より「何を考えて作ったか」。1つでも、理由を説明できる作品が強いです。", body: [ "たくさん作るより、1つの作品について「なぜそう作ったか」を話せることが大切です。", "画面の写真に、工夫した所と、そのとき迷ったことを短く添えましょう。", ], }, { id: 5, title: "色は3つまでに絞る", category: "デザイン", date: "2026-09-05", minutes: 3, color: "from-rose-400 to-pink-600", excerpt: "メインの色・アクセントの色・文字の色。この3つで、たいていの画面は作れます。", body: [ "色が増えるほど、どこを見ればいいかわからない画面になります。", "まずはメインの色を1つ決め、強調したい所だけにアクセントの色を使います。", ], }, { id: 6, title: "毎日30分の学習を続けるコツ", category: "キャリア", date: "2026-08-30", minutes: 4, color: "from-emerald-400 to-teal-600", excerpt: "「やる時間」より「やめる場所」を先に決める。次の一歩がわかる所で止めると、翌日すぐ始められます。", body: [ "学習が続かない原因の多くは、始めるときに「何をやるか」を考えることです。", "前の日に、次にやることを1行メモしてから終えると、翌日はそこから始めるだけになります。", ], }, ] 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) // ⑤ 開いている記事。今は「なし」(undefined)。ここに: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) // ⑥ 記事を開いているときは、詳細のページを先に返す。今は決まった値(false)なので、いつも一覧になる // ここに:false を、「開いている記事があるか」の条件に変える 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"> {/* ④ ここに:shown を map で並べる。押したら、その記事を開く(今は1つ目だけ、決まった文字) */} {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 > ) }