글 목록・상세
블로그의 글 목록과 글 페이지입니다. 카테고리로 좁힐 수 있고, 카드를 누르면 글이 열립니다.
블로그
핵심 포인트
- 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: "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 > ) }