@gomjellie/lazyapi 0.0.1
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/LICENSE +22 -0
- package/README.md +162 -0
- package/dist/App.d.ts +9 -0
- package/dist/App.js +65 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +39 -0
- package/dist/components/common/ErrorMessage.d.ts +10 -0
- package/dist/components/common/ErrorMessage.js +17 -0
- package/dist/components/common/KeybindingFooter.d.ts +18 -0
- package/dist/components/common/KeybindingFooter.js +20 -0
- package/dist/components/common/LoadingSpinner.d.ts +9 -0
- package/dist/components/common/LoadingSpinner.js +15 -0
- package/dist/components/common/SearchInput.d.ts +14 -0
- package/dist/components/common/SearchInput.js +72 -0
- package/dist/components/common/StatusBar.d.ts +14 -0
- package/dist/components/common/StatusBar.js +36 -0
- package/dist/components/detail-view/DetailView.d.ts +5 -0
- package/dist/components/detail-view/DetailView.js +266 -0
- package/dist/components/detail-view/LeftPanel.d.ts +19 -0
- package/dist/components/detail-view/LeftPanel.js +363 -0
- package/dist/components/detail-view/ParameterForm.d.ts +17 -0
- package/dist/components/detail-view/ParameterForm.js +77 -0
- package/dist/components/detail-view/RightPanel.d.ts +22 -0
- package/dist/components/detail-view/RightPanel.js +251 -0
- package/dist/components/list-view/EndpointList.d.ts +12 -0
- package/dist/components/list-view/EndpointList.js +72 -0
- package/dist/components/list-view/Explorer.d.ts +13 -0
- package/dist/components/list-view/Explorer.js +83 -0
- package/dist/components/list-view/FilterBar.d.ts +12 -0
- package/dist/components/list-view/FilterBar.js +33 -0
- package/dist/components/list-view/ListView.d.ts +5 -0
- package/dist/components/list-view/ListView.js +304 -0
- package/dist/components/list-view/SearchBar.d.ts +11 -0
- package/dist/components/list-view/SearchBar.js +14 -0
- package/dist/hooks/useApiClient.d.ts +11 -0
- package/dist/hooks/useApiClient.js +34 -0
- package/dist/hooks/useKeyPress.d.ts +26 -0
- package/dist/hooks/useKeyPress.js +57 -0
- package/dist/hooks/useNavigation.d.ts +10 -0
- package/dist/hooks/useNavigation.js +33 -0
- package/dist/services/http-client.d.ts +21 -0
- package/dist/services/http-client.js +173 -0
- package/dist/services/openapi-parser.d.ts +47 -0
- package/dist/services/openapi-parser.js +318 -0
- package/dist/store/appSlice.d.ts +14 -0
- package/dist/store/appSlice.js +144 -0
- package/dist/store/hooks.d.ts +9 -0
- package/dist/store/hooks.js +7 -0
- package/dist/store/index.d.ts +12 -0
- package/dist/store/index.js +12 -0
- package/dist/types/app-state.d.ts +126 -0
- package/dist/types/app-state.js +4 -0
- package/dist/types/openapi.d.ts +86 -0
- package/dist/types/openapi.js +115 -0
- package/dist/utils/clipboard.d.ts +11 -0
- package/dist/utils/clipboard.js +26 -0
- package/dist/utils/formatters.d.ts +35 -0
- package/dist/utils/formatters.js +93 -0
- package/dist/utils/schema-formatter.d.ts +21 -0
- package/dist/utils/schema-formatter.js +301 -0
- package/dist/utils/validators.d.ts +16 -0
- package/dist/utils/validators.js +158 -0
- package/package.json +88 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API 목록 화면
|
|
3
|
+
*/
|
|
4
|
+
import React, { useMemo } from 'react';
|
|
5
|
+
import { Box, useStdout } from 'ink';
|
|
6
|
+
import { useAppSelector, useAppDispatch } from '../../store/hooks.js';
|
|
7
|
+
import { setMode, listSelect, listSetFilter, listSetTag, listSetFocus, listSelectTag, listSetSearch, listSetTagSearch, } from '../../store/appSlice.js';
|
|
8
|
+
import { useNavigation } from '../../hooks/useNavigation.js';
|
|
9
|
+
import { useKeyPress } from '../../hooks/useKeyPress.js';
|
|
10
|
+
import { OpenAPIParserService } from '../../services/openapi-parser.js';
|
|
11
|
+
import StatusBar from '../common/StatusBar.js';
|
|
12
|
+
import KeybindingFooter from '../common/KeybindingFooter.js';
|
|
13
|
+
import EndpointList from './EndpointList.js';
|
|
14
|
+
import FilterBar from './FilterBar.js';
|
|
15
|
+
import SearchBar from './SearchBar.js';
|
|
16
|
+
import Explorer from './Explorer.js';
|
|
17
|
+
import { setCommandInput } from '../../store/appSlice.js';
|
|
18
|
+
export default function ListView() {
|
|
19
|
+
const endpoints = useAppSelector((state) => state.app.endpoints);
|
|
20
|
+
const list = useAppSelector((state) => state.app.list);
|
|
21
|
+
const mode = useAppSelector((state) => state.app.mode);
|
|
22
|
+
const document = useAppSelector((state) => state.app.document);
|
|
23
|
+
const dispatch = useAppDispatch();
|
|
24
|
+
const { goToDetail } = useNavigation();
|
|
25
|
+
const parser = useMemo(() => new OpenAPIParserService(), []);
|
|
26
|
+
const { stdout } = useStdout();
|
|
27
|
+
// 태그 목록 추출
|
|
28
|
+
const tags = useMemo(() => {
|
|
29
|
+
const t = new Set();
|
|
30
|
+
endpoints.forEach((ep) => ep.tags?.forEach((tag) => t.add(tag)));
|
|
31
|
+
return Array.from(t).sort();
|
|
32
|
+
}, [endpoints]);
|
|
33
|
+
const allTags = ['Overview', ...tags];
|
|
34
|
+
// Tag Explorer 검색 필터링
|
|
35
|
+
const filteredTags = list.tagSearchQuery
|
|
36
|
+
? allTags.filter((tag) => tag.toLowerCase().includes(list.tagSearchQuery.toLowerCase()))
|
|
37
|
+
: allTags;
|
|
38
|
+
// 필터링 및 검색 적용
|
|
39
|
+
const filteredEndpoints = useMemo(() => {
|
|
40
|
+
let filtered = endpoints;
|
|
41
|
+
// 태그 필터
|
|
42
|
+
if (list.activeTag) {
|
|
43
|
+
filtered = filtered.filter((ep) => ep.tags?.includes(list.activeTag));
|
|
44
|
+
}
|
|
45
|
+
// 메서드 필터
|
|
46
|
+
if (list.activeFilter !== 'ALL') {
|
|
47
|
+
filtered = parser.filterEndpoints(filtered, list.activeFilter);
|
|
48
|
+
}
|
|
49
|
+
// 검색
|
|
50
|
+
if (list.searchQuery) {
|
|
51
|
+
filtered = parser.searchEndpoints(filtered, list.searchQuery);
|
|
52
|
+
}
|
|
53
|
+
return filtered;
|
|
54
|
+
}, [list.activeFilter, list.searchQuery, list.activeTag, endpoints, parser]);
|
|
55
|
+
// 커맨드 실행 핸들러
|
|
56
|
+
const handleCommandSubmit = (cmd) => {
|
|
57
|
+
// 1. 종료 명령어 처리
|
|
58
|
+
if (cmd === 'q' || cmd === 'quit' || cmd === 'exit') {
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
// 2. 접두사 있는 명령어 처리 (t1, m1, e1...)
|
|
62
|
+
const match = cmd.match(/^([tme])(\d+)$/);
|
|
63
|
+
if (match) {
|
|
64
|
+
const type = match[1];
|
|
65
|
+
const index = parseInt(match[2], 10);
|
|
66
|
+
const targetIndex = index - 1;
|
|
67
|
+
if (type === 't') {
|
|
68
|
+
// Tag Explorer
|
|
69
|
+
if (targetIndex >= 0 && targetIndex < filteredTags.length) {
|
|
70
|
+
dispatch(listSelectTag(targetIndex));
|
|
71
|
+
dispatch(listSetFocus('TAGS'));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else if (type === 'm') {
|
|
75
|
+
// Filter (Method)
|
|
76
|
+
const filters = ['ALL', 'GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
|
|
77
|
+
if (targetIndex >= 0 && targetIndex < filters.length) {
|
|
78
|
+
dispatch(listSetFilter(filters[targetIndex]));
|
|
79
|
+
dispatch(listSetFocus('LIST'));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else if (type === 'e') {
|
|
83
|
+
// Endpoint List (Items)
|
|
84
|
+
if (targetIndex >= 0 && targetIndex < filteredEndpoints.length) {
|
|
85
|
+
dispatch(listSelect(targetIndex));
|
|
86
|
+
dispatch(listSetFocus('LIST'));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
dispatch(setMode('NORMAL'));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// 3. 숫자만 있는 경우 (기존 동작 호환: 현재 포커스된 영역의 아이템 선택)
|
|
93
|
+
const index = parseInt(cmd, 10);
|
|
94
|
+
if (!isNaN(index)) {
|
|
95
|
+
const targetIndex = index - 1;
|
|
96
|
+
if (list.focus === 'TAGS') {
|
|
97
|
+
if (targetIndex >= 0 && targetIndex < filteredTags.length) {
|
|
98
|
+
dispatch(listSelectTag(targetIndex));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// LIST focus: Filter selection
|
|
103
|
+
// 원래 LIST 포커스일 때 숫자 입력은 Filter를 선택하는 로직이었음.
|
|
104
|
+
const filters = ['ALL', 'GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
|
|
105
|
+
if (targetIndex >= 0 && targetIndex < filters.length) {
|
|
106
|
+
dispatch(listSetFilter(filters[targetIndex]));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
dispatch(setMode('NORMAL'));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
// 매치되지 않는 경우 그냥 모드 종료
|
|
113
|
+
dispatch(setMode('NORMAL'));
|
|
114
|
+
};
|
|
115
|
+
// 키보드 입력 처리
|
|
116
|
+
useKeyPress({
|
|
117
|
+
onTab: () => {
|
|
118
|
+
const newFocus = list.focus === 'LIST' ? 'TAGS' : 'LIST';
|
|
119
|
+
dispatch(listSetFocus(newFocus));
|
|
120
|
+
},
|
|
121
|
+
j: () => {
|
|
122
|
+
if (list.focus === 'TAGS') {
|
|
123
|
+
const nextIndex = Math.min(list.selectedTagIndex + 1, filteredTags.length - 1);
|
|
124
|
+
dispatch(listSelectTag(nextIndex));
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
// 다음 항목
|
|
128
|
+
const nextIndex = Math.min(list.selectedIndex + 1, filteredEndpoints.length - 1);
|
|
129
|
+
dispatch(listSelect(nextIndex));
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
k: () => {
|
|
133
|
+
if (list.focus === 'TAGS') {
|
|
134
|
+
const prevIndex = Math.max(list.selectedTagIndex - 1, 0);
|
|
135
|
+
dispatch(listSelectTag(prevIndex));
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
// 이전 항목
|
|
139
|
+
const prevIndex = Math.max(list.selectedIndex - 1, 0);
|
|
140
|
+
dispatch(listSelect(prevIndex));
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
// Page Up ({)
|
|
144
|
+
'{': () => {
|
|
145
|
+
if (list.focus === 'TAGS') {
|
|
146
|
+
const prevIndex = Math.max(list.selectedTagIndex - pageSize, 0);
|
|
147
|
+
dispatch(listSelectTag(prevIndex));
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
const prevIndex = Math.max(list.selectedIndex - pageSize, 0);
|
|
151
|
+
dispatch(listSelect(prevIndex));
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
// Page Down (})
|
|
155
|
+
'}': () => {
|
|
156
|
+
if (list.focus === 'TAGS') {
|
|
157
|
+
const nextIndex = Math.min(list.selectedTagIndex + pageSize, filteredTags.length - 1);
|
|
158
|
+
dispatch(listSelectTag(nextIndex));
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
const nextIndex = Math.min(list.selectedIndex + pageSize, filteredEndpoints.length - 1);
|
|
162
|
+
dispatch(listSelect(nextIndex));
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
g: () => {
|
|
166
|
+
if (list.focus === 'LIST') {
|
|
167
|
+
dispatch(listSelect(0));
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
G: () => {
|
|
171
|
+
if (list.focus === 'LIST') {
|
|
172
|
+
dispatch(listSelect(filteredEndpoints.length - 1));
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
'/': () => {
|
|
176
|
+
// 검색 모드 - focus에 따라 다른 모드
|
|
177
|
+
if (list.focus === 'TAGS') {
|
|
178
|
+
dispatch(setMode('TAG_SEARCH'));
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
dispatch(setMode('SEARCH'));
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
':': () => {
|
|
185
|
+
dispatch(setMode('COMMAND'));
|
|
186
|
+
},
|
|
187
|
+
onReturn: () => {
|
|
188
|
+
if (list.focus === 'TAGS') {
|
|
189
|
+
const tag = filteredTags[list.selectedTagIndex];
|
|
190
|
+
const actualTag = tag === 'Overview' ? null : tag;
|
|
191
|
+
// 다른 태그를 선택한 경우에만 태그 변경 (selectedIndex 리셋됨)
|
|
192
|
+
if (actualTag !== list.activeTag) {
|
|
193
|
+
dispatch(listSetTag(actualTag));
|
|
194
|
+
}
|
|
195
|
+
// 포커스 유지 (Enter는 선택만 수행)
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
// 선택한 엔드포인트 상세 보기
|
|
199
|
+
const selected = filteredEndpoints[list.selectedIndex];
|
|
200
|
+
if (selected) {
|
|
201
|
+
goToDetail(selected);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
l: () => {
|
|
206
|
+
if (list.focus === 'TAGS') {
|
|
207
|
+
const tag = filteredTags[list.selectedTagIndex];
|
|
208
|
+
const actualTag = tag === 'Overview' ? null : tag;
|
|
209
|
+
// 다른 태그를 선택한 경우에만 태그 변경 (selectedIndex 리셋됨)
|
|
210
|
+
if (actualTag !== list.activeTag) {
|
|
211
|
+
dispatch(listSetTag(actualTag));
|
|
212
|
+
}
|
|
213
|
+
// l 키는 오른쪽 패널로 이동
|
|
214
|
+
dispatch(listSetFocus('LIST'));
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
const selected = filteredEndpoints[list.selectedIndex];
|
|
218
|
+
if (selected) {
|
|
219
|
+
goToDetail(selected);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
h: () => {
|
|
224
|
+
if (list.focus === 'LIST') {
|
|
225
|
+
dispatch(listSetFocus('TAGS'));
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
}, mode === 'NORMAL');
|
|
229
|
+
// Command 모드 처리
|
|
230
|
+
useKeyPress({
|
|
231
|
+
onEscape: () => {
|
|
232
|
+
dispatch(setMode('NORMAL'));
|
|
233
|
+
dispatch(setCommandInput(''));
|
|
234
|
+
},
|
|
235
|
+
}, mode === 'COMMAND');
|
|
236
|
+
// 검색 모드 키보드 입력 처리 (엔드포인트 검색)
|
|
237
|
+
useKeyPress({
|
|
238
|
+
onReturn: () => {
|
|
239
|
+
// 검색 쿼리를 유지하고 NORMAL 모드로 전환
|
|
240
|
+
dispatch(setMode('NORMAL'));
|
|
241
|
+
// 첫 번째 필터링된 항목으로 인덱스 초기화
|
|
242
|
+
dispatch(listSelect(0));
|
|
243
|
+
},
|
|
244
|
+
onEscape: () => {
|
|
245
|
+
// 검색 취소
|
|
246
|
+
dispatch(listSetSearch(''));
|
|
247
|
+
dispatch(setMode('NORMAL'));
|
|
248
|
+
},
|
|
249
|
+
}, mode === 'SEARCH');
|
|
250
|
+
// Tag Explorer 검색 모드 키보드 입력 처리
|
|
251
|
+
useKeyPress({
|
|
252
|
+
onReturn: () => {
|
|
253
|
+
// 검색 쿼리를 유지하고 NORMAL 모드로 전환
|
|
254
|
+
dispatch(setMode('NORMAL'));
|
|
255
|
+
// 첫 번째 필터링된 항목으로 인덱스 초기화
|
|
256
|
+
dispatch(listSelectTag(0));
|
|
257
|
+
},
|
|
258
|
+
onEscape: () => {
|
|
259
|
+
// 검색 취소
|
|
260
|
+
dispatch(listSetTagSearch(''));
|
|
261
|
+
dispatch(setMode('NORMAL'));
|
|
262
|
+
},
|
|
263
|
+
}, mode === 'TAG_SEARCH');
|
|
264
|
+
const keyBindings = [
|
|
265
|
+
{ key: 'Tab', description: 'Switch Focus' },
|
|
266
|
+
{ key: 'j/k', description: 'Next/Prev' },
|
|
267
|
+
{ key: '{/}', description: 'Page Up/Down' },
|
|
268
|
+
{ key: 'h/l', description: 'Nav' },
|
|
269
|
+
{ key: ':', description: 'Command' },
|
|
270
|
+
{ key: 'Enter', description: 'Select' },
|
|
271
|
+
list.focus === 'TAGS'
|
|
272
|
+
? { key: '/', description: 'Search' }
|
|
273
|
+
: { key: '/', description: 'Search' },
|
|
274
|
+
];
|
|
275
|
+
const searchKeyBindings = [
|
|
276
|
+
{ key: 'Type', description: 'Search Query' },
|
|
277
|
+
{ key: 'Enter', description: 'Apply' },
|
|
278
|
+
{ key: 'Esc', description: 'Cancel' },
|
|
279
|
+
];
|
|
280
|
+
const tagSearchKeyBindings = [
|
|
281
|
+
{ key: 'Type', description: 'Search' },
|
|
282
|
+
{ key: 'Enter', description: 'Apply' },
|
|
283
|
+
{ key: 'Esc', description: 'Cancel' },
|
|
284
|
+
];
|
|
285
|
+
const terminalHeight = stdout?.rows || 24;
|
|
286
|
+
// ListView와 Tag Explorer의 가용 높이 계산 (동일하게 -9 사용)
|
|
287
|
+
const pageSize = Math.max(1, terminalHeight - 9);
|
|
288
|
+
return (React.createElement(Box, { flexDirection: "column", height: terminalHeight },
|
|
289
|
+
React.createElement(StatusBar, { title: "LAZYAPI", version: "1.0.0", currentPath: document?.info.title || '', mode: mode, status: "ONLINE" }),
|
|
290
|
+
React.createElement(Box, { flexDirection: "row", flexGrow: 1, overflow: "hidden" },
|
|
291
|
+
React.createElement(Box, null,
|
|
292
|
+
React.createElement(Explorer, { tags: tags, selectedTagIndex: list.selectedTagIndex, activeTag: list.activeTag, isFocused: list.focus === 'TAGS', searchQuery: list.tagSearchQuery, isSearchMode: mode === 'TAG_SEARCH', commandMode: mode === 'COMMAND', onSearchChange: (query) => dispatch(listSetTagSearch(query)) })),
|
|
293
|
+
React.createElement(Box, { flexDirection: "column", flexGrow: 1, marginLeft: 1, borderStyle: "single", borderColor: list.focus === 'LIST' ? 'green' : 'gray', paddingX: 1, paddingBottom: 1, paddingTop: 0 },
|
|
294
|
+
React.createElement(Box, { flexDirection: "row", alignItems: "center", gap: 2 },
|
|
295
|
+
React.createElement(FilterBar, { activeFilter: list.activeFilter, commandMode: mode === 'COMMAND', onFilterChange: (filter) => dispatch(listSetFilter(filter)) })),
|
|
296
|
+
React.createElement(SearchBar, { query: list.searchQuery, onQueryChange: (query) => dispatch(listSetSearch(query)), isSearchMode: mode === 'SEARCH' }),
|
|
297
|
+
React.createElement(Box, { flexGrow: 1, marginTop: 0, overflow: "hidden" },
|
|
298
|
+
React.createElement(EndpointList, { endpoints: filteredEndpoints, selectedIndex: list.selectedIndex, commandMode: mode === 'COMMAND' })))),
|
|
299
|
+
React.createElement(KeybindingFooter, { bindings: mode === 'SEARCH'
|
|
300
|
+
? searchKeyBindings
|
|
301
|
+
: mode === 'TAG_SEARCH'
|
|
302
|
+
? tagSearchKeyBindings
|
|
303
|
+
: keyBindings, mode: mode, additionalInfo: `${list.selectedIndex + 1}/${filteredEndpoints.length}`, commandInput: useAppSelector((state) => state.app.commandInput), onCommandChange: (value) => dispatch(setCommandInput(value)), onCommandSubmit: handleCommandSubmit })));
|
|
304
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 검색 바 컴포넌트
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react';
|
|
5
|
+
interface SearchBarProps {
|
|
6
|
+
query: string;
|
|
7
|
+
onQueryChange: (query: string) => void;
|
|
8
|
+
isSearchMode: boolean;
|
|
9
|
+
}
|
|
10
|
+
export default function SearchBar({ query, onQueryChange, isSearchMode }: SearchBarProps): React.JSX.Element;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 검색 바 컴포넌트
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { Box, Text } from 'ink';
|
|
6
|
+
import TextInput from 'ink-text-input';
|
|
7
|
+
export default function SearchBar({ query, onQueryChange, isSearchMode }) {
|
|
8
|
+
return (React.createElement(Box, { paddingX: 0, marginBottom: 0, flexDirection: "row", width: "100%" },
|
|
9
|
+
React.createElement(Text, { color: isSearchMode ? 'green' : 'gray', bold: true },
|
|
10
|
+
"\u279C",
|
|
11
|
+
' '),
|
|
12
|
+
React.createElement(Box, { flexGrow: 1 },
|
|
13
|
+
React.createElement(Text, { underline: true, color: isSearchMode ? 'white' : 'gray' }, isSearchMode ? (React.createElement(TextInput, { value: query, onChange: onQueryChange, placeholder: "", focus: true, showCursor: true })) : query ? (query) : ('Search...')))));
|
|
14
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API 클라이언트 Hook
|
|
3
|
+
*/
|
|
4
|
+
import { Endpoint } from '../types/openapi.js';
|
|
5
|
+
import { ApiResponse } from '../types/app-state.js';
|
|
6
|
+
export declare function useApiClient(baseURL?: string): {
|
|
7
|
+
executeRequest: (endpoint: Endpoint, parameterValues: Record<string, string>, headers: Record<string, string>, requestBody?: string, customBaseURL?: string) => Promise<ApiResponse | null>;
|
|
8
|
+
setBaseURL: (url: string) => void;
|
|
9
|
+
loading: boolean;
|
|
10
|
+
error: string | null;
|
|
11
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API 클라이언트 Hook
|
|
3
|
+
*/
|
|
4
|
+
import { useState, useCallback } from 'react';
|
|
5
|
+
import { HttpClientService } from '../services/http-client.js';
|
|
6
|
+
export function useApiClient(baseURL) {
|
|
7
|
+
const [loading, setLoading] = useState(false);
|
|
8
|
+
const [error, setError] = useState(null);
|
|
9
|
+
const [httpClient] = useState(() => new HttpClientService(baseURL));
|
|
10
|
+
const executeRequest = useCallback(async (endpoint, parameterValues, headers, requestBody, customBaseURL) => {
|
|
11
|
+
setLoading(true);
|
|
12
|
+
setError(null);
|
|
13
|
+
try {
|
|
14
|
+
const response = await httpClient.executeRequest(endpoint, parameterValues, headers, requestBody, customBaseURL || baseURL);
|
|
15
|
+
setLoading(false);
|
|
16
|
+
return response;
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
const errorMessage = err instanceof Error ? err.message : '요청 실행 중 오류가 발생했습니다';
|
|
20
|
+
setError(errorMessage);
|
|
21
|
+
setLoading(false);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}, [httpClient, baseURL]);
|
|
25
|
+
const setBaseURL = useCallback((url) => {
|
|
26
|
+
httpClient.setBaseURL(url);
|
|
27
|
+
}, [httpClient]);
|
|
28
|
+
return {
|
|
29
|
+
executeRequest,
|
|
30
|
+
setBaseURL,
|
|
31
|
+
loading,
|
|
32
|
+
error,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 키보드 입력 처리 Hook
|
|
3
|
+
*/
|
|
4
|
+
export interface KeyHandler {
|
|
5
|
+
(key: string, meta: {
|
|
6
|
+
ctrl: boolean;
|
|
7
|
+
shift: boolean;
|
|
8
|
+
meta: boolean;
|
|
9
|
+
}): void;
|
|
10
|
+
}
|
|
11
|
+
export interface KeyBindings {
|
|
12
|
+
[key: string]: KeyHandler | undefined;
|
|
13
|
+
onTab?: KeyHandler;
|
|
14
|
+
onShiftTab?: KeyHandler;
|
|
15
|
+
onEscape?: KeyHandler;
|
|
16
|
+
onReturn?: KeyHandler;
|
|
17
|
+
onBackspace?: KeyHandler;
|
|
18
|
+
onUpArrow?: KeyHandler;
|
|
19
|
+
onDownArrow?: KeyHandler;
|
|
20
|
+
onLeftArrow?: KeyHandler;
|
|
21
|
+
onRightArrow?: KeyHandler;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 키보드 입력을 처리하는 Hook
|
|
25
|
+
*/
|
|
26
|
+
export declare function useKeyPress(bindings: KeyBindings, isEnabled?: boolean): void;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 키보드 입력 처리 Hook
|
|
3
|
+
*/
|
|
4
|
+
import { useInput } from 'ink';
|
|
5
|
+
import { useCallback } from 'react';
|
|
6
|
+
/**
|
|
7
|
+
* 키보드 입력을 처리하는 Hook
|
|
8
|
+
*/
|
|
9
|
+
export function useKeyPress(bindings, isEnabled = true) {
|
|
10
|
+
const handleInput = useCallback((input, key) => {
|
|
11
|
+
if (!isEnabled)
|
|
12
|
+
return;
|
|
13
|
+
// 특수 키 처리
|
|
14
|
+
if (key.tab) {
|
|
15
|
+
if (key.shift && bindings.onShiftTab) {
|
|
16
|
+
bindings.onShiftTab(input, key);
|
|
17
|
+
}
|
|
18
|
+
else if (bindings.onTab) {
|
|
19
|
+
bindings.onTab(input, key);
|
|
20
|
+
}
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (key.escape && bindings.onEscape) {
|
|
24
|
+
bindings.onEscape(input, key);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (key.return && bindings.onReturn) {
|
|
28
|
+
bindings.onReturn(input, key);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (key.backspace && bindings.onBackspace) {
|
|
32
|
+
bindings.onBackspace(input, key);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (key.upArrow && bindings.onUpArrow) {
|
|
36
|
+
bindings.onUpArrow(input, key);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (key.downArrow && bindings.onDownArrow) {
|
|
40
|
+
bindings.onDownArrow(input, key);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (key.leftArrow && bindings.onLeftArrow) {
|
|
44
|
+
bindings.onLeftArrow(input, key);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (key.rightArrow && bindings.onRightArrow) {
|
|
48
|
+
bindings.onRightArrow(input, key);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// 일반 키 입력 처리
|
|
52
|
+
if (input && bindings[input]) {
|
|
53
|
+
bindings[input](input, key);
|
|
54
|
+
}
|
|
55
|
+
}, [bindings, isEnabled]);
|
|
56
|
+
useInput(handleInput, { isActive: isEnabled });
|
|
57
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 화면 네비게이션 Hook
|
|
3
|
+
*/
|
|
4
|
+
import { Endpoint } from '../types/openapi.js';
|
|
5
|
+
export declare function useNavigation(): {
|
|
6
|
+
currentScreen: import("../types/app-state.js").Screen;
|
|
7
|
+
goToList: () => void;
|
|
8
|
+
goToDetail: (endpoint: Endpoint) => void;
|
|
9
|
+
goBack: () => void;
|
|
10
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 화면 네비게이션 Hook
|
|
3
|
+
*/
|
|
4
|
+
import { useCallback } from 'react';
|
|
5
|
+
import { useAppSelector, useAppDispatch } from '../store/hooks.js';
|
|
6
|
+
import { setScreen, setMode, detailSet } from '../store/appSlice.js';
|
|
7
|
+
export function useNavigation() {
|
|
8
|
+
const screen = useAppSelector((state) => state.app.screen);
|
|
9
|
+
const dispatch = useAppDispatch();
|
|
10
|
+
const goToList = useCallback(() => {
|
|
11
|
+
dispatch(setScreen('LIST'));
|
|
12
|
+
dispatch(setMode('NORMAL'));
|
|
13
|
+
}, [dispatch]);
|
|
14
|
+
const goToDetail = useCallback((endpoint) => {
|
|
15
|
+
dispatch(detailSet(endpoint));
|
|
16
|
+
dispatch(setMode('NORMAL'));
|
|
17
|
+
}, [dispatch]);
|
|
18
|
+
const goBack = useCallback(() => {
|
|
19
|
+
switch (screen) {
|
|
20
|
+
case 'DETAIL':
|
|
21
|
+
goToList();
|
|
22
|
+
break;
|
|
23
|
+
default:
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
}, [screen, goToList]);
|
|
27
|
+
return {
|
|
28
|
+
currentScreen: screen,
|
|
29
|
+
goToList,
|
|
30
|
+
goToDetail,
|
|
31
|
+
goBack,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP 클라이언트 서비스
|
|
3
|
+
*/
|
|
4
|
+
import { Endpoint } from '../types/openapi.js';
|
|
5
|
+
import { ApiResponse } from '../types/app-state.js';
|
|
6
|
+
export declare class HttpClientService {
|
|
7
|
+
private client;
|
|
8
|
+
constructor(baseURL?: string);
|
|
9
|
+
/**
|
|
10
|
+
* API 요청 실행
|
|
11
|
+
*/
|
|
12
|
+
executeRequest(endpoint: Endpoint, parameterValues: Record<string, string>, headers: Record<string, string>, requestBody?: string, baseURL?: string): Promise<ApiResponse>;
|
|
13
|
+
/**
|
|
14
|
+
* 기본 URL 설정
|
|
15
|
+
*/
|
|
16
|
+
setBaseURL(baseURL: string): void;
|
|
17
|
+
/**
|
|
18
|
+
* 기본 헤더 설정
|
|
19
|
+
*/
|
|
20
|
+
setDefaultHeaders(headers: Record<string, string>): void;
|
|
21
|
+
}
|