내 강좌실 작업중11
This commit is contained in:
@@ -160,7 +160,7 @@ export default function AdminNoticeDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<main className="w-[1120px] bg-white">
|
<main className="w-[1120px] bg-white">
|
||||||
<div className="h-full flex flex-col">
|
<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
|
<Link
|
||||||
href="/admin/notices"
|
href="/admin/notices"
|
||||||
aria-label="뒤로 가기"
|
aria-label="뒤로 가기"
|
||||||
@@ -198,7 +198,7 @@ export default function AdminNoticeDetailPage() {
|
|||||||
<main className="w-[1120px] bg-white">
|
<main className="w-[1120px] bg-white">
|
||||||
<div className="h-full flex flex-col">
|
<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
|
<Link
|
||||||
href="/admin/notices"
|
href="/admin/notices"
|
||||||
aria-label="뒤로 가기"
|
aria-label="뒤로 가기"
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export default function AdminResourceDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<main className="w-[1120px] bg-white">
|
<main className="w-[1120px] bg-white">
|
||||||
<div className="h-full flex flex-col">
|
<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
|
<Link
|
||||||
href="/admin/resources"
|
href="/admin/resources"
|
||||||
aria-label="뒤로 가기"
|
aria-label="뒤로 가기"
|
||||||
@@ -198,7 +198,7 @@ export default function AdminResourceDetailPage() {
|
|||||||
<main className="w-[1120px] bg-white">
|
<main className="w-[1120px] bg-white">
|
||||||
<div className="h-full flex flex-col">
|
<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
|
<Link
|
||||||
href="/admin/resources"
|
href="/admin/resources"
|
||||||
aria-label="뒤로 가기"
|
aria-label="뒤로 가기"
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import MainLogoSvg from '../svgs/mainlogosvg';
|
import MainLogoSvg from '../svgs/mainlogosvg';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isMenuPage = pathname?.startsWith('/menu');
|
||||||
|
|
||||||
return (
|
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="flex justify-center">
|
||||||
<div className="w-full max-w-[1440px] px-[1px] py-[40px] flex gap-[32px]">
|
<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]">
|
<div className="flex flex-col items-center gap-[7px] w-[72px]">
|
||||||
|
|||||||
43
src/app/components/TokenExpiredModal.tsx
Normal file
43
src/app/components/TokenExpiredModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
55
src/app/components/TokenExpiredProvider.tsx
Normal file
55
src/app/components/TokenExpiredProvider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ export default function CourseDetailPage() {
|
|||||||
const [lectures, setLectures] = useState<any[]>([]);
|
const [lectures, setLectures] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [lectureProgresses, setLectureProgresses] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCourse = async () => {
|
const fetchCourse = async () => {
|
||||||
@@ -109,6 +110,26 @@ export default function CourseDetailPage() {
|
|||||||
|
|
||||||
setLectures(filteredLectures);
|
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 타입으로 변환
|
// API 응답 데이터를 CourseDetail 타입으로 변환
|
||||||
const courseDetail: CourseDetail = {
|
const courseDetail: CourseDetail = {
|
||||||
id: String(data.id || params.id),
|
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">
|
<div className="flex flex-col gap-[8px] items-start mt-[24px] w-full">
|
||||||
{lectures.length > 0 ? (
|
{lectures.length > 0 ? (
|
||||||
lectures.map((lecture: any, index: number) => {
|
lectures.map((lecture: any, index: number) => {
|
||||||
const isSubmitted = false; // TODO: 진행률 API에서 가져와야 함
|
const lectureId = String(lecture.id || lecture.lectureId);
|
||||||
const action = isSubmitted ? "복습하기" : (index === 0 ? "수강하기" : "이어서 수강하기");
|
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
|
const submitBtnBorder = isSubmitted
|
||||||
? "border-transparent"
|
? "border-transparent"
|
||||||
: (action === "이어서 수강하기" || action === "수강하기" ? "border-[#b1b8c0]" : "border-[#8c95a1]");
|
: (action === "이어서 수강하기" || action === "수강하기" ? "border-[#b1b8c0]" : "border-[#8c95a1]");
|
||||||
const submitBtnText = isSubmitted ? "text-[#384fbf]" : (action === "이어서 수강하기" || action === "수강하기" ? "text-[#b1b8c0]" : "text-[#4c5561]");
|
const submitBtnText = isSubmitted ? "text-[#384fbf]" : (action === "이어서 수강하기" || action === "수강하기" ? "text-[#b1b8c0]" : "text-[#4c5561]");
|
||||||
|
|
||||||
|
// isCompleted면 배경색 #F1F3F5, 아니면 기본 스타일
|
||||||
const rightBtnStyle =
|
const rightBtnStyle =
|
||||||
action === "이어서 수강하기" || action === "수강하기"
|
isCompleted
|
||||||
|
? "bg-[#f1f3f5] text-[#4c5561]"
|
||||||
|
: (action === "이어서 수강하기" || action === "수강하기"
|
||||||
? "bg-[#ecf0ff] text-[#384fbf]"
|
? "bg-[#ecf0ff] text-[#384fbf]"
|
||||||
: "bg-[#f1f3f5] text-[#4c5561]";
|
: "bg-[#f1f3f5] text-[#4c5561]");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={lecture.id || lecture.lectureId || index} className="bg-white border border-[#dee1e6] border-solid relative rounded-[8px] w-full">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
const lectureId = lecture.id || lecture.lectureId;
|
const lectureId = lecture.id || lecture.lectureId;
|
||||||
if (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`);
|
router.push(`/menu/courses/lessons/${lectureId}/start`);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={[
|
className={[
|
||||||
"box-border flex flex-col h-[32px] items-center justify-center px-[16px] py-[3px] rounded-[6px] shrink-0 transition-colors",
|
"box-border flex flex-col h-[32px] items-center justify-center px-[16px] py-[3px] rounded-[6px] shrink-0 transition-colors",
|
||||||
rightBtnStyle,
|
rightBtnStyle,
|
||||||
action === "이어서 수강하기" || action === "수강하기"
|
isCompleted
|
||||||
|
? "hover:bg-[#e5e8eb]"
|
||||||
|
: (action === "이어서 수강하기" || action === "수강하기"
|
||||||
? "hover:bg-[#d0d9ff]"
|
? "hover:bg-[#d0d9ff]"
|
||||||
: "hover:bg-[#e5e8eb]",
|
: "hover:bg-[#e5e8eb]"),
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<p className={[
|
<p className={[
|
||||||
"font-['Pretendard:Medium',sans-serif] text-[14px] leading-[1.5] text-center",
|
"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>
|
].join(" ")}>{action}</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import "./globals.css";
|
|||||||
import { pretendard } from "./fonts";
|
import { pretendard } from "./fonts";
|
||||||
import HeaderVisibility from "./components/HeaderVisibility";
|
import HeaderVisibility from "./components/HeaderVisibility";
|
||||||
import FooterVisibility from "./components/FooterVisibility";
|
import FooterVisibility from "./components/FooterVisibility";
|
||||||
|
import { TokenExpiredProvider } from "./components/TokenExpiredProvider";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "XRLMS",
|
title: "XRLMS",
|
||||||
@@ -15,11 +16,13 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<body className={`${pretendard.className} min-h-screen flex flex-col`}>
|
<body className={`${pretendard.className} min-h-screen flex flex-col`}>
|
||||||
|
<TokenExpiredProvider>
|
||||||
<HeaderVisibility />
|
<HeaderVisibility />
|
||||||
<main className="flex-1 min-h-0">
|
<main className="flex-1 min-h-0">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<FooterVisibility />
|
<FooterVisibility />
|
||||||
|
</TokenExpiredProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 토큰 삭제 및 로그인 페이지로 리다이렉트
|
* 토큰 삭제 및 토큰 만료 모달 표시
|
||||||
*/
|
*/
|
||||||
private handleTokenError() {
|
private handleTokenError() {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
@@ -54,14 +54,11 @@ class ApiService {
|
|||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
|
||||||
// 현재 경로 가져오기
|
// 로그인 페이지가 아닐 때만 모달 표시
|
||||||
const currentPath = window.location.pathname;
|
const currentPath = window.location.pathname;
|
||||||
|
|
||||||
// 로그인 페이지가 아닐 때만 리다이렉트
|
|
||||||
if (currentPath !== '/login') {
|
if (currentPath !== '/login') {
|
||||||
const loginUrl = new URL('/login', window.location.origin);
|
// 전역 이벤트 발생하여 모달 표시
|
||||||
loginUrl.searchParams.set('redirect', currentPath);
|
window.dispatchEvent(new CustomEvent('tokenExpired'));
|
||||||
window.location.href = loginUrl.toString();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,10 +59,8 @@ export default function LoginPage() {
|
|||||||
// admin 권한이면 /admin/id로 리다이렉트
|
// admin 권한이면 /admin/id로 리다이렉트
|
||||||
router.push('/admin/id');
|
router.push('/admin/id');
|
||||||
} else {
|
} else {
|
||||||
// 그 외의 경우 기존 로직대로 리다이렉트
|
// 그 외의 경우 홈 페이지로 이동 (redirect 파라미터 무시)
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
router.push('/');
|
||||||
const redirectPath = searchParams.get('redirect') || '/';
|
|
||||||
router.push(redirectPath);
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -144,12 +142,9 @@ export default function LoginPage() {
|
|||||||
// 사용자 정보 조회 실패 시에도 기존 로직대로 진행
|
// 사용자 정보 조회 실패 시에도 기존 로직대로 진행
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리다이렉트 경로 확인
|
// redirect 파라미터 무시하고 계정에 따른 메인 페이지로 이동
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
// admin이 아닌 경우 홈 페이지로 이동
|
||||||
const redirectPath = searchParams.get('redirect') || '/';
|
router.push('/');
|
||||||
|
|
||||||
// 메인 페이지로 이동
|
|
||||||
router.push(redirectPath);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "네트워크 오류가 발생했습니다.";
|
const errorMessage = error instanceof Error ? error.message : "네트워크 오류가 발생했습니다.";
|
||||||
console.error("로그인 오류:", errorMessage);
|
console.error("로그인 오류:", errorMessage);
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
|
||||||
import ChevronDownSvg from "../../svgs/chevrondownsvg";
|
|
||||||
|
|
||||||
type Lesson = {
|
type Lesson = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -35,31 +33,26 @@ function ProgressBar({ value }: { value: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CourseCard({ course, defaultOpen = false }: { course: Course; defaultOpen?: boolean }) {
|
export default function CourseCard({ course }: { course: Course }) {
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const totalMinutes = course.lessons.reduce((sum, l) => sum + (l.durationMin || 0), 0);
|
const totalMinutes = course.lessons.reduce((sum, l) => sum + (l.durationMin || 0), 0);
|
||||||
const totalHours = Math.floor(totalMinutes / 60);
|
const totalHours = Math.floor(totalMinutes / 60);
|
||||||
const restMinutes = 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 handleClick = () => {
|
||||||
const minutes = String(m).padStart(2, "0");
|
router.push(`/menu/courses/${course.id}`);
|
||||||
const seconds = String((m * 7) % 60).padStart(2, "0");
|
|
||||||
return `${minutes}:${seconds}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={cardClassName}>
|
<article className="rounded-xl bg-white shadow-[0_2px_8px_rgba(0,0,0,0.02)] border border-bg-primary-light">
|
||||||
<header className="flex items-center gap-6 px-8 py-6">
|
<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">
|
<div className="relative h-[120px] w-[180px] overflow-hidden rounded-[8px] bg-bg-gray-light">
|
||||||
<Image
|
<Image
|
||||||
src={`https://picsum.photos/seed/${encodeURIComponent(course.id)}/240/152`}
|
src={course.thumbnail || `https://picsum.photos/seed/${encodeURIComponent(course.id)}/240/152`}
|
||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
sizes="180px"
|
sizes="180px"
|
||||||
@@ -89,108 +82,7 @@ export default function CourseCard({ course, defaultOpen = false }: { course: Co
|
|||||||
<ProgressBar value={course.progressPct} />
|
<ProgressBar value={course.progressPct} />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
'use client';
|
'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 = {
|
type Lesson = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,113 +29,434 @@ type CourseDetail = {
|
|||||||
lessons: Lesson[];
|
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() {
|
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 (
|
return (
|
||||||
<main className="flex w-full flex-col">
|
<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>
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">교육 과정 상세보기</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="px-8 pb-20">
|
{/* 메인 콘텐츠 */}
|
||||||
<div className="rounded-[8px] bg-white px-8 pb-20 pt-6">
|
<section className="px-8 pb-[80px] pt-[24px]">
|
||||||
{/* 상단 소개 카드 */}
|
{/* 상단 정보 카드 */}
|
||||||
<div className="flex gap-6 rounded-[8px] bg-[#f8f9fa] p-6">
|
<div className="bg-[#f8f9fa] box-border flex gap-[24px] items-start p-[24px] rounded-[8px] w-full">
|
||||||
<div className="relative h-[159px] w-[292px] overflow-hidden rounded">
|
{/* 이미지 컨테이너 */}
|
||||||
<Image src={c.thumbnail} alt="" fill sizes="292px" className="object-cover" unoptimized />
|
<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>
|
||||||
<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]">
|
<div className="basis-0 flex flex-col gap-[12px] grow items-start min-h-px min-w-px relative shrink-0">
|
||||||
{c.status}
|
{/* 제목 영역 */}
|
||||||
</span>
|
<div className="flex gap-[8px] h-[27px] items-center w-full">
|
||||||
<h2 className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">{c.title}</h2>
|
<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>
|
||||||
<div className="mt-3 space-y-1">
|
<h2 className="font-['Pretendard:SemiBold',sans-serif] text-[#333c47] text-[18px] leading-[1.5]">{course.title}</h2>
|
||||||
<p className="text-[15px] leading-[1.5] text-[#333c47]">
|
</div>
|
||||||
<span className="font-medium">학습 목표:</span> {c.goal}
|
|
||||||
|
{/* 학습 목표 및 방법 */}
|
||||||
|
<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>
|
||||||
<p className="text-[15px] leading-[1.5] text-[#333c47]">
|
<p className="font-['Pretendard:Regular',sans-serif] text-[#333c47] text-[15px] leading-[1.5]">
|
||||||
<span className="font-medium">학습 방법:</span> {c.method}
|
<span className="font-['Pretendard:Medium',sans-serif]">학습 방법:</span>
|
||||||
|
<span>{` ${course.method}`}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 차시 리스트 */}
|
{/* Lecture 리스트 */}
|
||||||
<div className="mt-6 space-y-2">
|
<div className="flex flex-col gap-[8px] items-start mt-[24px] w-full">
|
||||||
{c.lessons.map((l) => {
|
{lectures.length > 0 ? (
|
||||||
const isSubmitted = l.state === "제출완료";
|
lectures.map((lecture: any, index: number) => {
|
||||||
const submitBtnStyle =
|
const lectureId = String(lecture.id || lecture.lectureId);
|
||||||
l.state === "제출완료"
|
const progress = lectureProgresses[lectureId];
|
||||||
? "border border-transparent text-[#384fbf]"
|
const isCompleted = progress?.isCompleted === true;
|
||||||
: "border " + (l.action === "이어서 수강하기" || l.action === "수강하기" ? "border-[#b1b8c0]" : "border-[#8c95a1]");
|
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 =
|
const rightBtnStyle =
|
||||||
l.action === "이어서 수강하기"
|
isCompleted
|
||||||
|
? "bg-[#f1f3f5] text-[#4c5561]"
|
||||||
|
: (action === "이어서 수강하기" || action === "수강하기"
|
||||||
? "bg-[#ecf0ff] text-[#384fbf]"
|
? "bg-[#ecf0ff] text-[#384fbf]"
|
||||||
: l.action === "수강하기"
|
: "bg-[#f1f3f5] text-[#4c5561]");
|
||||||
? "bg-[#ecf0ff] text-[#384fbf]"
|
|
||||||
: "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 (
|
return (
|
||||||
<div key={l.id} className="rounded-[8px] border border-[#dee1e6] bg-white">
|
<div key={lecture.id || lecture.lectureId || index} className="bg-white border border-[#dee1e6] border-solid relative rounded-[8px] w-full">
|
||||||
<div className="flex items-center justify-between gap-4 rounded-[8px] px-6 py-4">
|
<div className="box-border flex gap-[16px] items-center overflow-clip px-[24px] py-[16px] rounded-[inherit] w-full">
|
||||||
<div className="min-w-0">
|
<div className="basis-0 flex grow h-[46px] items-center justify-between min-h-px min-w-px relative shrink-0">
|
||||||
<p className="text-[16px] font-semibold leading-[1.5] text-[#333c47]">{l.title}</p>
|
{/* Lecture 정보 */}
|
||||||
<div className="mt-1 flex items-center gap-3">
|
<div className="basis-0 grow min-h-px min-w-px relative shrink-0">
|
||||||
<p className="w-[40px] text-[13px] leading-[1.4] text-[#8c95a1]">{l.duration}</p>
|
<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>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 영역 */}
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
{/* 학습 제출 버튼 */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={[
|
className={[
|
||||||
"h-8 rounded-[6px] px-4 text-[14px] font-medium leading-[1.5]",
|
"bg-white box-border flex flex-col h-[32px] items-center justify-center px-[16px] py-[3px] rounded-[6px] shrink-0",
|
||||||
"bg-white",
|
"border border-solid",
|
||||||
submitBtnStyle,
|
submitBtnBorder,
|
||||||
|
submitBtnText,
|
||||||
].join(" ")}
|
].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>
|
||||||
|
|
||||||
|
{/* 수강/복습 버튼 */}
|
||||||
<button
|
<button
|
||||||
type="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={[
|
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,
|
rightBtnStyle,
|
||||||
|
isCompleted
|
||||||
|
? "hover:bg-[#e5e8eb]"
|
||||||
|
: (action === "이어서 수강하기" || action === "수강하기"
|
||||||
|
? "hover:bg-[#d0d9ff]"
|
||||||
|
: "hover:bg-[#e5e8eb]"),
|
||||||
].join(" ")}
|
].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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
const imgImage2 = "/imgs/image-2.png";
|
const imgImage2 = "/imgs/image-2.png";
|
||||||
const imgLine58 = "/imgs/line-58.svg";
|
const imgLine58 = "/imgs/line-58.svg";
|
||||||
const img = "/imgs/asset-base.svg";
|
const img = "/imgs/asset-base.svg";
|
||||||
@@ -13,21 +16,88 @@ const imgIcon2 = "/imgs/icon-2.svg";
|
|||||||
const imgIcon3 = "/imgs/icon-3.svg";
|
const imgIcon3 = "/imgs/icon-3.svg";
|
||||||
const imgIcon4 = "/imgs/icon-4.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 (
|
return (
|
||||||
<div className="bg-white content-stretch flex flex-col items-center relative size-full">
|
<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="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="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="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} />
|
<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">
|
<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">
|
<p className="font-['Pretendard:SemiBold',sans-serif] relative shrink-0 text-[#6c7682] text-[16px] w-full">
|
||||||
원자로 운전 및 계통
|
{subjectTitle}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-['Pretendard:Bold',sans-serif] relative shrink-0 text-[#1b2027] text-[24px] w-full">
|
<p className="font-['Pretendard:Bold',sans-serif] relative shrink-0 text-[#1b2027] text-[24px] w-full">
|
||||||
6. 원자로 시동, 운전 및 정지 절차
|
{lectureTitle}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="content-stretch flex gap-[20px] h-[81px] items-center justify-center relative shrink-0">
|
<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>
|
||||||
<div className="relative shrink-0 w-[52px]">
|
<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-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]">
|
<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
|
2
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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 실습
|
XR 실습
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,14 +137,14 @@ export default function FigmaSelectedLessonPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="relative shrink-0 w-[52px]">
|
<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-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]">
|
<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
|
3
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,12 +153,26 @@ export default function FigmaSelectedLessonPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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="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 left-1/2 size-[120px] top-1/2 translate-x-[-50%] translate-y-[-50%]">
|
||||||
<div className="absolute contents inset-0">
|
<div className="absolute contents inset-0">
|
||||||
<img alt="" className="block max-w-none size-full" src={imgGroup} />
|
<img alt="" className="block max-w-none size-full" src={imgGroup} />
|
||||||
</div>
|
</div>
|
||||||
</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="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="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%]">
|
<div className="absolute left-0 size-[12px] top-1/2 translate-y-[-50%]">
|
||||||
@@ -152,6 +236,154 @@ export default function FigmaSelectedLessonPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<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">
|
<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]">
|
<li className="ms-[21px]">
|
||||||
@@ -161,6 +393,78 @@ export default function FigmaSelectedLessonPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,111 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import FigmaSelectedLessonPage from '../../FigmaSelectedLessonPage';
|
import FigmaSelectedLessonPage from '../../FigmaSelectedLessonPage';
|
||||||
|
import apiService from '@/app/lib/apiService';
|
||||||
|
|
||||||
export default function ContinueLessonPage() {
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,111 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import FigmaSelectedLessonPage from '../../FigmaSelectedLessonPage';
|
import FigmaSelectedLessonPage from '../../FigmaSelectedLessonPage';
|
||||||
|
import apiService from '@/app/lib/apiService';
|
||||||
|
|
||||||
export default function StartLessonPage() {
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState, useEffect } from "react";
|
||||||
import CourseCard from "./CourseCard";
|
import CourseCard from "./CourseCard";
|
||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
|
||||||
|
|
||||||
type CourseStatus = "전체" | "수강 예정" | "수강중" | "수강 완료";
|
type CourseStatus = "전체" | "수강 예정" | "수강중" | "수강 완료";
|
||||||
|
|
||||||
@@ -23,80 +25,277 @@ type Course = {
|
|||||||
lessons: Lesson[];
|
lessons: Lesson[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const COURSES: Course[] = [
|
const TABS: CourseStatus[] = ["전체", "수강중", "수강 완료"];
|
||||||
{
|
const ITEMS_PER_PAGE = 5;
|
||||||
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[] = ["전체", "수강 예정", "수강중", "수강 완료"];
|
|
||||||
|
|
||||||
export default function CoursesPage() {
|
export default function CoursesPage() {
|
||||||
const [activeTab, setActiveTab] = useState<CourseStatus>("전체");
|
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(() => {
|
const countsByStatus = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
전체: COURSES.length,
|
전체: courses.length,
|
||||||
"수강 예정": COURSES.filter((c) => c.status === "수강 예정").length,
|
수강중: courses.filter((c) => c.status === "수강중").length,
|
||||||
수강중: COURSES.filter((c) => c.status === "수강중").length,
|
"수강 완료": courses.filter((c) => c.status === "수강 완료").length,
|
||||||
"수강 완료": COURSES.filter((c) => c.status === "수강 완료").length,
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, [courses]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (activeTab === "전체") return COURSES;
|
if (activeTab === "전체") return courses;
|
||||||
return COURSES.filter((c) => c.status === activeTab);
|
return courses.filter((c) => c.status === activeTab);
|
||||||
|
}, [activeTab, courses]);
|
||||||
|
|
||||||
|
// 탭 변경 시 첫 페이지로 리셋
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
}, [activeTab]);
|
}, [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 (
|
return (
|
||||||
<main className="flex w-full flex-col">
|
<main className="flex w-full flex-col">
|
||||||
<div className="flex h-[100px] items-center px-8">
|
<div className="flex h-[100px] items-center px-8">
|
||||||
@@ -134,42 +333,98 @@ export default function CoursesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-8 pb-16 pt-6">
|
<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">
|
<div className="flex flex-col gap-4">
|
||||||
{filtered.map((course) => (
|
{pagedCourses.map((course) => (
|
||||||
<CourseCard key={course.id} course={course} />
|
<CourseCard key={course.id} course={course} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* pagination */}
|
{/* 페이지네이션 - 5개 초과일 때만 표시 */}
|
||||||
<div className="mt-10 flex items-center justify-center gap-2">
|
{totalCount > ITEMS_PER_PAGE && (
|
||||||
|
<div className="mt-10 flex items-center justify-center">
|
||||||
|
<div className="flex items-center justify-center gap-[8px]">
|
||||||
|
{/* First (맨 앞으로) */}
|
||||||
<button
|
<button
|
||||||
type="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="이전 페이지"
|
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}
|
||||||
>
|
>
|
||||||
‹
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
||||||
</button>
|
</button>
|
||||||
{[1, 2, 3].map((p) => (
|
|
||||||
|
{/* Numbers */}
|
||||||
|
{visiblePages.map((n) => {
|
||||||
|
const active = n === currentPage;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={p}
|
key={n}
|
||||||
type="button"
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(n)}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
className={[
|
className={[
|
||||||
"flex h-8 w-8 items-center justify-center rounded-full text-[13px] leading-[1.4]",
|
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
|
||||||
p === 2 ? "bg-[#1f2b91] text-white" : "text-[#4c5561] hover:bg-[#f1f3f5]",
|
active ? 'bg-bg-primary-light' : 'bg-white',
|
||||||
].join(" ")}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{p}
|
<span className="text-[16px] leading-[1.4] text-neutral-700">{n}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
<button
|
<button
|
||||||
type="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="다음 페이지"
|
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}
|
||||||
>
|
>
|
||||||
›
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import MenuSidebar from "./MenuSidebar";
|
import MenuSidebar from "./MenuSidebar";
|
||||||
|
|
||||||
export default function MenuLayout({ children }: { children: ReactNode }) {
|
export default function MenuLayout({ children }: { children: ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isContinuePage = pathname?.includes('/continue');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-[1440px] min-h-full">
|
<div className="mx-auto flex w-full max-w-[1440px] min-h-screen">
|
||||||
<aside className="hidden w-[320px] border-r border-[#dee1e6] px-4 py-6">
|
{!isContinuePage && (
|
||||||
|
<aside className="w-[320px] border-r border-[#dee1e6] px-4 py-6">
|
||||||
<MenuSidebar />
|
<MenuSidebar />
|
||||||
</aside>
|
</aside>
|
||||||
|
)}
|
||||||
<section className="flex-1">{children}</section>
|
<section className="flex-1">{children}</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ export default function NoticeDetailPage() {
|
|||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div className="w-full max-w-[1440px]">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export default function ResourceDetailPage() {
|
|||||||
<div className="w-full bg-white">
|
<div className="w-full bg-white">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div className="w-full max-w-[1440px]">
|
<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
|
<Link
|
||||||
href="/resources"
|
href="/resources"
|
||||||
aria-label="뒤로 가기"
|
aria-label="뒤로 가기"
|
||||||
@@ -163,7 +163,7 @@ export default function ResourceDetailPage() {
|
|||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div className="w-full max-w-[1440px]">
|
<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
|
<Link
|
||||||
href="/resources"
|
href="/resources"
|
||||||
aria-label="뒤로 가기"
|
aria-label="뒤로 가기"
|
||||||
|
|||||||
Reference in New Issue
Block a user