236 lines
9.2 KiB
TypeScript
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>
|
|
)
|
|
}
|