platform/lms-adm-fe release promotion snapshot

This commit is contained in:
2026-08-06 07:33:51 +00:00
parent d44b235bd6
commit ad41509aa4
8 changed files with 480 additions and 313 deletions
+2 -2
View File
@@ -25,12 +25,12 @@ export type CurriculumWeek = {
references?: CurriculumReference[];
exampleCodes?: CurriculumReference[];
deliverable: string;
requiresSourceSubmission: boolean;
};
modules?: CurriculumModule[];
environmentProfiles?: CurriculumEnvironmentProfile[];
visualizationPlatforms?: CurriculumVisualizationPlatform[];
mapKeys?: string[];
isTeamProject?: boolean;
};
export type CurriculumMap = {
@@ -145,7 +145,7 @@ export async function updateInstructorCurriculum(
duration?: string;
overview?: string;
deliverable?: string;
requiresSourceSubmission?: boolean;
isTeamProject?: boolean;
topics?: string[];
learningSteps?: Array<{
title?: string;
+56
View File
@@ -2,6 +2,8 @@ export type InstructorSubmissionCommitFile = {
path: string;
status: string;
patch: string;
originalRef?: string | null;
modifiedRef?: string | null;
};
export type InstructorSubmissionCommit = {
@@ -30,12 +32,14 @@ export type InstructorSubmissionItem = {
id: string;
week: number;
title: string;
isTeamProject: boolean;
};
module: {
id: string;
name: string;
folderName: string;
sortOrder: number;
sourceType?: 'CUSTOM' | 'GIT';
};
student: {
id: string;
@@ -43,6 +47,17 @@ export type InstructorSubmissionItem = {
email: string;
group: string;
};
team?: {
id: string;
name: string;
members: Array<{
id: string;
name: string;
email: string;
commitCount: number;
lastCommittedAt: string | null;
}>;
} | null;
repository: {
id: string;
owner: string;
@@ -73,6 +88,7 @@ export type InstructorSubmissionsResponse = {
id: string;
week: number;
title: string;
isTeamProject: boolean;
modules: Array<{
id: string;
name: string;
@@ -88,6 +104,7 @@ export type InstructorSubmissionsResponse = {
group: string;
}>;
items: InstructorSubmissionItem[];
teamItems: InstructorSubmissionItem[];
};
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://workspace.autonomystudio.net/lms-api/api').replace(/\/+$/, '');
@@ -106,6 +123,45 @@ export async function listInstructorSubmissions(accessToken: string): Promise<In
return response.json() as Promise<InstructorSubmissionsResponse>;
}
export async function getInstructorSubmissionFile(
accessToken: string,
input: {
owner: string;
repoName: string;
path: string;
baseRef?: string | null;
headRef?: string | null;
},
): Promise<{
original: string;
modified: string;
originalAvailable: boolean;
modifiedAvailable: boolean;
}> {
const query = new URLSearchParams({
owner: input.owner,
repo: input.repoName,
path: input.path,
});
if (input.baseRef) query.set('baseRef', input.baseRef);
if (input.headRef) query.set('headRef', input.headRef);
const response = await fetch(`${LMS_API_BASE_URL}/instructor/submissions/file?${query.toString()}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`파일 내용을 불러오지 못했습니다. (${response.status})`);
}
return response.json() as Promise<{
original: string;
modified: string;
originalAvailable: boolean;
modifiedAvailable: boolean;
}>;
}
export async function saveInstructorSubmissionScore(
accessToken: string,
input: { studentId: string; weekId: string; score: number },
+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}
+1 -1
View File
@@ -180,7 +180,7 @@ export function DashboardPage({ session, selectedCourseId, onNavigate, onOpenCha
<section className="mt-4 rounded-lg border border-[#c7d2e0] bg-white">
<div className="flex items-start justify-between gap-4 border-b border-[#cbd5e1] px-4 py-4">
<div>
<h2 className="text-[15px] font-black text-[#111827]">AI 가이드 에스컬레이션</h2>
<h2 className="text-[15px] font-black text-[#111827]">질의응답</h2>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">강사 지원이 필요한 질문</p>
</div>
<span className="rounded-full bg-[#fff1f2] px-2.5 py-1 text-[13px] font-bold text-[#e11d48]">{waitingThreads.length} 건</span>
+11 -11
View File
@@ -232,9 +232,9 @@ export function StudentsPage({ session, selectedCourseId }: { session: AuthSessi
</label>
</div>
<div>
<div>
<div className="hidden gap-4 grid-cols-[44px_108px_minmax(160px,1fr)_minmax(116px,0.7fr)_78px_minmax(150px,1fr)_58px_58px_118px_70px_42px] items-center border-b border-[#cbd5e1] bg-[#f8fafc] px-4 py-3 text-center text-[11px] font-bold text-[#64748b] xl:grid">
<div className="overflow-x-auto">
<div className="min-w-[1180px]">
<div className="grid grid-cols-[44px_108px_minmax(160px,1fr)_minmax(116px,0.7fr)_78px_minmax(150px,1fr)_58px_58px_118px_70px_42px] items-center gap-4 border-b border-[#cbd5e1] bg-[#f8fafc] px-4 py-3 text-center text-[11px] font-bold text-[#64748b]">
<span>No.</span>
<span>이름</span>
<span>이메일</span>
@@ -258,13 +258,13 @@ export function StudentsPage({ session, selectedCourseId }: { session: AuthSessi
<EmptyState message={error} />
) : filteredStudents.length > 0 ? (
pagedStudents.map((student, index) => (
<article className="grid gap-4 px-4 py-4 xl:grid-cols-[44px_108px_minmax(160px,1fr)_minmax(116px,0.7fr)_78px_minmax(150px,1fr)_58px_58px_118px_70px_42px] xl:items-center" key={student.id}>
<article className="grid grid-cols-[44px_108px_minmax(160px,1fr)_minmax(116px,0.7fr)_78px_minmax(150px,1fr)_58px_58px_118px_70px_42px] items-center gap-4 px-4 py-4" key={student.id}>
<Cell label="번호">
<span className="text-[13px] font-bold text-[#64748b]">{filteredStudents.length - ((page - 1) * pageSize + index)}</span>
</Cell>
<div className="min-w-0 text-center">
<p className="mb-2 text-[11px] font-bold text-[#94a3b8] xl:hidden">이름</p>
<p className="hidden">이름</p>
<h3 className="truncate text-[14px] font-black text-[#111827]">{student.name}</h3>
</div>
@@ -284,7 +284,7 @@ export function StudentsPage({ session, selectedCourseId }: { session: AuthSessi
<div>
<div className="mb-2 flex items-center justify-between text-[12px] font-bold text-[#475569]">
<span className="xl:hidden">진행률</span>
<span className="hidden">진행률</span>
<span className="font-semibold text-[#4f46e5]">{student.progress}%</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-[#dbe3ef]">
@@ -491,7 +491,7 @@ function EmptyState({ message }: { message: string }) {
function Cell({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="min-w-0 text-center">
<p className="mb-2 text-[11px] font-bold text-[#94a3b8] xl:hidden">{label}</p>
<p className="hidden">{label}</p>
{children}
</div>
);
@@ -500,11 +500,11 @@ function Cell({ label, children }: { label: string; children: React.ReactNode })
function StudentMetric({ icon: Icon, label, value, danger = false }: { icon: typeof GitBranch; label: string; value: number; danger?: boolean }) {
return (
<div className="min-w-0 text-center">
<div className="flex items-center justify-center gap-1.5 text-[11px] font-bold text-[#94a3b8] xl:hidden">
<div className="hidden">
<Icon size={13} aria-hidden="true" />
{label}
</div>
<p className={`mt-1 text-[13px] font-semibold xl:mt-0 ${danger ? 'text-[#e11d48]' : 'text-[#111827]'}`}>{value}</p>
<p className={`mt-0 text-[13px] font-semibold ${danger ? 'text-[#e11d48]' : 'text-[#111827]'}`}>{value}</p>
</div>
);
}
@@ -514,7 +514,7 @@ function StudentIssueMetric({ student, onOpen }: { student: InstructorStudent; o
return (
<div className="min-w-0 text-center">
<div className="flex items-center justify-center gap-1.5 text-[11px] font-bold text-[#94a3b8] xl:hidden">
<div className="hidden">
<CircleAlert size={13} aria-hidden="true" />
이슈
</div>
@@ -525,7 +525,7 @@ function StudentIssueMetric({ student, onOpen }: { student: InstructorStudent; o
}}
disabled={!hasIssues}
className={[
'mt-1 inline-flex h-7 min-w-7 items-center justify-center rounded-md px-2 text-[13px] font-black xl:mt-0',
'mt-0 inline-flex h-7 min-w-7 items-center justify-center rounded-md px-2 text-[13px] font-black',
hasIssues
? 'cursor-pointer bg-[#fff1f2] text-[#e11d48] transition-colors hover:bg-[#ffe4e6]'
: 'cursor-default bg-transparent text-[#111827]',
+263 -198
View File
@@ -1,26 +1,34 @@
import { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { BookOpen, CalendarDays, CheckCircle2, Clock3, FileCode2, FolderOpen, GitCommitHorizontal, Loader2, Save, UsersRound, UserRound, X, XCircle } from 'lucide-react';
import { DiffEditor, loader } from '@monaco-editor/react';
import * as monaco from 'monaco-editor';
import EditorWorker from '../../../node_modules/monaco-editor/esm/vs/editor/editor.worker.js?worker';
import { BookOpen, CalendarDays, CheckCircle2, ChevronDown, ChevronRight, Clock3, FileCode2, FolderOpen, GitCommitHorizontal, Loader2, Save, UsersRound, UserRound, X, XCircle } from 'lucide-react';
import { useToast } from '../../components/ToastProvider';
import { PageHeader } from '../../components/PageHeader';
import { listInstructorSubmissions, saveInstructorSubmissionScore, type InstructorSubmissionItem } from '../../lib/submissions';
import { getInstructorSubmissionFile, listInstructorSubmissions, saveInstructorSubmissionScore, type InstructorSubmissionItem } from '../../lib/submissions';
import type { AuthSession } from '../../types/auth';
type CommitFile = InstructorSubmissionItem['commits'][number]['files'][number];
type DiffViewLine = {
key: string;
type: 'file' | 'hunk' | 'context' | 'add' | 'delete' | 'note';
oldLine: number | null;
newLine: number | null;
content: string;
};
type PatchModalState = {
title: string;
subtitle: string;
files: CommitFile[];
activePath: string;
repository: NonNullable<InstructorSubmissionItem['repository']>;
} | null;
type SubmissionSelectionMember = {
id: string;
name: string;
email: string;
total: number;
submitted: number;
pending: number;
notInstalled: number;
commitCount?: number;
lastCommittedAt?: string | null;
};
const statusLabel: Record<InstructorSubmissionItem['status'], string> = {
SUBMITTED: '제출완료',
@@ -34,101 +42,19 @@ const statusTone: Record<InstructorSubmissionItem['status'], string> = {
NOT_INSTALLED: 'bg-[#f1f5f9] text-[#64748b]',
};
const parseDiffViewLines = (diff: string): DiffViewLine[] => {
const viewLines: DiffViewLine[] = [];
let oldLine = 0;
let newLine = 0;
loader.config({ monaco });
diff.split('\n').forEach((line, index) => {
if (line.startsWith('diff --git ')) {
const match = line.match(/^diff --git a\/(.+) b\/(.+)$/);
viewLines.push({
key: `file-${index}`,
type: 'file',
oldLine: null,
newLine: null,
content: match?.[2] ?? line.replace(/^diff --git\s+/, ''),
});
return;
}
if (
line.startsWith('index ') ||
line.startsWith('--- ') ||
line.startsWith('+++ ') ||
line.startsWith('new file mode ') ||
line.startsWith('deleted file mode ')
) {
return;
}
if (line.startsWith('\\ No newline')) {
viewLines.push({
key: `note-${index}`,
type: 'note',
oldLine: null,
newLine: null,
content: '파일 끝 줄바꿈 없음',
});
return;
}
if (line.startsWith('@@')) {
const match = line.match(/@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@(.*)$/);
oldLine = Number(match?.[1] ?? 0);
newLine = Number(match?.[2] ?? 0);
viewLines.push({
key: `hunk-${index}`,
type: 'hunk',
oldLine: null,
newLine: null,
content: match?.[3]?.trim() || line,
});
return;
}
if (!viewLines.length) return;
if (line.startsWith('+')) {
viewLines.push({
key: `add-${index}`,
type: 'add',
oldLine: null,
newLine,
content: line.slice(1),
});
newLine += 1;
return;
}
if (line.startsWith('-')) {
viewLines.push({
key: `delete-${index}`,
type: 'delete',
oldLine,
newLine: null,
content: line.slice(1),
});
oldLine += 1;
return;
}
viewLines.push({
key: `context-${index}`,
type: 'context',
oldLine,
newLine,
content: line.startsWith(' ') ? line.slice(1) : line,
});
oldLine += 1;
newLine += 1;
});
return viewLines;
// Vite serves this app below /lms-adm/. Monaco's default worker URL ignores
// that base path, so provide the worker explicitly for editor and diff tasks.
(globalThis as typeof globalThis & {
MonacoEnvironment?: { getWorker: () => Worker };
}).MonacoEnvironment = {
getWorker: () => new EditorWorker(),
};
export function SubmissionsPage({ session }: { session: AuthSession }) {
const [items, setItems] = useState<InstructorSubmissionItem[]>([]);
const [teamItems, setTeamItems] = useState<InstructorSubmissionItem[]>([]);
const [students, setStudents] = useState<Array<{ id: string; name: string; email: string }>>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -148,6 +74,7 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
.then((payload) => {
if (cancelled) return;
setItems(payload.items);
setTeamItems(payload.teamItems);
setStudents(payload.students);
setSelectedCourseId((current) => current && payload.courses.some((course) => course.id === current) ? current : payload.courses[0]?.id ?? null);
setSelectedWeekId(null);
@@ -159,6 +86,7 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
if (cancelled) return;
setError(reason instanceof Error ? reason.message : '제출 현황을 불러오지 못했습니다.');
setItems([]);
setTeamItems([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
@@ -169,16 +97,18 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
};
}, [session.accessToken]);
const courseNodes = useMemo(() => items.length
? Array.from(new Map(items.map((item) => [item.course.id, item.course])).values())
: [], [items]);
const allItems = useMemo(() => [...items, ...teamItems], [items, teamItems]);
const courseNodes = useMemo(() => allItems.length
? Array.from(new Map(allItems.map((item) => [item.course.id, item.course])).values())
: [], [allItems]);
const selectedCourse = courseNodes.find((course) => course.id === selectedCourseId) ?? null;
const courseItems = useMemo(() => selectedCourse ? items.filter((item) => item.course.id === selectedCourse.id) : [], [items, selectedCourse]);
const courseItems = useMemo(() => selectedCourse ? allItems.filter((item) => item.course.id === selectedCourse.id) : [], [allItems, selectedCourse]);
const weekNodes = useMemo(() => {
const byWeek = new Map<string, {
id: string;
week: number;
title: string;
isTeamProject: boolean;
total: number;
submitted: number;
pending: number;
@@ -189,6 +119,7 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
id: item.week.id,
week: item.week.week,
title: item.week.title,
isTeamProject: item.week.isTeamProject,
total: 0,
submitted: 0,
pending: 0,
@@ -204,10 +135,11 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
}, [courseItems]);
const selectedWeek = weekNodes.find((week) => week.id === selectedWeekId) ?? null;
const weekItems = useMemo(() => selectedWeek ? courseItems.filter((item) => item.week.id === selectedWeek.id) : [], [courseItems, selectedWeek]);
const teamProjectWeek = Boolean(selectedWeek?.isTeamProject);
const groupNodes = useMemo(() => {
const groupMap = new Map<string, { name: string; total: number; submitted: number; pending: number; notInstalled: number }>();
weekItems.forEach((item) => {
const group = item.student.group || '미배정';
const group = item.team?.name ?? item.student.group ?? '미배정';
const current = groupMap.get(group) ?? { name: group, total: 0, submitted: 0, pending: 0, notInstalled: 0 };
current.total += 1;
if (item.status === 'SUBMITTED') current.submitted += 1;
@@ -218,7 +150,19 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
return Array.from(groupMap.values()).sort((a, b) => a.name.localeCompare(b.name, 'ko'));
}, [weekItems]);
const selectedGroup = groupNodes.find((group) => group.name === selectedGroupName) ?? null;
const studentNodes = useMemo(() => students.map((student) => {
const studentNodes = useMemo<SubmissionSelectionMember[]>(() => {
if (teamProjectWeek) {
const team = weekItems.find((item) => item.team?.name === selectedGroup?.name)?.team;
return (team?.members ?? []).map((member) => ({
...member,
total: 1,
submitted: member.commitCount > 0 ? 1 : 0,
pending: member.commitCount > 0 ? 0 : 1,
notInstalled: 0,
}));
}
return students.map((student) => {
const studentItems = weekItems.filter((item) => item.student.group === selectedGroup?.name && item.student.id === student.id);
return {
...student,
@@ -227,9 +171,12 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
pending: studentItems.filter((item) => item.status === 'PENDING').length,
notInstalled: studentItems.filter((item) => item.status === 'NOT_INSTALLED').length,
};
}).filter((student) => student.total > 0), [selectedGroup?.name, students, weekItems]);
}).filter((student) => student.total > 0);
}, [selectedGroup?.name, students, teamProjectWeek, weekItems]);
const selectedStudent = studentNodes.find((student) => student.id === selectedStudentId) ?? null;
const detailItems = weekItems.filter((item) => item.student.id === selectedStudent?.id);
const detailItems = teamProjectWeek
? weekItems.filter((item) => item.team?.name === selectedGroup?.name)
: weekItems.filter((item) => item.student.id === selectedStudent?.id);
const selectedItem = detailItems.find((item) => item.id === selectedId) ?? null;
useEffect(() => {
@@ -291,8 +238,8 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
<div className="grid gap-3 rounded-lg border border-[#c7d2e0] bg-white p-3 xl:grid-cols-4">
<SelectionPanel icon={<BookOpen size={15} />} title="강좌" count={courseNodes.length}>
{loading ? <PanelLoadingState message="강좌를 불러오는 중입니다." /> : error ? <EmptyState message={error} /> : courseNodes.length ? courseNodes.map((course) => {
const courseItemCount = items.filter((item) => item.course.id === course.id).length;
const submittedCount = items.filter((item) => item.course.id === course.id && item.status === 'SUBMITTED').length;
const courseItemCount = allItems.filter((item) => item.course.id === course.id).length;
const submittedCount = allItems.filter((item) => item.course.id === course.id && item.status === 'SUBMITTED').length;
return (
<button
key={course.id}
@@ -331,7 +278,9 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-[#eef2ff] text-[12px] font-black text-[#4f46e5]">{week.week}</span>
<span className="min-w-0">
<span className="block truncate text-[13px] font-black text-[#111827]">{week.title}</span>
<span className="mt-1 block text-[11px] font-bold text-[#64748b]">제출 {week.submitted}/{week.total} · 미제출 {week.pending}</span>
<span className="mt-1 block text-[11px] font-bold text-[#64748b]">
{week.isTeamProject ? '팀 프로젝트 · ' : ''}제출 {week.submitted}/{week.total} · 미제출 {week.pending}
</span>
</span>
</button>
)) : <EmptyState message="조회된 주차가 없습니다." />}
@@ -357,14 +306,16 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
)) : <EmptyState message="조회된 그룹이 없습니다." />}
</SelectionPanel>
<SelectionPanel icon={<UserRound size={15} />} title="수강생" count={studentNodes.length}>
<SelectionPanel icon={teamProjectWeek ? <UsersRound size={15} /> : <UserRound size={15} />} title={teamProjectWeek ? '팀원' : '수강생'} count={studentNodes.length}>
{!selectedGroup ? <EmptyState message="그룹을 선택하세요." /> : studentNodes.length ? studentNodes.map((student) => (
<button
key={student.id}
type="button"
onClick={() => {
setSelectedStudentId(student.id);
const firstItem = weekItems.find((item) => item.student.id === student.id);
const firstItem = teamProjectWeek
? weekItems.find((item) => item.team?.name === selectedGroup?.name)
: weekItems.find((item) => item.student.id === student.id);
setSelectedId(firstItem?.id ?? null);
}}
className={selectionButtonClass(selectedStudent?.id === student.id)}
@@ -372,7 +323,11 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
<span className="min-w-0">
<span className="block truncate text-[13px] font-black text-[#111827]">{student.name}</span>
<span className="mt-1 block truncate text-[11px] font-bold text-[#64748b]">{student.email}</span>
<span className="mt-1 block text-[11px] font-bold text-[#64748b]">제출 {student.submitted}/{student.total} · 미제출 {student.pending}</span>
<span className="mt-1 block text-[11px] font-bold text-[#64748b]">
{teamProjectWeek
? `${student.commitCount ?? 0}개 커밋 · ${formatDate(student.lastCommittedAt ?? null)}`
: `제출 ${student.submitted}/${student.total} · 미제출 ${student.pending}`}
</span>
</span>
</button>
)) : <EmptyState message="조회된 수강생이 없습니다." />}
@@ -383,10 +338,17 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
<SubmissionDetail
item={selectedItem}
items={detailItems}
emptyMessage={selectedStudent ? '제출 내역이 없습니다.' : '수강생을 선택하세요.'}
emptyMessage={teamProjectWeek
? selectedGroup ? '팀 제출 내역이 없습니다.' : '그룹을 선택하세요.'
: selectedStudent ? '제출 내역이 없습니다.' : '수강생을 선택하세요.'}
teamMember={teamProjectWeek && selectedStudent ? {
id: selectedStudent.id,
name: selectedStudent.name,
email: selectedStudent.email,
} : null}
onSelect={setSelectedId}
onSaveScore={handleSaveScore}
onOpenPatch={(title, subtitle, files, activePath) => setPatchModal({ title, subtitle, files, activePath })}
onOpenPatch={(title, subtitle, files, activePath, repository) => setPatchModal({ title, subtitle, files, activePath, repository })}
/>
</section>
</section>
@@ -394,6 +356,7 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
{patchModal ? createPortal(
<PatchModal
modal={patchModal}
accessToken={session.accessToken}
onChangeActivePath={(activePath) => setPatchModal((current) => current ? { ...current, activePath } : current)}
onClose={() => setPatchModal(null)}
/>,
@@ -407,6 +370,7 @@ function SubmissionDetail({
item,
items,
emptyMessage,
teamMember,
onSelect,
onSaveScore,
onOpenPatch,
@@ -414,9 +378,16 @@ function SubmissionDetail({
item: InstructorSubmissionItem | null;
items: InstructorSubmissionItem[];
emptyMessage: string;
teamMember: { id: string; name: string; email: string } | null;
onSelect: (id: string) => void;
onSaveScore: (item: InstructorSubmissionItem, score: number) => Promise<number>;
onOpenPatch: (title: string, subtitle: string, files: CommitFile[], activePath: string) => void;
onOpenPatch: (
title: string,
subtitle: string,
files: CommitFile[],
activePath: string,
repository: NonNullable<InstructorSubmissionItem['repository']>,
) => void;
}) {
const [detailTab, setDetailTab] = useState<'final' | 'history'>('final');
const [scoreDraft, setScoreDraft] = useState('0');
@@ -430,7 +401,7 @@ function SubmissionDetail({
}, [item?.id, item?.score]);
const handleScoreSave = async () => {
if (!item) return;
if (!item || item.team) return;
const score = Number(scoreDraft);
if (!Number.isInteger(score) || score < 1 || score > 100) {
setScoreMessage({ tone: 'error', text: '1~100점으로 입력해 주세요.' });
@@ -473,14 +444,28 @@ function SubmissionDetail({
return <div className="grid min-h-[420px] place-items-center text-[13px] font-bold text-[#94a3b8]">{emptyMessage}</div>;
}
const teamProject = Boolean(item.team);
const visibleCommits = teamProject && teamMember
? item.commits.filter((commit) => commit.authorEmail.trim().toLowerCase() === teamMember.email.trim().toLowerCase())
: item.commits;
const ownerLabel = item.team?.name ?? item.student.name;
const ownerDescription = item.team
? `${item.team.members.length}명 · ${teamMember ? `${teamMember.name} 기여 내역` : '공동 저장소'}`
: item.student.email;
const openPatch = (title: string, subtitle: string, files: CommitFile[], activePath: string) => {
if (!item.repository) return;
onOpenPatch(title, subtitle, files, activePath, item.repository);
};
return (
<div className="min-w-0">
<div className="border-b border-[#cbd5e1] px-5 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[12px] font-black text-[#4f46e5]">{item.course.title}</p>
<h2 className="mt-1 truncate text-[17px] font-black text-[#111827]">{item.student.name} · {item.module.name}</h2>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">{item.week.week}주차 {item.week.title} · {item.student.email}</p>
<h2 className="mt-1 truncate text-[17px] font-black text-[#111827]">{ownerLabel} · {item.module.name}</h2>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">{item.week.week}주차 {item.week.title} · {ownerDescription}</p>
</div>
<span className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[12px] font-black ${statusTone[item.status]}`}>
{item.status === 'SUBMITTED' ? <CheckCircle2 size={14} /> : item.status === 'PENDING' ? <Clock3 size={14} /> : <XCircle size={14} />}
@@ -489,9 +474,9 @@ function SubmissionDetail({
</div>
</div>
<div className="grid max-h-[760px] overflow-hidden lg:grid-cols-[280px_minmax(0,1fr)]">
<div className="grid h-[min(760px,calc(100vh-260px))] min-h-[420px] overflow-hidden lg:grid-cols-[280px_minmax(0,1fr)]">
<div className="flex min-h-0 flex-col border-r border-[#cbd5e1] p-3">
<p className="mb-2 px-1 text-[11px] font-black text-[#64748b]">제출 내역</p>
<p className="mb-2 px-1 text-[11px] font-black text-[#64748b]">{teamProject ? '팀 모듈' : '제출 내역'}</p>
<div className="min-h-0 flex-1 overflow-y-auto">
{items.map((entry) => (
<button
@@ -510,7 +495,7 @@ function SubmissionDetail({
</button>
))}
</div>
<section className="mt-3 rounded-lg border border-[#d1d9e6] bg-[#f8fafc] p-3">
{!teamProject ? <section className="mt-3 rounded-lg border border-[#d1d9e6] bg-[#f8fafc] p-3">
<div className="flex items-center justify-between gap-3">
<p className="text-[12px] font-black text-[#334155]">과정 총점</p>
</div>
@@ -555,7 +540,7 @@ function SubmissionDetail({
{scoreMessage.text}
</p>
) : null}
</section>
</section> : null}
</div>
<div className="min-w-0 overflow-y-auto px-5 py-4">
@@ -604,17 +589,17 @@ function SubmissionDetail({
<button
key={`${item.id}-final-${file.path}`}
type="button"
onClick={() => onOpenPatch(
onClick={() => openPatch(
`${item.module.name} · 최종 변경사항`,
`${item.student.name} · ${formatDate(item.submittedAt)}`,
`${ownerLabel} · ${formatDate(item.submittedAt)}`,
item.finalFiles,
file.path,
)}
)}
className="group flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-3 text-left transition-[background-color,color] hover:bg-[#e2e8f0]"
>
<span className="inline-flex min-w-0 items-center gap-2">
<FileCode2 size={14} className="shrink-0 text-[#64748b]" />
<span className="truncate font-mono text-[12px] font-bold text-[#334155]">{file.path}</span>
<span className="truncate font-[NanumSquare] text-[12px] font-bold text-[#334155]">{file.path}</span>
</span>
<span className="shrink-0 rounded bg-[#f1f5f9] px-2 py-1 text-[10px] font-black uppercase text-[#64748b] transition-colors group-hover:bg-white">{file.status}</span>
</button>
@@ -626,15 +611,15 @@ function SubmissionDetail({
{item.status === 'NOT_INSTALLED' ? '아직 모듈 저장소가 생성되지 않았습니다.' : '최종 변경 파일이 없습니다.'}
</div>
)
) : item.commits.length ? (
) : visibleCommits.length ? (
<div className="grid gap-4">
{item.commits.map((commit) => (
{visibleCommits.map((commit) => (
<article key={commit.hash} className="min-w-0 overflow-hidden rounded-lg border border-[#d1d9e6] transition-[border-color,box-shadow] hover:border-[#c7d2fe] hover:shadow-[0_10px_26px_rgba(15,23,42,0.10)]">
<div className="border-b border-[#cbd5e1] bg-[#f8fafc] px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-[13px] font-black text-[#111827]">{commit.message}</p>
<p className="mt-1 text-[12px] font-semibold text-[#64748b]">{formatDate(commit.date)}</p>
<p className="mt-1 text-[12px] font-semibold text-[#64748b]">{teamProject ? `${commit.author} · ` : ''}{formatDate(commit.date)}</p>
</div>
<span className="inline-flex items-center gap-1.5 rounded-md bg-white px-2.5 py-1 font-mono text-[11px] font-black text-[#4f46e5]">
<GitCommitHorizontal size={13} />
@@ -647,9 +632,9 @@ function SubmissionDetail({
<button
key={`${commit.hash}-${file.path}`}
type="button"
onClick={() => onOpenPatch(
onClick={() => openPatch(
`${item.module.name} · ${commit.shortHash}`,
`${item.student.name} · ${formatDate(commit.date)}`,
`${ownerLabel} · ${formatDate(commit.date)}`,
commit.files,
file.path,
)}
@@ -657,7 +642,7 @@ function SubmissionDetail({
>
<span className="inline-flex min-w-0 items-center gap-2">
<FileCode2 size={14} className="shrink-0 text-[#64748b]" />
<span className="truncate font-mono text-[12px] font-bold text-[#334155]">{file.path}</span>
<span className="truncate font-[NanumSquare] text-[12px] font-bold text-[#334155]">{file.path}</span>
</span>
<span className="shrink-0 rounded bg-[#f1f5f9] px-2 py-1 text-[10px] font-black uppercase text-[#64748b] transition-colors group-hover:bg-white">{file.status}</span>
</button>
@@ -670,7 +655,9 @@ function SubmissionDetail({
</div>
) : (
<div className="grid h-[360px] place-items-center text-center text-[13px] font-bold text-[#94a3b8]">
{item.status === 'NOT_INSTALLED' ? '아직 모듈 저장소가 생성되지 않았습니다.' : '아직 제출된 커밋이 없습니다.'}
{item.status === 'NOT_INSTALLED'
? '아직 모듈 저장소가 생성되지 않았습니다.'
: teamMember ? '선택한 팀원의 반영 커밋이 없습니다.' : '아직 제출된 커밋이 없습니다.'}
</div>
)}
</div>
@@ -715,10 +702,12 @@ function PanelLoadingState({ message }: { message: string }) {
function PatchModal({
modal,
accessToken,
onChangeActivePath,
onClose,
}: {
modal: NonNullable<PatchModalState>;
accessToken: string;
onChangeActivePath: (path: string) => void;
onClose: () => void;
}) {
@@ -726,10 +715,57 @@ function PatchModal({
const visibleFiles = modal.files.filter((file) => !isIgnoredSubmissionFile(file.path));
const visibleActiveFile = visibleFiles.find((file) => file.path === activeFile?.path) ?? visibleFiles[0] ?? null;
const treeNodes = buildFileTree(visibleFiles);
const [content, setContent] = useState({
path: '',
original: '',
modified: '',
originalAvailable: false,
modifiedAvailable: false,
});
const [contentLoading, setContentLoading] = useState(false);
const [contentError, setContentError] = useState<string | null>(null);
useEffect(() => {
if (!visibleActiveFile) return;
let cancelled = false;
setContentLoading(true);
setContentError(null);
getInstructorSubmissionFile(accessToken, {
owner: modal.repository.owner,
repoName: modal.repository.name,
path: visibleActiveFile.path,
baseRef: visibleActiveFile.originalRef,
headRef: visibleActiveFile.modifiedRef,
})
.then((result) => {
if (!cancelled) setContent({ ...result, path: visibleActiveFile.path });
})
.catch((reason: Error) => {
if (cancelled) return;
setContent({
path: visibleActiveFile.path,
original: '',
modified: visibleActiveFile.patch,
originalAvailable: false,
modifiedAvailable: Boolean(visibleActiveFile.patch),
});
setContentError(reason.message);
})
.finally(() => {
if (!cancelled) setContentLoading(false);
});
return () => {
cancelled = true;
};
}, [accessToken, modal.repository.name, modal.repository.owner, visibleActiveFile]);
const isActiveFileContentLoaded = content.path === visibleActiveFile?.path;
return (
<div className="fixed inset-0 z-[80] grid place-items-center bg-[#0f172a]/34 px-6 py-6 backdrop-blur-[2px]">
<section className="grid h-[min(760px,calc(100vh-48px))] w-full max-w-[1240px] grid-rows-[78px_minmax(0,1fr)] overflow-hidden rounded-[14px] border border-white/70 bg-[#fbfdff] shadow-[0_34px_100px_rgba(15,23,42,0.30)]">
<section className="grid h-[min(780px,calc(100vh-48px))] w-full max-w-[1320px] grid-rows-[78px_minmax(0,1fr)] overflow-hidden rounded-[10px] border border-[#cfd8e6] bg-[#fbfdff] shadow-[0_34px_100px_rgba(15,23,42,0.30)]">
<header className="flex items-center justify-between border-b border-[#dfe6f1] bg-[#fbfdff] px-5">
<div className="flex min-w-0 items-center gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-md bg-[#eef2ff] text-[#4f46e5] shadow-[inset_0_0_0_1px_rgba(79,70,229,0.08)]">
@@ -750,13 +786,13 @@ function PatchModal({
</button>
</header>
<div className="grid min-h-0 grid-cols-[320px_minmax(0,1fr)]">
<aside className="min-h-0 border-r border-[#dfe6f1] bg-[#fbfdff]">
<div className="flex h-[52px] items-center justify-between px-5">
<span className="text-[13px] font-black text-[#334155]">파일 목록</span>
<span className="rounded-full bg-[#eef2f7] px-2.5 py-1 text-[11px] font-black text-[#475569]">{visibleFiles.length}개</span>
<div className="grid min-h-0 grid-cols-[300px_minmax(0,1fr)]">
<aside className="grid min-h-0 grid-rows-[38px_minmax(0,1fr)] border-r border-[#d9e0eb] bg-[#f3f3f3]">
<div className="flex items-center justify-between border-b border-[#d9e0eb] px-4">
<span className="text-[11px] font-black tracking-[0.08em] text-[#4b5563]">EXPLORER</span>
<span className="text-[11px] font-bold text-[#7b8797]">{visibleFiles.length}</span>
</div>
<div className="h-[calc(100%-52px)] overflow-auto px-4 pb-4">
<div className="min-h-0 overflow-auto py-2">
{treeNodes.map((node) => (
<FileTreeNode
key={node.path}
@@ -768,29 +804,69 @@ function PatchModal({
</div>
</aside>
<main className="grid min-h-0 grid-rows-[60px_minmax(0,1fr)] bg-[#fbfdff]">
<div className="flex items-center justify-between gap-3 px-5">
<main className="grid min-h-0 grid-rows-[38px_minmax(0,1fr)] bg-white">
<div className="flex items-center justify-between gap-3 border-b border-[#d9e0eb] px-4">
<div className="flex min-w-0 items-center gap-2.5">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md border border-[#d1d9e6] bg-white text-[#334155]">
<FileCode2 size={16} />
</span>
<FileCode2 size={15} className="shrink-0 text-[#64748b]" />
<span
className="min-w-0 truncate text-[14px] font-black text-[#1f2937]"
className="min-w-0 truncate font-mono text-[12px] font-bold text-[#1f2937]"
title={visibleActiveFile?.path ?? undefined}
>
{visibleActiveFile?.path.split('/').slice(-1)[0] ?? '파일 없음'}
{visibleActiveFile?.path ?? '파일 없음'}
</span>
</div>
{visibleActiveFile ? <span className="rounded-full bg-[#eef2ff] px-2.5 py-1 text-[10px] font-black uppercase text-[#4f46e5]">{visibleActiveFile.status}</span> : null}
{visibleActiveFile ? <span className="text-[10px] font-black uppercase text-[#4f46e5]">{visibleActiveFile.status}</span> : null}
</div>
<div className="min-h-0 overflow-auto px-5 pb-5">
{visibleActiveFile?.patch ? (
<div className="w-full min-w-0 overflow-hidden rounded-lg border border-[#d5deea] bg-white font-mono text-[11px] shadow-[0_8px_24px_rgba(15,23,42,0.06)]">
{parseDiffViewLines(visibleActiveFile.patch).map(renderDiffLine)}
</div>
) : (
<div className="grid h-full place-items-center text-[12px] font-bold text-[#94a3b8]">변경 내용 없음</div>
)}
<div className="relative min-h-0">
{visibleActiveFile && isActiveFileContentLoaded ? (
<DiffEditor
key={`${visibleActiveFile.path}:${visibleActiveFile.originalRef ?? 'empty'}:${visibleActiveFile.modifiedRef ?? 'empty'}`}
height="100%"
language={resolveMonacoLanguage(visibleActiveFile.path)}
original={content.original}
modified={content.modified}
originalModelPath={`original://${visibleActiveFile.originalRef ?? 'empty'}/${visibleActiveFile.path}`}
modifiedModelPath={`modified://${visibleActiveFile.modifiedRef ?? 'empty'}/${visibleActiveFile.path}`}
keepCurrentOriginalModel
keepCurrentModifiedModel
beforeMount={(instance) => {
instance.editor.defineTheme('autonomy-diff-light', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'diffEditor.insertedTextBackground': '#86efac',
'diffEditor.insertedLineBackground': '#dcfce7',
'diffEditor.removedTextBackground': '#fda4af',
'diffEditor.removedLineBackground': '#ffe4e6',
'diffEditorGutter.insertedLineBackground': '#4ade80',
'diffEditorGutter.removedLineBackground': '#fb7185',
},
});
}}
theme="autonomy-diff-light"
loading={<div className="grid h-full place-items-center text-[12px] font-bold text-[#94a3b8]">편집기를 불러오는 중입니다.</div>}
options={{
readOnly: true,
originalEditable: false,
renderSideBySide: true,
diffAlgorithm: 'advanced',
ignoreTrimWhitespace: false,
renderIndicators: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 12,
lineHeight: 19,
wordWrap: 'off',
diffWordWrap: 'off',
renderOverviewRuler: false,
hideUnchangedRegions: { enabled: false },
}}
/>
) : <div className="grid h-full place-items-center text-[12px] font-bold text-[#94a3b8]">{visibleActiveFile ? '파일 내용을 불러오는 중입니다.' : '표시할 파일이 없습니다.'}</div>}
<span className="pointer-events-none absolute inset-y-0 left-1/2 z-10 w-px bg-[#aebdce]" aria-hidden="true" />
{contentLoading ? <div className="absolute inset-0 grid place-items-center bg-white/72 text-[12px] font-bold text-[#64748b]">파일 내용을 불러오는 중입니다.</div> : null}
{contentError ? <p className="absolute bottom-3 left-3 rounded bg-[#fff7ed] px-2.5 py-1.5 text-[11px] font-bold text-[#c2410c]">{contentError}</p> : null}
</div>
</main>
</div>
@@ -803,32 +879,6 @@ function isIgnoredSubmissionFile(path: string) {
return path === '.gitignore' || path.endsWith('/.gitignore') || path === '.autonomy-studio-git.json';
}
function renderDiffLine(line: DiffViewLine) {
const rowClass = line.type === 'file'
? 'bg-[#edf4ff] text-[#4169e1]'
: line.type === 'hunk'
? 'bg-[#eef5ff] text-[#4169e1]'
: line.type === 'note'
? 'bg-[#fff7d6] text-[#9a5200]'
: line.type === 'add'
? 'bg-[#e8fff0] text-[#166534]'
: line.type === 'delete'
? 'bg-[#ffe3ea] text-[#9f1239]'
: 'bg-white text-[#263247]';
const marker = line.type === 'file' ? 'file' : line.type === 'add' ? '+' : line.type === 'delete' ? '-' : line.type === 'hunk' ? '@' : line.type === 'note' ? '!' : ' ';
return (
<div key={line.key} className={`grid min-w-0 grid-cols-[48px_48px_34px_minmax(0,1fr)] border-b border-[#dfe6f1] last:border-b-0 ${rowClass}`}>
<span className="px-2 py-1.5 text-right font-normal tabular-nums text-[#8b99ad]">{line.oldLine ?? ''}</span>
<span className="px-2 py-1.5 text-right font-normal tabular-nums text-[#8b99ad]">{line.newLine ?? ''}</span>
<span className="px-2 py-1.5 text-center font-semibold">{marker}</span>
<code className={`min-w-0 max-w-full whitespace-pre-wrap break-all px-3 py-1.5 font-normal ${line.type === 'file' || line.type === 'hunk' ? 'font-sans' : ''}`}>
{line.content || ' '}
</code>
</div>
);
}
type FileTreeNodeModel = {
name: string;
path: string;
@@ -878,18 +928,22 @@ function FileTreeNode({
depth?: number;
}) {
const active = node.kind === 'file' && node.path === activePath;
const [expanded, setExpanded] = useState(true);
if (node.kind === 'folder') {
return (
<div>
<div
className="mb-1 flex h-7 items-center gap-2 rounded-md pr-2 text-[11px] font-black text-[#64748b] transition-colors hover:bg-[#f8faff] hover:text-[#334155]"
style={{ paddingLeft: depth * 14 }}
<button
type="button"
onClick={() => setExpanded((current) => !current)}
className="flex h-7 w-full cursor-pointer items-center gap-1.5 pr-2 text-left text-[12px] font-bold text-[#4b5563] hover:bg-[#e5e5e5]"
style={{ paddingLeft: depth * 13 + 5 }}
title={node.path}
>
<FolderOpen size={14} className="shrink-0 text-[#94a3b8]" />
{expanded ? <ChevronDown size={14} className="shrink-0" /> : <ChevronRight size={14} className="shrink-0" />}
<FolderOpen size={14} className="shrink-0 text-[#718096]" />
<span className="truncate">{node.name}</span>
</div>
{node.children.map((child) => (
</button>
{expanded && node.children.map((child) => (
<FileTreeNode key={child.path} node={child} activePath={activePath} onSelect={onSelect} depth={depth + 1} />
))}
</div>
@@ -901,22 +955,33 @@ function FileTreeNode({
type="button"
onClick={() => onSelect(node.path)}
className={[
'mb-1.5 grid min-h-[58px] w-full cursor-pointer grid-cols-[24px_minmax(0,1fr)_auto] items-center gap-2 rounded-md border py-2 pr-2.5 text-left transition-[background-color,border-color,box-shadow,color]',
active ? 'border-[#c7d2fe] bg-[#eef2ff] text-[#4f46e5] shadow-[inset_4px_0_0_#4f46e5]' : 'border-transparent bg-transparent text-[#334155] hover:border-[#cbd5e1] hover:bg-[#f1f5f9] hover:shadow-[0_8px_18px_rgba(15,23,42,0.08)]',
'grid h-7 w-full cursor-pointer grid-cols-[18px_minmax(0,1fr)_auto] items-center gap-1.5 py-0.5 pr-2 text-left transition-colors',
active ? 'bg-[#dbeafe] text-[#1d4ed8]' : 'text-[#374151] hover:bg-[#e5e5e5]',
].join(' ')}
style={{ paddingLeft: depth * 12 + 10 }}
style={{ paddingLeft: depth * 13 + 21 }}
title={node.path}
>
<FileCode2 size={14} className="shrink-0 text-[#64748b]" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] font-black text-[#111827]">{node.name}</span>
<span className="mt-0.5 block truncate font-mono text-[10px] font-semibold text-[#64748b]">{node.path}</span>
</span>
{node.file ? <span className="shrink-0 rounded bg-[#eef2ff] px-1.5 py-0.5 text-[9px] font-black uppercase text-[#4f46e5]">{node.file.status}</span> : null}
<FileCode2 size={13} className="shrink-0 text-[#64748b]" />
<span className="min-w-0 truncate font-mono text-[11px] font-medium">{node.name}</span>
{node.file ? <span className="shrink-0 text-[9px] font-bold uppercase text-[#64748b]">{node.file.status}</span> : null}
</button>
);
}
function resolveMonacoLanguage(path: string) {
const extension = path.split('.').pop()?.toLowerCase();
if (extension === 'py') return 'python';
if (['ts', 'tsx'].includes(extension ?? '')) return 'typescript';
if (['js', 'jsx', 'mjs', 'cjs'].includes(extension ?? '')) return 'javascript';
if (['cpp', 'cc', 'cxx', 'hpp', 'hh', 'h'].includes(extension ?? '')) return 'cpp';
if (['yaml', 'yml'].includes(extension ?? '')) return 'yaml';
if (extension === 'json') return 'json';
if (['xml', 'urdf', 'sdf', 'xacro'].includes(extension ?? '')) return 'xml';
if (extension === 'md') return 'markdown';
if (extension === 'sh') return 'shell';
return 'plaintext';
}
function EmptyState({ message }: { message: string }) {
return <div className="grid min-h-[180px] place-items-center px-4 text-center text-[13px] font-bold text-[#94a3b8]">{message}</div>;
}