mirim-app/frontend/src/pages/LearnHubPage.jsx
AWESOMEDEV 1867c050c4 feat(ux): 학습센터를 카테고리 선택 방식으로 (83강좌 한번에 → 카테고리별 12개)
강좌 83개가 한 화면에 다 펼쳐져 압도적이던 문제.
- 카테고리 탭(7개, 각 '이름 완료/전체' 표시) 추가, 선택한 카테고리 강좌만 렌더
- 처음엔 첫 카테고리(컴퓨터 기초)를 열어 둠, .tabs/.tab 기존 스타일 재사용
- 전체 진도율 바는 히어로에 그대로 유지

검증: 초기 컴퓨터기초 12개만 노출, '앱 개발' 탭 클릭 시 앱 12강좌로 전환, 콘솔에러0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:55:42 +09:00

147 lines
6.7 KiB
JavaScript

// 이 파일이 하는 일: "학습 센터" 허브 — courseCatalog의 데이터를 카테고리별 카드로 그리고,
// 내 코스 완료 현황(서버)을 얹어 진도율을 보여준다.
// 학습 포인트: 코스 목록(정적 데이터)은 courseCatalog.jsx에, 완료 여부(사용자별 데이터)는
// 서버 DB에 있다 — "무엇이 어디에 사는 게 맞는가"를 구분하는 것이 설계의 시작이다.
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import client from '../api/client';
import { COURSE_CATEGORIES, ALL_COURSES } from '../courseCatalog';
export default function LearnHubPage() {
// 완료한 코스 slug들 — 포함 여부 확인이 잦으니 Set이 제격 (대시보드와 같은 패턴)
const [completedSlugs, setCompletedSlugs] = useState(new Set());
// 지금 보고 있는 카테고리. 처음엔 첫 카테고리(컴퓨터 기초)를 연다.
// 학습 포인트: 코스 83개를 한 번에 다 펼치면 압도적이라, 카테고리 하나씩만 보여준다.
// "한 화면에 담을 정보의 양"을 줄이는 게 직관성의 핵심이다.
const [activeCategory, setActiveCategory] = useState(COURSE_CATEGORIES[0].category);
useEffect(() => {
let cancelled = false;
client.get('/course-progress/me').then((res) => {
if (!cancelled) setCompletedSlugs(new Set(res.data));
});
return () => {
cancelled = true;
};
}, []);
const totalDone = ALL_COURSES.filter((c) => completedSlugs.has(c.slug)).length;
const totalCount = ALL_COURSES.length;
const percent = totalCount === 0 ? 0 : Math.round((totalDone / totalCount) * 100);
// 선택된 카테고리 (없으면 첫 번째로 안전하게 폴백)
const group =
COURSE_CATEGORIES.find((g) => g.category === activeCategory) || COURSE_CATEGORIES[0];
return (
<div>
<div className="hero">
<div className="eyebrow">Learning Center</div>
<h1>어썸데브 학습 센터</h1>
<p>
하드웨어에서 네트워크, 서버, 코드, 디자인까지 아래에서 위로 쌓는 순서 그대로.
코스를 읽으면 하단의 <strong>"✓ 이 코스 완료하기"</strong> .
</p>
<div className="chip-row" style={{ alignItems: 'center' }}>
<div className="stat">
<span className="stat-num">{percent}%</span>
<span className="stat-label">
{totalDone} / {totalCount} 코스 완료
</span>
</div>
<div className="progress-track" style={{ flex: 1, minWidth: 160 }}>
<div className="progress-fill" style={{ width: `${percent}%` }} />
</div>
</div>
<div className="chip-row">
<span className="chip">카드의 번호(01, 02) 배우는 순서예요</span>
<span className="chip">카테고리당 12강좌</span>
<span className="chip">강좌당 40~90</span>
</div>
{/* 회사 한 줄 소개 — 대시보드와 같은 문구(홈페이지 모토) */}
<p className="muted" style={{ fontSize: 13, marginTop: 14, marginBottom: 0 }}>
어썸데브 고객과 함께 나은 세상을 만들어 나가는 기업
</p>
</div>
{/* 카테고리 선택 탭 — 누른 카테고리의 강좌만 아래에 보여준다.
각 탭에 "이름 + 완료/전체"를 함께 표시해 어디를 얼마나 했는지 한눈에. */}
<div className="tabs">
{COURSE_CATEGORIES.map((g) => {
const gDone = g.items.filter((i) => i.slug && completedSlugs.has(i.slug)).length;
return (
<button
key={g.category}
type="button"
className={`tab${g.category === activeCategory ? ' active' : ''}`}
onClick={() => setActiveCategory(g.category)}
>
{g.category} {gDone}/{g.items.length}
</button>
);
})}
</div>
{/* 선택된 카테고리 하나만 렌더 */}
{(() => {
const groupDone = group.items.filter((i) => i.slug && completedSlugs.has(i.slug)).length;
return (
<div key={group.category}>
<div className="day-head">
<span className="day-badge">{group.category}</span>
<span className="day-count">
{group.desc} · {groupDone} / {group.items.length} 완료
</span>
</div>
<div className="grid-cards">
{group.items.map((course, index) => {
// external이 있으면 그 주소로, 아니면 /learn/<slug>로 이동한다.
const to = course.external || `/learn/${course.slug}`;
const done = course.slug && completedSlugs.has(course.slug);
return (
<Link key={to} to={to} style={{ color: 'inherit' }}>
<div
className={`card card-clickable${done ? ' card-completed' : ''}`}
style={{ height: '100%' }}
>
{/* 학습 포인트: 카탈로그 배열의 순서가 곧 배우는 순서 — index로 번호를 매긴다.
padStart(2,'0')는 1을 "01"로 만들어 준다 (자릿수 맞추기). */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
}}
>
<span style={{ fontSize: 26 }}>{course.icon}</span>
<span
style={{
fontSize: 13,
fontWeight: 800,
color: done ? 'var(--teal)' : 'var(--primary)',
fontVariantNumeric: 'tabular-nums',
}}
>
{done ? '✓ 완료' : String(index + 1).padStart(2, '0')}
</span>
</div>
<div className="assign-title-row">
<span className="assign-title">{course.title}</span>
<span className="badge badge-primary">{course.level}</span>
</div>
<p className="muted" style={{ fontSize: 13.5, marginTop: 6 }}>
{course.desc}
</p>
</div>
</Link>
);
})}
</div>
</div>
);
})()}
</div>
);
}