내 강좌실 작업중11
This commit is contained in:
@@ -160,7 +160,7 @@ export default function AdminNoticeDetailPage() {
|
||||
</div>
|
||||
<main className="w-[1120px] bg-white">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="h-[100px] flex items-center gap-[10px] px-[32px]">
|
||||
<div className="h-[100px] flex items-center gap-[12px] px-[32px]">
|
||||
<Link
|
||||
href="/admin/notices"
|
||||
aria-label="뒤로 가기"
|
||||
@@ -198,7 +198,7 @@ export default function AdminNoticeDetailPage() {
|
||||
<main className="w-[1120px] bg-white">
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 상단 타이틀 */}
|
||||
<div className="h-[100px] flex items-center gap-[10px] px-[32px]">
|
||||
<div className="h-[100px] flex items-center gap-[12px] px-[32px]">
|
||||
<Link
|
||||
href="/admin/notices"
|
||||
aria-label="뒤로 가기"
|
||||
|
||||
@@ -160,7 +160,7 @@ export default function AdminResourceDetailPage() {
|
||||
</div>
|
||||
<main className="w-[1120px] bg-white">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="h-[100px] flex items-center gap-[10px] px-[32px]">
|
||||
<div className="h-[100px] flex items-center gap-[12px] px-[32px]">
|
||||
<Link
|
||||
href="/admin/resources"
|
||||
aria-label="뒤로 가기"
|
||||
@@ -198,7 +198,7 @@ export default function AdminResourceDetailPage() {
|
||||
<main className="w-[1120px] bg-white">
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 상단 타이틀 */}
|
||||
<div className="h-[100px] flex items-center gap-[10px] px-[32px]">
|
||||
<div className="h-[100px] flex items-center gap-[12px] px-[32px]">
|
||||
<Link
|
||||
href="/admin/resources"
|
||||
aria-label="뒤로 가기"
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import MainLogoSvg from '../svgs/mainlogosvg';
|
||||
|
||||
export default function Footer() {
|
||||
const pathname = usePathname();
|
||||
const isMenuPage = pathname?.startsWith('/menu');
|
||||
|
||||
return (
|
||||
<footer className="bg-[#f2f3f7] border-t border-[rgba(0,0,0,0.1)]">
|
||||
<footer className="bg-[#f2f3f7] border-t border-[rgba(0,0,0,0.1)] relative">
|
||||
{isMenuPage && (
|
||||
<div className="absolute left-1/2 -translate-x-1/2 w-full max-w-[1440px]">
|
||||
<div className="absolute left-[320px] top-0 bottom-0 w-px bg-[#dee1e6]"></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-full max-w-[1440px] px-[1px] py-[40px] flex gap-[32px]">
|
||||
<div className="flex flex-col items-center gap-[7px] w-[72px]">
|
||||
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lectureProgresses, setLectureProgresses] = useState<Record<string, any>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCourse = async () => {
|
||||
@@ -109,6 +110,26 @@ export default function CourseDetailPage() {
|
||||
|
||||
setLectures(filteredLectures);
|
||||
|
||||
// 각 lecture의 진행률 조회
|
||||
const progressMap: Record<string, any> = {};
|
||||
await Promise.all(
|
||||
filteredLectures.map(async (lecture: any) => {
|
||||
const lectureId = lecture.id || lecture.lectureId;
|
||||
if (lectureId) {
|
||||
try {
|
||||
const progressResponse = await apiService.getLectureProgress(lectureId);
|
||||
if (progressResponse?.data) {
|
||||
progressMap[String(lectureId)] = progressResponse.data;
|
||||
}
|
||||
} catch (progressErr) {
|
||||
// 진행률이 없으면 무시 (에러가 아닐 수 있음)
|
||||
console.log(`Lecture ${lectureId} 진행률 조회 실패:`, progressErr);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
setLectureProgresses(progressMap);
|
||||
|
||||
// API 응답 데이터를 CourseDetail 타입으로 변환
|
||||
const courseDetail: CourseDetail = {
|
||||
id: String(data.id || params.id),
|
||||
@@ -287,16 +308,27 @@ export default function CourseDetailPage() {
|
||||
<div className="flex flex-col gap-[8px] items-start mt-[24px] w-full">
|
||||
{lectures.length > 0 ? (
|
||||
lectures.map((lecture: any, index: number) => {
|
||||
const isSubmitted = false; // TODO: 진행률 API에서 가져와야 함
|
||||
const action = isSubmitted ? "복습하기" : (index === 0 ? "수강하기" : "이어서 수강하기");
|
||||
const lectureId = String(lecture.id || lecture.lectureId);
|
||||
const progress = lectureProgresses[lectureId];
|
||||
const isCompleted = progress?.isCompleted === true;
|
||||
const hasProgress = !!progress;
|
||||
|
||||
// 버튼 텍스트 결정: isCompleted면 "복습하기", progress 있으면 "이어서 수강하기", 없으면 "수강하기"
|
||||
const action = isCompleted ? "복습하기" : (hasProgress ? "이어서 수강하기" : "수강하기");
|
||||
|
||||
const isSubmitted = false; // TODO: 학습 제출 상태는 별도로 관리 필요
|
||||
const submitBtnBorder = isSubmitted
|
||||
? "border-transparent"
|
||||
: (action === "이어서 수강하기" || action === "수강하기" ? "border-[#b1b8c0]" : "border-[#8c95a1]");
|
||||
const submitBtnText = isSubmitted ? "text-[#384fbf]" : (action === "이어서 수강하기" || action === "수강하기" ? "text-[#b1b8c0]" : "text-[#4c5561]");
|
||||
|
||||
// isCompleted면 배경색 #F1F3F5, 아니면 기본 스타일
|
||||
const rightBtnStyle =
|
||||
action === "이어서 수강하기" || action === "수강하기"
|
||||
? "bg-[#ecf0ff] text-[#384fbf]"
|
||||
: "bg-[#f1f3f5] text-[#4c5561]";
|
||||
isCompleted
|
||||
? "bg-[#f1f3f5] text-[#4c5561]"
|
||||
: (action === "이어서 수강하기" || action === "수강하기"
|
||||
? "bg-[#ecf0ff] text-[#384fbf]"
|
||||
: "bg-[#f1f3f5] text-[#4c5561]");
|
||||
|
||||
return (
|
||||
<div key={lecture.id || lecture.lectureId || index} className="bg-white border border-[#dee1e6] border-solid relative rounded-[8px] w-full">
|
||||
@@ -349,23 +381,37 @@ export default function CourseDetailPage() {
|
||||
{/* 수강/복습 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
const lectureId = lecture.id || lecture.lectureId;
|
||||
if (lectureId) {
|
||||
// "수강하기" 버튼 클릭 시 progress 생성
|
||||
if (action === "수강하기") {
|
||||
try {
|
||||
await apiService.updateLectureProgress({
|
||||
lectureId: Number(lectureId),
|
||||
progressPct: 0,
|
||||
lastPositionSec: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Progress 생성 실패:', error);
|
||||
}
|
||||
}
|
||||
router.push(`/menu/courses/lessons/${lectureId}/start`);
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
"box-border flex flex-col h-[32px] items-center justify-center px-[16px] py-[3px] rounded-[6px] shrink-0 transition-colors",
|
||||
rightBtnStyle,
|
||||
action === "이어서 수강하기" || action === "수강하기"
|
||||
? "hover:bg-[#d0d9ff]"
|
||||
: "hover:bg-[#e5e8eb]",
|
||||
isCompleted
|
||||
? "hover:bg-[#e5e8eb]"
|
||||
: (action === "이어서 수강하기" || action === "수강하기"
|
||||
? "hover:bg-[#d0d9ff]"
|
||||
: "hover:bg-[#e5e8eb]"),
|
||||
].join(" ")}
|
||||
>
|
||||
<p className={[
|
||||
"font-['Pretendard:Medium',sans-serif] text-[14px] leading-[1.5] text-center",
|
||||
action === "이어서 수강하기" || action === "수강하기" ? "text-[#384fbf]" : "text-[#4c5561]",
|
||||
isCompleted ? "text-[#4c5561]" : (action === "이어서 수강하기" || action === "수강하기" ? "text-[#384fbf]" : "text-[#4c5561]"),
|
||||
].join(" ")}>{action}</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import "./globals.css";
|
||||
import { pretendard } from "./fonts";
|
||||
import HeaderVisibility from "./components/HeaderVisibility";
|
||||
import FooterVisibility from "./components/FooterVisibility";
|
||||
import { TokenExpiredProvider } from "./components/TokenExpiredProvider";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "XRLMS",
|
||||
@@ -15,11 +16,13 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="ko">
|
||||
<body className={`${pretendard.className} min-h-screen flex flex-col`}>
|
||||
<HeaderVisibility />
|
||||
<main className="flex-1 min-h-0">
|
||||
{children}
|
||||
</main>
|
||||
<FooterVisibility />
|
||||
<TokenExpiredProvider>
|
||||
<HeaderVisibility />
|
||||
<main className="flex-1 min-h-0">
|
||||
{children}
|
||||
</main>
|
||||
<FooterVisibility />
|
||||
</TokenExpiredProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -45,7 +45,7 @@ class ApiService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 삭제 및 로그인 페이지로 리다이렉트
|
||||
* 토큰 삭제 및 토큰 만료 모달 표시
|
||||
*/
|
||||
private handleTokenError() {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -54,14 +54,11 @@ class ApiService {
|
||||
localStorage.removeItem('token');
|
||||
document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
|
||||
// 현재 경로 가져오기
|
||||
// 로그인 페이지가 아닐 때만 모달 표시
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
// 로그인 페이지가 아닐 때만 리다이렉트
|
||||
if (currentPath !== '/login') {
|
||||
const loginUrl = new URL('/login', window.location.origin);
|
||||
loginUrl.searchParams.set('redirect', currentPath);
|
||||
window.location.href = loginUrl.toString();
|
||||
// 전역 이벤트 발생하여 모달 표시
|
||||
window.dispatchEvent(new CustomEvent('tokenExpired'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,10 +59,8 @@ export default function LoginPage() {
|
||||
// admin 권한이면 /admin/id로 리다이렉트
|
||||
router.push('/admin/id');
|
||||
} else {
|
||||
// 그 외의 경우 기존 로직대로 리다이렉트
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const redirectPath = searchParams.get('redirect') || '/';
|
||||
router.push(redirectPath);
|
||||
// 그 외의 경우 홈 페이지로 이동 (redirect 파라미터 무시)
|
||||
router.push('/');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -144,12 +142,9 @@ export default function LoginPage() {
|
||||
// 사용자 정보 조회 실패 시에도 기존 로직대로 진행
|
||||
}
|
||||
|
||||
// 리다이렉트 경로 확인
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const redirectPath = searchParams.get('redirect') || '/';
|
||||
|
||||
// 메인 페이지로 이동
|
||||
router.push(redirectPath);
|
||||
// redirect 파라미터 무시하고 계정에 따른 메인 페이지로 이동
|
||||
// admin이 아닌 경우 홈 페이지로 이동
|
||||
router.push('/');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "네트워크 오류가 발생했습니다.";
|
||||
console.error("로그인 오류:", errorMessage);
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import ChevronDownSvg from "../../svgs/chevrondownsvg";
|
||||
|
||||
type Lesson = {
|
||||
id: string;
|
||||
@@ -35,31 +33,26 @@ function ProgressBar({ value }: { value: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function CourseCard({ course, defaultOpen = false }: { course: Course; defaultOpen?: boolean }) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
export default function CourseCard({ course }: { course: Course }) {
|
||||
const router = useRouter();
|
||||
|
||||
const totalMinutes = course.lessons.reduce((sum, l) => sum + (l.durationMin || 0), 0);
|
||||
const totalHours = Math.floor(totalMinutes / 60);
|
||||
const restMinutes = totalMinutes % 60;
|
||||
const firstIncomplete = course.lessons.find((l) => !l.isCompleted)?.id;
|
||||
const cardClassName = [
|
||||
"rounded-xl bg-white shadow-[0_2px_8px_rgba(0,0,0,0.02)]",
|
||||
open ? "border-[3px] border-primary" : "border border-bg-primary-light",
|
||||
].join(" ");
|
||||
|
||||
const formatDuration = (m: number) => {
|
||||
const minutes = String(m).padStart(2, "0");
|
||||
const seconds = String((m * 7) % 60).padStart(2, "0");
|
||||
return `${minutes}:${seconds}`;
|
||||
const handleClick = () => {
|
||||
router.push(`/menu/courses/${course.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<article className={cardClassName}>
|
||||
<header className="flex items-center gap-6 px-8 py-6">
|
||||
<article className="rounded-xl bg-white shadow-[0_2px_8px_rgba(0,0,0,0.02)] border border-bg-primary-light">
|
||||
<header
|
||||
onClick={handleClick}
|
||||
className="flex items-center gap-6 px-8 py-6 cursor-pointer hover:bg-bg-gray-light/50 transition-colors"
|
||||
>
|
||||
<div className="relative h-[120px] w-[180px] overflow-hidden rounded-[8px] bg-bg-gray-light">
|
||||
<Image
|
||||
src={`https://picsum.photos/seed/${encodeURIComponent(course.id)}/240/152`}
|
||||
src={course.thumbnail || `https://picsum.photos/seed/${encodeURIComponent(course.id)}/240/152`}
|
||||
alt=""
|
||||
fill
|
||||
sizes="180px"
|
||||
@@ -89,108 +82,7 @@ export default function CourseCard({ course, defaultOpen = false }: { course: Co
|
||||
<ProgressBar value={course.progressPct} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-start">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex h-8 w-8 items-center justify-center text-text-label cursor-pointer"
|
||||
aria-label={open ? "접기" : "펼치기"}
|
||||
>
|
||||
<ChevronDownSvg
|
||||
width={16}
|
||||
height={16}
|
||||
className={["transition-transform", open ? "rotate-180" : "rotate-0"].join(" ")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{open ? (
|
||||
<div className="px-6 pb-6">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{course.lessons.map((lesson, idx) => (
|
||||
<li key={lesson.id} className="rounded-lg border border-input-border bg-white">
|
||||
<div className="flex items-center justify-between gap-4 px-[24px] py-[16px]">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[16px] font-semibold leading-normal text-neutral-700">
|
||||
{`${idx + 1}. ${lesson.title}`}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<span className="text-[13px] leading-[1.4] text-text-meta">
|
||||
{formatDuration(lesson.durationMin)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
"inline-flex h-[32px] w-[140px] items-center justify-center gap-[6px] rounded-[6px] px-4 text-center whitespace-nowrap cursor-pointer",
|
||||
lesson.isCompleted
|
||||
? "bg-white text-[13px] font-medium leading-[1.4] text-primary"
|
||||
: "bg-white text-[14px] font-medium leading-normal text-basic-text border border-text-meta",
|
||||
].join(" ")}
|
||||
>
|
||||
{lesson.isCompleted ? (
|
||||
<>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="10"
|
||||
height="7"
|
||||
viewBox="0 0 10 7"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M8.75 0.75L3.25 6.25L0.75 3.75"
|
||||
stroke="var(--color-primary)"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>학습 제출 완료</span>
|
||||
</>
|
||||
) : (
|
||||
"학습 제출 하기"
|
||||
)}
|
||||
</button>
|
||||
{lesson.isCompleted ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/menu/courses/lessons/${lesson.id}/review`)}
|
||||
className="inline-flex h-[32px] w-[140px] items-center justify-center rounded-[6px] bg-bg-gray-light px-4 text-center text-[14px] font-medium leading-normal text-basic-text whitespace-nowrap cursor-pointer"
|
||||
>
|
||||
복습하기
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
lesson.id === firstIncomplete
|
||||
? `/menu/courses/lessons/${lesson.id}/continue`
|
||||
: `/menu/courses/lessons/${lesson.id}/start`,
|
||||
)
|
||||
}
|
||||
className={[
|
||||
"inline-flex h-[32px] w-[140px] items-center justify-center rounded-[6px] px-4 text-center text-[14px] font-medium leading-normal whitespace-nowrap cursor-pointer",
|
||||
lesson.id === firstIncomplete
|
||||
? "bg-bg-primary-light text-primary"
|
||||
: "border border-input-border text-basic-text",
|
||||
].join(" ")}
|
||||
>
|
||||
{lesson.id === firstIncomplete ? "이어서 수강하기" : "수강하기"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import apiService from '../../../lib/apiService';
|
||||
import BackCircleSvg from '../../../svgs/backcirclesvg';
|
||||
|
||||
const imgPlay = '/imgs/play.svg';
|
||||
const imgMusicAudioPlay = '/imgs/music-audio-play.svg';
|
||||
|
||||
type Lesson = {
|
||||
id: string;
|
||||
@@ -22,113 +29,434 @@ type CourseDetail = {
|
||||
lessons: Lesson[];
|
||||
};
|
||||
|
||||
const MOCK_DETAIL: CourseDetail = {
|
||||
id: "c1",
|
||||
status: "수강 중",
|
||||
title: "원자로 운전 및 계통",
|
||||
goal:
|
||||
"원자로 운전 원리와 주요 계통의 구조 및 기능을 이해하고, 실제 운전 상황을 가상 환경에서 체험하여 문제 해결 능력을 기른다.",
|
||||
method:
|
||||
"강좌 동영상을 통해 이론을 학습한 후, XR 실습을 통해 원자로 운전 및 계통 제어 과정을 체험한다. 이후 문제 풀이를 통해 학습 내용을 점검하며 평가를 받아 학습 성취도를 확인한다.",
|
||||
summary: "VOD · 총 6강 · 4시간 20분",
|
||||
submitSummary: "학습 제출 3/6",
|
||||
thumbnail: "https://picsum.photos/seed/course-detail/584/318",
|
||||
lessons: [
|
||||
{ id: "l1", title: "1. 원자로 기초 및 핵분열 원리", duration: "12:46", state: "제출완료", action: "복습하기" },
|
||||
{ id: "l2", title: "2. 제어봉 및 원자로 출력 제어", duration: "18:23", state: "제출완료", action: "복습하기" },
|
||||
{ id: "l3", title: "3. 터빈-발전기 계통 및 전력 생산", duration: "13:47", state: "제출완료", action: "복습하기" },
|
||||
{ id: "l4", title: "4. 보조 계통 및 안전 계통", duration: "13:47", state: "제출대기", action: "복습하기" },
|
||||
{ id: "l5", title: "5. 원자로 냉각재 계통 (RCS) 및 열수력", duration: "08:11", state: "제출대기", action: "이어서 수강하기" },
|
||||
{ id: "l6", title: "6. 원자로 시동, 운전 및 정지 절차", duration: "13:47", state: "제출대기", action: "수강하기" },
|
||||
],
|
||||
};
|
||||
|
||||
export default function CourseDetailPage() {
|
||||
const c = MOCK_DETAIL;
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const [course, setCourse] = useState<CourseDetail | null>(null);
|
||||
const [lectures, setLectures] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lectureProgresses, setLectureProgresses] = useState<Record<string, any>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCourse = async () => {
|
||||
if (!params?.courseId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
let data: any = null;
|
||||
const subjectId = String(params.courseId);
|
||||
|
||||
// getSubjects로 과목 정보 가져오기
|
||||
try {
|
||||
const subjectsResponse = await apiService.getSubjects();
|
||||
let subjectsData: any[] = [];
|
||||
|
||||
if (Array.isArray(subjectsResponse.data)) {
|
||||
subjectsData = subjectsResponse.data;
|
||||
} else if (subjectsResponse.data && typeof subjectsResponse.data === 'object') {
|
||||
subjectsData = subjectsResponse.data.items ||
|
||||
subjectsResponse.data.courses ||
|
||||
subjectsResponse.data.data ||
|
||||
subjectsResponse.data.list ||
|
||||
subjectsResponse.data.subjects ||
|
||||
subjectsResponse.data.subjectList ||
|
||||
[];
|
||||
}
|
||||
|
||||
// ID로 과목 찾기
|
||||
data = subjectsData.find((s: any) => String(s.id || s.subjectId) === subjectId);
|
||||
} catch (subjectsErr) {
|
||||
console.error('getSubjects 실패:', subjectsErr);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error('강좌를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 썸네일 이미지 가져오기
|
||||
let thumbnail = '/imgs/talk.png';
|
||||
if (data.imageKey) {
|
||||
try {
|
||||
const imageUrl = await apiService.getFile(data.imageKey);
|
||||
if (imageUrl) {
|
||||
thumbnail = imageUrl;
|
||||
}
|
||||
} catch (imgErr) {
|
||||
console.error('이미지 다운로드 실패:', imgErr);
|
||||
}
|
||||
}
|
||||
|
||||
// getLectures로 모든 lecture 가져오기
|
||||
let allLectures: any[] = [];
|
||||
try {
|
||||
const lecturesResponse = await apiService.getLectures();
|
||||
if (Array.isArray(lecturesResponse.data)) {
|
||||
allLectures = lecturesResponse.data;
|
||||
} else if (lecturesResponse.data && typeof lecturesResponse.data === 'object') {
|
||||
allLectures = lecturesResponse.data.items ||
|
||||
lecturesResponse.data.lectures ||
|
||||
lecturesResponse.data.data ||
|
||||
lecturesResponse.data.list ||
|
||||
[];
|
||||
}
|
||||
|
||||
// subjectId로 필터링
|
||||
const filteredLectures = allLectures.filter((lecture: any) =>
|
||||
String(lecture.subjectId || lecture.subject_id) === subjectId
|
||||
);
|
||||
|
||||
setLectures(filteredLectures);
|
||||
|
||||
// 각 lecture의 진행률 조회
|
||||
const progressMap: Record<string, any> = {};
|
||||
await Promise.all(
|
||||
filteredLectures.map(async (lecture: any) => {
|
||||
const lectureId = lecture.id || lecture.lectureId;
|
||||
if (lectureId) {
|
||||
try {
|
||||
const progressResponse = await apiService.getLectureProgress(lectureId);
|
||||
if (progressResponse?.data) {
|
||||
progressMap[String(lectureId)] = progressResponse.data;
|
||||
}
|
||||
} catch (progressErr) {
|
||||
// 진행률이 없으면 무시 (에러가 아닐 수 있음)
|
||||
console.log(`Lecture ${lectureId} 진행률 조회 실패:`, progressErr);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
setLectureProgresses(progressMap);
|
||||
|
||||
// 전체 진행률 계산
|
||||
const totalMinutes = filteredLectures.reduce((sum, l) => sum + (l.durationMin || l.duration_min || l.duration || 0), 0);
|
||||
const totalHours = Math.floor(totalMinutes / 60);
|
||||
const restMinutes = totalMinutes % 60;
|
||||
|
||||
// API 응답 데이터를 CourseDetail 타입으로 변환
|
||||
const courseDetail: CourseDetail = {
|
||||
id: String(data.id || params.courseId),
|
||||
status: data.status || "수강 예정",
|
||||
title: data.title || data.lectureName || data.subjectName || '',
|
||||
goal: data.objective || data.goal || '',
|
||||
method: data.method || '',
|
||||
summary: `VOD · 총 ${filteredLectures.length || 0}강 · ${totalHours}시간 ${restMinutes}분`,
|
||||
submitSummary: data.submitSummary || '',
|
||||
thumbnail: thumbnail,
|
||||
lessons: [],
|
||||
};
|
||||
|
||||
setCourse(courseDetail);
|
||||
} catch (lecturesErr) {
|
||||
console.error('getLectures 실패:', lecturesErr);
|
||||
|
||||
// API 응답 데이터를 CourseDetail 타입으로 변환 (lecture 없이)
|
||||
const courseDetail: CourseDetail = {
|
||||
id: String(data.id || params.courseId),
|
||||
status: data.status || "수강 예정",
|
||||
title: data.title || data.lectureName || data.subjectName || '',
|
||||
goal: data.objective || data.goal || '',
|
||||
method: data.method || '',
|
||||
summary: `VOD · 총 0강`,
|
||||
submitSummary: data.submitSummary || '',
|
||||
thumbnail: thumbnail,
|
||||
lessons: [],
|
||||
};
|
||||
|
||||
setCourse(courseDetail);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('강좌 조회 실패:', err);
|
||||
setError(err instanceof Error ? err.message : '강좌를 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCourse();
|
||||
}, [params?.courseId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="flex w-full flex-col">
|
||||
<div className="flex h-[100px] items-center 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 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="rounded-[8px] bg-white px-8 pb-20 pt-6">
|
||||
{/* 상단 소개 카드 */}
|
||||
<div className="flex gap-6 rounded-[8px] bg-[#f8f9fa] p-6">
|
||||
<div className="relative h-[159px] w-[292px] overflow-hidden rounded">
|
||||
<Image src={c.thumbnail} alt="" fill sizes="292px" className="object-cover" unoptimized />
|
||||
{/* 메인 콘텐츠 */}
|
||||
<section className="px-8 pb-[80px] pt-[24px]">
|
||||
{/* 상단 정보 카드 */}
|
||||
<div className="bg-[#f8f9fa] box-border flex gap-[24px] items-start p-[24px] rounded-[8px] w-full">
|
||||
{/* 이미지 컨테이너 */}
|
||||
<div className="overflow-clip relative rounded-[4px] shrink-0 w-[220.5px] h-[159px]">
|
||||
<Image
|
||||
src={course.thumbnail}
|
||||
alt={course.title}
|
||||
fill
|
||||
sizes="220.5px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 교육과정 정보 */}
|
||||
<div className="basis-0 flex flex-col gap-[12px] grow items-start min-h-px min-w-px relative shrink-0">
|
||||
{/* 제목 영역 */}
|
||||
<div className="flex gap-[8px] h-[27px] items-center w-full">
|
||||
<div className="bg-[#e5f5ec] box-border flex h-[20px] items-center justify-center px-[4px] py-0 rounded-[4px] shrink-0">
|
||||
<p className="font-['Pretendard:SemiBold',sans-serif] text-[#0c9d61] text-[13px] leading-[1.4]">{course.status}</p>
|
||||
</div>
|
||||
<h2 className="font-['Pretendard:SemiBold',sans-serif] text-[#333c47] text-[18px] leading-[1.5]">{course.title}</h2>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex h-[27px] items-center gap-2">
|
||||
<span className="h-[20px] rounded-[4px] bg-[#e5f5ec] px-1.5 text-[13px] font-semibold leading-[1.4] text-[#0c9d61]">
|
||||
{c.status}
|
||||
</span>
|
||||
<h2 className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">{c.title}</h2>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1">
|
||||
<p className="text-[15px] leading-[1.5] text-[#333c47]">
|
||||
<span className="font-medium">학습 목표:</span> {c.goal}
|
||||
</p>
|
||||
<p className="text-[15px] leading-[1.5] text-[#333c47]">
|
||||
<span className="font-medium">학습 방법:</span> {c.method}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-5 text-[13px] leading-[1.4] text-[#8c95a1]">
|
||||
<span>{c.summary}</span>
|
||||
<span>{c.submitSummary}</span>
|
||||
|
||||
{/* 학습 목표 및 방법 */}
|
||||
<div className="flex flex-col gap-[4px] items-start w-full">
|
||||
<p className="font-['Pretendard:Regular',sans-serif] text-[#333c47] text-[15px] leading-[1.5] mb-0">
|
||||
<span className="font-['Pretendard:Medium',sans-serif]">학습 목표:</span>
|
||||
<span>{` ${course.goal}`}</span>
|
||||
</p>
|
||||
<p className="font-['Pretendard:Regular',sans-serif] text-[#333c47] text-[15px] leading-[1.5]">
|
||||
<span className="font-['Pretendard:Medium',sans-serif]">학습 방법:</span>
|
||||
<span>{` ${course.method}`}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 통계 정보 */}
|
||||
<div className="flex gap-[4px] items-center w-full">
|
||||
<div className="flex gap-[20px] items-center">
|
||||
{/* VOD 정보 */}
|
||||
<div className="flex gap-[4px] items-center">
|
||||
<div className="relative shrink-0 size-[16px]">
|
||||
<img src={imgPlay} alt="" className="block max-w-none size-full" />
|
||||
</div>
|
||||
<p className="font-['Pretendard:Medium',sans-serif] text-[#8c95a1] text-[13px] leading-[1.4]">{course.summary}</p>
|
||||
</div>
|
||||
|
||||
{/* 학습 제출 정보 */}
|
||||
{course.submitSummary && (
|
||||
<div className="flex gap-[4px] items-center">
|
||||
<div className="relative shrink-0 size-[16px]">
|
||||
<img src={imgMusicAudioPlay} alt="" className="block max-w-none size-full" />
|
||||
</div>
|
||||
<p className="font-['Pretendard:Medium',sans-serif] text-[#8c95a1] text-[13px] leading-[1.4]">{course.submitSummary}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 차시 리스트 */}
|
||||
<div className="mt-6 space-y-2">
|
||||
{c.lessons.map((l) => {
|
||||
const isSubmitted = l.state === "제출완료";
|
||||
const submitBtnStyle =
|
||||
l.state === "제출완료"
|
||||
? "border border-transparent text-[#384fbf]"
|
||||
: "border " + (l.action === "이어서 수강하기" || l.action === "수강하기" ? "border-[#b1b8c0]" : "border-[#8c95a1]");
|
||||
{/* Lecture 리스트 */}
|
||||
<div className="flex flex-col gap-[8px] items-start mt-[24px] w-full">
|
||||
{lectures.length > 0 ? (
|
||||
lectures.map((lecture: any, index: number) => {
|
||||
const lectureId = String(lecture.id || lecture.lectureId);
|
||||
const progress = lectureProgresses[lectureId];
|
||||
const isCompleted = progress?.isCompleted === true;
|
||||
const hasProgress = !!progress;
|
||||
|
||||
// 첫 번째 미완료 강의 찾기
|
||||
const firstIncompleteIndex = lectures.findIndex((l: any) => {
|
||||
const lid = String(l.id || l.lectureId);
|
||||
const prog = lectureProgresses[lid];
|
||||
return !prog?.isCompleted;
|
||||
});
|
||||
const isFirstIncomplete = index === firstIncompleteIndex && !isCompleted;
|
||||
|
||||
// 버튼 텍스트 결정: isCompleted면 "복습하기", progress 있으면 "이어서 수강하기", 없으면 "수강하기"
|
||||
const action = isCompleted ? "복습하기" : (hasProgress || isFirstIncomplete ? "이어서 수강하기" : "수강하기");
|
||||
|
||||
const isSubmitted = false; // TODO: 학습 제출 상태는 별도로 관리 필요
|
||||
const submitBtnBorder = isSubmitted
|
||||
? "border-transparent"
|
||||
: (action === "이어서 수강하기" || action === "수강하기" ? "border-[#b1b8c0]" : "border-[#8c95a1]");
|
||||
const submitBtnText = isSubmitted ? "text-[#384fbf]" : (action === "이어서 수강하기" || action === "수강하기" ? "text-[#b1b8c0]" : "text-[#4c5561]");
|
||||
|
||||
// isCompleted면 배경색 #F1F3F5, 아니면 기본 스타일
|
||||
const rightBtnStyle =
|
||||
l.action === "이어서 수강하기"
|
||||
? "bg-[#ecf0ff] text-[#384fbf]"
|
||||
: l.action === "수강하기"
|
||||
? "bg-[#ecf0ff] text-[#384fbf]"
|
||||
: "bg-[#f1f3f5] text-[#4c5561]";
|
||||
isCompleted
|
||||
? "bg-[#f1f3f5] text-[#4c5561]"
|
||||
: (action === "이어서 수강하기" || action === "수강하기"
|
||||
? "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 (
|
||||
<div key={l.id} className="rounded-[8px] border border-[#dee1e6] bg-white">
|
||||
<div className="flex items-center justify-between gap-4 rounded-[8px] px-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[16px] font-semibold leading-[1.5] text-[#333c47]">{l.title}</p>
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
<p className="w-[40px] text-[13px] leading-[1.4] text-[#8c95a1]">{l.duration}</p>
|
||||
<div key={lecture.id || lecture.lectureId || index} className="bg-white border border-[#dee1e6] border-solid relative rounded-[8px] w-full">
|
||||
<div className="box-border flex gap-[16px] items-center overflow-clip px-[24px] py-[16px] rounded-[inherit] w-full">
|
||||
<div className="basis-0 flex grow h-[46px] items-center justify-between min-h-px min-w-px relative shrink-0">
|
||||
{/* Lecture 정보 */}
|
||||
<div className="basis-0 grow min-h-px min-w-px relative shrink-0">
|
||||
<div className="flex flex-col gap-[4px] items-start w-full">
|
||||
<p className="font-['Pretendard:SemiBold',sans-serif] text-[#333c47] text-[16px] leading-[1.5]">
|
||||
{index + 1}. {lecture.title || lecture.lectureName || ''}
|
||||
</p>
|
||||
<div className="flex gap-[12px] items-center w-full">
|
||||
<p className="font-['Pretendard:Medium',sans-serif] text-[#8c95a1] text-[13px] leading-[1.4] w-[40px]">
|
||||
{duration}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="relative shrink-0">
|
||||
<div className="flex gap-[8px] items-center">
|
||||
{/* 학습 제출 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
"bg-white box-border flex flex-col h-[32px] items-center justify-center px-[16px] py-[3px] rounded-[6px] shrink-0",
|
||||
"border border-solid",
|
||||
submitBtnBorder,
|
||||
submitBtnText,
|
||||
].join(" ")}
|
||||
>
|
||||
{isSubmitted ? (
|
||||
<div className="flex gap-[4px] h-[18px] items-center">
|
||||
<div className="relative shrink-0 size-[12px]">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 3L4.5 8.5L2 6" stroke="#384fbf" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-['Pretendard:Medium',sans-serif] text-[#384fbf] text-[13px] leading-[1.4]">학습 제출 완료</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className={[
|
||||
"font-['Pretendard:Medium',sans-serif] text-[14px] leading-[1.5] text-center",
|
||||
submitBtnText,
|
||||
].join(" ")}>학습 제출 하기</p>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 수강/복습 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const lectureId = lecture.id || lecture.lectureId;
|
||||
if (lectureId) {
|
||||
if (action === "복습하기") {
|
||||
router.push(`/menu/courses/lessons/${lectureId}/review`);
|
||||
} else if (action === "수강하기") {
|
||||
// "수강하기" 버튼 클릭 시 progress 생성
|
||||
try {
|
||||
await apiService.updateLectureProgress({
|
||||
lectureId: Number(lectureId),
|
||||
progressPct: 0,
|
||||
lastPositionSec: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Progress 생성 실패:', error);
|
||||
}
|
||||
router.push(`/menu/courses/lessons/${lectureId}/start`);
|
||||
} else if (action === "이어서 수강하기") {
|
||||
// 첫 번째 미완료 강의면 continue, 아니면 start
|
||||
if (isFirstIncomplete && !hasProgress) {
|
||||
router.push(`/menu/courses/lessons/${lectureId}/start`);
|
||||
} else {
|
||||
router.push(`/menu/courses/lessons/${lectureId}/continue`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
"box-border flex flex-col h-[32px] items-center justify-center px-[16px] py-[3px] rounded-[6px] shrink-0 transition-colors",
|
||||
rightBtnStyle,
|
||||
isCompleted
|
||||
? "hover:bg-[#e5e8eb]"
|
||||
: (action === "이어서 수강하기" || action === "수강하기"
|
||||
? "hover:bg-[#d0d9ff]"
|
||||
: "hover:bg-[#e5e8eb]"),
|
||||
].join(" ")}
|
||||
>
|
||||
<p className={[
|
||||
"font-['Pretendard:Medium',sans-serif] text-[14px] leading-[1.5] text-center",
|
||||
isCompleted ? "text-[#4c5561]" : (action === "이어서 수강하기" || action === "수강하기" ? "text-[#384fbf]" : "text-[#4c5561]"),
|
||||
].join(" ")}>{action}</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
"h-8 rounded-[6px] px-4 text-[14px] font-medium leading-[1.5]",
|
||||
"bg-white",
|
||||
submitBtnStyle,
|
||||
].join(" ")}
|
||||
>
|
||||
{isSubmitted ? "학습 제출 완료" : "학습 제출 하기"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
"h-8 rounded-[6px] px-4 text-[14px] font-medium leading-[1.5]",
|
||||
rightBtnStyle,
|
||||
].join(" ")}
|
||||
>
|
||||
{l.action}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
})
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8 w-full">
|
||||
<p className="text-[14px] text-[#8c95a1]">등록된 강의가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
const imgImage2 = "/imgs/image-2.png";
|
||||
const imgLine58 = "/imgs/line-58.svg";
|
||||
const img = "/imgs/asset-base.svg";
|
||||
@@ -13,21 +16,88 @@ const imgIcon2 = "/imgs/icon-2.svg";
|
||||
const imgIcon3 = "/imgs/icon-3.svg";
|
||||
const imgIcon4 = "/imgs/icon-4.svg";
|
||||
|
||||
export default function FigmaSelectedLessonPage() {
|
||||
interface FigmaSelectedLessonPageProps {
|
||||
subjectTitle?: string;
|
||||
lectureTitle?: string;
|
||||
}
|
||||
|
||||
export default function FigmaSelectedLessonPage({
|
||||
subjectTitle = '원자로 운전 및 계통',
|
||||
lectureTitle = '6. 원자로 시동, 운전 및 정지 절차',
|
||||
}: FigmaSelectedLessonPageProps) {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const [isVideoCompleted, setIsVideoCompleted] = useState(false);
|
||||
const [isXrActive, setIsXrActive] = useState(false);
|
||||
const [isVrCompleted, setIsVrCompleted] = useState(false);
|
||||
const [isProblemActive, setIsProblemActive] = useState(false);
|
||||
const [showVideoModal, setShowVideoModal] = useState(false);
|
||||
const [showVrModal, setShowVrModal] = useState(false);
|
||||
const [showProblemConfirmModal, setShowProblemConfirmModal] = useState(false);
|
||||
|
||||
const handleVideoComplete = () => {
|
||||
setIsVideoCompleted(true);
|
||||
setShowVideoModal(true);
|
||||
};
|
||||
|
||||
const handleVideoModalProceed = () => {
|
||||
setShowVideoModal(false);
|
||||
setIsXrActive(true);
|
||||
};
|
||||
|
||||
const handleVideoModalReplay = () => {
|
||||
setShowVideoModal(false);
|
||||
setIsVideoCompleted(false);
|
||||
};
|
||||
|
||||
const handleVrComplete = () => {
|
||||
setIsVrCompleted(true);
|
||||
setShowVrModal(true);
|
||||
};
|
||||
|
||||
const handleVrModalProceed = () => {
|
||||
setShowVrModal(false);
|
||||
setShowProblemConfirmModal(true);
|
||||
};
|
||||
|
||||
const handleProblemConfirmProceed = () => {
|
||||
setShowProblemConfirmModal(false);
|
||||
setIsProblemActive(true);
|
||||
// 문제 풀기 페이지로 이동
|
||||
const lessonId = params?.lessonId as string;
|
||||
if (lessonId) {
|
||||
router.push(`/menu/courses/lessons/${lessonId}/review`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProblemConfirmReplay = () => {
|
||||
setShowProblemConfirmModal(false);
|
||||
};
|
||||
|
||||
const handleVrModalReplay = () => {
|
||||
setShowVrModal(false);
|
||||
setIsVrCompleted(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white content-stretch flex flex-col items-center relative size-full">
|
||||
<div className="content-stretch flex flex-col items-start max-w-[1440px] relative shrink-0 w-[1440px]">
|
||||
<div className="box-border content-stretch flex gap-[10px] h-[100px] items-center px-[32px] py-0 relative shrink-0 w-full">
|
||||
<div className="basis-0 content-stretch flex gap-[12px] grow items-center min-h-px min-w-px relative shrink-0">
|
||||
<div className="relative shrink-0 size-[32px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
aria-label="뒤로 가기"
|
||||
className="relative shrink-0 size-[32px] rounded-full inline-flex items-center justify-center hover:bg-black/5 cursor-pointer"
|
||||
>
|
||||
<img alt="" className="block max-w-none size-full" src={imgArrowsDiagramsArrow} />
|
||||
</div>
|
||||
</button>
|
||||
<div className="basis-0 content-stretch flex flex-col grow items-start justify-center leading-[1.5] min-h-px min-w-px not-italic relative shrink-0">
|
||||
<p className="font-['Pretendard:SemiBold',sans-serif] relative shrink-0 text-[#6c7682] text-[16px] w-full">
|
||||
원자로 운전 및 계통
|
||||
{subjectTitle}
|
||||
</p>
|
||||
<p className="font-['Pretendard:Bold',sans-serif] relative shrink-0 text-[#1b2027] text-[24px] w-full">
|
||||
6. 원자로 시동, 운전 및 정지 절차
|
||||
{lectureTitle}
|
||||
</p>
|
||||
</div>
|
||||
<div className="content-stretch flex gap-[20px] h-[81px] items-center justify-center relative shrink-0">
|
||||
@@ -50,14 +120,14 @@ export default function FigmaSelectedLessonPage() {
|
||||
</div>
|
||||
<div className="relative shrink-0 w-[52px]">
|
||||
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex flex-col gap-[4px] items-center relative w-[52px]">
|
||||
<div className="bg-[#dee1e6] relative rounded-[2.23696e+07px] shrink-0 size-[32px]">
|
||||
<div className={`${isXrActive ? 'bg-[#384fbf]' : 'bg-[#dee1e6]'} relative rounded-[2.23696e+07px] shrink-0 size-[32px]`}>
|
||||
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex items-center justify-center relative size-[32px]">
|
||||
<p className="font-['Pretendard:Bold',sans-serif] leading-[18px] not-italic relative shrink-0 text-[#6c7682] text-[14px] text-nowrap whitespace-pre">
|
||||
<p className={`font-['Pretendard:Bold',sans-serif] leading-[18px] not-italic relative shrink-0 text-[14px] text-nowrap whitespace-pre ${isXrActive ? 'text-white' : 'text-[#6c7682]'}`}>
|
||||
2
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#6c7682] text-[14px] text-nowrap whitespace-pre">
|
||||
<p className={`font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[14px] text-nowrap whitespace-pre ${isXrActive ? 'text-[#384fbf]' : 'text-[#6c7682]'}`}>
|
||||
XR 실습
|
||||
</p>
|
||||
</div>
|
||||
@@ -67,14 +137,14 @@ export default function FigmaSelectedLessonPage() {
|
||||
</div>
|
||||
<div className="relative shrink-0 w-[52px]">
|
||||
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex flex-col gap-[4px] items-center relative w-[52px]">
|
||||
<div className="bg-[#dee1e6] relative rounded-[2.23696e+07px] shrink-0 size-[32px]">
|
||||
<div className={`${isProblemActive ? 'bg-[#384fbf]' : 'bg-[#dee1e6]'} relative rounded-[2.23696e+07px] shrink-0 size-[32px]`}>
|
||||
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex items-center justify-center relative size-[32px]">
|
||||
<p className="font-['Pretendard:Bold',sans-serif] leading-[18px] not-italic relative shrink-0 text-[#6c7682] text-[14px] text-nowrap whitespace-pre">
|
||||
<p className={`font-['Pretendard:Bold',sans-serif] leading-[18px] not-italic relative shrink-0 text-[14px] text-nowrap whitespace-pre ${isProblemActive ? 'text-white' : 'text-[#6c7682]'}`}>
|
||||
3
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#6c7682] text-[14px] text-nowrap whitespace-pre">
|
||||
<p className={`font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[14px] text-nowrap whitespace-pre ${isProblemActive ? 'text-[#384fbf]' : 'text-[#6c7682]'}`}>
|
||||
문제 풀기
|
||||
</p>
|
||||
</div>
|
||||
@@ -83,75 +153,237 @@ export default function FigmaSelectedLessonPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="box-border content-stretch flex flex-col gap-[24px] items-center overflow-clip pb-[80px] pt-[24px] px-8 relative rounded-[8px] shrink-0 w-full">
|
||||
<div className="aspect-[1920/1080] bg-black overflow-clip relative rounded-[8px] shrink-0 w-full">
|
||||
<div className="absolute left-1/2 size-[120px] top-1/2 translate-x-[-50%] translate-y-[-50%]">
|
||||
<div className="absolute contents inset-0">
|
||||
<img alt="" className="block max-w-none size-full" src={imgGroup} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute 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} />
|
||||
{!isXrActive ? (
|
||||
// 영상 영역
|
||||
<div className="aspect-[1920/1080] bg-black overflow-clip relative rounded-[8px] shrink-0 w-full">
|
||||
<div className="absolute left-1/2 size-[120px] top-1/2 translate-x-[-50%] translate-y-[-50%]">
|
||||
<div className="absolute contents inset-0">
|
||||
<img alt="" className="block max-w-none size-full" src={imgGroup} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="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 className="absolute left-1/2 top-[40%] translate-x-[-50%] translate-y-[-50%] z-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleVideoComplete}
|
||||
className="bg-[#1f2b91] box-border content-stretch flex gap-[4px] h-[40px] items-center justify-center px-[24px] py-0 relative rounded-[8px] shrink-0 cursor-pointer hover:bg-[#1a2370] transition-colors"
|
||||
>
|
||||
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[16px] text-center text-nowrap text-white whitespace-pre">
|
||||
시청끝
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute bg-gradient-to-t bottom-0 box-border content-stretch flex flex-col from-[rgba(0,0,0,0.8)] gap-[20px] items-center justify-center left-[-0.5px] px-[16px] py-[24px] to-[rgba(0,0,0,0)] w-[1376px]">
|
||||
<div className="bg-[#333c47] h-[4px] relative rounded-[3.35544e+07px] shrink-0 w-full">
|
||||
<div className="absolute left-0 size-[12px] top-1/2 translate-y-[-50%]">
|
||||
<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 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 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 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 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>
|
||||
<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>
|
||||
) : showProblemConfirmModal ? (
|
||||
// 문제 풀기 확인창
|
||||
<div className="bg-white content-stretch flex flex-col items-center relative rounded-[12px] shrink-0 w-full">
|
||||
<div className="box-border content-stretch flex gap-[10px] items-center overflow-clip p-[24px] relative shrink-0 w-full">
|
||||
<div className="basis-0 flex flex-col font-['Pretendard:Bold',sans-serif] grow h-[32px] justify-center leading-[0] min-h-px min-w-px not-italic relative shrink-0 text-[#333c47] text-[20px] text-center">
|
||||
<p className="leading-[1.5]">학습 평가 전 확인해 주세요!</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="box-border content-stretch flex flex-col gap-[16px] items-center px-[24px] py-0 relative shrink-0">
|
||||
<div className="bg-gray-50 border border-[#dee1e6] border-solid box-border content-stretch flex flex-col gap-[8px] items-start justify-center p-[24px] relative rounded-[16px] shrink-0 w-full">
|
||||
<div className="content-stretch flex gap-[10px] items-center justify-center relative shrink-0">
|
||||
<p className="font-['Pretendard:Bold',sans-serif] h-[23px] leading-[1.5] not-italic relative shrink-0 text-[#4c5561] text-[15px] w-full">
|
||||
유의 사항
|
||||
</p>
|
||||
</div>
|
||||
<div className="content-stretch flex flex-col font-['Pretendard:Regular',sans-serif] gap-[4px] items-start leading-[0] not-italic relative shrink-0 text-[#4c5561] text-[15px] text-nowrap w-full">
|
||||
<ul className="[white-space-collapse:collapse] block relative shrink-0">
|
||||
<li className="ms-[22.5px]">
|
||||
<span className="leading-[1.5]">문제는 제한 시간 30분 안에 모두 풀어야 합니다.</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ul className="[white-space-collapse:collapse] block relative shrink-0">
|
||||
<li className="ms-[22.5px]">
|
||||
<span className="leading-[1.5]">제한 시간 내 제출하지 않으면, 강좌 수강을 마친 뒤 다시 응시해야 합니다.</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ul className="[white-space-collapse:collapse] block relative shrink-0">
|
||||
<li className="ms-[22.5px]">
|
||||
<span className="leading-[1.5]">재응시 횟수는 총 3회이며, 합격점은 100점 만점 중 70점 이상입니다.</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ul className="[white-space-collapse:collapse] block relative shrink-0">
|
||||
<li className="ms-[22.5px]">
|
||||
<span className="leading-[1.5]">합격하면, 문제 제출 후 영업일 기준 3~5일 내에 강사 피드백과 함께 수료증이 발급됩니다.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="box-border content-stretch flex flex-col gap-[32px] items-center p-[24px] relative shrink-0 w-full">
|
||||
<div className="content-stretch flex gap-[12px] items-start relative shrink-0 w-full">
|
||||
<div className="basis-0 content-stretch flex gap-[12px] grow items-start min-h-px min-w-px relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProblemConfirmReplay}
|
||||
className="basis-0 bg-[#f1f3f5] box-border content-stretch flex flex-col grow h-[56px] items-center justify-center min-h-px min-w-px px-[8px] py-0 relative rounded-[12px] shrink-0 min-w-[90px] cursor-pointer hover:bg-[#e1e5e9] transition-colors"
|
||||
>
|
||||
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#4c5561] text-[18px] text-center text-nowrap whitespace-pre">
|
||||
강좌 다시 보기
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProblemConfirmProceed}
|
||||
className="basis-0 bg-[#1f2b91] box-border content-stretch flex flex-col grow h-[56px] items-center justify-center min-h-px min-w-px p-[8px] relative rounded-[12px] shrink-0 min-w-[90px] cursor-pointer hover:bg-[#1a2370] transition-colors"
|
||||
>
|
||||
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[18px] text-center text-nowrap text-white whitespace-pre">
|
||||
문제 풀기
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// VR 콘텐츠 영역
|
||||
<div className="aspect-[1920/1080] bg-black overflow-clip relative rounded-[8px] shrink-0 w-full">
|
||||
<div className="absolute left-1/2 size-[120px] top-1/2 translate-x-[-50%] translate-y-[-50%]">
|
||||
<div className="absolute contents inset-0">
|
||||
<img alt="" className="block max-w-none size-full" src={imgGroup} />
|
||||
</div>
|
||||
</div>
|
||||
{/* VR 콘텐츠 시청끝 버튼 (더미 중간에 배치) */}
|
||||
<div className="absolute left-1/2 top-[40%] translate-x-[-50%] translate-y-[-50%] z-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleVrComplete}
|
||||
className="bg-[#1f2b91] box-border content-stretch flex gap-[4px] h-[40px] items-center justify-center px-[24px] py-0 relative rounded-[8px] shrink-0 cursor-pointer hover:bg-[#1a2370] transition-colors"
|
||||
>
|
||||
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[16px] text-center text-nowrap text-white whitespace-pre">
|
||||
시청끝
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute bg-gradient-to-t bottom-0 box-border content-stretch flex flex-col from-[rgba(0,0,0,0.8)] gap-[20px] items-center justify-center left-[-0.5px] px-[16px] py-[24px] to-[rgba(0,0,0,0)] w-[1376px]">
|
||||
<div className="bg-[#333c47] h-[4px] relative rounded-[3.35544e+07px] shrink-0 w-full">
|
||||
<div className="absolute left-0 size-[12px] top-1/2 translate-y-[-50%]">
|
||||
<img alt="" className="block max-w-none size-full" src={imgEllipse2} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="content-stretch flex h-[32px] items-center justify-between relative shrink-0 w-full">
|
||||
<div className="relative shrink-0">
|
||||
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex gap-[8px] items-center relative">
|
||||
<div className="content-stretch flex gap-[16px] items-center relative shrink-0">
|
||||
<div className="relative shrink-0 size-[32px]">
|
||||
<img alt="" className="block max-w-none size-full" src={imgMusicAudioPlay} />
|
||||
</div>
|
||||
<div className="content-stretch flex gap-[8px] h-[32px] items-center relative shrink-0 w-[120px]">
|
||||
<div className="relative rounded-[4px] shrink-0 size-[32px]">
|
||||
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex items-center justify-center relative size-[32px]">
|
||||
<div className="relative shrink-0 size-[18px]">
|
||||
<img alt="" className="block max-w-none size-full" src={imgIcon1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="basis-0 grow h-[4px] min-h-px min-w-px relative rounded-[3.35544e+07px] shrink-0">
|
||||
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-[4px] w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="font-['Pretendard:Medium',sans-serif] leading-[19.5px] not-italic relative shrink-0 text-[13px] text-nowrap text-white whitespace-pre">
|
||||
0:00 / 12:26
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative shrink-0">
|
||||
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border content-stretch flex gap-[12px] items-center relative">
|
||||
<div className="bg-[#333c47] box-border content-stretch flex gap-[4px] h-[32px] items-center justify-center px-[16px] py-[3px] relative rounded-[6px] shrink-0 w-[112px]">
|
||||
<div className="relative shrink-0 size-[16px]">
|
||||
<img alt="" className="block max-w-none size-full" src={imgIcon2} />
|
||||
</div>
|
||||
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[14px] text-center text-nowrap text-white whitespace-pre">
|
||||
이전 강의
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-[#333c47] box-border content-stretch flex gap-[4px] h-[32px] items-center justify-center px-[16px] py-[3px] relative rounded-[6px] shrink-0 w-[112px]">
|
||||
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[14px] text-center text-nowrap text-white whitespace-pre">
|
||||
다음 강의
|
||||
</p>
|
||||
<div className="flex items-center justify-center relative shrink-0">
|
||||
<div className="flex-none rotate-[180deg] scale-y-[-100%]">
|
||||
<div className="relative size-[16px]">
|
||||
<img alt="" className="block max-w-none size-full" src={imgIcon3} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="content-stretch flex items-center justify-center relative rounded-[4px] shrink-0 size-[32px]">
|
||||
<div className="relative shrink-0 size-[18px]">
|
||||
<img alt="" className="block max-w-none size-full" src={imgIcon4} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-[#fff9ee] border border-[#ffdd82] border-solid box-border content-stretch flex flex-col h-[55px] items-start pb-px pt-[17px] px-[17px] relative rounded-[8px] shrink-0 w-full">
|
||||
<ul className="[white-space-collapse:collapse] block font-['Pretendard:Medium',sans-serif] leading-[0] not-italic relative shrink-0 text-[#333c47] text-[14px] text-nowrap">
|
||||
<li className="ms-[21px]">
|
||||
@@ -161,6 +393,78 @@ export default function FigmaSelectedLessonPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 영상 완료 모달 */}
|
||||
{showVideoModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-[2px]">
|
||||
<div className="bg-white box-border content-stretch flex flex-col gap-[32px] items-end justify-end p-[24px] relative rounded-[8px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] w-[480px]">
|
||||
<div className="content-stretch flex flex-col gap-[16px] items-start justify-center relative shrink-0 w-full">
|
||||
<div className="content-stretch flex gap-[8px] items-start relative shrink-0 w-full">
|
||||
<div className="basis-0 font-['Pretendard:SemiBold',sans-serif] grow leading-[1.5] min-h-px min-w-px not-italic relative shrink-0 text-[#333c47] text-[18px]">
|
||||
<p className="mb-0">강좌 수강을 완료했습니다.</p>
|
||||
<p>XR 학습을 진행해 주세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="content-stretch flex gap-[8px] items-center justify-end relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleVideoModalReplay}
|
||||
className="bg-[#f1f3f5] box-border content-stretch flex flex-col h-[40px] items-center justify-center px-[16px] py-0 relative rounded-[8px] shrink-0 min-w-[82px] cursor-pointer hover:bg-[#e1e5e9] transition-colors"
|
||||
>
|
||||
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#4c5561] text-[16px] text-center text-nowrap whitespace-pre">
|
||||
강좌 다시 보기
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleVideoModalProceed}
|
||||
className="bg-[#1f2b91] box-border content-stretch flex flex-col h-[40px] items-center justify-center px-[16px] py-0 relative rounded-[8px] shrink-0 min-w-[82px] cursor-pointer hover:bg-[#1a2370] transition-colors"
|
||||
>
|
||||
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[16px] text-center text-nowrap text-white whitespace-pre">
|
||||
진행하기
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VR 콘텐츠 완료 모달 */}
|
||||
{showVrModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-[2px]">
|
||||
<div className="bg-white box-border content-stretch flex flex-col gap-[32px] items-end justify-end p-[24px] relative rounded-[8px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] w-[480px]">
|
||||
<div className="content-stretch flex flex-col gap-[16px] items-start justify-center relative shrink-0 w-full">
|
||||
<div className="content-stretch flex gap-[8px] items-start relative shrink-0 w-full">
|
||||
<div className="basis-0 font-['Pretendard:SemiBold',sans-serif] grow leading-[1.5] min-h-px min-w-px not-italic relative shrink-0 text-[#333c47] text-[18px]">
|
||||
<p className="mb-0">XR 학습을 완료했습니다.</p>
|
||||
<p>문제 풀기를 진행해 주세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="content-stretch flex gap-[8px] items-center justify-end relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleVrModalReplay}
|
||||
className="bg-[#f1f3f5] box-border content-stretch flex flex-col h-[40px] items-center justify-center px-[16px] py-0 relative rounded-[8px] shrink-0 min-w-[82px] cursor-pointer hover:bg-[#e1e5e9] transition-colors"
|
||||
>
|
||||
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#4c5561] text-[16px] text-center text-nowrap whitespace-pre">
|
||||
XR 다시 보기
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleVrModalProceed}
|
||||
className="bg-[#1f2b91] box-border content-stretch flex flex-col h-[40px] items-center justify-center px-[16px] py-0 relative rounded-[8px] shrink-0 min-w-[82px] cursor-pointer hover:bg-[#1a2370] transition-colors"
|
||||
>
|
||||
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[16px] text-center text-nowrap text-white whitespace-pre">
|
||||
진행하기
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,111 @@
|
||||
'use client';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import FigmaSelectedLessonPage from '../../FigmaSelectedLessonPage';
|
||||
import apiService from '@/app/lib/apiService';
|
||||
|
||||
export default function ContinueLessonPage() {
|
||||
return <FigmaSelectedLessonPage />;
|
||||
const params = useParams();
|
||||
const [subjectTitle, setSubjectTitle] = useState<string>('');
|
||||
const [lectureTitle, setLectureTitle] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLessonData = async () => {
|
||||
if (!params?.lessonId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 1. 강의 정보 가져오기 - getLecture 실패 시 getLectures로 재시도
|
||||
let lectureData: any = null;
|
||||
try {
|
||||
const lectureResponse = await apiService.getLecture(params.lessonId as string);
|
||||
lectureData = lectureResponse.data;
|
||||
} catch (lectureErr) {
|
||||
console.log('getLecture 실패, getLectures로 재시도:', lectureErr);
|
||||
// getLecture 실패 시 getLectures로 재시도
|
||||
try {
|
||||
const listResponse = await apiService.getLectures();
|
||||
let lectures: any[] = [];
|
||||
|
||||
if (Array.isArray(listResponse.data)) {
|
||||
lectures = listResponse.data;
|
||||
} else if (listResponse.data && typeof listResponse.data === 'object') {
|
||||
lectures = listResponse.data.items ||
|
||||
listResponse.data.lectures ||
|
||||
listResponse.data.data ||
|
||||
listResponse.data.list ||
|
||||
[];
|
||||
}
|
||||
|
||||
lectureData = lectures.find((l: any) =>
|
||||
String(l.id || l.lectureId) === String(params.lessonId)
|
||||
);
|
||||
} catch (listErr) {
|
||||
console.error('getLectures 실패:', listErr);
|
||||
}
|
||||
}
|
||||
|
||||
if (lectureData) {
|
||||
// 강의 제목 설정
|
||||
const title = lectureData.title || lectureData.lectureName || '';
|
||||
setLectureTitle(title);
|
||||
|
||||
// 2. 과목 정보 가져오기
|
||||
const subjectId = lectureData.subjectId || lectureData.subject_id;
|
||||
if (subjectId) {
|
||||
try {
|
||||
const subjectsResponse = await apiService.getSubjects();
|
||||
let subjectsData: any[] = [];
|
||||
|
||||
if (Array.isArray(subjectsResponse.data)) {
|
||||
subjectsData = subjectsResponse.data;
|
||||
} else if (subjectsResponse.data && typeof subjectsResponse.data === 'object') {
|
||||
subjectsData = subjectsResponse.data.items ||
|
||||
subjectsResponse.data.courses ||
|
||||
subjectsResponse.data.data ||
|
||||
subjectsResponse.data.list ||
|
||||
subjectsResponse.data.subjects ||
|
||||
subjectsResponse.data.subjectList ||
|
||||
[];
|
||||
}
|
||||
|
||||
// subjectId로 과목 찾기
|
||||
const subject = subjectsData.find((s: any) =>
|
||||
String(s.id || s.subjectId) === String(subjectId)
|
||||
);
|
||||
|
||||
if (subject) {
|
||||
const subjectName = subject.title || subject.subjectName || subject.name || '';
|
||||
setSubjectTitle(subjectName);
|
||||
}
|
||||
} catch (subjectsErr) {
|
||||
console.error('과목 정보 조회 실패:', subjectsErr);
|
||||
// 과목 정보 조회 실패해도 강의는 표시
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('강의 정보 조회 실패:', err);
|
||||
// 에러가 발생해도 기본값으로 표시
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLessonData();
|
||||
}, [params?.lessonId]);
|
||||
|
||||
// 로딩 중이거나 에러가 있어도 기본값으로 페이지 표시 (영상은 더미 데이터 사용)
|
||||
return (
|
||||
<FigmaSelectedLessonPage
|
||||
subjectTitle={subjectTitle || undefined}
|
||||
lectureTitle={lectureTitle || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,111 @@
|
||||
'use client';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import FigmaSelectedLessonPage from '../../FigmaSelectedLessonPage';
|
||||
import apiService from '@/app/lib/apiService';
|
||||
|
||||
export default function StartLessonPage() {
|
||||
return <FigmaSelectedLessonPage />;
|
||||
const params = useParams();
|
||||
const [subjectTitle, setSubjectTitle] = useState<string>('');
|
||||
const [lectureTitle, setLectureTitle] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLessonData = async () => {
|
||||
if (!params?.lessonId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 1. 강의 정보 가져오기 - getLecture 실패 시 getLectures로 재시도
|
||||
let lectureData: any = null;
|
||||
try {
|
||||
const lectureResponse = await apiService.getLecture(params.lessonId as string);
|
||||
lectureData = lectureResponse.data;
|
||||
} catch (lectureErr) {
|
||||
console.log('getLecture 실패, getLectures로 재시도:', lectureErr);
|
||||
// getLecture 실패 시 getLectures로 재시도
|
||||
try {
|
||||
const listResponse = await apiService.getLectures();
|
||||
let lectures: any[] = [];
|
||||
|
||||
if (Array.isArray(listResponse.data)) {
|
||||
lectures = listResponse.data;
|
||||
} else if (listResponse.data && typeof listResponse.data === 'object') {
|
||||
lectures = listResponse.data.items ||
|
||||
listResponse.data.lectures ||
|
||||
listResponse.data.data ||
|
||||
listResponse.data.list ||
|
||||
[];
|
||||
}
|
||||
|
||||
lectureData = lectures.find((l: any) =>
|
||||
String(l.id || l.lectureId) === String(params.lessonId)
|
||||
);
|
||||
} catch (listErr) {
|
||||
console.error('getLectures 실패:', listErr);
|
||||
}
|
||||
}
|
||||
|
||||
if (lectureData) {
|
||||
// 강의 제목 설정
|
||||
const title = lectureData.title || lectureData.lectureName || '';
|
||||
setLectureTitle(title);
|
||||
|
||||
// 2. 과목 정보 가져오기
|
||||
const subjectId = lectureData.subjectId || lectureData.subject_id;
|
||||
if (subjectId) {
|
||||
try {
|
||||
const subjectsResponse = await apiService.getSubjects();
|
||||
let subjectsData: any[] = [];
|
||||
|
||||
if (Array.isArray(subjectsResponse.data)) {
|
||||
subjectsData = subjectsResponse.data;
|
||||
} else if (subjectsResponse.data && typeof subjectsResponse.data === 'object') {
|
||||
subjectsData = subjectsResponse.data.items ||
|
||||
subjectsResponse.data.courses ||
|
||||
subjectsResponse.data.data ||
|
||||
subjectsResponse.data.list ||
|
||||
subjectsResponse.data.subjects ||
|
||||
subjectsResponse.data.subjectList ||
|
||||
[];
|
||||
}
|
||||
|
||||
// subjectId로 과목 찾기
|
||||
const subject = subjectsData.find((s: any) =>
|
||||
String(s.id || s.subjectId) === String(subjectId)
|
||||
);
|
||||
|
||||
if (subject) {
|
||||
const subjectName = subject.title || subject.subjectName || subject.name || '';
|
||||
setSubjectTitle(subjectName);
|
||||
}
|
||||
} catch (subjectsErr) {
|
||||
console.error('과목 정보 조회 실패:', subjectsErr);
|
||||
// 과목 정보 조회 실패해도 강의는 표시
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('강의 정보 조회 실패:', err);
|
||||
// 에러가 발생해도 기본값으로 표시
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLessonData();
|
||||
}, [params?.lessonId]);
|
||||
|
||||
// 로딩 중이거나 에러가 있어도 기본값으로 페이지 표시 (영상은 더미 데이터 사용)
|
||||
return (
|
||||
<FigmaSelectedLessonPage
|
||||
subjectTitle={subjectTitle || undefined}
|
||||
lectureTitle={lectureTitle || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import CourseCard from "./CourseCard";
|
||||
import apiService from "@/app/lib/apiService";
|
||||
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
|
||||
|
||||
type CourseStatus = "전체" | "수강 예정" | "수강중" | "수강 완료";
|
||||
|
||||
@@ -23,80 +25,277 @@ type Course = {
|
||||
lessons: Lesson[];
|
||||
};
|
||||
|
||||
const COURSES: Course[] = [
|
||||
{
|
||||
id: "c1",
|
||||
title: "원자로 운전 및 계통",
|
||||
description:
|
||||
"원자로 운전 원리와 주요 계통의 구조 및 기능을 이해하고, 일련 단계 가이드 기반의 현업에서 배워야 할 핵심을 익힙니다.",
|
||||
thumbnail: "/imgs/talk.png",
|
||||
status: "수강중",
|
||||
progressPct: 80,
|
||||
lessons: [
|
||||
{ id: "c1l1", title: "1. 원자로 기초 및 핵분열 원리", durationMin: 12, progressPct: 100, isCompleted: true },
|
||||
{ id: "c1l2", title: "2. 제어봉 및 원자로 출력 제어", durationMin: 18, progressPct: 100, isCompleted: true },
|
||||
{ id: "c1l3", title: "3. 터빈-발전기 계통 및 전력 생산", durationMin: 13, progressPct: 60, isCompleted: false },
|
||||
{ id: "c1l4", title: "4. 보조 계통 및 안전 계통", durationMin: 13, progressPct: 0, isCompleted: false },
|
||||
{ id: "c1l5", title: "5. 원자로 냉각재 계통 (RCS) 및 열수력", durationMin: 8, progressPct: 0, isCompleted: false },
|
||||
{ id: "c1l6", title: "6. 원자로 시동, 운전 및 정지 절차", durationMin: 13, progressPct: 0, isCompleted: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "c2",
|
||||
title: "확률론",
|
||||
description:
|
||||
"확률과 통계의 주요 개념을 가이드하며, 일련 단계 실습을 기반으로 문제 해결력을 기릅니다.",
|
||||
thumbnail: "/imgs/talk.png",
|
||||
status: "수강 완료",
|
||||
progressPct: 100,
|
||||
lessons: [
|
||||
{ id: "c2l1", title: "기초 개념", durationMin: 10, progressPct: 100, isCompleted: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "c3",
|
||||
title: "부서간 협업",
|
||||
description:
|
||||
"부서간 협업 절차와 기록 기준을 가이드하며, 협업 중 생길 수 있는 리스크 관리법을 다룹니다.",
|
||||
thumbnail: "/imgs/talk.png",
|
||||
status: "수강중",
|
||||
progressPct: 60,
|
||||
lessons: [
|
||||
{ id: "c3l1", title: "의사소통 원칙", durationMin: 9, progressPct: 100, isCompleted: true },
|
||||
{ id: "c3l2", title: "문서 공유와 승인", durationMin: 14, progressPct: 30, isCompleted: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "c4",
|
||||
title: "방사선의 이해",
|
||||
description:
|
||||
"방사선 안전 기준을 가이드하며, 일과 관련된 위험과 보호 장비 선택법을 배웁니다.",
|
||||
thumbnail: "/imgs/talk.png",
|
||||
status: "수강 예정",
|
||||
progressPct: 0,
|
||||
lessons: [{ id: "c4l1", title: "기초 이론", durationMin: 12, progressPct: 0, isCompleted: false }],
|
||||
},
|
||||
];
|
||||
|
||||
const TABS: CourseStatus[] = ["전체", "수강 예정", "수강중", "수강 완료"];
|
||||
const TABS: CourseStatus[] = ["전체", "수강중", "수강 완료"];
|
||||
const ITEMS_PER_PAGE = 5;
|
||||
|
||||
export default function CoursesPage() {
|
||||
const [activeTab, setActiveTab] = useState<CourseStatus>("전체");
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCourses = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 1. getSubjects()로 모든 subject 조회
|
||||
let allSubjects: any[] = [];
|
||||
try {
|
||||
const subjectsResponse = await apiService.getSubjects();
|
||||
if (Array.isArray(subjectsResponse.data)) {
|
||||
allSubjects = subjectsResponse.data;
|
||||
} else if (subjectsResponse.data && typeof subjectsResponse.data === 'object') {
|
||||
allSubjects = subjectsResponse.data.items ||
|
||||
subjectsResponse.data.subjects ||
|
||||
subjectsResponse.data.data ||
|
||||
subjectsResponse.data.list ||
|
||||
subjectsResponse.data.subjectList ||
|
||||
[];
|
||||
}
|
||||
} catch (subjectsErr) {
|
||||
console.error('getSubjects 실패:', subjectsErr);
|
||||
}
|
||||
|
||||
// subject 정보를 Map으로 변환 (subjectId로 빠르게 조회)
|
||||
const subjectMap: Record<string, any> = {};
|
||||
allSubjects.forEach((subject: any) => {
|
||||
const subjectId = String(subject.id || subject.subjectId || '');
|
||||
if (subjectId) {
|
||||
subjectMap[subjectId] = subject;
|
||||
}
|
||||
});
|
||||
|
||||
// 2. getLectures()로 모든 lecture 조회
|
||||
let allLectures: any[] = [];
|
||||
try {
|
||||
const lecturesResponse = await apiService.getLectures();
|
||||
if (Array.isArray(lecturesResponse.data)) {
|
||||
allLectures = lecturesResponse.data;
|
||||
} else if (lecturesResponse.data && typeof lecturesResponse.data === 'object') {
|
||||
allLectures = lecturesResponse.data.items ||
|
||||
lecturesResponse.data.lectures ||
|
||||
lecturesResponse.data.data ||
|
||||
lecturesResponse.data.list ||
|
||||
[];
|
||||
}
|
||||
} catch (lecturesErr) {
|
||||
console.error('getLectures 실패:', lecturesErr);
|
||||
setCourses([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 각 lecture의 ID 추출
|
||||
const lectureIds = allLectures.map((lecture: any) =>
|
||||
String(lecture.id || lecture.lectureId || '')
|
||||
).filter((id: string) => id);
|
||||
|
||||
// 4. 각 lectureId에 대해 진행률 조회
|
||||
const progressMap: Record<string, any> = {};
|
||||
await Promise.all(
|
||||
lectureIds.map(async (lectureId) => {
|
||||
try {
|
||||
const progressResponse = await apiService.getLectureProgress(lectureId);
|
||||
if (progressResponse?.data) {
|
||||
progressMap[lectureId] = progressResponse.data;
|
||||
}
|
||||
} catch (progressErr) {
|
||||
// 진행률이 없으면 무시
|
||||
console.log(`Lecture ${lectureId} 진행률 조회 실패:`, progressErr);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 5. 진행률이 있는 lecture만 필터링
|
||||
const lecturesWithProgress = allLectures.filter((lecture: any) => {
|
||||
const lectureId = String(lecture.id || lecture.lectureId || '');
|
||||
return progressMap[lectureId];
|
||||
});
|
||||
|
||||
// 6. 교육과정(subjectId)별로 그룹화
|
||||
const coursesBySubject: Record<string, {
|
||||
subjectId: string;
|
||||
subjectName: string;
|
||||
subjectDescription: string;
|
||||
subjectImageKey?: string;
|
||||
lectures: any[];
|
||||
}> = {};
|
||||
|
||||
lecturesWithProgress.forEach((lecture: any) => {
|
||||
const subjectId = String(lecture.subjectId || lecture.subject_id || '');
|
||||
const lectureId = String(lecture.id || lecture.lectureId || '');
|
||||
|
||||
if (!subjectId || !lectureId) return;
|
||||
|
||||
// subject 정보 가져오기
|
||||
const subject = subjectMap[subjectId];
|
||||
|
||||
if (!coursesBySubject[subjectId]) {
|
||||
coursesBySubject[subjectId] = {
|
||||
subjectId,
|
||||
subjectName: subject?.title || subject?.name || subject?.subjectName || lecture.subjectName || lecture.subject_name || lecture.subject?.name || '',
|
||||
subjectDescription: subject?.description || subject?.subjectDescription || lecture.subjectDescription || lecture.subject_description || lecture.subject?.description || '',
|
||||
subjectImageKey: subject?.imageKey || subject?.image_key || subject?.fileKey || lecture.subjectImageKey || lecture.subject_image_key || lecture.subject?.imageKey,
|
||||
lectures: [],
|
||||
};
|
||||
}
|
||||
|
||||
coursesBySubject[subjectId].lectures.push({
|
||||
...lecture,
|
||||
progress: progressMap[lectureId],
|
||||
});
|
||||
});
|
||||
|
||||
// 6. Course 타입으로 변환
|
||||
const transformedCourses: Course[] = await Promise.all(
|
||||
Object.values(coursesBySubject).map(async (courseData) => {
|
||||
// 썸네일 이미지 가져오기
|
||||
let thumbnail = '/imgs/talk.png';
|
||||
if (courseData.subjectImageKey) {
|
||||
try {
|
||||
const imageUrl = await apiService.getFile(courseData.subjectImageKey);
|
||||
if (imageUrl) {
|
||||
thumbnail = imageUrl;
|
||||
}
|
||||
} catch (imgErr) {
|
||||
console.error('이미지 다운로드 실패:', imgErr);
|
||||
}
|
||||
}
|
||||
|
||||
// lessons 변환 (lecture를 lesson으로 변환)
|
||||
const transformedLessons: Lesson[] = courseData.lectures.map((lecture: any, index: number) => {
|
||||
const lectureId = String(lecture.id || lecture.lectureId || '');
|
||||
const progress = lecture.progress || progressMap[lectureId];
|
||||
const progressPct = progress?.progressPct ||
|
||||
progress?.progress_pct ||
|
||||
progress?.progressRate ||
|
||||
progress?.progress_rate ||
|
||||
0;
|
||||
// isCompleted는 progress 데이터에서 가져오거나, progressPct가 100이면 완료
|
||||
const isCompleted = progress?.isCompleted ||
|
||||
progress?.is_completed ||
|
||||
progressPct === 100 ||
|
||||
false;
|
||||
|
||||
return {
|
||||
id: lectureId,
|
||||
title: lecture.title || lecture.lectureName || lecture.lecture_name || lecture.name || `강의 ${index + 1}`,
|
||||
durationMin: lecture.durationMin || lecture.duration_min || lecture.duration || 0,
|
||||
progressPct: progressPct,
|
||||
isCompleted,
|
||||
};
|
||||
});
|
||||
|
||||
// 전체 진행률 계산 (모든 lecture의 진행률 평균)
|
||||
let totalLectureProgress = 0;
|
||||
let totalLectures = 0;
|
||||
let allCompleted = true;
|
||||
let hasProgress = false;
|
||||
|
||||
courseData.lectures.forEach((lecture: any) => {
|
||||
const lectureId = String(lecture.id || lecture.lectureId || '');
|
||||
const progress = lecture.progress || progressMap[lectureId];
|
||||
|
||||
// progress 데이터가 있으면 무조건 수강중 또는 수강 완료
|
||||
if (progress) {
|
||||
hasProgress = true;
|
||||
}
|
||||
|
||||
const progressPct = progress?.progressPct ||
|
||||
progress?.progress_pct ||
|
||||
progress?.progressRate ||
|
||||
progress?.progress_rate ||
|
||||
0;
|
||||
const isCompleted = progress?.isCompleted ||
|
||||
progress?.is_completed ||
|
||||
progressPct === 100 ||
|
||||
false;
|
||||
|
||||
totalLectureProgress += progressPct;
|
||||
totalLectures += 1;
|
||||
|
||||
if (!isCompleted) {
|
||||
allCompleted = false;
|
||||
}
|
||||
});
|
||||
|
||||
const avgProgress = totalLectures > 0 ? Math.round(totalLectureProgress / totalLectures) : 0;
|
||||
|
||||
// 상태 결정: 수강 완료 = isCompleted가 true, 수강중 = isCompleted가 false이고 progress 데이터가 있는 경우
|
||||
// progress 데이터가 있으면 무조건 "수강중" 또는 "수강 완료"
|
||||
let status: CourseStatus = "수강 예정";
|
||||
if (allCompleted) {
|
||||
status = "수강 완료";
|
||||
} else if (hasProgress) {
|
||||
status = "수강중";
|
||||
}
|
||||
|
||||
return {
|
||||
id: courseData.subjectId,
|
||||
title: courseData.subjectName || '제목 없음',
|
||||
description: courseData.subjectDescription || '',
|
||||
thumbnail,
|
||||
status,
|
||||
progressPct: avgProgress,
|
||||
lessons: transformedLessons,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// "수강 예정" 상태 제거 (수강중, 수강 완료만 표시)
|
||||
const filteredCourses = transformedCourses.filter((course) =>
|
||||
course.status === "수강중" || course.status === "수강 완료"
|
||||
);
|
||||
|
||||
// 디버깅: 데이터 확인
|
||||
console.log('🔍 [CoursesPage] 전체 courses:', transformedCourses);
|
||||
console.log('🔍 [CoursesPage] 필터링된 courses:', filteredCourses);
|
||||
console.log('🔍 [CoursesPage] progressMap:', progressMap);
|
||||
|
||||
setCourses(filteredCourses);
|
||||
} catch (err) {
|
||||
console.error('강좌 조회 실패:', err);
|
||||
setCourses([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCourses();
|
||||
}, []);
|
||||
|
||||
const countsByStatus = useMemo(() => {
|
||||
return {
|
||||
전체: COURSES.length,
|
||||
"수강 예정": COURSES.filter((c) => c.status === "수강 예정").length,
|
||||
수강중: COURSES.filter((c) => c.status === "수강중").length,
|
||||
"수강 완료": COURSES.filter((c) => c.status === "수강 완료").length,
|
||||
전체: courses.length,
|
||||
수강중: courses.filter((c) => c.status === "수강중").length,
|
||||
"수강 완료": courses.filter((c) => c.status === "수강 완료").length,
|
||||
};
|
||||
}, []);
|
||||
}, [courses]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (activeTab === "전체") return COURSES;
|
||||
return COURSES.filter((c) => c.status === activeTab);
|
||||
if (activeTab === "전체") return courses;
|
||||
return courses.filter((c) => c.status === activeTab);
|
||||
}, [activeTab, courses]);
|
||||
|
||||
// 탭 변경 시 첫 페이지로 리셋
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [activeTab]);
|
||||
|
||||
const totalCount = useMemo(() => filtered.length, [filtered]);
|
||||
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
||||
const pagedCourses = useMemo(
|
||||
() => filtered.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE),
|
||||
[filtered, currentPage]
|
||||
);
|
||||
|
||||
// 페이지네이션: 10개씩 표시
|
||||
const pageGroup = Math.floor((currentPage - 1) / 10);
|
||||
const startPage = pageGroup * 10 + 1;
|
||||
const endPage = Math.min(startPage + 9, totalPages);
|
||||
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
|
||||
|
||||
return (
|
||||
<main className="flex w-full flex-col">
|
||||
<div className="flex h-[100px] items-center px-8">
|
||||
@@ -134,41 +333,97 @@ export default function CoursesPage() {
|
||||
</div>
|
||||
|
||||
<div className="px-8 pb-16 pt-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
{filtered.map((course) => (
|
||||
<CourseCard key={course.id} course={course} />
|
||||
))}
|
||||
</div>
|
||||
{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">
|
||||
{pagedCourses.map((course) => (
|
||||
<CourseCard key={course.id} course={course} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* pagination */}
|
||||
<div className="mt-10 flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-[#8c95a1] hover:bg-[#f1f3f5]"
|
||||
aria-label="이전 페이지"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{[1, 2, 3].map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
className={[
|
||||
"flex h-8 w-8 items-center justify-center rounded-full text-[13px] leading-[1.4]",
|
||||
p === 2 ? "bg-[#1f2b91] text-white" : "text-[#4c5561] hover:bg-[#f1f3f5]",
|
||||
].join(" ")}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-[#8c95a1] hover:bg-[#f1f3f5]"
|
||||
aria-label="다음 페이지"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
{/* 페이지네이션 - 5개 초과일 때만 표시 */}
|
||||
{totalCount > ITEMS_PER_PAGE && (
|
||||
<div className="mt-10 flex items-center justify-center">
|
||||
<div className="flex items-center justify-center gap-[8px]">
|
||||
{/* First (맨 앞으로) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(1)}
|
||||
aria-label="맨 앞 페이지"
|
||||
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<div className="relative flex items-center justify-center w-full h-full">
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Prev */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
aria-label="이전 페이지"
|
||||
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
||||
</button>
|
||||
|
||||
{/* Numbers */}
|
||||
{visiblePages.map((n) => {
|
||||
const active = n === currentPage;
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(n)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={[
|
||||
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
|
||||
active ? 'bg-bg-primary-light' : 'bg-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-[16px] leading-[1.4] text-neutral-700">{n}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Next */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
aria-label="다음 페이지"
|
||||
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
|
||||
</button>
|
||||
|
||||
{/* Last (맨 뒤로) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
aria-label="맨 뒤 페이지"
|
||||
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<div className="relative flex items-center justify-center w-full h-full">
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import MenuSidebar from "./MenuSidebar";
|
||||
|
||||
export default function MenuLayout({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const isContinuePage = pathname?.includes('/continue');
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1440px] min-h-full">
|
||||
<aside className="hidden w-[320px] border-r border-[#dee1e6] px-4 py-6">
|
||||
<MenuSidebar />
|
||||
</aside>
|
||||
<div className="mx-auto flex w-full max-w-[1440px] min-h-screen">
|
||||
{!isContinuePage && (
|
||||
<aside className="w-[320px] border-r border-[#dee1e6] px-4 py-6">
|
||||
<MenuSidebar />
|
||||
</aside>
|
||||
)}
|
||||
<section className="flex-1">{children}</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -170,7 +170,7 @@ export default function NoticeDetailPage() {
|
||||
<div className="flex justify-center">
|
||||
<div className="w-full max-w-[1440px]">
|
||||
{/* 상단 타이틀 */}
|
||||
<div className="h-[100px] flex items-center gap-3 px-8">
|
||||
<div className="h-[100px] flex items-center gap-[12px] px-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
|
||||
@@ -133,7 +133,7 @@ export default function ResourceDetailPage() {
|
||||
<div className="w-full bg-white">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-full max-w-[1440px]">
|
||||
<div className="h-[100px] flex items-center gap-3 px-8">
|
||||
<div className="h-[100px] flex items-center gap-[12px] px-8">
|
||||
<Link
|
||||
href="/resources"
|
||||
aria-label="뒤로 가기"
|
||||
@@ -163,7 +163,7 @@ export default function ResourceDetailPage() {
|
||||
<div className="flex justify-center">
|
||||
<div className="w-full max-w-[1440px]">
|
||||
{/* 상단 타이틀 */}
|
||||
<div className="h-[100px] flex items-center gap-3 px-8">
|
||||
<div className="h-[100px] flex items-center gap-[12px] px-8">
|
||||
<Link
|
||||
href="/resources"
|
||||
aria-label="뒤로 가기"
|
||||
|
||||
Reference in New Issue
Block a user