강좌 83개가 한 화면에 다 펼쳐져 압도적이던 문제. - 카테고리 탭(7개, 각 '이름 완료/전체' 표시) 추가, 선택한 카테고리 강좌만 렌더 - 처음엔 첫 카테고리(컴퓨터 기초)를 열어 둠, .tabs/.tab 기존 스타일 재사용 - 전체 진도율 바는 히어로에 그대로 유지 검증: 초기 컴퓨터기초 12개만 노출, '앱 개발' 탭 클릭 시 앱 12강좌로 전환, 콘솔에러0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
147 lines
6.7 KiB
JavaScript
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>
|
|
);
|
|
}
|