강좌 삭제 구현1

This commit is contained in:
2025-11-28 21:37:05 +09:00
parent 03b4fa108a
commit 5a26d96386
6 changed files with 877 additions and 250 deletions

View File

@@ -411,7 +411,8 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet
}; };
// 삭제 버튼 클릭 핸들러 // 삭제 버튼 클릭 핸들러
const handleDeleteClick = () => { const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation();
setIsDeleteConfirmOpen(true); setIsDeleteConfirmOpen(true);
}; };
@@ -559,9 +560,12 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet
}); });
}; };
if (!open) return null; if (!open && !isDeleteConfirmOpen) return null;
return ( return (
<>
{/* 메인 모달 */}
{open && (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center" className="fixed inset-0 z-50 flex items-center justify-center"
aria-hidden={!open} aria-hidden={!open}
@@ -838,6 +842,8 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet
</div> </div>
</div> </div>
</div> </div>
</div>
)}
{/* 삭제 확인 모달 */} {/* 삭제 확인 모달 */}
{isDeleteConfirmOpen && ( {isDeleteConfirmOpen && (
@@ -847,41 +853,48 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet
onClick={handleDeleteCancel} onClick={handleDeleteCancel}
aria-hidden="true" aria-hidden="true"
/> />
<div className="relative z-10 bg-white rounded-[8px] p-[24px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] flex flex-col gap-[32px] items-end justify-end min-w-[500px]"> <div className="relative z-10 bg-white rounded-[8px] p-[24px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] flex flex-col gap-[32px] items-end justify-end">
<div className="flex flex-col gap-[16px] items-start justify-center w-full"> <div className="flex flex-col gap-[16px] items-start justify-center w-full">
<h2 className="text-[18px] font-semibold leading-normal text-[var(--color-neutral-700)]"> <div className="flex gap-[8px] items-start w-full">
? <p className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">
</h2> ?
<p className="text-[15px] font-normal leading-normal text-[var(--color-basic-text)]">
.
<br />
?
</p> </p>
</div>
<div className="flex gap-[8px] items-start w-full">
<div className="text-[15px] font-normal leading-[1.5] text-[#4c5561]">
<p className="mb-0"> .</p>
<p> ?</p>
</div>
</div>
{errors.submit && ( {errors.submit && (
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.submit}</p> <p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.submit}</p>
)} )}
</div> </div>
<div className="flex gap-2 items-center justify-end"> <div className="flex gap-[8px] items-center justify-end shrink-0">
<button <button
type="button" type="button"
onClick={handleDeleteCancel} onClick={handleDeleteCancel}
className="h-[40px] px-2 rounded-[8px] bg-[var(--color-bg-gray-light)] text-[16px] font-semibold leading-normal text-[var(--color-basic-text)] w-[80px] hover:bg-[var(--color-bg-gray-hover)] cursor-pointer transition-colors" className="bg-[#f1f3f5] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#e9ecef] cursor-pointer transition-colors"
> >
<p className="text-[16px] font-semibold leading-[1.5] text-[#4c5561] text-center whitespace-pre">
</p>
</button> </button>
<button <button
type="button" type="button"
onClick={handleDeleteConfirm} onClick={handleDeleteConfirm}
disabled={isDeleting} disabled={isDeleting}
className="h-[40px] px-4 rounded-[8px] bg-[#fef2f2] text-[16px] font-semibold leading-normal text-[var(--color-error)] hover:bg-[#fae6e6] cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="bg-[#f64c4c] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#e63939] cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
{isDeleting ? '삭제 중...' : '삭제하기'} <p className="text-[16px] font-semibold leading-[1.5] text-white text-center whitespace-pre">
{isDeleting ? '삭제 중...' : '삭제'}
</p>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </>
); );
} }

View File

@@ -26,13 +26,16 @@ export default function LessonEditPage() {
const [courseVideoCount, setCourseVideoCount] = useState(0); const [courseVideoCount, setCourseVideoCount] = useState(0);
const [courseVideoFiles, setCourseVideoFiles] = useState<string[]>([]); const [courseVideoFiles, setCourseVideoFiles] = useState<string[]>([]);
const [courseVideoFileObjects, setCourseVideoFileObjects] = useState<File[]>([]); const [courseVideoFileObjects, setCourseVideoFileObjects] = useState<File[]>([]);
const [courseVideoFileKeys, setCourseVideoFileKeys] = useState<string[]>([]);
const [existingVideoFiles, setExistingVideoFiles] = useState<Array<{fileName: string, fileKey?: string, url?: string}>>([]); const [existingVideoFiles, setExistingVideoFiles] = useState<Array<{fileName: string, fileKey?: string, url?: string}>>([]);
const [vrContentCount, setVrContentCount] = useState(0); const [vrContentCount, setVrContentCount] = useState(0);
const [vrContentFiles, setVrContentFiles] = useState<string[]>([]); const [vrContentFiles, setVrContentFiles] = useState<string[]>([]);
const [vrContentFileObjects, setVrContentFileObjects] = useState<File[]>([]); const [vrContentFileObjects, setVrContentFileObjects] = useState<File[]>([]);
const [vrContentFileKeys, setVrContentFileKeys] = useState<string[]>([]);
const [existingVrFiles, setExistingVrFiles] = useState<Array<{fileName: string, fileKey?: string, url?: string}>>([]); const [existingVrFiles, setExistingVrFiles] = useState<Array<{fileName: string, fileKey?: string, url?: string}>>([]);
const [questionFileCount, setQuestionFileCount] = useState(0); const [questionFileCount, setQuestionFileCount] = useState(0);
const [questionFileObject, setQuestionFileObject] = useState<File | null>(null); const [questionFileObject, setQuestionFileObject] = useState<File | null>(null);
const [questionFileKey, setQuestionFileKey] = useState<string | null>(null);
const [existingQuestionFile, setExistingQuestionFile] = useState<{fileName: string, fileKey?: string} | null>(null); const [existingQuestionFile, setExistingQuestionFile] = useState<{fileName: string, fileKey?: string} | null>(null);
// 원본 데이터 저장 (변경사항 비교용) // 원본 데이터 저장 (변경사항 비교용)
@@ -271,61 +274,41 @@ export default function LessonEditPage() {
return; return;
} }
// 새로 업로드된 파일들 처리 // 이미 업로드된 fileKey 배열 사용
let videoUrl: string | undefined; let videoUrl: string | undefined;
let webglUrl: string | undefined; let webglUrl: string | undefined;
let csvUrl: string | undefined; let csvUrl: string | undefined;
// 강좌 영상 업로드 (새 파일이 있는 경우) // 강좌 영상 fileKey (새로 업로드된 파일이 있는 경우)
if (courseVideoFileObjects.length > 0) { if (courseVideoFileKeys.length > 0) {
try { videoUrl = courseVideoFileKeys[0]; // 첫 번째 fileKey 사용
const uploadResponse = await apiService.uploadFiles(courseVideoFileObjects); // TODO: API가 배열을 받는 경우 courseVideoFileKeys 배열 전체를 저장해야 함
// 다중 파일 업로드 응답 처리 (실제 API 응답 구조에 맞게 조정 필요)
if (uploadResponse.data && uploadResponse.data.length > 0) {
videoUrl = uploadResponse.data[0].url || uploadResponse.data[0].fileKey;
}
} catch (error) {
console.error('강좌 영상 업로드 실패:', error);
throw new Error('강좌 영상 업로드에 실패했습니다.');
}
} else if (existingVideoFiles.length > 0 && existingVideoFiles[0].url) { } else if (existingVideoFiles.length > 0 && existingVideoFiles[0].url) {
// 기존 파일 URL 유지 // 기존 파일 URL 유지
videoUrl = existingVideoFiles[0].url; videoUrl = existingVideoFiles[0].url;
} else if (existingVideoFiles.length > 0 && existingVideoFiles[0].fileKey) {
// 기존 파일 fileKey 사용
videoUrl = existingVideoFiles[0].fileKey;
} }
// VR 콘텐츠 업로드 (새 파일이 있는 경우) // VR 콘텐츠 fileKey (새로 업로드된 파일이 있는 경우)
if (vrContentFileObjects.length > 0) { if (vrContentFileKeys.length > 0) {
try { webglUrl = vrContentFileKeys[0]; // 첫 번째 fileKey 사용
const uploadResponse = await apiService.uploadFiles(vrContentFileObjects); // TODO: API가 배열을 받는 경우 vrContentFileKeys 배열 전체를 저장해야 함
if (uploadResponse.data && uploadResponse.data.length > 0) {
webglUrl = uploadResponse.data[0].url || uploadResponse.data[0].fileKey;
}
} catch (error) {
console.error('VR 콘텐츠 업로드 실패:', error);
throw new Error('VR 콘텐츠 업로드에 실패했습니다.');
}
} else if (existingVrFiles.length > 0 && existingVrFiles[0].url) { } else if (existingVrFiles.length > 0 && existingVrFiles[0].url) {
// 기존 파일 URL 유지 // 기존 파일 URL 유지
webglUrl = existingVrFiles[0].url; webglUrl = existingVrFiles[0].url;
} else if (existingVrFiles.length > 0 && existingVrFiles[0].fileKey) {
// 기존 파일 fileKey 사용
webglUrl = existingVrFiles[0].fileKey;
} }
// 학습 평가 문제 업로드 (새 파일이 있는 경우) // 학습 평가 문제 fileKey (새로 업로드된 파일이 있는 경우)
if (questionFileObject) { if (questionFileKey) {
try { csvUrl = questionFileKey;
const uploadResponse = await apiService.uploadFile(questionFileObject);
const fileKey = uploadResponse.data?.key || uploadResponse.data?.fileKey || uploadResponse.data?.csvKey;
if (fileKey) {
csvUrl = `csv/${fileKey}`;
} else if (uploadResponse.data?.url) {
csvUrl = uploadResponse.data.url;
}
} catch (error) {
console.error('학습 평가 문제 업로드 실패:', error);
throw new Error('학습 평가 문제 업로드에 실패했습니다.');
}
} else if (existingQuestionFile?.fileKey) { } else if (existingQuestionFile?.fileKey) {
// 기존 파일 URL 유지 // 기존 파일 fileKey 사용
csvUrl = `csv/${existingQuestionFile.fileKey}`; csvUrl = existingQuestionFile.fileKey;
} else if (originalData.csvUrl) { } else if (originalData.csvUrl) {
// 원본 데이터의 csvUrl 유지 // 원본 데이터의 csvUrl 유지
csvUrl = originalData.csvUrl; csvUrl = originalData.csvUrl;
@@ -634,36 +617,74 @@ export default function LessonEditPage() {
if (!files) return; if (!files) return;
const MAX_SIZE = 30 * 1024 * 1024; // 30MB const MAX_SIZE = 30 * 1024 * 1024; // 30MB
const MAX_COUNT = 10; // 최대 10개
const validFiles: File[] = []; const validFiles: File[] = [];
const oversizedFiles: string[] = []; const oversizedFiles: string[] = [];
const invalidTypeFiles: string[] = [];
Array.from(files).forEach((file) => { Array.from(files).forEach((file) => {
// mp4 파일인지 확인
if (!file.name.toLowerCase().endsWith('.mp4')) { if (!file.name.toLowerCase().endsWith('.mp4')) {
invalidTypeFiles.push(file.name);
return; return;
} }
if (file.size < MAX_SIZE) { // 각 파일이 30MB 이하인지 검사
if (file.size <= MAX_SIZE) {
validFiles.push(file); validFiles.push(file);
} else { } else {
oversizedFiles.push(file.name); oversizedFiles.push(file.name);
} }
}); });
if (oversizedFiles.length > 0) { // 파일 타입 오류
alert(`다음 파일은 30MB 미만이어야 합니다:\n${oversizedFiles.join('\n')}`); if (invalidTypeFiles.length > 0) {
alert(`다음 파일은 MP4 형식만 가능합니다:\n${invalidTypeFiles.join('\n')}`);
} }
if (existingVideoFiles.length + courseVideoFiles.length + validFiles.length > 10) { // 30MB 초과 파일이 있으면 알림
alert('강좌 영상은 최대 10개까지 첨부할 수 있습니다.'); if (oversizedFiles.length > 0) {
alert(`다음 파일은 30MB 이하여야 합니다:\n${oversizedFiles.join('\n')}`);
}
// 파일 개수 제한 확인
const totalCount = existingVideoFiles.length + courseVideoFiles.length + validFiles.length;
if (totalCount > MAX_COUNT) {
const currentCount = existingVideoFiles.length + courseVideoFiles.length;
const availableCount = MAX_COUNT - currentCount;
alert(`강좌 영상은 최대 ${MAX_COUNT}개까지 첨부할 수 있습니다. (현재 ${currentCount}개, 추가 가능 ${availableCount > 0 ? availableCount : 0}개)`);
e.target.value = '';
return;
}
// 30MB 초과 파일이나 잘못된 타입 파일만 있는 경우 중단
if (validFiles.length === 0 && (oversizedFiles.length > 0 || invalidTypeFiles.length > 0)) {
e.target.value = ''; e.target.value = '';
return; return;
} }
if (validFiles.length > 0) { if (validFiles.length > 0) {
try { try {
await apiService.uploadFiles(validFiles); // 다중 파일 업로드
const uploadResponse = await apiService.uploadFiles(validFiles);
// 응답에서 fileKey 배열 추출
const fileKeys: string[] = [];
if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results)) {
uploadResponse.data.results.forEach((result: any) => {
if (result.ok && result.fileKey) {
fileKeys.push(result.fileKey);
}
});
}
if (fileKeys.length > 0) {
setCourseVideoFiles(prev => [...prev, ...validFiles.map(f => f.name)]); setCourseVideoFiles(prev => [...prev, ...validFiles.map(f => f.name)]);
setCourseVideoFileObjects(prev => [...prev, ...validFiles]); setCourseVideoFileObjects(prev => [...prev, ...validFiles]);
setCourseVideoFileKeys(prev => [...prev, ...fileKeys]);
setCourseVideoCount(prev => prev + validFiles.length); setCourseVideoCount(prev => prev + validFiles.length);
} else {
throw new Error('파일 업로드는 완료되었지만 fileKey를 받지 못했습니다.');
}
} catch (error) { } catch (error) {
console.error('강좌 영상 업로드 실패:', error); console.error('강좌 영상 업로드 실패:', error);
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.'); alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
@@ -717,6 +738,7 @@ export default function LessonEditPage() {
onClick={() => { onClick={() => {
setCourseVideoFiles(prev => prev.filter((_, i) => i !== index)); setCourseVideoFiles(prev => prev.filter((_, i) => i !== index));
setCourseVideoFileObjects(prev => prev.filter((_, i) => i !== index)); setCourseVideoFileObjects(prev => prev.filter((_, i) => i !== index));
setCourseVideoFileKeys(prev => prev.filter((_, i) => i !== index));
setCourseVideoCount(prev => prev - 1); setCourseVideoCount(prev => prev - 1);
}} }}
className="size-[16px] flex items-center justify-center cursor-pointer hover:opacity-70 transition-opacity shrink-0" className="size-[16px] flex items-center justify-center cursor-pointer hover:opacity-70 transition-opacity shrink-0"
@@ -754,36 +776,74 @@ export default function LessonEditPage() {
if (!files) return; if (!files) return;
const MAX_SIZE = 30 * 1024 * 1024; // 30MB const MAX_SIZE = 30 * 1024 * 1024; // 30MB
const MAX_COUNT = 10; // 최대 10개
const validFiles: File[] = []; const validFiles: File[] = [];
const oversizedFiles: string[] = []; const oversizedFiles: string[] = [];
const invalidTypeFiles: string[] = [];
Array.from(files).forEach((file) => { Array.from(files).forEach((file) => {
// zip 파일인지 확인
if (!file.name.toLowerCase().endsWith('.zip')) { if (!file.name.toLowerCase().endsWith('.zip')) {
invalidTypeFiles.push(file.name);
return; return;
} }
if (file.size < MAX_SIZE) { // 각 파일이 30MB 이하인지 검사
if (file.size <= MAX_SIZE) {
validFiles.push(file); validFiles.push(file);
} else { } else {
oversizedFiles.push(file.name); oversizedFiles.push(file.name);
} }
}); });
if (oversizedFiles.length > 0) { // 파일 타입 오류
alert(`다음 파일은 30MB 미만이어야 합니다:\n${oversizedFiles.join('\n')}`); if (invalidTypeFiles.length > 0) {
alert(`다음 파일은 ZIP 형식만 가능합니다:\n${invalidTypeFiles.join('\n')}`);
} }
if (existingVrFiles.length + vrContentFiles.length + validFiles.length > 10) { // 30MB 초과 파일이 있으면 알림
alert('VR 콘텐츠는 최대 10개까지 첨부할 수 있습니다.'); if (oversizedFiles.length > 0) {
alert(`다음 파일은 30MB 이하여야 합니다:\n${oversizedFiles.join('\n')}`);
}
// 파일 개수 제한 확인
const totalCount = existingVrFiles.length + vrContentFiles.length + validFiles.length;
if (totalCount > MAX_COUNT) {
const currentCount = existingVrFiles.length + vrContentFiles.length;
const availableCount = MAX_COUNT - currentCount;
alert(`VR 콘텐츠는 최대 ${MAX_COUNT}개까지 첨부할 수 있습니다. (현재 ${currentCount}개, 추가 가능 ${availableCount > 0 ? availableCount : 0}개)`);
e.target.value = '';
return;
}
// 30MB 초과 파일이나 잘못된 타입 파일만 있는 경우 중단
if (validFiles.length === 0 && (oversizedFiles.length > 0 || invalidTypeFiles.length > 0)) {
e.target.value = ''; e.target.value = '';
return; return;
} }
if (validFiles.length > 0) { if (validFiles.length > 0) {
try { try {
await apiService.uploadFiles(validFiles); // 다중 파일 업로드
const uploadResponse = await apiService.uploadFiles(validFiles);
// 응답에서 fileKey 배열 추출
const fileKeys: string[] = [];
if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results)) {
uploadResponse.data.results.forEach((result: any) => {
if (result.ok && result.fileKey) {
fileKeys.push(result.fileKey);
}
});
}
if (fileKeys.length > 0) {
setVrContentFiles(prev => [...prev, ...validFiles.map(f => f.name)]); setVrContentFiles(prev => [...prev, ...validFiles.map(f => f.name)]);
setVrContentFileObjects(prev => [...prev, ...validFiles]); setVrContentFileObjects(prev => [...prev, ...validFiles]);
setVrContentFileKeys(prev => [...prev, ...fileKeys]);
setVrContentCount(prev => prev + validFiles.length); setVrContentCount(prev => prev + validFiles.length);
} else {
throw new Error('파일 업로드는 완료되었지만 fileKey를 받지 못했습니다.');
}
} catch (error) { } catch (error) {
console.error('VR 콘텐츠 업로드 실패:', error); console.error('VR 콘텐츠 업로드 실패:', error);
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.'); alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
@@ -837,6 +897,7 @@ export default function LessonEditPage() {
onClick={() => { onClick={() => {
setVrContentFiles(prev => prev.filter((_, i) => i !== index)); setVrContentFiles(prev => prev.filter((_, i) => i !== index));
setVrContentFileObjects(prev => prev.filter((_, i) => i !== index)); setVrContentFileObjects(prev => prev.filter((_, i) => i !== index));
setVrContentFileKeys(prev => prev.filter((_, i) => i !== index));
setVrContentCount(prev => prev - 1); setVrContentCount(prev => prev - 1);
}} }}
className="size-[16px] flex items-center justify-center cursor-pointer hover:opacity-70 transition-opacity shrink-0" className="size-[16px] flex items-center justify-center cursor-pointer hover:opacity-70 transition-opacity shrink-0"
@@ -876,10 +937,30 @@ export default function LessonEditPage() {
const file = files[0]; const file = files[0];
if (file.name.toLowerCase().endsWith('.csv')) { if (file.name.toLowerCase().endsWith('.csv')) {
try { try {
await apiService.uploadFile(file); // 단일 파일 업로드
const uploadResponse = await apiService.uploadFile(file);
// 응답에서 fileKey 추출
let fileKey: string | null = null;
if (uploadResponse.data?.fileKey) {
fileKey = uploadResponse.data.fileKey;
} else if (uploadResponse.data?.key) {
fileKey = uploadResponse.data.key;
} else if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results) && uploadResponse.data.results.length > 0) {
const result = uploadResponse.data.results[0];
if (result.ok && result.fileKey) {
fileKey = result.fileKey;
}
}
if (fileKey) {
setQuestionFileObject(file); setQuestionFileObject(file);
setQuestionFileKey(fileKey);
setQuestionFileCount(1); setQuestionFileCount(1);
setExistingQuestionFile(null); setExistingQuestionFile(null);
} else {
throw new Error('파일 업로드는 완료되었지만 fileKey를 받지 못했습니다.');
}
} catch (error) { } catch (error) {
console.error('학습 평가 문제 업로드 실패:', error); console.error('학습 평가 문제 업로드 실패:', error);
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.'); alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
@@ -900,6 +981,7 @@ export default function LessonEditPage() {
type="button" type="button"
onClick={() => { onClick={() => {
setQuestionFileObject(null); setQuestionFileObject(null);
setQuestionFileKey(null);
setExistingQuestionFile(null); setExistingQuestionFile(null);
setQuestionFileCount(0); setQuestionFileCount(0);
}} }}

View File

@@ -6,6 +6,7 @@ import AdminSidebar from '@/app/components/AdminSidebar';
import BackArrowSvg from '@/app/svgs/backarrow'; import BackArrowSvg from '@/app/svgs/backarrow';
import DownloadIcon from '@/app/svgs/downloadicon'; import DownloadIcon from '@/app/svgs/downloadicon';
import apiService from '@/app/lib/apiService'; import apiService from '@/app/lib/apiService';
import { getCourses } from '@/app/admin/courses/mockData';
type VideoFile = { type VideoFile = {
id: string; id: string;
@@ -49,6 +50,9 @@ export default function AdminCourseDetailPage() {
const [course, setCourse] = useState<CourseDetail | null>(null); const [course, setCourse] = useState<CourseDetail | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [showToast, setShowToast] = useState(false);
useEffect(() => { useEffect(() => {
const fetchCourse = async () => { const fetchCourse = async () => {
@@ -90,20 +94,8 @@ export default function AdminCourseDetailPage() {
throw new Error('강좌를 찾을 수 없습니다.'); throw new Error('강좌를 찾을 수 없습니다.');
} }
// 첨부파일 정보 구성 // 첨부파일 정보 구성 (나중에 videoFiles와 vrFiles 길이로 업데이트됨)
const attachmentParts: string[] = []; // 이 부분은 videoFiles와 vrFiles가 구성된 후에 업데이트됩니다
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'; let thumbnail = '/imgs/talk.png';
@@ -118,28 +110,87 @@ export default function AdminCourseDetailPage() {
} }
} }
// 교육 과정명 가져오기 // 교육 과정명 가져오기 - AdminLessonsPage와 동일한 방식
let courseName = ''; let courseName = '';
if (data.subjectId || data.subject_id) { const subjectId = data.subjectId || data.subject_id;
if (subjectId) {
try { try {
const subjectsResponse = await apiService.getSubjects(); const courses = await getCourses();
const subjects = Array.isArray(subjectsResponse.data) const foundCourse = courses.find(course => course.id === String(subjectId));
? subjectsResponse.data courseName = foundCourse?.courseName || '';
: subjectsResponse.data?.items || subjectsResponse.data?.subjects || [];
const subject = subjects.find((s: any) => // 교육과정명을 찾지 못한 경우 fallback
String(s.id || s.subjectId) === String(data.subjectId || data.subject_id) if (!courseName) {
); courseName = data.subjectName || data.courseName || '';
courseName = subject?.courseName || subject?.name || subject?.subjectName || ''; }
} catch (err) { } catch (err) {
console.error('교육 과정명 조회 실패:', err); console.error('교육 과정명 조회 실패:', err);
// 에러 발생 시 fallback
courseName = data.subjectName || data.courseName || '';
} }
} }
// 학습 목표를 여러 줄로 분리 // 학습 목표를 여러 줄로 분리
const goalLines = (data.objective || data.goal || '').split('\n').filter((line: string) => line.trim()); const goalLines = (data.objective || data.goal || '').split('\n').filter((line: string) => line.trim());
// 비디오 파일 목록 구성 // 비디오 파일 목록 구성 - fileKey 배열에서 파일 조회
const videoFiles: VideoFile[] = []; const videoFiles: VideoFile[] = [];
// fileKey 배열 수집
const videoFileKeys: string[] = [];
// videoUrl이 배열인 경우 처리
if (data.videoUrl && Array.isArray(data.videoUrl)) {
// videoUrl 배열의 각 항목을 fileKey로 사용
videoFileKeys.push(...data.videoUrl);
} else if (data.videoKeys && Array.isArray(data.videoKeys)) {
videoFileKeys.push(...data.videoKeys);
} else if (data.videoFileKeys && Array.isArray(data.videoFileKeys)) {
videoFileKeys.push(...data.videoFileKeys);
} else if (data.videoFiles && Array.isArray(data.videoFiles)) {
// videoFiles 배열에서 fileKey 추출
data.videoFiles.forEach((vf: any) => {
if (vf.fileKey || vf.key) {
videoFileKeys.push(vf.fileKey || vf.key);
}
});
} else if (data.videoKey) {
// 단일 fileKey
videoFileKeys.push(data.videoKey);
} else if (data.videoUrl && typeof data.videoUrl === 'string') {
// 단일 videoUrl을 fileKey로 사용 (하위 호환성)
videoFileKeys.push(data.videoUrl);
}
// fileKey 배열이 있으면 각각에 대해 파일 조회 API 호출
if (videoFileKeys.length > 0) {
const videoFilePromises = videoFileKeys.map(async (fileKey, index) => {
try {
const fileUrl = await apiService.getFile(fileKey);
return {
id: String(index + 1),
fileName: data.videoFiles?.[index]?.fileName || data.videoFiles?.[index]?.name || `강좌영상_${index + 1}.mp4`,
fileSize: data.videoFiles?.[index]?.fileSize || '796.35 KB',
fileKey: fileKey,
url: fileUrl || undefined,
};
} catch (err) {
console.error(`비디오 파일 ${index + 1} 조회 실패:`, err);
// 조회 실패해도 fileKey는 저장
return {
id: String(index + 1),
fileName: data.videoFiles?.[index]?.fileName || data.videoFiles?.[index]?.name || `강좌영상_${index + 1}.mp4`,
fileSize: data.videoFiles?.[index]?.fileSize || '796.35 KB',
fileKey: fileKey,
url: undefined,
};
}
});
const resolvedVideoFiles = await Promise.all(videoFilePromises);
videoFiles.push(...resolvedVideoFiles);
} else {
// fileKey 배열이 없으면 기존 방식대로 처리
if (data.videoUrl) { if (data.videoUrl) {
videoFiles.push({ videoFiles.push({
id: '1', id: '1',
@@ -149,7 +200,7 @@ export default function AdminCourseDetailPage() {
url: data.videoUrl, url: data.videoUrl,
}); });
} }
// 여러 비디오 파일이 있는 경우 처리 // 여러 비디오 파일이 있는 경우 처리 (기존 로직 유지)
if (data.videoFiles && Array.isArray(data.videoFiles)) { if (data.videoFiles && Array.isArray(data.videoFiles)) {
data.videoFiles.forEach((vf: any, index: number) => { data.videoFiles.forEach((vf: any, index: number) => {
videoFiles.push({ videoFiles.push({
@@ -161,10 +212,76 @@ export default function AdminCourseDetailPage() {
}); });
}); });
} }
}
// VR 파일 목록 구성 // VR 파일 목록 구성 - fileKey 배열에서 파일 조회
const vrFiles: VrFile[] = []; const vrFiles: VrFile[] = [];
if (data.webglUrl || data.vrUrl) {
// fileKey 배열 수집
const vrFileKeys: string[] = [];
// webglUrl이 배열인 경우 처리
if (data.webglUrl && Array.isArray(data.webglUrl)) {
// webglUrl 배열의 각 항목을 fileKey로 사용
vrFileKeys.push(...data.webglUrl);
} else if (data.vrUrl && Array.isArray(data.vrUrl)) {
// vrUrl 배열의 각 항목을 fileKey로 사용
vrFileKeys.push(...data.vrUrl);
} else if (data.webglKeys && Array.isArray(data.webglKeys)) {
vrFileKeys.push(...data.webglKeys);
} else if (data.vrKeys && Array.isArray(data.vrKeys)) {
vrFileKeys.push(...data.vrKeys);
} else if (data.webglFileKeys && Array.isArray(data.webglFileKeys)) {
vrFileKeys.push(...data.webglFileKeys);
} else if (data.vrFileKeys && Array.isArray(data.vrFileKeys)) {
vrFileKeys.push(...data.vrFileKeys);
} else if (data.webglFiles && Array.isArray(data.webglFiles)) {
// webglFiles 배열에서 fileKey 추출
data.webglFiles.forEach((vf: any) => {
if (vf.fileKey || vf.key) {
vrFileKeys.push(vf.fileKey || vf.key);
}
});
} else if (data.webglKey || data.vrKey) {
// 단일 fileKey
vrFileKeys.push(data.webglKey || data.vrKey);
} else if (data.webglUrl && typeof data.webglUrl === 'string') {
// 단일 webglUrl을 fileKey로 사용 (하위 호환성)
vrFileKeys.push(data.webglUrl);
} else if (data.vrUrl && typeof data.vrUrl === 'string') {
// 단일 vrUrl을 fileKey로 사용 (하위 호환성)
vrFileKeys.push(data.vrUrl);
}
// fileKey 배열이 있으면 각각에 대해 파일 조회 API 호출
if (vrFileKeys.length > 0) {
const vrFilePromises = vrFileKeys.map(async (fileKey, index) => {
try {
const fileUrl = await apiService.getFile(fileKey);
return {
id: String(index + 1),
fileName: data.webglFiles?.[index]?.fileName || data.webglFiles?.[index]?.name || data.vrFileName || data.webglFileName || `VR_콘텐츠_${index + 1}.zip`,
fileSize: data.webglFiles?.[index]?.fileSize || '796.35 KB',
fileKey: fileKey,
url: fileUrl || undefined,
};
} catch (err) {
console.error(`VR 파일 ${index + 1} 조회 실패:`, err);
// 조회 실패해도 fileKey는 저장
return {
id: String(index + 1),
fileName: data.webglFiles?.[index]?.fileName || data.webglFiles?.[index]?.name || data.vrFileName || data.webglFileName || `VR_콘텐츠_${index + 1}.zip`,
fileSize: data.webglFiles?.[index]?.fileSize || '796.35 KB',
fileKey: fileKey,
url: undefined,
};
}
});
const resolvedVrFiles = await Promise.all(vrFilePromises);
vrFiles.push(...resolvedVrFiles);
} else if (data.webglUrl || data.vrUrl) {
// fileKey 배열이 없고 webglUrl이 있으면 기존 방식대로 처리
vrFiles.push({ vrFiles.push({
id: '1', id: '1',
fileName: data.vrFileName || data.webglFileName || 'VR_콘텐츠.zip', fileName: data.vrFileName || data.webglFileName || 'VR_콘텐츠.zip',
@@ -217,6 +334,16 @@ export default function AdminCourseDetailPage() {
fetchCourse(); fetchCourse();
}, [params?.id]); }, [params?.id]);
// 토스트 자동 닫기
useEffect(() => {
if (showToast) {
const timer = setTimeout(() => {
setShowToast(false);
}, 3000);
return () => clearTimeout(timer);
}
}, [showToast]);
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen flex flex-col bg-white"> <div className="min-h-screen flex flex-col bg-white">
@@ -324,6 +451,38 @@ export default function AdminCourseDetailPage() {
} }
}; };
const handleDeleteClick = () => {
setIsDeleteModalOpen(true);
};
const handleDeleteCancel = () => {
setIsDeleteModalOpen(false);
};
const handleDeleteConfirm = async () => {
if (!params?.id || isDeleting) return;
setIsDeleting(true);
try {
const response = await apiService.deleteLecture(params.id as string);
// 응답 메시지 확인
if (response.data?.message === '삭제 완료') {
// 토스트 팝업 표시
setShowToast(true);
// 강좌 목록으로 이동
router.push('/admin/lessons');
} else {
// 예상치 못한 응답
alert('강좌 삭제에 실패했습니다.');
setIsDeleting(false);
}
} catch (err) {
console.error('강좌 삭제 실패:', err);
alert('강좌 삭제에 실패했습니다.');
setIsDeleting(false);
}
};
const goalLines = course.goal.split('\n').filter(line => line.trim()); const goalLines = course.goal.split('\n').filter(line => line.trim());
return ( return (
@@ -582,7 +741,8 @@ export default function AdminCourseDetailPage() {
<div className="flex gap-[12px] items-center justify-end"> <div className="flex gap-[12px] items-center justify-end">
<button <button
type="button" type="button"
className="bg-[#fef2f2] h-[48px] rounded-[10px] px-2 flex items-center justify-center min-w-[80px]" onClick={handleDeleteClick}
className="bg-[#fef2f2] h-[48px] rounded-[10px] px-2 flex items-center justify-center min-w-[80px] cursor-pointer hover:bg-[#fae6e6] transition-colors"
> >
<p className="text-[16px] font-semibold leading-[1.5] text-[#f64c4c]"></p> <p className="text-[16px] font-semibold leading-[1.5] text-[#f64c4c]"></p>
</button> </button>
@@ -621,6 +781,70 @@ export default function AdminCourseDetailPage() {
</main> </main>
</div> </div>
</div> </div>
{/* 삭제 확인 모달 */}
{isDeleteModalOpen && (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={handleDeleteCancel}
aria-hidden="true"
/>
<div className="relative z-10 bg-white rounded-[8px] p-[24px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] flex flex-col gap-[32px] items-end justify-end min-w-[500px]">
<div className="flex flex-col gap-[16px] items-start justify-center w-full">
<div className="flex gap-[8px] items-start w-full">
<p className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">
?
</p>
</div>
<div className="flex gap-[8px] items-start w-full">
<div className="text-[15px] font-normal leading-[1.5] text-[#4c5561]">
<p className="mb-0"> .</p>
<p> ?</p>
</div>
</div>
</div>
<div className="flex gap-[8px] items-center justify-end shrink-0">
<button
type="button"
onClick={handleDeleteCancel}
className="bg-[#f1f3f5] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#e9ecef] cursor-pointer transition-colors"
>
<p className="text-[16px] font-semibold leading-[1.5] text-[#4c5561] text-center whitespace-pre">
</p>
</button>
<button
type="button"
onClick={handleDeleteConfirm}
disabled={isDeleting}
className="bg-[#f64c4c] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#e63939] cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<p className="text-[16px] font-semibold leading-[1.5] text-white text-center whitespace-pre">
{isDeleting ? '삭제 중...' : '삭제'}
</p>
</button>
</div>
</div>
</div>
)}
{/* 삭제 완료 토스트 */}
{showToast && (
<div className="fixed right-[60px] bottom-[60px] z-50">
<div className="bg-white border border-[#dee1e6] rounded-[8px] p-4 min-w-[360px] flex gap-[10px] items-center">
<div className="relative shrink-0 w-[16.667px] h-[16.667px]">
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8.5" cy="8.5" r="8.5" fill="#384FBF"/>
<path d="M5.5 8.5L7.5 10.5L11.5 6.5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] text-nowrap">
.
</p>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -39,11 +39,14 @@ export default function AdminLessonsPage() {
const [courseVideoCount, setCourseVideoCount] = useState(0); const [courseVideoCount, setCourseVideoCount] = useState(0);
const [courseVideoFiles, setCourseVideoFiles] = useState<string[]>([]); const [courseVideoFiles, setCourseVideoFiles] = useState<string[]>([]);
const [courseVideoFileObjects, setCourseVideoFileObjects] = useState<File[]>([]); const [courseVideoFileObjects, setCourseVideoFileObjects] = useState<File[]>([]);
const [courseVideoFileKeys, setCourseVideoFileKeys] = useState<string[]>([]);
const [vrContentCount, setVrContentCount] = useState(0); const [vrContentCount, setVrContentCount] = useState(0);
const [vrContentFiles, setVrContentFiles] = useState<string[]>([]); const [vrContentFiles, setVrContentFiles] = useState<string[]>([]);
const [vrContentFileObjects, setVrContentFileObjects] = useState<File[]>([]); const [vrContentFileObjects, setVrContentFileObjects] = useState<File[]>([]);
const [vrContentFileKeys, setVrContentFileKeys] = useState<string[]>([]);
const [questionFileCount, setQuestionFileCount] = useState(0); const [questionFileCount, setQuestionFileCount] = useState(0);
const [questionFileObject, setQuestionFileObject] = useState<File | null>(null); const [questionFileObject, setQuestionFileObject] = useState<File | null>(null);
const [questionFileKey, setQuestionFileKey] = useState<string | null>(null);
// 에러 상태 // 에러 상태
const [errors, setErrors] = useState<{ const [errors, setErrors] = useState<{
@@ -234,11 +237,14 @@ export default function AdminLessonsPage() {
setCourseVideoCount(0); setCourseVideoCount(0);
setCourseVideoFiles([]); setCourseVideoFiles([]);
setCourseVideoFileObjects([]); setCourseVideoFileObjects([]);
setCourseVideoFileKeys([]);
setVrContentCount(0); setVrContentCount(0);
setVrContentFiles([]); setVrContentFiles([]);
setVrContentFileObjects([]); setVrContentFileObjects([]);
setVrContentFileKeys([]);
setQuestionFileCount(0); setQuestionFileCount(0);
setQuestionFileObject(null); setQuestionFileObject(null);
setQuestionFileKey(null);
setErrors({}); setErrors({});
}; };
@@ -274,72 +280,27 @@ export default function AdminLessonsPage() {
setErrors({}); setErrors({});
try { try {
// 파일 업로드 및 키 추출 // 이미 업로드된 fileKey 배열 사용
let videoUrl: string | undefined; const videoFileKeys = courseVideoFileKeys;
let webglUrl: string | undefined; const vrFileKeys = vrContentFileKeys;
const csvFileKey = questionFileKey;
// 강좌 영상 fileKey 배열 (모든 fileKey를 배열로 저장)
let videoUrl: string[] | undefined;
if (videoFileKeys.length > 0) {
videoUrl = videoFileKeys; // 모든 fileKey를 배열로 저장
}
// VR 콘텐츠 fileKey 배열 (모든 fileKey를 배열로 저장)
let webglUrl: string[] | undefined;
if (vrFileKeys.length > 0) {
webglUrl = vrFileKeys; // 모든 fileKey를 배열로 저장
}
// 학습 평가 문제 fileKey
let csvKey: string | undefined; let csvKey: string | undefined;
if (csvFileKey) {
// 강좌 영상 업로드 (첫 번째 파일만 사용) csvKey = csvFileKey;
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 구성 // API 요청 body 구성
@@ -347,8 +308,8 @@ export default function AdminLessonsPage() {
subjectId: number; subjectId: number;
title: string; title: string;
objective: string; objective: string;
videoUrl?: string; videoUrl?: string | string[];
webglUrl?: string; webglUrl?: string | string[];
csvKey?: string; csvKey?: string;
} = { } = {
subjectId: Number(selectedCourse), subjectId: Number(selectedCourse),
@@ -357,10 +318,10 @@ export default function AdminLessonsPage() {
}; };
// 선택적 필드 추가 // 선택적 필드 추가
if (videoUrl) { if (videoUrl && videoUrl.length > 0) {
requestBody.videoUrl = videoUrl; requestBody.videoUrl = videoUrl;
} }
if (webglUrl) { if (webglUrl && webglUrl.length > 0) {
requestBody.webglUrl = webglUrl; requestBody.webglUrl = webglUrl;
} }
if (csvKey) { if (csvKey) {
@@ -416,11 +377,14 @@ export default function AdminLessonsPage() {
setCourseVideoCount(0); setCourseVideoCount(0);
setCourseVideoFiles([]); setCourseVideoFiles([]);
setCourseVideoFileObjects([]); setCourseVideoFileObjects([]);
setCourseVideoFileKeys([]);
setVrContentCount(0); setVrContentCount(0);
setVrContentFiles([]); setVrContentFiles([]);
setVrContentFileObjects([]); setVrContentFileObjects([]);
setVrContentFileKeys([]);
setQuestionFileCount(0); setQuestionFileCount(0);
setQuestionFileObject(null); setQuestionFileObject(null);
setQuestionFileKey(null);
// 토스트 팝업 표시 // 토스트 팝업 표시
setShowToast(true); setShowToast(true);
@@ -628,37 +592,56 @@ export default function AdminLessonsPage() {
<input <input
type="file" type="file"
multiple multiple
accept=".mp4,video/mp4" accept=".mp4,.avi,.mov,.wmv,.flv,.webm,.mkv,video/mp4,video/avi,video/quicktime,video/x-ms-wmv,video/x-flv,video/webm,video/x-matroska"
className="hidden" className="hidden"
onChange={async (e) => { onChange={async (e) => {
const files = e.target.files; const files = e.target.files;
if (!files) return; if (!files) return;
const MAX_SIZE = 30 * 1024 * 1024; // 30MB const MAX_SIZE = 30 * 1024 * 1024; // 30MB
const MAX_COUNT = 10; // 최대 10개
const validFiles: File[] = []; const validFiles: File[] = [];
const oversizedFiles: string[] = []; const oversizedFiles: string[] = [];
const invalidTypeFiles: string[] = [];
// 영상 파일 확장자 확인
const videoExtensions = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.mkv'];
Array.from(files).forEach((file) => { Array.from(files).forEach((file) => {
// mp4 파일인지 확인 const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
if (!file.name.toLowerCase().endsWith('.mp4')) { if (!videoExtensions.includes(fileExtension)) {
invalidTypeFiles.push(file.name);
return; return;
} }
// 각 파일이 30MB 미만인지 검사 // 각 파일이 30MB 이하인지 검사
if (file.size < MAX_SIZE) { if (file.size <= MAX_SIZE) {
validFiles.push(file); validFiles.push(file);
} else { } else {
oversizedFiles.push(file.name); oversizedFiles.push(file.name);
} }
}); });
// 30MB 이상인 파일이 있으면 알림 // 파일 타입 오류
if (invalidTypeFiles.length > 0) {
alert(`다음 파일은 영상 파일 형식만 가능합니다 (MP4, AVI, MOV, WMV, FLV, WEBM, MKV):\n${invalidTypeFiles.join('\n')}`);
}
// 30MB 초과 파일이 있으면 알림
if (oversizedFiles.length > 0) { if (oversizedFiles.length > 0) {
alert(`다음 파일은 30MB 미만이어야 합니다:\n${oversizedFiles.join('\n')}`); alert(`다음 파일은 30MB 이하여야 합니다:\n${oversizedFiles.join('\n')}`);
} }
// 파일 개수 제한 확인 // 파일 개수 제한 확인
if (courseVideoCount + validFiles.length > 10) { const totalCount = courseVideoCount + validFiles.length;
alert('강좌 영상은 최대 10개까지 첨부할 수 있습니다.'); if (totalCount > MAX_COUNT) {
const availableCount = MAX_COUNT - courseVideoCount;
alert(`강좌 영상은 최대 ${MAX_COUNT}개까지 첨부할 수 있습니다. (현재 ${courseVideoCount}개, 추가 가능 ${availableCount > 0 ? availableCount : 0}개)`);
e.target.value = '';
return;
}
// 30MB 초과 파일이나 잘못된 타입 파일만 있는 경우 중단
if (validFiles.length === 0 && (oversizedFiles.length > 0 || invalidTypeFiles.length > 0)) {
e.target.value = ''; e.target.value = '';
return; return;
} }
@@ -666,10 +649,26 @@ export default function AdminLessonsPage() {
if (validFiles.length > 0) { if (validFiles.length > 0) {
try { try {
// 다중 파일 업로드 // 다중 파일 업로드
await apiService.uploadFiles(validFiles); const uploadResponse = await apiService.uploadFiles(validFiles);
// 응답에서 fileKey 배열 추출
const fileKeys: string[] = [];
if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results)) {
uploadResponse.data.results.forEach((result: any) => {
if (result.ok && result.fileKey) {
fileKeys.push(result.fileKey);
}
});
}
if (fileKeys.length > 0) {
setCourseVideoFiles(prev => [...prev, ...validFiles.map(f => f.name)]); setCourseVideoFiles(prev => [...prev, ...validFiles.map(f => f.name)]);
setCourseVideoFileObjects(prev => [...prev, ...validFiles]); setCourseVideoFileObjects(prev => [...prev, ...validFiles]);
setCourseVideoFileKeys(prev => [...prev, ...fileKeys]);
setCourseVideoCount(prev => prev + validFiles.length); setCourseVideoCount(prev => prev + validFiles.length);
} else {
throw new Error('파일 업로드는 완료되었지만 fileKey를 받지 못했습니다.');
}
} catch (error) { } catch (error) {
console.error('강좌 영상 업로드 실패:', error); console.error('강좌 영상 업로드 실패:', error);
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.'); alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
@@ -703,6 +702,7 @@ export default function AdminLessonsPage() {
onClick={() => { onClick={() => {
setCourseVideoFiles(prev => prev.filter((_, i) => i !== index)); setCourseVideoFiles(prev => prev.filter((_, i) => i !== index));
setCourseVideoFileObjects(prev => prev.filter((_, i) => i !== index)); setCourseVideoFileObjects(prev => prev.filter((_, i) => i !== index));
setCourseVideoFileKeys(prev => prev.filter((_, i) => i !== index));
setCourseVideoCount(prev => prev - 1); setCourseVideoCount(prev => prev - 1);
}} }}
className="size-[16px] flex items-center justify-center cursor-pointer hover:opacity-70 transition-opacity shrink-0" className="size-[16px] flex items-center justify-center cursor-pointer hover:opacity-70 transition-opacity shrink-0"
@@ -740,30 +740,47 @@ export default function AdminLessonsPage() {
if (!files) return; if (!files) return;
const MAX_SIZE = 30 * 1024 * 1024; // 30MB const MAX_SIZE = 30 * 1024 * 1024; // 30MB
const MAX_COUNT = 10; // 최대 10개
const validFiles: File[] = []; const validFiles: File[] = [];
const oversizedFiles: string[] = []; const oversizedFiles: string[] = [];
const invalidTypeFiles: string[] = [];
Array.from(files).forEach((file) => { Array.from(files).forEach((file) => {
// zip 파일인지 확인 // ZIP 파일인지 확인
if (!file.name.toLowerCase().endsWith('.zip')) { const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
if (fileExtension !== '.zip') {
invalidTypeFiles.push(file.name);
return; return;
} }
// 각 파일이 30MB 미만인지 검사 // 각 파일이 30MB 이하인지 검사
if (file.size < MAX_SIZE) { if (file.size <= MAX_SIZE) {
validFiles.push(file); validFiles.push(file);
} else { } else {
oversizedFiles.push(file.name); oversizedFiles.push(file.name);
} }
}); });
// 30MB 이상인 파일이 있으면 알림 // 파일 타입 오류
if (invalidTypeFiles.length > 0) {
alert(`다음 파일은 ZIP 형식만 가능합니다:\n${invalidTypeFiles.join('\n')}`);
}
// 30MB 초과 파일이 있으면 알림
if (oversizedFiles.length > 0) { if (oversizedFiles.length > 0) {
alert(`다음 파일은 30MB 미만이어야 합니다:\n${oversizedFiles.join('\n')}`); alert(`다음 파일은 30MB 이하여야 합니다:\n${oversizedFiles.join('\n')}`);
} }
// 파일 개수 제한 확인 // 파일 개수 제한 확인
if (vrContentCount + validFiles.length > 10) { const totalCount = vrContentCount + validFiles.length;
alert('VR 콘텐츠는 최대 10개까지 첨부할 수 있습니다.'); if (totalCount > MAX_COUNT) {
const availableCount = MAX_COUNT - vrContentCount;
alert(`VR 콘텐츠는 최대 ${MAX_COUNT}개까지 첨부할 수 있습니다. (현재 ${vrContentCount}개, 추가 가능 ${availableCount > 0 ? availableCount : 0}개)`);
e.target.value = '';
return;
}
// 30MB 초과 파일이나 잘못된 타입 파일만 있는 경우 중단
if (validFiles.length === 0 && (oversizedFiles.length > 0 || invalidTypeFiles.length > 0)) {
e.target.value = ''; e.target.value = '';
return; return;
} }
@@ -771,10 +788,26 @@ export default function AdminLessonsPage() {
if (validFiles.length > 0) { if (validFiles.length > 0) {
try { try {
// 다중 파일 업로드 // 다중 파일 업로드
await apiService.uploadFiles(validFiles); const uploadResponse = await apiService.uploadFiles(validFiles);
// 응답에서 fileKey 배열 추출
const fileKeys: string[] = [];
if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results)) {
uploadResponse.data.results.forEach((result: any) => {
if (result.ok && result.fileKey) {
fileKeys.push(result.fileKey);
}
});
}
if (fileKeys.length > 0) {
setVrContentFiles(prev => [...prev, ...validFiles.map(f => f.name)]); setVrContentFiles(prev => [...prev, ...validFiles.map(f => f.name)]);
setVrContentFileObjects(prev => [...prev, ...validFiles]); setVrContentFileObjects(prev => [...prev, ...validFiles]);
setVrContentFileKeys(prev => [...prev, ...fileKeys]);
setVrContentCount(prev => prev + validFiles.length); setVrContentCount(prev => prev + validFiles.length);
} else {
throw new Error('파일 업로드는 완료되었지만 fileKey를 받지 못했습니다.');
}
} catch (error) { } catch (error) {
console.error('VR 콘텐츠 업로드 실패:', error); console.error('VR 콘텐츠 업로드 실패:', error);
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.'); alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
@@ -808,6 +841,7 @@ export default function AdminLessonsPage() {
onClick={() => { onClick={() => {
setVrContentFiles(prev => prev.filter((_, i) => i !== index)); setVrContentFiles(prev => prev.filter((_, i) => i !== index));
setVrContentFileObjects(prev => prev.filter((_, i) => i !== index)); setVrContentFileObjects(prev => prev.filter((_, i) => i !== index));
setVrContentFileKeys(prev => prev.filter((_, i) => i !== index));
setVrContentCount(prev => prev - 1); setVrContentCount(prev => prev - 1);
}} }}
className="size-[16px] flex items-center justify-center cursor-pointer hover:opacity-70 transition-opacity shrink-0" className="size-[16px] flex items-center justify-center cursor-pointer hover:opacity-70 transition-opacity shrink-0"
@@ -844,25 +878,59 @@ export default function AdminLessonsPage() {
<span></span> <span></span>
<input <input
type="file" type="file"
accept=".csv" accept=".csv,text/csv,application/vnd.ms-excel"
className="hidden" className="hidden"
onChange={async (e) => { onChange={async (e) => {
const files = e.target.files; const files = e.target.files;
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
const file = files[0]; const file = files[0];
// CSV 파일만 허용 // CSV 파일만 허용
if (file.name.toLowerCase().endsWith('.csv')) { if (!file.name.toLowerCase().endsWith('.csv')) {
alert('CSV 파일 형식만 첨부할 수 있습니다.');
e.target.value = '';
return;
}
try { try {
// 단일 파일 업로드 // 단일 파일 업로드
await apiService.uploadFile(file); const uploadResponse = await apiService.uploadFile(file);
// 응답에서 fileKey 추출
let fileKey: string | null = null;
if (uploadResponse.data?.fileKey) {
fileKey = uploadResponse.data.fileKey;
} else if (uploadResponse.data?.key) {
fileKey = uploadResponse.data.key;
} else if (uploadResponse.data?.id) {
fileKey = uploadResponse.data.id;
} else if (uploadResponse.data?.imageKey) {
fileKey = uploadResponse.data.imageKey;
} else if (uploadResponse.data?.fileId) {
fileKey = uploadResponse.data.fileId;
} else if (uploadResponse.data?.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey)) {
fileKey = uploadResponse.data.data.key || uploadResponse.data.data.fileKey;
} else if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results) && uploadResponse.data.results.length > 0) {
const result = uploadResponse.data.results[0];
if (result.ok && result.fileKey) {
fileKey = result.fileKey;
}
}
if (fileKey) {
setQuestionFileObject(file); setQuestionFileObject(file);
setQuestionFileKey(fileKey);
setQuestionFileCount(1); setQuestionFileCount(1);
} else {
throw new Error('파일 업로드는 완료되었지만 fileKey를 받지 못했습니다.');
}
} catch (error) { } catch (error) {
console.error('학습 평가 문제 업로드 실패:', error); console.error('학습 평가 문제 업로드 실패:', error);
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.'); alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
} }
} // input 초기화 (같은 파일 다시 선택 가능하도록)
e.target.value = '';
}} }}
/> />
</label> </label>

View File

@@ -0,0 +1,231 @@
'use client';
import { useState, useRef } from 'react';
interface CsvViewerProps {
onFileSelect?: (file: File) => void;
onDataParsed?: (data: string[][]) => void;
}
export default function CsvViewer({ onFileSelect, onDataParsed }: CsvViewerProps) {
const [csvData, setCsvData] = useState<string[][]>([]);
const [headers, setHeaders] = useState<string[]>([]);
const [rows, setRows] = useState<string[][]>([]);
const [fileName, setFileName] = useState<string>('');
const fileInputRef = useRef<HTMLInputElement>(null);
// CSV 파싱 함수
const parseCsv = (text: string): string[][] => {
const lines: string[][] = [];
let currentLine: string[] = [];
let currentField = '';
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const char = text[i];
const nextChar = text[i + 1];
if (char === '"') {
if (inQuotes && nextChar === '"') {
// 이스케이프된 따옴표
currentField += '"';
i++; // 다음 문자 건너뛰기
} else {
// 따옴표 시작/끝
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
// 필드 구분자
currentLine.push(currentField.trim());
currentField = '';
} else if ((char === '\n' || char === '\r') && !inQuotes) {
// 줄바꿈
if (char === '\r' && nextChar === '\n') {
i++; // \r\n 건너뛰기
}
if (currentField || currentLine.length > 0) {
currentLine.push(currentField.trim());
lines.push(currentLine);
currentLine = [];
currentField = '';
}
} else {
currentField += char;
}
}
// 마지막 필드 추가
if (currentField || currentLine.length > 0) {
currentLine.push(currentField.trim());
lines.push(currentLine);
}
return lines;
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// CSV 파일인지 확인
if (!file.name.toLowerCase().endsWith('.csv')) {
alert('CSV 파일만 업로드할 수 있습니다.');
e.target.value = '';
return;
}
setFileName(file.name);
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target?.result as string;
if (!text) return;
try {
const parsed = parseCsv(text);
if (parsed.length === 0) {
alert('CSV 파일이 비어있습니다.');
return;
}
// 첫 번째 줄을 헤더로 사용
const csvHeaders = parsed[0];
const csvRows = parsed.slice(1);
setHeaders(csvHeaders);
setRows(csvRows);
setCsvData(parsed);
// 콜백 호출
if (onFileSelect) {
onFileSelect(file);
}
if (onDataParsed) {
onDataParsed(parsed);
}
} catch (error) {
console.error('CSV 파싱 오류:', error);
alert('CSV 파일을 읽는 중 오류가 발생했습니다.');
}
};
reader.onerror = () => {
alert('파일을 읽는 중 오류가 발생했습니다.');
};
reader.readAsText(file, 'UTF-8');
};
const handleClear = () => {
setCsvData([]);
setHeaders([]);
setRows([]);
setFileName('');
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<div className="flex flex-col gap-4">
{/* 파일 업로드 영역 */}
<div className="flex items-center gap-4">
<label className="h-[40px] px-4 py-2 border border-[#8c95a1] rounded-[8px] bg-white text-[16px] font-semibold leading-[1.5] text-[#4c5561] whitespace-nowrap hover:bg-gray-50 transition-colors cursor-pointer flex items-center justify-center">
<span>CSV </span>
<input
ref={fileInputRef}
type="file"
accept=".csv,text/csv,application/vnd.ms-excel"
className="hidden"
onChange={handleFileChange}
/>
</label>
{fileName && (
<div className="flex items-center gap-2">
<span className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">
{fileName}
</span>
<button
type="button"
onClick={handleClear}
className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] hover:text-[#4c5561] transition-colors cursor-pointer"
>
</button>
</div>
)}
</div>
{/* 표 영역 */}
{csvData.length > 0 && (
<div className="w-full border border-[#dee1e6] border-solid relative size-full bg-white">
<div className="content-stretch flex flex-col items-start justify-center relative size-full">
{/* 헤더 */}
<div className="bg-[#f1f8ff] content-stretch flex h-[48px] items-center overflow-clip relative shrink-0 w-full">
{headers.map((header, index) => {
const isLast = index === headers.length - 1;
return (
<div
key={index}
className={`border-[#dee1e6] border-[0px_1px_0px_0px] border-solid box-border content-stretch flex gap-[10px] h-full items-center justify-center px-[8px] py-[12px] relative shrink-0 ${
index === 0 ? 'w-[48px]' : index === 1 ? 'basis-0 grow min-h-px min-w-px' : 'w-[140px]'
}`}
>
<div className="flex flex-col font-['Pretendard:SemiBold',sans-serif] justify-center leading-[0] not-italic relative shrink-0 text-[#4c5561] text-[14px] text-nowrap">
<p className="leading-[1.5] whitespace-pre">{header || `${index + 1}`}</p>
</div>
</div>
);
})}
</div>
{/* 데이터 행 */}
{rows.map((row, rowIndex) => (
<div
key={rowIndex}
className="border-[#dee1e6] border-[1px_0px_0px] border-solid h-[48px] relative shrink-0 w-full"
>
<div className="content-stretch flex h-[48px] items-start overflow-clip relative rounded-[inherit] w-full">
{headers.map((_, colIndex) => {
const isLast = colIndex === headers.length - 1;
const cellValue = row[colIndex] || '';
return (
<div
key={colIndex}
className={`border-[#dee1e6] ${
isLast ? '' : 'border-[0px_1px_0px_0px]'
} border-solid box-border content-stretch flex flex-col gap-[4px] ${
colIndex === 0
? 'items-center justify-center w-[48px] px-[8px] py-[12px]'
: 'items-center min-h-px min-w-px px-[8px] py-[12px]'
} ${
colIndex === 1 ? 'basis-0 grow' : ''
} relative shrink-0`}
>
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#1b2027] text-[15px] text-nowrap whitespace-pre">
{cellValue}
</p>
</div>
);
})}
</div>
</div>
))}
</div>
</div>
)}
{/* 데이터가 없을 때 */}
{csvData.length === 0 && (
<div className="w-full rounded-[8px] border border-[#dee1e6] bg-white min-h-[200px] flex items-center justify-center">
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47]">
CSV .
</p>
</div>
)}
</div>
);
}

View File

@@ -380,8 +380,8 @@ class ApiService {
subjectId: number; subjectId: number;
title: string; title: string;
objective: string; objective: string;
videoUrl?: string; videoUrl?: string | string[];
webglUrl?: string; webglUrl?: string | string[];
csvKey?: string; csvKey?: string;
}) { }) {
return this.request('/lectures', { return this.request('/lectures', {
@@ -397,8 +397,8 @@ class ApiService {
subjectId?: number; subjectId?: number;
title?: string; title?: string;
objective?: string; objective?: string;
videoUrl?: string; videoUrl?: string | string[];
webglUrl?: string; webglUrl?: string | string[];
csvKey?: string; csvKey?: string;
csvUrl?: string; csvUrl?: string;
}) { }) {
@@ -408,6 +408,15 @@ class ApiService {
}); });
} }
/**
* 강좌(lecture) 삭제
*/
async deleteLecture(lectureId: string | number) {
return this.request(`/lectures/${lectureId}`, {
method: 'DELETE',
});
}
/** /**
* 리소스 조회 * 리소스 조회
*/ */