내 강좌실 작업중11

This commit is contained in:
wallace
2025-12-01 15:07:15 +09:00
parent 3ef3990f1d
commit eea113040b
18 changed files with 1559 additions and 418 deletions

View File

@@ -160,7 +160,7 @@ export default function AdminNoticeDetailPage() {
</div>
<main className="w-[1120px] bg-white">
<div className="h-full flex flex-col">
<div className="h-[100px] flex items-center gap-[10px] px-[32px]">
<div className="h-[100px] flex items-center gap-[12px] px-[32px]">
<Link
href="/admin/notices"
aria-label="뒤로 가기"
@@ -198,7 +198,7 @@ export default function AdminNoticeDetailPage() {
<main className="w-[1120px] bg-white">
<div className="h-full flex flex-col">
{/* 상단 타이틀 */}
<div className="h-[100px] flex items-center gap-[10px] px-[32px]">
<div className="h-[100px] flex items-center gap-[12px] px-[32px]">
<Link
href="/admin/notices"
aria-label="뒤로 가기"

View File

@@ -160,7 +160,7 @@ export default function AdminResourceDetailPage() {
</div>
<main className="w-[1120px] bg-white">
<div className="h-full flex flex-col">
<div className="h-[100px] flex items-center gap-[10px] px-[32px]">
<div className="h-[100px] flex items-center gap-[12px] px-[32px]">
<Link
href="/admin/resources"
aria-label="뒤로 가기"
@@ -198,7 +198,7 @@ export default function AdminResourceDetailPage() {
<main className="w-[1120px] bg-white">
<div className="h-full flex flex-col">
{/* 상단 타이틀 */}
<div className="h-[100px] flex items-center gap-[10px] px-[32px]">
<div className="h-[100px] flex items-center gap-[12px] px-[32px]">
<Link
href="/admin/resources"
aria-label="뒤로 가기"

View File

@@ -1,10 +1,19 @@
'use client';
import { usePathname } from "next/navigation";
import MainLogoSvg from '../svgs/mainlogosvg';
export default function Footer() {
const pathname = usePathname();
const isMenuPage = pathname?.startsWith('/menu');
return (
<footer className="bg-[#f2f3f7] border-t border-[rgba(0,0,0,0.1)]">
<footer className="bg-[#f2f3f7] border-t border-[rgba(0,0,0,0.1)] relative">
{isMenuPage && (
<div className="absolute left-1/2 -translate-x-1/2 w-full max-w-[1440px]">
<div className="absolute left-[320px] top-0 bottom-0 w-px bg-[#dee1e6]"></div>
</div>
)}
<div className="flex justify-center">
<div className="w-full max-w-[1440px] px-[1px] py-[40px] flex gap-[32px]">
<div className="flex flex-col items-center gap-[7px] w-[72px]">

View File

@@ -0,0 +1,43 @@
"use client";
import React from "react";
type TokenExpiredModalProps = {
open: boolean;
onClose: () => void;
};
export default function TokenExpiredModal({ open, onClose }: TokenExpiredModalProps) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<button
type="button"
aria-label="닫기"
className="absolute inset-0 bg-black/40 cursor-default"
onClick={onClose}
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="token-expired-title"
className="relative bg-white box-border flex flex-col items-stretch justify-start p-6 rounded-[8px] min-w-[500px] max-w-[calc(100%-48px)]"
>
<div className="text-[18px] leading-normal font-semibold text-neutral-700 mb-8" id="token-expired-title">
.
</div>
<div className="flex items-center justify-end gap-[8px]">
<button
type="button"
onClick={onClose}
className="h-[40px] min-w-20 px-[12px] rounded-[8px] bg-active-button text-white text-[16px] font-semibold cursor-pointer"
>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import React, { createContext, useContext, useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import TokenExpiredModal from "./TokenExpiredModal";
type TokenExpiredContextType = {
showModal: () => void;
};
const TokenExpiredContext = createContext<TokenExpiredContextType | undefined>(undefined);
export function useTokenExpired() {
const context = useContext(TokenExpiredContext);
if (!context) {
throw new Error("useTokenExpired must be used within TokenExpiredProvider");
}
return context;
}
export function TokenExpiredProvider({ children }: { children: React.ReactNode }) {
const [isModalOpen, setIsModalOpen] = useState(false);
const router = useRouter();
const showModal = () => {
setIsModalOpen(true);
};
const handleClose = () => {
setIsModalOpen(false);
// 모달 닫은 후 로그인 페이지로 이동
router.push("/login");
};
// 전역 이벤트 리스너 등록
useEffect(() => {
const handleTokenExpired = () => {
showModal();
};
window.addEventListener("tokenExpired", handleTokenExpired);
return () => {
window.removeEventListener("tokenExpired", handleTokenExpired);
};
}, []);
return (
<TokenExpiredContext.Provider value={{ showModal }}>
{children}
<TokenExpiredModal open={isModalOpen} onClose={handleClose} />
</TokenExpiredContext.Provider>
);
}

View File

@@ -36,6 +36,7 @@ export default function CourseDetailPage() {
const [lectures, setLectures] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lectureProgresses, setLectureProgresses] = useState<Record<string, any>>({});
useEffect(() => {
const fetchCourse = async () => {
@@ -109,6 +110,26 @@ export default function CourseDetailPage() {
setLectures(filteredLectures);
// 각 lecture의 진행률 조회
const progressMap: Record<string, any> = {};
await Promise.all(
filteredLectures.map(async (lecture: any) => {
const lectureId = lecture.id || lecture.lectureId;
if (lectureId) {
try {
const progressResponse = await apiService.getLectureProgress(lectureId);
if (progressResponse?.data) {
progressMap[String(lectureId)] = progressResponse.data;
}
} catch (progressErr) {
// 진행률이 없으면 무시 (에러가 아닐 수 있음)
console.log(`Lecture ${lectureId} 진행률 조회 실패:`, progressErr);
}
}
})
);
setLectureProgresses(progressMap);
// API 응답 데이터를 CourseDetail 타입으로 변환
const courseDetail: CourseDetail = {
id: String(data.id || params.id),
@@ -287,16 +308,27 @@ export default function CourseDetailPage() {
<div className="flex flex-col gap-[8px] items-start mt-[24px] w-full">
{lectures.length > 0 ? (
lectures.map((lecture: any, index: number) => {
const isSubmitted = false; // TODO: 진행률 API에서 가져와야 함
const action = isSubmitted ? "복습하기" : (index === 0 ? "수강하기" : "이어서 수강하기");
const lectureId = String(lecture.id || lecture.lectureId);
const progress = lectureProgresses[lectureId];
const isCompleted = progress?.isCompleted === true;
const hasProgress = !!progress;
// 버튼 텍스트 결정: isCompleted면 "복습하기", progress 있으면 "이어서 수강하기", 없으면 "수강하기"
const action = isCompleted ? "복습하기" : (hasProgress ? "이어서 수강하기" : "수강하기");
const isSubmitted = false; // TODO: 학습 제출 상태는 별도로 관리 필요
const submitBtnBorder = isSubmitted
? "border-transparent"
: (action === "이어서 수강하기" || action === "수강하기" ? "border-[#b1b8c0]" : "border-[#8c95a1]");
const submitBtnText = isSubmitted ? "text-[#384fbf]" : (action === "이어서 수강하기" || action === "수강하기" ? "text-[#b1b8c0]" : "text-[#4c5561]");
// isCompleted면 배경색 #F1F3F5, 아니면 기본 스타일
const rightBtnStyle =
action === "이어서 수강하기" || action === "수강하기"
isCompleted
? "bg-[#f1f3f5] text-[#4c5561]"
: (action === "이어서 수강하기" || action === "수강하기"
? "bg-[#ecf0ff] text-[#384fbf]"
: "bg-[#f1f3f5] text-[#4c5561]";
: "bg-[#f1f3f5] text-[#4c5561]");
return (
<div key={lecture.id || lecture.lectureId || index} className="bg-white border border-[#dee1e6] border-solid relative rounded-[8px] w-full">
@@ -349,23 +381,37 @@ export default function CourseDetailPage() {
{/* 수강/복습 버튼 */}
<button
type="button"
onClick={() => {
onClick={async () => {
const lectureId = lecture.id || lecture.lectureId;
if (lectureId) {
// "수강하기" 버튼 클릭 시 progress 생성
if (action === "수강하기") {
try {
await apiService.updateLectureProgress({
lectureId: Number(lectureId),
progressPct: 0,
lastPositionSec: 0,
});
} catch (error) {
console.error('Progress 생성 실패:', error);
}
}
router.push(`/menu/courses/lessons/${lectureId}/start`);
}
}}
className={[
"box-border flex flex-col h-[32px] items-center justify-center px-[16px] py-[3px] rounded-[6px] shrink-0 transition-colors",
rightBtnStyle,
action === "이어서 수강하기" || action === "수강하기"
isCompleted
? "hover:bg-[#e5e8eb]"
: (action === "이어서 수강하기" || action === "수강하기"
? "hover:bg-[#d0d9ff]"
: "hover:bg-[#e5e8eb]",
: "hover:bg-[#e5e8eb]"),
].join(" ")}
>
<p className={[
"font-['Pretendard:Medium',sans-serif] text-[14px] leading-[1.5] text-center",
action === "이어서 수강하기" || action === "수강하기" ? "text-[#384fbf]" : "text-[#4c5561]",
isCompleted ? "text-[#4c5561]" : (action === "이어서 수강하기" || action === "수강하기" ? "text-[#384fbf]" : "text-[#4c5561]"),
].join(" ")}>{action}</p>
</button>
</div>

View File

@@ -3,6 +3,7 @@ import "./globals.css";
import { pretendard } from "./fonts";
import HeaderVisibility from "./components/HeaderVisibility";
import FooterVisibility from "./components/FooterVisibility";
import { TokenExpiredProvider } from "./components/TokenExpiredProvider";
export const metadata: Metadata = {
title: "XRLMS",
@@ -15,11 +16,13 @@ export default function RootLayout({
return (
<html lang="ko">
<body className={`${pretendard.className} min-h-screen flex flex-col`}>
<TokenExpiredProvider>
<HeaderVisibility />
<main className="flex-1 min-h-0">
{children}
</main>
<FooterVisibility />
</TokenExpiredProvider>
</body>
</html>
);

View File

@@ -45,7 +45,7 @@ class ApiService {
}
/**
* 토큰 삭제 및 로그인 페이지로 리다이렉트
* 토큰 삭제 및 토큰 만료 모달 표시
*/
private handleTokenError() {
if (typeof window === 'undefined') return;
@@ -54,14 +54,11 @@ class ApiService {
localStorage.removeItem('token');
document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
// 현재 경로 가져오기
// 로그인 페이지가 아닐 때만 모달 표시
const currentPath = window.location.pathname;
// 로그인 페이지가 아닐 때만 리다이렉트
if (currentPath !== '/login') {
const loginUrl = new URL('/login', window.location.origin);
loginUrl.searchParams.set('redirect', currentPath);
window.location.href = loginUrl.toString();
// 전역 이벤트 발생하여 모달 표시
window.dispatchEvent(new CustomEvent('tokenExpired'));
}
}

View File

@@ -59,10 +59,8 @@ export default function LoginPage() {
// admin 권한이면 /admin/id로 리다이렉트
router.push('/admin/id');
} else {
// 그 외의 경우 기존 로직대로 리다이렉트
const searchParams = new URLSearchParams(window.location.search);
const redirectPath = searchParams.get('redirect') || '/';
router.push(redirectPath);
// 그 외의 경우 홈 페이지로 이동 (redirect 파라미터 무시)
router.push('/');
}
})
.catch(() => {
@@ -144,12 +142,9 @@ export default function LoginPage() {
// 사용자 정보 조회 실패 시에도 기존 로직대로 진행
}
// 리다이렉트 경로 확인
const searchParams = new URLSearchParams(window.location.search);
const redirectPath = searchParams.get('redirect') || '/';
// 메인 페이지로 이동
router.push(redirectPath);
// redirect 파라미터 무시하고 계정에 따른 메인 페이지로 이동
// admin이 아닌 경우 홈 페이지로 이동
router.push('/');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "네트워크 오류가 발생했습니다.";
console.error("로그인 오류:", errorMessage);

View File

@@ -2,8 +2,6 @@
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState } from "react";
import ChevronDownSvg from "../../svgs/chevrondownsvg";
type Lesson = {
id: string;
@@ -35,31 +33,26 @@ function ProgressBar({ value }: { value: number }) {
);
}
export default function CourseCard({ course, defaultOpen = false }: { course: Course; defaultOpen?: boolean }) {
const [open, setOpen] = useState(defaultOpen);
export default function CourseCard({ course }: { course: Course }) {
const router = useRouter();
const totalMinutes = course.lessons.reduce((sum, l) => sum + (l.durationMin || 0), 0);
const totalHours = Math.floor(totalMinutes / 60);
const restMinutes = totalMinutes % 60;
const firstIncomplete = course.lessons.find((l) => !l.isCompleted)?.id;
const cardClassName = [
"rounded-xl bg-white shadow-[0_2px_8px_rgba(0,0,0,0.02)]",
open ? "border-[3px] border-primary" : "border border-bg-primary-light",
].join(" ");
const formatDuration = (m: number) => {
const minutes = String(m).padStart(2, "0");
const seconds = String((m * 7) % 60).padStart(2, "0");
return `${minutes}:${seconds}`;
const handleClick = () => {
router.push(`/menu/courses/${course.id}`);
};
return (
<article className={cardClassName}>
<header className="flex items-center gap-6 px-8 py-6">
<article className="rounded-xl bg-white shadow-[0_2px_8px_rgba(0,0,0,0.02)] border border-bg-primary-light">
<header
onClick={handleClick}
className="flex items-center gap-6 px-8 py-6 cursor-pointer hover:bg-bg-gray-light/50 transition-colors"
>
<div className="relative h-[120px] w-[180px] overflow-hidden rounded-[8px] bg-bg-gray-light">
<Image
src={`https://picsum.photos/seed/${encodeURIComponent(course.id)}/240/152`}
src={course.thumbnail || `https://picsum.photos/seed/${encodeURIComponent(course.id)}/240/152`}
alt=""
fill
sizes="180px"
@@ -89,108 +82,7 @@ export default function CourseCard({ course, defaultOpen = false }: { course: Co
<ProgressBar value={course.progressPct} />
</div>
</div>
<div className="flex items-center gap-2 self-start">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="flex h-8 w-8 items-center justify-center text-text-label cursor-pointer"
aria-label={open ? "접기" : "펼치기"}
>
<ChevronDownSvg
width={16}
height={16}
className={["transition-transform", open ? "rotate-180" : "rotate-0"].join(" ")}
/>
</button>
</div>
</header>
{open ? (
<div className="px-6 pb-6">
<ul className="flex flex-col gap-2">
{course.lessons.map((lesson, idx) => (
<li key={lesson.id} className="rounded-lg border border-input-border bg-white">
<div className="flex items-center justify-between gap-4 px-[24px] py-[16px]">
<div className="min-w-0 flex-1">
<p className="truncate text-[16px] font-semibold leading-normal text-neutral-700">
{`${idx + 1}. ${lesson.title}`}
</p>
<div className="mt-2 flex items-center gap-3">
<span className="text-[13px] leading-[1.4] text-text-meta">
{formatDuration(lesson.durationMin)}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className={[
"inline-flex h-[32px] w-[140px] items-center justify-center gap-[6px] rounded-[6px] px-4 text-center whitespace-nowrap cursor-pointer",
lesson.isCompleted
? "bg-white text-[13px] font-medium leading-[1.4] text-primary"
: "bg-white text-[14px] font-medium leading-normal text-basic-text border border-text-meta",
].join(" ")}
>
{lesson.isCompleted ? (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="7"
viewBox="0 0 10 7"
fill="none"
aria-hidden
>
<path
d="M8.75 0.75L3.25 6.25L0.75 3.75"
stroke="var(--color-primary)"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span> </span>
</>
) : (
"학습 제출 하기"
)}
</button>
{lesson.isCompleted ? (
<button
type="button"
onClick={() => router.push(`/menu/courses/lessons/${lesson.id}/review`)}
className="inline-flex h-[32px] w-[140px] items-center justify-center rounded-[6px] bg-bg-gray-light px-4 text-center text-[14px] font-medium leading-normal text-basic-text whitespace-nowrap cursor-pointer"
>
</button>
) : (
<button
type="button"
onClick={() =>
router.push(
lesson.id === firstIncomplete
? `/menu/courses/lessons/${lesson.id}/continue`
: `/menu/courses/lessons/${lesson.id}/start`,
)
}
className={[
"inline-flex h-[32px] w-[140px] items-center justify-center rounded-[6px] px-4 text-center text-[14px] font-medium leading-normal whitespace-nowrap cursor-pointer",
lesson.id === firstIncomplete
? "bg-bg-primary-light text-primary"
: "border border-input-border text-basic-text",
].join(" ")}
>
{lesson.id === firstIncomplete ? "이어서 수강하기" : "수강하기"}
</button>
)}
</div>
</div>
</li>
))}
</ul>
</div>
) : null}
</article>
);
}

View File

@@ -1,6 +1,13 @@
'use client';
import Image from "next/image";
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Image from 'next/image';
import apiService from '../../../lib/apiService';
import BackCircleSvg from '../../../svgs/backcirclesvg';
const imgPlay = '/imgs/play.svg';
const imgMusicAudioPlay = '/imgs/music-audio-play.svg';
type Lesson = {
id: string;
@@ -22,113 +29,434 @@ type CourseDetail = {
lessons: Lesson[];
};
const MOCK_DETAIL: CourseDetail = {
id: "c1",
status: "수강 중",
title: "원자로 운전 및 계통",
goal:
"원자로 운전 원리와 주요 계통의 구조 및 기능을 이해하고, 실제 운전 상황을 가상 환경에서 체험하여 문제 해결 능력을 기른다.",
method:
"강좌 동영상을 통해 이론을 학습한 후, XR 실습을 통해 원자로 운전 및 계통 제어 과정을 체험한다. 이후 문제 풀이를 통해 학습 내용을 점검하며 평가를 받아 학습 성취도를 확인한다.",
summary: "VOD · 총 6강 · 4시간 20분",
submitSummary: "학습 제출 3/6",
thumbnail: "https://picsum.photos/seed/course-detail/584/318",
lessons: [
{ id: "l1", title: "1. 원자로 기초 및 핵분열 원리", duration: "12:46", state: "제출완료", action: "복습하기" },
{ id: "l2", title: "2. 제어봉 및 원자로 출력 제어", duration: "18:23", state: "제출완료", action: "복습하기" },
{ id: "l3", title: "3. 터빈-발전기 계통 및 전력 생산", duration: "13:47", state: "제출완료", action: "복습하기" },
{ id: "l4", title: "4. 보조 계통 및 안전 계통", duration: "13:47", state: "제출대기", action: "복습하기" },
{ id: "l5", title: "5. 원자로 냉각재 계통 (RCS) 및 열수력", duration: "08:11", state: "제출대기", action: "이어서 수강하기" },
{ id: "l6", title: "6. 원자로 시동, 운전 및 정지 절차", duration: "13:47", state: "제출대기", action: "수강하기" },
],
};
export default function CourseDetailPage() {
const c = MOCK_DETAIL;
const params = useParams();
const router = useRouter();
const [course, setCourse] = useState<CourseDetail | null>(null);
const [lectures, setLectures] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lectureProgresses, setLectureProgresses] = useState<Record<string, any>>({});
useEffect(() => {
const fetchCourse = async () => {
if (!params?.courseId) return;
try {
setLoading(true);
setError(null);
let data: any = null;
const subjectId = String(params.courseId);
// getSubjects로 과목 정보 가져오기
try {
const subjectsResponse = await apiService.getSubjects();
let subjectsData: any[] = [];
if (Array.isArray(subjectsResponse.data)) {
subjectsData = subjectsResponse.data;
} else if (subjectsResponse.data && typeof subjectsResponse.data === 'object') {
subjectsData = subjectsResponse.data.items ||
subjectsResponse.data.courses ||
subjectsResponse.data.data ||
subjectsResponse.data.list ||
subjectsResponse.data.subjects ||
subjectsResponse.data.subjectList ||
[];
}
// ID로 과목 찾기
data = subjectsData.find((s: any) => String(s.id || s.subjectId) === subjectId);
} catch (subjectsErr) {
console.error('getSubjects 실패:', subjectsErr);
}
if (!data) {
throw new Error('강좌를 찾을 수 없습니다.');
}
// 썸네일 이미지 가져오기
let thumbnail = '/imgs/talk.png';
if (data.imageKey) {
try {
const imageUrl = await apiService.getFile(data.imageKey);
if (imageUrl) {
thumbnail = imageUrl;
}
} catch (imgErr) {
console.error('이미지 다운로드 실패:', imgErr);
}
}
// getLectures로 모든 lecture 가져오기
let allLectures: any[] = [];
try {
const lecturesResponse = await apiService.getLectures();
if (Array.isArray(lecturesResponse.data)) {
allLectures = lecturesResponse.data;
} else if (lecturesResponse.data && typeof lecturesResponse.data === 'object') {
allLectures = lecturesResponse.data.items ||
lecturesResponse.data.lectures ||
lecturesResponse.data.data ||
lecturesResponse.data.list ||
[];
}
// subjectId로 필터링
const filteredLectures = allLectures.filter((lecture: any) =>
String(lecture.subjectId || lecture.subject_id) === subjectId
);
setLectures(filteredLectures);
// 각 lecture의 진행률 조회
const progressMap: Record<string, any> = {};
await Promise.all(
filteredLectures.map(async (lecture: any) => {
const lectureId = lecture.id || lecture.lectureId;
if (lectureId) {
try {
const progressResponse = await apiService.getLectureProgress(lectureId);
if (progressResponse?.data) {
progressMap[String(lectureId)] = progressResponse.data;
}
} catch (progressErr) {
// 진행률이 없으면 무시 (에러가 아닐 수 있음)
console.log(`Lecture ${lectureId} 진행률 조회 실패:`, progressErr);
}
}
})
);
setLectureProgresses(progressMap);
// 전체 진행률 계산
const totalMinutes = filteredLectures.reduce((sum, l) => sum + (l.durationMin || l.duration_min || l.duration || 0), 0);
const totalHours = Math.floor(totalMinutes / 60);
const restMinutes = totalMinutes % 60;
// API 응답 데이터를 CourseDetail 타입으로 변환
const courseDetail: CourseDetail = {
id: String(data.id || params.courseId),
status: data.status || "수강 예정",
title: data.title || data.lectureName || data.subjectName || '',
goal: data.objective || data.goal || '',
method: data.method || '',
summary: `VOD · 총 ${filteredLectures.length || 0}강 · ${totalHours}시간 ${restMinutes}`,
submitSummary: data.submitSummary || '',
thumbnail: thumbnail,
lessons: [],
};
setCourse(courseDetail);
} catch (lecturesErr) {
console.error('getLectures 실패:', lecturesErr);
// API 응답 데이터를 CourseDetail 타입으로 변환 (lecture 없이)
const courseDetail: CourseDetail = {
id: String(data.id || params.courseId),
status: data.status || "수강 예정",
title: data.title || data.lectureName || data.subjectName || '',
goal: data.objective || data.goal || '',
method: data.method || '',
summary: `VOD · 총 0강`,
submitSummary: data.submitSummary || '',
thumbnail: thumbnail,
lessons: [],
};
setCourse(courseDetail);
}
} catch (err) {
console.error('강좌 조회 실패:', err);
setError(err instanceof Error ? err.message : '강좌를 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
fetchCourse();
}, [params?.courseId]);
if (loading) {
return (
<main className="flex w-full flex-col">
<div className="flex h-[100px] items-center px-8">
<div className="flex h-[100px] items-center gap-[12px] px-8">
<button
type="button"
onClick={() => router.back()}
aria-label="뒤로 가기"
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline cursor-pointer"
>
<BackCircleSvg width={32} height={32} />
</button>
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]"> </h1>
</div>
<section className="px-8 pb-20">
<div className="flex items-center justify-center py-20">
<p className="text-[16px] text-[#8c95a1]"> ...</p>
</div>
</section>
</main>
);
}
if (error || !course) {
return (
<main className="flex w-full flex-col">
<div className="flex h-[100px] items-center gap-[12px] px-8">
<button
type="button"
onClick={() => router.back()}
aria-label="뒤로 가기"
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline cursor-pointer"
>
<BackCircleSvg width={32} height={32} />
</button>
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]"> </h1>
</div>
<section className="px-8 pb-20">
<div className="flex flex-col items-center justify-center py-20">
<p className="text-[16px] text-red-500 mb-4">{error || '강좌를 찾을 수 없습니다.'}</p>
<button
type="button"
onClick={() => router.push('/menu/courses')}
className="px-4 py-2 rounded-[6px] bg-primary text-white text-[14px] font-medium"
>
</button>
</div>
</section>
</main>
);
}
const formatDuration = (minutes: number) => {
const mins = Math.floor(minutes);
const secs = Math.floor((minutes - mins) * 60);
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
};
return (
<main className="flex w-full flex-col">
{/* 헤더 */}
<div className="flex h-[100px] items-center gap-[12px] px-8">
<button
type="button"
onClick={() => router.back()}
aria-label="뒤로 가기"
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline cursor-pointer"
>
<BackCircleSvg width={32} height={32} />
</button>
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]"> </h1>
</div>
<section className="px-8 pb-20">
<div className="rounded-[8px] bg-white px-8 pb-20 pt-6">
{/* 상단 소개 카드 */}
<div className="flex gap-6 rounded-[8px] bg-[#f8f9fa] p-6">
<div className="relative h-[159px] w-[292px] overflow-hidden rounded">
<Image src={c.thumbnail} alt="" fill sizes="292px" className="object-cover" unoptimized />
{/* 메인 콘텐츠 */}
<section className="px-8 pb-[80px] pt-[24px]">
{/* 상단 정보 카드 */}
<div className="bg-[#f8f9fa] box-border flex gap-[24px] items-start p-[24px] rounded-[8px] w-full">
{/* 이미지 컨테이너 */}
<div className="overflow-clip relative rounded-[4px] shrink-0 w-[220.5px] h-[159px]">
<Image
src={course.thumbnail}
alt={course.title}
fill
sizes="220.5px"
className="object-cover"
unoptimized
/>
</div>
<div className="flex-1">
<div className="flex h-[27px] items-center gap-2">
<span className="h-[20px] rounded-[4px] bg-[#e5f5ec] px-1.5 text-[13px] font-semibold leading-[1.4] text-[#0c9d61]">
{c.status}
</span>
<h2 className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">{c.title}</h2>
{/* 교육과정 정보 */}
<div className="basis-0 flex flex-col gap-[12px] grow items-start min-h-px min-w-px relative shrink-0">
{/* 제목 영역 */}
<div className="flex gap-[8px] h-[27px] items-center w-full">
<div className="bg-[#e5f5ec] box-border flex h-[20px] items-center justify-center px-[4px] py-0 rounded-[4px] shrink-0">
<p className="font-['Pretendard:SemiBold',sans-serif] text-[#0c9d61] text-[13px] leading-[1.4]">{course.status}</p>
</div>
<div className="mt-3 space-y-1">
<p className="text-[15px] leading-[1.5] text-[#333c47]">
<span className="font-medium"> :</span> {c.goal}
<h2 className="font-['Pretendard:SemiBold',sans-serif] text-[#333c47] text-[18px] leading-[1.5]">{course.title}</h2>
</div>
{/* 학습 목표 및 방법 */}
<div className="flex flex-col gap-[4px] items-start w-full">
<p className="font-['Pretendard:Regular',sans-serif] text-[#333c47] text-[15px] leading-[1.5] mb-0">
<span className="font-['Pretendard:Medium',sans-serif]"> :</span>
<span>{` ${course.goal}`}</span>
</p>
<p className="text-[15px] leading-[1.5] text-[#333c47]">
<span className="font-medium"> :</span> {c.method}
<p className="font-['Pretendard:Regular',sans-serif] text-[#333c47] text-[15px] leading-[1.5]">
<span className="font-['Pretendard:Medium',sans-serif]"> :</span>
<span>{` ${course.method}`}</span>
</p>
</div>
<div className="mt-3 flex items-center gap-5 text-[13px] leading-[1.4] text-[#8c95a1]">
<span>{c.summary}</span>
<span>{c.submitSummary}</span>
{/* 통계 정보 */}
<div className="flex gap-[4px] items-center w-full">
<div className="flex gap-[20px] items-center">
{/* VOD 정보 */}
<div className="flex gap-[4px] items-center">
<div className="relative shrink-0 size-[16px]">
<img src={imgPlay} alt="" className="block max-w-none size-full" />
</div>
<p className="font-['Pretendard:Medium',sans-serif] text-[#8c95a1] text-[13px] leading-[1.4]">{course.summary}</p>
</div>
{/* 학습 제출 정보 */}
{course.submitSummary && (
<div className="flex gap-[4px] items-center">
<div className="relative shrink-0 size-[16px]">
<img src={imgMusicAudioPlay} alt="" className="block max-w-none size-full" />
</div>
<p className="font-['Pretendard:Medium',sans-serif] text-[#8c95a1] text-[13px] leading-[1.4]">{course.submitSummary}</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* 차시 리스트 */}
<div className="mt-6 space-y-2">
{c.lessons.map((l) => {
const isSubmitted = l.state === "제출완료";
const submitBtnStyle =
l.state === "제출완료"
? "border border-transparent text-[#384fbf]"
: "border " + (l.action === "이어서 수강하기" || l.action === "수강하기" ? "border-[#b1b8c0]" : "border-[#8c95a1]");
{/* Lecture 리스트 */}
<div className="flex flex-col gap-[8px] items-start mt-[24px] w-full">
{lectures.length > 0 ? (
lectures.map((lecture: any, index: number) => {
const lectureId = String(lecture.id || lecture.lectureId);
const progress = lectureProgresses[lectureId];
const isCompleted = progress?.isCompleted === true;
const hasProgress = !!progress;
// 첫 번째 미완료 강의 찾기
const firstIncompleteIndex = lectures.findIndex((l: any) => {
const lid = String(l.id || l.lectureId);
const prog = lectureProgresses[lid];
return !prog?.isCompleted;
});
const isFirstIncomplete = index === firstIncompleteIndex && !isCompleted;
// 버튼 텍스트 결정: isCompleted면 "복습하기", progress 있으면 "이어서 수강하기", 없으면 "수강하기"
const action = isCompleted ? "복습하기" : (hasProgress || isFirstIncomplete ? "이어서 수강하기" : "수강하기");
const isSubmitted = false; // TODO: 학습 제출 상태는 별도로 관리 필요
const submitBtnBorder = isSubmitted
? "border-transparent"
: (action === "이어서 수강하기" || action === "수강하기" ? "border-[#b1b8c0]" : "border-[#8c95a1]");
const submitBtnText = isSubmitted ? "text-[#384fbf]" : (action === "이어서 수강하기" || action === "수강하기" ? "text-[#b1b8c0]" : "text-[#4c5561]");
// isCompleted면 배경색 #F1F3F5, 아니면 기본 스타일
const rightBtnStyle =
l.action === "이어서 수강하기"
isCompleted
? "bg-[#f1f3f5] text-[#4c5561]"
: (action === "이어서 수강하기" || action === "수강하기"
? "bg-[#ecf0ff] text-[#384fbf]"
: l.action === "수강하기"
? "bg-[#ecf0ff] text-[#384fbf]"
: "bg-[#f1f3f5] text-[#4c5561]";
: "bg-[#f1f3f5] text-[#4c5561]");
const durationMin = lecture.durationMin || lecture.duration_min || lecture.duration || 0;
const duration = typeof durationMin === 'number' ? formatDuration(durationMin) : (lecture.duration || '00:00');
return (
<div key={l.id} className="rounded-[8px] border border-[#dee1e6] bg-white">
<div className="flex items-center justify-between gap-4 rounded-[8px] px-6 py-4">
<div className="min-w-0">
<p className="text-[16px] font-semibold leading-[1.5] text-[#333c47]">{l.title}</p>
<div className="mt-1 flex items-center gap-3">
<p className="w-[40px] text-[13px] leading-[1.4] text-[#8c95a1]">{l.duration}</p>
<div key={lecture.id || lecture.lectureId || index} className="bg-white border border-[#dee1e6] border-solid relative rounded-[8px] w-full">
<div className="box-border flex gap-[16px] items-center overflow-clip px-[24px] py-[16px] rounded-[inherit] w-full">
<div className="basis-0 flex grow h-[46px] items-center justify-between min-h-px min-w-px relative shrink-0">
{/* Lecture 정보 */}
<div className="basis-0 grow min-h-px min-w-px relative shrink-0">
<div className="flex flex-col gap-[4px] items-start w-full">
<p className="font-['Pretendard:SemiBold',sans-serif] text-[#333c47] text-[16px] leading-[1.5]">
{index + 1}. {lecture.title || lecture.lectureName || ''}
</p>
<div className="flex gap-[12px] items-center w-full">
<p className="font-['Pretendard:Medium',sans-serif] text-[#8c95a1] text-[13px] leading-[1.4] w-[40px]">
{duration}
</p>
</div>
</div>
<div className="flex items-center gap-2">
</div>
{/* 버튼 영역 */}
<div className="relative shrink-0">
<div className="flex gap-[8px] items-center">
{/* 학습 제출 버튼 */}
<button
type="button"
className={[
"h-8 rounded-[6px] px-4 text-[14px] font-medium leading-[1.5]",
"bg-white",
submitBtnStyle,
"bg-white box-border flex flex-col h-[32px] items-center justify-center px-[16px] py-[3px] rounded-[6px] shrink-0",
"border border-solid",
submitBtnBorder,
submitBtnText,
].join(" ")}
>
{isSubmitted ? "학습 제출 완료" : "학습 제출 하기"}
{isSubmitted ? (
<div className="flex gap-[4px] h-[18px] items-center">
<div className="relative shrink-0 size-[12px]">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 3L4.5 8.5L2 6" stroke="#384fbf" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<p className="font-['Pretendard:Medium',sans-serif] text-[#384fbf] text-[13px] leading-[1.4]"> </p>
</div>
) : (
<p className={[
"font-['Pretendard:Medium',sans-serif] text-[14px] leading-[1.5] text-center",
submitBtnText,
].join(" ")}> </p>
)}
</button>
{/* 수강/복습 버튼 */}
<button
type="button"
onClick={async () => {
const lectureId = lecture.id || lecture.lectureId;
if (lectureId) {
if (action === "복습하기") {
router.push(`/menu/courses/lessons/${lectureId}/review`);
} else if (action === "수강하기") {
// "수강하기" 버튼 클릭 시 progress 생성
try {
await apiService.updateLectureProgress({
lectureId: Number(lectureId),
progressPct: 0,
lastPositionSec: 0,
});
} catch (error) {
console.error('Progress 생성 실패:', error);
}
router.push(`/menu/courses/lessons/${lectureId}/start`);
} else if (action === "이어서 수강하기") {
// 첫 번째 미완료 강의면 continue, 아니면 start
if (isFirstIncomplete && !hasProgress) {
router.push(`/menu/courses/lessons/${lectureId}/start`);
} else {
router.push(`/menu/courses/lessons/${lectureId}/continue`);
}
}
}
}}
className={[
"h-8 rounded-[6px] px-4 text-[14px] font-medium leading-[1.5]",
"box-border flex flex-col h-[32px] items-center justify-center px-[16px] py-[3px] rounded-[6px] shrink-0 transition-colors",
rightBtnStyle,
isCompleted
? "hover:bg-[#e5e8eb]"
: (action === "이어서 수강하기" || action === "수강하기"
? "hover:bg-[#d0d9ff]"
: "hover:bg-[#e5e8eb]"),
].join(" ")}
>
{l.action}
<p className={[
"font-['Pretendard:Medium',sans-serif] text-[14px] leading-[1.5] text-center",
isCompleted ? "text-[#4c5561]" : (action === "이어서 수강하기" || action === "수강하기" ? "text-[#384fbf]" : "text-[#4c5561]"),
].join(" ")}>{action}</p>
</button>
</div>
</div>
</div>
</div>
</div>
);
})}
})
) : (
<div className="flex items-center justify-center py-8 w-full">
<p className="text-[14px] text-[#8c95a1]"> .</p>
</div>
)}
</div>
</section>
</main>

View File

@@ -1,5 +1,8 @@
'use client';
import { useRouter, useParams } from 'next/navigation';
import { useState } from 'react';
const imgImage2 = "/imgs/image-2.png";
const imgLine58 = "/imgs/line-58.svg";
const img = "/imgs/asset-base.svg";
@@ -13,21 +16,88 @@ const imgIcon2 = "/imgs/icon-2.svg";
const imgIcon3 = "/imgs/icon-3.svg";
const imgIcon4 = "/imgs/icon-4.svg";
export default function FigmaSelectedLessonPage() {
interface FigmaSelectedLessonPageProps {
subjectTitle?: string;
lectureTitle?: string;
}
export default function FigmaSelectedLessonPage({
subjectTitle = '원자로 운전 및 계통',
lectureTitle = '6. 원자로 시동, 운전 및 정지 절차',
}: FigmaSelectedLessonPageProps) {
const router = useRouter();
const params = useParams();
const [isVideoCompleted, setIsVideoCompleted] = useState(false);
const [isXrActive, setIsXrActive] = useState(false);
const [isVrCompleted, setIsVrCompleted] = useState(false);
const [isProblemActive, setIsProblemActive] = useState(false);
const [showVideoModal, setShowVideoModal] = useState(false);
const [showVrModal, setShowVrModal] = useState(false);
const [showProblemConfirmModal, setShowProblemConfirmModal] = useState(false);
const handleVideoComplete = () => {
setIsVideoCompleted(true);
setShowVideoModal(true);
};
const handleVideoModalProceed = () => {
setShowVideoModal(false);
setIsXrActive(true);
};
const handleVideoModalReplay = () => {
setShowVideoModal(false);
setIsVideoCompleted(false);
};
const handleVrComplete = () => {
setIsVrCompleted(true);
setShowVrModal(true);
};
const handleVrModalProceed = () => {
setShowVrModal(false);
setShowProblemConfirmModal(true);
};
const handleProblemConfirmProceed = () => {
setShowProblemConfirmModal(false);
setIsProblemActive(true);
// 문제 풀기 페이지로 이동
const lessonId = params?.lessonId as string;
if (lessonId) {
router.push(`/menu/courses/lessons/${lessonId}/review`);
}
};
const handleProblemConfirmReplay = () => {
setShowProblemConfirmModal(false);
};
const handleVrModalReplay = () => {
setShowVrModal(false);
setIsVrCompleted(false);
};
return (
<div className="bg-white content-stretch flex flex-col items-center relative size-full">
<div className="content-stretch flex flex-col items-start max-w-[1440px] relative shrink-0 w-[1440px]">
<div className="box-border content-stretch flex gap-[10px] h-[100px] items-center px-[32px] py-0 relative shrink-0 w-full">
<div className="basis-0 content-stretch flex gap-[12px] grow items-center min-h-px min-w-px relative shrink-0">
<div className="relative shrink-0 size-[32px]">
<button
type="button"
onClick={() => router.back()}
aria-label="뒤로 가기"
className="relative shrink-0 size-[32px] rounded-full inline-flex items-center justify-center hover:bg-black/5 cursor-pointer"
>
<img alt="" className="block max-w-none size-full" src={imgArrowsDiagramsArrow} />
</div>
</button>
<div className="basis-0 content-stretch flex flex-col grow items-start justify-center leading-[1.5] min-h-px min-w-px not-italic relative shrink-0">
<p className="font-['Pretendard:SemiBold',sans-serif] relative shrink-0 text-[#6c7682] text-[16px] w-full">
{subjectTitle}
</p>
<p className="font-['Pretendard:Bold',sans-serif] relative shrink-0 text-[#1b2027] text-[24px] w-full">
6. ,
{lectureTitle}
</p>
</div>
<div className="content-stretch flex gap-[20px] h-[81px] items-center justify-center relative shrink-0">
@@ -50,14 +120,14 @@ export default function FigmaSelectedLessonPage() {
</div>
<div className="relative shrink-0 w-[52px]">
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex flex-col gap-[4px] items-center relative w-[52px]">
<div className="bg-[#dee1e6] relative rounded-[2.23696e+07px] shrink-0 size-[32px]">
<div className={`${isXrActive ? 'bg-[#384fbf]' : 'bg-[#dee1e6]'} relative rounded-[2.23696e+07px] shrink-0 size-[32px]`}>
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex items-center justify-center relative size-[32px]">
<p className="font-['Pretendard:Bold',sans-serif] leading-[18px] not-italic relative shrink-0 text-[#6c7682] text-[14px] text-nowrap whitespace-pre">
<p className={`font-['Pretendard:Bold',sans-serif] leading-[18px] not-italic relative shrink-0 text-[14px] text-nowrap whitespace-pre ${isXrActive ? 'text-white' : 'text-[#6c7682]'}`}>
2
</p>
</div>
</div>
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#6c7682] text-[14px] text-nowrap whitespace-pre">
<p className={`font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[14px] text-nowrap whitespace-pre ${isXrActive ? 'text-[#384fbf]' : 'text-[#6c7682]'}`}>
XR
</p>
</div>
@@ -67,14 +137,14 @@ export default function FigmaSelectedLessonPage() {
</div>
<div className="relative shrink-0 w-[52px]">
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex flex-col gap-[4px] items-center relative w-[52px]">
<div className="bg-[#dee1e6] relative rounded-[2.23696e+07px] shrink-0 size-[32px]">
<div className={`${isProblemActive ? 'bg-[#384fbf]' : 'bg-[#dee1e6]'} relative rounded-[2.23696e+07px] shrink-0 size-[32px]`}>
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex items-center justify-center relative size-[32px]">
<p className="font-['Pretendard:Bold',sans-serif] leading-[18px] not-italic relative shrink-0 text-[#6c7682] text-[14px] text-nowrap whitespace-pre">
<p className={`font-['Pretendard:Bold',sans-serif] leading-[18px] not-italic relative shrink-0 text-[14px] text-nowrap whitespace-pre ${isProblemActive ? 'text-white' : 'text-[#6c7682]'}`}>
3
</p>
</div>
</div>
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#6c7682] text-[14px] text-nowrap whitespace-pre">
<p className={`font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[14px] text-nowrap whitespace-pre ${isProblemActive ? 'text-[#384fbf]' : 'text-[#6c7682]'}`}>
</p>
</div>
@@ -83,12 +153,26 @@ export default function FigmaSelectedLessonPage() {
</div>
</div>
<div className="box-border content-stretch flex flex-col gap-[24px] items-center overflow-clip pb-[80px] pt-[24px] px-8 relative rounded-[8px] shrink-0 w-full">
{!isXrActive ? (
// 영상 영역
<div className="aspect-[1920/1080] bg-black overflow-clip relative rounded-[8px] shrink-0 w-full">
<div className="absolute left-1/2 size-[120px] top-1/2 translate-x-[-50%] translate-y-[-50%]">
<div className="absolute contents inset-0">
<img alt="" className="block max-w-none size-full" src={imgGroup} />
</div>
</div>
{/* 시청끝 버튼 (더미 중간에 배치) */}
<div className="absolute left-1/2 top-[40%] translate-x-[-50%] translate-y-[-50%] z-10">
<button
type="button"
onClick={handleVideoComplete}
className="bg-[#1f2b91] box-border content-stretch flex gap-[4px] h-[40px] items-center justify-center px-[24px] py-0 relative rounded-[8px] shrink-0 cursor-pointer hover:bg-[#1a2370] transition-colors"
>
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[16px] text-center text-nowrap text-white whitespace-pre">
</p>
</button>
</div>
<div className="absolute bg-gradient-to-t bottom-0 box-border content-stretch flex flex-col from-[rgba(0,0,0,0.8)] gap-[20px] items-center justify-center left-[-0.5px] px-[16px] py-[24px] to-[rgba(0,0,0,0)] w-[1376px]">
<div className="bg-[#333c47] h-[4px] relative rounded-[3.35544e+07px] shrink-0 w-full">
<div className="absolute left-0 size-[12px] top-1/2 translate-y-[-50%]">
@@ -152,6 +236,154 @@ export default function FigmaSelectedLessonPage() {
</div>
</div>
</div>
) : showProblemConfirmModal ? (
// 문제 풀기 확인창
<div className="bg-white content-stretch flex flex-col items-center relative rounded-[12px] shrink-0 w-full">
<div className="box-border content-stretch flex gap-[10px] items-center overflow-clip p-[24px] relative shrink-0 w-full">
<div className="basis-0 flex flex-col font-['Pretendard:Bold',sans-serif] grow h-[32px] justify-center leading-[0] min-h-px min-w-px not-italic relative shrink-0 text-[#333c47] text-[20px] text-center">
<p className="leading-[1.5]"> !</p>
</div>
</div>
<div className="box-border content-stretch flex flex-col gap-[16px] items-center px-[24px] py-0 relative shrink-0">
<div className="bg-gray-50 border border-[#dee1e6] border-solid box-border content-stretch flex flex-col gap-[8px] items-start justify-center p-[24px] relative rounded-[16px] shrink-0 w-full">
<div className="content-stretch flex gap-[10px] items-center justify-center relative shrink-0">
<p className="font-['Pretendard:Bold',sans-serif] h-[23px] leading-[1.5] not-italic relative shrink-0 text-[#4c5561] text-[15px] w-full">
</p>
</div>
<div className="content-stretch flex flex-col font-['Pretendard:Regular',sans-serif] gap-[4px] items-start leading-[0] not-italic relative shrink-0 text-[#4c5561] text-[15px] text-nowrap w-full">
<ul className="[white-space-collapse:collapse] block relative shrink-0">
<li className="ms-[22.5px]">
<span className="leading-[1.5]"> 30 .</span>
</li>
</ul>
<ul className="[white-space-collapse:collapse] block relative shrink-0">
<li className="ms-[22.5px]">
<span className="leading-[1.5]"> , .</span>
</li>
</ul>
<ul className="[white-space-collapse:collapse] block relative shrink-0">
<li className="ms-[22.5px]">
<span className="leading-[1.5]"> 3, 100 70 .</span>
</li>
</ul>
<ul className="[white-space-collapse:collapse] block relative shrink-0">
<li className="ms-[22.5px]">
<span className="leading-[1.5]">, 3~5 .</span>
</li>
</ul>
</div>
</div>
</div>
<div className="box-border content-stretch flex flex-col gap-[32px] items-center p-[24px] relative shrink-0 w-full">
<div className="content-stretch flex gap-[12px] items-start relative shrink-0 w-full">
<div className="basis-0 content-stretch flex gap-[12px] grow items-start min-h-px min-w-px relative shrink-0">
<button
type="button"
onClick={handleProblemConfirmReplay}
className="basis-0 bg-[#f1f3f5] box-border content-stretch flex flex-col grow h-[56px] items-center justify-center min-h-px min-w-px px-[8px] py-0 relative rounded-[12px] shrink-0 min-w-[90px] cursor-pointer hover:bg-[#e1e5e9] transition-colors"
>
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#4c5561] text-[18px] text-center text-nowrap whitespace-pre">
</p>
</button>
<button
type="button"
onClick={handleProblemConfirmProceed}
className="basis-0 bg-[#1f2b91] box-border content-stretch flex flex-col grow h-[56px] items-center justify-center min-h-px min-w-px p-[8px] relative rounded-[12px] shrink-0 min-w-[90px] cursor-pointer hover:bg-[#1a2370] transition-colors"
>
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[18px] text-center text-nowrap text-white whitespace-pre">
</p>
</button>
</div>
</div>
</div>
</div>
) : (
// VR 콘텐츠 영역
<div className="aspect-[1920/1080] bg-black overflow-clip relative rounded-[8px] shrink-0 w-full">
<div className="absolute left-1/2 size-[120px] top-1/2 translate-x-[-50%] translate-y-[-50%]">
<div className="absolute contents inset-0">
<img alt="" className="block max-w-none size-full" src={imgGroup} />
</div>
</div>
{/* VR 콘텐츠 시청끝 버튼 (더미 중간에 배치) */}
<div className="absolute left-1/2 top-[40%] translate-x-[-50%] translate-y-[-50%] z-10">
<button
type="button"
onClick={handleVrComplete}
className="bg-[#1f2b91] box-border content-stretch flex gap-[4px] h-[40px] items-center justify-center px-[24px] py-0 relative rounded-[8px] shrink-0 cursor-pointer hover:bg-[#1a2370] transition-colors"
>
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[16px] text-center text-nowrap text-white whitespace-pre">
</p>
</button>
</div>
<div className="absolute bg-gradient-to-t bottom-0 box-border content-stretch flex flex-col from-[rgba(0,0,0,0.8)] gap-[20px] items-center justify-center left-[-0.5px] px-[16px] py-[24px] to-[rgba(0,0,0,0)] w-[1376px]">
<div className="bg-[#333c47] h-[4px] relative rounded-[3.35544e+07px] shrink-0 w-full">
<div className="absolute left-0 size-[12px] top-1/2 translate-y-[-50%]">
<img alt="" className="block max-w-none size-full" src={imgEllipse2} />
</div>
</div>
<div className="content-stretch flex h-[32px] items-center justify-between relative shrink-0 w-full">
<div className="relative shrink-0">
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex gap-[8px] items-center relative">
<div className="content-stretch flex gap-[16px] items-center relative shrink-0">
<div className="relative shrink-0 size-[32px]">
<img alt="" className="block max-w-none size-full" src={imgMusicAudioPlay} />
</div>
<div className="content-stretch flex gap-[8px] h-[32px] items-center relative shrink-0 w-[120px]">
<div className="relative rounded-[4px] shrink-0 size-[32px]">
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex items-center justify-center relative size-[32px]">
<div className="relative shrink-0 size-[18px]">
<img alt="" className="block max-w-none size-full" src={imgIcon1} />
</div>
</div>
</div>
<div className="basis-0 grow h-[4px] min-h-px min-w-px relative rounded-[3.35544e+07px] shrink-0">
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-[4px] w-full" />
</div>
</div>
<p className="font-['Pretendard:Medium',sans-serif] leading-[19.5px] not-italic relative shrink-0 text-[13px] text-nowrap text-white whitespace-pre">
0:00 / 12:26
</p>
</div>
</div>
</div>
<div className="relative shrink-0">
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex gap-[12px] items-center relative">
<div className="bg-[#333c47] box-border content-stretch flex gap-[4px] h-[32px] items-center justify-center px-[16px] py-[3px] relative rounded-[6px] shrink-0 w-[112px]">
<div className="relative shrink-0 size-[16px]">
<img alt="" className="block max-w-none size-full" src={imgIcon2} />
</div>
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[14px] text-center text-nowrap text-white whitespace-pre">
</p>
</div>
<div className="bg-[#333c47] box-border content-stretch flex gap-[4px] h-[32px] items-center justify-center px-[16px] py-[3px] relative rounded-[6px] shrink-0 w-[112px]">
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[14px] text-center text-nowrap text-white whitespace-pre">
</p>
<div className="flex items-center justify-center relative shrink-0">
<div className="flex-none rotate-[180deg] scale-y-[-100%]">
<div className="relative size-[16px]">
<img alt="" className="block max-w-none size-full" src={imgIcon3} />
</div>
</div>
</div>
</div>
<div className="content-stretch flex items-center justify-center relative rounded-[4px] shrink-0 size-[32px]">
<div className="relative shrink-0 size-[18px]">
<img alt="" className="block max-w-none size-full" src={imgIcon4} />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
<div className="bg-[#fff9ee] border border-[#ffdd82] border-solid box-border content-stretch flex flex-col h-[55px] items-start pb-px pt-[17px] px-[17px] relative rounded-[8px] shrink-0 w-full">
<ul className="[white-space-collapse:collapse] block font-['Pretendard:Medium',sans-serif] leading-[0] not-italic relative shrink-0 text-[#333c47] text-[14px] text-nowrap">
<li className="ms-[21px]">
@@ -161,6 +393,78 @@ export default function FigmaSelectedLessonPage() {
</div>
</div>
</div>
{/* 영상 완료 모달 */}
{showVideoModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-[2px]">
<div className="bg-white box-border content-stretch flex flex-col gap-[32px] items-end justify-end p-[24px] relative rounded-[8px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] w-[480px]">
<div className="content-stretch flex flex-col gap-[16px] items-start justify-center relative shrink-0 w-full">
<div className="content-stretch flex gap-[8px] items-start relative shrink-0 w-full">
<div className="basis-0 font-['Pretendard:SemiBold',sans-serif] grow leading-[1.5] min-h-px min-w-px not-italic relative shrink-0 text-[#333c47] text-[18px]">
<p className="mb-0"> .</p>
<p>XR .</p>
</div>
</div>
</div>
<div className="content-stretch flex gap-[8px] items-center justify-end relative shrink-0">
<button
type="button"
onClick={handleVideoModalReplay}
className="bg-[#f1f3f5] box-border content-stretch flex flex-col h-[40px] items-center justify-center px-[16px] py-0 relative rounded-[8px] shrink-0 min-w-[82px] cursor-pointer hover:bg-[#e1e5e9] transition-colors"
>
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#4c5561] text-[16px] text-center text-nowrap whitespace-pre">
</p>
</button>
<button
type="button"
onClick={handleVideoModalProceed}
className="bg-[#1f2b91] box-border content-stretch flex flex-col h-[40px] items-center justify-center px-[16px] py-0 relative rounded-[8px] shrink-0 min-w-[82px] cursor-pointer hover:bg-[#1a2370] transition-colors"
>
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[16px] text-center text-nowrap text-white whitespace-pre">
</p>
</button>
</div>
</div>
</div>
)}
{/* VR 콘텐츠 완료 모달 */}
{showVrModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-[2px]">
<div className="bg-white box-border content-stretch flex flex-col gap-[32px] items-end justify-end p-[24px] relative rounded-[8px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] w-[480px]">
<div className="content-stretch flex flex-col gap-[16px] items-start justify-center relative shrink-0 w-full">
<div className="content-stretch flex gap-[8px] items-start relative shrink-0 w-full">
<div className="basis-0 font-['Pretendard:SemiBold',sans-serif] grow leading-[1.5] min-h-px min-w-px not-italic relative shrink-0 text-[#333c47] text-[18px]">
<p className="mb-0">XR .</p>
<p> .</p>
</div>
</div>
</div>
<div className="content-stretch flex gap-[8px] items-center justify-end relative shrink-0">
<button
type="button"
onClick={handleVrModalReplay}
className="bg-[#f1f3f5] box-border content-stretch flex flex-col h-[40px] items-center justify-center px-[16px] py-0 relative rounded-[8px] shrink-0 min-w-[82px] cursor-pointer hover:bg-[#e1e5e9] transition-colors"
>
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#4c5561] text-[16px] text-center text-nowrap whitespace-pre">
XR
</p>
</button>
<button
type="button"
onClick={handleVrModalProceed}
className="bg-[#1f2b91] box-border content-stretch flex flex-col h-[40px] items-center justify-center px-[16px] py-0 relative rounded-[8px] shrink-0 min-w-[82px] cursor-pointer hover:bg-[#1a2370] transition-colors"
>
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[16px] text-center text-nowrap text-white whitespace-pre">
</p>
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,8 +1,111 @@
'use client';
import { useParams } from 'next/navigation';
import { useState, useEffect } from 'react';
import FigmaSelectedLessonPage from '../../FigmaSelectedLessonPage';
import apiService from '@/app/lib/apiService';
export default function ContinueLessonPage() {
return <FigmaSelectedLessonPage />;
const params = useParams();
const [subjectTitle, setSubjectTitle] = useState<string>('');
const [lectureTitle, setLectureTitle] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchLessonData = async () => {
if (!params?.lessonId) return;
try {
setLoading(true);
setError(null);
// 1. 강의 정보 가져오기 - getLecture 실패 시 getLectures로 재시도
let lectureData: any = null;
try {
const lectureResponse = await apiService.getLecture(params.lessonId as string);
lectureData = lectureResponse.data;
} catch (lectureErr) {
console.log('getLecture 실패, getLectures로 재시도:', lectureErr);
// getLecture 실패 시 getLectures로 재시도
try {
const listResponse = await apiService.getLectures();
let lectures: any[] = [];
if (Array.isArray(listResponse.data)) {
lectures = listResponse.data;
} else if (listResponse.data && typeof listResponse.data === 'object') {
lectures = listResponse.data.items ||
listResponse.data.lectures ||
listResponse.data.data ||
listResponse.data.list ||
[];
}
lectureData = lectures.find((l: any) =>
String(l.id || l.lectureId) === String(params.lessonId)
);
} catch (listErr) {
console.error('getLectures 실패:', listErr);
}
}
if (lectureData) {
// 강의 제목 설정
const title = lectureData.title || lectureData.lectureName || '';
setLectureTitle(title);
// 2. 과목 정보 가져오기
const subjectId = lectureData.subjectId || lectureData.subject_id;
if (subjectId) {
try {
const subjectsResponse = await apiService.getSubjects();
let subjectsData: any[] = [];
if (Array.isArray(subjectsResponse.data)) {
subjectsData = subjectsResponse.data;
} else if (subjectsResponse.data && typeof subjectsResponse.data === 'object') {
subjectsData = subjectsResponse.data.items ||
subjectsResponse.data.courses ||
subjectsResponse.data.data ||
subjectsResponse.data.list ||
subjectsResponse.data.subjects ||
subjectsResponse.data.subjectList ||
[];
}
// subjectId로 과목 찾기
const subject = subjectsData.find((s: any) =>
String(s.id || s.subjectId) === String(subjectId)
);
if (subject) {
const subjectName = subject.title || subject.subjectName || subject.name || '';
setSubjectTitle(subjectName);
}
} catch (subjectsErr) {
console.error('과목 정보 조회 실패:', subjectsErr);
// 과목 정보 조회 실패해도 강의는 표시
}
}
}
} catch (err) {
console.error('강의 정보 조회 실패:', err);
// 에러가 발생해도 기본값으로 표시
} finally {
setLoading(false);
}
};
fetchLessonData();
}, [params?.lessonId]);
// 로딩 중이거나 에러가 있어도 기본값으로 페이지 표시 (영상은 더미 데이터 사용)
return (
<FigmaSelectedLessonPage
subjectTitle={subjectTitle || undefined}
lectureTitle={lectureTitle || undefined}
/>
);
}

View File

@@ -1,8 +1,111 @@
'use client';
import { useParams } from 'next/navigation';
import { useState, useEffect } from 'react';
import FigmaSelectedLessonPage from '../../FigmaSelectedLessonPage';
import apiService from '@/app/lib/apiService';
export default function StartLessonPage() {
return <FigmaSelectedLessonPage />;
const params = useParams();
const [subjectTitle, setSubjectTitle] = useState<string>('');
const [lectureTitle, setLectureTitle] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchLessonData = async () => {
if (!params?.lessonId) return;
try {
setLoading(true);
setError(null);
// 1. 강의 정보 가져오기 - getLecture 실패 시 getLectures로 재시도
let lectureData: any = null;
try {
const lectureResponse = await apiService.getLecture(params.lessonId as string);
lectureData = lectureResponse.data;
} catch (lectureErr) {
console.log('getLecture 실패, getLectures로 재시도:', lectureErr);
// getLecture 실패 시 getLectures로 재시도
try {
const listResponse = await apiService.getLectures();
let lectures: any[] = [];
if (Array.isArray(listResponse.data)) {
lectures = listResponse.data;
} else if (listResponse.data && typeof listResponse.data === 'object') {
lectures = listResponse.data.items ||
listResponse.data.lectures ||
listResponse.data.data ||
listResponse.data.list ||
[];
}
lectureData = lectures.find((l: any) =>
String(l.id || l.lectureId) === String(params.lessonId)
);
} catch (listErr) {
console.error('getLectures 실패:', listErr);
}
}
if (lectureData) {
// 강의 제목 설정
const title = lectureData.title || lectureData.lectureName || '';
setLectureTitle(title);
// 2. 과목 정보 가져오기
const subjectId = lectureData.subjectId || lectureData.subject_id;
if (subjectId) {
try {
const subjectsResponse = await apiService.getSubjects();
let subjectsData: any[] = [];
if (Array.isArray(subjectsResponse.data)) {
subjectsData = subjectsResponse.data;
} else if (subjectsResponse.data && typeof subjectsResponse.data === 'object') {
subjectsData = subjectsResponse.data.items ||
subjectsResponse.data.courses ||
subjectsResponse.data.data ||
subjectsResponse.data.list ||
subjectsResponse.data.subjects ||
subjectsResponse.data.subjectList ||
[];
}
// subjectId로 과목 찾기
const subject = subjectsData.find((s: any) =>
String(s.id || s.subjectId) === String(subjectId)
);
if (subject) {
const subjectName = subject.title || subject.subjectName || subject.name || '';
setSubjectTitle(subjectName);
}
} catch (subjectsErr) {
console.error('과목 정보 조회 실패:', subjectsErr);
// 과목 정보 조회 실패해도 강의는 표시
}
}
}
} catch (err) {
console.error('강의 정보 조회 실패:', err);
// 에러가 발생해도 기본값으로 표시
} finally {
setLoading(false);
}
};
fetchLessonData();
}, [params?.lessonId]);
// 로딩 중이거나 에러가 있어도 기본값으로 페이지 표시 (영상은 더미 데이터 사용)
return (
<FigmaSelectedLessonPage
subjectTitle={subjectTitle || undefined}
lectureTitle={lectureTitle || undefined}
/>
);
}

View File

@@ -1,7 +1,9 @@
'use client';
import { useMemo, useState } from "react";
import { useMemo, useState, useEffect } from "react";
import CourseCard from "./CourseCard";
import apiService from "@/app/lib/apiService";
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
type CourseStatus = "전체" | "수강 예정" | "수강중" | "수강 완료";
@@ -23,80 +25,277 @@ type Course = {
lessons: Lesson[];
};
const COURSES: Course[] = [
{
id: "c1",
title: "원자로 운전 및 계통",
description:
"원자로 운전 원리와 주요 계통의 구조 및 기능을 이해하고, 일련 단계 가이드 기반의 현업에서 배워야 할 핵심을 익힙니다.",
thumbnail: "/imgs/talk.png",
status: "수강중",
progressPct: 80,
lessons: [
{ id: "c1l1", title: "1. 원자로 기초 및 핵분열 원리", durationMin: 12, progressPct: 100, isCompleted: true },
{ id: "c1l2", title: "2. 제어봉 및 원자로 출력 제어", durationMin: 18, progressPct: 100, isCompleted: true },
{ id: "c1l3", title: "3. 터빈-발전기 계통 및 전력 생산", durationMin: 13, progressPct: 60, isCompleted: false },
{ id: "c1l4", title: "4. 보조 계통 및 안전 계통", durationMin: 13, progressPct: 0, isCompleted: false },
{ id: "c1l5", title: "5. 원자로 냉각재 계통 (RCS) 및 열수력", durationMin: 8, progressPct: 0, isCompleted: false },
{ id: "c1l6", title: "6. 원자로 시동, 운전 및 정지 절차", durationMin: 13, progressPct: 0, isCompleted: false },
],
},
{
id: "c2",
title: "확률론",
description:
"확률과 통계의 주요 개념을 가이드하며, 일련 단계 실습을 기반으로 문제 해결력을 기릅니다.",
thumbnail: "/imgs/talk.png",
status: "수강 완료",
progressPct: 100,
lessons: [
{ id: "c2l1", title: "기초 개념", durationMin: 10, progressPct: 100, isCompleted: true },
],
},
{
id: "c3",
title: "부서간 협업",
description:
"부서간 협업 절차와 기록 기준을 가이드하며, 협업 중 생길 수 있는 리스크 관리법을 다룹니다.",
thumbnail: "/imgs/talk.png",
status: "수강중",
progressPct: 60,
lessons: [
{ id: "c3l1", title: "의사소통 원칙", durationMin: 9, progressPct: 100, isCompleted: true },
{ id: "c3l2", title: "문서 공유와 승인", durationMin: 14, progressPct: 30, isCompleted: false },
],
},
{
id: "c4",
title: "방사선의 이해",
description:
"방사선 안전 기준을 가이드하며, 일과 관련된 위험과 보호 장비 선택법을 배웁니다.",
thumbnail: "/imgs/talk.png",
status: "수강 예정",
progressPct: 0,
lessons: [{ id: "c4l1", title: "기초 이론", durationMin: 12, progressPct: 0, isCompleted: false }],
},
];
const TABS: CourseStatus[] = ["전체", "수강 예정", "수강중", "수강 완료"];
const TABS: CourseStatus[] = ["전체", "수강중", "수강 완료"];
const ITEMS_PER_PAGE = 5;
export default function CoursesPage() {
const [activeTab, setActiveTab] = useState<CourseStatus>("전체");
const [courses, setCourses] = useState<Course[]>([]);
const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
const fetchCourses = async () => {
try {
setLoading(true);
// 1. getSubjects()로 모든 subject 조회
let allSubjects: any[] = [];
try {
const subjectsResponse = await apiService.getSubjects();
if (Array.isArray(subjectsResponse.data)) {
allSubjects = subjectsResponse.data;
} else if (subjectsResponse.data && typeof subjectsResponse.data === 'object') {
allSubjects = subjectsResponse.data.items ||
subjectsResponse.data.subjects ||
subjectsResponse.data.data ||
subjectsResponse.data.list ||
subjectsResponse.data.subjectList ||
[];
}
} catch (subjectsErr) {
console.error('getSubjects 실패:', subjectsErr);
}
// subject 정보를 Map으로 변환 (subjectId로 빠르게 조회)
const subjectMap: Record<string, any> = {};
allSubjects.forEach((subject: any) => {
const subjectId = String(subject.id || subject.subjectId || '');
if (subjectId) {
subjectMap[subjectId] = subject;
}
});
// 2. getLectures()로 모든 lecture 조회
let allLectures: any[] = [];
try {
const lecturesResponse = await apiService.getLectures();
if (Array.isArray(lecturesResponse.data)) {
allLectures = lecturesResponse.data;
} else if (lecturesResponse.data && typeof lecturesResponse.data === 'object') {
allLectures = lecturesResponse.data.items ||
lecturesResponse.data.lectures ||
lecturesResponse.data.data ||
lecturesResponse.data.list ||
[];
}
} catch (lecturesErr) {
console.error('getLectures 실패:', lecturesErr);
setCourses([]);
setLoading(false);
return;
}
// 3. 각 lecture의 ID 추출
const lectureIds = allLectures.map((lecture: any) =>
String(lecture.id || lecture.lectureId || '')
).filter((id: string) => id);
// 4. 각 lectureId에 대해 진행률 조회
const progressMap: Record<string, any> = {};
await Promise.all(
lectureIds.map(async (lectureId) => {
try {
const progressResponse = await apiService.getLectureProgress(lectureId);
if (progressResponse?.data) {
progressMap[lectureId] = progressResponse.data;
}
} catch (progressErr) {
// 진행률이 없으면 무시
console.log(`Lecture ${lectureId} 진행률 조회 실패:`, progressErr);
}
})
);
// 5. 진행률이 있는 lecture만 필터링
const lecturesWithProgress = allLectures.filter((lecture: any) => {
const lectureId = String(lecture.id || lecture.lectureId || '');
return progressMap[lectureId];
});
// 6. 교육과정(subjectId)별로 그룹화
const coursesBySubject: Record<string, {
subjectId: string;
subjectName: string;
subjectDescription: string;
subjectImageKey?: string;
lectures: any[];
}> = {};
lecturesWithProgress.forEach((lecture: any) => {
const subjectId = String(lecture.subjectId || lecture.subject_id || '');
const lectureId = String(lecture.id || lecture.lectureId || '');
if (!subjectId || !lectureId) return;
// subject 정보 가져오기
const subject = subjectMap[subjectId];
if (!coursesBySubject[subjectId]) {
coursesBySubject[subjectId] = {
subjectId,
subjectName: subject?.title || subject?.name || subject?.subjectName || lecture.subjectName || lecture.subject_name || lecture.subject?.name || '',
subjectDescription: subject?.description || subject?.subjectDescription || lecture.subjectDescription || lecture.subject_description || lecture.subject?.description || '',
subjectImageKey: subject?.imageKey || subject?.image_key || subject?.fileKey || lecture.subjectImageKey || lecture.subject_image_key || lecture.subject?.imageKey,
lectures: [],
};
}
coursesBySubject[subjectId].lectures.push({
...lecture,
progress: progressMap[lectureId],
});
});
// 6. Course 타입으로 변환
const transformedCourses: Course[] = await Promise.all(
Object.values(coursesBySubject).map(async (courseData) => {
// 썸네일 이미지 가져오기
let thumbnail = '/imgs/talk.png';
if (courseData.subjectImageKey) {
try {
const imageUrl = await apiService.getFile(courseData.subjectImageKey);
if (imageUrl) {
thumbnail = imageUrl;
}
} catch (imgErr) {
console.error('이미지 다운로드 실패:', imgErr);
}
}
// lessons 변환 (lecture를 lesson으로 변환)
const transformedLessons: Lesson[] = courseData.lectures.map((lecture: any, index: number) => {
const lectureId = String(lecture.id || lecture.lectureId || '');
const progress = lecture.progress || progressMap[lectureId];
const progressPct = progress?.progressPct ||
progress?.progress_pct ||
progress?.progressRate ||
progress?.progress_rate ||
0;
// isCompleted는 progress 데이터에서 가져오거나, progressPct가 100이면 완료
const isCompleted = progress?.isCompleted ||
progress?.is_completed ||
progressPct === 100 ||
false;
return {
id: lectureId,
title: lecture.title || lecture.lectureName || lecture.lecture_name || lecture.name || `강의 ${index + 1}`,
durationMin: lecture.durationMin || lecture.duration_min || lecture.duration || 0,
progressPct: progressPct,
isCompleted,
};
});
// 전체 진행률 계산 (모든 lecture의 진행률 평균)
let totalLectureProgress = 0;
let totalLectures = 0;
let allCompleted = true;
let hasProgress = false;
courseData.lectures.forEach((lecture: any) => {
const lectureId = String(lecture.id || lecture.lectureId || '');
const progress = lecture.progress || progressMap[lectureId];
// progress 데이터가 있으면 무조건 수강중 또는 수강 완료
if (progress) {
hasProgress = true;
}
const progressPct = progress?.progressPct ||
progress?.progress_pct ||
progress?.progressRate ||
progress?.progress_rate ||
0;
const isCompleted = progress?.isCompleted ||
progress?.is_completed ||
progressPct === 100 ||
false;
totalLectureProgress += progressPct;
totalLectures += 1;
if (!isCompleted) {
allCompleted = false;
}
});
const avgProgress = totalLectures > 0 ? Math.round(totalLectureProgress / totalLectures) : 0;
// 상태 결정: 수강 완료 = isCompleted가 true, 수강중 = isCompleted가 false이고 progress 데이터가 있는 경우
// progress 데이터가 있으면 무조건 "수강중" 또는 "수강 완료"
let status: CourseStatus = "수강 예정";
if (allCompleted) {
status = "수강 완료";
} else if (hasProgress) {
status = "수강중";
}
return {
id: courseData.subjectId,
title: courseData.subjectName || '제목 없음',
description: courseData.subjectDescription || '',
thumbnail,
status,
progressPct: avgProgress,
lessons: transformedLessons,
};
})
);
// "수강 예정" 상태 제거 (수강중, 수강 완료만 표시)
const filteredCourses = transformedCourses.filter((course) =>
course.status === "수강중" || course.status === "수강 완료"
);
// 디버깅: 데이터 확인
console.log('🔍 [CoursesPage] 전체 courses:', transformedCourses);
console.log('🔍 [CoursesPage] 필터링된 courses:', filteredCourses);
console.log('🔍 [CoursesPage] progressMap:', progressMap);
setCourses(filteredCourses);
} catch (err) {
console.error('강좌 조회 실패:', err);
setCourses([]);
} finally {
setLoading(false);
}
};
fetchCourses();
}, []);
const countsByStatus = useMemo(() => {
return {
전체: COURSES.length,
"수강 예정": COURSES.filter((c) => c.status === "수강 예정").length,
수강중: COURSES.filter((c) => c.status === "수강").length,
"수강 완료": COURSES.filter((c) => c.status === "수강 완료").length,
전체: courses.length,
수강중: courses.filter((c) => c.status === "수강").length,
"수강 완료": courses.filter((c) => c.status === "수강 완료").length,
};
}, []);
}, [courses]);
const filtered = useMemo(() => {
if (activeTab === "전체") return COURSES;
return COURSES.filter((c) => c.status === activeTab);
if (activeTab === "전체") return courses;
return courses.filter((c) => c.status === activeTab);
}, [activeTab, courses]);
// 탭 변경 시 첫 페이지로 리셋
useEffect(() => {
setCurrentPage(1);
}, [activeTab]);
const totalCount = useMemo(() => filtered.length, [filtered]);
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
const pagedCourses = useMemo(
() => filtered.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE),
[filtered, currentPage]
);
// 페이지네이션: 10개씩 표시
const pageGroup = Math.floor((currentPage - 1) / 10);
const startPage = pageGroup * 10 + 1;
const endPage = Math.min(startPage + 9, totalPages);
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
return (
<main className="flex w-full flex-col">
<div className="flex h-[100px] items-center px-8">
@@ -134,42 +333,98 @@ export default function CoursesPage() {
</div>
<div className="px-8 pb-16 pt-6">
{loading ? (
<div className="flex items-center justify-center py-16">
<p className="text-[14px] text-text-meta"> ...</p>
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center py-16">
<p className="text-[14px] text-text-meta"> .</p>
</div>
) : (
<div className="flex flex-col gap-4">
{filtered.map((course) => (
{pagedCourses.map((course) => (
<CourseCard key={course.id} course={course} />
))}
</div>
)}
{/* pagination */}
<div className="mt-10 flex items-center justify-center gap-2">
{/* 페이지네이션 - 5개 초과일 때만 표시 */}
{totalCount > ITEMS_PER_PAGE && (
<div className="mt-10 flex items-center justify-center">
<div className="flex items-center justify-center gap-[8px]">
{/* First (맨 앞으로) */}
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full text-[#8c95a1] hover:bg-[#f1f3f5]"
onClick={() => setCurrentPage(1)}
aria-label="맨 앞 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === 1}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
</div>
</button>
{/* Prev */}
<button
type="button"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
aria-label="이전 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === 1}
>
&#x2039;
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
</button>
{[1, 2, 3].map((p) => (
{/* Numbers */}
{visiblePages.map((n) => {
const active = n === currentPage;
return (
<button
key={p}
key={n}
type="button"
onClick={() => setCurrentPage(n)}
aria-current={active ? 'page' : undefined}
className={[
"flex h-8 w-8 items-center justify-center rounded-full text-[13px] leading-[1.4]",
p === 2 ? "bg-[#1f2b91] text-white" : "text-[#4c5561] hover:bg-[#f1f3f5]",
].join(" ")}
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
active ? 'bg-bg-primary-light' : 'bg-white',
].join(' ')}
>
{p}
<span className="text-[16px] leading-[1.4] text-neutral-700">{n}</span>
</button>
))}
);
})}
{/* Next */}
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full text-[#8c95a1] hover:bg-[#f1f3f5]"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
aria-label="다음 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
>
&#x203A;
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
</button>
{/* Last (맨 뒤로) */}
<button
type="button"
onClick={() => setCurrentPage(totalPages)}
aria-label="맨 뒤 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
</div>
</button>
</div>
</div>
)}
</div>
</main>
);
}

View File

@@ -1,12 +1,20 @@
'use client';
import type { ReactNode } from "react";
import { usePathname } from "next/navigation";
import MenuSidebar from "./MenuSidebar";
export default function MenuLayout({ children }: { children: ReactNode }) {
const pathname = usePathname();
const isContinuePage = pathname?.includes('/continue');
return (
<div className="mx-auto flex w-full max-w-[1440px] min-h-full">
<aside className="hidden w-[320px] border-r border-[#dee1e6] px-4 py-6">
<div className="mx-auto flex w-full max-w-[1440px] min-h-screen">
{!isContinuePage && (
<aside className="w-[320px] border-r border-[#dee1e6] px-4 py-6">
<MenuSidebar />
</aside>
)}
<section className="flex-1">{children}</section>
</div>
);

View File

@@ -170,7 +170,7 @@ export default function NoticeDetailPage() {
<div className="flex justify-center">
<div className="w-full max-w-[1440px]">
{/* 상단 타이틀 */}
<div className="h-[100px] flex items-center gap-3 px-8">
<div className="h-[100px] flex items-center gap-[12px] px-8">
<button
type="button"
onClick={() => router.back()}

View File

@@ -133,7 +133,7 @@ export default function ResourceDetailPage() {
<div className="w-full bg-white">
<div className="flex justify-center">
<div className="w-full max-w-[1440px]">
<div className="h-[100px] flex items-center gap-3 px-8">
<div className="h-[100px] flex items-center gap-[12px] px-8">
<Link
href="/resources"
aria-label="뒤로 가기"
@@ -163,7 +163,7 @@ export default function ResourceDetailPage() {
<div className="flex justify-center">
<div className="w-full max-w-[1440px]">
{/* 상단 타이틀 */}
<div className="h-[100px] flex items-center gap-3 px-8">
<div className="h-[100px] flex items-center gap-[12px] px-8">
<Link
href="/resources"
aria-label="뒤로 가기"