feat: add Monaco diff editor for submission changes

This commit is contained in:
2026-08-06 16:16:30 +09:00
parent 4b2e25d385
commit 604da5d6e1
8 changed files with 480 additions and 313 deletions
+76 -101
View File
@@ -1,6 +1,6 @@
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, 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 { PageHeader } from '../../components/PageHeader';
import { useToast } from '../../components/ToastProvider';
@@ -26,8 +26,8 @@ function isPlatformEnvironment(config: unknown): boolean {
return target === 'remote-jetson' || target === 'platform';
}
function environmentStorageKey(profile: Pick<CurriculumEnvironmentProfile, 'config'>): 'local' | 'platform' {
return isPlatformEnvironment(profile.config) ? 'platform' : 'local';
function environmentStorageKey(profile: Pick<CurriculumEnvironmentProfile, 'config' | 'profileType'>): 'local' | 'platform' {
return profile.profileType.toUpperCase() === 'PLATFORM' || isPlatformEnvironment(profile.config) ? 'platform' : 'local';
}
function parseModuleParameter(parameter: string): ModuleParameterDraft {
@@ -121,7 +121,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
const [draftDuration, setDraftDuration] = useState('');
const [draftOverview, setDraftOverview] = useState('');
const [draftDeliverable, setDraftDeliverable] = useState('');
const [draftRequiresSourceSubmission, setDraftRequiresSourceSubmission] = useState(true);
const [draftIsTeamProject, setDraftIsTeamProject] = useState(false);
const [draftTopics, setDraftTopics] = useState<string[]>([]);
const [draftTopicDetails, setDraftTopicDetails] = useState<string[]>([]);
const [draftTopicVideoUrls, setDraftTopicVideoUrls] = useState<string[]>([]);
@@ -173,39 +173,19 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
const editableEnvironmentProfiles = useMemo<EditableEnvironmentProfile[]>(() => {
if (!selectedWeek) return [];
const savedProfiles = new globalThis.Map((selectedWeek.environmentProfiles ?? []).map((profile) => [profile.platformKey, profile]));
const profilesByEnvironment = new globalThis.Map<'local' | 'platform', EditableEnvironmentProfile>();
visualizationPlatforms
.filter((platform) => visualizationEnabledByKey[platform.key])
.forEach((platform) => {
const profile = savedProfiles.get(platform.key) ?? {
id: `draft:${selectedWeek.week}:${platform.key}`,
platformKey: platform.key,
profileType: platform.type,
name: platform.displayName,
config: platform.config && typeof platform.config === 'object' && !Array.isArray(platform.config)
? platform.config as Record<string, unknown>
: undefined,
};
const storageKey = environmentStorageKey(profile);
const existing = profilesByEnvironment.get(storageKey);
if (existing) {
existing.platformKeys.push(platform.key);
return;
}
profilesByEnvironment.set(storageKey, {
...profile,
id: `draft:${selectedWeek.week}:${storageKey}`,
name: storageKey === 'local' ? '로컬' : '플랫폼',
profileType: storageKey === 'local' ? 'LOCAL' : 'PLATFORM',
storageKey,
platformKeys: [platform.key],
});
});
return [...profilesByEnvironment.values()];
}, [selectedWeek, visualizationEnabledByKey, visualizationPlatforms]);
return (['local', 'platform'] as const).map((storageKey) => {
const savedProfile = (selectedWeek.environmentProfiles ?? []).find((profile) => environmentStorageKey(profile) === storageKey);
return {
id: `draft:${selectedWeek.week}:${storageKey}`,
platformKey: storageKey,
profileType: storageKey === 'local' ? 'LOCAL' : 'PLATFORM',
name: storageKey === 'local' ? '로컬' : '플랫폼',
config: savedProfile?.config ?? (storageKey === 'platform' ? { target: 'remote-jetson' } : { target: 'local' }),
storageKey,
platformKeys: [storageKey],
};
});
}, [selectedWeek]);
const resolveProgressSelection = (nextWeeks: CurriculumWeek[], setting: InstructorCurriculumResponse['setting']) => {
const nextWeek = nextWeeks.find((week) => week.state === 'current')?.week
@@ -300,7 +280,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
setDraftDuration(selectedWeek.duration);
setDraftOverview(selectedWeek.detail.overview);
setDraftDeliverable(selectedWeek.detail.deliverable);
setDraftRequiresSourceSubmission(selectedWeek.detail.requiresSourceSubmission ?? true);
setDraftIsTeamProject(Boolean(selectedWeek.isTeamProject));
setDraftTopics(selectedWeek.topics.length ? selectedWeek.topics : ['']);
setDraftTopicDetails(selectedWeek.detail.topicDetails.length ? selectedWeek.detail.topicDetails : ['']);
const topicVideoUrls = selectedWeek.topics.map((_, index) => selectedWeek.detail.topicVideoUrls?.[index] ?? '');
@@ -376,7 +356,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
duration: draftDuration,
overview: draftOverview,
deliverable: draftDeliverable,
requiresSourceSubmission: draftRequiresSourceSubmission,
isTeamProject: draftIsTeamProject,
topics: draftTopics.map((topic) => topic.trim()).filter(Boolean),
learningSteps: buildDraftLearningSteps(),
topicDetails: draftTopicDetails,
@@ -424,7 +404,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
duration: draftDuration,
overview: draftOverview,
deliverable: draftDeliverable,
requiresSourceSubmission: draftRequiresSourceSubmission,
isTeamProject: draftIsTeamProject,
topics,
learningSteps: buildDraftLearningSteps(),
topicDetails: draftTopicDetails,
@@ -465,7 +445,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
duration: draftDuration,
overview: draftOverview,
deliverable: draftDeliverable,
requiresSourceSubmission: draftRequiresSourceSubmission,
isTeamProject: draftIsTeamProject,
topics: draftTopics.map((topic) => topic.trim()).filter(Boolean),
learningSteps: buildDraftLearningSteps(),
topicDetails: draftTopicDetails,
@@ -577,7 +557,6 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
duration: '-',
overview: '',
deliverable: '',
requiresSourceSubmission: true,
topics: ['학습단계 1'],
learningSteps: [{ title: '학습단계 1', description: '', videoEnabled: false, videoUrl: '', concepts: [] }],
topicDetails: [''],
@@ -751,7 +730,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
<PageHeader route="curriculum" />
<section className="mb-4 grid gap-3 md:grid-cols-3">
<SummaryCard label="선택 그룹" value={selectedGroup?.name ?? '등록된 그룹 없음'} icon={Target} />
<SummaryCard label="선택 그룹" value={selectedGroup?.name ?? '등록된 그룹 없음'} icon={UsersRound} />
<SummaryCard label="현재 진행 주차" value={currentProgressWeek ? `${currentProgressWeek}주차` : '-'} icon={RadioTower} />
<SummaryCard label="현재 학습단계" value={currentProgressLearningStep ?? '-'} icon={Target} />
</section>
@@ -988,6 +967,9 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
const globallyEnabled = moduleEnabledById[module.id] !== false;
const checked = selectedModuleIds.has(module.id);
const parameterRows = environmentModuleParametersByPlatformKey[profile.storageKey]?.[module.id] ?? [];
const dockerImage = environmentModuleDockerImagesByPlatformKey[profile.storageKey]?.[module.id]
|| module.dockerImage
|| '-';
return (
<div
key={module.id}
@@ -1025,21 +1007,10 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
</label>
{checked ? (
<div className="mt-2 border-t border-[#e2e8f0] pt-2">
<label className="mb-2 block text-[10px] text-[#64748b]">
<span className="mb-1 block">실행 이미지</span>
<input
value={environmentModuleDockerImagesByPlatformKey[profile.storageKey]?.[module.id] ?? ''}
placeholder={module.dockerImage ?? '기본 이미지 사용'}
className="h-7 w-full rounded border border-[#dbe3ee] px-1.5 font-mono text-[11px] font-medium outline-none focus:border-[#60a5fa]"
onChange={(event) => setEnvironmentModuleDockerImagesByPlatformKey((current) => ({
...current,
[profile.storageKey]: {
...(current[profile.storageKey] ?? {}),
[module.id]: event.target.value,
},
}))}
/>
</label>
<div className="mb-2">
<span className="block text-[10px] text-[#64748b]">실행 이미지</span>
<span className="mt-0.5 block truncate font-mono text-[10px] font-medium text-[#475569]" title={dockerImage}>{dockerImage}</span>
</div>
<div className="mb-1 flex items-center justify-between gap-2 text-[10px] text-[#64748b]">
<span>실행 파라미터</span>
<button
@@ -1223,28 +1194,30 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
) : (
<div className="grid gap-4 p-5">
<section className="grid gap-3 pb-2">
<label className="block">
<span className="text-[12px] font-black text-[#475569]">주차 제목</span>
<input
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
value={draftTitle}
onChange={(event) => setDraftTitle(event.target.value)}
/>
</label>
<label className="block">
<span className="text-[12px] font-black text-[#475569]">플랫폼 배지</span>
<select
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
value={selectedTagLabel}
onChange={(event) => setDraftTagLabel(event.target.value)}
>
{tagOptions.length ? tagOptions.map((option) => (
<option key={option.key} value={option.label}>{option.label}</option>
)) : (
<option value="없음">없음</option>
)}
</select>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="text-[12px] font-black text-[#475569]">주차 제목</span>
<input
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
value={draftTitle}
onChange={(event) => setDraftTitle(event.target.value)}
/>
</label>
<label className="block">
<span className="text-[12px] font-black text-[#475569]">플랫폼 배지</span>
<select
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
value={selectedTagLabel}
onChange={(event) => setDraftTagLabel(event.target.value)}
>
{tagOptions.length ? tagOptions.map((option) => (
<option key={option.key} value={option.label}>{option.label}</option>
)) : (
<option value="없음">없음</option>
)}
</select>
</label>
</div>
<label className="block h-[100px]">
<span className="text-[12px] font-black text-[#475569]">주차 개요</span>
<textarea
@@ -1253,15 +1226,28 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
onChange={(event) => setDraftOverview(event.target.value)}
/>
</label>
<label className="block">
<span className="text-[12px] font-black text-[#475569]">예상 학습 시간</span>
<input
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
value={draftDuration}
placeholder="예: 8-10시간"
onChange={(event) => setDraftDuration(event.target.value)}
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="text-[12px] font-black text-[#475569]">예상 학습 시간</span>
<input
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
value={draftDuration}
placeholder="예: 8-10시간"
onChange={(event) => setDraftDuration(event.target.value)}
/>
</label>
<label className="block">
<span className="text-[12px] font-black text-[#475569]">팀 프로젝트</span>
<select
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
value={draftIsTeamProject ? 'true' : 'false'}
onChange={(event) => setDraftIsTeamProject(event.target.value === 'true')}
>
<option value="false">미사용</option>
<option value="true">사용</option>
</select>
</label>
</div>
</section>
<section>
@@ -1421,18 +1407,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
</div>
</section>
<label className="block">
<div className="flex items-center justify-between gap-3">
<h3 className="text-[15px] font-black text-[#111827]">제출 기준</h3>
<label className="inline-flex cursor-pointer items-center gap-2 text-[12px] font-black text-[#475569]">
<input
type="checkbox"
className="h-4 w-4 cursor-pointer rounded border-[#cbd5e1] text-[#2563eb] focus:ring-[#2563eb]"
checked={draftRequiresSourceSubmission}
onChange={(event) => setDraftRequiresSourceSubmission(event.target.checked)}
/>
소스제출
</label>
</div>
<h3 className="text-[15px] font-black text-[#111827]">제출 기준</h3>
<textarea
className="mt-1.5 min-h-[84px] w-full resize-none rounded-md border border-[#d1d9e6] bg-white px-3 py-2 text-[13px] font-semibold leading-relaxed text-[#111827] outline-none focus:border-[#2563eb]"
value={draftDeliverable}