platform/lms-adm-fe release promotion snapshot
This commit is contained in:
Generated
+69
@@ -8,9 +8,11 @@
|
|||||||
"name": "lms-adm-fe",
|
"name": "lms-adm-fe",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
|
"monaco-editor": "^0.56.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^7.18.0",
|
"react-router-dom": "^7.18.0",
|
||||||
@@ -748,6 +750,29 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -1460,6 +1485,13 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
@@ -1597,6 +1629,15 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.364",
|
"version": "1.5.364",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz",
|
"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"
|
"@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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -2310,6 +2373,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
||||||
|
|||||||
@@ -10,9 +10,11 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
|
"monaco-editor": "^0.56.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^7.18.0",
|
"react-router-dom": "^7.18.0",
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ export type CurriculumWeek = {
|
|||||||
references?: CurriculumReference[];
|
references?: CurriculumReference[];
|
||||||
exampleCodes?: CurriculumReference[];
|
exampleCodes?: CurriculumReference[];
|
||||||
deliverable: string;
|
deliverable: string;
|
||||||
requiresSourceSubmission: boolean;
|
|
||||||
};
|
};
|
||||||
modules?: CurriculumModule[];
|
modules?: CurriculumModule[];
|
||||||
environmentProfiles?: CurriculumEnvironmentProfile[];
|
environmentProfiles?: CurriculumEnvironmentProfile[];
|
||||||
visualizationPlatforms?: CurriculumVisualizationPlatform[];
|
visualizationPlatforms?: CurriculumVisualizationPlatform[];
|
||||||
mapKeys?: string[];
|
mapKeys?: string[];
|
||||||
|
isTeamProject?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CurriculumMap = {
|
export type CurriculumMap = {
|
||||||
@@ -145,7 +145,7 @@ export async function updateInstructorCurriculum(
|
|||||||
duration?: string;
|
duration?: string;
|
||||||
overview?: string;
|
overview?: string;
|
||||||
deliverable?: string;
|
deliverable?: string;
|
||||||
requiresSourceSubmission?: boolean;
|
isTeamProject?: boolean;
|
||||||
topics?: string[];
|
topics?: string[];
|
||||||
learningSteps?: Array<{
|
learningSteps?: Array<{
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export type InstructorSubmissionCommitFile = {
|
|||||||
path: string;
|
path: string;
|
||||||
status: string;
|
status: string;
|
||||||
patch: string;
|
patch: string;
|
||||||
|
originalRef?: string | null;
|
||||||
|
modifiedRef?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InstructorSubmissionCommit = {
|
export type InstructorSubmissionCommit = {
|
||||||
@@ -30,12 +32,14 @@ export type InstructorSubmissionItem = {
|
|||||||
id: string;
|
id: string;
|
||||||
week: number;
|
week: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
isTeamProject: boolean;
|
||||||
};
|
};
|
||||||
module: {
|
module: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
folderName: string;
|
folderName: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
sourceType?: 'CUSTOM' | 'GIT';
|
||||||
};
|
};
|
||||||
student: {
|
student: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -43,6 +47,17 @@ export type InstructorSubmissionItem = {
|
|||||||
email: string;
|
email: string;
|
||||||
group: string;
|
group: string;
|
||||||
};
|
};
|
||||||
|
team?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
members: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
commitCount: number;
|
||||||
|
lastCommittedAt: string | null;
|
||||||
|
}>;
|
||||||
|
} | null;
|
||||||
repository: {
|
repository: {
|
||||||
id: string;
|
id: string;
|
||||||
owner: string;
|
owner: string;
|
||||||
@@ -73,6 +88,7 @@ export type InstructorSubmissionsResponse = {
|
|||||||
id: string;
|
id: string;
|
||||||
week: number;
|
week: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
isTeamProject: boolean;
|
||||||
modules: Array<{
|
modules: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -88,6 +104,7 @@ export type InstructorSubmissionsResponse = {
|
|||||||
group: string;
|
group: string;
|
||||||
}>;
|
}>;
|
||||||
items: InstructorSubmissionItem[];
|
items: InstructorSubmissionItem[];
|
||||||
|
teamItems: InstructorSubmissionItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://workspace.autonomystudio.net/lms-api/api').replace(/\/+$/, '');
|
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>;
|
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(
|
export async function saveInstructorSubmissionScore(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
input: { studentId: string; weekId: string; score: number },
|
input: { studentId: string; weekId: string; score: number },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
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 { ConfirmDialog } from '../../components/ConfirmDialog';
|
||||||
import { PageHeader } from '../../components/PageHeader';
|
import { PageHeader } from '../../components/PageHeader';
|
||||||
import { useToast } from '../../components/ToastProvider';
|
import { useToast } from '../../components/ToastProvider';
|
||||||
@@ -26,8 +26,8 @@ function isPlatformEnvironment(config: unknown): boolean {
|
|||||||
return target === 'remote-jetson' || target === 'platform';
|
return target === 'remote-jetson' || target === 'platform';
|
||||||
}
|
}
|
||||||
|
|
||||||
function environmentStorageKey(profile: Pick<CurriculumEnvironmentProfile, 'config'>): 'local' | 'platform' {
|
function environmentStorageKey(profile: Pick<CurriculumEnvironmentProfile, 'config' | 'profileType'>): 'local' | 'platform' {
|
||||||
return isPlatformEnvironment(profile.config) ? 'platform' : 'local';
|
return profile.profileType.toUpperCase() === 'PLATFORM' || isPlatformEnvironment(profile.config) ? 'platform' : 'local';
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseModuleParameter(parameter: string): ModuleParameterDraft {
|
function parseModuleParameter(parameter: string): ModuleParameterDraft {
|
||||||
@@ -121,7 +121,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
const [draftDuration, setDraftDuration] = useState('');
|
const [draftDuration, setDraftDuration] = useState('');
|
||||||
const [draftOverview, setDraftOverview] = useState('');
|
const [draftOverview, setDraftOverview] = useState('');
|
||||||
const [draftDeliverable, setDraftDeliverable] = useState('');
|
const [draftDeliverable, setDraftDeliverable] = useState('');
|
||||||
const [draftRequiresSourceSubmission, setDraftRequiresSourceSubmission] = useState(true);
|
const [draftIsTeamProject, setDraftIsTeamProject] = useState(false);
|
||||||
const [draftTopics, setDraftTopics] = useState<string[]>([]);
|
const [draftTopics, setDraftTopics] = useState<string[]>([]);
|
||||||
const [draftTopicDetails, setDraftTopicDetails] = useState<string[]>([]);
|
const [draftTopicDetails, setDraftTopicDetails] = useState<string[]>([]);
|
||||||
const [draftTopicVideoUrls, setDraftTopicVideoUrls] = useState<string[]>([]);
|
const [draftTopicVideoUrls, setDraftTopicVideoUrls] = useState<string[]>([]);
|
||||||
@@ -173,39 +173,19 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
const editableEnvironmentProfiles = useMemo<EditableEnvironmentProfile[]>(() => {
|
const editableEnvironmentProfiles = useMemo<EditableEnvironmentProfile[]>(() => {
|
||||||
if (!selectedWeek) return [];
|
if (!selectedWeek) return [];
|
||||||
|
|
||||||
const savedProfiles = new globalThis.Map((selectedWeek.environmentProfiles ?? []).map((profile) => [profile.platformKey, profile]));
|
return (['local', 'platform'] as const).map((storageKey) => {
|
||||||
const profilesByEnvironment = new globalThis.Map<'local' | 'platform', EditableEnvironmentProfile>();
|
const savedProfile = (selectedWeek.environmentProfiles ?? []).find((profile) => environmentStorageKey(profile) === storageKey);
|
||||||
|
return {
|
||||||
visualizationPlatforms
|
id: `draft:${selectedWeek.week}:${storageKey}`,
|
||||||
.filter((platform) => visualizationEnabledByKey[platform.key])
|
platformKey: storageKey,
|
||||||
.forEach((platform) => {
|
profileType: storageKey === 'local' ? 'LOCAL' : 'PLATFORM',
|
||||||
const profile = savedProfiles.get(platform.key) ?? {
|
name: storageKey === 'local' ? '로컬' : '플랫폼',
|
||||||
id: `draft:${selectedWeek.week}:${platform.key}`,
|
config: savedProfile?.config ?? (storageKey === 'platform' ? { target: 'remote-jetson' } : { target: 'local' }),
|
||||||
platformKey: platform.key,
|
storageKey,
|
||||||
profileType: platform.type,
|
platformKeys: [storageKey],
|
||||||
name: platform.displayName,
|
};
|
||||||
config: platform.config && typeof platform.config === 'object' && !Array.isArray(platform.config)
|
});
|
||||||
? platform.config as Record<string, unknown>
|
}, [selectedWeek]);
|
||||||
: 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]);
|
|
||||||
|
|
||||||
const resolveProgressSelection = (nextWeeks: CurriculumWeek[], setting: InstructorCurriculumResponse['setting']) => {
|
const resolveProgressSelection = (nextWeeks: CurriculumWeek[], setting: InstructorCurriculumResponse['setting']) => {
|
||||||
const nextWeek = nextWeeks.find((week) => week.state === 'current')?.week
|
const nextWeek = nextWeeks.find((week) => week.state === 'current')?.week
|
||||||
@@ -300,7 +280,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
setDraftDuration(selectedWeek.duration);
|
setDraftDuration(selectedWeek.duration);
|
||||||
setDraftOverview(selectedWeek.detail.overview);
|
setDraftOverview(selectedWeek.detail.overview);
|
||||||
setDraftDeliverable(selectedWeek.detail.deliverable);
|
setDraftDeliverable(selectedWeek.detail.deliverable);
|
||||||
setDraftRequiresSourceSubmission(selectedWeek.detail.requiresSourceSubmission ?? true);
|
setDraftIsTeamProject(Boolean(selectedWeek.isTeamProject));
|
||||||
setDraftTopics(selectedWeek.topics.length ? selectedWeek.topics : ['']);
|
setDraftTopics(selectedWeek.topics.length ? selectedWeek.topics : ['']);
|
||||||
setDraftTopicDetails(selectedWeek.detail.topicDetails.length ? selectedWeek.detail.topicDetails : ['']);
|
setDraftTopicDetails(selectedWeek.detail.topicDetails.length ? selectedWeek.detail.topicDetails : ['']);
|
||||||
const topicVideoUrls = selectedWeek.topics.map((_, index) => selectedWeek.detail.topicVideoUrls?.[index] ?? '');
|
const topicVideoUrls = selectedWeek.topics.map((_, index) => selectedWeek.detail.topicVideoUrls?.[index] ?? '');
|
||||||
@@ -376,7 +356,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
duration: draftDuration,
|
duration: draftDuration,
|
||||||
overview: draftOverview,
|
overview: draftOverview,
|
||||||
deliverable: draftDeliverable,
|
deliverable: draftDeliverable,
|
||||||
requiresSourceSubmission: draftRequiresSourceSubmission,
|
isTeamProject: draftIsTeamProject,
|
||||||
topics: draftTopics.map((topic) => topic.trim()).filter(Boolean),
|
topics: draftTopics.map((topic) => topic.trim()).filter(Boolean),
|
||||||
learningSteps: buildDraftLearningSteps(),
|
learningSteps: buildDraftLearningSteps(),
|
||||||
topicDetails: draftTopicDetails,
|
topicDetails: draftTopicDetails,
|
||||||
@@ -424,7 +404,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
duration: draftDuration,
|
duration: draftDuration,
|
||||||
overview: draftOverview,
|
overview: draftOverview,
|
||||||
deliverable: draftDeliverable,
|
deliverable: draftDeliverable,
|
||||||
requiresSourceSubmission: draftRequiresSourceSubmission,
|
isTeamProject: draftIsTeamProject,
|
||||||
topics,
|
topics,
|
||||||
learningSteps: buildDraftLearningSteps(),
|
learningSteps: buildDraftLearningSteps(),
|
||||||
topicDetails: draftTopicDetails,
|
topicDetails: draftTopicDetails,
|
||||||
@@ -465,7 +445,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
duration: draftDuration,
|
duration: draftDuration,
|
||||||
overview: draftOverview,
|
overview: draftOverview,
|
||||||
deliverable: draftDeliverable,
|
deliverable: draftDeliverable,
|
||||||
requiresSourceSubmission: draftRequiresSourceSubmission,
|
isTeamProject: draftIsTeamProject,
|
||||||
topics: draftTopics.map((topic) => topic.trim()).filter(Boolean),
|
topics: draftTopics.map((topic) => topic.trim()).filter(Boolean),
|
||||||
learningSteps: buildDraftLearningSteps(),
|
learningSteps: buildDraftLearningSteps(),
|
||||||
topicDetails: draftTopicDetails,
|
topicDetails: draftTopicDetails,
|
||||||
@@ -577,7 +557,6 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
duration: '-',
|
duration: '-',
|
||||||
overview: '',
|
overview: '',
|
||||||
deliverable: '',
|
deliverable: '',
|
||||||
requiresSourceSubmission: true,
|
|
||||||
topics: ['학습단계 1'],
|
topics: ['학습단계 1'],
|
||||||
learningSteps: [{ title: '학습단계 1', description: '', videoEnabled: false, videoUrl: '', concepts: [] }],
|
learningSteps: [{ title: '학습단계 1', description: '', videoEnabled: false, videoUrl: '', concepts: [] }],
|
||||||
topicDetails: [''],
|
topicDetails: [''],
|
||||||
@@ -751,7 +730,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
<PageHeader route="curriculum" />
|
<PageHeader route="curriculum" />
|
||||||
|
|
||||||
<section className="mb-4 grid gap-3 md:grid-cols-3">
|
<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={currentProgressWeek ? `${currentProgressWeek}주차` : '-'} icon={RadioTower} />
|
||||||
<SummaryCard label="현재 학습단계" value={currentProgressLearningStep ?? '-'} icon={Target} />
|
<SummaryCard label="현재 학습단계" value={currentProgressLearningStep ?? '-'} icon={Target} />
|
||||||
</section>
|
</section>
|
||||||
@@ -988,6 +967,9 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
const globallyEnabled = moduleEnabledById[module.id] !== false;
|
const globallyEnabled = moduleEnabledById[module.id] !== false;
|
||||||
const checked = selectedModuleIds.has(module.id);
|
const checked = selectedModuleIds.has(module.id);
|
||||||
const parameterRows = environmentModuleParametersByPlatformKey[profile.storageKey]?.[module.id] ?? [];
|
const parameterRows = environmentModuleParametersByPlatformKey[profile.storageKey]?.[module.id] ?? [];
|
||||||
|
const dockerImage = environmentModuleDockerImagesByPlatformKey[profile.storageKey]?.[module.id]
|
||||||
|
|| module.dockerImage
|
||||||
|
|| '-';
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={module.id}
|
key={module.id}
|
||||||
@@ -1025,21 +1007,10 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
</label>
|
</label>
|
||||||
{checked ? (
|
{checked ? (
|
||||||
<div className="mt-2 border-t border-[#e2e8f0] pt-2">
|
<div className="mt-2 border-t border-[#e2e8f0] pt-2">
|
||||||
<label className="mb-2 block text-[10px] text-[#64748b]">
|
<div className="mb-2">
|
||||||
<span className="mb-1 block">실행 이미지</span>
|
<span className="block text-[10px] text-[#64748b]">실행 이미지</span>
|
||||||
<input
|
<span className="mt-0.5 block truncate font-mono text-[10px] font-medium text-[#475569]" title={dockerImage}>{dockerImage}</span>
|
||||||
value={environmentModuleDockerImagesByPlatformKey[profile.storageKey]?.[module.id] ?? ''}
|
</div>
|
||||||
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-1 flex items-center justify-between gap-2 text-[10px] text-[#64748b]">
|
<div className="mb-1 flex items-center justify-between gap-2 text-[10px] text-[#64748b]">
|
||||||
<span>실행 파라미터</span>
|
<span>실행 파라미터</span>
|
||||||
<button
|
<button
|
||||||
@@ -1223,28 +1194,30 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 p-5">
|
<div className="grid gap-4 p-5">
|
||||||
<section className="grid gap-3 pb-2">
|
<section className="grid gap-3 pb-2">
|
||||||
<label className="block">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<span className="text-[12px] font-black text-[#475569]">주차 제목</span>
|
<label className="block">
|
||||||
<input
|
<span className="text-[12px] font-black text-[#475569]">주차 제목</span>
|
||||||
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]"
|
<input
|
||||||
value={draftTitle}
|
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]"
|
||||||
onChange={(event) => setDraftTitle(event.target.value)}
|
value={draftTitle}
|
||||||
/>
|
onChange={(event) => setDraftTitle(event.target.value)}
|
||||||
</label>
|
/>
|
||||||
<label className="block">
|
</label>
|
||||||
<span className="text-[12px] font-black text-[#475569]">플랫폼 배지</span>
|
<label className="block">
|
||||||
<select
|
<span className="text-[12px] font-black text-[#475569]">플랫폼 배지</span>
|
||||||
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]"
|
<select
|
||||||
value={selectedTagLabel}
|
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]"
|
||||||
onChange={(event) => setDraftTagLabel(event.target.value)}
|
value={selectedTagLabel}
|
||||||
>
|
onChange={(event) => setDraftTagLabel(event.target.value)}
|
||||||
{tagOptions.length ? tagOptions.map((option) => (
|
>
|
||||||
<option key={option.key} value={option.label}>{option.label}</option>
|
{tagOptions.length ? tagOptions.map((option) => (
|
||||||
)) : (
|
<option key={option.key} value={option.label}>{option.label}</option>
|
||||||
<option value="없음">없음</option>
|
)) : (
|
||||||
)}
|
<option value="없음">없음</option>
|
||||||
</select>
|
)}
|
||||||
</label>
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<label className="block h-[100px]">
|
<label className="block h-[100px]">
|
||||||
<span className="text-[12px] font-black text-[#475569]">주차 개요</span>
|
<span className="text-[12px] font-black text-[#475569]">주차 개요</span>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -1253,15 +1226,28 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
onChange={(event) => setDraftOverview(event.target.value)}
|
onChange={(event) => setDraftOverview(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<span className="text-[12px] font-black text-[#475569]">예상 학습 시간</span>
|
<label className="block">
|
||||||
<input
|
<span className="text-[12px] font-black text-[#475569]">예상 학습 시간</span>
|
||||||
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]"
|
<input
|
||||||
value={draftDuration}
|
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]"
|
||||||
placeholder="예: 8-10시간"
|
value={draftDuration}
|
||||||
onChange={(event) => setDraftDuration(event.target.value)}
|
placeholder="예: 8-10시간"
|
||||||
/>
|
onChange={(event) => setDraftDuration(event.target.value)}
|
||||||
</label>
|
/>
|
||||||
|
</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>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -1421,18 +1407,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<h3 className="text-[15px] font-black text-[#111827]">제출 기준</h3>
|
||||||
<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
|
<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]"
|
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}
|
value={draftDeliverable}
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export function DashboardPage({ session, selectedCourseId, onNavigate, onOpenCha
|
|||||||
<section className="mt-4 rounded-lg border border-[#c7d2e0] bg-white">
|
<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 className="flex items-start justify-between gap-4 border-b border-[#cbd5e1] px-4 py-4">
|
||||||
<div>
|
<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>
|
<p className="mt-1 text-[12px] font-bold text-[#64748b]">강사 지원이 필요한 질문</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full bg-[#fff1f2] px-2.5 py-1 text-[13px] font-bold text-[#e11d48]">{waitingThreads.length} 건</span>
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="overflow-x-auto">
|
||||||
<div>
|
<div className="min-w-[1180px]">
|
||||||
<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="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>No.</span>
|
||||||
<span>이름</span>
|
<span>이름</span>
|
||||||
<span>이메일</span>
|
<span>이메일</span>
|
||||||
@@ -258,13 +258,13 @@ export function StudentsPage({ session, selectedCourseId }: { session: AuthSessi
|
|||||||
<EmptyState message={error} />
|
<EmptyState message={error} />
|
||||||
) : filteredStudents.length > 0 ? (
|
) : filteredStudents.length > 0 ? (
|
||||||
pagedStudents.map((student, index) => (
|
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="번호">
|
<Cell label="번호">
|
||||||
<span className="text-[13px] font-bold text-[#64748b]">{filteredStudents.length - ((page - 1) * pageSize + index)}</span>
|
<span className="text-[13px] font-bold text-[#64748b]">{filteredStudents.length - ((page - 1) * pageSize + index)}</span>
|
||||||
</Cell>
|
</Cell>
|
||||||
|
|
||||||
<div className="min-w-0 text-center">
|
<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>
|
<h3 className="truncate text-[14px] font-black text-[#111827]">{student.name}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@ export function StudentsPage({ session, selectedCourseId }: { session: AuthSessi
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-center justify-between text-[12px] font-bold text-[#475569]">
|
<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>
|
<span className="font-semibold text-[#4f46e5]">{student.progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 overflow-hidden rounded-full bg-[#dbe3ef]">
|
<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 }) {
|
function Cell({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 text-center">
|
<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}
|
{children}
|
||||||
</div>
|
</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 }) {
|
function StudentMetric({ icon: Icon, label, value, danger = false }: { icon: typeof GitBranch; label: string; value: number; danger?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 text-center">
|
<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" />
|
<Icon size={13} aria-hidden="true" />
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -514,7 +514,7 @@ function StudentIssueMetric({ student, onOpen }: { student: InstructorStudent; o
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 text-center">
|
<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" />
|
<CircleAlert size={13} aria-hidden="true" />
|
||||||
이슈
|
이슈
|
||||||
</div>
|
</div>
|
||||||
@@ -525,7 +525,7 @@ function StudentIssueMetric({ student, onOpen }: { student: InstructorStudent; o
|
|||||||
}}
|
}}
|
||||||
disabled={!hasIssues}
|
disabled={!hasIssues}
|
||||||
className={[
|
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
|
hasIssues
|
||||||
? 'cursor-pointer bg-[#fff1f2] text-[#e11d48] transition-colors hover:bg-[#ffe4e6]'
|
? 'cursor-pointer bg-[#fff1f2] text-[#e11d48] transition-colors hover:bg-[#ffe4e6]'
|
||||||
: 'cursor-default bg-transparent text-[#111827]',
|
: 'cursor-default bg-transparent text-[#111827]',
|
||||||
|
|||||||
@@ -1,26 +1,34 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
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 { useToast } from '../../components/ToastProvider';
|
||||||
import { PageHeader } from '../../components/PageHeader';
|
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';
|
import type { AuthSession } from '../../types/auth';
|
||||||
|
|
||||||
type CommitFile = InstructorSubmissionItem['commits'][number]['files'][number];
|
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 = {
|
type PatchModalState = {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
files: CommitFile[];
|
files: CommitFile[];
|
||||||
activePath: string;
|
activePath: string;
|
||||||
|
repository: NonNullable<InstructorSubmissionItem['repository']>;
|
||||||
} | null;
|
} | 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> = {
|
const statusLabel: Record<InstructorSubmissionItem['status'], string> = {
|
||||||
SUBMITTED: '제출완료',
|
SUBMITTED: '제출완료',
|
||||||
@@ -34,101 +42,19 @@ const statusTone: Record<InstructorSubmissionItem['status'], string> = {
|
|||||||
NOT_INSTALLED: 'bg-[#f1f5f9] text-[#64748b]',
|
NOT_INSTALLED: 'bg-[#f1f5f9] text-[#64748b]',
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseDiffViewLines = (diff: string): DiffViewLine[] => {
|
loader.config({ monaco });
|
||||||
const viewLines: DiffViewLine[] = [];
|
|
||||||
let oldLine = 0;
|
|
||||||
let newLine = 0;
|
|
||||||
|
|
||||||
diff.split('\n').forEach((line, index) => {
|
// Vite serves this app below /lms-adm/. Monaco's default worker URL ignores
|
||||||
if (line.startsWith('diff --git ')) {
|
// that base path, so provide the worker explicitly for editor and diff tasks.
|
||||||
const match = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
(globalThis as typeof globalThis & {
|
||||||
viewLines.push({
|
MonacoEnvironment?: { getWorker: () => Worker };
|
||||||
key: `file-${index}`,
|
}).MonacoEnvironment = {
|
||||||
type: 'file',
|
getWorker: () => new EditorWorker(),
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SubmissionsPage({ session }: { session: AuthSession }) {
|
export function SubmissionsPage({ session }: { session: AuthSession }) {
|
||||||
const [items, setItems] = useState<InstructorSubmissionItem[]>([]);
|
const [items, setItems] = useState<InstructorSubmissionItem[]>([]);
|
||||||
|
const [teamItems, setTeamItems] = useState<InstructorSubmissionItem[]>([]);
|
||||||
const [students, setStudents] = useState<Array<{ id: string; name: string; email: string }>>([]);
|
const [students, setStudents] = useState<Array<{ id: string; name: string; email: string }>>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -148,6 +74,7 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
|
|||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setItems(payload.items);
|
setItems(payload.items);
|
||||||
|
setTeamItems(payload.teamItems);
|
||||||
setStudents(payload.students);
|
setStudents(payload.students);
|
||||||
setSelectedCourseId((current) => current && payload.courses.some((course) => course.id === current) ? current : payload.courses[0]?.id ?? null);
|
setSelectedCourseId((current) => current && payload.courses.some((course) => course.id === current) ? current : payload.courses[0]?.id ?? null);
|
||||||
setSelectedWeekId(null);
|
setSelectedWeekId(null);
|
||||||
@@ -159,6 +86,7 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setError(reason instanceof Error ? reason.message : '제출 현황을 불러오지 못했습니다.');
|
setError(reason instanceof Error ? reason.message : '제출 현황을 불러오지 못했습니다.');
|
||||||
setItems([]);
|
setItems([]);
|
||||||
|
setTeamItems([]);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
@@ -169,16 +97,18 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
|
|||||||
};
|
};
|
||||||
}, [session.accessToken]);
|
}, [session.accessToken]);
|
||||||
|
|
||||||
const courseNodes = useMemo(() => items.length
|
const allItems = useMemo(() => [...items, ...teamItems], [items, teamItems]);
|
||||||
? Array.from(new Map(items.map((item) => [item.course.id, item.course])).values())
|
const courseNodes = useMemo(() => allItems.length
|
||||||
: [], [items]);
|
? Array.from(new Map(allItems.map((item) => [item.course.id, item.course])).values())
|
||||||
|
: [], [allItems]);
|
||||||
const selectedCourse = courseNodes.find((course) => course.id === selectedCourseId) ?? null;
|
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 weekNodes = useMemo(() => {
|
||||||
const byWeek = new Map<string, {
|
const byWeek = new Map<string, {
|
||||||
id: string;
|
id: string;
|
||||||
week: number;
|
week: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
isTeamProject: boolean;
|
||||||
total: number;
|
total: number;
|
||||||
submitted: number;
|
submitted: number;
|
||||||
pending: number;
|
pending: number;
|
||||||
@@ -189,6 +119,7 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
|
|||||||
id: item.week.id,
|
id: item.week.id,
|
||||||
week: item.week.week,
|
week: item.week.week,
|
||||||
title: item.week.title,
|
title: item.week.title,
|
||||||
|
isTeamProject: item.week.isTeamProject,
|
||||||
total: 0,
|
total: 0,
|
||||||
submitted: 0,
|
submitted: 0,
|
||||||
pending: 0,
|
pending: 0,
|
||||||
@@ -204,10 +135,11 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
|
|||||||
}, [courseItems]);
|
}, [courseItems]);
|
||||||
const selectedWeek = weekNodes.find((week) => week.id === selectedWeekId) ?? null;
|
const selectedWeek = weekNodes.find((week) => week.id === selectedWeekId) ?? null;
|
||||||
const weekItems = useMemo(() => selectedWeek ? courseItems.filter((item) => item.week.id === selectedWeek.id) : [], [courseItems, selectedWeek]);
|
const weekItems = useMemo(() => selectedWeek ? courseItems.filter((item) => item.week.id === selectedWeek.id) : [], [courseItems, selectedWeek]);
|
||||||
|
const teamProjectWeek = Boolean(selectedWeek?.isTeamProject);
|
||||||
const groupNodes = useMemo(() => {
|
const groupNodes = useMemo(() => {
|
||||||
const groupMap = new Map<string, { name: string; total: number; submitted: number; pending: number; notInstalled: number }>();
|
const groupMap = new Map<string, { name: string; total: number; submitted: number; pending: number; notInstalled: number }>();
|
||||||
weekItems.forEach((item) => {
|
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 };
|
const current = groupMap.get(group) ?? { name: group, total: 0, submitted: 0, pending: 0, notInstalled: 0 };
|
||||||
current.total += 1;
|
current.total += 1;
|
||||||
if (item.status === 'SUBMITTED') current.submitted += 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'));
|
return Array.from(groupMap.values()).sort((a, b) => a.name.localeCompare(b.name, 'ko'));
|
||||||
}, [weekItems]);
|
}, [weekItems]);
|
||||||
const selectedGroup = groupNodes.find((group) => group.name === selectedGroupName) ?? null;
|
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);
|
const studentItems = weekItems.filter((item) => item.student.group === selectedGroup?.name && item.student.id === student.id);
|
||||||
return {
|
return {
|
||||||
...student,
|
...student,
|
||||||
@@ -227,9 +171,12 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
|
|||||||
pending: studentItems.filter((item) => item.status === 'PENDING').length,
|
pending: studentItems.filter((item) => item.status === 'PENDING').length,
|
||||||
notInstalled: studentItems.filter((item) => item.status === 'NOT_INSTALLED').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 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;
|
const selectedItem = detailItems.find((item) => item.id === selectedId) ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
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">
|
<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}>
|
<SelectionPanel icon={<BookOpen size={15} />} title="강좌" count={courseNodes.length}>
|
||||||
{loading ? <PanelLoadingState message="강좌를 불러오는 중입니다." /> : error ? <EmptyState message={error} /> : courseNodes.length ? courseNodes.map((course) => {
|
{loading ? <PanelLoadingState message="강좌를 불러오는 중입니다." /> : error ? <EmptyState message={error} /> : courseNodes.length ? courseNodes.map((course) => {
|
||||||
const courseItemCount = items.filter((item) => item.course.id === course.id).length;
|
const courseItemCount = allItems.filter((item) => item.course.id === course.id).length;
|
||||||
const submittedCount = items.filter((item) => item.course.id === course.id && item.status === 'SUBMITTED').length;
|
const submittedCount = allItems.filter((item) => item.course.id === course.id && item.status === 'SUBMITTED').length;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={course.id}
|
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="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="min-w-0">
|
||||||
<span className="block truncate text-[13px] font-black text-[#111827]">{week.title}</span>
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)) : <EmptyState message="조회된 주차가 없습니다." />}
|
)) : <EmptyState message="조회된 주차가 없습니다." />}
|
||||||
@@ -357,14 +306,16 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
|
|||||||
)) : <EmptyState message="조회된 그룹이 없습니다." />}
|
)) : <EmptyState message="조회된 그룹이 없습니다." />}
|
||||||
</SelectionPanel>
|
</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) => (
|
{!selectedGroup ? <EmptyState message="그룹을 선택하세요." /> : studentNodes.length ? studentNodes.map((student) => (
|
||||||
<button
|
<button
|
||||||
key={student.id}
|
key={student.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedStudentId(student.id);
|
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);
|
setSelectedId(firstItem?.id ?? null);
|
||||||
}}
|
}}
|
||||||
className={selectionButtonClass(selectedStudent?.id === student.id)}
|
className={selectionButtonClass(selectedStudent?.id === student.id)}
|
||||||
@@ -372,7 +323,11 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
|
|||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block truncate text-[13px] font-black text-[#111827]">{student.name}</span>
|
<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 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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)) : <EmptyState message="조회된 수강생이 없습니다." />}
|
)) : <EmptyState message="조회된 수강생이 없습니다." />}
|
||||||
@@ -383,10 +338,17 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
|
|||||||
<SubmissionDetail
|
<SubmissionDetail
|
||||||
item={selectedItem}
|
item={selectedItem}
|
||||||
items={detailItems}
|
items={detailItems}
|
||||||
emptyMessage={selectedStudent ? '제출 내역이 없습니다.' : '수강생을 선택하세요.'}
|
emptyMessage={teamProjectWeek
|
||||||
|
? selectedGroup ? '팀 제출 내역이 없습니다.' : '그룹을 선택하세요.'
|
||||||
|
: selectedStudent ? '제출 내역이 없습니다.' : '수강생을 선택하세요.'}
|
||||||
|
teamMember={teamProjectWeek && selectedStudent ? {
|
||||||
|
id: selectedStudent.id,
|
||||||
|
name: selectedStudent.name,
|
||||||
|
email: selectedStudent.email,
|
||||||
|
} : null}
|
||||||
onSelect={setSelectedId}
|
onSelect={setSelectedId}
|
||||||
onSaveScore={handleSaveScore}
|
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>
|
||||||
</section>
|
</section>
|
||||||
@@ -394,6 +356,7 @@ export function SubmissionsPage({ session }: { session: AuthSession }) {
|
|||||||
{patchModal ? createPortal(
|
{patchModal ? createPortal(
|
||||||
<PatchModal
|
<PatchModal
|
||||||
modal={patchModal}
|
modal={patchModal}
|
||||||
|
accessToken={session.accessToken}
|
||||||
onChangeActivePath={(activePath) => setPatchModal((current) => current ? { ...current, activePath } : current)}
|
onChangeActivePath={(activePath) => setPatchModal((current) => current ? { ...current, activePath } : current)}
|
||||||
onClose={() => setPatchModal(null)}
|
onClose={() => setPatchModal(null)}
|
||||||
/>,
|
/>,
|
||||||
@@ -407,6 +370,7 @@ function SubmissionDetail({
|
|||||||
item,
|
item,
|
||||||
items,
|
items,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
|
teamMember,
|
||||||
onSelect,
|
onSelect,
|
||||||
onSaveScore,
|
onSaveScore,
|
||||||
onOpenPatch,
|
onOpenPatch,
|
||||||
@@ -414,9 +378,16 @@ function SubmissionDetail({
|
|||||||
item: InstructorSubmissionItem | null;
|
item: InstructorSubmissionItem | null;
|
||||||
items: InstructorSubmissionItem[];
|
items: InstructorSubmissionItem[];
|
||||||
emptyMessage: string;
|
emptyMessage: string;
|
||||||
|
teamMember: { id: string; name: string; email: string } | null;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
onSaveScore: (item: InstructorSubmissionItem, score: number) => Promise<number>;
|
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 [detailTab, setDetailTab] = useState<'final' | 'history'>('final');
|
||||||
const [scoreDraft, setScoreDraft] = useState('0');
|
const [scoreDraft, setScoreDraft] = useState('0');
|
||||||
@@ -430,7 +401,7 @@ function SubmissionDetail({
|
|||||||
}, [item?.id, item?.score]);
|
}, [item?.id, item?.score]);
|
||||||
|
|
||||||
const handleScoreSave = async () => {
|
const handleScoreSave = async () => {
|
||||||
if (!item) return;
|
if (!item || item.team) return;
|
||||||
const score = Number(scoreDraft);
|
const score = Number(scoreDraft);
|
||||||
if (!Number.isInteger(score) || score < 1 || score > 100) {
|
if (!Number.isInteger(score) || score < 1 || score > 100) {
|
||||||
setScoreMessage({ tone: 'error', text: '1~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>;
|
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 (
|
return (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="border-b border-[#cbd5e1] px-5 py-4">
|
<div className="border-b border-[#cbd5e1] px-5 py-4">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[12px] font-black text-[#4f46e5]">{item.course.title}</p>
|
<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>
|
<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} · {item.student.email}</p>
|
<p className="mt-1 text-[12px] font-bold text-[#64748b]">{item.week.week}주차 {item.week.title} · {ownerDescription}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[12px] font-black ${statusTone[item.status]}`}>
|
<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} />}
|
{item.status === 'SUBMITTED' ? <CheckCircle2 size={14} /> : item.status === 'PENDING' ? <Clock3 size={14} /> : <XCircle size={14} />}
|
||||||
@@ -489,9 +474,9 @@ function SubmissionDetail({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
{items.map((entry) => (
|
{items.map((entry) => (
|
||||||
<button
|
<button
|
||||||
@@ -510,7 +495,7 @@ function SubmissionDetail({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<p className="text-[12px] font-black text-[#334155]">과정 총점</p>
|
<p className="text-[12px] font-black text-[#334155]">과정 총점</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -555,7 +540,7 @@ function SubmissionDetail({
|
|||||||
{scoreMessage.text}
|
{scoreMessage.text}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 overflow-y-auto px-5 py-4">
|
<div className="min-w-0 overflow-y-auto px-5 py-4">
|
||||||
@@ -604,17 +589,17 @@ function SubmissionDetail({
|
|||||||
<button
|
<button
|
||||||
key={`${item.id}-final-${file.path}`}
|
key={`${item.id}-final-${file.path}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpenPatch(
|
onClick={() => openPatch(
|
||||||
`${item.module.name} · 최종 변경사항`,
|
`${item.module.name} · 최종 변경사항`,
|
||||||
`${item.student.name} · ${formatDate(item.submittedAt)}`,
|
`${ownerLabel} · ${formatDate(item.submittedAt)}`,
|
||||||
item.finalFiles,
|
item.finalFiles,
|
||||||
file.path,
|
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]"
|
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">
|
<span className="inline-flex min-w-0 items-center gap-2">
|
||||||
<FileCode2 size={14} className="shrink-0 text-[#64748b]" />
|
<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>
|
||||||
<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>
|
<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>
|
</button>
|
||||||
@@ -626,15 +611,15 @@ function SubmissionDetail({
|
|||||||
{item.status === 'NOT_INSTALLED' ? '아직 모듈 저장소가 생성되지 않았습니다.' : '최종 변경 파일이 없습니다.'}
|
{item.status === 'NOT_INSTALLED' ? '아직 모듈 저장소가 생성되지 않았습니다.' : '최종 변경 파일이 없습니다.'}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : item.commits.length ? (
|
) : visibleCommits.length ? (
|
||||||
<div className="grid gap-4">
|
<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)]">
|
<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="border-b border-[#cbd5e1] bg-[#f8fafc] px-4 py-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-[13px] font-black text-[#111827]">{commit.message}</p>
|
<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>
|
</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]">
|
<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} />
|
<GitCommitHorizontal size={13} />
|
||||||
@@ -647,9 +632,9 @@ function SubmissionDetail({
|
|||||||
<button
|
<button
|
||||||
key={`${commit.hash}-${file.path}`}
|
key={`${commit.hash}-${file.path}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpenPatch(
|
onClick={() => openPatch(
|
||||||
`${item.module.name} · ${commit.shortHash}`,
|
`${item.module.name} · ${commit.shortHash}`,
|
||||||
`${item.student.name} · ${formatDate(commit.date)}`,
|
`${ownerLabel} · ${formatDate(commit.date)}`,
|
||||||
commit.files,
|
commit.files,
|
||||||
file.path,
|
file.path,
|
||||||
)}
|
)}
|
||||||
@@ -657,7 +642,7 @@ function SubmissionDetail({
|
|||||||
>
|
>
|
||||||
<span className="inline-flex min-w-0 items-center gap-2">
|
<span className="inline-flex min-w-0 items-center gap-2">
|
||||||
<FileCode2 size={14} className="shrink-0 text-[#64748b]" />
|
<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>
|
||||||
<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>
|
<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>
|
</button>
|
||||||
@@ -670,7 +655,9 @@ function SubmissionDetail({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid h-[360px] place-items-center text-center text-[13px] font-bold text-[#94a3b8]">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -715,10 +702,12 @@ function PanelLoadingState({ message }: { message: string }) {
|
|||||||
|
|
||||||
function PatchModal({
|
function PatchModal({
|
||||||
modal,
|
modal,
|
||||||
|
accessToken,
|
||||||
onChangeActivePath,
|
onChangeActivePath,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
modal: NonNullable<PatchModalState>;
|
modal: NonNullable<PatchModalState>;
|
||||||
|
accessToken: string;
|
||||||
onChangeActivePath: (path: string) => void;
|
onChangeActivePath: (path: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -726,10 +715,57 @@ function PatchModal({
|
|||||||
const visibleFiles = modal.files.filter((file) => !isIgnoredSubmissionFile(file.path));
|
const visibleFiles = modal.files.filter((file) => !isIgnoredSubmissionFile(file.path));
|
||||||
const visibleActiveFile = visibleFiles.find((file) => file.path === activeFile?.path) ?? visibleFiles[0] ?? null;
|
const visibleActiveFile = visibleFiles.find((file) => file.path === activeFile?.path) ?? visibleFiles[0] ?? null;
|
||||||
const treeNodes = buildFileTree(visibleFiles);
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 z-[80] grid place-items-center bg-[#0f172a]/34 px-6 py-6 backdrop-blur-[2px]">
|
<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">
|
<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">
|
<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)]">
|
<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>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid min-h-0 grid-cols-[320px_minmax(0,1fr)]">
|
<div className="grid min-h-0 grid-cols-[300px_minmax(0,1fr)]">
|
||||||
<aside className="min-h-0 border-r border-[#dfe6f1] bg-[#fbfdff]">
|
<aside className="grid min-h-0 grid-rows-[38px_minmax(0,1fr)] border-r border-[#d9e0eb] bg-[#f3f3f3]">
|
||||||
<div className="flex h-[52px] items-center justify-between px-5">
|
<div className="flex items-center justify-between border-b border-[#d9e0eb] px-4">
|
||||||
<span className="text-[13px] font-black text-[#334155]">파일 목록</span>
|
<span className="text-[11px] font-black tracking-[0.08em] text-[#4b5563]">EXPLORER</span>
|
||||||
<span className="rounded-full bg-[#eef2f7] px-2.5 py-1 text-[11px] font-black text-[#475569]">{visibleFiles.length}개</span>
|
<span className="text-[11px] font-bold text-[#7b8797]">{visibleFiles.length}</span>
|
||||||
</div>
|
</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) => (
|
{treeNodes.map((node) => (
|
||||||
<FileTreeNode
|
<FileTreeNode
|
||||||
key={node.path}
|
key={node.path}
|
||||||
@@ -768,29 +804,69 @@ function PatchModal({
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="grid min-h-0 grid-rows-[60px_minmax(0,1fr)] bg-[#fbfdff]">
|
<main className="grid min-h-0 grid-rows-[38px_minmax(0,1fr)] bg-white">
|
||||||
<div className="flex items-center justify-between gap-3 px-5">
|
<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">
|
<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={15} className="shrink-0 text-[#64748b]" />
|
||||||
<FileCode2 size={16} />
|
|
||||||
</span>
|
|
||||||
<span
|
<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}
|
title={visibleActiveFile?.path ?? undefined}
|
||||||
>
|
>
|
||||||
{visibleActiveFile?.path.split('/').slice(-1)[0] ?? '파일 없음'}
|
{visibleActiveFile?.path ?? '파일 없음'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
<div className="min-h-0 overflow-auto px-5 pb-5">
|
<div className="relative min-h-0">
|
||||||
{visibleActiveFile?.patch ? (
|
{visibleActiveFile && isActiveFileContentLoaded ? (
|
||||||
<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)]">
|
<DiffEditor
|
||||||
{parseDiffViewLines(visibleActiveFile.patch).map(renderDiffLine)}
|
key={`${visibleActiveFile.path}:${visibleActiveFile.originalRef ?? 'empty'}:${visibleActiveFile.modifiedRef ?? 'empty'}`}
|
||||||
</div>
|
height="100%"
|
||||||
) : (
|
language={resolveMonacoLanguage(visibleActiveFile.path)}
|
||||||
<div className="grid h-full place-items-center text-[12px] font-bold text-[#94a3b8]">변경 내용 없음</div>
|
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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -803,32 +879,6 @@ function isIgnoredSubmissionFile(path: string) {
|
|||||||
return path === '.gitignore' || path.endsWith('/.gitignore') || path === '.autonomy-studio-git.json';
|
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 = {
|
type FileTreeNodeModel = {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -878,18 +928,22 @@ function FileTreeNode({
|
|||||||
depth?: number;
|
depth?: number;
|
||||||
}) {
|
}) {
|
||||||
const active = node.kind === 'file' && node.path === activePath;
|
const active = node.kind === 'file' && node.path === activePath;
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
if (node.kind === 'folder') {
|
if (node.kind === 'folder') {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<button
|
||||||
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]"
|
type="button"
|
||||||
style={{ paddingLeft: depth * 14 }}
|
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}
|
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>
|
<span className="truncate">{node.name}</span>
|
||||||
</div>
|
</button>
|
||||||
{node.children.map((child) => (
|
{expanded && node.children.map((child) => (
|
||||||
<FileTreeNode key={child.path} node={child} activePath={activePath} onSelect={onSelect} depth={depth + 1} />
|
<FileTreeNode key={child.path} node={child} activePath={activePath} onSelect={onSelect} depth={depth + 1} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -901,22 +955,33 @@ function FileTreeNode({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(node.path)}
|
onClick={() => onSelect(node.path)}
|
||||||
className={[
|
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]',
|
'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 ? '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)]',
|
active ? 'bg-[#dbeafe] text-[#1d4ed8]' : 'text-[#374151] hover:bg-[#e5e5e5]',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
style={{ paddingLeft: depth * 12 + 10 }}
|
style={{ paddingLeft: depth * 13 + 21 }}
|
||||||
title={node.path}
|
title={node.path}
|
||||||
>
|
>
|
||||||
<FileCode2 size={14} className="shrink-0 text-[#64748b]" />
|
<FileCode2 size={13} className="shrink-0 text-[#64748b]" />
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 truncate font-mono text-[11px] font-medium">{node.name}</span>
|
||||||
<span className="block truncate text-[12px] font-black text-[#111827]">{node.name}</span>
|
{node.file ? <span className="shrink-0 text-[9px] font-bold uppercase text-[#64748b]">{node.file.status}</span> : null}
|
||||||
<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}
|
|
||||||
</button>
|
</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 }) {
|
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>;
|
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