feat: add Monaco diff editor for submission changes
This commit is contained in:
Generated
+69
@@ -8,9 +8,11 @@
|
||||
"name": "lms-adm-fe",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"monaco-editor": "^0.56.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.18.0",
|
||||
@@ -748,6 +750,29 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@monaco-editor/loader": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
|
||||
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"state-local": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@monaco-editor/react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
|
||||
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@monaco-editor/loader": "^1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"monaco-editor": ">= 0.25.0 < 1",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -1460,6 +1485,13 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
@@ -1597,6 +1629,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.8",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
|
||||
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.364",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz",
|
||||
@@ -2061,6 +2102,28 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.56.0",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.56.0.tgz",
|
||||
"integrity": "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dompurify": "3.4.8",
|
||||
"marked": "14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -2310,6 +2373,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/state-local": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
|
||||
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"monaco-editor": "^0.56.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.18.0",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
return (['local', 'platform'] as const).map((storageKey) => {
|
||||
const savedProfile = (selectedWeek.environmentProfiles ?? []).find((profile) => environmentStorageKey(profile) === storageKey);
|
||||
return {
|
||||
id: `draft:${selectedWeek.week}:${storageKey}`,
|
||||
name: storageKey === 'local' ? '로컬' : '플랫폼',
|
||||
platformKey: storageKey,
|
||||
profileType: storageKey === 'local' ? 'LOCAL' : 'PLATFORM',
|
||||
name: storageKey === 'local' ? '로컬' : '플랫폼',
|
||||
config: savedProfile?.config ?? (storageKey === 'platform' ? { target: 'remote-jetson' } : { target: 'local' }),
|
||||
storageKey,
|
||||
platformKeys: [platform.key],
|
||||
platformKeys: [storageKey],
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return [...profilesByEnvironment.values()];
|
||||
}, [selectedWeek, visualizationEnabledByKey, visualizationPlatforms]);
|
||||
}, [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,6 +1194,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
||||
) : (
|
||||
<div className="grid gap-4 p-5">
|
||||
<section className="grid gap-3 pb-2">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-[12px] font-black text-[#475569]">주차 제목</span>
|
||||
<input
|
||||
@@ -1245,6 +1217,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="block h-[100px]">
|
||||
<span className="text-[12px] font-black text-[#475569]">주차 개요</span>
|
||||
<textarea
|
||||
@@ -1253,6 +1226,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
||||
onChange={(event) => setDraftOverview(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
|
||||
@@ -1262,6 +1236,18 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
||||
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>
|
||||
<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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]',
|
||||
|
||||
@@ -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,9 +589,9 @@ 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,
|
||||
)}
|
||||
@@ -614,7 +599,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>
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user