create-web-0to1 0.1.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.
Files changed (193) hide show
  1. package/README.md +68 -0
  2. package/internal/engine/create-feature-crud-script.ts +134 -0
  3. package/internal/engine/create-feature-crud.template.mjs +601 -0
  4. package/internal/engine/create-feature-script.ts +142 -0
  5. package/internal/engine/generator-engine.ts +546 -0
  6. package/internal/engine/standalone-feature-preset.ts +34 -0
  7. package/internal/meta/preset-plan.ts +41 -0
  8. package/internal/meta/runtime-copy-plan.ts +220 -0
  9. package/internal/meta/runtime-layout.ts +262 -0
  10. package/internal/meta/scaffold-manifest.ts +169 -0
  11. package/internal/meta/standalone-dependency-manifest.ts +75 -0
  12. package/package.json +45 -0
  13. package/scripts/create-app.mjs +1612 -0
  14. package/source/core/auth/auth-events.ts +13 -0
  15. package/source/core/error/app-error.ts +85 -0
  16. package/source/core/error/handle-app-error.client.ts +35 -0
  17. package/source/core/lib/dayjs.ts +25 -0
  18. package/source/core/query/query-client.ts +126 -0
  19. package/source/core/request/request-core.ts +210 -0
  20. package/source/core/routes/route-paths.ts +4 -0
  21. package/source/core/ui/button.tsx +24 -0
  22. package/source/core/ui/modal-store.ts +32 -0
  23. package/source/core/ui/text-input-field.tsx +36 -0
  24. package/source/core/utils/build-query-string.ts +30 -0
  25. package/source/core/utils/format/date.ts +41 -0
  26. package/source/core/utils/format/index.ts +3 -0
  27. package/source/core/utils/format/number.ts +13 -0
  28. package/source/core/utils/format/text.ts +15 -0
  29. package/source/core/utils/schema-utils.ts +27 -0
  30. package/source/wrappers/monorepo/core/internal.ts +21 -0
  31. package/source/wrappers/monorepo/core/src/index.ts +4 -0
  32. package/source/wrappers/monorepo/core-next/src/auth.client.ts +1 -0
  33. package/source/wrappers/monorepo/core-next/src/auth.server.ts +94 -0
  34. package/source/wrappers/monorepo/core-next/src/bootstrap.client.tsx +21 -0
  35. package/source/wrappers/monorepo/core-next/src/bootstrap.tsx +18 -0
  36. package/source/wrappers/monorepo/core-next/src/index.ts +1 -0
  37. package/source/wrappers/monorepo/core-react/src/app-providers.tsx +36 -0
  38. package/source/wrappers/monorepo/core-react/src/auth.ts +42 -0
  39. package/source/wrappers/monorepo/core-react/src/hydration.tsx +21 -0
  40. package/source/wrappers/monorepo/core-react/src/index.ts +7 -0
  41. package/source/wrappers/monorepo/core-react/src/provider.tsx +49 -0
  42. package/source/wrappers/monorepo/core-react/src/query-client.ts +48 -0
  43. package/source/wrappers/monorepo/core-react/src/query-error-handler.ts +62 -0
  44. package/source/wrappers/monorepo/core-react/src/query-keys.ts +22 -0
  45. package/source/wrappers/monorepo/request/core-fetch.ts +27 -0
  46. package/source/wrappers/monorepo/request/core-request.ts +93 -0
  47. package/source/wrappers/next/auth/auth-error-listener.tsx +34 -0
  48. package/source/wrappers/next/error/handle-app-error.server.ts +41 -0
  49. package/source/wrappers/next/query/hydration.tsx +20 -0
  50. package/source/wrappers/next/query/providers.tsx +35 -0
  51. package/source/wrappers/next/request/request.client.ts +24 -0
  52. package/source/wrappers/next/request/request.server.ts +64 -0
  53. package/source/wrappers/next/request/request.ts +52 -0
  54. package/source/wrappers/next/ui/global-modal.tsx +29 -0
  55. package/source/wrappers/react/auth/auth-error-listener.tsx +34 -0
  56. package/source/wrappers/react/query/providers.tsx +31 -0
  57. package/source/wrappers/react/request/request.client.ts +24 -0
  58. package/source/wrappers/react/request/request.ts +51 -0
  59. package/source/wrappers/react/ui/global-modal.tsx +27 -0
  60. package/templates/monorepo/.dockerignore +38 -0
  61. package/templates/monorepo/README.md +292 -0
  62. package/templates/monorepo/_gitignore +38 -0
  63. package/templates/monorepo/_npmrc +1 -0
  64. package/templates/monorepo/apps/project/Dockerfile +32 -0
  65. package/templates/monorepo/apps/project/eslint.config.mjs +4 -0
  66. package/templates/monorepo/apps/project/index.html +14 -0
  67. package/templates/monorepo/apps/project/index.ts +15 -0
  68. package/templates/monorepo/apps/project/package.json +21 -0
  69. package/templates/monorepo/apps/project/tsconfig.json +9 -0
  70. package/templates/monorepo/apps/project/vite.config.ts +6 -0
  71. package/templates/monorepo/apps/web/Dockerfile +43 -0
  72. package/templates/monorepo/apps/web/README.md +111 -0
  73. package/templates/monorepo/apps/web/_gitignore +36 -0
  74. package/templates/monorepo/apps/web/app/favicon.ico +0 -0
  75. package/templates/monorepo/apps/web/app/global-error.tsx +12 -0
  76. package/templates/monorepo/apps/web/app/globals.css +0 -0
  77. package/templates/monorepo/apps/web/app/layout.tsx +28 -0
  78. package/templates/monorepo/apps/web/app/page.tsx +7 -0
  79. package/templates/monorepo/apps/web/app/providers.tsx +25 -0
  80. package/templates/monorepo/apps/web/eslint.config.js +4 -0
  81. package/templates/monorepo/apps/web/next-env.d.ts +6 -0
  82. package/templates/monorepo/apps/web/next.config.js +4 -0
  83. package/templates/monorepo/apps/web/package.json +31 -0
  84. package/templates/monorepo/apps/web/public/file-text.svg +3 -0
  85. package/templates/monorepo/apps/web/public/globe.svg +10 -0
  86. package/templates/monorepo/apps/web/public/next.svg +1 -0
  87. package/templates/monorepo/apps/web/public/turborepo-dark.svg +19 -0
  88. package/templates/monorepo/apps/web/public/turborepo-light.svg +19 -0
  89. package/templates/monorepo/apps/web/public/vercel.svg +10 -0
  90. package/templates/monorepo/apps/web/public/window.svg +3 -0
  91. package/templates/monorepo/apps/web/tsconfig.json +20 -0
  92. package/templates/monorepo/package.json +24 -0
  93. package/templates/monorepo/packages/core/eslint.config.mjs +4 -0
  94. package/templates/monorepo/packages/core/package.json +32 -0
  95. package/templates/monorepo/packages/core/tsconfig.json +8 -0
  96. package/templates/monorepo/packages/core-next/eslint.config.mjs +13 -0
  97. package/templates/monorepo/packages/core-next/package.json +43 -0
  98. package/templates/monorepo/packages/core-next/tsconfig.json +8 -0
  99. package/templates/monorepo/packages/core-react/eslint.config.mjs +4 -0
  100. package/templates/monorepo/packages/core-react/package.json +34 -0
  101. package/templates/monorepo/packages/core-react/tsconfig.json +8 -0
  102. package/templates/monorepo/packages/eslint-config/README.md +3 -0
  103. package/templates/monorepo/packages/eslint-config/base.js +57 -0
  104. package/templates/monorepo/packages/eslint-config/next.js +22 -0
  105. package/templates/monorepo/packages/eslint-config/package.json +25 -0
  106. package/templates/monorepo/packages/eslint-config/react-internal.js +33 -0
  107. package/templates/monorepo/packages/typescript-config/base.json +19 -0
  108. package/templates/monorepo/packages/typescript-config/nextjs.json +12 -0
  109. package/templates/monorepo/packages/typescript-config/package.json +9 -0
  110. package/templates/monorepo/packages/typescript-config/react-library.json +7 -0
  111. package/templates/monorepo/packages/ui/eslint.config.mjs +4 -0
  112. package/templates/monorepo/packages/ui/package.json +26 -0
  113. package/templates/monorepo/packages/ui/src/button.tsx +20 -0
  114. package/templates/monorepo/packages/ui/src/card.tsx +27 -0
  115. package/templates/monorepo/packages/ui/src/code.tsx +11 -0
  116. package/templates/monorepo/packages/ui/tsconfig.json +8 -0
  117. package/templates/monorepo/pnpm-workspace.yaml +9 -0
  118. package/templates/monorepo/turbo/generators/config.js +1336 -0
  119. package/templates/monorepo/turbo/generators/templates/next-app/Dockerfile.tpl +30 -0
  120. package/templates/monorepo/turbo/generators/templates/next-app/README.md.tpl +118 -0
  121. package/templates/monorepo/turbo/generators/templates/next-app/app/global-error.tsx.tpl +12 -0
  122. package/templates/monorepo/turbo/generators/templates/next-app/app/globals.css.tpl +1 -0
  123. package/templates/monorepo/turbo/generators/templates/next-app/app/layout.tsx.tpl +29 -0
  124. package/templates/monorepo/turbo/generators/templates/next-app/app/page.tsx.tpl +7 -0
  125. package/templates/monorepo/turbo/generators/templates/next-app/app/providers.tsx.tpl +25 -0
  126. package/templates/monorepo/turbo/generators/templates/next-app/eslint.config.js.tpl +4 -0
  127. package/templates/monorepo/turbo/generators/templates/next-app/next.config.js.tpl +6 -0
  128. package/templates/monorepo/turbo/generators/templates/next-app/tsconfig.json.tpl +18 -0
  129. package/templates/monorepo/turbo/generators/templates/vite-app/Dockerfile.tpl +22 -0
  130. package/templates/monorepo/turbo/generators/templates/vite-app/README.plain.md.tpl +90 -0
  131. package/templates/monorepo/turbo/generators/templates/vite-app/README.react.md.tpl +107 -0
  132. package/templates/monorepo/turbo/generators/templates/vite-app/eslint.config.mjs.tpl +4 -0
  133. package/templates/monorepo/turbo/generators/templates/vite-app/index.html.tpl +12 -0
  134. package/templates/monorepo/turbo/generators/templates/vite-app/index.ts.tpl +22 -0
  135. package/templates/monorepo/turbo/generators/templates/vite-app/tsconfig.json.tpl +9 -0
  136. package/templates/monorepo/turbo/generators/templates/vite-app/vite.config.ts.tpl +6 -0
  137. package/templates/monorepo/turbo.json +28 -0
  138. package/templates/next/.env.example +2 -0
  139. package/templates/next/.prettierignore +9 -0
  140. package/templates/next/.prettierrc.json +9 -0
  141. package/templates/next/README.md +246 -0
  142. package/templates/next/_gitignore +44 -0
  143. package/templates/next/eslint.config.mjs +51 -0
  144. package/templates/next/next.config.ts +7 -0
  145. package/templates/next/package.json +24 -0
  146. package/templates/next/postcss.config.mjs +7 -0
  147. package/templates/next/scripts/create-feature-crud.mjs +5 -0
  148. package/templates/next/scripts/create-feature.mjs +5 -0
  149. package/templates/next/src/app/error.tsx +33 -0
  150. package/templates/next/src/app/globals.css +35 -0
  151. package/templates/next/src/app/layout.tsx +39 -0
  152. package/templates/next/src/app/login/page.tsx +17 -0
  153. package/templates/next/src/app/page.tsx +32 -0
  154. package/templates/next/src/app/providers.tsx +20 -0
  155. package/templates/next/tsconfig.json +34 -0
  156. package/templates/react/.env.example +1 -0
  157. package/templates/react/.prettierignore +10 -0
  158. package/templates/react/.prettierrc.json +9 -0
  159. package/templates/react/README.md +250 -0
  160. package/templates/react/_gitignore +31 -0
  161. package/templates/react/eslint.config.mjs +64 -0
  162. package/templates/react/package.json +19 -0
  163. package/templates/react/scripts/create-feature-crud.mjs +5 -0
  164. package/templates/react/scripts/create-feature.mjs +5 -0
  165. package/templates/react/src/app/app.tsx +15 -0
  166. package/templates/react/src/app/error-boundary.tsx +59 -0
  167. package/templates/react/src/app/frame.tsx +32 -0
  168. package/templates/react/src/app/globals.css +43 -0
  169. package/templates/react/src/app/not-found-page.tsx +23 -0
  170. package/templates/react/src/app/providers.tsx +16 -0
  171. package/templates/react/src/app/router.tsx +62 -0
  172. package/templates/react/src/main.tsx +12 -0
  173. package/templates/react/src/pages/index/page.tsx +36 -0
  174. package/templates/react/src/pages/login/page.tsx +18 -0
  175. package/templates/react/tsconfig.app.json +30 -0
  176. package/templates/react/tsconfig.json +4 -0
  177. package/templates/react/tsconfig.node.json +24 -0
  178. package/templates/react/vite.config.ts +14 -0
  179. package/templates/shared/docs//352/260/234/353/260/234/354/233/220/354/271/231/00-/354/240/204/353/260/230/354/240/201/354/235/270-/355/217/264/353/215/224/352/265/254/354/241/260.md +150 -0
  180. package/templates/shared/docs//352/260/234/353/260/234/354/233/220/354/271/231/01-/352/265/254/354/241/260/354/231/200-/353/235/274/354/232/260/355/214/205.md +186 -0
  181. package/templates/shared/docs//352/260/234/353/260/234/354/233/220/354/271/231/02-/354/204/234/353/262/204/354/231/200-/355/201/264/353/235/274/354/235/264/354/226/270/355/212/270.md +86 -0
  182. package/templates/shared/docs//352/260/234/353/260/234/354/233/220/354/271/231/03-/354/203/201/355/203/234/352/264/200/353/246/254.md +84 -0
  183. package/templates/shared/docs//352/260/234/353/260/234/354/233/220/354/271/231/04-API/354/231/200-/353/215/260/354/235/264/355/204/260.md +199 -0
  184. package/templates/shared/docs//352/260/234/353/260/234/354/233/220/354/271/231/05-/354/227/220/353/237/254/354/231/200-UI-/354/203/201/355/203/234.md +159 -0
  185. package/templates/shared/docs//352/260/234/353/260/234/354/233/220/354/271/231/06-/355/217/274.md +116 -0
  186. package/templates/shared/docs//352/260/234/353/260/234/354/233/220/354/271/231/07-/354/212/244/355/203/200/354/235/274/353/247/201/352/263/274-/354/240/221/352/267/274/354/204/261.md +73 -0
  187. package/templates/shared/docs//352/260/234/353/260/234/354/233/220/354/271/231/08-/353/204/244/354/235/264/353/260/215-/354/204/244/354/240/225-/355/217/254/353/247/267/355/214/205.md +98 -0
  188. package/templates/shared/docs//352/260/234/353/260/234/354/233/220/354/271/231/09-/353/235/274/354/232/260/355/212/270-/354/240/225/354/235/230.md +169 -0
  189. package/templates/shared/docs//352/260/234/353/260/234/354/233/220/354/271/231/10-/354/273/244/353/260/213-/354/273/250/353/262/244/354/205/230.md +64 -0
  190. package/templates/shared/docs//352/260/234/353/260/234/354/233/220/354/271/231/11-/352/270/260/355/203/200-/354/233/220/354/271/231.md +187 -0
  191. package/templates/shared/docs//352/260/234/353/260/234/354/233/220/354/271/231/12-/354/213/244/353/254/264-/353/215/260/354/235/264/355/204/260-/355/214/250/355/204/264.md +302 -0
  192. package/templates/shared/docs//352/260/234/353/260/234/354/233/220/354/271/231/13-/354/204/261/353/212/245-/354/233/220/354/271/231.md +175 -0
  193. package/templates/shared/docs//352/260/234/353/260/234/354/233/220/354/271/231/README.md +39 -0
@@ -0,0 +1,13 @@
1
+ export const AUTH_UNAUTHORIZED_EVENT = 'app:auth-unauthorized';
2
+
3
+ /**
4
+ * 클라이언트 전역에서 인증 만료를 감지했을 때
5
+ * 로그인 이동 같은 후속 처리를 트리거하는 공통 이벤트입니다.
6
+ */
7
+ export function dispatchUnauthorizedEvent() {
8
+ if (typeof window === 'undefined') {
9
+ return;
10
+ }
11
+
12
+ window.dispatchEvent(new Event(AUTH_UNAUTHORIZED_EVENT));
13
+ }
@@ -0,0 +1,85 @@
1
+ export const APP_ERROR_CODES = {
2
+ NETWORK_ERROR: 'NETWORK_ERROR',
3
+ UNAUTHORIZED: 'UNAUTHORIZED',
4
+ FORBIDDEN: 'FORBIDDEN',
5
+ NOT_FOUND: 'NOT_FOUND',
6
+ INVALID_RESPONSE: 'INVALID_RESPONSE',
7
+ REQUEST_ABORTED: 'REQUEST_ABORTED',
8
+ INVALID_CONFIGURATION: 'INVALID_CONFIGURATION',
9
+ UNKNOWN_ERROR: 'UNKNOWN_ERROR',
10
+ } as const;
11
+
12
+ export type AppErrorCode = (typeof APP_ERROR_CODES)[keyof typeof APP_ERROR_CODES];
13
+
14
+ export type AppError = {
15
+ code: AppErrorCode;
16
+ message: string;
17
+ status?: number;
18
+ };
19
+
20
+ export const APP_ERROR_MESSAGES: Record<AppErrorCode, string> = {
21
+ [APP_ERROR_CODES.NETWORK_ERROR]: '네트워크 오류가 발생했습니다.',
22
+ [APP_ERROR_CODES.UNAUTHORIZED]: '로그인이 필요합니다.',
23
+ [APP_ERROR_CODES.FORBIDDEN]: '접근 권한이 없습니다.',
24
+ [APP_ERROR_CODES.NOT_FOUND]: '요청한 리소스를 찾을 수 없습니다.',
25
+ [APP_ERROR_CODES.INVALID_RESPONSE]: '응답 형식이 올바르지 않습니다.',
26
+ [APP_ERROR_CODES.REQUEST_ABORTED]: '요청이 취소되었습니다.',
27
+ [APP_ERROR_CODES.INVALID_CONFIGURATION]: 'core 설정이 초기화되지 않았습니다.',
28
+ [APP_ERROR_CODES.UNKNOWN_ERROR]: '알 수 없는 오류가 발생했습니다.',
29
+ };
30
+
31
+ export class ApiError extends Error implements AppError {
32
+ code: AppErrorCode;
33
+ status?: number;
34
+
35
+ constructor(
36
+ message: string,
37
+ status?: number,
38
+ code: AppErrorCode = APP_ERROR_CODES.UNKNOWN_ERROR,
39
+ ) {
40
+ super(message);
41
+ this.name = 'ApiError';
42
+ this.code = code;
43
+ this.status = status;
44
+ }
45
+ }
46
+
47
+ export function isAppError(value: unknown): value is AppError {
48
+ if (!value || typeof value !== 'object') {
49
+ return false;
50
+ }
51
+
52
+ return 'code' in value && 'message' in value;
53
+ }
54
+
55
+ export function getAppErrorMessage(code: AppErrorCode) {
56
+ return APP_ERROR_MESSAGES[code];
57
+ }
58
+
59
+ /**
60
+ * fetch가 던지는 브라우저/런타임 오류를 템플릿 공통 AppError로 맞춥니다.
61
+ */
62
+ export function normalizeAppError(error: unknown): AppError {
63
+ if (isAppError(error)) {
64
+ return error;
65
+ }
66
+
67
+ if (error instanceof DOMException && error.name === 'AbortError') {
68
+ return {
69
+ code: APP_ERROR_CODES.REQUEST_ABORTED,
70
+ message: getAppErrorMessage(APP_ERROR_CODES.REQUEST_ABORTED),
71
+ };
72
+ }
73
+
74
+ if (error instanceof TypeError) {
75
+ return {
76
+ code: APP_ERROR_CODES.NETWORK_ERROR,
77
+ message: getAppErrorMessage(APP_ERROR_CODES.NETWORK_ERROR),
78
+ };
79
+ }
80
+
81
+ return {
82
+ code: APP_ERROR_CODES.UNKNOWN_ERROR,
83
+ message: getAppErrorMessage(APP_ERROR_CODES.UNKNOWN_ERROR),
84
+ };
85
+ }
@@ -0,0 +1,35 @@
1
+ import { dispatchUnauthorizedEvent } from '../auth/auth-events';
2
+ import { APP_ERROR_CODES, normalizeAppError } from './app-error';
3
+ import { useModalStore } from '../ui/modal-store';
4
+
5
+ /**
6
+ * AppProviders에서 QueryProviders의 onGlobalError로 연결할 때 사용할 수 있는
7
+ * 기본 클라이언트 에러 정책 예시입니다.
8
+ */
9
+ export function handleClientAppError(error: unknown) {
10
+ const appError = normalizeAppError(error);
11
+
12
+ if (appError.code === APP_ERROR_CODES.REQUEST_ABORTED) {
13
+ return;
14
+ }
15
+
16
+ if (appError.code === APP_ERROR_CODES.UNAUTHORIZED) {
17
+ dispatchUnauthorizedEvent();
18
+ return;
19
+ }
20
+
21
+ if (appError.code === APP_ERROR_CODES.NETWORK_ERROR) {
22
+ useModalStore.getState().openModal({
23
+ title: '네트워크 오류',
24
+ description: '잠시 후 다시 시도해주세요.',
25
+ });
26
+ return;
27
+ }
28
+
29
+ if (appError.code === APP_ERROR_CODES.FORBIDDEN) {
30
+ useModalStore.getState().openModal({
31
+ title: '접근 권한 없음',
32
+ description: '이 작업을 수행할 권한이 없습니다.',
33
+ });
34
+ }
35
+ }
@@ -0,0 +1,25 @@
1
+ import 'dayjs/locale/ko';
2
+
3
+ import dayjsBase, { type ConfigType } from 'dayjs';
4
+ import customParseFormat from 'dayjs/plugin/customParseFormat';
5
+ import timezone from 'dayjs/plugin/timezone';
6
+ import utc from 'dayjs/plugin/utc';
7
+
8
+ dayjsBase.extend(utc);
9
+ dayjsBase.extend(timezone);
10
+ dayjsBase.extend(customParseFormat);
11
+ dayjsBase.locale('ko');
12
+
13
+ export const APP_TIME_ZONE = 'Asia/Seoul';
14
+
15
+ dayjsBase.tz.setDefault(APP_TIME_ZONE);
16
+
17
+ export const dayjs = dayjsBase;
18
+
19
+ export function appDayjs(value?: ConfigType) {
20
+ if (value === undefined) {
21
+ return dayjs.tz();
22
+ }
23
+
24
+ return dayjs.tz(value);
25
+ }
@@ -0,0 +1,126 @@
1
+ import {
2
+ type DefaultOptions,
3
+ MutationCache,
4
+ QueryCache,
5
+ QueryClient,
6
+ } from '@tanstack/react-query';
7
+
8
+ const baseDefaultOptions: DefaultOptions = {
9
+ queries: {
10
+ staleTime: 30_000,
11
+ retry: 1,
12
+ },
13
+ };
14
+
15
+ export type SharedQueryClientOptions = {
16
+ onGlobalError?: (error: unknown) => void;
17
+ defaultOptions?: DefaultOptions;
18
+ };
19
+
20
+ export type StandaloneQueryClientOptions = Pick<
21
+ SharedQueryClientOptions,
22
+ 'onGlobalError'
23
+ >;
24
+
25
+ export type MakeQueryClientOptions = StandaloneQueryClientOptions;
26
+
27
+ type SharedQueryClientRuntimeOptions = {
28
+ /**
29
+ * React / Next standalone 템플릿은 서버에서 매번 기본 QueryClient를 새로 만들고,
30
+ * 서버 호출 시 전달된 옵션은 무시하는 기존 동작을 유지합니다.
31
+ */
32
+ useServerOptions?: boolean;
33
+ };
34
+
35
+ /**
36
+ * 공통 QueryClient 기본 옵션과 타깃별 override를 한 번만 합칩니다.
37
+ */
38
+ export function mergeQueryClientDefaultOptions(
39
+ baseOptions: DefaultOptions,
40
+ overrideOptions?: DefaultOptions,
41
+ ): DefaultOptions {
42
+ return {
43
+ ...baseOptions,
44
+ ...overrideOptions,
45
+ queries: {
46
+ ...baseOptions.queries,
47
+ ...overrideOptions?.queries,
48
+ },
49
+ mutations: {
50
+ ...baseOptions.mutations,
51
+ ...overrideOptions?.mutations,
52
+ },
53
+ };
54
+ }
55
+
56
+ export function makeSharedQueryClient({
57
+ onGlobalError,
58
+ defaultOptions,
59
+ }: SharedQueryClientOptions = {}) {
60
+ return new QueryClient({
61
+ queryCache: new QueryCache({
62
+ onError: onGlobalError,
63
+ }),
64
+ mutationCache: new MutationCache({
65
+ onError: onGlobalError,
66
+ }),
67
+ defaultOptions: mergeQueryClientDefaultOptions(
68
+ baseDefaultOptions,
69
+ defaultOptions,
70
+ ),
71
+ });
72
+ }
73
+
74
+ let browserQueryClient: QueryClient | undefined;
75
+
76
+ function isServerEnvironment() {
77
+ return typeof window === 'undefined';
78
+ }
79
+
80
+ /**
81
+ * runtime 분기는 core 한 곳에서만 처리합니다.
82
+ * 브라우저에서는 싱글톤을 재사용하고, 서버에서는 요청마다 새 client를 만듭니다.
83
+ */
84
+ export function getSharedQueryClient(
85
+ options: SharedQueryClientOptions = {},
86
+ runtimeOptions: SharedQueryClientRuntimeOptions = {},
87
+ ) {
88
+ const { useServerOptions = true } = runtimeOptions;
89
+
90
+ if (isServerEnvironment()) {
91
+ return makeSharedQueryClient(useServerOptions ? options : {});
92
+ }
93
+
94
+ if (!browserQueryClient) {
95
+ browserQueryClient = makeSharedQueryClient(options);
96
+ }
97
+
98
+ return browserQueryClient;
99
+ }
100
+
101
+ export function createSharedQueryClient(options: SharedQueryClientOptions = {}) {
102
+ return makeSharedQueryClient(options);
103
+ }
104
+
105
+ export function makeStandaloneQueryClient({
106
+ onGlobalError,
107
+ }: StandaloneQueryClientOptions = {}) {
108
+ return makeSharedQueryClient({
109
+ onGlobalError,
110
+ });
111
+ }
112
+
113
+ /**
114
+ * standalone 템플릿의 기존 계약을 유지합니다.
115
+ * 서버에서는 옵션 없는 새 client를 만들고, 브라우저에서는 singleton을 재사용합니다.
116
+ */
117
+ export function getStandaloneQueryClient(
118
+ options: StandaloneQueryClientOptions = {},
119
+ ) {
120
+ return getSharedQueryClient(options, {
121
+ useServerOptions: false,
122
+ });
123
+ }
124
+
125
+ export const makeQueryClient = makeStandaloneQueryClient;
126
+ export const getQueryClient = getStandaloneQueryClient;
@@ -0,0 +1,210 @@
1
+ import {
2
+ APP_ERROR_CODES,
3
+ type AppError,
4
+ getAppErrorMessage,
5
+ normalizeAppError,
6
+ } from '../error/app-error';
7
+ import { buildQueryString } from '../utils/build-query-string';
8
+
9
+ type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
10
+ type Primitive = string | number | boolean | null | undefined;
11
+ type ErrorResponse = {
12
+ code?: AppError['code'];
13
+ message?: string;
14
+ };
15
+ type NextFetchOptions = {
16
+ revalidate?: number | false;
17
+ tags?: string[];
18
+ };
19
+ type RequestCoreInit = RequestInit & {
20
+ next?: NextFetchOptions;
21
+ };
22
+
23
+ export type RequestParamValue = Primitive | Primitive[];
24
+
25
+ export type RequestParams = Record<string, RequestParamValue>;
26
+
27
+ export type JsonValue = Primitive | JsonObject | JsonValue[];
28
+
29
+ export type JsonObject = {
30
+ [key: string]: JsonValue;
31
+ };
32
+
33
+ export type RequestData = JsonObject | FormData | null;
34
+
35
+ export type RequestOptions = Omit<RequestCoreInit, 'body' | 'headers' | 'method'> & {
36
+ method: HttpMethod;
37
+ url: string;
38
+ params?: RequestParams;
39
+ data?: RequestData;
40
+ headers?: HeadersInit;
41
+ baseUrl?: string;
42
+ };
43
+
44
+ type RequestRuntimeOptions<TRequestOptions extends RequestOptions = RequestOptions> = {
45
+ resolveBaseUrl?: (
46
+ baseUrl: string | undefined,
47
+ options: TRequestOptions,
48
+ ) => string | undefined;
49
+ createHeaders?: (args: {
50
+ data: RequestData | undefined;
51
+ headers: HeadersInit | undefined;
52
+ options: TRequestOptions;
53
+ requestUrl: string;
54
+ resolvedBaseUrl: string | undefined;
55
+ }) => HeadersInit | undefined;
56
+ resolveCredentials?: (args: {
57
+ options: TRequestOptions;
58
+ requestUrl: string;
59
+ resolvedBaseUrl: string | undefined;
60
+ }) => RequestCredentials | undefined;
61
+ };
62
+
63
+ function isAbsoluteUrl(url: string) {
64
+ return url.startsWith('http://') || url.startsWith('https://');
65
+ }
66
+
67
+ export function isFormData(value: RequestData | undefined): value is FormData {
68
+ return typeof FormData !== 'undefined' && value instanceof FormData;
69
+ }
70
+
71
+ function getAppErrorCodeFromStatus(status: number): AppError['code'] {
72
+ switch (status) {
73
+ case 401:
74
+ return APP_ERROR_CODES.UNAUTHORIZED;
75
+ case 403:
76
+ return APP_ERROR_CODES.FORBIDDEN;
77
+ case 404:
78
+ return APP_ERROR_CODES.NOT_FOUND;
79
+ default:
80
+ return APP_ERROR_CODES.UNKNOWN_ERROR;
81
+ }
82
+ }
83
+
84
+ export function buildRequestUrl(
85
+ url: string,
86
+ params?: RequestParams,
87
+ baseUrl?: string,
88
+ ) {
89
+ const query = params ? buildQueryString(params) : '';
90
+
91
+ if (isAbsoluteUrl(url)) {
92
+ return `${url}${query}`;
93
+ }
94
+
95
+ if (!baseUrl) {
96
+ throw new Error('상대 경로 요청에는 API 기본 URL이 필요합니다.');
97
+ }
98
+
99
+ return `${baseUrl}${url}${query}`;
100
+ }
101
+
102
+ export function createRequestBody(
103
+ method: RequestOptions['method'],
104
+ data: RequestData | undefined,
105
+ ) {
106
+ if (method === 'GET' || method === 'DELETE' || data === undefined) {
107
+ return undefined;
108
+ }
109
+
110
+ if (isFormData(data)) {
111
+ return data;
112
+ }
113
+
114
+ return JSON.stringify(data);
115
+ }
116
+
117
+ /**
118
+ * JSON 요청은 기본 Content-Type만 보장하고,
119
+ * FormData는 브라우저가 boundary를 직접 붙이도록 그대로 둡니다.
120
+ */
121
+ export function createRequestHeaders(
122
+ data: RequestData | undefined,
123
+ headers?: HeadersInit,
124
+ ) {
125
+ const nextHeaders = new Headers(headers);
126
+
127
+ if (!isFormData(data) && !nextHeaders.has('Content-Type')) {
128
+ nextHeaders.set('Content-Type', 'application/json');
129
+ }
130
+
131
+ return nextHeaders;
132
+ }
133
+
134
+ async function parseResponse<T>(response: Response): Promise<T | ErrorResponse | null> {
135
+ if (response.status === 204 || response.status === 205) {
136
+ return null;
137
+ }
138
+
139
+ try {
140
+ return (await response.json()) as T | ErrorResponse;
141
+ } catch {
142
+ throw {
143
+ code: APP_ERROR_CODES.INVALID_RESPONSE,
144
+ message: getAppErrorMessage(APP_ERROR_CODES.INVALID_RESPONSE),
145
+ status: response.status,
146
+ } satisfies AppError;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * 요청 직렬화, 응답 파싱, 공통 오류 처리는 core에서 한 번만 담당합니다.
152
+ * wrapper는 baseUrl, 헤더, 인증 정책처럼 타깃별 차이만 주입합니다.
153
+ */
154
+ export async function executeRequest<
155
+ T,
156
+ TRequestOptions extends RequestOptions = RequestOptions,
157
+ >(
158
+ options: TRequestOptions,
159
+ runtimeOptions: RequestRuntimeOptions<TRequestOptions> = {},
160
+ ): Promise<T> {
161
+ const { method, url, params, data, headers, baseUrl, ...init } = options;
162
+ const resolvedBaseUrl = runtimeOptions.resolveBaseUrl?.(baseUrl, options) ?? baseUrl;
163
+ const requestUrl = buildRequestUrl(url, params, resolvedBaseUrl);
164
+ const resolvedCredentials =
165
+ init.credentials ??
166
+ runtimeOptions.resolveCredentials?.({
167
+ options,
168
+ requestUrl,
169
+ resolvedBaseUrl,
170
+ });
171
+ const resolvedHeaders =
172
+ runtimeOptions.createHeaders?.({
173
+ data,
174
+ headers,
175
+ options,
176
+ requestUrl,
177
+ resolvedBaseUrl,
178
+ }) ?? createRequestHeaders(data, headers);
179
+
180
+ const requestInit: RequestCoreInit = {
181
+ ...init,
182
+ method,
183
+ credentials: resolvedCredentials,
184
+ headers: resolvedHeaders,
185
+ body: createRequestBody(method, data),
186
+ };
187
+
188
+ let response: Response;
189
+
190
+ try {
191
+ response = await fetch(requestUrl, requestInit);
192
+ } catch (error) {
193
+ throw normalizeAppError(error);
194
+ }
195
+
196
+ const payload = await parseResponse<T>(response);
197
+
198
+ if (!response.ok) {
199
+ const code =
200
+ (payload as ErrorResponse | null)?.code ?? getAppErrorCodeFromStatus(response.status);
201
+
202
+ throw {
203
+ code,
204
+ message: (payload as ErrorResponse | null)?.message ?? getAppErrorMessage(code),
205
+ status: response.status,
206
+ } satisfies AppError;
207
+ }
208
+
209
+ return payload as T;
210
+ }
@@ -0,0 +1,4 @@
1
+ export const routePaths = {
2
+ home: '/',
3
+ login: '/login',
4
+ } as const;
@@ -0,0 +1,24 @@
1
+ import type { ButtonHTMLAttributes, ReactNode } from 'react';
2
+
3
+ export type ButtonProps = {
4
+ children: ReactNode;
5
+ isLoading?: boolean;
6
+ } & ButtonHTMLAttributes<HTMLButtonElement>;
7
+
8
+ export function Button({
9
+ children,
10
+ disabled,
11
+ isLoading = false,
12
+ className = '',
13
+ ...props
14
+ }: ButtonProps) {
15
+ return (
16
+ <button
17
+ disabled={disabled || isLoading}
18
+ className={`inline-flex items-center justify-center rounded-xl bg-zinc-950 px-4 py-2 text-sm font-medium text-white transition hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-zinc-400 ${className}`}
19
+ {...props}
20
+ >
21
+ {isLoading ? '저장 중...' : children}
22
+ </button>
23
+ );
24
+ }
@@ -0,0 +1,32 @@
1
+ import { create } from 'zustand';
2
+
3
+ type ModalPayload = {
4
+ title: string;
5
+ description?: string;
6
+ };
7
+
8
+ type ModalState = {
9
+ isOpen: boolean;
10
+ title: string;
11
+ description?: string;
12
+ openModal: (payload: ModalPayload) => void;
13
+ closeModal: () => void;
14
+ };
15
+
16
+ export const useModalStore = create<ModalState>((set) => ({
17
+ isOpen: false,
18
+ title: '',
19
+ description: '',
20
+ openModal: ({ title, description }) =>
21
+ set({
22
+ isOpen: true,
23
+ title,
24
+ description,
25
+ }),
26
+ closeModal: () =>
27
+ set({
28
+ isOpen: false,
29
+ title: '',
30
+ description: '',
31
+ }),
32
+ }));
@@ -0,0 +1,36 @@
1
+ import type { InputHTMLAttributes } from 'react';
2
+
3
+ export type TextInputFieldProps = {
4
+ label: string;
5
+ required?: boolean;
6
+ error?: string;
7
+ helperText?: string;
8
+ } & InputHTMLAttributes<HTMLInputElement>;
9
+
10
+ export function TextInputField({
11
+ label,
12
+ required,
13
+ error,
14
+ helperText,
15
+ id,
16
+ className = '',
17
+ ...props
18
+ }: TextInputFieldProps) {
19
+ const inputId = id ?? props.name;
20
+
21
+ return (
22
+ <div className="flex flex-col gap-2">
23
+ <label htmlFor={inputId} className="text-sm font-semibold text-zinc-900">
24
+ {label}
25
+ {required ? <span className="ml-1 text-rose-600">*</span> : null}
26
+ </label>
27
+ <input
28
+ id={inputId}
29
+ className={`rounded-xl border px-4 py-3 text-sm text-zinc-950 outline-none ring-0 transition placeholder:text-zinc-400 focus:border-zinc-950 ${error ? 'border-rose-500' : 'border-zinc-300'} ${className}`}
30
+ {...props}
31
+ />
32
+ {error ? <p className="text-sm text-rose-600">{error}</p> : null}
33
+ {!error && helperText ? <p className="text-sm text-zinc-500">{helperText}</p> : null}
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,30 @@
1
+ type Primitive = string | number | boolean | null | undefined;
2
+ type QueryValue = Primitive | Primitive[];
3
+
4
+ export function buildQueryString(params: Record<string, QueryValue>) {
5
+ const searchParams = new URLSearchParams();
6
+
7
+ Object.entries(params).forEach(([key, value]) => {
8
+ if (Array.isArray(value)) {
9
+ value.forEach((item) => {
10
+ if (item === null || item === undefined || item === '') {
11
+ return;
12
+ }
13
+
14
+ searchParams.append(key, String(item));
15
+ });
16
+
17
+ return;
18
+ }
19
+
20
+ if (value === null || value === undefined || value === '') {
21
+ return;
22
+ }
23
+
24
+ searchParams.set(key, String(value));
25
+ });
26
+
27
+ const queryString = searchParams.toString();
28
+
29
+ return queryString ? `?${queryString}` : '';
30
+ }
@@ -0,0 +1,41 @@
1
+ import type { ConfigType } from 'dayjs';
2
+
3
+ import { appDayjs } from '../../lib/dayjs';
4
+
5
+ export type DateValue = ConfigType | null | undefined;
6
+
7
+ const EMPTY_FORMAT_VALUE = '-';
8
+
9
+ function formatDateValue(value: DateValue, pattern: string, fallback = EMPTY_FORMAT_VALUE) {
10
+ if (value === null || value === undefined) {
11
+ return fallback;
12
+ }
13
+
14
+ const date = appDayjs(value);
15
+
16
+ if (!date.isValid()) {
17
+ return fallback;
18
+ }
19
+
20
+ return date.format(pattern);
21
+ }
22
+
23
+ export function formatDate(value: DateValue) {
24
+ return formatDateValue(value, 'YYYY-MM-DD');
25
+ }
26
+
27
+ export function formatDateTime(value: DateValue) {
28
+ return formatDateValue(value, 'YYYY-MM-DD HH:mm');
29
+ }
30
+
31
+ export function formatDateTimeSeconds(value: DateValue) {
32
+ return formatDateValue(value, 'YYYY-MM-DD HH:mm:ss');
33
+ }
34
+
35
+ export function formatMonthDay(value: DateValue) {
36
+ return formatDateValue(value, 'MM-DD');
37
+ }
38
+
39
+ export function toDateKey(value: DateValue = new Date()) {
40
+ return formatDateValue(value, 'YYYYMMDD', '');
41
+ }
@@ -0,0 +1,3 @@
1
+ export * from './date';
2
+ export * from './number';
3
+ export * from './text';
@@ -0,0 +1,13 @@
1
+ const numberFormatter = new Intl.NumberFormat('ko-KR');
2
+ const compactNumberFormatter = new Intl.NumberFormat('ko-KR', {
3
+ notation: 'compact',
4
+ maximumFractionDigits: 1,
5
+ });
6
+
7
+ export function formatNumber(value: number) {
8
+ return numberFormatter.format(value);
9
+ }
10
+
11
+ export function formatCompactNumber(value: number) {
12
+ return compactNumberFormatter.format(value);
13
+ }