platform/lms-adm-fe release promotion snapshot

This commit is contained in:
2026-09-23 07:06:56 +00:00
parent 7654822424
commit 238a84423a
3 changed files with 64 additions and 1 deletions
+54
View File
@@ -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<HTMLInputElement>(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 (
<section>
<div className="flex items-center justify-between gap-3">
<h3 className="text-[15px] font-black text-[#111827]">대표이미지</h3>
<div className="flex gap-2">
<button type="button" disabled={busy} onClick={() => input.current?.click()}
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-[#d1d9e6] bg-white px-3 text-[12px] font-bold disabled:opacity-50">
{busy ? <Loader2 size={14} className="animate-spin" /> : <ImagePlus size={14} />}
{busy ? '저장 중' : cover ? '이미지 변경' : '이미지 업로드'}
</button>
{cover && <button type="button" title="대표이미지 삭제" aria-label="대표이미지 삭제" disabled={busy}
onClick={() => void save()} className="grid h-8 w-8 place-items-center rounded-md border border-[#d1d9e6] bg-white text-red-600 disabled:opacity-50"><Trash2 size={14} /></button>}
</div>
</div>
<input ref={input} type="file" accept="image/png,image/jpeg,image/webp" className="hidden"
onChange={(event) => { const file = event.target.files?.[0]; event.target.value = ''; if (file) void save(file); }} />
{cover && <img src={cover.downloadUrl} alt={`${week}주차 대표이미지`} className="mt-3 h-[240px] w-full rounded-md object-cover object-center" />}
{error && <p role="alert" className="mt-2 text-[13px] text-red-600">{error}</p>}
</section>
);
}
+3 -1
View File
@@ -9,6 +9,7 @@ export type CurriculumWeek = {
duration: string; duration: string;
github: string; github: string;
detail: { detail: {
coverImage?: CurriculumReference | null;
camera?: { url: string }; camera?: { url: string };
overview: string; overview: string;
learningSteps?: Array<{ learningSteps?: Array<{
@@ -255,6 +256,7 @@ export async function uploadCurriculumReference(
week: number; week: number;
title?: string; title?: string;
file: File; file: File;
kind?: 'cover-image';
}, },
): Promise<InstructorCurriculumResponse> { ): Promise<InstructorCurriculumResponse> {
const formData = new FormData(); const formData = new FormData();
@@ -262,7 +264,7 @@ export async function uploadCurriculumReference(
formData.set('courseId', input.courseId ?? ''); formData.set('courseId', input.courseId ?? '');
formData.set('title', input.title?.trim() || input.file.name); 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', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
+7
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; 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 { 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 { ConfirmDialog } from '../../components/ConfirmDialog';
import { WeekCoverEditor } from '../../components/WeekCoverEditor';
import { PageHeader } from '../../components/PageHeader'; import { PageHeader } from '../../components/PageHeader';
import { useToast } from '../../components/ToastProvider'; import { useToast } from '../../components/ToastProvider';
import { createInstructorCurriculumMap, deleteCurriculumReference, deleteInstructorCurriculumWeek, getInstructorCurriculum, updateInstructorCurriculum, uploadCurriculumExampleCode, uploadCurriculumReference, type CurriculumEnvironmentProfile, type CurriculumWeek, type InstructorCurriculumResponse } from '../../lib/curriculum'; 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 }) { export function CurriculumPage({ session, selectedCourseId }: { session: AuthSession; selectedCourseId: string }) {
const { showToast } = useToast(); const { showToast } = useToast();
const [weeks, setWeeks] = useState<CurriculumWeek[]>([]); const [weeks, setWeeks] = useState<CurriculumWeek[]>([]);
const [coverOverrides, setCoverOverrides] = useState<Record<string, CurriculumWeek['detail']['coverImage']>>({});
const [groups, setGroups] = useState<InstructorGroup[]>([]); const [groups, setGroups] = useState<InstructorGroup[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string>(''); const [selectedGroupId, setSelectedGroupId] = useState<string>('');
const [activeWeek, setActiveWeek] = useState(1); const [activeWeek, setActiveWeek] = useState(1);
@@ -1398,6 +1400,11 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
)} )}
</div> </div>
</section> </section>
{selectedWeek && <WeekCoverEditor key={`${selectedCourseId}:${selectedWeek.week}`}
accessToken={session.accessToken} courseId={selectedCourseId} week={selectedWeek.week}
cover={coverOverrides[`${selectedCourseId}:${selectedWeek.week}`] !== undefined
? coverOverrides[`${selectedCourseId}:${selectedWeek.week}`] ?? null : selectedWeek.detail.coverImage ?? null}
onChange={(cover) => setCoverOverrides((current) => ({ ...current, [`${selectedCourseId}:${selectedWeek.week}`]: cover }))} />}
<label className="block"> <label className="block">
<h3 className="text-[15px] font-black text-[#111827]">제출 기준</h3> <h3 className="text-[15px] font-black text-[#111827]">제출 기준</h3>
<textarea <textarea