diff --git a/prisma.config.ts b/prisma.config.ts deleted file mode 100644 index 410cb59..0000000 --- a/prisma.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig, env } from "prisma/config"; -import "dotenv/config"; - -export default defineConfig({ - schema: "prisma/schema.prisma", - migrations: { - path: "prisma/migrations", - }, - engine: "classic", - datasource: { - url: env("DATABASE_URL"), - }, -}); diff --git a/src/app/admin/courses/CourseRegistrationModal.tsx b/src/app/admin/courses/CourseRegistrationModal.tsx index 1205ba0..2ba2dde 100644 --- a/src/app/admin/courses/CourseRegistrationModal.tsx +++ b/src/app/admin/courses/CourseRegistrationModal.tsx @@ -6,6 +6,7 @@ import DropdownIcon from "@/app/svgs/dropdownicon"; import CloseXOSvg from "@/app/svgs/closexo"; import { type UserRow } from "@/app/admin/id/mockData"; import { type Course } from "./mockData"; +import apiService from "@/app/lib/apiService"; type Props = { open: boolean; @@ -25,6 +26,8 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet const [previewUrl, setPreviewUrl] = useState(null); const [isDragging, setIsDragging] = useState(false); const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isImageDeleted, setIsImageDeleted] = useState(false); const dropdownRef = useRef(null); const modalRef = useRef(null); const fileInputRef = useRef(null); @@ -138,12 +141,47 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet return instructors.find(inst => inst.id === instructorId); }, [instructors, instructorId]); + // previewUrl 변경 시 이전 Blob URL 정리 + useEffect(() => { + return () => { + if (previewUrl && previewUrl.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl); + } + }; + }, [previewUrl]); + // 수정 모드일 때 기존 데이터 채우기 useEffect(() => { if (open && editingCourse) { setCourseName(editingCourse.courseName); // 수정 모드일 때 강사 목록 자동 로드 loadInstructors(); + + // 수정 모드일 때 이미지 로드 + if (editingCourse.imageKey) { + setIsImageDeleted(false); // 초기화 + setSelectedImage(null); // 새 이미지 선택 초기화 + const loadImage = async () => { + try { + const imageUrl = await apiService.getFile(editingCourse.imageKey!); + // 이미지가 있으면 previewUrl 설정, 없으면 null + if (imageUrl) { + setPreviewUrl(imageUrl); + } else { + setPreviewUrl(null); + } + } catch (error) { + console.error('이미지 로드 오류:', error); + // 이미지 로드 실패 시 null로 설정 + setPreviewUrl(null); + } + }; + loadImage(); + } else { + setIsImageDeleted(false); // 초기화 + setSelectedImage(null); // 새 이미지 선택 초기화 + setPreviewUrl(null); // 이미지가 없으면 명시적으로 null 설정 + } } else if (!open) { setCourseName(""); setInstructorId(""); @@ -153,6 +191,7 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet setSelectedImage(null); setPreviewUrl(null); setIsDragging(false); + setIsImageDeleted(false); setInstructors([]); // 모달 닫을 때 강사 목록 초기화 } }, [open, editingCourse]); @@ -224,106 +263,24 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet }); try { - // 토큰 가져오기 - const token = localStorage.getItem('token') || document.cookie - .split('; ') - .find(row => row.startsWith('token=')) - ?.split('=')[1]; - - // API base URL 설정 - const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL - ? process.env.NEXT_PUBLIC_API_BASE_URL - : 'https://hrdi.coconutmeet.net'; - let imageKey: string | null = null; - // 이미지가 있으면 먼저 업로드하여 imageKey 받기 + // 새 이미지가 선택된 경우 업로드 if (selectedImage) { try { - // 이미지 업로드 API 호출 - 일반적인 엔드포인트 경로 시도 - const possibleEndpoints = [ - `${baseUrl}/admin/files/upload`, - `${baseUrl}/admin/files`, - `${baseUrl}/admin/upload`, - `${baseUrl}/admin/images/upload`, - `${baseUrl}/admin/images`, - `${baseUrl}/files/upload`, - `${baseUrl}/files`, - `${baseUrl}/upload`, - `${baseUrl}/api/files/upload`, - `${baseUrl}/api/upload`, - `${baseUrl}/api/files`, - ]; - - let uploadResponse: Response | null = null; - let lastError: Error | null = null; - let uploadSuccess = false; - let lastStatusCode: number | null = null; - - // 여러 엔드포인트를 시도 - for (const uploadUrl of possibleEndpoints) { - try { - // 각 시도마다 새로운 FormData 생성 (FormData는 한 번만 사용 가능) - const formData = new FormData(); - formData.append('file', selectedImage); - // 일부 API는 'image' 필드명을 사용할 수 있음 - formData.append('image', selectedImage); - - uploadResponse = await fetch(uploadUrl, { - method: 'POST', - headers: { - ...(token && { Authorization: `Bearer ${token}` }), - // Content-Type은 FormData 사용 시 자동으로 설정되므로 명시하지 않음 - }, - body: formData, - }); - - lastStatusCode = uploadResponse.status; - - if (uploadResponse.ok) { - const uploadData = await uploadResponse.json(); - // 응답에서 imageKey 추출 (실제 응답 구조에 맞게 수정 필요) - imageKey = uploadData.imageKey || uploadData.key || uploadData.id || uploadData.fileKey || uploadData.fileId || uploadData.data?.key || uploadData.data?.imageKey || null; - uploadSuccess = true; - break; // 성공하면 루프 종료 - } else if (uploadResponse.status !== 404) { - // 404가 아닌 다른 에러면 해당 엔드포인트가 맞을 수 있으므로 에러 정보 저장 - try { - const errorData = await uploadResponse.json(); - lastError = new Error( - errorData.message || errorData.error || `이미지 업로드 실패: ${uploadResponse.status} ${uploadResponse.statusText}` - ); - } catch { - lastError = new Error(`이미지 업로드 실패: ${uploadResponse.status} ${uploadResponse.statusText}`); - } - // 401, 403 같은 인증/권한 오류는 더 이상 시도하지 않음 - if (uploadResponse.status === 401 || uploadResponse.status === 403) { - break; - } - } - } catch (fetchError) { - // 네트워크 에러 등은 무시하고 다음 엔드포인트 시도 - if (!lastError) { - lastError = fetchError instanceof Error ? fetchError : new Error('네트워크 오류가 발생했습니다.'); - } - continue; - } - } - - if (!uploadSuccess) { - // 모든 엔드포인트가 404를 반환한 경우와 다른 오류를 구분 - if (lastStatusCode === 404 || !lastStatusCode) { - console.warn('이미지 업로드 엔드포인트를 찾을 수 없습니다. 이미지 없이 계속 진행합니다.'); - } else { - const errorMessage = lastError?.message || '이미지 업로드에 실패했습니다.'; - console.error('이미지 업로드 실패:', errorMessage); - } - // 이미지 업로드 실패 시 사용자에게 알림 (경고 수준) - setErrors((prev) => ({ - ...prev, - image: '이미지 업로드에 실패했습니다. 이미지 없이 계속 진행됩니다.', - })); - // 이미지 업로드 실패해도 계속 진행 (선택사항) + const uploadResponse = await apiService.uploadFile(selectedImage); + + // 응답에서 imageKey 추출 + // apiService.uploadFile은 ApiResponse 형태로 반환하므로 uploadResponse.data가 실제 응답 데이터 + if (uploadResponse.data) { + // 다양한 가능한 응답 구조 확인 + imageKey = uploadResponse.data.imageKey + || uploadResponse.data.key + || uploadResponse.data.id + || uploadResponse.data.fileKey + || uploadResponse.data.fileId + || (uploadResponse.data.data && (uploadResponse.data.data.imageKey || uploadResponse.data.data.key)) + || null; } } catch (uploadError) { const errorMessage = uploadError instanceof Error ? uploadError.message : '이미지 업로드 중 오류가 발생했습니다.'; @@ -336,54 +293,117 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet } } - // /subjects API 호출 + const token = localStorage.getItem('token') || document.cookie + .split('; ') + .find(row => row.startsWith('token=')) + ?.split('=')[1]; + + const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL + ? process.env.NEXT_PUBLIC_API_BASE_URL + : 'https://hrdi.coconutmeet.net'; + const requestBody: { title: string; instructor: string; - imageKey?: string | null; + imageKey?: string; } = { title: courseName.trim(), instructor: selectedInstructor.name, }; - if (imageKey) { - requestBody.imageKey = imageKey; - } - - const response = await fetch(`${baseUrl}/subjects`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(token && { Authorization: `Bearer ${token}` }), - }, - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - let errorMessage = `과목 등록 실패 (${response.status})`; - try { - const errorData = await response.json(); - if (errorData.error) { - errorMessage = errorData.error; - } else if (errorData.message) { - errorMessage = errorData.message; - } - } catch (parseError) { - // JSON 파싱 실패 시 기본 메시지 사용 + // imageKey 처리: 수정 모드에서는 항상 명시적으로 설정 + if (editingCourse && editingCourse.id) { + // 수정 모드: 이미지가 삭제된 경우 "null" 문자열, 새 이미지가 있으면 값, 기존 이미지 유지 시 기존 값, 없으면 "null" + if (isImageDeleted) { + // 이미지가 삭제된 경우 무조건 "null" 문자열로 설정 + requestBody.imageKey = "null"; + } else if (imageKey) { + // 새 이미지가 업로드된 경우 + requestBody.imageKey = imageKey; + } else if (editingCourse.imageKey) { + // 기존 이미지를 유지하는 경우 + requestBody.imageKey = editingCourse.imageKey; + } else { + // 이미지가 없는 경우 "null" 문자열로 명시적으로 설정 + requestBody.imageKey = "null"; + } + } else { + // 등록 모드: 새 이미지가 있으면 값, 없으면 undefined (선택사항) + if (imageKey) { + requestBody.imageKey = imageKey; } - console.error('과목 등록 실패:', errorMessage); - setErrors({ submit: errorMessage }); - setIsSaving(false); - return; } - // 성공 시 onSave 콜백 호출 - if (onSave && selectedInstructor) { - onSave(courseName.trim(), selectedInstructor.name); + // 수정 모드인지 등록 모드인지 확인 + if (editingCourse && editingCourse.id) { + // 수정 모드: PUT /subjects/{id} + try { + await apiService.updateSubject(editingCourse.id, requestBody); + + // 성공 시 onSave 콜백 호출 및 모달 닫기 + if (onSave && selectedInstructor) { + onSave(courseName.trim(), selectedInstructor.name); + } + onClose(); // 모달 닫기 + } catch (updateError) { + const errorMessage = updateError instanceof Error ? updateError.message : '과목 수정 중 오류가 발생했습니다.'; + console.error('과목 수정 실패:', errorMessage); + setErrors({ submit: errorMessage }); + setIsSaving(false); + return; + } + } else { + // 등록 모드: POST /subjects + const response = await fetch(`${baseUrl}/subjects`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token && { Authorization: `Bearer ${token}` }), + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + let errorMessage = `과목 등록 실패 (${response.status})`; + try { + const errorData = await response.json(); + if (errorData.error) { + errorMessage = errorData.error; + } else if (errorData.message) { + errorMessage = errorData.message; + } + } catch (parseError) { + // JSON 파싱 실패 시 기본 메시지 사용 + } + console.error('과목 등록 실패:', errorMessage); + setErrors({ submit: errorMessage }); + setIsSaving(false); + return; + } + + // 응답에서 id 추출하여 저장 + try { + const responseData = await response.json(); + + // 응답에서 id 추출 (다양한 가능한 필드명 확인) + const subjectId = responseData.id + || responseData.data?.id + || responseData.subjectId + || responseData.data?.subjectId + || null; + } catch (parseError) { + // 응답 파싱 실패 시 무시 + } + + // 성공 시 onSave 콜백 호출 및 모달 닫기 + if (onSave && selectedInstructor) { + onSave(courseName.trim(), selectedInstructor.name); + } + onClose(); // 모달 닫기 } } catch (error) { const errorMessage = error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.'; - console.error('과목 등록 오류:', errorMessage); + console.error('과목 저장 오류:', errorMessage); setErrors({ submit: errorMessage }); } finally { setIsSaving(false); @@ -396,11 +416,37 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet }; // 삭제 확인 핸들러 - const handleDeleteConfirm = () => { - if (onDelete) { - onDelete(); + const handleDeleteConfirm = async () => { + if (!editingCourse || !editingCourse.id) { + console.error('삭제할 교육과정 정보가 없습니다.'); + setErrors({ submit: '삭제할 교육과정 정보가 없습니다.' }); + return; + } + + if (isDeleting) return; // 이미 삭제 중이면 중복 호출 방지 + + setIsDeleting(true); + setErrors((prev) => { + const next = { ...prev }; + delete next.submit; + return next; + }); + + try { + await apiService.deleteSubject(editingCourse.id); + + // 성공 시 모달 닫기 및 콜백 호출 setIsDeleteConfirmOpen(false); + if (onDelete) { + onDelete(); + } onClose(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '교육과정 삭제 중 오류가 발생했습니다.'; + console.error('교육과정 삭제 실패:', errorMessage); + setErrors({ submit: errorMessage }); + } finally { + setIsDeleting(false); } }; @@ -434,12 +480,18 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet } setSelectedImage(file); + setIsImageDeleted(false); // 새 이미지 선택 시 삭제 상태 해제 setErrors((prev) => { const next = { ...prev }; delete next.image; return next; }); + // 기존 previewUrl이 Blob URL인 경우 메모리 해제 + if (previewUrl && previewUrl.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl); + } + // 미리보기 URL 생성 const reader = new FileReader(); reader.onloadend = () => { @@ -491,7 +543,12 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet const handleRemoveImage = (e: React.MouseEvent) => { e.stopPropagation(); setSelectedImage(null); + // previewUrl이 Blob URL인 경우 메모리 해제 + if (previewUrl && previewUrl.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl); + } setPreviewUrl(null); + setIsImageDeleted(true); // 이미지 삭제 상태 설정 if (fileInputRef.current) { fileInputRef.current.value = ""; } @@ -800,6 +857,9 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet
정말 삭제하시겠습니까?

+ {errors.submit && ( +

{errors.submit}

+ )}
diff --git a/src/app/admin/courses/mockData.ts b/src/app/admin/courses/mockData.ts index eac11a3..78e30fd 100644 --- a/src/app/admin/courses/mockData.ts +++ b/src/app/admin/courses/mockData.ts @@ -7,39 +7,71 @@ export type Course = { createdAt: string; // 생성일 (YYYY-MM-DD) createdBy: string; // 등록자 hasLessons: boolean; // 강좌포함여부 + imageKey?: string; // 이미지 키 }; // 과목 리스트 조회 API export async function getCourses(): Promise { try { - const response = await apiService.getSubjects(); - const data = response.data; + // 교육과정과 강좌 리스트를 동시에 가져오기 + const [subjectsResponse, lecturesResponse] = await Promise.all([ + apiService.getSubjects(), + apiService.getLectures().catch(() => ({ data: [] })), // 강좌 리스트 조회 실패 시 빈 배열 반환 + ]); + + const subjectsData = subjectsResponse.data; + const lecturesData = lecturesResponse.data || []; // 디버깅: API 응답 구조 확인 - console.log('🔍 [getCourses] API 원본 응답:', data); - console.log('🔍 [getCourses] 응답 타입:', Array.isArray(data) ? '배열' : typeof data); + console.log('🔍 [getCourses] API 원본 응답:', subjectsData); + console.log('🔍 [getCourses] 응답 타입:', Array.isArray(subjectsData) ? '배열' : typeof subjectsData); + console.log('🔍 [getCourses] 강좌 리스트:', lecturesData); // API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태) let coursesArray: any[] = []; - if (Array.isArray(data)) { - coursesArray = data; - } else if (data && typeof data === 'object') { + if (Array.isArray(subjectsData)) { + coursesArray = subjectsData; + } else if (subjectsData && typeof subjectsData === 'object') { // 더 많은 가능한 필드명 확인 - coursesArray = data.items || data.courses || data.data || data.list || data.subjects || data.subjectList || []; + coursesArray = subjectsData.items || subjectsData.courses || subjectsData.data || subjectsData.list || subjectsData.subjects || subjectsData.subjectList || []; + } + + // 강좌 리스트가 배열이 아닌 경우 처리 + let lecturesArray: any[] = []; + if (Array.isArray(lecturesData)) { + lecturesArray = lecturesData; + } else if (lecturesData && typeof lecturesData === 'object') { + lecturesArray = lecturesData.items || lecturesData.lectures || lecturesData.data || lecturesData.list || []; } console.log('🔍 [getCourses] 변환 전 배열:', coursesArray); console.log('🔍 [getCourses] 배열 길이:', coursesArray.length); + console.log('🔍 [getCourses] 강좌 배열 길이:', lecturesArray.length); + + // 각 교육과정에 대해 강좌가 있는지 확인하기 위한 Set 생성 + const courseIdsWithLessons = new Set(); + lecturesArray.forEach((lecture: any) => { + const subjectId = String(lecture.subjectId || lecture.subject_id || ''); + if (subjectId) { + courseIdsWithLessons.add(subjectId); + } + }); + + console.log('🔍 [getCourses] 강좌가 있는 교육과정 ID:', Array.from(courseIdsWithLessons)); // API 응답 데이터를 Course 형식으로 변환 const transformedCourses: Course[] = coursesArray.map((item: any) => { + const courseId = String(item.id || item.subjectId || item.subject_id || ''); + const hasLessons = courseIdsWithLessons.has(courseId); + const transformed = { - id: String(item.id || item.subjectId || item.subject_id || ''), + id: courseId, courseName: item.courseName || item.name || item.subjectName || item.subject_name || item.title || '', instructorName: item.instructorName || item.instructor || item.instructor_name || item.teacherName || '', createdAt: item.createdAt || item.createdDate || item.created_date || item.createdAt || '', createdBy: item.createdBy || item.creator || item.created_by || item.creatorName || '', - hasLessons: item.hasLessons !== undefined ? item.hasLessons : (item.has_lessons !== undefined ? item.has_lessons : false), + hasLessons: hasLessons, + imageKey: item.imageKey || item.image_key || item.fileKey || item.file_key || undefined, }; console.log('🔍 [getCourses] 변환된 항목:', transformed); return transformed; diff --git a/src/app/admin/courses/page.tsx b/src/app/admin/courses/page.tsx index aa2c581..3ea88db 100644 --- a/src/app/admin/courses/page.tsx +++ b/src/app/admin/courses/page.tsx @@ -6,6 +6,24 @@ import CourseRegistrationModal from "./CourseRegistrationModal"; import ChevronDownSvg from "@/app/svgs/chevrondownsvg"; import { getCourses, type Course } from "./mockData"; +// 날짜를 yyyy-mm-dd 형식으로 변환하는 함수 +const formatDate = (dateString: string): string => { + if (!dateString) return ''; + try { + const date = new Date(dateString); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } catch { + // 이미 yyyy-mm-dd 형식이거나 파싱 실패 시 원본 반환 + if (dateString.includes('T')) { + return dateString.split('T')[0]; + } + return dateString; + } +}; + export default function AdminCoursesPage() { const [courses, setCourses] = useState([]); 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.title} +
+
+
+ + {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 ( +
+
+
+

{l.title}

+
+

{l.duration}

+
+
+
+ + +
+
+
+ ); + })} +
+
+
+
+
+
+
+
+ ); +} + 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.title} +
+
+
+ + {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 ( +
+
+
+

{l.title}

+
+

{l.duration}

+
+
+
+ + +
+
+
+ ); + })} +
+
+
+
+ ); +} + 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() {