글 목록・상세

블로그의 글 목록과 글 페이지입니다. 카테고리로 좁힐 수 있고, 카드를 누르면 글이 열립니다.

블로그

핵심 포인트

  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: "useState를 3가지 질문으로 쓰기",
    category: "React",
    date: "2026-10-02",
    minutes: 5,
    color: "from-sky-400 to-blue-600",
    excerpt: "'무엇을 바꾸나?', '어느 state?', '새 값은?' 세 가지에 답하면 state를 바꾸는 한 줄을 쓸 수 있습니다.",
    body: [
      "state를 바꾸는 한 줄이 막히면 세 가지 질문에 차례로 답해 봅니다.",
      "첫째는 '화면에서 무엇을 바꾸고 싶은가', 둘째는 '그것을 기억하는 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: "메인 색, 강조 색, 글자 색. 이 세 가지로 대부분의 화면을 만들 수 있습니다.",
    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">
        {/* ④ shown을 map으로 나열한다. 누르면 그 글을 연다 */}
        {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 >
  )
}