([]);
const [isLoading, setIsLoading] = useState(true);
@@ -199,7 +217,7 @@ export default function AdminCoursesPage() {
{course.instructorName}
|
- {course.createdAt}
+ {formatDate(course.createdAt)}
|
{course.hasLessons ? (
diff --git a/src/app/admin/lessons/[id]/page.tsx b/src/app/admin/lessons/[id]/page.tsx
new file mode 100644
index 0000000..0e0b661
--- /dev/null
+++ b/src/app/admin/lessons/[id]/page.tsx
@@ -0,0 +1,320 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useParams, useRouter } from 'next/navigation';
+import Image from 'next/image';
+import AdminSidebar from '@/app/components/AdminSidebar';
+import BackArrowSvg from '@/app/svgs/backarrow';
+import apiService from '@/app/lib/apiService';
+
+type Lesson = {
+ id: string;
+ title: string;
+ duration: string; // "12:46" 형식
+ state: "제출완료" | "제출대기";
+ action: "복습하기" | "이어서 수강하기" | "수강하기";
+};
+
+type CourseDetail = {
+ id: string;
+ status: "수강 중" | "수강 예정" | "수강 완료";
+ title: string;
+ goal: string;
+ method: string;
+ summary: string; // VOD · 총 n강 · n시간 n분
+ submitSummary: string; // 학습 제출 n/n
+ thumbnail: string;
+ lessons: Lesson[];
+};
+
+export default function AdminCourseDetailPage() {
+ const params = useParams();
+ const router = useRouter();
+ const [course, setCourse] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchCourse = async () => {
+ if (!params?.id) return;
+
+ try {
+ setLoading(true);
+ setError(null);
+
+ // sessionStorage에서 저장된 강좌 데이터 가져오기
+ let data: any = null;
+ const storedData = typeof window !== 'undefined' ? sessionStorage.getItem('selectedLecture') : null;
+
+ if (storedData) {
+ try {
+ data = JSON.parse(storedData);
+ // 사용 후 삭제
+ sessionStorage.removeItem('selectedLecture');
+ } catch (e) {
+ console.error('저장된 데이터 파싱 실패:', e);
+ }
+ }
+
+ // sessionStorage에 데이터가 없으면 강좌 리스트에서 찾기
+ if (!data) {
+ try {
+ const response = await apiService.getLectures();
+ const lectures = Array.isArray(response.data)
+ ? response.data
+ : response.data?.items || response.data?.lectures || response.data?.data || [];
+
+ data = lectures.find((l: any) => String(l.id || l.lectureId) === params.id);
+ } catch (err) {
+ console.error('강좌 리스트 조회 실패:', err);
+ }
+ }
+
+ if (!data) {
+ throw new Error('강좌를 찾을 수 없습니다.');
+ }
+
+ // 첨부파일 정보 구성
+ const attachmentParts: string[] = [];
+ if (data.videoUrl) {
+ attachmentParts.push('강좌영상 1개');
+ }
+ if (data.webglUrl) {
+ attachmentParts.push('VR콘텐츠 1개');
+ }
+ if (data.csvKey) {
+ attachmentParts.push('평가문제 1개');
+ }
+ const attachments = attachmentParts.length > 0
+ ? attachmentParts.join(', ')
+ : '없음';
+
+ // 썸네일 이미지 가져오기
+ let thumbnail = '/imgs/talk.png';
+ if (data.imageKey) {
+ try {
+ const imageUrl = await apiService.getFile(data.imageKey);
+ if (imageUrl) {
+ thumbnail = imageUrl;
+ }
+ } catch (err) {
+ console.error('이미지 로드 실패:', err);
+ }
+ }
+
+ // API 응답 구조에 맞게 데이터 매핑
+ const courseDetail: CourseDetail = {
+ id: String(data.id || params.id),
+ status: "수강 예정", // 관리자 페이지에서는 기본값
+ title: data.title || data.lectureName || '',
+ goal: data.objective || data.goal || '',
+ method: data.method || '',
+ summary: `VOD · 총 1강`,
+ submitSummary: '',
+ thumbnail: thumbnail,
+ lessons: [
+ {
+ id: String(data.id || params.id),
+ title: data.title || data.lectureName || '',
+ duration: '00:00',
+ state: "제출대기",
+ action: "수강하기",
+ }
+ ],
+ };
+
+ setCourse(courseDetail);
+ } catch (err) {
+ console.error('강좌 조회 실패:', err);
+ setError(err instanceof Error ? err.message : '강좌를 불러오는데 실패했습니다.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchCourse();
+ }, [params?.id]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error || !course) {
+ return (
+
+
+
+
+
+
+
+ 강좌 상세보기
+
+
+
+ {error || '강좌를 찾을 수 없습니다.'}
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {/* 사이드바 */}
+
+ {/* 메인 콘텐츠 */}
+
+
+ {/* 헤더 */}
+
+
+
+
+ 강좌 상세보기
+
+
+
+
+ {/* 콘텐츠 */}
+
+
+ {/* 상단 소개 카드 */}
+
+
+
+
+
+
+
+ {course.status}
+
+ {course.title}
+
+
+
+ 학습 목표: {course.goal}
+
+
+ 학습 방법: {course.method}
+
+
+
+ {course.summary}
+ {course.submitSummary && {course.submitSummary}}
+
+
+
+
+ {/* 차시 리스트 */}
+
+ {course.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]");
+ const rightBtnStyle =
+ l.action === "이어서 수강하기"
+ ? "bg-[#ecf0ff] text-[#384fbf]"
+ : l.action === "수강하기"
+ ? "bg-[#ecf0ff] text-[#384fbf]"
+ : "bg-[#f1f3f5] text-[#4c5561]";
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+ );
+}
+
diff --git a/src/app/admin/lessons/page.tsx b/src/app/admin/lessons/page.tsx
index f4e62b2..e7ce450 100644
--- a/src/app/admin/lessons/page.tsx
+++ b/src/app/admin/lessons/page.tsx
@@ -1,12 +1,14 @@
'use client';
-import { useState, useMemo, useRef, useEffect } from "react";
+import { useState, useMemo, useRef, useEffect, useCallback } from "react";
+import { useRouter } from "next/navigation";
import AdminSidebar from "@/app/components/AdminSidebar";
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
import DropdownIcon from "@/app/svgs/dropdownicon";
import BackArrowSvg from "@/app/svgs/backarrow";
import { getCourses, type Course } from "@/app/admin/courses/mockData";
import CloseXOSvg from "@/app/svgs/closexo";
+import apiService from "@/app/lib/apiService";
type Lesson = {
id: string;
@@ -19,6 +21,7 @@ type Lesson = {
};
export default function AdminLessonsPage() {
+ const router = useRouter();
const [lessons, setLessons] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
const [isRegistrationMode, setIsRegistrationMode] = useState(false);
@@ -26,6 +29,8 @@ export default function AdminLessonsPage() {
const dropdownRef = useRef(null);
const [courses, setCourses] = useState([]);
const [currentUser, setCurrentUser] = useState("관리자");
+ const [showToast, setShowToast] = useState(false);
+ const rawLecturesRef = useRef([]); // 원본 강좌 데이터 저장
// 등록 폼 상태
const [selectedCourse, setSelectedCourse] = useState("");
@@ -33,9 +38,65 @@ export default function AdminLessonsPage() {
const [learningGoal, setLearningGoal] = useState("");
const [courseVideoCount, setCourseVideoCount] = useState(0);
const [courseVideoFiles, setCourseVideoFiles] = useState([]);
+ const [courseVideoFileObjects, setCourseVideoFileObjects] = useState([]);
const [vrContentCount, setVrContentCount] = useState(0);
const [vrContentFiles, setVrContentFiles] = useState([]);
+ const [vrContentFileObjects, setVrContentFileObjects] = useState([]);
const [questionFileCount, setQuestionFileCount] = useState(0);
+ const [questionFileObject, setQuestionFileObject] = useState(null);
+
+ // 에러 상태
+ const [errors, setErrors] = useState<{
+ selectedCourse?: string;
+ lessonName?: string;
+ learningGoal?: string;
+ }>({});
+
+ // 교육과정명 매핑 함수
+ const mapCourseNames = useCallback((lectures: any[]) => {
+ const fetchedLessons: Lesson[] = lectures.map((lecture: any) => {
+ // 첨부파일 정보 구성
+ const attachmentParts: string[] = [];
+ if (lecture.videoUrl) {
+ attachmentParts.push('강좌영상 1개');
+ }
+ if (lecture.webglUrl) {
+ attachmentParts.push('VR콘텐츠 1개');
+ }
+ if (lecture.csvKey) {
+ attachmentParts.push('평가문제 1개');
+ }
+ const attachments = attachmentParts.length > 0
+ ? attachmentParts.join(', ')
+ : '없음';
+
+ // subjectId로 교육과정명 찾기
+ const subjectId = lecture.subjectId || lecture.subject_id;
+ let courseName = '';
+ if (subjectId && courses.length > 0) {
+ const foundCourse = courses.find(course => course.id === String(subjectId));
+ courseName = foundCourse?.courseName || '';
+ }
+
+ // 교육과정명을 찾지 못한 경우 fallback
+ if (!courseName) {
+ courseName = lecture.subjectName || lecture.courseName || '';
+ }
+
+ return {
+ id: String(lecture.id || lecture.lectureId || ''),
+ courseName,
+ lessonName: lecture.title || lecture.lessonName || '',
+ attachments,
+ questionCount: lecture.csvKey ? 1 : 0,
+ createdBy: lecture.createdBy || lecture.instructorName || '관리자',
+ createdAt: lecture.createdAt
+ ? new Date(lecture.createdAt).toISOString().split('T')[0]
+ : new Date().toISOString().split('T')[0],
+ };
+ });
+ setLessons(fetchedLessons);
+ }, [courses]);
// 교육과정 목록 가져오기
useEffect(() => {
@@ -51,6 +112,34 @@ export default function AdminLessonsPage() {
fetchCourses();
}, []);
+ // 강좌 리스트 조회
+ useEffect(() => {
+ async function fetchLectures() {
+ try {
+ const response = await apiService.getLectures();
+ if (response.data && Array.isArray(response.data)) {
+ // 원본 데이터 저장
+ rawLecturesRef.current = response.data;
+ // 교육과정명 매핑 함수 호출
+ mapCourseNames(response.data);
+ }
+ } catch (error) {
+ console.error('강좌 리스트 조회 오류:', error);
+ setLessons([]);
+ rawLecturesRef.current = [];
+ }
+ }
+
+ fetchLectures();
+ }, [mapCourseNames]);
+
+ // 교육과정 목록이 로드되면 강좌 리스트의 교육과정명 업데이트
+ useEffect(() => {
+ if (rawLecturesRef.current.length > 0) {
+ mapCourseNames(rawLecturesRef.current);
+ }
+ }, [mapCourseNames]);
+
// 현재 사용자 정보 가져오기
useEffect(() => {
async function fetchCurrentUser() {
@@ -144,66 +233,215 @@ export default function AdminLessonsPage() {
setLearningGoal("");
setCourseVideoCount(0);
setCourseVideoFiles([]);
+ setCourseVideoFileObjects([]);
setVrContentCount(0);
setVrContentFiles([]);
+ setVrContentFileObjects([]);
setQuestionFileCount(0);
+ setQuestionFileObject(null);
+ setErrors({});
};
const handleRegisterClick = () => {
setIsRegistrationMode(true);
};
- const handleSaveClick = () => {
+ const handleSaveClick = async () => {
// 유효성 검사
- if (!selectedCourse || !lessonName) {
- // TODO: 에러 메시지 표시
+ const newErrors: {
+ selectedCourse?: string;
+ lessonName?: string;
+ learningGoal?: string;
+ } = {};
+
+ if (!selectedCourse) {
+ newErrors.selectedCourse = "교육과정을 선택해 주세요.";
+ }
+ if (!lessonName.trim()) {
+ newErrors.lessonName = "강좌명을 입력해 주세요.";
+ }
+ if (!learningGoal.trim()) {
+ newErrors.learningGoal = "내용을 입력해 주세요.";
+ }
+
+ // 에러가 있으면 표시하고 중단
+ if (Object.keys(newErrors).length > 0) {
+ setErrors(newErrors);
return;
}
- // 첨부파일 정보 문자열 생성
- const attachmentParts: string[] = [];
- if (courseVideoCount > 0) {
- attachmentParts.push(`강좌영상 ${courseVideoCount}개`);
+ // 에러 초기화
+ setErrors({});
+
+ try {
+ // 파일 업로드 및 키 추출
+ let videoUrl: string | undefined;
+ let webglUrl: string | undefined;
+ let csvKey: string | undefined;
+
+ // 강좌 영상 업로드 (첫 번째 파일만 사용)
+ if (courseVideoFileObjects.length > 0) {
+ try {
+ const uploadResponse = await apiService.uploadFile(courseVideoFileObjects[0]);
+ if (uploadResponse.data) {
+ const fileKey = uploadResponse.data.key
+ || uploadResponse.data.fileKey
+ || uploadResponse.data.id
+ || uploadResponse.data.imageKey
+ || uploadResponse.data.fileId
+ || (uploadResponse.data.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey))
+ || null;
+ if (fileKey) {
+ videoUrl = fileKey;
+ }
+ }
+ } catch (error) {
+ console.error('강좌 영상 업로드 실패:', error);
+ }
+ }
+
+ // VR 콘텐츠 업로드 (첫 번째 파일만 사용)
+ if (vrContentFileObjects.length > 0) {
+ try {
+ const uploadResponse = await apiService.uploadFile(vrContentFileObjects[0]);
+ if (uploadResponse.data) {
+ const fileKey = uploadResponse.data.key
+ || uploadResponse.data.fileKey
+ || uploadResponse.data.id
+ || uploadResponse.data.imageKey
+ || uploadResponse.data.fileId
+ || (uploadResponse.data.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey))
+ || null;
+ if (fileKey) {
+ webglUrl = fileKey;
+ }
+ }
+ } catch (error) {
+ console.error('VR 콘텐츠 업로드 실패:', error);
+ }
+ }
+
+ // 학습 평가 문제 업로드
+ if (questionFileObject) {
+ try {
+ const uploadResponse = await apiService.uploadFile(questionFileObject);
+ if (uploadResponse.data) {
+ const fileKey = uploadResponse.data.key
+ || uploadResponse.data.fileKey
+ || uploadResponse.data.id
+ || uploadResponse.data.imageKey
+ || uploadResponse.data.fileId
+ || (uploadResponse.data.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey))
+ || null;
+ if (fileKey) {
+ csvKey = fileKey;
+ }
+ }
+ } catch (error) {
+ console.error('학습 평가 문제 업로드 실패:', error);
+ }
+ }
+
+ // API 요청 body 구성
+ const requestBody: {
+ subjectId: number;
+ title: string;
+ objective: string;
+ videoUrl?: string;
+ webglUrl?: string;
+ csvKey?: string;
+ } = {
+ subjectId: Number(selectedCourse),
+ title: lessonName.trim(),
+ objective: learningGoal.trim(),
+ };
+
+ // 선택적 필드 추가
+ if (videoUrl) {
+ requestBody.videoUrl = videoUrl;
+ }
+ if (webglUrl) {
+ requestBody.webglUrl = webglUrl;
+ }
+ if (csvKey) {
+ requestBody.csvKey = csvKey;
+ }
+
+ // 강좌 등록 API 호출
+ const response = await apiService.createLecture(requestBody);
+
+ // 응답에서 id 추출 및 저장
+ const lectureId = response.data?.id;
+ if (!lectureId) {
+ throw new Error('강좌 등록 응답에서 ID를 받지 못했습니다.');
+ }
+
+ // 첨부파일 정보 문자열 생성
+ const attachmentParts: string[] = [];
+ if (courseVideoCount > 0) {
+ attachmentParts.push(`강좌영상 ${courseVideoCount}개`);
+ }
+ if (vrContentCount > 0) {
+ attachmentParts.push(`VR콘텐츠 ${vrContentCount}개`);
+ }
+ if (questionFileCount > 0) {
+ attachmentParts.push(`평가문제 ${questionFileCount}개`);
+ }
+ const attachments = attachmentParts.length > 0
+ ? attachmentParts.join(', ')
+ : '없음';
+
+ // 교육과정명 가져오기
+ const courseName = courseOptions.find(c => c.id === selectedCourse)?.name || '';
+
+ // 새 강좌 생성 (API 응답의 id 사용)
+ const newLesson: Lesson = {
+ id: String(lectureId),
+ courseName,
+ lessonName,
+ attachments,
+ questionCount: questionFileCount,
+ createdBy: currentUser,
+ createdAt: new Date().toISOString().split('T')[0],
+ };
+
+ // 강좌 목록에 추가
+ setLessons(prev => [...prev, newLesson]);
+
+ // 등록 모드 종료 및 폼 초기화
+ setIsRegistrationMode(false);
+ setSelectedCourse("");
+ setLessonName("");
+ setLearningGoal("");
+ setCourseVideoCount(0);
+ setCourseVideoFiles([]);
+ setCourseVideoFileObjects([]);
+ setVrContentCount(0);
+ setVrContentFiles([]);
+ setVrContentFileObjects([]);
+ setQuestionFileCount(0);
+ setQuestionFileObject(null);
+
+ // 토스트 팝업 표시
+ setShowToast(true);
+ } catch (error) {
+ console.error('강좌 등록 실패:', error);
+ const errorMessage = error instanceof Error ? error.message : '강좌 등록 중 오류가 발생했습니다.';
+ alert(errorMessage);
}
- if (vrContentCount > 0) {
- attachmentParts.push(`VR콘텐츠 ${vrContentCount}개`);
- }
- if (questionFileCount > 0) {
- attachmentParts.push(`평가문제 ${questionFileCount}개`);
- }
- const attachments = attachmentParts.length > 0
- ? attachmentParts.join(', ')
- : '없음';
-
- // 교육과정명 가져오기
- const courseName = courseOptions.find(c => c.id === selectedCourse)?.name || '';
-
- // 새 강좌 생성
- const newLesson: Lesson = {
- id: String(Date.now()),
- courseName,
- lessonName,
- attachments,
- questionCount: questionFileCount,
- createdBy: currentUser,
- createdAt: new Date().toISOString().split('T')[0],
- };
-
- // 강좌 목록에 추가
- setLessons(prev => [...prev, newLesson]);
-
- // 등록 모드 종료 및 폼 초기화
- setIsRegistrationMode(false);
- setSelectedCourse("");
- setLessonName("");
- setLearningGoal("");
- setCourseVideoCount(0);
- setCourseVideoFiles([]);
- setVrContentCount(0);
- setVrContentFiles([]);
- setQuestionFileCount(0);
};
+ // 토스트 자동 닫기
+ useEffect(() => {
+ if (showToast) {
+ const timer = setTimeout(() => {
+ setShowToast(false);
+ }, 3000); // 3초 후 자동 닫기
+
+ return () => clearTimeout(timer);
+ }
+ }, [showToast]);
+
return (
{/* 메인 레이아웃 */}
@@ -254,7 +492,9 @@ export default function AdminLessonsPage() {
{isDropdownOpen && (
-
+
{courseOptions.map((course, index) => (
)}
+ {errors.selectedCourse && (
+
+ {errors.selectedCourse}
+
+ )}
{/* 강좌명 */}
@@ -301,10 +550,23 @@ export default function AdminLessonsPage() {
setLessonName(e.target.value)}
+ onChange={(e) => {
+ setLessonName(e.target.value);
+ // 에러 초기화
+ if (errors.lessonName) {
+ setErrors(prev => ({ ...prev, lessonName: undefined }));
+ }
+ }}
placeholder="강좌명을 입력해 주세요."
- className="h-[40px] px-[12px] py-[8px] border border-[#dee1e6] rounded-[8px] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] focus:outline-none focus:shadow-[inset_0_0_0_1px_#333c47]"
+ className={`h-[40px] px-[12px] py-[8px] border rounded-[8px] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] focus:outline-none focus:shadow-[inset_0_0_0_1px_#333c47] ${
+ errors.lessonName ? 'border-[#e63946]' : 'border-[#dee1e6]'
+ }`}
/>
+ {errors.lessonName && (
+
+ {errors.lessonName}
+
+ )}
{/* 학습 목표 */}
@@ -318,10 +580,16 @@ export default function AdminLessonsPage() {
onChange={(e) => {
if (e.target.value.length <= 1000) {
setLearningGoal(e.target.value);
+ // 에러 초기화
+ if (errors.learningGoal) {
+ setErrors(prev => ({ ...prev, learningGoal: undefined }));
+ }
}
}}
placeholder="내용을 입력해 주세요. (최대 1,000자)"
- className="w-full h-[160px] px-[12px] py-[8px] border border-[#dee1e6] rounded-[8px] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] focus:outline-none focus:shadow-[inset_0_0_0_1px_#333c47] resize-none"
+ className={`w-full h-[160px] px-[12px] py-[8px] border rounded-[8px] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] focus:outline-none focus:shadow-[inset_0_0_0_1px_#333c47] resize-none ${
+ errors.learningGoal ? 'border-[#e63946]' : 'border-[#dee1e6]'
+ }`}
/>
@@ -329,6 +597,11 @@ export default function AdminLessonsPage() {
+ {errors.learningGoal && (
+
+ {errors.learningGoal}
+
+ )}
@@ -357,22 +630,50 @@ export default function AdminLessonsPage() {
multiple
accept=".mp4,video/mp4"
className="hidden"
- onChange={(e) => {
+ onChange={async (e) => {
const files = e.target.files;
if (!files) return;
const MAX_SIZE = 30 * 1024 * 1024; // 30MB
- const mp4Files: string[] = [];
+ const validFiles: File[] = [];
+ const oversizedFiles: string[] = [];
+
Array.from(files).forEach((file) => {
- // mp4 파일이고 30MB 이하인 파일만 필터링
- if (file.name.toLowerCase().endsWith('.mp4') && file.size <= MAX_SIZE) {
- mp4Files.push(file.name);
+ // mp4 파일인지 확인
+ if (!file.name.toLowerCase().endsWith('.mp4')) {
+ return;
+ }
+ // 각 파일이 30MB 미만인지 검사
+ if (file.size < MAX_SIZE) {
+ validFiles.push(file);
+ } else {
+ oversizedFiles.push(file.name);
}
});
- if (courseVideoCount + mp4Files.length <= 10) {
- setCourseVideoFiles(prev => [...prev, ...mp4Files]);
- setCourseVideoCount(prev => prev + mp4Files.length);
+ // 30MB 이상인 파일이 있으면 알림
+ if (oversizedFiles.length > 0) {
+ alert(`다음 파일은 30MB 미만이어야 합니다:\n${oversizedFiles.join('\n')}`);
+ }
+
+ // 파일 개수 제한 확인
+ if (courseVideoCount + validFiles.length > 10) {
+ alert('강좌 영상은 최대 10개까지 첨부할 수 있습니다.');
+ e.target.value = '';
+ return;
+ }
+
+ if (validFiles.length > 0) {
+ try {
+ // 다중 파일 업로드
+ await apiService.uploadFiles(validFiles);
+ setCourseVideoFiles(prev => [...prev, ...validFiles.map(f => f.name)]);
+ setCourseVideoFileObjects(prev => [...prev, ...validFiles]);
+ setCourseVideoCount(prev => prev + validFiles.length);
+ } catch (error) {
+ console.error('강좌 영상 업로드 실패:', error);
+ alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
+ }
}
// input 초기화 (같은 파일 다시 선택 가능하도록)
e.target.value = '';
@@ -401,6 +702,7 @@ export default function AdminLessonsPage() {
type="button"
onClick={() => {
setCourseVideoFiles(prev => prev.filter((_, i) => i !== index));
+ setCourseVideoFileObjects(prev => prev.filter((_, i) => i !== index));
setCourseVideoCount(prev => prev - 1);
}}
className="size-[16px] flex items-center justify-center cursor-pointer hover:opacity-70 transition-opacity shrink-0"
@@ -433,22 +735,50 @@ export default function AdminLessonsPage() {
multiple
accept=".zip,application/zip"
className="hidden"
- onChange={(e) => {
+ onChange={async (e) => {
const files = e.target.files;
if (!files) return;
const MAX_SIZE = 30 * 1024 * 1024; // 30MB
- const zipFiles: string[] = [];
+ const validFiles: File[] = [];
+ const oversizedFiles: string[] = [];
+
Array.from(files).forEach((file) => {
- // zip 파일이고 30MB 이하인 파일만 필터링
- if (file.name.toLowerCase().endsWith('.zip') && file.size <= MAX_SIZE) {
- zipFiles.push(file.name);
+ // zip 파일인지 확인
+ if (!file.name.toLowerCase().endsWith('.zip')) {
+ return;
+ }
+ // 각 파일이 30MB 미만인지 검사
+ if (file.size < MAX_SIZE) {
+ validFiles.push(file);
+ } else {
+ oversizedFiles.push(file.name);
}
});
- if (vrContentCount + zipFiles.length <= 10) {
- setVrContentFiles(prev => [...prev, ...zipFiles]);
- setVrContentCount(prev => prev + zipFiles.length);
+ // 30MB 이상인 파일이 있으면 알림
+ if (oversizedFiles.length > 0) {
+ alert(`다음 파일은 30MB 미만이어야 합니다:\n${oversizedFiles.join('\n')}`);
+ }
+
+ // 파일 개수 제한 확인
+ if (vrContentCount + validFiles.length > 10) {
+ alert('VR 콘텐츠는 최대 10개까지 첨부할 수 있습니다.');
+ e.target.value = '';
+ return;
+ }
+
+ if (validFiles.length > 0) {
+ try {
+ // 다중 파일 업로드
+ await apiService.uploadFiles(validFiles);
+ setVrContentFiles(prev => [...prev, ...validFiles.map(f => f.name)]);
+ setVrContentFileObjects(prev => [...prev, ...validFiles]);
+ setVrContentCount(prev => prev + validFiles.length);
+ } catch (error) {
+ console.error('VR 콘텐츠 업로드 실패:', error);
+ alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
+ }
}
// input 초기화 (같은 파일 다시 선택 가능하도록)
e.target.value = '';
@@ -477,6 +807,7 @@ export default function AdminLessonsPage() {
type="button"
onClick={() => {
setVrContentFiles(prev => prev.filter((_, i) => i !== index));
+ setVrContentFileObjects(prev => prev.filter((_, i) => i !== index));
setVrContentCount(prev => prev - 1);
}}
className="size-[16px] flex items-center justify-center cursor-pointer hover:opacity-70 transition-opacity shrink-0"
@@ -515,14 +846,22 @@ export default function AdminLessonsPage() {
type="file"
accept=".csv"
className="hidden"
- onChange={(e) => {
+ onChange={async (e) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const file = files[0];
// CSV 파일만 허용
if (file.name.toLowerCase().endsWith('.csv')) {
- setQuestionFileCount(1);
+ try {
+ // 단일 파일 업로드
+ await apiService.uploadFile(file);
+ setQuestionFileObject(file);
+ setQuestionFileCount(1);
+ } catch (error) {
+ console.error('학습 평가 문제 업로드 실패:', error);
+ alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
+ }
}
}}
/>
@@ -618,10 +957,23 @@ export default function AdminLessonsPage() {
- {paginatedLessons.map((lesson) => (
+ {paginatedLessons.map((lesson) => {
+ // 원본 강좌 데이터 찾기
+ const rawLecture = rawLecturesRef.current.find(
+ (l: any) => String(l.id || l.lectureId) === lesson.id
+ );
+
+ return (
{
+ // 원본 데이터를 sessionStorage에 저장
+ if (rawLecture) {
+ sessionStorage.setItem('selectedLecture', JSON.stringify(rawLecture));
+ }
+ router.push(`/admin/lessons/${lesson.id}`);
+ }}
+ className="h-12 hover:bg-[#F5F7FF] transition-colors cursor-pointer"
>
|
{lesson.courseName}
@@ -642,7 +994,8 @@ export default function AdminLessonsPage() {
{lesson.createdAt}
|
- ))}
+ );
+ })}
@@ -740,6 +1093,23 @@ export default function AdminLessonsPage() {
+
+ {/* 강좌 등록 완료 토스트 */}
+ {showToast && (
+
+ )}
);
}
diff --git a/src/app/admin/notices/page.tsx b/src/app/admin/notices/page.tsx
index 08e7092..59587ae 100644
--- a/src/app/admin/notices/page.tsx
+++ b/src/app/admin/notices/page.tsx
@@ -5,6 +5,7 @@ import AdminSidebar from "@/app/components/AdminSidebar";
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
import BackArrowSvg from "@/app/svgs/backarrow";
import { MOCK_NOTICES, type Notice } from "@/app/admin/notices/mockData";
+import apiService from "@/app/lib/apiService";
export default function AdminNoticesPage() {
const [notices, setNotices] = useState(MOCK_NOTICES);
@@ -30,14 +31,21 @@ export default function AdminNoticesPage() {
fileInputRef.current?.click();
};
- const handleFileChange = (e: ChangeEvent) => {
+ const handleFileChange = async (e: ChangeEvent) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 30 * 1024 * 1024) {
alert('30MB 미만의 파일만 첨부할 수 있습니다.');
return;
}
- setAttachedFile(file);
+ try {
+ // 단일 파일 업로드
+ await apiService.uploadFile(file);
+ setAttachedFile(file);
+ } catch (error) {
+ console.error('파일 업로드 실패:', error);
+ alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
+ }
}
};
diff --git a/src/app/course-list/[id]/page.tsx b/src/app/course-list/[id]/page.tsx
new file mode 100644
index 0000000..21b936b
--- /dev/null
+++ b/src/app/course-list/[id]/page.tsx
@@ -0,0 +1,212 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useParams, useRouter } from 'next/navigation';
+import Image from 'next/image';
+import apiService from '../../lib/apiService';
+
+type Lesson = {
+ id: string;
+ title: string;
+ duration: string; // "12:46" 형식
+ state: "제출완료" | "제출대기";
+ action: "복습하기" | "이어서 수강하기" | "수강하기";
+};
+
+type CourseDetail = {
+ id: string;
+ status: "수강 중" | "수강 예정" | "수강 완료";
+ title: string;
+ goal: string;
+ method: string;
+ summary: string; // VOD · 총 n강 · n시간 n분
+ submitSummary: string; // 학습 제출 n/n
+ thumbnail: string;
+ lessons: Lesson[];
+};
+
+export default function CourseDetailPage() {
+ const params = useParams();
+ const router = useRouter();
+ const [course, setCourse] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchCourse = async () => {
+ if (!params?.id) return;
+
+ try {
+ setLoading(true);
+ setError(null);
+ const response = await apiService.getLecture(params.id as string);
+
+ // API 응답 데이터를 CourseDetail 타입으로 변환
+ const data = response.data;
+
+ // API 응답 구조에 맞게 데이터 매핑
+ // 실제 API 응답 구조에 따라 조정 필요
+ const courseDetail: CourseDetail = {
+ id: String(data.id || params.id),
+ status: data.status || "수강 예정",
+ title: data.title || data.lectureName || '',
+ goal: data.objective || data.goal || '',
+ method: data.method || '',
+ summary: data.summary || `VOD · 총 ${data.lessons?.length || 0}강`,
+ submitSummary: data.submitSummary || '',
+ thumbnail: data.thumbnail || data.imageKey || data.imageUrl || '/imgs/talk.png',
+ lessons: (data.lessons || []).map((lesson: any, index: number) => ({
+ id: String(lesson.id || lesson.lessonId || index + 1),
+ title: `${index + 1}. ${lesson.title || lesson.lessonName || ''}`,
+ duration: lesson.duration || '00:00',
+ state: lesson.isCompleted ? "제출완료" : "제출대기",
+ action: lesson.isCompleted ? "복습하기" : (index === 0 ? "수강하기" : "이어서 수강하기"),
+ })),
+ };
+
+ setCourse(courseDetail);
+ } catch (err) {
+ console.error('강좌 조회 실패:', err);
+ setError(err instanceof Error ? err.message : '강좌를 불러오는데 실패했습니다.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchCourse();
+ }, [params?.id]);
+
+ if (loading) {
+ return (
+
+
+ 교육 과정 상세보기
+
+
+
+ );
+ }
+
+ if (error || !course) {
+ return (
+
+
+ 교육 과정 상세보기
+
+
+
+ {error || '강좌를 찾을 수 없습니다.'}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ 교육 과정 상세보기
+
+
+
+
+ {/* 상단 소개 카드 */}
+
+
+
+
+
+
+
+ {course.status}
+
+ {course.title}
+
+
+
+ 학습 목표: {course.goal}
+
+
+ 학습 방법: {course.method}
+
+
+
+ {course.summary}
+ {course.submitSummary && {course.submitSummary}}
+
+
+
+
+ {/* 차시 리스트 */}
+
+ {course.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]");
+ const rightBtnStyle =
+ l.action === "이어서 수강하기"
+ ? "bg-[#ecf0ff] text-[#384fbf]"
+ : l.action === "수강하기"
+ ? "bg-[#ecf0ff] text-[#384fbf]"
+ : "bg-[#f1f3f5] text-[#4c5561]";
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+
+
+
+ );
+}
+
diff --git a/src/app/course-list/page.tsx b/src/app/course-list/page.tsx
index 3ad2b2b..8714b69 100644
--- a/src/app/course-list/page.tsx
+++ b/src/app/course-list/page.tsx
@@ -1,6 +1,7 @@
'use client';
import { useMemo, useState } from 'react';
+import { useRouter } from 'next/navigation';
import ChevronDownSvg from '../svgs/chevrondownsvg';
// 피그마 선택 컴포넌트의 구조/스타일(타이포/여백/색상)을 반영한 리스트 UI
@@ -27,6 +28,7 @@ type Course = { id: string; title: string; image: string; inProgress?: boolean }
export default function CourseListPage() {
const ITEMS_PER_PAGE = 20;
const [page, setPage] = useState(1);
+ const router = useRouter();
const base: Omit[] = [
{ title: '원자로 운전 및 계통', image: imgThumbA, inProgress: true },
@@ -75,7 +77,8 @@ export default function CourseListPage() {
{pagedCourses.map((c) => (
router.push(`/course-list/${c.id}`)}
+ className="flex h-[260px] w-[249.6px] flex-col gap-[16px] rounded-[8px] bg-white cursor-pointer hover:shadow-lg transition-shadow"
>
{/* 섬네일 */}
diff --git a/src/app/globals.css b/src/app/globals.css
index 0a120f7..293ffbe 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -61,3 +61,26 @@ button {
button:hover {
cursor: pointer;
}
+
+/* 드롭다운 스크롤바 스타일 - 배경 없애기 */
+.dropdown-scroll {
+ scrollbar-width: thin;
+ scrollbar-color: transparent transparent;
+}
+
+.dropdown-scroll::-webkit-scrollbar {
+ width: 6px;
+}
+
+.dropdown-scroll::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.dropdown-scroll::-webkit-scrollbar-thumb {
+ background-color: transparent;
+ border-radius: 3px;
+}
+
+.dropdown-scroll:hover::-webkit-scrollbar-thumb {
+ background-color: rgba(0, 0, 0, 0.2);
+}
diff --git a/src/app/lib/apiService.ts b/src/app/lib/apiService.ts
index ebd64ed..f574581 100644
--- a/src/app/lib/apiService.ts
+++ b/src/app/lib/apiService.ts
@@ -46,10 +46,13 @@ class ApiService {
/**
* 기본 헤더 생성
*/
- private getDefaultHeaders(): Record {
- const headers: Record = {
- 'Content-Type': 'application/json',
- };
+ private getDefaultHeaders(isFormData: boolean = false): Record {
+ const headers: Record = {};
+
+ // FormData인 경우 Content-Type을 설정하지 않음 (브라우저가 자동으로 설정)
+ if (!isFormData) {
+ headers['Content-Type'] = 'application/json';
+ }
const token = this.getToken();
if (token) {
@@ -98,16 +101,20 @@ class ApiService {
const url = endpoint.startsWith('http') ? endpoint : `${this.baseURL}${endpoint}`;
+ // FormData 여부 확인
+ const isFormData = body instanceof FormData;
+
const requestOptions: RequestInit = {
method,
headers: {
- ...this.getDefaultHeaders(),
+ ...this.getDefaultHeaders(isFormData),
...headers,
},
};
if (body && method !== 'GET') {
- requestOptions.body = JSON.stringify(body);
+ // FormData인 경우 그대로 사용, 아닌 경우 JSON으로 변환
+ requestOptions.body = isFormData ? body : JSON.stringify(body);
}
try {
@@ -277,11 +284,12 @@ class ApiService {
* 과목 수정
*/
async updateSubject(subjectId: string, subjectData: {
- courseName: string;
- instructorName: string;
+ title: string;
+ instructor: string;
+ imageKey?: string | null;
}) {
return this.request(`/subjects/${subjectId}`, {
- method: 'PUT',
+ method: 'PATCH',
body: subjectData,
});
}
@@ -347,12 +355,109 @@ class ApiService {
return this.request('/lessons');
}
+ /**
+ * 강좌 리스트 조회
+ */
+ async getLectures() {
+ return this.request('/lectures', {
+ method: 'GET',
+ });
+ }
+
+ /**
+ * 특정 강좌 조회
+ */
+ async getLecture(id: string | number) {
+ return this.request(`/lectures/${id}`, {
+ method: 'GET',
+ });
+ }
+
+ /**
+ * 강좌(lecture) 생성
+ */
+ async createLecture(lectureData: {
+ subjectId: number;
+ title: string;
+ objective: string;
+ videoUrl?: string;
+ webglUrl?: string;
+ csvKey?: string;
+ }) {
+ return this.request('/lectures', {
+ method: 'POST',
+ body: lectureData,
+ });
+ }
+
/**
* 리소스 조회
*/
async getResources() {
return this.request('/resources');
}
+
+ // ===== 파일 업로드 관련 API =====
+
+ /**
+ * 단일 파일 업로드
+ * @param file 업로드할 파일 (File 객체 또는 Blob)
+ */
+ async uploadFile(file: File | Blob) {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ return this.request('/uploads-api/file', {
+ method: 'POST',
+ body: formData,
+ });
+ }
+
+ /**
+ * 다중 파일 업로드
+ * @param files 업로드할 파일 배열 (File[] 또는 Blob[])
+ */
+ async uploadFiles(files: (File | Blob)[]) {
+ const formData = new FormData();
+ files.forEach((file, index) => {
+ formData.append('files', file);
+ });
+
+ return this.request('/uploads-api/files', {
+ method: 'POST',
+ body: formData,
+ });
+ }
+
+ /**
+ * 파일 다운로드 (이미지 URL 가져오기)
+ * @param fileKey 파일 키
+ * @returns 파일 URL (Blob URL), 파일이 없으면 null 반환
+ */
+ async getFile(fileKey: string): Promise {
+ const url = `${this.baseURL}/api/files/${fileKey}`;
+ const token = this.getToken();
+
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: {
+ ...(token && { Authorization: `Bearer ${token}` }),
+ },
+ });
+
+ // 404 에러는 이미지가 없는 것으로 간주하고 null 반환
+ if (response.status === 404) {
+ return null;
+ }
+
+ if (!response.ok) {
+ throw new Error(`파일을 가져오는데 실패했습니다. (${response.status})`);
+ }
+
+ // 이미지 파일이므로 Blob으로 변환하여 URL 생성
+ const blob = await response.blob();
+ return URL.createObjectURL(blob);
+ }
}
// 기본 API 서비스 인스턴스 생성
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
index abf794a..83417ff 100644
--- a/src/app/login/page.tsx
+++ b/src/app/login/page.tsx
@@ -8,7 +8,7 @@ import LoginCheckboxActiveSvg from "@/app/svgs/logincheckboxactivesvg";
import LoginCheckboxInactiveSvg from "@/app/svgs/logincheckboxinactivesvg";
import LoginInputSvg from "@/app/svgs/inputformx";
import LoginErrorModal from "./LoginErrorModal";
-import LoginOption from "@/app/login/LoginOption";
+import LoginOption from "@/app/login/loginoption";
import apiService from "@/app/lib/apiService";
export default function LoginPage() {
|