1001 lines
45 KiB
TypeScript
1001 lines
45 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { DiffEditor, loader } from '@monaco-editor/react';
|
|
import * as monaco from 'monaco-editor';
|
|
import EditorWorker from '../../../node_modules/monaco-editor/esm/vs/editor/editor.worker.js?worker';
|
|
import { BookOpen, CalendarDays, CheckCircle2, ChevronDown, ChevronRight, Clock3, FileCode2, FolderOpen, GitCommitHorizontal, Loader2, Save, UsersRound, UserRound, X, XCircle } from 'lucide-react';
|
|
|
|
import { useToast } from '../../components/ToastProvider';
|
|
import { PageHeader } from '../../components/PageHeader';
|
|
import { getInstructorSubmissionFile, listInstructorSubmissions, saveInstructorSubmissionScore, type InstructorSubmissionItem } from '../../lib/submissions';
|
|
import type { AuthSession } from '../../types/auth';
|
|
|
|
type CommitFile = InstructorSubmissionItem['commits'][number]['files'][number];
|
|
type PatchModalState = {
|
|
title: string;
|
|
subtitle: string;
|
|
files: CommitFile[];
|
|
activePath: string;
|
|
repository: NonNullable<InstructorSubmissionItem['repository']>;
|
|
} | null;
|
|
type SubmissionSelectionMember = {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
total: number;
|
|
submitted: number;
|
|
pending: number;
|
|
notInstalled: number;
|
|
commitCount?: number;
|
|
lastCommittedAt?: string | null;
|
|
};
|
|
|
|
const statusLabel: Record<InstructorSubmissionItem['status'], string> = {
|
|
SUBMITTED: '제출완료',
|
|
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]',
|
|
};
|
|
|
|
loader.config({ monaco });
|
|
|
|
// Vite serves this app below /lms-adm/. Monaco's default worker URL ignores
|
|
// that base path, so provide the worker explicitly for editor and diff tasks.
|
|
(globalThis as typeof globalThis & {
|
|
MonacoEnvironment?: { getWorker: () => Worker };
|
|
}).MonacoEnvironment = {
|
|
getWorker: () => new EditorWorker(),
|
|
};
|
|
|
|
export function SubmissionsPage({ session }: { session: AuthSession }) {
|
|
const [items, setItems] = useState<InstructorSubmissionItem[]>([]);
|
|
const [teamItems, setTeamItems] = useState<InstructorSubmissionItem[]>([]);
|
|
const [students, setStudents] = useState<Array<{ id: string; name: string; email: string }>>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
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);
|
|
setTeamItems(payload.teamItems);
|
|
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([]);
|
|
setTeamItems([]);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [session.accessToken]);
|
|
|
|
const allItems = useMemo(() => [...items, ...teamItems], [items, teamItems]);
|
|
const courseNodes = useMemo(() => allItems.length
|
|
? Array.from(new Map(allItems.map((item) => [item.course.id, item.course])).values())
|
|
: [], [allItems]);
|
|
const selectedCourse = courseNodes.find((course) => course.id === selectedCourseId) ?? null;
|
|
const courseItems = useMemo(() => selectedCourse ? allItems.filter((item) => item.course.id === selectedCourse.id) : [], [allItems, selectedCourse]);
|
|
const weekNodes = useMemo(() => {
|
|
const byWeek = new Map<string, {
|
|
id: string;
|
|
week: number;
|
|
title: string;
|
|
isTeamProject: boolean;
|
|
total: number;
|
|
submitted: number;
|
|
pending: number;
|
|
notInstalled: number;
|
|
}>();
|
|
courseItems.forEach((item) => {
|
|
const current = byWeek.get(item.week.id) ?? {
|
|
id: item.week.id,
|
|
week: item.week.week,
|
|
title: item.week.title,
|
|
isTeamProject: item.week.isTeamProject,
|
|
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 teamProjectWeek = Boolean(selectedWeek?.isTeamProject);
|
|
const groupNodes = useMemo(() => {
|
|
const groupMap = new Map<string, { name: string; total: number; submitted: number; pending: number; notInstalled: number }>();
|
|
weekItems.forEach((item) => {
|
|
const group = item.team?.name ?? item.student.group ?? '미배정';
|
|
const current = groupMap.get(group) ?? { name: group, total: 0, submitted: 0, pending: 0, notInstalled: 0 };
|
|
current.total += 1;
|
|
if (item.status === 'SUBMITTED') current.submitted += 1;
|
|
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<SubmissionSelectionMember[]>(() => {
|
|
if (teamProjectWeek) {
|
|
const team = weekItems.find((item) => item.team?.name === selectedGroup?.name)?.team;
|
|
return (team?.members ?? []).map((member) => ({
|
|
...member,
|
|
total: 1,
|
|
submitted: member.commitCount > 0 ? 1 : 0,
|
|
pending: member.commitCount > 0 ? 0 : 1,
|
|
notInstalled: 0,
|
|
}));
|
|
}
|
|
|
|
return students.map((student) => {
|
|
const studentItems = weekItems.filter((item) => item.student.group === selectedGroup?.name && item.student.id === student.id);
|
|
return {
|
|
...student,
|
|
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, teamProjectWeek, weekItems]);
|
|
const selectedStudent = studentNodes.find((student) => student.id === selectedStudentId) ?? null;
|
|
const detailItems = teamProjectWeek
|
|
? weekItems.filter((item) => item.team?.name === selectedGroup?.name)
|
|
: weekItems.filter((item) => item.student.id === selectedStudent?.id);
|
|
const selectedItem = detailItems.find((item) => item.id === selectedId) ?? null;
|
|
|
|
useEffect(() => {
|
|
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 = allItems.filter((item) => item.course.id === course.id).length;
|
|
const submittedCount = allItems.filter((item) => item.course.id === course.id && item.status === 'SUBMITTED').length;
|
|
return (
|
|
<button
|
|
key={course.id}
|
|
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.isTeamProject ? '팀 프로젝트 · ' : ''}제출 {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={teamProjectWeek ? <UsersRound size={15} /> : <UserRound size={15} />} title={teamProjectWeek ? '팀원' : '수강생'} count={studentNodes.length}>
|
|
{!selectedGroup ? <EmptyState message="그룹을 선택하세요." /> : studentNodes.length ? studentNodes.map((student) => (
|
|
<button
|
|
key={student.id}
|
|
type="button"
|
|
onClick={() => {
|
|
setSelectedStudentId(student.id);
|
|
const firstItem = teamProjectWeek
|
|
? weekItems.find((item) => item.team?.name === selectedGroup?.name)
|
|
: weekItems.find((item) => item.student.id === student.id);
|
|
setSelectedId(firstItem?.id ?? null);
|
|
}}
|
|
className={selectionButtonClass(selectedStudent?.id === student.id)}
|
|
>
|
|
<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]">
|
|
{teamProjectWeek
|
|
? `${student.commitCount ?? 0}개 커밋 · ${formatDate(student.lastCommittedAt ?? null)}`
|
|
: `제출 ${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={teamProjectWeek
|
|
? selectedGroup ? '팀 제출 내역이 없습니다.' : '그룹을 선택하세요.'
|
|
: selectedStudent ? '제출 내역이 없습니다.' : '수강생을 선택하세요.'}
|
|
teamMember={teamProjectWeek && selectedStudent ? {
|
|
id: selectedStudent.id,
|
|
name: selectedStudent.name,
|
|
email: selectedStudent.email,
|
|
} : null}
|
|
onSelect={setSelectedId}
|
|
onSaveScore={handleSaveScore}
|
|
onOpenPatch={(title, subtitle, files, activePath, repository) => setPatchModal({ title, subtitle, files, activePath, repository })}
|
|
/>
|
|
</section>
|
|
</section>
|
|
|
|
{patchModal ? createPortal(
|
|
<PatchModal
|
|
modal={patchModal}
|
|
accessToken={session.accessToken}
|
|
onChangeActivePath={(activePath) => setPatchModal((current) => current ? { ...current, activePath } : current)}
|
|
onClose={() => setPatchModal(null)}
|
|
/>,
|
|
document.body,
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SubmissionDetail({
|
|
item,
|
|
items,
|
|
emptyMessage,
|
|
teamMember,
|
|
onSelect,
|
|
onSaveScore,
|
|
onOpenPatch,
|
|
}: {
|
|
item: InstructorSubmissionItem | null;
|
|
items: InstructorSubmissionItem[];
|
|
emptyMessage: string;
|
|
teamMember: { id: string; name: string; email: string } | null;
|
|
onSelect: (id: string) => void;
|
|
onSaveScore: (item: InstructorSubmissionItem, score: number) => Promise<number>;
|
|
onOpenPatch: (
|
|
title: string,
|
|
subtitle: string,
|
|
files: CommitFile[],
|
|
activePath: string,
|
|
repository: NonNullable<InstructorSubmissionItem['repository']>,
|
|
) => 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 || item.team) 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>;
|
|
}
|
|
|
|
const teamProject = Boolean(item.team);
|
|
const visibleCommits = teamProject && teamMember
|
|
? item.commits.filter((commit) => commit.authorEmail.trim().toLowerCase() === teamMember.email.trim().toLowerCase())
|
|
: item.commits;
|
|
const ownerLabel = item.team?.name ?? item.student.name;
|
|
const ownerDescription = item.team
|
|
? `${item.team.members.length}명 · ${teamMember ? `${teamMember.name} 기여 내역` : '공동 저장소'}`
|
|
: item.student.email;
|
|
|
|
const openPatch = (title: string, subtitle: string, files: CommitFile[], activePath: string) => {
|
|
if (!item.repository) return;
|
|
onOpenPatch(title, subtitle, files, activePath, item.repository);
|
|
};
|
|
|
|
return (
|
|
<div className="min-w-0">
|
|
<div className="border-b border-[#cbd5e1] px-5 py-4">
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="text-[12px] font-black text-[#4f46e5]">{item.course.title}</p>
|
|
<h2 className="mt-1 truncate text-[17px] font-black text-[#111827]">{ownerLabel} · {item.module.name}</h2>
|
|
<p className="mt-1 text-[12px] font-bold text-[#64748b]">{item.week.week}주차 {item.week.title} · {ownerDescription}</p>
|
|
</div>
|
|
<span className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[12px] font-black ${statusTone[item.status]}`}>
|
|
{item.status === 'SUBMITTED' ? <CheckCircle2 size={14} /> : item.status === 'PENDING' ? <Clock3 size={14} /> : <XCircle size={14} />}
|
|
{statusLabel[item.status]}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid h-[min(760px,calc(100vh-260px))] min-h-[420px] overflow-hidden lg:grid-cols-[280px_minmax(0,1fr)]">
|
|
<div className="flex min-h-0 flex-col border-r border-[#cbd5e1] p-3">
|
|
<p className="mb-2 px-1 text-[11px] font-black text-[#64748b]">{teamProject ? '팀 모듈' : '제출 내역'}</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>
|
|
{!teamProject ? <section className="mt-3 rounded-lg border border-[#d1d9e6] bg-[#f8fafc] p-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-[12px] font-black text-[#334155]">과정 총점</p>
|
|
</div>
|
|
<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> : null}
|
|
</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={() => openPatch(
|
|
`${item.module.name} · 최종 변경사항`,
|
|
`${ownerLabel} · ${formatDate(item.submittedAt)}`,
|
|
item.finalFiles,
|
|
file.path,
|
|
)}
|
|
className="group flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-3 text-left transition-[background-color,color] hover:bg-[#e2e8f0]"
|
|
>
|
|
<span className="inline-flex min-w-0 items-center gap-2">
|
|
<FileCode2 size={14} className="shrink-0 text-[#64748b]" />
|
|
<span className="truncate font-[NanumSquare] text-[12px] font-bold text-[#334155]">{file.path}</span>
|
|
</span>
|
|
<span className="shrink-0 rounded bg-[#f1f5f9] px-2 py-1 text-[10px] font-black uppercase text-[#64748b] transition-colors group-hover:bg-white">{file.status}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</article>
|
|
) : (
|
|
<div className="grid h-[360px] place-items-center text-center text-[13px] font-bold text-[#94a3b8]">
|
|
{item.status === 'NOT_INSTALLED' ? '아직 모듈 저장소가 생성되지 않았습니다.' : '최종 변경 파일이 없습니다.'}
|
|
</div>
|
|
)
|
|
) : visibleCommits.length ? (
|
|
<div className="grid gap-4">
|
|
{visibleCommits.map((commit) => (
|
|
<article key={commit.hash} className="min-w-0 overflow-hidden rounded-lg border border-[#d1d9e6] transition-[border-color,box-shadow] hover:border-[#c7d2fe] hover:shadow-[0_10px_26px_rgba(15,23,42,0.10)]">
|
|
<div className="border-b border-[#cbd5e1] bg-[#f8fafc] px-4 py-3">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="truncate text-[13px] font-black text-[#111827]">{commit.message}</p>
|
|
<p className="mt-1 text-[12px] font-semibold text-[#64748b]">{teamProject ? `${commit.author} · ` : ''}{formatDate(commit.date)}</p>
|
|
</div>
|
|
<span className="inline-flex items-center gap-1.5 rounded-md bg-white px-2.5 py-1 font-mono text-[11px] font-black text-[#4f46e5]">
|
|
<GitCommitHorizontal size={13} />
|
|
{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={() => openPatch(
|
|
`${item.module.name} · ${commit.shortHash}`,
|
|
`${ownerLabel} · ${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-[NanumSquare] text-[12px] font-bold text-[#334155]">{file.path}</span>
|
|
</span>
|
|
<span className="shrink-0 rounded bg-[#f1f5f9] px-2 py-1 text-[10px] font-black uppercase text-[#64748b] transition-colors group-hover:bg-white">{file.status}</span>
|
|
</button>
|
|
)) : (
|
|
<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'
|
|
? '아직 모듈 저장소가 생성되지 않았습니다.'
|
|
: teamMember ? '선택한 팀원의 반영 커밋이 없습니다.' : '아직 제출된 커밋이 없습니다.'}
|
|
</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,
|
|
accessToken,
|
|
onChangeActivePath,
|
|
onClose,
|
|
}: {
|
|
modal: NonNullable<PatchModalState>;
|
|
accessToken: string;
|
|
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);
|
|
const [content, setContent] = useState({
|
|
path: '',
|
|
original: '',
|
|
modified: '',
|
|
originalAvailable: false,
|
|
modifiedAvailable: false,
|
|
});
|
|
const [contentLoading, setContentLoading] = useState(false);
|
|
const [contentError, setContentError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!visibleActiveFile) return;
|
|
let cancelled = false;
|
|
setContentLoading(true);
|
|
setContentError(null);
|
|
|
|
getInstructorSubmissionFile(accessToken, {
|
|
owner: modal.repository.owner,
|
|
repoName: modal.repository.name,
|
|
path: visibleActiveFile.path,
|
|
baseRef: visibleActiveFile.originalRef,
|
|
headRef: visibleActiveFile.modifiedRef,
|
|
})
|
|
.then((result) => {
|
|
if (!cancelled) setContent({ ...result, path: visibleActiveFile.path });
|
|
})
|
|
.catch((reason: Error) => {
|
|
if (cancelled) return;
|
|
setContent({
|
|
path: visibleActiveFile.path,
|
|
original: '',
|
|
modified: visibleActiveFile.patch,
|
|
originalAvailable: false,
|
|
modifiedAvailable: Boolean(visibleActiveFile.patch),
|
|
});
|
|
setContentError(reason.message);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setContentLoading(false);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [accessToken, modal.repository.name, modal.repository.owner, visibleActiveFile]);
|
|
|
|
const isActiveFileContentLoaded = content.path === visibleActiveFile?.path;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[80] grid place-items-center bg-[#0f172a]/34 px-6 py-6 backdrop-blur-[2px]">
|
|
<section className="grid h-[min(780px,calc(100vh-48px))] w-full max-w-[1320px] grid-rows-[78px_minmax(0,1fr)] overflow-hidden rounded-[10px] border border-[#cfd8e6] bg-[#fbfdff] shadow-[0_34px_100px_rgba(15,23,42,0.30)]">
|
|
<header className="flex items-center justify-between border-b border-[#dfe6f1] bg-[#fbfdff] px-5">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-md bg-[#eef2ff] text-[#4f46e5] shadow-[inset_0_0_0_1px_rgba(79,70,229,0.08)]">
|
|
<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-[300px_minmax(0,1fr)]">
|
|
<aside className="grid min-h-0 grid-rows-[38px_minmax(0,1fr)] border-r border-[#d9e0eb] bg-[#f3f3f3]">
|
|
<div className="flex items-center justify-between border-b border-[#d9e0eb] px-4">
|
|
<span className="text-[11px] font-black tracking-[0.08em] text-[#4b5563]">EXPLORER</span>
|
|
<span className="text-[11px] font-bold text-[#7b8797]">{visibleFiles.length}</span>
|
|
</div>
|
|
<div className="min-h-0 overflow-auto py-2">
|
|
{treeNodes.map((node) => (
|
|
<FileTreeNode
|
|
key={node.path}
|
|
node={node}
|
|
activePath={visibleActiveFile?.path ?? ''}
|
|
onSelect={onChangeActivePath}
|
|
/>
|
|
))}
|
|
</div>
|
|
</aside>
|
|
|
|
<main className="grid min-h-0 grid-rows-[38px_minmax(0,1fr)] bg-white">
|
|
<div className="flex items-center justify-between gap-3 border-b border-[#d9e0eb] px-4">
|
|
<div className="flex min-w-0 items-center gap-2.5">
|
|
<FileCode2 size={15} className="shrink-0 text-[#64748b]" />
|
|
<span
|
|
className="min-w-0 truncate font-mono text-[12px] font-bold text-[#1f2937]"
|
|
title={visibleActiveFile?.path ?? undefined}
|
|
>
|
|
{visibleActiveFile?.path ?? '파일 없음'}
|
|
</span>
|
|
</div>
|
|
{visibleActiveFile ? <span className="text-[10px] font-black uppercase text-[#4f46e5]">{visibleActiveFile.status}</span> : null}
|
|
</div>
|
|
<div className="relative min-h-0">
|
|
{visibleActiveFile && isActiveFileContentLoaded ? (
|
|
<DiffEditor
|
|
key={`${visibleActiveFile.path}:${visibleActiveFile.originalRef ?? 'empty'}:${visibleActiveFile.modifiedRef ?? 'empty'}`}
|
|
height="100%"
|
|
language={resolveMonacoLanguage(visibleActiveFile.path)}
|
|
original={content.original}
|
|
modified={content.modified}
|
|
originalModelPath={`original://${visibleActiveFile.originalRef ?? 'empty'}/${visibleActiveFile.path}`}
|
|
modifiedModelPath={`modified://${visibleActiveFile.modifiedRef ?? 'empty'}/${visibleActiveFile.path}`}
|
|
keepCurrentOriginalModel
|
|
keepCurrentModifiedModel
|
|
beforeMount={(instance) => {
|
|
instance.editor.defineTheme('autonomy-diff-light', {
|
|
base: 'vs',
|
|
inherit: true,
|
|
rules: [],
|
|
colors: {
|
|
'diffEditor.insertedTextBackground': '#86efac',
|
|
'diffEditor.insertedLineBackground': '#dcfce7',
|
|
'diffEditor.removedTextBackground': '#fda4af',
|
|
'diffEditor.removedLineBackground': '#ffe4e6',
|
|
'diffEditorGutter.insertedLineBackground': '#4ade80',
|
|
'diffEditorGutter.removedLineBackground': '#fb7185',
|
|
},
|
|
});
|
|
}}
|
|
theme="autonomy-diff-light"
|
|
loading={<div className="grid h-full place-items-center text-[12px] font-bold text-[#94a3b8]">편집기를 불러오는 중입니다.</div>}
|
|
options={{
|
|
readOnly: true,
|
|
originalEditable: false,
|
|
renderSideBySide: true,
|
|
diffAlgorithm: 'advanced',
|
|
ignoreTrimWhitespace: false,
|
|
renderIndicators: true,
|
|
minimap: { enabled: false },
|
|
scrollBeyondLastLine: false,
|
|
fontSize: 12,
|
|
lineHeight: 19,
|
|
wordWrap: 'off',
|
|
diffWordWrap: 'off',
|
|
renderOverviewRuler: false,
|
|
hideUnchangedRegions: { enabled: false },
|
|
}}
|
|
/>
|
|
) : <div className="grid h-full place-items-center text-[12px] font-bold text-[#94a3b8]">{visibleActiveFile ? '파일 내용을 불러오는 중입니다.' : '표시할 파일이 없습니다.'}</div>}
|
|
<span className="pointer-events-none absolute inset-y-0 left-1/2 z-10 w-px bg-[#aebdce]" aria-hidden="true" />
|
|
{contentLoading ? <div className="absolute inset-0 grid place-items-center bg-white/72 text-[12px] font-bold text-[#64748b]">파일 내용을 불러오는 중입니다.</div> : null}
|
|
{contentError ? <p className="absolute bottom-3 left-3 rounded bg-[#fff7ed] px-2.5 py-1.5 text-[11px] font-bold text-[#c2410c]">{contentError}</p> : null}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function isIgnoredSubmissionFile(path: string) {
|
|
return path === '.gitignore' || path.endsWith('/.gitignore') || path === '.autonomy-studio-git.json';
|
|
}
|
|
|
|
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;
|
|
const [expanded, setExpanded] = useState(true);
|
|
if (node.kind === 'folder') {
|
|
return (
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded((current) => !current)}
|
|
className="flex h-7 w-full cursor-pointer items-center gap-1.5 pr-2 text-left text-[12px] font-bold text-[#4b5563] hover:bg-[#e5e5e5]"
|
|
style={{ paddingLeft: depth * 13 + 5 }}
|
|
title={node.path}
|
|
>
|
|
{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>
|
|
</button>
|
|
{expanded && 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={[
|
|
'grid h-7 w-full cursor-pointer grid-cols-[18px_minmax(0,1fr)_auto] items-center gap-1.5 py-0.5 pr-2 text-left transition-colors',
|
|
active ? 'bg-[#dbeafe] text-[#1d4ed8]' : 'text-[#374151] hover:bg-[#e5e5e5]',
|
|
].join(' ')}
|
|
style={{ paddingLeft: depth * 13 + 21 }}
|
|
title={node.path}
|
|
>
|
|
<FileCode2 size={13} className="shrink-0 text-[#64748b]" />
|
|
<span className="min-w-0 truncate font-mono text-[11px] font-medium">{node.name}</span>
|
|
{node.file ? <span className="shrink-0 text-[9px] font-bold uppercase text-[#64748b]">{node.file.status}</span> : null}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function resolveMonacoLanguage(path: string) {
|
|
const extension = path.split('.').pop()?.toLowerCase();
|
|
if (extension === 'py') return 'python';
|
|
if (['ts', 'tsx'].includes(extension ?? '')) return 'typescript';
|
|
if (['js', 'jsx', 'mjs', 'cjs'].includes(extension ?? '')) return 'javascript';
|
|
if (['cpp', 'cc', 'cxx', 'hpp', 'hh', 'h'].includes(extension ?? '')) return 'cpp';
|
|
if (['yaml', 'yml'].includes(extension ?? '')) return 'yaml';
|
|
if (extension === 'json') return 'json';
|
|
if (['xml', 'urdf', 'sdf', 'xacro'].includes(extension ?? '')) return 'xml';
|
|
if (extension === 'md') return 'markdown';
|
|
if (extension === 'sh') return 'shell';
|
|
return 'plaintext';
|
|
}
|
|
|
|
function EmptyState({ message }: { message: string }) {
|
|
return <div className="grid min-h-[180px] place-items-center px-4 text-center text-[13px] font-bold text-[#94a3b8]">{message}</div>;
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|