From 662b64afb11fe45e7fd29d478f5a33d884ecd892 Mon Sep 17 00:00:00 2001 From: chyoo Date: Wed, 23 Sep 2026 16:04:08 +0900 Subject: [PATCH] feat: add weekly curriculum cover image upload and display --- src/components/WeekCoverEditor.tsx | 54 +++++++++++++++++++++++++ src/lib/curriculum.ts | 4 +- src/pages/curriculum/CurriculumPage.tsx | 7 ++++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 src/components/WeekCoverEditor.tsx diff --git a/src/components/WeekCoverEditor.tsx b/src/components/WeekCoverEditor.tsx new file mode 100644 index 0000000..35f4834 --- /dev/null +++ b/src/components/WeekCoverEditor.tsx @@ -0,0 +1,54 @@ +import { useRef, useState } from 'react'; +import { ImagePlus, Loader2, Trash2 } from 'lucide-react'; +import { deleteCurriculumReference, uploadCurriculumReference, type CurriculumReference } from '../lib/curriculum'; + +type Props = { + accessToken: string; + courseId: string; + week: number; + cover: CurriculumReference | null; + onChange: (cover: CurriculumReference | null) => void; +}; + +export function WeekCoverEditor({ accessToken, courseId, week, cover, onChange }: Props) { + const input = useRef(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + async function save(file?: File) { + if (busy || (!file && !cover)) return; + if (file && (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) || file.size > 8 * 1024 * 1024)) { + setError('8MB 이하의 PNG, JPEG, WebP 이미지를 선택해주세요.'); + return; + } + setBusy(true); + setError(''); + try { + const result = file + ? await uploadCurriculumReference(accessToken, { courseId, week, file, kind: 'cover-image' }) + : await deleteCurriculumReference(accessToken, cover!.id, courseId); + onChange(result.weeks.find((item) => item.week === week)?.detail.coverImage ?? null); + } catch (reason) { + setError(reason instanceof Error ? reason.message : '대표이미지를 저장하지 못했습니다.'); + } finally { setBusy(false); } + } + return ( +
+
+

대표이미지

+
+ + {cover && } +
+
+ { const file = event.target.files?.[0]; event.target.value = ''; if (file) void save(file); }} /> + {cover && {`${week}주차} + {error &&

{error}

} +
+ ); +} diff --git a/src/lib/curriculum.ts b/src/lib/curriculum.ts index d5fce03..bb1e5a6 100644 --- a/src/lib/curriculum.ts +++ b/src/lib/curriculum.ts @@ -9,6 +9,7 @@ export type CurriculumWeek = { duration: string; github: string; detail: { + coverImage?: CurriculumReference | null; camera?: { url: string }; overview: string; learningSteps?: Array<{ @@ -255,6 +256,7 @@ export async function uploadCurriculumReference( week: number; title?: string; file: File; + kind?: 'cover-image'; }, ): Promise { const formData = new FormData(); @@ -262,7 +264,7 @@ export async function uploadCurriculumReference( formData.set('courseId', input.courseId ?? ''); formData.set('title', input.title?.trim() || input.file.name); - const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/weeks/${input.week}/references`, { + const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/weeks/${input.week}/${input.kind ?? 'references'}`, { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, diff --git a/src/pages/curriculum/CurriculumPage.tsx b/src/pages/curriculum/CurriculumPage.tsx index 1322e88..f26f52e 100644 --- a/src/pages/curriculum/CurriculumPage.tsx +++ b/src/pages/curriculum/CurriculumPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { CheckCircle2, CirclePlay, Clock3, FileCode2, FileText, Loader2, Map, Plus, RadioTower, Save, Target, Trash2, Upload, UsersRound, type LucideIcon } from 'lucide-react'; import { ConfirmDialog } from '../../components/ConfirmDialog'; +import { WeekCoverEditor } from '../../components/WeekCoverEditor'; import { PageHeader } from '../../components/PageHeader'; import { useToast } from '../../components/ToastProvider'; import { createInstructorCurriculumMap, deleteCurriculumReference, deleteInstructorCurriculumWeek, getInstructorCurriculum, updateInstructorCurriculum, uploadCurriculumExampleCode, uploadCurriculumReference, type CurriculumEnvironmentProfile, type CurriculumWeek, type InstructorCurriculumResponse } from '../../lib/curriculum'; @@ -106,6 +107,7 @@ function readEnvironmentModuleDockerImages( export function CurriculumPage({ session, selectedCourseId }: { session: AuthSession; selectedCourseId: string }) { const { showToast } = useToast(); const [weeks, setWeeks] = useState([]); + const [coverOverrides, setCoverOverrides] = useState>({}); const [groups, setGroups] = useState([]); const [selectedGroupId, setSelectedGroupId] = useState(''); const [activeWeek, setActiveWeek] = useState(1); @@ -1398,6 +1400,11 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes )} + {selectedWeek && setCoverOverrides((current) => ({ ...current, [`${selectedCourseId}:${selectedWeek.week}`]: cover }))} />}