記事一覧・詳細

ブログの記事一覧と、記事のページです。カテゴリで絞り込み、カードを押すと記事が開きます。

ブログ

ここがポイント

  1. 1

    開いている記事は、id だけ覚える

    記事そのものではなく、開いている記事の id を state に持つ。中身は一覧から find で探す。一覧が変わっても、ずれない

  2. 2

    記事を開いているときは、先に詳細を返す

    if (opened) { return 詳細 } と先に書き、そのあとに一覧を書く。2つの画面を1つの部品で切り替えられる

  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: "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 >
  )
}