@t3q-ui/design-system 0.0.0-stage → 1.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1977 -0
- package/LICENSE.md +88 -0
- package/README.md +464 -2
- package/cli/doctor.mjs +196 -0
- package/cli/lib.mjs +425 -0
- package/cli/setup.mjs +181 -0
- package/cli/t3q-design.mjs +78 -0
- package/dist/chunk-IPF3K4BS.js +4274 -0
- package/dist/chunk-IPF3K4BS.js.map +1 -0
- package/dist/chunk-MHAO45AL.cjs +4427 -0
- package/dist/chunk-MHAO45AL.cjs.map +1 -0
- package/dist/fonts.css +12 -0
- package/dist/index-Ci1Wh5sA.d.cts +253 -0
- package/dist/index-Ci1Wh5sA.d.ts +253 -0
- package/dist/index.cjs +10998 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +332 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.cts +2554 -0
- package/dist/index.d.ts +2554 -0
- package/dist/index.js +10354 -0
- package/dist/index.js.map +1 -0
- package/dist/patterns/index.cjs +885 -0
- package/dist/patterns/index.cjs.map +1 -0
- package/dist/patterns/index.css +265 -0
- package/dist/patterns/index.css.map +1 -0
- package/dist/patterns/index.d.cts +373 -0
- package/dist/patterns/index.d.ts +373 -0
- package/dist/patterns/index.js +873 -0
- package/dist/patterns/index.js.map +1 -0
- package/dist/styles.css +2014 -0
- package/package.json +212 -4
- package/skills/tds/SKILL.md +91 -0
- package/skills/tds/examples/dashboard-page.tsx +344 -0
- package/skills/tds/examples/detail-page.tsx +388 -0
- package/skills/tds/examples/form-page.tsx +460 -0
- package/skills/tds/examples/list-page.tsx +275 -0
- package/skills/tds/reference/components.md +1424 -0
- package/skills/tds/rules.md +28 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TDS 목록 화면 예제 — "검색·필터 → 표 → 행 클릭으로 오른쪽 상세 패널 → 삭제 확인" 흐름.
|
|
3
|
+
* 이 파일은 TDS 저장소의 타입 검사(`pnpm typecheck`)와 렌더 테스트를 통과한 코드다. 새 목록 화면은 이 구조에서 시작한다.
|
|
4
|
+
*
|
|
5
|
+
* 이 예제가 보여 주는 것 (자주 틀리는 곳):
|
|
6
|
+
* - ListPage 가 헤더·필터 줄·표·페이지네이션·빈 결과·로딩·오류를 그린다. 표·헤더를 직접 만들지 않는다
|
|
7
|
+
* - 주요 행동 버튼은 `actions` → 제목 오른쪽에 놓인다
|
|
8
|
+
* - 상태처럼 선택지가 3~6개인 다중 선택 필터는 `quickFilter` → 표 왼쪽 위에 세그먼트로 항상 보이고, 항목마다 건수가 붙는다
|
|
9
|
+
* (드롭다운에 숨기지 않는다). 선택지가 많거나 검색이 필요한 축만 `TableFilterMultiSelect title='…'` 로 `filters` 에 둔다
|
|
10
|
+
* - `onResetFilters` 는 **항상** 넘기고 `filtersActive` 로 비활성을 표현한다 → 빈 결과 가운데에 "필터 초기화" 가 나온다
|
|
11
|
+
* - 행 클릭 → `detail` 슬롯이 목록 **오른쪽에 나란히** 상세 패널을 그린다(표가 좁아지고 목록은 계속 보인다, 닫기 버튼·Esc).
|
|
12
|
+
* `activeRowId` 로 열린 행을 표시하고, 상세 필드는 `MetaList` + `MetaRow` 로 세로로 쌓는다
|
|
13
|
+
* (`Stack` 은 기본 방향이 가로라 MetaRow 를 감싸면 옆으로 늘어서 잘린다). 표를 좁히면 안 될 때만 `<Drawer modal={false}>`
|
|
14
|
+
* - 빈 결과는 `emptyState={{ title, description }}` 만 넘긴다 → 패턴이 아이콘·제목·설명·초기화 버튼 상자로 그린다
|
|
15
|
+
* - 비활성 버튼(실행 중이 아닐 때의 '중지')에는 왜 못 하는지를 `detail.note` 로 버튼 줄에 한 줄 적는다 — 비활성 버튼에는 tooltip 이 뜨지 않는다
|
|
16
|
+
* - 삭제는 `AlertDialog`(variant danger)로 확인받는다
|
|
17
|
+
* - 정렬할 수 있는 열은 `sortable: true` 를 명시한다 → 정렬 전에도 아이콘이 보이고 누르면 방향이 바뀐다
|
|
18
|
+
* - 상태는 `Badge` 에 아이콘 + 글자(`<Badge variant='primary'><LucideIcon iconComponentName='play' />실행 중</Badge>` — 아이콘은 자식으로 바로, 크기는 기본). 별도 상태 컴포넌트를 만들지 않는다.
|
|
19
|
+
* 실행 중·활성처럼 건강하게 돌아가는 상태는 `primary`(▶), 학습 중·배포 중처럼 끝이 있는 진행은 `info` + `loader-circle`, 완료·준비됨만 `success`(✓) — 실행 중에 ✓ 를 붙이면 끝난 것으로 읽힌다(2026-10-20 17차)
|
|
20
|
+
* - 숫자 열(개수·크기·비율)은 `type: 'number'` → 셀·제목이 오른쪽 정렬된다
|
|
21
|
+
* - 상세 패널의 필드는 `MetaRow align='start'` → 값이 라벨 옆에 붙는다(넓은 패널에서 라벨과 값 사이가 비지 않는다)
|
|
22
|
+
* - 열 폭: 이름 열 `flex: 2, minWidth: 200, maxWidth: 480`, 글자가 들어가는 열은 **모두** `flex: 1, minWidth: 120`(여기서는 소유자 하나지만 팀·프레임워크·설명이 있으면 전부) — 남는 폭이 여러 열로 흩어져야 이름 열만 넓어지지 않는다(2026-10-20 17차·18차). 글자 열이 하나도 없으면 maxWidth 를 뺀다(표 오른쪽이 빈다). 숫자·날짜·상태·진행률 열은 `width`(제목이 들어가는 최소 폭은 표가 보정하고 넘치는 글자는 말줄임표).
|
|
23
|
+
* 보조 정보(이미지·id·경로)는 열을 늘리지 않고 `secondaryField` 로 이름 아래 줄에 — 열이 6~7개를 넘으면 잘린다
|
|
24
|
+
* - 빠른 필터의 건수 뱃지는 표의 상태 뱃지와 같은 색(`countVariant: STATUS[s].tone`)
|
|
25
|
+
* - 목 데이터는 소비 앱(App.tsx)이 `fetchEnvs` 로 넘긴다 — **20행 이상**(페이지 크기 20 이면 두 페이지). 10행이면 표 아래가 비어 보인다
|
|
26
|
+
*/
|
|
27
|
+
import { type ReactNode, useEffect, useMemo, useState } from 'react'
|
|
28
|
+
import {
|
|
29
|
+
AlertDialog,
|
|
30
|
+
AlertDialogAction,
|
|
31
|
+
AlertDialogCancel,
|
|
32
|
+
AlertDialogContent,
|
|
33
|
+
AlertDialogDescription,
|
|
34
|
+
AlertDialogFooter,
|
|
35
|
+
AlertDialogHeader,
|
|
36
|
+
AlertDialogTitle,
|
|
37
|
+
Button,
|
|
38
|
+
LucideIcon,
|
|
39
|
+
MetaList,
|
|
40
|
+
MetaRow,
|
|
41
|
+
Progress,
|
|
42
|
+
Badge,
|
|
43
|
+
type BadgeProps,
|
|
44
|
+
TextField,
|
|
45
|
+
} from '@t3q-ui/design-system'
|
|
46
|
+
import { ListPage } from '@t3q-ui/design-system/patterns'
|
|
47
|
+
import type { ColDef, ICellRendererParams } from 'ag-grid-community'
|
|
48
|
+
|
|
49
|
+
type Status = 'RUNNING' | 'STOPPED' | 'ERROR'
|
|
50
|
+
type Env = {
|
|
51
|
+
id: string
|
|
52
|
+
name: string
|
|
53
|
+
image: string
|
|
54
|
+
status: Status
|
|
55
|
+
gpu: number
|
|
56
|
+
/** GPU 사용률 0~100 — 표의 진행률 열 예시 */
|
|
57
|
+
gpuUsage: number
|
|
58
|
+
owner: string
|
|
59
|
+
updatedAt: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 상태값 → 글자·variant·아이콘 매핑은 한 곳에(Badge 에 아이콘 + 글자).
|
|
63
|
+
// 실행환경의 '실행 중' 은 컨테이너가 돌아가는 프로세스라 primary(▶). '사용 중·활성' 같은 켜진 상태라면 primary + circle-dot(파란 ●), 준비됨·완료는 success(✓) — 한 화면에 같은 tone 의 상태 둘 금지(19차: 둘 다 초록이라 "two different states look the same"). ▶ 는 상태에 붙이면 재생 버튼처럼, success 의 ✓ 는 끝난 상태로 읽힌다(17·18차)
|
|
64
|
+
const STATUS: Record<
|
|
65
|
+
Status,
|
|
66
|
+
{ label: string; variant: NonNullable<BadgeProps['variant']>; icon: ReactNode }
|
|
67
|
+
> = {
|
|
68
|
+
RUNNING: { label: '실행 중', variant: 'primary', icon: <LucideIcon iconComponentName='play' /> },
|
|
69
|
+
STOPPED: {
|
|
70
|
+
label: '중지',
|
|
71
|
+
variant: 'secondary',
|
|
72
|
+
icon: <LucideIcon iconComponentName='circle-pause' />,
|
|
73
|
+
},
|
|
74
|
+
ERROR: { label: '오류', variant: 'danger', icon: <LucideIcon iconComponentName='circle-x' /> },
|
|
75
|
+
}
|
|
76
|
+
const STATUS_OPTIONS = (Object.keys(STATUS) as Status[]).map((s) => ({
|
|
77
|
+
value: s,
|
|
78
|
+
label: STATUS[s].label,
|
|
79
|
+
}))
|
|
80
|
+
const PAGE_SIZE = 20
|
|
81
|
+
|
|
82
|
+
// 남는 폭은 이름 열(flex 2, 상한 480)과 글자 열 전부(flex 1)가 나눈다 — 글자 열이 하나뿐이면 2:1 로 나눠도 이름 열이 500px 라 비어 보인다(2026-10-20 17차·18차).
|
|
83
|
+
// maxWidth 를 주면 넓은 화면에서 표 오른쪽이 빈다. 이미지는 열이 아니라 이름 아래 보조 줄(ListPage secondaryField='image')
|
|
84
|
+
const COLUMNS: ColDef<Env>[] = [
|
|
85
|
+
{ field: 'name', headerName: '이름', flex: 2, minWidth: 200, maxWidth: 480, sortable: true },
|
|
86
|
+
{
|
|
87
|
+
field: 'status',
|
|
88
|
+
headerName: '상태',
|
|
89
|
+
width: 120,
|
|
90
|
+
cellRenderer: (p: ICellRendererParams<Env>) =>
|
|
91
|
+
p.data ? (
|
|
92
|
+
<Badge variant={STATUS[p.data.status].variant}>
|
|
93
|
+
{STATUS[p.data.status].icon}
|
|
94
|
+
{STATUS[p.data.status].label}
|
|
95
|
+
</Badge>
|
|
96
|
+
) : null,
|
|
97
|
+
},
|
|
98
|
+
// 숫자 열은 type: 'number' — 셀·제목 오른쪽 정렬, 숫자 폭 고정. width 는 제목 글자가 들어갈 만큼(모자라면 표가 올려 준다)
|
|
99
|
+
{ field: 'gpu', headerName: 'GPU', width: 90, type: 'number', sortable: true },
|
|
100
|
+
// 표의 진행률 열: 고정 폭 막대(120px) + 오른쪽 % 글자 한 줄. 글자 아래 막대를 쌓거나 w-full 로 두면 행마다 막대 폭이 달라진다(2026-10-20 16차).
|
|
101
|
+
// 막대 이름은 aria-label 로 행마다 '무엇 몇 %' — 이름 없는 progressbar 는 행마다 axe serious(2026-10-20 17차)
|
|
102
|
+
{
|
|
103
|
+
field: 'gpuUsage',
|
|
104
|
+
headerName: 'GPU 사용률',
|
|
105
|
+
width: 190,
|
|
106
|
+
sortable: true,
|
|
107
|
+
cellRenderer: (p: ICellRendererParams<Env>) =>
|
|
108
|
+
p.data ? (
|
|
109
|
+
<div className='flex items-center gap-[8px]'>
|
|
110
|
+
<Progress
|
|
111
|
+
value={p.data.gpuUsage}
|
|
112
|
+
className='w-[120px]'
|
|
113
|
+
aria-label={`GPU 사용률 ${p.data.gpuUsage}%`}
|
|
114
|
+
/>
|
|
115
|
+
<span className='text-small-regular tabular-nums text-sys-text-main'>
|
|
116
|
+
{p.data.gpuUsage}%
|
|
117
|
+
</span>
|
|
118
|
+
</div>
|
|
119
|
+
) : null,
|
|
120
|
+
},
|
|
121
|
+
{ field: 'owner', headerName: '소유자', flex: 1, minWidth: 120 },
|
|
122
|
+
{ field: 'updatedAt', headerName: '수정일', width: 170, sortable: true },
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
export function ExecutionEnvironmentListPage({ fetchEnvs }: { fetchEnvs: () => Promise<Env[]> }) {
|
|
126
|
+
const [all, setAll] = useState<Env[] | undefined>()
|
|
127
|
+
const [error, setError] = useState<string | null>(null)
|
|
128
|
+
const [search, setSearch] = useState('')
|
|
129
|
+
const [statuses, setStatuses] = useState<string[]>([])
|
|
130
|
+
const [page, setPage] = useState(1)
|
|
131
|
+
const [selected, setSelected] = useState<Env | null>(null)
|
|
132
|
+
const [confirmDelete, setConfirmDelete] = useState(false)
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
fetchEnvs().then(setAll, () => setError('실행환경 목록을 불러오지 못했습니다.'))
|
|
136
|
+
}, [fetchEnvs])
|
|
137
|
+
|
|
138
|
+
const filtered = useMemo(
|
|
139
|
+
() =>
|
|
140
|
+
(all ?? []).filter(
|
|
141
|
+
(e) => e.name.includes(search) && (statuses.length === 0 || statuses.includes(e.status)),
|
|
142
|
+
),
|
|
143
|
+
[all, search, statuses],
|
|
144
|
+
)
|
|
145
|
+
// 빠른 필터의 건수: 검색은 적용하고 상태 필터는 적용하지 않은 목록 기준 — 선택지마다 "몇 건이 있나"
|
|
146
|
+
const statusCounts = useMemo(() => {
|
|
147
|
+
const out: Partial<Record<Status, number>> = {}
|
|
148
|
+
for (const e of all ?? []) if (e.name.includes(search)) out[e.status] = (out[e.status] ?? 0) + 1
|
|
149
|
+
return out
|
|
150
|
+
}, [all, search])
|
|
151
|
+
const filtersActive = search !== '' || statuses.length > 0
|
|
152
|
+
const resetFilters = () => {
|
|
153
|
+
setSearch('')
|
|
154
|
+
setStatuses([])
|
|
155
|
+
setPage(1)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<>
|
|
160
|
+
<ListPage<Env>
|
|
161
|
+
title='실행환경'
|
|
162
|
+
description='모델 학습·추론에 쓰는 컨테이너 실행환경을 관리합니다.'
|
|
163
|
+
actions={<Button variant='primaryBold'>실행환경 생성</Button>}
|
|
164
|
+
filters={
|
|
165
|
+
<TextField
|
|
166
|
+
aria-label='이름으로 검색'
|
|
167
|
+
placeholder='이름으로 검색'
|
|
168
|
+
value={search}
|
|
169
|
+
onChange={(e) => {
|
|
170
|
+
setSearch(e.target.value)
|
|
171
|
+
setPage(1)
|
|
172
|
+
}}
|
|
173
|
+
/>
|
|
174
|
+
}
|
|
175
|
+
onResetFilters={resetFilters}
|
|
176
|
+
filtersActive={filtersActive}
|
|
177
|
+
quickFilter={{
|
|
178
|
+
label: '상태',
|
|
179
|
+
// 건수 뱃지 색은 표의 상태 뱃지와 같은 variant — 칩과 표가 같은 색 언어
|
|
180
|
+
options: STATUS_OPTIONS.map((o) => ({
|
|
181
|
+
...o,
|
|
182
|
+
count: statusCounts[o.value] ?? 0,
|
|
183
|
+
countVariant: STATUS[o.value].variant,
|
|
184
|
+
})),
|
|
185
|
+
value: statuses,
|
|
186
|
+
onChange: (v) => {
|
|
187
|
+
setStatuses(v)
|
|
188
|
+
setPage(1)
|
|
189
|
+
},
|
|
190
|
+
}}
|
|
191
|
+
columns={COLUMNS}
|
|
192
|
+
// 이름 아래 보조 줄(이미지) — 열을 하나 줄이고 행은 56 으로 그려진다
|
|
193
|
+
secondaryField='image'
|
|
194
|
+
rows={filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)}
|
|
195
|
+
loading={all === undefined && !error}
|
|
196
|
+
error={error}
|
|
197
|
+
emptyState={
|
|
198
|
+
filtersActive
|
|
199
|
+
? {
|
|
200
|
+
title: '검색 조건에 맞는 실행환경이 없습니다',
|
|
201
|
+
description: '검색어나 상태 필터를 바꾸거나, 아래 버튼으로 필터를 초기화해 보세요.',
|
|
202
|
+
}
|
|
203
|
+
: {
|
|
204
|
+
title: '등록된 실행환경이 없습니다',
|
|
205
|
+
description: '오른쪽 위 "실행환경 생성" 버튼으로 첫 실행환경을 만드세요.',
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
pagination={{ total: filtered.length, page, pageSize: PAGE_SIZE, onChange: setPage }}
|
|
209
|
+
onRowClick={setSelected}
|
|
210
|
+
activeRowId={selected?.id ?? null}
|
|
211
|
+
// 상세는 목록 오른쪽에 나란한 패널: 표가 좁아지고 목록은 계속 보인다. 닫기 버튼·Esc 로 닫는다
|
|
212
|
+
detail={
|
|
213
|
+
selected
|
|
214
|
+
? {
|
|
215
|
+
title: selected.name,
|
|
216
|
+
description: selected.id,
|
|
217
|
+
onClose: () => setSelected(null),
|
|
218
|
+
children: (
|
|
219
|
+
<MetaList>
|
|
220
|
+
<MetaRow align='start' label='이미지' value={selected.image} />
|
|
221
|
+
<MetaRow
|
|
222
|
+
align='start'
|
|
223
|
+
label='상태'
|
|
224
|
+
value={
|
|
225
|
+
<Badge variant={STATUS[selected.status].variant}>
|
|
226
|
+
{STATUS[selected.status].icon}
|
|
227
|
+
{STATUS[selected.status].label}
|
|
228
|
+
</Badge>
|
|
229
|
+
}
|
|
230
|
+
/>
|
|
231
|
+
<MetaRow align='start' label='GPU' value={`${selected.gpu}개`} />
|
|
232
|
+
<MetaRow align='start' label='소유자' value={selected.owner} />
|
|
233
|
+
<MetaRow align='start' label='수정일' value={selected.updatedAt} />
|
|
234
|
+
</MetaList>
|
|
235
|
+
),
|
|
236
|
+
// 비활성 버튼에는 tooltip 이 뜨지 않으므로 이유를 note 로 적는다
|
|
237
|
+
note:
|
|
238
|
+
selected.status !== 'RUNNING' ? '실행 중인 환경만 중지할 수 있습니다' : undefined,
|
|
239
|
+
footer: (
|
|
240
|
+
<>
|
|
241
|
+
<Button variant='dangerOutline' onClick={() => setConfirmDelete(true)}>
|
|
242
|
+
삭제
|
|
243
|
+
</Button>
|
|
244
|
+
<Button variant='primaryBold' disabled={selected.status !== 'RUNNING'}>
|
|
245
|
+
중지
|
|
246
|
+
</Button>
|
|
247
|
+
</>
|
|
248
|
+
),
|
|
249
|
+
}
|
|
250
|
+
: undefined
|
|
251
|
+
}
|
|
252
|
+
/>
|
|
253
|
+
|
|
254
|
+
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
|
255
|
+
<AlertDialogContent variant='danger'>
|
|
256
|
+
<AlertDialogHeader>
|
|
257
|
+
<AlertDialogTitle>{selected?.name} 을(를) 삭제할까요?</AlertDialogTitle>
|
|
258
|
+
<AlertDialogDescription>삭제하면 되돌릴 수 없습니다.</AlertDialogDescription>
|
|
259
|
+
</AlertDialogHeader>
|
|
260
|
+
<AlertDialogFooter>
|
|
261
|
+
<AlertDialogCancel>취소</AlertDialogCancel>
|
|
262
|
+
<AlertDialogAction
|
|
263
|
+
onClick={() => {
|
|
264
|
+
setAll((prev) => prev?.filter((e) => e.id !== selected?.id))
|
|
265
|
+
setSelected(null)
|
|
266
|
+
}}
|
|
267
|
+
>
|
|
268
|
+
삭제
|
|
269
|
+
</AlertDialogAction>
|
|
270
|
+
</AlertDialogFooter>
|
|
271
|
+
</AlertDialogContent>
|
|
272
|
+
</AlertDialog>
|
|
273
|
+
</>
|
|
274
|
+
)
|
|
275
|
+
}
|