feat: 학습 센터 코스 진도 체크 — 완료 토글 API + 하단 완료 바 + 허브 진도율
- 백엔드: CourseProgress 엔티티(user+slug 유니크)·서비스·API
(GET /api/course-progress/me, POST /api/course-progress/{slug}/toggle, slug 형식 검증)
- 프론트: CourseShell 래퍼로 72개 코스에 하단 완료 바 자동 부착,
허브에 전체/카테고리별 진도율·완료 카드 표시(✓·청록 테두리)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
1303d06811
commit
b8c9da2d4a
@ -0,0 +1,66 @@
|
||||
package dev.awesomedev.mirim.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* "어떤 사용자가 학습 센터의 어떤 코스를 완료했다"는 사실 하나를 기록하는 JPA 엔티티다.
|
||||
* 행이 존재하면 완료, 없으면 미완료 — 완료 취소는 행 삭제로 표현한다 (Progress와 같은 방식).
|
||||
*
|
||||
* 학습 포인트: 체크리스트(Progress)와 달리 코스 목록은 DB 테이블이 아니라
|
||||
* 프론트의 courseCatalog.jsx(데이터 파일)에 있다. 그래서 외래키 대신
|
||||
* 코스의 slug 문자열("hardware", "sql" 같은 주소 조각)을 그대로 저장한다.
|
||||
* 장점: 코스를 추가할 때 DB 작업이 필요 없다. 단점: DB가 slug의 유효성을 보장하지 못한다.
|
||||
* 이런 트레이드오프(교환 관계)를 알고 선택하는 것이 설계다.
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "course_progress",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "course_slug"})
|
||||
)
|
||||
public class CourseProgress {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** 완료한 사용자. DB에는 user_id 외래키 컬럼이 된다. */
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id")
|
||||
private User user;
|
||||
|
||||
/** 완료한 코스의 slug (예: "hardware", "sql", "tool-vscode") */
|
||||
@Column(name = "course_slug", nullable = false, length = 60)
|
||||
private String courseSlug;
|
||||
|
||||
/** 완료한 시각 */
|
||||
@Column(nullable = false)
|
||||
private Instant completedAt;
|
||||
|
||||
protected CourseProgress() {
|
||||
}
|
||||
|
||||
public CourseProgress(User user, String courseSlug) {
|
||||
this.user = user;
|
||||
this.courseSlug = courseSlug;
|
||||
this.completedAt = Instant.now();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public String getCourseSlug() {
|
||||
return courseSlug;
|
||||
}
|
||||
|
||||
public Instant getCompletedAt() {
|
||||
return completedAt;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package dev.awesomedev.mirim.repository;
|
||||
|
||||
import dev.awesomedev.mirim.domain.CourseProgress;
|
||||
import dev.awesomedev.mirim.domain.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* course_progress 테이블에 대한 DB 접근 창구.
|
||||
* 학습 포인트: 메서드 이름 규칙(findBy + 필드명)만 지키면
|
||||
* Spring Data JPA가 SQL을 대신 만들어 준다 — SQL 입문 코스의 "JPA와 SQL의 관계" 참고.
|
||||
*/
|
||||
public interface CourseProgressRepository extends JpaRepository<CourseProgress, Long> {
|
||||
|
||||
/** 이 사용자의 완료 기록 전부 */
|
||||
List<CourseProgress> findByUser(User user);
|
||||
|
||||
/** 이 사용자가 이 코스를 완료했는지 (있으면 완료) */
|
||||
Optional<CourseProgress> findByUserAndCourseSlug(User user, String courseSlug);
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
package dev.awesomedev.mirim.service;
|
||||
|
||||
import dev.awesomedev.mirim.domain.CourseProgress;
|
||||
import dev.awesomedev.mirim.domain.User;
|
||||
import dev.awesomedev.mirim.repository.CourseProgressRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* 코스 완료 기록의 비즈니스 로직 — 내 완료 목록 조회, 완료 토글(있으면 삭제, 없으면 저장).
|
||||
* ProgressService(체크리스트)와 같은 패턴이다. 패턴이 같으면 읽는 사람이 편하다.
|
||||
*/
|
||||
@Service
|
||||
public class CourseProgressService {
|
||||
|
||||
private final CourseProgressRepository courseProgressRepository;
|
||||
|
||||
public CourseProgressService(CourseProgressRepository courseProgressRepository) {
|
||||
this.courseProgressRepository = courseProgressRepository;
|
||||
}
|
||||
|
||||
/** 내가 완료한 코스 slug 목록 */
|
||||
@Transactional(readOnly = true)
|
||||
public List<String> completedSlugs(User user) {
|
||||
// 학습 포인트: 엔티티 리스트에서 필요한 값(slug)만 뽑아 돌려준다.
|
||||
// 프론트는 slug 배열만 있으면 되니까 — 필요한 만큼만 주는 것이 좋은 API다.
|
||||
return courseProgressRepository.findByUser(user).stream()
|
||||
.map(CourseProgress::getCourseSlug)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 완료 상태 뒤집기. 반환값 = 토글 후 완료 여부.
|
||||
* 학습 포인트: "완료 취소"를 별도 API로 만들지 않고 토글 하나로 처리 —
|
||||
* 프론트 버튼 하나와 API 하나가 1:1로 대응해 단순하다.
|
||||
*/
|
||||
@Transactional
|
||||
public boolean toggle(User user, String courseSlug) {
|
||||
Optional<CourseProgress> existing =
|
||||
courseProgressRepository.findByUserAndCourseSlug(user, courseSlug);
|
||||
if (existing.isPresent()) {
|
||||
courseProgressRepository.delete(existing.get());
|
||||
return false;
|
||||
}
|
||||
courseProgressRepository.save(new CourseProgress(user, courseSlug));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
package dev.awesomedev.mirim.web;
|
||||
|
||||
import dev.awesomedev.mirim.domain.User;
|
||||
import dev.awesomedev.mirim.service.AuthService;
|
||||
import dev.awesomedev.mirim.service.CourseProgressService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* 학습 센터 코스 완료(진도) API — 내 완료 목록 조회, 완료 토글.
|
||||
* ProgressController(체크리스트)와 같은 구조다.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/course-progress")
|
||||
public class CourseProgressController {
|
||||
|
||||
private final CourseProgressService courseProgressService;
|
||||
private final AuthService authService;
|
||||
|
||||
public CourseProgressController(CourseProgressService courseProgressService,
|
||||
AuthService authService) {
|
||||
this.courseProgressService = courseProgressService;
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
/** GET /api/course-progress/me — 내가 완료한 코스 slug 배열 */
|
||||
@GetMapping("/me")
|
||||
public List<String> myCompleted(Authentication authentication) {
|
||||
User user = authService.requireUser(authentication);
|
||||
return courseProgressService.completedSlugs(user);
|
||||
}
|
||||
|
||||
/** POST /api/course-progress/{slug}/toggle — 완료 상태 뒤집기 */
|
||||
@PostMapping("/{slug}/toggle")
|
||||
public Map<String, Object> toggle(@PathVariable String slug, Authentication authentication) {
|
||||
User user = authService.requireUser(authentication);
|
||||
// 학습 포인트: slug는 프론트 카탈로그에만 있는 값이라 서버가 목록 검증은 못 하지만,
|
||||
// 형식(소문자·숫자·하이픈, 60자 이내)은 검사해서 이상한 값이 쌓이는 것을 막는다.
|
||||
if (!slug.matches("[a-z0-9-]{1,60}")) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "잘못된 코스 주소입니다.");
|
||||
}
|
||||
boolean completed = courseProgressService.toggle(user, slug);
|
||||
return Map.of("slug", slug, "completed", completed);
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@ import { Suspense } from 'react';
|
||||
import { Routes, Route, NavLink, Navigate, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { ALL_COURSES } from './courseCatalog';
|
||||
import CourseShell from './components/CourseShell';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import SignupPage from './pages/SignupPage';
|
||||
import FindAccountPage from './pages/FindAccountPage';
|
||||
@ -112,11 +113,13 @@ export default function App() {
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
{/* 학습 센터: 허브 + 코스들(카탈로그에서 자동 생성) */}
|
||||
<Route path="/learn" element={<LearnHubPage />} />
|
||||
{/* 학습 포인트: 코스를 CourseShell로 감싸면 72개 코스 전부에
|
||||
하단 "완료" 바가 자동으로 붙는다 — 공통 기능은 감싸는 컴포넌트로. */}
|
||||
{ALL_COURSES.map((course) => (
|
||||
<Route
|
||||
key={course.slug}
|
||||
path={`/learn/${course.slug}`}
|
||||
element={<course.Component />}
|
||||
element={<CourseShell course={course} />}
|
||||
/>
|
||||
))}
|
||||
<Route path="/setup" element={<SetupGuidePage />} />
|
||||
|
||||
70
frontend/src/components/CourseShell.jsx
Normal file
70
frontend/src/components/CourseShell.jsx
Normal file
@ -0,0 +1,70 @@
|
||||
// 이 파일이 하는 일: 모든 코스 페이지를 감싸는 "껍데기" — 코스 본문 아래에
|
||||
// 완료 체크 바(뒤로가기 + 완료 버튼)를 붙여 준다.
|
||||
//
|
||||
// 학습 포인트: 코스가 72개인데 완료 버튼을 72개 파일에 각각 넣으면 유지보수 지옥이 된다.
|
||||
// 라우트에서 코스를 이 컴포넌트로 한 번 감싸면(App.jsx 참고) 모든 코스가 자동으로
|
||||
// 완료 바를 갖는다 — "공통 기능은 감싸는 컴포넌트로"가 React의 대표 패턴이다.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import client from '../api/client';
|
||||
|
||||
export default function CourseShell({ course }) {
|
||||
const [completed, setCompleted] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 학습 포인트: 대문자 변수에 담아야 JSX에서 <Component />로 쓸 수 있다.
|
||||
// (소문자로 시작하면 React가 HTML 태그로 착각한다)
|
||||
const Component = course.Component;
|
||||
|
||||
// 이 코스의 완료 여부를 서버에서 확인
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
client
|
||||
.get('/course-progress/me')
|
||||
.then((res) => {
|
||||
if (!cancelled) setCompleted(res.data.includes(course.slug));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [course.slug]);
|
||||
|
||||
async function toggle() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await client.post(`/course-progress/${course.slug}/toggle`);
|
||||
setCompleted(res.data.completed);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Component />
|
||||
|
||||
{/* 코스 하단 완료 바 — sticky로 화면 아래에 붙어 다닌다 */}
|
||||
<div className={`course-complete-bar${completed ? ' done' : ''}`}>
|
||||
<Link to="/learn" className="btn btn-ghost">
|
||||
← 학습 센터
|
||||
</Link>
|
||||
<span className="course-complete-label">
|
||||
{course.icon} {course.title}
|
||||
{completed && <span className="badge badge-teal" style={{ marginLeft: 8 }}>완료됨</span>}
|
||||
</span>
|
||||
<button
|
||||
className={completed ? 'btn' : 'btn btn-primary'}
|
||||
onClick={toggle}
|
||||
disabled={loading || saving}
|
||||
>
|
||||
{saving ? '저장 중...' : completed ? '완료 취소' : '✓ 이 코스 완료하기'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,30 @@
|
||||
// 이 파일이 하는 일: "학습 센터" 허브 — courseCatalog의 데이터를 카테고리별 카드로 그린다.
|
||||
// 학습 포인트: 이 파일엔 코스 목록이 없다! 목록은 courseCatalog.jsx(데이터)에 있고,
|
||||
// 여기는 "그리는 법"만 안다. 코스를 추가해도 이 파일은 한 글자도 안 바뀐다.
|
||||
// 이 파일이 하는 일: "학습 센터" 허브 — 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());
|
||||
|
||||
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);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="hero">
|
||||
@ -12,8 +32,19 @@ export default function LearnHubPage() {
|
||||
<h1>어썸데브 학습 센터</h1>
|
||||
<p>
|
||||
하드웨어에서 네트워크, 서버, 코드, 디자인까지 — 아래에서 위로 쌓는 순서 그대로.
|
||||
총 {ALL_COURSES.length + 1}개 강좌, 아침 수업(CS 커리큘럼)과 짝을 이루는 자율 학습 코너예요.
|
||||
코스를 다 읽으면 하단의 <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>
|
||||
@ -21,57 +52,64 @@ export default function LearnHubPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{COURSE_CATEGORIES.map((group) => (
|
||||
<div key={group.category}>
|
||||
<div className="day-head">
|
||||
<span className="day-badge">{group.category}</span>
|
||||
<span className="day-count">
|
||||
{group.desc} · {group.items.length}강좌
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid-cards">
|
||||
{group.items.map((course, index) => {
|
||||
// external이 있으면 그 주소로, 아니면 /learn/<slug>로 이동한다.
|
||||
const to = course.external || `/learn/${course.slug}`;
|
||||
return (
|
||||
<Link key={to} to={to} style={{ color: 'inherit' }}>
|
||||
<div className="card card-clickable" style={{ height: '100%' }}>
|
||||
{/* 학습 포인트: 카탈로그 배열의 순서가 곧 배우는 순서 — index로 번호를 매긴다.
|
||||
padStart(2,'0')는 1을 "01"로 만들어 준다 (자릿수 맞추기). */}
|
||||
{COURSE_CATEGORIES.map((group) => {
|
||||
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
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
className={`card card-clickable${done ? ' card-completed' : ''}`}
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
<span style={{ fontSize: 26 }}>{course.icon}</span>
|
||||
<span
|
||||
{/* 학습 포인트: 카탈로그 배열의 순서가 곧 배우는 순서 — index로 번호를 매긴다.
|
||||
padStart(2,'0')는 1을 "01"로 만들어 준다 (자릿수 맞추기). */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 800,
|
||||
color: 'var(--primary)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -722,6 +722,40 @@ inline-code, .icode {
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* 코스 완료 바 — 코스 페이지 하단에 sticky로 붙는다 (CourseShell.jsx) */
|
||||
.course-complete-bar {
|
||||
position: sticky;
|
||||
bottom: 14px;
|
||||
margin-top: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 6px 24px rgba(16, 26, 40, 0.14);
|
||||
}
|
||||
|
||||
.course-complete-bar.done {
|
||||
border-color: color-mix(in srgb, var(--teal) 45%, transparent);
|
||||
}
|
||||
|
||||
.course-complete-label {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 허브 카드 완료 상태 */
|
||||
.card-completed {
|
||||
border-color: color-mix(in srgb, var(--teal) 45%, transparent);
|
||||
}
|
||||
|
||||
/* 접었다 펴는 상세 — 학습 포인트: <details>는 JS 없이도 접기/펼치기가 되는 HTML 기본 기능 */
|
||||
.fold summary {
|
||||
cursor: pointer;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user