This commit is contained in:
2026-08-03 13:58:20 +09:00
parent ab40abf5b3
commit def692a28b
8 changed files with 609 additions and 59 deletions
+420 -22
View File
@@ -1,14 +1,77 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { CheckCircle2, CirclePlay, Clock3, FileCode2, FileText, Loader2, 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, type LucideIcon } from 'lucide-react';
import { ConfirmDialog } from '../../components/ConfirmDialog';
import { PageHeader } from '../../components/PageHeader';
import { useToast } from '../../components/ToastProvider';
import { deleteCurriculumReference, deleteInstructorCurriculumWeek, getInstructorCurriculum, updateInstructorCurriculum, uploadCurriculumExampleCode, uploadCurriculumReference, type CurriculumWeek, type InstructorCurriculumResponse } from '../../lib/curriculum';
import { createInstructorCurriculumMap, deleteCurriculumReference, deleteInstructorCurriculumWeek, getInstructorCurriculum, updateInstructorCurriculum, uploadCurriculumExampleCode, uploadCurriculumReference, type CurriculumEnvironmentProfile, type CurriculumWeek, type InstructorCurriculumResponse } from '../../lib/curriculum';
import { listInstructorGroups, type InstructorGroup } from '../../lib/groups';
import type { AuthSession } from '../../types/auth';
const CURRICULUM_SELECTED_GROUP_STORAGE_PREFIX = 'lms-admin-curriculum-selected-group-id';
type ModuleParameterDraft = {
key: string;
value: string;
};
function parseModuleParameter(parameter: string): ModuleParameterDraft {
const separatorIndex = parameter.indexOf(':=');
if (separatorIndex < 0) return { key: parameter.trim(), value: '' };
return {
key: parameter.slice(0, separatorIndex).trim(),
value: parameter.slice(separatorIndex + 2).trim(),
};
}
function readEnvironmentModuleIds(profile: CurriculumEnvironmentProfile, modules: NonNullable<CurriculumWeek['modules']>): string[] {
const config = profile.config;
const moduleSet = config && typeof config.moduleSet === 'object' && config.moduleSet !== null && !Array.isArray(config.moduleSet)
? config.moduleSet as Record<string, unknown>
: null;
if (!moduleSet || moduleSet.includeUnlisted !== false || !Array.isArray(moduleSet.modules)) {
return modules.map((module) => module.id);
}
return moduleSet.modules.flatMap((item) => (
item && typeof item === 'object' && typeof (item as Record<string, unknown>).id === 'string'
? [(item as Record<string, unknown>).id as string]
: []
));
}
function readEnvironmentModuleParameters(profile: CurriculumEnvironmentProfile): Record<string, ModuleParameterDraft[]> {
const config = profile.config;
const moduleSet = config && typeof config.moduleSet === 'object' && config.moduleSet !== null && !Array.isArray(config.moduleSet)
? config.moduleSet as Record<string, unknown>
: null;
if (!moduleSet || !Array.isArray(moduleSet.modules)) return {};
return Object.fromEntries(moduleSet.modules.flatMap((item) => {
if (!item || typeof item !== 'object') return [];
const module = item as Record<string, unknown>;
if (typeof module.id !== 'string') return [];
const parameters = Array.isArray(module.defaultParameters)
? module.defaultParameters.filter((value): value is string => typeof value === 'string').map(parseModuleParameter)
: [];
return [[module.id, parameters]];
}));
}
function readEnvironmentModuleDockerImages(profile: CurriculumEnvironmentProfile): Record<string, string> {
const config = profile.config;
const moduleSet = config && typeof config.moduleSet === 'object' && config.moduleSet !== null && !Array.isArray(config.moduleSet)
? config.moduleSet as Record<string, unknown>
: null;
if (!moduleSet || !Array.isArray(moduleSet.modules)) return {};
return Object.fromEntries(moduleSet.modules.flatMap((item) => {
if (!item || typeof item !== 'object') return [];
const module = item as Record<string, unknown>;
return typeof module.id === 'string' && typeof module.dockerImage === 'string'
? [[module.id, module.dockerImage]]
: [];
}));
}
export function CurriculumPage({ session, selectedCourseId }: { session: AuthSession; selectedCourseId: string }) {
const { showToast } = useToast();
const [weeks, setWeeks] = useState<CurriculumWeek[]>([]);
@@ -31,9 +94,19 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
const [draftTopicConcepts, setDraftTopicConcepts] = useState<string[]>([]);
const [draftPracticeContents, setDraftPracticeContents] = useState<Array<{ title: string; description: string }>>([]);
const [moduleEnabledById, setModuleEnabledById] = useState<Record<string, boolean>>({});
const [moduleDefaultParametersById, setModuleDefaultParametersById] = useState<Record<string, ModuleParameterDraft[]>>({});
const [environmentModuleIdsByPlatformKey, setEnvironmentModuleIdsByPlatformKey] = useState<Record<string, string[]>>({});
const [environmentModuleParametersByPlatformKey, setEnvironmentModuleParametersByPlatformKey] = useState<Record<string, Record<string, ModuleParameterDraft[]>>>({});
const [environmentModuleDockerImagesByPlatformKey, setEnvironmentModuleDockerImagesByPlatformKey] = useState<Record<string, Record<string, string>>>({});
const [tagOptions, setTagOptions] = useState<NonNullable<InstructorCurriculumResponse['tagOptions']>>([]);
const [visualizationPlatforms, setVisualizationPlatforms] = useState<NonNullable<InstructorCurriculumResponse['visualizationPlatforms']>>([]);
const [visualizationEnabledByKey, setVisualizationEnabledByKey] = useState<Record<string, boolean>>({});
const [mapOptions, setMapOptions] = useState<NonNullable<InstructorCurriculumResponse['mapOptions']>>([]);
const [mapEnabledByKey, setMapEnabledByKey] = useState<Record<string, boolean>>({});
const [mapCreateOpen, setMapCreateOpen] = useState(false);
const [mapName, setMapName] = useState('');
const [mapDescription, setMapDescription] = useState('');
const [creatingMap, setCreatingMap] = useState(false);
const [referenceTitle, setReferenceTitle] = useState('');
const [referenceFile, setReferenceFile] = useState<File | null>(null);
const [exampleCodeTitle, setExampleCodeTitle] = useState('');
@@ -90,6 +163,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
setWeeks(payload.weeks);
setTagOptions(payload.tagOptions ?? []);
setVisualizationPlatforms(payload.visualizationPlatforms ?? []);
setMapOptions(payload.mapOptions ?? []);
const progress = resolveProgressSelection(payload.weeks, payload.setting);
setCurrentProgressWeek(progress.week);
setCurrentProgressLearningStep(progress.learningStep);
@@ -164,7 +238,21 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
setDraftTopicConcepts(selectedWeek.topics.map((_, index) => selectedWeek.detail.learningSteps?.[index]?.concepts?.join('\n') ?? ''));
setDraftPracticeContents(selectedWeek.detail.practiceContents?.length ? selectedWeek.detail.practiceContents : []);
setModuleEnabledById(Object.fromEntries((selectedWeek.modules ?? []).map((module) => [module.id, module.enabled !== false])));
setModuleDefaultParametersById(Object.fromEntries((selectedWeek.modules ?? []).map((module) => [module.id, (module.defaultParameters ?? []).map(parseModuleParameter)])));
setEnvironmentModuleIdsByPlatformKey(Object.fromEntries((selectedWeek.environmentProfiles ?? []).map((profile) => [
profile.platformKey,
readEnvironmentModuleIds(profile, selectedWeek.modules ?? []),
])));
setEnvironmentModuleParametersByPlatformKey(Object.fromEntries((selectedWeek.environmentProfiles ?? []).map((profile) => [
profile.platformKey,
readEnvironmentModuleParameters(profile),
])));
setEnvironmentModuleDockerImagesByPlatformKey(Object.fromEntries((selectedWeek.environmentProfiles ?? []).map((profile) => [
profile.platformKey,
readEnvironmentModuleDockerImages(profile),
])));
setVisualizationEnabledByKey(Object.fromEntries((selectedWeek.visualizationPlatforms ?? []).map((platform) => [platform.key, true])));
setMapEnabledByKey(Object.fromEntries((selectedWeek.mapKeys ?? []).map((key) => [key, true])));
setReferenceTitle('');
setReferenceFile(null);
setExampleCodeTitle('');
@@ -330,6 +418,23 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
const disabledModuleIds = (selectedWeek.modules ?? [])
.filter((module) => moduleEnabledById[module.id] === false)
.map((module) => module.id);
const moduleDefaultParameters = (selectedWeek.modules ?? []).map((module) => ({
moduleId: module.id,
parameters: (moduleDefaultParametersById[module.id] ?? [])
.filter((parameter) => parameter.key.trim())
.map((parameter) => `${parameter.key.trim()}:=${parameter.value.trim()}`),
}));
const environmentModuleSets = (selectedWeek.environmentProfiles ?? []).map((profile) => ({
platformKey: profile.platformKey,
moduleIds: environmentModuleIdsByPlatformKey[profile.platformKey] ?? [],
modules: (environmentModuleIdsByPlatformKey[profile.platformKey] ?? []).map((id) => ({
id,
defaultParameters: (environmentModuleParametersByPlatformKey[profile.platformKey]?.[id] ?? [])
.filter((parameter) => parameter.key.trim())
.map((parameter) => `${parameter.key.trim()}:=${parameter.value.trim()}`),
dockerImage: environmentModuleDockerImagesByPlatformKey[profile.platformKey]?.[id]?.trim() || undefined,
})),
}));
const payload = await updateInstructorCurriculum(session.accessToken, {
courseId: selectedCourseId || null,
groupId: selectedGroupId || null,
@@ -337,9 +442,12 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
activeLearningStep,
applyToStudents: false,
disabledModuleIds,
moduleDefaultParameters,
environmentModuleSets,
visualizationPlatformKeys: visualizationPlatforms
.filter((platform) => visualizationEnabledByKey[platform.key])
.map((platform) => platform.key),
mapKeys: mapOptions.filter((map) => mapEnabledByKey[map.key]).map((map) => map.key),
});
applyPayload(payload);
setActiveWeek(selectedWeek.week);
@@ -354,6 +462,29 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
}
};
const handleCreateMap = async () => {
const name = mapName.trim();
if (!name) return;
setCreatingMap(true);
try {
const map = await createInstructorCurriculumMap(session.accessToken, {
courseId: selectedCourseId || null,
name,
description: mapDescription.trim(),
});
setMapOptions((current) => [...current, map]);
setMapEnabledByKey((current) => ({ ...current, [map.key]: true }));
setMapCreateOpen(false);
setMapName('');
setMapDescription('');
showToast({ title: '학습 맵이 추가되었습니다.', message: '현재 주차에 선택된 상태입니다.' });
} catch (reason) {
setError(reason instanceof Error ? reason.message : '학습 맵을 등록하지 못했습니다.');
} finally {
setCreatingMap(false);
}
};
const handleAddWeek = async () => {
const nextWeek = Math.max(0, ...weeks.map((week) => week.week)) + 1;
setSaving(true);
@@ -767,34 +898,236 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
{selectedWeek?.modules?.length ? selectedWeek.modules.map((module) => {
const enabled = moduleEnabledById[module.id] !== false;
return (
<label
<div
key={module.id}
className={`flex cursor-pointer items-center gap-3 rounded-lg border px-4 py-3 transition ${enabled ? 'border-[#d1d9e6] bg-white hover:bg-[#f8fafc]' : 'border-[#cbd5e1] bg-[#f8fafc] opacity-70 hover:bg-[#f1f5f9]'}`}
className={`rounded-lg border px-4 py-3 transition ${enabled ? 'border-[#d1d9e6] bg-white hover:bg-[#f8fafc]' : 'border-[#cbd5e1] bg-[#f8fafc] opacity-70 hover:bg-[#f1f5f9]'}`}
>
<input
type="checkbox"
className="h-4 w-4 shrink-0 cursor-pointer rounded border-[#cbd5e1] text-[#2563eb] focus:ring-[#2563eb]"
checked={enabled}
onChange={(event) => {
const checked = event.target.checked;
setModuleEnabledById((current) => ({ ...current, [module.id]: checked }));
}}
/>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-[#ede9ff] text-[12px] font-black text-[#4f46e5]">{module.sortOrder}</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14px] font-black text-[#111827]">{module.name}</span>
<span className="mt-1 block truncate text-[12px] font-bold text-[#64748b]">{module.category} · {module.folderName}</span>
</span>
<span className={`shrink-0 rounded-md px-2.5 py-1 text-[11px] font-black ${enabled ? 'bg-[#dcfce7] text-[#15803d]' : 'bg-[#e2e8f0] text-[#64748b]'}`}>
{enabled ? '사용' : '미사용'}
</span>
</label>
<div className="flex items-center gap-3">
<input
type="checkbox"
className="h-4 w-4 shrink-0 cursor-pointer rounded border-[#cbd5e1] text-[#2563eb] focus:ring-[#2563eb]"
checked={enabled}
onChange={(event) => {
const checked = event.target.checked;
setModuleEnabledById((current) => ({ ...current, [module.id]: checked }));
}}
/>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-[#ede9ff] text-[12px] font-black text-[#4f46e5]">{module.sortOrder}</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14px] font-black text-[#111827]">{module.name}</span>
<span className="mt-1 block truncate text-[12px] font-bold text-[#64748b]">{module.category} · {module.folderName}</span>
</span>
<span className={`shrink-0 rounded-md px-2.5 py-1 text-[11px] font-black ${enabled ? 'bg-[#dcfce7] text-[#15803d]' : 'bg-[#e2e8f0] text-[#64748b]'}`}>
{enabled ? '사용' : '미사용'}
</span>
</div>
<div className="mt-3 border-t border-[#e2e8f0] pt-3">
<div className="flex items-center justify-between gap-3">
<span className="text-[12px] font-bold text-[#475569]">기본 파라미터</span>
<button
type="button"
className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border border-[#bfdbfe] px-2 text-[12px] font-bold text-[#2563eb] transition hover:bg-[#eff6ff] disabled:cursor-not-allowed disabled:opacity-50"
disabled={!enabled}
onClick={() => setModuleDefaultParametersById((current) => ({
...current,
[module.id]: [...(current[module.id] ?? []), { key: '', value: '' }],
}))}
>
<Plus className="h-3.5 w-3.5" />
행 추가
</button>
</div>
<div className="mt-2 grid gap-2">
{(moduleDefaultParametersById[module.id] ?? []).map((parameter, parameterIndex) => (
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_28px] items-center gap-2" key={`${module.id}-${parameterIndex}`}>
<input
className="h-8 min-w-0 rounded-md border border-[#cbd5e1] bg-white px-2.5 font-mono text-[12px] text-[#111827] outline-none transition focus:border-[#2563eb] focus:ring-2 focus:ring-[#bfdbfe] disabled:cursor-not-allowed disabled:bg-[#f1f5f9]"
value={parameter.key}
placeholder="키"
disabled={!enabled}
onChange={(event) => setModuleDefaultParametersById((current) => ({
...current,
[module.id]: (current[module.id] ?? []).map((item, index) => index === parameterIndex ? { ...item, key: event.target.value } : item),
}))}
/>
<input
className="h-8 min-w-0 rounded-md border border-[#cbd5e1] bg-white px-2.5 font-mono text-[12px] text-[#111827] outline-none transition focus:border-[#2563eb] focus:ring-2 focus:ring-[#bfdbfe] disabled:cursor-not-allowed disabled:bg-[#f1f5f9]"
value={parameter.value}
placeholder="값"
disabled={!enabled}
onChange={(event) => setModuleDefaultParametersById((current) => ({
...current,
[module.id]: (current[module.id] ?? []).map((item, index) => index === parameterIndex ? { ...item, value: event.target.value } : item),
}))}
/>
<button
type="button"
title="행 삭제"
aria-label="행 삭제"
className="grid h-7 w-7 cursor-pointer place-items-center rounded-md text-[#94a3b8] transition hover:bg-[#fef2f2] hover:text-[#dc2626] disabled:cursor-not-allowed disabled:opacity-50"
disabled={!enabled}
onClick={() => setModuleDefaultParametersById((current) => ({
...current,
[module.id]: (current[module.id] ?? []).filter((_, index) => index !== parameterIndex),
}))}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
</div>
</div>
);
}) : (
<p className="rounded-lg border border-dashed border-[#d1d9e6] px-4 py-10 text-center text-[13px] font-bold text-[#64748b]">등록된 모듈이 없습니다.</p>
)}
</div>
</section>
{(selectedWeek?.environmentProfiles?.length ?? 0) > 0 ? (
<section className="border-t border-[#cbd5e1] pt-4">
<div>
<h3 className="text-[15px] font-black text-[#111827]">환경별 모듈 구성</h3>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">소스 저장소는 공통으로 유지하고, 로컬과 플랫폼에서 실행할 모듈만 다르게 선택합니다.</p>
</div>
<div className="mt-3 grid gap-3">
{(selectedWeek.environmentProfiles ?? []).map((profile) => {
const selectedModuleIds = new Set(environmentModuleIdsByPlatformKey[profile.platformKey] ?? []);
const selectedCount = (selectedWeek.modules ?? []).filter((module) => selectedModuleIds.has(module.id)).length;
const targetLabel = profile.platformKey === 'futuredrive-local-pc' ? '로컬' : '플랫폼';
return (
<div key={profile.id} className="rounded-lg border border-[#d1d9e6] bg-[#f8fafc] p-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<span className="block truncate text-[13px] font-black text-[#111827]">{profile.name}</span>
<span className="mt-0.5 block text-[11px] font-bold text-[#64748b]">{targetLabel} · {profile.profileType}</span>
</div>
<span className="shrink-0 rounded-md bg-white px-2.5 py-1 text-[11px] font-black text-[#475569]">{selectedCount}/{selectedWeek.modules?.length ?? 0}</span>
</div>
<div className="mt-3 grid gap-1.5 sm:grid-cols-2">
{(selectedWeek.modules ?? []).map((module) => {
const globallyEnabled = moduleEnabledById[module.id] !== false;
const checked = selectedModuleIds.has(module.id);
const parameterRows = environmentModuleParametersByPlatformKey[profile.platformKey]?.[module.id] ?? [];
return (
<div
key={module.id}
className={`min-w-0 rounded-md border px-2.5 py-2 text-[12px] font-bold transition ${globallyEnabled ? 'border-[#dbe3ee] bg-white hover:border-[#93c5fd]' : 'cursor-not-allowed border-[#e2e8f0] bg-[#f1f5f9] text-[#94a3b8]'}`}
>
<label className={`flex min-w-0 items-center gap-2 ${globallyEnabled ? 'cursor-pointer' : 'cursor-not-allowed'}`}>
<input
type="checkbox"
className="h-3.5 w-3.5 shrink-0 cursor-pointer rounded border-[#cbd5e1] text-[#2563eb] focus:ring-[#2563eb] disabled:cursor-not-allowed"
checked={checked}
disabled={!globallyEnabled}
onChange={(event) => {
const enabled = event.target.checked;
setEnvironmentModuleIdsByPlatformKey((current) => {
const currentIds = new Set(current[profile.platformKey] ?? []);
if (enabled) currentIds.add(module.id);
else currentIds.delete(module.id);
return { ...current, [profile.platformKey]: [...currentIds] };
});
if (!enabled) {
setEnvironmentModuleParametersByPlatformKey((current) => {
const profileParameters = { ...(current[profile.platformKey] ?? {}) };
delete profileParameters[module.id];
return { ...current, [profile.platformKey]: profileParameters };
});
setEnvironmentModuleDockerImagesByPlatformKey((current) => {
const profileImages = { ...(current[profile.platformKey] ?? {}) };
delete profileImages[module.id];
return { ...current, [profile.platformKey]: profileImages };
});
}
}}
/>
<span className="min-w-0 truncate">{module.name}</span>
</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.platformKey]?.[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.platformKey]: {
...(current[profile.platformKey] ?? {}),
[module.id]: event.target.value,
},
}))}
/>
</label>
<div className="mb-1 flex items-center justify-between gap-2 text-[10px] text-[#64748b]">
<span>실행 파라미터</span>
<button
type="button"
className="flex h-5 w-5 items-center justify-center rounded text-[#2563eb] hover:bg-[#eff6ff]"
aria-label={`${module.name} 파라미터 추가`}
onClick={() => setEnvironmentModuleParametersByPlatformKey((current) => ({
...current,
[profile.platformKey]: {
...(current[profile.platformKey] ?? {}),
[module.id]: [...(current[profile.platformKey]?.[module.id] ?? []), { key: '', value: '' }],
},
}))}
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{parameterRows.map((parameter, parameterIndex) => (
<div key={`${module.id}-${parameterIndex}`} className="mb-1 flex items-center gap-1">
<input
value={parameter.key}
placeholder="키"
className="min-w-0 flex-1 rounded border border-[#dbe3ee] px-1.5 py-1 text-[11px] font-medium outline-none focus:border-[#60a5fa]"
onChange={(event) => setEnvironmentModuleParametersByPlatformKey((current) => {
const rows = [...(current[profile.platformKey]?.[module.id] ?? [])];
rows[parameterIndex] = { ...rows[parameterIndex], key: event.target.value };
return { ...current, [profile.platformKey]: { ...(current[profile.platformKey] ?? {}), [module.id]: rows } };
})}
/>
<input
value={parameter.value}
placeholder="값"
className="min-w-0 flex-1 rounded border border-[#dbe3ee] px-1.5 py-1 text-[11px] font-medium outline-none focus:border-[#60a5fa]"
onChange={(event) => setEnvironmentModuleParametersByPlatformKey((current) => {
const rows = [...(current[profile.platformKey]?.[module.id] ?? [])];
rows[parameterIndex] = { ...rows[parameterIndex], value: event.target.value };
return { ...current, [profile.platformKey]: { ...(current[profile.platformKey] ?? {}), [module.id]: rows } };
})}
/>
<button
type="button"
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-[#94a3b8] hover:bg-[#f1f5f9] hover:text-[#dc2626]"
aria-label={`${module.name} 파라미터 삭제`}
onClick={() => setEnvironmentModuleParametersByPlatformKey((current) => ({
...current,
[profile.platformKey]: {
...(current[profile.platformKey] ?? {}),
[module.id]: (current[profile.platformKey]?.[module.id] ?? []).filter((_, index) => index !== parameterIndex),
},
}))}
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
</div>
) : null}
</div>
);
})}
</div>
</div>
);
})}
</div>
</section>
) : null}
<section className="border-t border-[#cbd5e1] pt-4">
<div className="flex items-center justify-between gap-3">
<div>
@@ -838,6 +1171,43 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
)}
</div>
</section>
<section className="border-t border-[#cbd5e1] pt-4">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-[15px] font-black text-[#111827]">학습 맵</h3>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">학생이 맵 선택 화면에서 사용할 NAV2 학습 환경을 선택하세요.</p>
</div>
<button
type="button"
onClick={() => setMapCreateOpen(true)}
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-[#d1d9e6] bg-white px-3 text-[12px] font-black text-[#475569] transition hover:border-[#2563eb] hover:bg-[#eff6ff] hover:text-[#2563eb]"
>
<Plus size={14} aria-hidden="true" />
맵 추가
</button>
</div>
<div className="mt-3 grid gap-2">
{mapOptions.map((map) => {
const enabled = Boolean(mapEnabledByKey[map.key]);
return (
<label key={map.key} className={`flex cursor-pointer items-center gap-3 rounded-lg border px-4 py-3 transition ${enabled ? 'border-[#d1d9e6] bg-white hover:bg-[#f8fafc]' : 'border-[#cbd5e1] bg-[#f8fafc] opacity-75 hover:bg-[#f1f5f9]'}`}>
<input
type="checkbox"
className="h-4 w-4 shrink-0 cursor-pointer rounded border-[#cbd5e1] text-[#2563eb] focus:ring-[#2563eb]"
checked={enabled}
onChange={(event) => setMapEnabledByKey((current) => ({ ...current, [map.key]: event.target.checked }))}
/>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-[#eff6ff] text-[#2563eb]"><Map size={15} /></span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14px] font-black text-[#111827]">{map.name}</span>
<span className="mt-1 block truncate text-[12px] font-bold text-[#64748b]">{map.description}</span>
</span>
{map.custom ? <span className="shrink-0 rounded-md bg-[#ede9ff] px-2.5 py-1 text-[11px] font-black text-[#6d28d9]">커스텀</span> : null}
</label>
);
})}
</div>
</section>
</div>
) : activeTab === 'examples' ? (
<div className="grid gap-4 p-5">
@@ -1218,6 +1588,34 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
</div>
</section>
{mapCreateOpen ? (
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-950/45 p-5" role="dialog" aria-modal="true" aria-label="학습 맵 추가">
<section className="w-full max-w-[420px] rounded-lg border border-[#cbd5e1] bg-white shadow-2xl">
<header className="border-b border-[#e2e8f0] px-5 py-4">
<h2 className="text-[16px] font-black text-[#111827]">학습 맵 추가</h2>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">등록 후 현재 주차에 선택된 상태로 추가됩니다.</p>
</header>
<div className="grid gap-4 px-5 py-4">
<label className="grid gap-1.5 text-[12px] font-black text-[#334155]">
맵 이름
<input autoFocus value={mapName} onChange={(event) => setMapName(event.target.value)} placeholder="예: 실습 장애물 맵" className="h-10 rounded-md border border-[#cbd5e1] px-3 text-[13px] font-semibold outline-none transition focus:border-[#2563eb] focus:ring-2 focus:ring-[#bfdbfe]" />
</label>
<label className="grid gap-1.5 text-[12px] font-black text-[#334155]">
설명
<input value={mapDescription} onChange={(event) => setMapDescription(event.target.value)} placeholder="학생에게 보여줄 맵 설명" className="h-10 rounded-md border border-[#cbd5e1] px-3 text-[13px] font-semibold outline-none transition focus:border-[#2563eb] focus:ring-2 focus:ring-[#bfdbfe]" />
</label>
</div>
<footer className="flex justify-end gap-2 border-t border-[#e2e8f0] px-5 py-3">
<button type="button" disabled={creatingMap} onClick={() => setMapCreateOpen(false)} className="h-8 rounded-md border border-[#d1d9e6] px-3 text-[12px] font-black text-[#475569] hover:bg-[#f8fafc] disabled:opacity-50">취소</button>
<button type="button" disabled={creatingMap || !mapName.trim()} onClick={() => void handleCreateMap()} className="inline-flex h-8 items-center gap-1.5 rounded-md bg-[#2563eb] px-3 text-[12px] font-black text-white hover:bg-[#1d4ed8] disabled:opacity-50">
{creatingMap ? <Loader2 className="animate-spin" size={13} /> : <Plus size={13} />}
추가
</button>
</footer>
</section>
</div>
) : null}
<ConfirmDialog
open={deleteConfirmOpen && Boolean(selectedWeek)}
title={selectedWeek ? `${selectedWeek.week}주차를 삭제할까요?` : '주차를 삭제할까요?'}