Files
lms-web-fe/src/lms-renderer/features/workspace/ModuleListRow.tsx
T
2026-08-03 14:06:06 +09:00

236 lines
9.2 KiB
TypeScript

import { Download, FolderOpen, Hammer, Play, RefreshCw, Square, Trash2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type React from 'react'
export type ModuleListRowItem = {
state: 'running' | 'stopped' | 'uninstalled'
category: string
name: string
parameters: string[]
cpu: number | null
memory: number | null
runtime: string
}
type ModuleActionTone = 'install' | 'start' | 'warning' | 'delete' | 'neutral'
type ModuleListRowProps<Module extends ModuleListRowItem> = {
dark: boolean
module: Module
installing: boolean
controlling: boolean
bulkControlling: boolean
onAddModuleTab: (module: Module) => void
onOpenSource: (module: Module) => void
onOpenParameters: (module: Module) => void
onInstallModule: (module: Module) => void
onDeleteModule: (module: Module) => void
onStartModule: (module: Module) => void
onBuildModule: (module: Module) => void
onStopModule: (module: Module) => void
onRestartModule: (module: Module) => void
}
const stateLabel: Record<ModuleListRowItem['state'], string> = {
running: '실행 중',
stopped: '정지됨',
uninstalled: '미설치'
}
function actionButtonClass(dark: boolean, tone: ModuleActionTone, busy: boolean): string {
return [
'group/action relative grid h-7 w-7 cursor-pointer place-items-center rounded-md border transition',
busy ? 'opacity-55' : '',
tone === 'install'
? dark
? 'border-[#2f8dff]/45 bg-[#2f8dff]/18 text-[#9cc8ff] hover:bg-[#2f8dff]/26'
: 'border-[#2f8dff]/35 bg-[#edf6ff] text-[#1d5fae] hover:bg-[#dfefff]'
: tone === 'start'
? dark
? 'border-[#2fbda0]/35 bg-[#2fbda0]/10 text-[#80ddc7] hover:bg-[#2fbda0]/18'
: 'border-[#2fbda0]/28 bg-[#effbf7] text-[#177553] hover:bg-[#e1f7ee]'
: tone === 'warning'
? dark
? 'border-[#f59e0b]/25 bg-[#f59e0b]/8 text-[#e8b76b] hover:bg-[#f59e0b]/14'
: 'border-[#f59e0b]/22 bg-[#fff8e8] text-[#9a6814] hover:bg-[#fff1cf]'
: tone === 'delete'
? dark
? 'border-[#ef4444]/20 bg-white/[0.03] text-[#e87979] hover:border-[#ef4444]/40 hover:bg-[#ef4444]/10 hover:text-[#ffb4b4]'
: 'border-[#ef4444]/18 bg-white text-[#c25a5a] hover:border-[#ef4444]/32 hover:bg-[#fff4f4] hover:text-[#b42323]'
: dark
? 'border-white/15 bg-white/[0.04] text-white/60 hover:bg-white/10 hover:text-white'
: 'border-[#cfd6e4] bg-white text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'
].join(' ')
}
function TooltipLabel({ dark, children }: { dark: boolean; children: React.ReactNode }): React.JSX.Element {
return (
<span
className={[
'pointer-events-none absolute -top-8 left-1/2 z-20 -translate-x-1/2 whitespace-nowrap rounded px-2 py-1 text-[11px] font-bold opacity-0 shadow-[0_8px_18px_rgba(0,0,0,0.22)] transition group-hover/action:opacity-100',
dark ? 'bg-[#111315] text-white' : 'bg-[#111520] text-white'
].join(' ')}
>
{children}
</span>
)
}
export default function ModuleListRow<Module extends ModuleListRowItem>({
dark,
module,
installing,
controlling,
bulkControlling,
onAddModuleTab,
onOpenSource,
onOpenParameters,
onInstallModule,
onDeleteModule,
onStartModule,
onBuildModule,
onStopModule,
onRestartModule
}: ModuleListRowProps<Module>): React.JSX.Element {
const selectable = module.state !== 'uninstalled'
const busy = installing || controlling
const parameters = Array.isArray(module.parameters) ? module.parameters : []
const stateStyle: Record<ModuleListRowItem['state'], string> = {
running: dark ? 'bg-[#12372d] text-[#72e2b5]' : 'bg-[#dff8ed] text-[#177553]',
stopped: dark ? 'bg-white/8 text-white/45' : 'bg-[#edf0f5] text-[#687284]',
uninstalled: dark ? 'bg-[#2d2d2d] text-white/45' : 'bg-[#edf0f5] text-[#687284]'
}
const controlActions: Array<{
label: string
Icon: LucideIcon
tone: ModuleActionTone
action: (module: Module) => void
}> = module.state === 'running'
? [
{ label: '재시작', Icon: RefreshCw, tone: 'neutral', action: onRestartModule },
{ label: '중지', Icon: Square, tone: 'warning', action: onStopModule }
]
: module.state === 'stopped' && controlling
? [
{ label: '빌드', Icon: Hammer, tone: 'neutral', action: onBuildModule },
{ label: '중지', Icon: Square, tone: 'warning', action: onStopModule }
]
: module.state === 'stopped'
? [
{ label: '빌드', Icon: Hammer, tone: 'neutral', action: onBuildModule },
{ label: '실행', Icon: Play, tone: 'start', action: onStartModule }
]
: []
const installAction = module.state === 'uninstalled'
? { label: '설치', Icon: Download, tone: 'install' as ModuleActionTone }
: { label: '삭제', Icon: Trash2, tone: 'delete' as ModuleActionTone }
const installActionDisabled = busy || (module.state !== 'uninstalled' && module.state !== 'stopped')
return (
<div
onDoubleClick={() => {
if (selectable) onOpenSource(module)
}}
className={[
'grid h-[58px] grid-cols-[88px_115px_minmax(220px,1fr)_105px_132px_70px] items-center px-3 text-[12px] transition',
'cursor-default',
selectable
? dark ? 'text-white/70 hover:bg-white/[0.045]' : 'text-[#4f596b] hover:bg-[#f5f7fb]'
: dark ? 'text-white/42' : 'text-[#8a94a5]'
].join(' ')}
>
<span className="text-center">
<span className={['inline-flex h-6 items-center rounded px-2 text-[11px] font-bold', stateStyle[module.state]].join(' ')}>
{stateLabel[module.state]}
</span>
</span>
<span className={['text-center', dark ? 'text-white/52' : 'text-[#687284]'].join(' ')}>{module.category}</span>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<span className={['truncate text-[12px] font-semibold', dark ? 'text-white/86' : 'text-[#111520]'].join(' ')}>{module.name}</span>
</span>
<button
type="button"
title="파라미터 설정"
onClick={(event) => {
event.stopPropagation()
onOpenParameters(module)
}}
className={[
'mt-1 block h-[14px] max-w-full truncate text-left text-[10px] transition',
'cursor-pointer hover:underline',
dark ? 'text-white/32 hover:text-white/55' : 'text-[#8a94a5] hover:text-[#4f596b]'
].join(' ')}
>
{parameters.length ? parameters.join(' ') : '파라미터 설정'}
</button>
</span>
<span className={['text-center', module.runtime === '-' ? (dark ? 'text-white/28' : 'text-[#a0a7b4]') : (dark ? 'font-mono text-white/58' : 'font-mono text-[#687284]')].join(' ')}>
{module.runtime}
</span>
<span className="flex justify-center gap-1.5">
{selectable && (
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onAddModuleTab(module)
}}
aria-label="열기"
className={[
'group/action relative grid h-7 w-7 cursor-pointer place-items-center rounded-md border transition',
dark
? 'border-[#2f8dff]/35 bg-[#2f8dff]/12 text-[#9cc8ff] hover:bg-[#2f8dff]/22'
: 'border-[#2f8dff]/28 bg-[#edf6ff] text-[#1d5fae] hover:bg-[#dfefff]'
].join(' ')}
>
<FolderOpen size={14} />
<TooltipLabel dark={dark}>열기</TooltipLabel>
</button>
)}
{controlActions.map(({ label, Icon, tone, action }) => (
(() => {
const canCancelBuild = module.state === 'stopped' && controlling && label === '중지'
const disabled = bulkControlling || (busy && !canCancelBuild)
return (
<button
key={`${module.name}-${label}`}
type="button"
disabled={disabled}
onClick={(event) => {
event.stopPropagation()
action(module)
}}
aria-label={label}
className={actionButtonClass(dark, tone, disabled)}
>
{busy && !canCancelBuild ? <RefreshCw size={14} className="animate-spin" /> : <Icon size={14} />}
<TooltipLabel dark={dark}>{label}</TooltipLabel>
</button>
)
})()
))}
</span>
<span className="flex justify-center">
<button
type="button"
disabled={installActionDisabled}
onClick={(event) => {
event.stopPropagation()
if (module.state === 'uninstalled') {
onInstallModule(module)
return
}
onDeleteModule(module)
}}
aria-label={installAction.label}
className={actionButtonClass(dark, installAction.tone, installActionDisabled)}
>
{busy ? <RefreshCw size={14} className="animate-spin" /> : <installAction.Icon size={14} />}
<TooltipLabel dark={dark}>{installAction.label}</TooltipLabel>
</button>
</span>
</div>
)
}