feat: add LMS web client
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import type { ModuleRow } from './types'
|
||||
|
||||
type GitChangedFile = {
|
||||
path: string
|
||||
status: 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked'
|
||||
staged: boolean
|
||||
unstaged: boolean
|
||||
}
|
||||
|
||||
type GitCommitInfo = {
|
||||
hash: string
|
||||
shortHash: string
|
||||
author: string
|
||||
date: string
|
||||
message: string
|
||||
}
|
||||
|
||||
type GitStatusInfo = {
|
||||
ok: boolean
|
||||
branch: string | null
|
||||
ahead: number
|
||||
behind: number
|
||||
files: GitChangedFile[]
|
||||
selectedFilePath: string | null
|
||||
diff: string
|
||||
commits: GitCommitInfo[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
type GitSubmissionInfo = {
|
||||
id: string
|
||||
submittedAt: string
|
||||
moduleName: string
|
||||
commit: GitCommitInfo
|
||||
}
|
||||
|
||||
type DiffViewLine = {
|
||||
key: string
|
||||
type: 'file' | 'hunk' | 'context' | 'add' | 'delete' | 'note'
|
||||
oldLine: number | null
|
||||
newLine: number | null
|
||||
content: string
|
||||
}
|
||||
|
||||
type GitTarget = {
|
||||
moduleName: string
|
||||
filePath: string
|
||||
}
|
||||
|
||||
type GitSubmissionGroup = {
|
||||
id: string
|
||||
submittedAt: string
|
||||
submissions: GitSubmissionInfo[]
|
||||
}
|
||||
|
||||
type Props = {
|
||||
dark: boolean
|
||||
borderSoft: string
|
||||
textStrong: string
|
||||
textMid: string
|
||||
textDim: string
|
||||
gitPanelTab: 'source' | 'history'
|
||||
gitChangedFileCount: number
|
||||
gitChangedModules: ModuleRow[]
|
||||
gitModuleStatuses: Record<string, GitStatusInfo>
|
||||
gitError: string | null
|
||||
gitLoading: boolean
|
||||
gitSubmissionsLoading: boolean
|
||||
gitSubmissionDiffLoading: boolean
|
||||
gitSubmissions: GitSubmissionInfo[]
|
||||
gitSubmissionGroups: GitSubmissionGroup[]
|
||||
selectedGitTarget: GitTarget | null
|
||||
selectedGitFile: GitChangedFile | null
|
||||
selectedGitStatus: GitStatusInfo | null
|
||||
selectedGitSubmission: GitSubmissionInfo | null
|
||||
selectedGitSubmissionDiff: string
|
||||
onGitPanelTabChange: (tab: 'source' | 'history') => void
|
||||
onRefresh: () => void
|
||||
onSelectGitFile: (moduleName: string, filePath: string) => void
|
||||
onOpenGitChangedFile: (moduleName: string, filePath: string) => void
|
||||
onOpenGitFileContextMenu: (module: ModuleRow, file: GitChangedFile, x: number, y: number) => void
|
||||
onSelectGitSubmission: (submission: GitSubmissionInfo) => void
|
||||
parseDiffViewLines: (diff: string) => DiffViewLine[]
|
||||
renderDiffLine: (line: DiffViewLine) => React.JSX.Element
|
||||
getGitStatusLabel: (status: GitChangedFile['status']) => string
|
||||
getGitStatusTone: (status: GitChangedFile['status']) => string
|
||||
formatGitCommitDate: (date: string) => string
|
||||
}
|
||||
|
||||
function WorkspaceSourceControlView({
|
||||
dark,
|
||||
borderSoft,
|
||||
textStrong,
|
||||
textMid,
|
||||
textDim,
|
||||
gitPanelTab,
|
||||
gitChangedFileCount,
|
||||
gitChangedModules,
|
||||
gitModuleStatuses,
|
||||
gitError,
|
||||
gitLoading,
|
||||
gitSubmissionsLoading,
|
||||
gitSubmissionDiffLoading,
|
||||
gitSubmissions,
|
||||
gitSubmissionGroups,
|
||||
selectedGitTarget,
|
||||
selectedGitFile,
|
||||
selectedGitStatus,
|
||||
selectedGitSubmission,
|
||||
selectedGitSubmissionDiff,
|
||||
onGitPanelTabChange,
|
||||
onRefresh,
|
||||
onSelectGitFile,
|
||||
onOpenGitChangedFile,
|
||||
onOpenGitFileContextMenu,
|
||||
onSelectGitSubmission,
|
||||
parseDiffViewLines,
|
||||
renderDiffLine,
|
||||
getGitStatusLabel,
|
||||
getGitStatusTone,
|
||||
formatGitCommitDate
|
||||
}: Props): React.JSX.Element {
|
||||
const refreshing = gitPanelTab === 'history' ? gitSubmissionsLoading : gitLoading
|
||||
|
||||
return (
|
||||
<section
|
||||
className={['min-w-0 overflow-hidden', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}
|
||||
style={{ gridColumn: '4 / span 3', gridRow: 2 }}
|
||||
>
|
||||
<div className={['flex h-full min-h-0 flex-col', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
|
||||
<div className={['flex h-10 shrink-0 items-end justify-between px-3', dark ? 'bg-[#252525]' : 'bg-[#f7f8fb]'].join(' ')}>
|
||||
<div className="flex items-end">
|
||||
{[
|
||||
{ key: 'source' as const, label: '소스 제어' },
|
||||
{ key: 'history' as const, label: '제출 내역' }
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => onGitPanelTabChange(tab.key)}
|
||||
className={[
|
||||
'h-8 min-w-[92px] cursor-pointer border-x border-t px-3 text-[12px] font-bold transition first:rounded-tl-md last:rounded-tr-md',
|
||||
gitPanelTab === tab.key
|
||||
? dark
|
||||
? 'border-white/10 bg-[#2b2b2b] text-white'
|
||||
: 'border-[#e1e6ef] bg-white text-[#111520]'
|
||||
: dark
|
||||
? 'border-white/8 bg-[#202020] text-white/45 hover:text-white/70'
|
||||
: 'border-[#e1e6ef] bg-[#eef1f6] text-[#687284] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing}
|
||||
className={[
|
||||
'flex h-7 items-center gap-1.5 rounded-md border px-3 text-[12px] font-bold transition',
|
||||
refreshing ? 'cursor-default opacity-45' : 'cursor-pointer',
|
||||
dark
|
||||
? 'border-white/15 bg-white/[0.04] text-white/70 hover:border-white/25 hover:bg-white/10 hover:text-white'
|
||||
: 'border-[#cfd6e4] bg-white text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
aria-label="새로고침"
|
||||
>
|
||||
<RefreshCw size={14} className={refreshing ? 'animate-spin' : ''} />
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{gitPanelTab === 'source' ? (
|
||||
<div className={['grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)]', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
|
||||
<aside className={['flex min-h-0 flex-col border-r', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
|
||||
<div className={['flex h-11 items-center justify-between border-b px-4', borderSoft, dark ? 'bg-[#2b2b2b]' : 'bg-[#f3f5f9]'].join(' ')}>
|
||||
<span className={['truncate text-[12px] font-bold', textMid].join(' ')}>변경 파일</span>
|
||||
<span className={['text-[11px] font-bold', textDim].join(' ')}>{gitChangedFileCount}개</span>
|
||||
</div>
|
||||
|
||||
<div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{gitError && (
|
||||
<div className={['mb-3 rounded-lg border px-3 py-2 text-[12px] font-semibold leading-relaxed', dark ? 'border-[#ef4444]/24 bg-[#ef4444]/10 text-[#fca5a5]' : 'border-[#fecaca] bg-[#fff1f2] text-[#dc2626]'].join(' ')}>
|
||||
{gitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gitLoading && !Object.keys(gitModuleStatuses).length ? (
|
||||
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
|
||||
Git 상태 불러오는 중
|
||||
</div>
|
||||
) : gitChangedFileCount === 0 ? (
|
||||
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
|
||||
변경된 파일 없음
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{gitChangedModules.map((module) => {
|
||||
const status = gitModuleStatuses[module.name]
|
||||
return (
|
||||
<div key={module.name} className={['overflow-hidden rounded-lg border', dark ? 'border-white/10' : 'border-[#e1e6ef]'].join(' ')}>
|
||||
<div className={['flex items-center justify-between px-3 py-2', dark ? 'bg-[#1f1f1f]' : 'bg-[#f8fafc]'].join(' ')}>
|
||||
<div className="min-w-0">
|
||||
<p className={['truncate text-[12px] font-black', textStrong].join(' ')}>{module.name}</p>
|
||||
<p className={['mt-0.5 truncate font-mono text-[10px]', textDim].join(' ')}>
|
||||
{status?.branch ?? 'main'}
|
||||
</p>
|
||||
</div>
|
||||
<span className={['shrink-0 rounded px-2 py-0.5 text-[10px] font-black', dark ? 'bg-white/8 text-white/58' : 'bg-[#edf0f5] text-[#687284]'].join(' ')}>
|
||||
{status?.files.length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-1 p-1.5">
|
||||
{(status?.files ?? []).map((file) => {
|
||||
const active = selectedGitTarget?.moduleName === module.name && selectedGitTarget.filePath === file.path
|
||||
return (
|
||||
<button
|
||||
key={`${module.name}-${file.path}`}
|
||||
type="button"
|
||||
onClick={() => onSelectGitFile(module.name, file.path)}
|
||||
onDoubleClick={() => onOpenGitChangedFile(module.name, file.path)}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
onOpenGitFileContextMenu(module, file, event.clientX, event.clientY)
|
||||
}}
|
||||
className={[
|
||||
'min-w-0 rounded-md border px-3 py-2 text-left transition',
|
||||
active
|
||||
? dark ? 'border-[#2563eb]/50 bg-[#2563eb]/16' : 'border-[#2563eb]/35 bg-[#edf6ff]'
|
||||
: dark ? 'border-transparent hover:bg-white/[0.045]' : 'border-transparent hover:bg-[#f3f5f9]'
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={['shrink-0 rounded px-1.5 py-0.5 text-[10px] font-black', getGitStatusTone(file.status)].join(' ')}>
|
||||
{getGitStatusLabel(file.status)}
|
||||
</span>
|
||||
<span className={['truncate text-[12px] font-bold', dark ? 'text-white/78' : 'text-[#111520]'].join(' ')}>
|
||||
{file.path.split('/').pop()}
|
||||
</span>
|
||||
</div>
|
||||
<p className={['mt-1 truncate font-mono text-[10px]', textDim].join(' ')}>{file.path}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className={['min-h-0 border-t', borderSoft].join(' ')}>
|
||||
<div className={['flex h-11 items-center justify-between px-4', dark ? 'bg-[#171717]' : 'bg-[#f9fafc]'].join(' ')}>
|
||||
<span className={['block min-w-0 truncate text-[12px] font-bold', textMid].join(' ')}>
|
||||
{selectedGitTarget ? `${selectedGitTarget.moduleName} / ${selectedGitTarget.filePath}` : ''}
|
||||
</span>
|
||||
{selectedGitTarget && selectedGitFile?.status !== 'deleted' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenGitChangedFile(selectedGitTarget.moduleName, selectedGitTarget.filePath)}
|
||||
className={['h-7 cursor-pointer rounded-md border px-3 text-[11px] font-bold transition', dark ? 'border-white/12 text-white/58 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')}
|
||||
>
|
||||
파일 열기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={['thin-y-scrollbar h-[calc(100%-44px)] overflow-auto p-4 font-mono text-[11px] leading-5', dark ? 'bg-[#171717] text-white/68' : 'bg-[#f9fafc] text-[#374151]'].join(' ')}>
|
||||
{gitLoading && !selectedGitStatus ? (
|
||||
<div className={['grid h-full place-items-center font-sans text-[12px] font-semibold', textDim].join(' ')}>
|
||||
변경 내용 불러오는 중
|
||||
</div>
|
||||
) : selectedGitStatus?.diff ? (
|
||||
<div className={['overflow-hidden rounded-md border text-[11px]', dark ? 'border-white/10 bg-[#111111]' : 'border-[#e1e6ef] bg-white'].join(' ')}>
|
||||
{parseDiffViewLines(selectedGitStatus.diff).map(renderDiffLine)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full" />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className={['grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)]', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
|
||||
<aside className={['flex min-h-0 flex-col border-r', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
|
||||
<div className={['flex h-11 items-center justify-between border-b px-4', borderSoft, dark ? 'bg-[#2b2b2b]' : 'bg-[#f3f5f9]'].join(' ')}>
|
||||
<span className={['text-[12px] font-bold', textMid].join(' ')}>제출 목록</span>
|
||||
<span className={['text-[11px] font-bold', textDim].join(' ')}>{gitSubmissionGroups.length}개</span>
|
||||
</div>
|
||||
<div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{gitError && (
|
||||
<div className={['mb-3 rounded-lg border px-3 py-2 text-[12px] font-semibold leading-relaxed', dark ? 'border-[#ef4444]/24 bg-[#ef4444]/10 text-[#fca5a5]' : 'border-[#fecaca] bg-[#fff1f2] text-[#dc2626]'].join(' ')}>
|
||||
{gitError}
|
||||
</div>
|
||||
)}
|
||||
{gitSubmissionsLoading && !gitSubmissions.length ? (
|
||||
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
|
||||
제출 내역 불러오는 중
|
||||
</div>
|
||||
) : !gitSubmissionGroups.length ? (
|
||||
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
|
||||
제출 내역 없음
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{gitSubmissionGroups.map((group, groupIndex) => (
|
||||
<div key={group.id} className={['overflow-hidden rounded-lg border', dark ? 'border-white/10' : 'border-[#e1e6ef]'].join(' ')}>
|
||||
<div className={['px-3 py-2', dark ? 'bg-[#1f1f1f]' : 'bg-[#f8fafc]'].join(' ')}>
|
||||
<p className={['text-[12px] font-black', textStrong].join(' ')}>제출 {gitSubmissionGroups.length - groupIndex}</p>
|
||||
<p className={['mt-0.5 text-[10px] font-bold', textDim].join(' ')}>
|
||||
{formatGitCommitDate(group.submittedAt)} · {group.submissions.length}개 모듈
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-1 p-1.5">
|
||||
{group.submissions.map((submission) => {
|
||||
const active = selectedGitSubmission?.moduleName === submission.moduleName && selectedGitSubmission.commit.hash === submission.commit.hash
|
||||
return (
|
||||
<button
|
||||
key={`${submission.moduleName}-${submission.commit.hash}`}
|
||||
type="button"
|
||||
onClick={() => onSelectGitSubmission(submission)}
|
||||
className={[
|
||||
'min-w-0 rounded-md border px-3 py-2 text-left transition',
|
||||
active
|
||||
? dark ? 'border-[#2563eb]/50 bg-[#2563eb]/16' : 'border-[#2563eb]/35 bg-[#edf6ff]'
|
||||
: dark ? 'border-transparent hover:bg-white/[0.045]' : 'border-transparent hover:bg-[#f3f5f9]'
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<span className={['min-w-0 truncate text-[12px] font-black', textStrong].join(' ')}>{submission.moduleName}</span>
|
||||
<span className={['shrink-0 font-mono text-[10px]', dark ? 'text-[#9dbdff]' : 'text-[#1d5fae]'].join(' ')}>{submission.commit.shortHash}</span>
|
||||
</div>
|
||||
<p className={['mt-1 truncate text-[11px] font-bold', textDim].join(' ')}>{submission.commit.message}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className={['min-h-0 border-t', borderSoft].join(' ')}>
|
||||
<div className={['flex h-11 items-center justify-between px-4', dark ? 'bg-[#171717]' : 'bg-[#f9fafc]'].join(' ')}>
|
||||
<span className={['block min-w-0 truncate text-[12px] font-bold', textMid].join(' ')}>
|
||||
{selectedGitSubmission ? `${selectedGitSubmission.moduleName} / ${selectedGitSubmission.commit.shortHash}` : ''}
|
||||
</span>
|
||||
{selectedGitSubmission && (
|
||||
<span className={['shrink-0 text-[11px] font-bold', textDim].join(' ')}>
|
||||
{formatGitCommitDate(selectedGitSubmission.submittedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={['thin-y-scrollbar h-[calc(100%-44px)] overflow-auto p-4 font-mono text-[11px] leading-5', dark ? 'bg-[#171717] text-white/68' : 'bg-[#f9fafc] text-[#374151]'].join(' ')}>
|
||||
{gitSubmissionDiffLoading ? (
|
||||
<div className={['grid h-full place-items-center font-sans text-[12px] font-semibold', textDim].join(' ')}>
|
||||
변경 내용 불러오는 중
|
||||
</div>
|
||||
) : selectedGitSubmissionDiff ? (
|
||||
<div className={['overflow-hidden rounded-md border text-[11px]', dark ? 'border-white/10 bg-[#111111]' : 'border-[#e1e6ef] bg-white'].join(' ')}>
|
||||
{parseDiffViewLines(selectedGitSubmissionDiff).map(renderDiffLine)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full" />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkspaceSourceControlView
|
||||
Reference in New Issue
Block a user