sh-ui-cli 0.24.0 → 0.31.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/README.md +8 -9
- package/data/changelog/versions.json +117 -1
- package/data/registry/react/components/code-editor/index.tsx +232 -0
- package/data/registry/react/components/code-editor/styles.css +76 -0
- package/data/registry/react/components/code-tabs/index.tsx +49 -0
- package/data/registry/react/components/header/index.tsx +632 -82
- package/data/registry/react/components/header/styles.css +169 -9
- package/data/registry/react/components/markdown-editor/index.tsx +121 -0
- package/data/registry/react/components/markdown-editor/styles.css +160 -0
- package/data/registry/react/components/page-toc/index.tsx +175 -0
- package/data/registry/react/components/page-toc/styles.css +82 -0
- package/data/registry/react/components/rich-text-editor/index.tsx +350 -0
- package/data/registry/react/components/rich-text-editor/styles.css +196 -0
- package/data/registry/react/registry.json +100 -0
- package/data/summaries/react.json +6 -1
- package/package.json +1 -1
- package/src/mcp.mjs +0 -1
- package/templates/flutter-standalone/README.md +2 -2
- package/templates/monorepo/README.md +4 -4
- package/templates/nextjs-app/app/api/proxy/[...path]/route.ts +85 -0
- package/templates/nextjs-app/src/shared/api/apiTypes.ts +21 -0
- package/templates/nextjs-app/src/shared/api/error.ts +12 -0
- package/templates/nextjs-app/src/shared/api/http.ts +56 -0
- package/templates/nextjs-standalone/README.md +3 -3
- package/templates/nextjs-standalone/app/api/proxy/[...path]/route.ts +85 -0
- package/templates/nextjs-standalone/src/shared/api/apiTypes.ts +21 -0
- package/templates/nextjs-standalone/src/shared/api/error.ts +12 -0
- package/templates/nextjs-standalone/src/shared/api/http.ts +56 -0
|
@@ -157,6 +157,106 @@
|
|
|
157
157
|
],
|
|
158
158
|
"registryDependencies": []
|
|
159
159
|
},
|
|
160
|
+
"code-tabs": {
|
|
161
|
+
"name": "code-tabs",
|
|
162
|
+
"type": "component",
|
|
163
|
+
"files": [
|
|
164
|
+
{
|
|
165
|
+
"src": "components/code-tabs/index.tsx",
|
|
166
|
+
"dest": "{components}/code-tabs/index.tsx"
|
|
167
|
+
}
|
|
168
|
+
],
|
|
169
|
+
"dependencies": [],
|
|
170
|
+
"registryDependencies": [
|
|
171
|
+
"tabs",
|
|
172
|
+
"code-panel"
|
|
173
|
+
]
|
|
174
|
+
},
|
|
175
|
+
"page-toc": {
|
|
176
|
+
"name": "page-toc",
|
|
177
|
+
"type": "component",
|
|
178
|
+
"files": [
|
|
179
|
+
{
|
|
180
|
+
"src": "components/page-toc/index.tsx",
|
|
181
|
+
"dest": "{components}/page-toc/index.tsx"
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
"src": "components/page-toc/styles.css",
|
|
185
|
+
"dest": "{components}/page-toc/styles.css"
|
|
186
|
+
}
|
|
187
|
+
],
|
|
188
|
+
"dependencies": [],
|
|
189
|
+
"registryDependencies": []
|
|
190
|
+
},
|
|
191
|
+
"code-editor": {
|
|
192
|
+
"name": "code-editor",
|
|
193
|
+
"type": "component",
|
|
194
|
+
"files": [
|
|
195
|
+
{
|
|
196
|
+
"src": "components/code-editor/index.tsx",
|
|
197
|
+
"dest": "{components}/code-editor/index.tsx"
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
"src": "components/code-editor/styles.css",
|
|
201
|
+
"dest": "{components}/code-editor/styles.css"
|
|
202
|
+
}
|
|
203
|
+
],
|
|
204
|
+
"dependencies": [
|
|
205
|
+
"codemirror",
|
|
206
|
+
"@codemirror/state",
|
|
207
|
+
"@codemirror/view",
|
|
208
|
+
"@codemirror/lang-javascript",
|
|
209
|
+
"@codemirror/lang-json",
|
|
210
|
+
"@codemirror/lang-css",
|
|
211
|
+
"@codemirror/lang-html",
|
|
212
|
+
"@codemirror/lang-markdown"
|
|
213
|
+
],
|
|
214
|
+
"registryDependencies": []
|
|
215
|
+
},
|
|
216
|
+
"markdown-editor": {
|
|
217
|
+
"name": "markdown-editor",
|
|
218
|
+
"type": "component",
|
|
219
|
+
"files": [
|
|
220
|
+
{
|
|
221
|
+
"src": "components/markdown-editor/index.tsx",
|
|
222
|
+
"dest": "{components}/markdown-editor/index.tsx"
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
"src": "components/markdown-editor/styles.css",
|
|
226
|
+
"dest": "{components}/markdown-editor/styles.css"
|
|
227
|
+
}
|
|
228
|
+
],
|
|
229
|
+
"dependencies": [
|
|
230
|
+
"react-markdown",
|
|
231
|
+
"remark-gfm"
|
|
232
|
+
],
|
|
233
|
+
"registryDependencies": [
|
|
234
|
+
"code-editor"
|
|
235
|
+
]
|
|
236
|
+
},
|
|
237
|
+
"rich-text-editor": {
|
|
238
|
+
"name": "rich-text-editor",
|
|
239
|
+
"type": "component",
|
|
240
|
+
"files": [
|
|
241
|
+
{
|
|
242
|
+
"src": "components/rich-text-editor/index.tsx",
|
|
243
|
+
"dest": "{components}/rich-text-editor/index.tsx"
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
"src": "components/rich-text-editor/styles.css",
|
|
247
|
+
"dest": "{components}/rich-text-editor/styles.css"
|
|
248
|
+
}
|
|
249
|
+
],
|
|
250
|
+
"dependencies": [
|
|
251
|
+
"@tiptap/react",
|
|
252
|
+
"@tiptap/pm",
|
|
253
|
+
"@tiptap/starter-kit",
|
|
254
|
+
"@tiptap/extension-placeholder",
|
|
255
|
+
"@tiptap/extension-link",
|
|
256
|
+
"lucide-react"
|
|
257
|
+
],
|
|
258
|
+
"registryDependencies": []
|
|
259
|
+
},
|
|
160
260
|
"select": {
|
|
161
261
|
"name": "select",
|
|
162
262
|
"type": "component",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"accordion": "펼침/접힘 아코디언 — single/multiple (Base UI).",
|
|
28
28
|
"carousel": "슬라이드 캐러셀 — Embla 기반, autoplay/autoscroll.",
|
|
29
29
|
"sidebar": "앱 사이드바 — collapsible, SidebarMenu/SidebarGroup 조합.",
|
|
30
|
-
"header": "앱 헤더 — 로고/네비/액션 compound.",
|
|
30
|
+
"header": "앱 헤더 — 로고/네비/액션 compound, 데스크탑 inline / 모바일 drawer 자동 전환. drawer focus trap·ESC·focus restore. HeaderNav value(controlled) / defaultValue+onValueChange(uncontrolled) 로 자식 HeaderItem active 자동 매칭 (aria-current 자동, match 커스터마이즈). HeaderMenu(서브메뉴, 데스크탑 portal dropdown / drawer collapsible) · HeaderNavGroup(섹션 라벨) · HeaderDesktopOnly/HeaderMobileOnly(가시성 토글, drawer 이동 없음). variant(solid/transparent/blur) · stickyHide(prefers-reduced-motion 존중, 컨테이너 스크롤 자동 감지) 정식 지원. backdrop-filter @supports 폴백.",
|
|
31
31
|
"breadcrumb": "경로 내비게이션 — compound (Breadcrumb.List/Item/Link/Page/Separator/Ellipsis). aria-current 자동.",
|
|
32
32
|
"pagination": "페이지 단위 내비게이션 — compound (PaginationContent/Item/Link/Previous/Next/Ellipsis). getPaginationRange 유틸 동봉. aria-current 자동.",
|
|
33
33
|
"avatar": "프로필 아바타 — 이미지 fallback → initials (Base UI).",
|
|
@@ -38,6 +38,11 @@
|
|
|
38
38
|
"skeleton": "스켈레톤 로딩 플레이스홀더.",
|
|
39
39
|
"theme": "테마 프로바이더 + useTheme 훅 — light/dark/system.",
|
|
40
40
|
"code-panel": "Shiki 기반 코드 하이라이트 패널 — 복사 버튼 포함.",
|
|
41
|
+
"code-tabs": "여러 코드 뷰(예: React/Flutter, 강조/전체)를 탭으로 전환 — Tabs + CodePanel 합성, 각 탭 내용은 CodePanelProps 그대로.",
|
|
42
|
+
"page-toc": "페이지 자동 목차 — 헤딩 스캔 · slugify · IntersectionObserver active 추적 · smooth scroll. routeKey 로 라우트 변경 자동 재스캔, levels/excludeSelector 커스터마이즈.",
|
|
43
|
+
"code-editor": "CodeMirror 6 기반 코드 에디터 — js/ts/jsx/tsx/json/css/html/markdown, sh-ui 토큰 테마, readOnly·placeholder·minHeight/maxHeight. controlled(value/onChange) · uncontrolled(defaultValue) 모두 지원.",
|
|
44
|
+
"markdown-editor": "마크다운 에디터 — CodeEditor + react-markdown 라이브 프리뷰 합성, GFM 지원, raw HTML 차단으로 XSS 방어. controlled(value/onChange) · uncontrolled(defaultValue) 모두 지원.",
|
|
45
|
+
"rich-text-editor": "Tiptap 3 기반 WYSIWYG 에디터 — HTML 입출력, 기본 toolbar(헤딩·리스트·인용·코드·링크·강조), readOnly·placeholder. controlled(value/onChange) · uncontrolled(defaultValue) 모두 지원.",
|
|
41
46
|
"base": "CSS 리셋 — base.css.",
|
|
42
47
|
"breakpoints": "반응형 미디어 쿼리 토큰 — breakpoints.css.",
|
|
43
48
|
"focus-ring": "공용 포커스 링 스타일 — focus-ring.css.",
|
package/package.json
CHANGED
package/src/mcp.mjs
CHANGED
|
@@ -113,7 +113,6 @@ const SERVER_INSTRUCTIONS = `sh-ui — Base UI 위에 빌드된 React/Flutter
|
|
|
113
113
|
- 인터랙티브 프롬프트 없이 한 번에 스캐폴드 + 토큰 + sh-ui.config.json 생성
|
|
114
114
|
|
|
115
115
|
**2차 — Bash** (사용자가 직접 셸에서 돌리고 싶다고 명시할 때만):
|
|
116
|
-
npm create sh-ui my-app
|
|
117
116
|
npx sh-ui-cli create my-app --platform next --structure standalone --yes
|
|
118
117
|
|
|
119
118
|
\`create-next-app\` + \`sh_ui_init\` 조합은 **쓰지 말 것** — 위 두 경로가 더 짧고 sh-ui 관용에 맞다.
|
|
@@ -77,7 +77,7 @@ pnpm dev # 모든 앱 동시 실행
|
|
|
77
77
|
## 앱 추가
|
|
78
78
|
|
|
79
79
|
```bash
|
|
80
|
-
npx sh-ui-create add-app
|
|
80
|
+
npx sh-ui-cli create add-app
|
|
81
81
|
```
|
|
82
82
|
|
|
83
83
|
`apps/{name}/` 과 `packages/ui/ui-apps/ui-{name}/` 을 함께 생성합니다.
|
|
@@ -93,11 +93,11 @@ pnpm --filter admin build
|
|
|
93
93
|
|
|
94
94
|
```bash
|
|
95
95
|
# 모든 ui 패키지에 추가 (대화형)
|
|
96
|
-
npx sh-ui-create add-component button
|
|
96
|
+
npx sh-ui-cli create add-component button
|
|
97
97
|
|
|
98
98
|
# 특정 앱에만 추가
|
|
99
|
-
npx sh-ui-create add-component button --app web
|
|
99
|
+
npx sh-ui-cli create add-component button --app web
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
-
내부적으로 `packages/ui/ui-apps/ui-{app}/` 디렉토리에서 `npx sh-ui add button` 이 실행되며,
|
|
102
|
+
내부적으로 `packages/ui/ui-apps/ui-{app}/` 디렉토리에서 `npx sh-ui-cli add button` 이 실행되며,
|
|
103
103
|
각 패키지의 `sh-ui.config.json` 에 선언된 경로로 컴포넌트가 복사됩니다.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { cookies } from 'next/headers';
|
|
2
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
3
|
+
|
|
4
|
+
const API_URL = process.env.API_URL ?? 'http://localhost:8080/api';
|
|
5
|
+
const ACCESS_TOKEN_COOKIE = 'accessToken';
|
|
6
|
+
const LOCALE_COOKIE = 'NEXT_LOCALE';
|
|
7
|
+
|
|
8
|
+
const proxyRequest = async (
|
|
9
|
+
request: NextRequest,
|
|
10
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
11
|
+
method: string,
|
|
12
|
+
) => {
|
|
13
|
+
const { path } = await ctx.params;
|
|
14
|
+
const url = new URL(`${API_URL}/${path.join('/')}`);
|
|
15
|
+
|
|
16
|
+
request.nextUrl.searchParams.forEach((value, key) => {
|
|
17
|
+
url.searchParams.set(key, value);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const cookieStore = await cookies();
|
|
21
|
+
const accessToken = cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;
|
|
22
|
+
const locale =
|
|
23
|
+
cookieStore.get(LOCALE_COOKIE)?.value ??
|
|
24
|
+
request.headers.get('Accept-Language') ??
|
|
25
|
+
undefined;
|
|
26
|
+
|
|
27
|
+
const headers: Record<string, string> = {};
|
|
28
|
+
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
|
|
29
|
+
if (locale) headers['Accept-Language'] = locale;
|
|
30
|
+
|
|
31
|
+
let body: BodyInit | undefined;
|
|
32
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
33
|
+
const contentType = request.headers.get('Content-Type');
|
|
34
|
+
if (contentType?.includes('multipart/form-data')) {
|
|
35
|
+
body = await request.formData();
|
|
36
|
+
} else {
|
|
37
|
+
headers['Content-Type'] = 'application/json';
|
|
38
|
+
body = await request.text();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetch(url.toString(), { method, headers, body });
|
|
44
|
+
const data = await response.json();
|
|
45
|
+
return NextResponse.json(data, { status: response.status });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error(`[PROXY] ${method} ${url.toString()} —`, error);
|
|
48
|
+
return NextResponse.json(
|
|
49
|
+
{
|
|
50
|
+
result: 'ERROR',
|
|
51
|
+
data: null,
|
|
52
|
+
error: {
|
|
53
|
+
code: 'NETWORK_ERROR',
|
|
54
|
+
message: '서버에 연결할 수 없습니다.',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{ status: 502 },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const GET = (
|
|
63
|
+
req: NextRequest,
|
|
64
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
65
|
+
) => proxyRequest(req, ctx, 'GET');
|
|
66
|
+
|
|
67
|
+
export const POST = (
|
|
68
|
+
req: NextRequest,
|
|
69
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
70
|
+
) => proxyRequest(req, ctx, 'POST');
|
|
71
|
+
|
|
72
|
+
export const PUT = (
|
|
73
|
+
req: NextRequest,
|
|
74
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
75
|
+
) => proxyRequest(req, ctx, 'PUT');
|
|
76
|
+
|
|
77
|
+
export const PATCH = (
|
|
78
|
+
req: NextRequest,
|
|
79
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
80
|
+
) => proxyRequest(req, ctx, 'PATCH');
|
|
81
|
+
|
|
82
|
+
export const DELETE = (
|
|
83
|
+
req: NextRequest,
|
|
84
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
85
|
+
) => proxyRequest(req, ctx, 'DELETE');
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** 공통 API 에러 형식 */
|
|
2
|
+
export type ApiErrorBody = {
|
|
3
|
+
message: string;
|
|
4
|
+
code: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/** 백엔드 공통 응답 래퍼 */
|
|
8
|
+
export type ApiResponse<TData = unknown, TError = ApiErrorBody> = {
|
|
9
|
+
result: 'SUCCESS' | 'ERROR';
|
|
10
|
+
data: TData;
|
|
11
|
+
error: TError | null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** 페이지네이션 응답 래퍼 */
|
|
15
|
+
export type PaginatedData<T> = {
|
|
16
|
+
content: T[];
|
|
17
|
+
totalItems: number;
|
|
18
|
+
offset: number;
|
|
19
|
+
limit: number;
|
|
20
|
+
hasNext: boolean;
|
|
21
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ApiErrorBody } from './apiTypes';
|
|
2
|
+
|
|
3
|
+
export class ApiError extends Error {
|
|
4
|
+
constructor(
|
|
5
|
+
public readonly status: number,
|
|
6
|
+
public readonly code: string,
|
|
7
|
+
public readonly data: ApiErrorBody | null,
|
|
8
|
+
) {
|
|
9
|
+
super(data?.message ?? `API 요청 실패 (${status})`);
|
|
10
|
+
this.name = 'ApiError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
|
|
3
|
+
import { ApiError } from './error';
|
|
4
|
+
import type { ApiResponse } from './apiTypes';
|
|
5
|
+
|
|
6
|
+
const HTTP_TIMEOUT = 10_000;
|
|
7
|
+
|
|
8
|
+
const http = axios.create({
|
|
9
|
+
baseURL: '/api/proxy',
|
|
10
|
+
timeout: HTTP_TIMEOUT,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// 서버 컴포넌트에서 호출 시 axios 는 절대 URL 이 필요하다.
|
|
14
|
+
// 호스트만 프리픽스하고 그 외 분기는 두지 않는다.
|
|
15
|
+
http.interceptors.request.use(async (config) => {
|
|
16
|
+
if (typeof window !== 'undefined') return config;
|
|
17
|
+
|
|
18
|
+
const { headers: getHeaders } = await import('next/headers');
|
|
19
|
+
const hdrs = await getHeaders();
|
|
20
|
+
const host = hdrs.get('host') ?? 'localhost:3000';
|
|
21
|
+
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
|
22
|
+
config.baseURL = `${protocol}://${host}/api/proxy`;
|
|
23
|
+
return config;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
http.interceptors.response.use(
|
|
27
|
+
(response) => {
|
|
28
|
+
const body = response.data as ApiResponse;
|
|
29
|
+
if (body && typeof body === 'object' && 'result' in body) {
|
|
30
|
+
if (body.result === 'ERROR') {
|
|
31
|
+
return Promise.reject(
|
|
32
|
+
new ApiError(response.status, body.error?.code ?? '', body.error),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
response.data = body.data;
|
|
36
|
+
}
|
|
37
|
+
return response;
|
|
38
|
+
},
|
|
39
|
+
(error) => {
|
|
40
|
+
if (axios.isAxiosError(error)) {
|
|
41
|
+
const { response } = error;
|
|
42
|
+
const body = response?.data as ApiResponse | undefined;
|
|
43
|
+
const errorBody = body?.error ?? null;
|
|
44
|
+
return Promise.reject(
|
|
45
|
+
new ApiError(
|
|
46
|
+
response?.status ?? 0,
|
|
47
|
+
errorBody?.code ?? '',
|
|
48
|
+
errorBody,
|
|
49
|
+
),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return Promise.reject(error);
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
export { http };
|
|
@@ -69,9 +69,9 @@ pnpm dev
|
|
|
69
69
|
## sh-ui 컴포넌트 추가
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
|
-
npx sh-ui add button
|
|
73
|
-
npx sh-ui add dialog
|
|
72
|
+
npx sh-ui-cli add button
|
|
73
|
+
npx sh-ui-cli add dialog
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
`sh-ui.config.json` 의 `paths.components` 설정에 따라 `src/shared/ui/` 에 복사됩니다.
|
|
77
|
-
토큰을 커스텀하려면 `sh-ui.config.json` 의 `theme` 값을 바꾸고 `npx sh-ui add tokens` 로 재생성.
|
|
77
|
+
토큰을 커스텀하려면 `sh-ui.config.json` 의 `theme` 값을 바꾸고 `npx sh-ui-cli add tokens` 로 재생성.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { cookies } from 'next/headers';
|
|
2
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
3
|
+
|
|
4
|
+
const API_URL = process.env.API_URL ?? 'http://localhost:8080/api';
|
|
5
|
+
const ACCESS_TOKEN_COOKIE = 'accessToken';
|
|
6
|
+
const LOCALE_COOKIE = 'NEXT_LOCALE';
|
|
7
|
+
|
|
8
|
+
const proxyRequest = async (
|
|
9
|
+
request: NextRequest,
|
|
10
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
11
|
+
method: string,
|
|
12
|
+
) => {
|
|
13
|
+
const { path } = await ctx.params;
|
|
14
|
+
const url = new URL(`${API_URL}/${path.join('/')}`);
|
|
15
|
+
|
|
16
|
+
request.nextUrl.searchParams.forEach((value, key) => {
|
|
17
|
+
url.searchParams.set(key, value);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const cookieStore = await cookies();
|
|
21
|
+
const accessToken = cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;
|
|
22
|
+
const locale =
|
|
23
|
+
cookieStore.get(LOCALE_COOKIE)?.value ??
|
|
24
|
+
request.headers.get('Accept-Language') ??
|
|
25
|
+
undefined;
|
|
26
|
+
|
|
27
|
+
const headers: Record<string, string> = {};
|
|
28
|
+
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
|
|
29
|
+
if (locale) headers['Accept-Language'] = locale;
|
|
30
|
+
|
|
31
|
+
let body: BodyInit | undefined;
|
|
32
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
33
|
+
const contentType = request.headers.get('Content-Type');
|
|
34
|
+
if (contentType?.includes('multipart/form-data')) {
|
|
35
|
+
body = await request.formData();
|
|
36
|
+
} else {
|
|
37
|
+
headers['Content-Type'] = 'application/json';
|
|
38
|
+
body = await request.text();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetch(url.toString(), { method, headers, body });
|
|
44
|
+
const data = await response.json();
|
|
45
|
+
return NextResponse.json(data, { status: response.status });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error(`[PROXY] ${method} ${url.toString()} —`, error);
|
|
48
|
+
return NextResponse.json(
|
|
49
|
+
{
|
|
50
|
+
result: 'ERROR',
|
|
51
|
+
data: null,
|
|
52
|
+
error: {
|
|
53
|
+
code: 'NETWORK_ERROR',
|
|
54
|
+
message: '서버에 연결할 수 없습니다.',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{ status: 502 },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const GET = (
|
|
63
|
+
req: NextRequest,
|
|
64
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
65
|
+
) => proxyRequest(req, ctx, 'GET');
|
|
66
|
+
|
|
67
|
+
export const POST = (
|
|
68
|
+
req: NextRequest,
|
|
69
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
70
|
+
) => proxyRequest(req, ctx, 'POST');
|
|
71
|
+
|
|
72
|
+
export const PUT = (
|
|
73
|
+
req: NextRequest,
|
|
74
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
75
|
+
) => proxyRequest(req, ctx, 'PUT');
|
|
76
|
+
|
|
77
|
+
export const PATCH = (
|
|
78
|
+
req: NextRequest,
|
|
79
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
80
|
+
) => proxyRequest(req, ctx, 'PATCH');
|
|
81
|
+
|
|
82
|
+
export const DELETE = (
|
|
83
|
+
req: NextRequest,
|
|
84
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
85
|
+
) => proxyRequest(req, ctx, 'DELETE');
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** 공통 API 에러 형식 */
|
|
2
|
+
export type ApiErrorBody = {
|
|
3
|
+
message: string;
|
|
4
|
+
code: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/** 백엔드 공통 응답 래퍼 */
|
|
8
|
+
export type ApiResponse<TData = unknown, TError = ApiErrorBody> = {
|
|
9
|
+
result: 'SUCCESS' | 'ERROR';
|
|
10
|
+
data: TData;
|
|
11
|
+
error: TError | null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** 페이지네이션 응답 래퍼 */
|
|
15
|
+
export type PaginatedData<T> = {
|
|
16
|
+
content: T[];
|
|
17
|
+
totalItems: number;
|
|
18
|
+
offset: number;
|
|
19
|
+
limit: number;
|
|
20
|
+
hasNext: boolean;
|
|
21
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ApiErrorBody } from './apiTypes';
|
|
2
|
+
|
|
3
|
+
export class ApiError extends Error {
|
|
4
|
+
constructor(
|
|
5
|
+
public readonly status: number,
|
|
6
|
+
public readonly code: string,
|
|
7
|
+
public readonly data: ApiErrorBody | null,
|
|
8
|
+
) {
|
|
9
|
+
super(data?.message ?? `API 요청 실패 (${status})`);
|
|
10
|
+
this.name = 'ApiError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
|
|
3
|
+
import { ApiError } from './error';
|
|
4
|
+
import type { ApiResponse } from './apiTypes';
|
|
5
|
+
|
|
6
|
+
const HTTP_TIMEOUT = 10_000;
|
|
7
|
+
|
|
8
|
+
const http = axios.create({
|
|
9
|
+
baseURL: '/api/proxy',
|
|
10
|
+
timeout: HTTP_TIMEOUT,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// 서버 컴포넌트에서 호출 시 axios 는 절대 URL 이 필요하다.
|
|
14
|
+
// 호스트만 프리픽스하고 그 외 분기는 두지 않는다.
|
|
15
|
+
http.interceptors.request.use(async (config) => {
|
|
16
|
+
if (typeof window !== 'undefined') return config;
|
|
17
|
+
|
|
18
|
+
const { headers: getHeaders } = await import('next/headers');
|
|
19
|
+
const hdrs = await getHeaders();
|
|
20
|
+
const host = hdrs.get('host') ?? 'localhost:3000';
|
|
21
|
+
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
|
22
|
+
config.baseURL = `${protocol}://${host}/api/proxy`;
|
|
23
|
+
return config;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
http.interceptors.response.use(
|
|
27
|
+
(response) => {
|
|
28
|
+
const body = response.data as ApiResponse;
|
|
29
|
+
if (body && typeof body === 'object' && 'result' in body) {
|
|
30
|
+
if (body.result === 'ERROR') {
|
|
31
|
+
return Promise.reject(
|
|
32
|
+
new ApiError(response.status, body.error?.code ?? '', body.error),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
response.data = body.data;
|
|
36
|
+
}
|
|
37
|
+
return response;
|
|
38
|
+
},
|
|
39
|
+
(error) => {
|
|
40
|
+
if (axios.isAxiosError(error)) {
|
|
41
|
+
const { response } = error;
|
|
42
|
+
const body = response?.data as ApiResponse | undefined;
|
|
43
|
+
const errorBody = body?.error ?? null;
|
|
44
|
+
return Promise.reject(
|
|
45
|
+
new ApiError(
|
|
46
|
+
response?.status ?? 0,
|
|
47
|
+
errorBody?.code ?? '',
|
|
48
|
+
errorBody,
|
|
49
|
+
),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return Promise.reject(error);
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
export { http };
|