chore: init lms adm fe

This commit is contained in:
2026-07-07 17:23:40 +09:00
commit d682e9a382
57 changed files with 12065 additions and 0 deletions
+935
View File
@@ -0,0 +1,935 @@
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 { useToast } from '../../components/ToastProvider';
import { PageHeader } from '../../components/PageHeader';
import { 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;
} | null;
const statusLabel: Record<InstructorSubmissionItem['status'], string> = {
SUBMITTED: '제출완료',
PENDING: '미제출',
NOT_INSTALLED: '미설치',
};
const statusTone: Record<InstructorSubmissionItem['status'], string> = {
SUBMITTED: 'bg-[#ecfdf5] text-[#15803d]',
PENDING: 'bg-[#fff7ed] text-[#c2410c]',
NOT_INSTALLED: 'bg-[#f1f5f9] text-[#64748b]',
};
const parseDiffViewLines = (diff: string): DiffViewLine[] => {
const viewLines: DiffViewLine[] = [];
let oldLine = 0;
let newLine = 0;
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;
};
export function SubmissionsPage({ session }: { session: AuthSession }) {
const [items, setItems] = 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);
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null);
const [selectedWeekId, setSelectedWeekId] = useState<string | null>(null);
const [selectedGroupName, setSelectedGroupName] = useState<string | null>(null);
const [selectedStudentId, setSelectedStudentId] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [patchModal, setPatchModal] = useState<PatchModalState>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
listInstructorSubmissions(session.accessToken)
.then((payload) => {
if (cancelled) return;
setItems(payload.items);
setStudents(payload.students);
setSelectedCourseId((current) => current && payload.courses.some((course) => course.id === current) ? current : payload.courses[0]?.id ?? null);
setSelectedWeekId(null);
setSelectedGroupName(null);
setSelectedStudentId(null);
setSelectedId(null);
})
.catch((reason) => {
if (cancelled) return;
setError(reason instanceof Error ? reason.message : '제출 현황을 불러오지 못했습니다.');
setItems([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [session.accessToken]);
const courseNodes = useMemo(() => items.length
? Array.from(new Map(items.map((item) => [item.course.id, item.course])).values())
: [], [items]);
const selectedCourse = courseNodes.find((course) => course.id === selectedCourseId) ?? null;
const courseItems = useMemo(() => selectedCourse ? items.filter((item) => item.course.id === selectedCourse.id) : [], [items, selectedCourse]);
const weekNodes = useMemo(() => {
const byWeek = new Map<string, {
id: string;
week: number;
title: string;
total: number;
submitted: number;
pending: number;
notInstalled: number;
}>();
courseItems.forEach((item) => {
const current = byWeek.get(item.week.id) ?? {
id: item.week.id,
week: item.week.week,
title: item.week.title,
total: 0,
submitted: 0,
pending: 0,
notInstalled: 0,
};
current.total += 1;
if (item.status === 'SUBMITTED') current.submitted += 1;
if (item.status === 'PENDING') current.pending += 1;
if (item.status === 'NOT_INSTALLED') current.notInstalled += 1;
byWeek.set(item.week.id, current);
});
return Array.from(byWeek.values()).sort((a, b) => a.week - b.week);
}, [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 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 current = groupMap.get(group) ?? { name: group, total: 0, submitted: 0, pending: 0, notInstalled: 0 };
current.total += 1;
if (item.status === 'SUBMITTED') current.submitted += 1;
if (item.status === 'PENDING') current.pending += 1;
if (item.status === 'NOT_INSTALLED') current.notInstalled += 1;
groupMap.set(group, current);
});
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 studentItems = weekItems.filter((item) => item.student.group === selectedGroup?.name && item.student.id === student.id);
return {
...student,
total: studentItems.length,
submitted: studentItems.filter((item) => item.status === 'SUBMITTED').length,
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]);
const selectedStudent = studentNodes.find((student) => student.id === selectedStudentId) ?? null;
const detailItems = weekItems.filter((item) => item.student.id === selectedStudent?.id);
const selectedItem = detailItems.find((item) => item.id === selectedId) ?? null;
useEffect(() => {
if (selectedCourseId && courseNodes.some((course) => course.id === selectedCourseId)) return;
setSelectedCourseId(courseNodes[0]?.id ?? null);
setSelectedWeekId(null);
setSelectedGroupName(null);
setSelectedStudentId(null);
setSelectedId(null);
}, [courseNodes, selectedCourseId]);
useEffect(() => {
if (selectedWeekId && weekNodes.some((week) => week.id === selectedWeekId)) return;
setSelectedWeekId(null);
setSelectedGroupName(null);
setSelectedId(null);
setSelectedStudentId(null);
}, [selectedWeekId, weekNodes]);
useEffect(() => {
if (selectedGroupName && groupNodes.some((group) => group.name === selectedGroupName)) return;
setSelectedGroupName(null);
setSelectedStudentId(null);
setSelectedId(null);
}, [groupNodes, selectedGroupName]);
useEffect(() => {
if (selectedStudentId && studentNodes.some((student) => student.id === selectedStudentId)) return;
setSelectedStudentId(null);
setSelectedId(null);
}, [selectedStudentId, studentNodes, weekItems]);
useEffect(() => {
if (selectedId && detailItems.some((item) => item.id === selectedId)) return;
setSelectedId(null);
}, [detailItems, selectedId]);
const handleSaveScore = async (item: InstructorSubmissionItem, score: number) => {
const result = await saveInstructorSubmissionScore(session.accessToken, {
studentId: item.student.id,
weekId: item.week.id,
score,
});
setItems((current) => current.map((entry) => (
entry.student.id === item.student.id && entry.week.id === item.week.id
? { ...entry, score: result.score }
: entry
)));
return result.score;
};
return (
<div className="mx-auto max-w-[1320px]">
<PageHeader route="submissions" />
<section className="grid gap-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}>
{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;
return (
<button
key={course.id}
type="button"
onClick={() => {
setSelectedCourseId(course.id);
setSelectedWeekId(null);
setSelectedGroupName(null);
setSelectedStudentId(null);
setSelectedId(null);
}}
className={selectionButtonClass(selectedCourse?.id === course.id)}
>
<span className="min-w-0">
<span className="block truncate text-[13px] font-black text-[#111827]">{course.title}</span>
<span className="mt-1 block text-[11px] font-bold text-[#64748b]">제출 {submittedCount}/{courseItemCount}</span>
</span>
</button>
);
}) : <EmptyState message="조회된 강좌가 없습니다." />}
</SelectionPanel>
<SelectionPanel icon={<CalendarDays size={15} />} title="주차" count={weekNodes.length}>
{loading ? <PanelLoadingState message="주차를 불러오는 중입니다." /> : !selectedCourse ? <EmptyState message="강좌를 선택하세요." /> : weekNodes.length ? weekNodes.map((week) => (
<button
key={week.id}
type="button"
onClick={() => {
setSelectedWeekId(week.id);
setSelectedGroupName(null);
setSelectedStudentId(null);
setSelectedId(null);
}}
className={selectionButtonClass(selectedWeek?.id === week.id)}
>
<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>
</button>
)) : <EmptyState message="조회된 주차가 없습니다." />}
</SelectionPanel>
<SelectionPanel icon={<UsersRound size={15} />} title="그룹" count={groupNodes.length}>
{!selectedWeek ? <EmptyState message="주차를 선택하세요." /> : groupNodes.length ? groupNodes.map((group) => (
<button
key={group.name}
type="button"
onClick={() => {
setSelectedGroupName(group.name);
setSelectedStudentId(null);
setSelectedId(null);
}}
className={selectionButtonClass(selectedGroup?.name === group.name)}
>
<span className="min-w-0">
<span className="block truncate text-[13px] font-black text-[#111827]">{group.name}</span>
<span className="mt-1 block text-[11px] font-bold text-[#64748b]">제출 {group.submitted}/{group.total} · 미제출 {group.pending} · 미설치 {group.notInstalled}</span>
</span>
</button>
)) : <EmptyState message="조회된 그룹이 없습니다." />}
</SelectionPanel>
<SelectionPanel icon={<UserRound size={15} />} title="수강생" 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);
setSelectedId(firstItem?.id ?? null);
}}
className={selectionButtonClass(selectedStudent?.id === student.id)}
>
<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>
</button>
)) : <EmptyState message="조회된 수강생이 없습니다." />}
</SelectionPanel>
</div>
<section className="overflow-hidden rounded-lg border border-[#c7d2e0] bg-white">
<SubmissionDetail
item={selectedItem}
items={detailItems}
emptyMessage={selectedStudent ? '제출 내역이 없습니다.' : '수강생을 선택하세요.'}
onSelect={setSelectedId}
onSaveScore={handleSaveScore}
onOpenPatch={(title, subtitle, files, activePath) => setPatchModal({ title, subtitle, files, activePath })}
/>
</section>
</section>
{patchModal ? createPortal(
<PatchModal
modal={patchModal}
onChangeActivePath={(activePath) => setPatchModal((current) => current ? { ...current, activePath } : current)}
onClose={() => setPatchModal(null)}
/>,
document.body,
) : null}
</div>
);
}
function SubmissionDetail({
item,
items,
emptyMessage,
onSelect,
onSaveScore,
onOpenPatch,
}: {
item: InstructorSubmissionItem | null;
items: InstructorSubmissionItem[];
emptyMessage: string;
onSelect: (id: string) => void;
onSaveScore: (item: InstructorSubmissionItem, score: number) => Promise<number>;
onOpenPatch: (title: string, subtitle: string, files: CommitFile[], activePath: string) => void;
}) {
const [detailTab, setDetailTab] = useState<'final' | 'history'>('final');
const [scoreDraft, setScoreDraft] = useState('0');
const [scoreSaving, setScoreSaving] = useState(false);
const [scoreMessage, setScoreMessage] = useState<{ tone: 'success' | 'error'; text: string } | null>(null);
const { showToast } = useToast();
useEffect(() => {
setScoreDraft(String(item?.score ?? 0));
setScoreMessage(null);
}, [item?.id, item?.score]);
const handleScoreSave = async () => {
if (!item) return;
const score = Number(scoreDraft);
if (!Number.isInteger(score) || score < 1 || score > 100) {
setScoreMessage({ tone: 'error', text: '1~100점으로 입력해 주세요.' });
showToast({
title: '점수 저장 실패',
message: '총점은 1~100점으로 입력해 주세요.',
variant: 'error',
});
return;
}
setScoreSaving(true);
setScoreMessage(null);
try {
const savedScore = await onSaveScore(item, score);
setScoreDraft(String(savedScore));
setScoreMessage({ tone: 'success', text: '점수가 저장되었습니다.' });
showToast({
title: '점수가 저장되었습니다.',
message: `${item.student.name} · ${item.week.week}주차 과정 총점 ${savedScore}점`,
variant: 'success',
});
} catch (error) {
const message = error instanceof Error ? error.message : '점수를 저장하지 못했습니다.';
setScoreMessage({
tone: 'error',
text: message,
});
showToast({
title: '점수 저장 실패',
message,
variant: 'error',
});
} finally {
setScoreSaving(false);
}
};
if (!item) {
return <div className="grid min-h-[420px] place-items-center text-[13px] font-bold text-[#94a3b8]">{emptyMessage}</div>;
}
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>
</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} />}
{statusLabel[item.status]}
</span>
</div>
</div>
<div className="grid max-h-[760px] 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>
<div className="min-h-0 flex-1 overflow-y-auto">
{items.map((entry) => (
<button
key={entry.id}
type="button"
onClick={() => onSelect(entry.id)}
className={`${selectionButtonClass(entry.id === item.id)} mb-2 min-h-[62px]`}
>
<span className="min-w-0 flex-1">
<span className="flex items-start justify-between gap-2">
<span className="min-w-0 truncate text-[12px] font-black text-[#111827]">{entry.module.name}</span>
<span className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-black ${statusTone[entry.status]}`}>{statusLabel[entry.status]}</span>
</span>
<span className="mt-1 block text-[11px] font-bold text-[#64748b]">{entry.commitCount}개 커밋 · {formatDate(entry.submittedAt)}</span>
</span>
</button>
))}
</div>
<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>
<div className="mt-2 flex items-center gap-2">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={3}
value={scoreDraft}
onChange={(event) => {
const digits = event.target.value.replace(/\D/g, '');
if (!digits) {
setScoreDraft('');
return;
}
setScoreDraft(String(Math.min(100, Number(digits))));
}}
onBlur={() => {
if (!scoreDraft) setScoreDraft('0');
}}
className="h-10 min-w-0 flex-1 rounded-md border border-[#cbd5e1] bg-white px-3 text-right text-[14px] font-black text-[#111827] outline-none transition-colors focus:border-[#4f46e5]"
aria-label="총점"
/>
<span className="text-[12px] font-black text-[#64748b]">점</span>
<button
type="button"
onClick={handleScoreSave}
disabled={scoreSaving}
className="inline-flex h-10 cursor-pointer items-center gap-1.5 rounded-md bg-[#4f46e5] px-3 text-[12px] font-black text-white transition-colors hover:bg-[#4338ca] disabled:cursor-wait disabled:bg-[#a5b4fc]"
>
{scoreSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
저장
</button>
</div>
{scoreMessage ? (
<p className={[
'mt-2 text-[11px] font-bold',
scoreMessage.tone === 'success' ? 'text-[#15803d]' : 'text-[#dc2626]',
].join(' ')}
>
{scoreMessage.text}
</p>
) : null}
</section>
</div>
<div className="min-w-0 overflow-y-auto px-5 py-4">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="inline-flex rounded-lg bg-[#eef2f7] p-1">
<button
type="button"
onClick={() => setDetailTab('final')}
className={[
'h-8 cursor-pointer rounded-md px-3 text-[12px] font-black transition-colors',
detailTab === 'final' ? 'bg-white text-[#4f46e5] shadow-sm' : 'text-[#64748b] hover:text-[#111827]',
].join(' ')}
>
최종 변경사항
</button>
<button
type="button"
onClick={() => setDetailTab('history')}
className={[
'h-8 cursor-pointer rounded-md px-3 text-[12px] font-black transition-colors',
detailTab === 'history' ? 'bg-white text-[#4f46e5] shadow-sm' : 'text-[#64748b] hover:text-[#111827]',
].join(' ')}
>
제출 이력
</button>
</div>
<p className="text-[11px] font-bold text-[#94a3b8]">
최종 {item.finalFiles.length}개 파일 · 제출 {item.commitCount}회
</p>
</div>
{detailTab === 'final' ? (
item.finalFiles.length ? (
<article 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]">원본 대비 최종 변경사항</p>
<p className="mt-1 text-[12px] font-semibold text-[#64748b]">마지막 제출 기준 · {formatDate(item.submittedAt)}</p>
</div>
<span className="shrink-0 rounded bg-[#eef2ff] px-2.5 py-1 text-[11px] font-black text-[#4f46e5]">{item.finalFiles.length}개 파일</span>
</div>
</div>
<div className="divide-y divide-[#edf2f7]">
{item.finalFiles.map((file) => (
<button
key={`${item.id}-final-${file.path}`}
type="button"
onClick={() => onOpenPatch(
`${item.module.name} · 최종 변경사항`,
`${item.student.name} · ${formatDate(item.submittedAt)}`,
item.finalFiles,
file.path,
)}
className="group flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-3 text-left transition-[background-color,color] hover:bg-[#e2e8f0]"
>
<span className="inline-flex min-w-0 items-center gap-2">
<FileCode2 size={14} className="shrink-0 text-[#64748b]" />
<span className="truncate font-mono text-[12px] font-bold text-[#334155]">{file.path}</span>
</span>
<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>
))}
</div>
</article>
) : (
<div className="grid h-[360px] place-items-center text-center text-[13px] font-bold text-[#94a3b8]">
{item.status === 'NOT_INSTALLED' ? '아직 모듈 저장소가 생성되지 않았습니다.' : '최종 변경 파일이 없습니다.'}
</div>
)
) : item.commits.length ? (
<div className="grid gap-4">
{item.commits.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>
</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} />
{commit.shortHash}
</span>
</div>
</div>
<div className="divide-y divide-[#edf2f7]">
{commit.files.length ? commit.files.map((file) => (
<button
key={`${commit.hash}-${file.path}`}
type="button"
onClick={() => onOpenPatch(
`${item.module.name} · ${commit.shortHash}`,
`${item.student.name} · ${formatDate(commit.date)}`,
commit.files,
file.path,
)}
className="group flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-3 text-left transition-[background-color,color] hover:bg-[#e2e8f0]"
>
<span className="inline-flex min-w-0 items-center gap-2">
<FileCode2 size={14} className="shrink-0 text-[#64748b]" />
<span className="truncate font-mono text-[12px] font-bold text-[#334155]">{file.path}</span>
</span>
<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>
)) : (
<p className="px-4 py-4 text-[12px] font-bold text-[#94a3b8]">파일 목록 없음</p>
)}
</div>
</article>
))}
</div>
) : (
<div className="grid h-[360px] place-items-center text-center text-[13px] font-bold text-[#94a3b8]">
{item.status === 'NOT_INSTALLED' ? '아직 모듈 저장소가 생성되지 않았습니다.' : '아직 제출된 커밋이 없습니다.'}
</div>
)}
</div>
</div>
</div>
);
}
function SelectionPanel({ icon, title, count, children }: { icon: React.ReactNode; title: string; count: number; children: React.ReactNode }) {
return (
<section className="min-w-0 overflow-hidden rounded-lg border border-[#d1d9e6] bg-[#f8fafc]">
<div className="flex h-10 items-center justify-between border-b border-[#cbd5e1] px-3">
<span className="inline-flex items-center gap-2 text-[12px] font-black text-[#475569]">{icon}{title}</span>
<span className="text-[11px] font-black text-[#94a3b8]">{count}개</span>
</div>
<div className="grid max-h-[340px] min-h-[340px] gap-2 overflow-y-auto p-2 content-start">
{children}
</div>
</section>
);
}
function selectionButtonClass(active: boolean) {
return [
'relative flex min-h-[66px] w-full cursor-pointer items-center gap-3 overflow-hidden rounded-lg border py-2 pl-4 pr-3 text-left transition-[background-color,border-color,box-shadow] before:absolute before:left-0 before:top-0 before:h-full before:w-1.5 before:content-[""]',
active
? 'border-[#c7d2fe] bg-[#eef2ff] shadow-[0_8px_20px_rgba(79,70,229,0.10)] before:bg-[#2563eb]'
: 'border-[#cbd5e1] bg-white before:bg-transparent hover:border-[#c7d2fe] hover:bg-[#f8faff] hover:shadow-[0_8px_20px_rgba(79,70,229,0.10)] hover:before:bg-[#bfdbfe]',
].join(' ');
}
function PanelLoadingState({ message }: { message: string }) {
return (
<div className="grid min-h-[180px] place-items-center px-4 text-center text-[13px] font-bold text-[#64748b]">
<span className="inline-flex items-center gap-2">
<Loader2 className="animate-spin" size={17} aria-hidden="true" />
{message}
</span>
</div>
);
}
function PatchModal({
modal,
onChangeActivePath,
onClose,
}: {
modal: NonNullable<PatchModalState>;
onChangeActivePath: (path: string) => void;
onClose: () => void;
}) {
const activeFile = modal.files.find((file) => file.path === modal.activePath) ?? modal.files[0] ?? null;
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);
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)]">
<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)]">
<FileCode2 size={18} />
</span>
<div className="min-w-0">
<h2 className="truncate text-[16px] font-black text-[#111827]">{modal.title}</h2>
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{modal.subtitle}</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="grid h-9 w-9 cursor-pointer place-items-center rounded-md border border-[#dbe3ef] bg-white text-[#475569] shadow-sm transition-[background-color,border-color,color] hover:border-[#c7d2fe] hover:bg-[#f8faff] hover:text-[#111827]"
aria-label="닫기"
>
<X size={17} />
</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>
<div className="h-[calc(100%-52px)] overflow-auto px-4 pb-4">
{treeNodes.map((node) => (
<FileTreeNode
key={node.path}
node={node}
activePath={visibleActiveFile?.path ?? ''}
onSelect={onChangeActivePath}
/>
))}
</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">
<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>
<span
className="min-w-0 truncate text-[14px] font-black text-[#1f2937]"
title={visibleActiveFile?.path ?? undefined}
>
{visibleActiveFile?.path.split('/').slice(-1)[0] ?? '파일 없음'}
</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}
</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>
</main>
</div>
</section>
</div>
);
}
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;
kind: 'folder' | 'file';
file?: CommitFile;
children: FileTreeNodeModel[];
};
function buildFileTree(files: CommitFile[]): FileTreeNodeModel[] {
const roots: FileTreeNodeModel[] = [];
const nodeMap = new Map<string, FileTreeNodeModel>();
files.forEach((file) => {
const parts = file.path.split('/').filter(Boolean);
parts.forEach((part, index) => {
const path = parts.slice(0, index + 1).join('/');
const isFile = index === parts.length - 1;
if (!nodeMap.has(path)) {
const node: FileTreeNodeModel = {
name: part,
path,
kind: isFile ? 'file' : 'folder',
file: isFile ? file : undefined,
children: [],
};
nodeMap.set(path, node);
const parentPath = parts.slice(0, index).join('/');
const parent = parentPath ? nodeMap.get(parentPath) : null;
if (parent) parent.children.push(node);
else roots.push(node);
}
});
});
return roots;
}
function FileTreeNode({
node,
activePath,
onSelect,
depth = 0,
}: {
node: FileTreeNodeModel;
activePath: string;
onSelect: (path: string) => void;
depth?: number;
}) {
const active = node.kind === 'file' && node.path === activePath;
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 }}
title={node.path}
>
<FolderOpen size={14} className="shrink-0 text-[#94a3b8]" />
<span className="truncate">{node.name}</span>
</div>
{node.children.map((child) => (
<FileTreeNode key={child.path} node={child} activePath={activePath} onSelect={onSelect} depth={depth + 1} />
))}
</div>
);
}
return (
<button
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)]',
].join(' ')}
style={{ paddingLeft: depth * 12 + 10 }}
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}
</button>
);
}
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>;
}
function formatDate(value: string | null) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('ko-KR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}