feat: add Monaco diff editor for submission changes
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user