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.
- package/README.md +68 -0
- package/internal/engine/create-feature-crud-script.ts +134 -0
- package/internal/engine/create-feature-crud.template.mjs +601 -0
- package/internal/engine/create-feature-script.ts +142 -0
- package/internal/engine/generator-engine.ts +546 -0
- package/internal/engine/standalone-feature-preset.ts +34 -0
- package/internal/meta/preset-plan.ts +41 -0
- package/internal/meta/runtime-copy-plan.ts +220 -0
- package/internal/meta/runtime-layout.ts +262 -0
- package/internal/meta/scaffold-manifest.ts +169 -0
- package/internal/meta/standalone-dependency-manifest.ts +75 -0
- package/package.json +45 -0
- package/scripts/create-app.mjs +1612 -0
- package/source/core/auth/auth-events.ts +13 -0
- package/source/core/error/app-error.ts +85 -0
- package/source/core/error/handle-app-error.client.ts +35 -0
- package/source/core/lib/dayjs.ts +25 -0
- package/source/core/query/query-client.ts +126 -0
- package/source/core/request/request-core.ts +210 -0
- package/source/core/routes/route-paths.ts +4 -0
- package/source/core/ui/button.tsx +24 -0
- package/source/core/ui/modal-store.ts +32 -0
- package/source/core/ui/text-input-field.tsx +36 -0
- package/source/core/utils/build-query-string.ts +30 -0
- package/source/core/utils/format/date.ts +41 -0
- package/source/core/utils/format/index.ts +3 -0
- package/source/core/utils/format/number.ts +13 -0
- package/source/core/utils/format/text.ts +15 -0
- package/source/core/utils/schema-utils.ts +27 -0
- package/source/wrappers/monorepo/core/internal.ts +21 -0
- package/source/wrappers/monorepo/core/src/index.ts +4 -0
- package/source/wrappers/monorepo/core-next/src/auth.client.ts +1 -0
- package/source/wrappers/monorepo/core-next/src/auth.server.ts +94 -0
- package/source/wrappers/monorepo/core-next/src/bootstrap.client.tsx +21 -0
- package/source/wrappers/monorepo/core-next/src/bootstrap.tsx +18 -0
- package/source/wrappers/monorepo/core-next/src/index.ts +1 -0
- package/source/wrappers/monorepo/core-react/src/app-providers.tsx +36 -0
- package/source/wrappers/monorepo/core-react/src/auth.ts +42 -0
- package/source/wrappers/monorepo/core-react/src/hydration.tsx +21 -0
- package/source/wrappers/monorepo/core-react/src/index.ts +7 -0
- package/source/wrappers/monorepo/core-react/src/provider.tsx +49 -0
- package/source/wrappers/monorepo/core-react/src/query-client.ts +48 -0
- package/source/wrappers/monorepo/core-react/src/query-error-handler.ts +62 -0
- package/source/wrappers/monorepo/core-react/src/query-keys.ts +22 -0
- package/source/wrappers/monorepo/request/core-fetch.ts +27 -0
- package/source/wrappers/monorepo/request/core-request.ts +93 -0
- package/source/wrappers/next/auth/auth-error-listener.tsx +34 -0
- package/source/wrappers/next/error/handle-app-error.server.ts +41 -0
- package/source/wrappers/next/query/hydration.tsx +20 -0
- package/source/wrappers/next/query/providers.tsx +35 -0
- package/source/wrappers/next/request/request.client.ts +24 -0
- package/source/wrappers/next/request/request.server.ts +64 -0
- package/source/wrappers/next/request/request.ts +52 -0
- package/source/wrappers/next/ui/global-modal.tsx +29 -0
- package/source/wrappers/react/auth/auth-error-listener.tsx +34 -0
- package/source/wrappers/react/query/providers.tsx +31 -0
- package/source/wrappers/react/request/request.client.ts +24 -0
- package/source/wrappers/react/request/request.ts +51 -0
- package/source/wrappers/react/ui/global-modal.tsx +27 -0
- package/templates/monorepo/.dockerignore +38 -0
- package/templates/monorepo/README.md +292 -0
- package/templates/monorepo/_gitignore +38 -0
- package/templates/monorepo/_npmrc +1 -0
- package/templates/monorepo/apps/project/Dockerfile +32 -0
- package/templates/monorepo/apps/project/eslint.config.mjs +4 -0
- package/templates/monorepo/apps/project/index.html +14 -0
- package/templates/monorepo/apps/project/index.ts +15 -0
- package/templates/monorepo/apps/project/package.json +21 -0
- package/templates/monorepo/apps/project/tsconfig.json +9 -0
- package/templates/monorepo/apps/project/vite.config.ts +6 -0
- package/templates/monorepo/apps/web/Dockerfile +43 -0
- package/templates/monorepo/apps/web/README.md +111 -0
- package/templates/monorepo/apps/web/_gitignore +36 -0
- package/templates/monorepo/apps/web/app/favicon.ico +0 -0
- package/templates/monorepo/apps/web/app/global-error.tsx +12 -0
- package/templates/monorepo/apps/web/app/globals.css +0 -0
- package/templates/monorepo/apps/web/app/layout.tsx +28 -0
- package/templates/monorepo/apps/web/app/page.tsx +7 -0
- package/templates/monorepo/apps/web/app/providers.tsx +25 -0
- package/templates/monorepo/apps/web/eslint.config.js +4 -0
- package/templates/monorepo/apps/web/next-env.d.ts +6 -0
- package/templates/monorepo/apps/web/next.config.js +4 -0
- package/templates/monorepo/apps/web/package.json +31 -0
- package/templates/monorepo/apps/web/public/file-text.svg +3 -0
- package/templates/monorepo/apps/web/public/globe.svg +10 -0
- package/templates/monorepo/apps/web/public/next.svg +1 -0
- package/templates/monorepo/apps/web/public/turborepo-dark.svg +19 -0
- package/templates/monorepo/apps/web/public/turborepo-light.svg +19 -0
- package/templates/monorepo/apps/web/public/vercel.svg +10 -0
- package/templates/monorepo/apps/web/public/window.svg +3 -0
- package/templates/monorepo/apps/web/tsconfig.json +20 -0
- package/templates/monorepo/package.json +24 -0
- package/templates/monorepo/packages/core/eslint.config.mjs +4 -0
- package/templates/monorepo/packages/core/package.json +32 -0
- package/templates/monorepo/packages/core/tsconfig.json +8 -0
- package/templates/monorepo/packages/core-next/eslint.config.mjs +13 -0
- package/templates/monorepo/packages/core-next/package.json +43 -0
- package/templates/monorepo/packages/core-next/tsconfig.json +8 -0
- package/templates/monorepo/packages/core-react/eslint.config.mjs +4 -0
- package/templates/monorepo/packages/core-react/package.json +34 -0
- package/templates/monorepo/packages/core-react/tsconfig.json +8 -0
- package/templates/monorepo/packages/eslint-config/README.md +3 -0
- package/templates/monorepo/packages/eslint-config/base.js +57 -0
- package/templates/monorepo/packages/eslint-config/next.js +22 -0
- package/templates/monorepo/packages/eslint-config/package.json +25 -0
- package/templates/monorepo/packages/eslint-config/react-internal.js +33 -0
- package/templates/monorepo/packages/typescript-config/base.json +19 -0
- package/templates/monorepo/packages/typescript-config/nextjs.json +12 -0
- package/templates/monorepo/packages/typescript-config/package.json +9 -0
- package/templates/monorepo/packages/typescript-config/react-library.json +7 -0
- package/templates/monorepo/packages/ui/eslint.config.mjs +4 -0
- package/templates/monorepo/packages/ui/package.json +26 -0
- package/templates/monorepo/packages/ui/src/button.tsx +20 -0
- package/templates/monorepo/packages/ui/src/card.tsx +27 -0
- package/templates/monorepo/packages/ui/src/code.tsx +11 -0
- package/templates/monorepo/packages/ui/tsconfig.json +8 -0
- package/templates/monorepo/pnpm-workspace.yaml +9 -0
- package/templates/monorepo/turbo/generators/config.js +1336 -0
- package/templates/monorepo/turbo/generators/templates/next-app/Dockerfile.tpl +30 -0
- package/templates/monorepo/turbo/generators/templates/next-app/README.md.tpl +118 -0
- package/templates/monorepo/turbo/generators/templates/next-app/app/global-error.tsx.tpl +12 -0
- package/templates/monorepo/turbo/generators/templates/next-app/app/globals.css.tpl +1 -0
- package/templates/monorepo/turbo/generators/templates/next-app/app/layout.tsx.tpl +29 -0
- package/templates/monorepo/turbo/generators/templates/next-app/app/page.tsx.tpl +7 -0
- package/templates/monorepo/turbo/generators/templates/next-app/app/providers.tsx.tpl +25 -0
- package/templates/monorepo/turbo/generators/templates/next-app/eslint.config.js.tpl +4 -0
- package/templates/monorepo/turbo/generators/templates/next-app/next.config.js.tpl +6 -0
- package/templates/monorepo/turbo/generators/templates/next-app/tsconfig.json.tpl +18 -0
- package/templates/monorepo/turbo/generators/templates/vite-app/Dockerfile.tpl +22 -0
- package/templates/monorepo/turbo/generators/templates/vite-app/README.plain.md.tpl +90 -0
- package/templates/monorepo/turbo/generators/templates/vite-app/README.react.md.tpl +107 -0
- package/templates/monorepo/turbo/generators/templates/vite-app/eslint.config.mjs.tpl +4 -0
- package/templates/monorepo/turbo/generators/templates/vite-app/index.html.tpl +12 -0
- package/templates/monorepo/turbo/generators/templates/vite-app/index.ts.tpl +22 -0
- package/templates/monorepo/turbo/generators/templates/vite-app/tsconfig.json.tpl +9 -0
- package/templates/monorepo/turbo/generators/templates/vite-app/vite.config.ts.tpl +6 -0
- package/templates/monorepo/turbo.json +28 -0
- package/templates/next/.env.example +2 -0
- package/templates/next/.prettierignore +9 -0
- package/templates/next/.prettierrc.json +9 -0
- package/templates/next/README.md +246 -0
- package/templates/next/_gitignore +44 -0
- package/templates/next/eslint.config.mjs +51 -0
- package/templates/next/next.config.ts +7 -0
- package/templates/next/package.json +24 -0
- package/templates/next/postcss.config.mjs +7 -0
- package/templates/next/scripts/create-feature-crud.mjs +5 -0
- package/templates/next/scripts/create-feature.mjs +5 -0
- package/templates/next/src/app/error.tsx +33 -0
- package/templates/next/src/app/globals.css +35 -0
- package/templates/next/src/app/layout.tsx +39 -0
- package/templates/next/src/app/login/page.tsx +17 -0
- package/templates/next/src/app/page.tsx +32 -0
- package/templates/next/src/app/providers.tsx +20 -0
- package/templates/next/tsconfig.json +34 -0
- package/templates/react/.env.example +1 -0
- package/templates/react/.prettierignore +10 -0
- package/templates/react/.prettierrc.json +9 -0
- package/templates/react/README.md +250 -0
- package/templates/react/_gitignore +31 -0
- package/templates/react/eslint.config.mjs +64 -0
- package/templates/react/package.json +19 -0
- package/templates/react/scripts/create-feature-crud.mjs +5 -0
- package/templates/react/scripts/create-feature.mjs +5 -0
- package/templates/react/src/app/app.tsx +15 -0
- package/templates/react/src/app/error-boundary.tsx +59 -0
- package/templates/react/src/app/frame.tsx +32 -0
- package/templates/react/src/app/globals.css +43 -0
- package/templates/react/src/app/not-found-page.tsx +23 -0
- package/templates/react/src/app/providers.tsx +16 -0
- package/templates/react/src/app/router.tsx +62 -0
- package/templates/react/src/main.tsx +12 -0
- package/templates/react/src/pages/index/page.tsx +36 -0
- package/templates/react/src/pages/login/page.tsx +18 -0
- package/templates/react/tsconfig.app.json +30 -0
- package/templates/react/tsconfig.json +4 -0
- package/templates/react/tsconfig.node.json +24 -0
- package/templates/react/vite.config.ts +14 -0
- 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
- 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
- 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
- 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
- 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
- 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
- package/templates/shared/docs//352/260/234/353/260/234/354/233/220/354/271/231/06-/355/217/274.md +116 -0
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- package/templates/shared/docs//352/260/234/353/260/234/354/233/220/354/271/231/README.md +39 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# API와 데이터
|
|
2
|
+
|
|
3
|
+
## 원칙
|
|
4
|
+
|
|
5
|
+
- 공통 request 계층과 feature API 계층을 분리합니다.
|
|
6
|
+
- 컴포넌트는 `fetch` 구현이 아니라 도메인 API 함수만 호출하게 만듭니다.
|
|
7
|
+
- 공통 request는 공통 처리까지만 맡고, 비즈니스 로직은 맡지 않습니다.
|
|
8
|
+
|
|
9
|
+
## Why?
|
|
10
|
+
|
|
11
|
+
- 공통 request가 너무 똑똑해지면 재사용성과 테스트성이 같이 떨어집니다.
|
|
12
|
+
- 컴포넌트에서 `fetch`를 직접 길게 쓰기 시작하면 도메인 의도가 흐려집니다.
|
|
13
|
+
- feature API가 있으면 Query, Server Component, Action 어디서든 같은 함수를 재사용하기 쉬워집니다.
|
|
14
|
+
|
|
15
|
+
## 공통 request가 맡는 일
|
|
16
|
+
|
|
17
|
+
- base URL 관리
|
|
18
|
+
- 공통 헤더 처리
|
|
19
|
+
- 인증 토큰 주입
|
|
20
|
+
- 요청 취소 처리
|
|
21
|
+
- 에러 정규화
|
|
22
|
+
|
|
23
|
+
## 공통 request가 맡지 않는 일
|
|
24
|
+
|
|
25
|
+
- 모달 열기
|
|
26
|
+
- 토스트 띄우기
|
|
27
|
+
- 화면 이동
|
|
28
|
+
- 특정 기능 전용 분기
|
|
29
|
+
|
|
30
|
+
## API layer 원칙
|
|
31
|
+
|
|
32
|
+
- feature별 API 함수는 feature 안에 둡니다.
|
|
33
|
+
- 필요하면 DTO, 폼 타입, 도메인 타입을 분리합니다.
|
|
34
|
+
- 응답은 실사용 데이터 형태로 정리하되, 서버 원본이 꼭 필요하면 명시적으로 구분합니다.
|
|
35
|
+
- 템플릿 기준으로 공통 요청은 `src/shared/api/request.server.ts`, `src/shared/api/request.client.ts`를 재사용합니다.
|
|
36
|
+
- 인증이 필요한 요청은 `requestServerAuth`, `requestClientAuth`를 우선 검토합니다.
|
|
37
|
+
|
|
38
|
+
## TanStack Query 기준
|
|
39
|
+
|
|
40
|
+
- Query key는 feature의 `hooks/query-keys.ts`처럼 hook 계층 가까이에서 관리합니다.
|
|
41
|
+
- Query와 Mutation은 역할을 분리합니다.
|
|
42
|
+
- Query 함수는 재사용 가능한 API 함수에 연결하기 쉽게 만듭니다.
|
|
43
|
+
- Mutation은 요청만 보내지 말고, 성공 후 어떤 query를 갱신할지도 같이 정합니다.
|
|
44
|
+
|
|
45
|
+
## Mutation 후 갱신 기준
|
|
46
|
+
|
|
47
|
+
- 생성, 수정, 삭제가 끝나면 어떤 목록과 상세 데이터가 영향을 받는지 같이 봅니다.
|
|
48
|
+
- 성공 후 `invalidateQueries`를 할지, 응답으로 바로 cache를 갱신할지 기준을 미리 정합니다.
|
|
49
|
+
- 변경 범위가 단순하면 invalidate로 시작하고, 정말 필요할 때만 직접 cache update를 검토합니다.
|
|
50
|
+
- mutation 훅 안에서 갱신 기준이 보이게 두는 편이 유지보수에 유리합니다.
|
|
51
|
+
|
|
52
|
+
## 심화 패턴: `prefetch + hydration`
|
|
53
|
+
|
|
54
|
+
- 기본 예시는 서버에서 바로 데이터를 가져오는 정도로도 충분합니다.
|
|
55
|
+
- 다만 서버에서 먼저 채운 Query 캐시를 클라이언트에서도 그대로 이어서 쓰고 싶다면 `prefetch + hydration` 패턴을 씁니다.
|
|
56
|
+
- 이 패턴은 초기 로딩을 줄이면서, 클라이언트에서 같은 query를 이어서 사용할 수 있다는 장점이 있습니다.
|
|
57
|
+
|
|
58
|
+
## 언제 쓰는가
|
|
59
|
+
|
|
60
|
+
- 서버에서 먼저 데이터를 준비하고
|
|
61
|
+
- 클라이언트에서도 같은 데이터를 `useQuery`로 계속 사용할 때
|
|
62
|
+
- 첫 렌더와 이후 클라이언트 갱신 흐름을 하나로 이어가고 싶을 때
|
|
63
|
+
|
|
64
|
+
## 언제 굳이 안 써도 되는가
|
|
65
|
+
|
|
66
|
+
- 서버 컴포넌트에서 한 번만 가져와서 바로 렌더하면 끝나는 화면
|
|
67
|
+
- 클라이언트에서 같은 query를 다시 쓸 필요가 없는 화면
|
|
68
|
+
- 기본 설명 문서나 단순 예시
|
|
69
|
+
|
|
70
|
+
## 심화 예시 코드
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
// src/app/posts/page.tsx
|
|
74
|
+
import { getPostListServer } from '@/features/posts/api/get-post-list.server';
|
|
75
|
+
import { PostListClient } from '@/features/posts/components/post-list-client';
|
|
76
|
+
import { postQueryKeys } from '@/features/posts/hooks/query-keys';
|
|
77
|
+
import { QueryHydration } from '@/shared/query/hydration';
|
|
78
|
+
|
|
79
|
+
export default async function PostsPage() {
|
|
80
|
+
return (
|
|
81
|
+
<QueryHydration
|
|
82
|
+
prefetch={(queryClient) =>
|
|
83
|
+
queryClient.prefetchQuery({
|
|
84
|
+
queryKey: postQueryKeys.list(),
|
|
85
|
+
queryFn: getPostListServer,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
>
|
|
89
|
+
<PostListClient />
|
|
90
|
+
</QueryHydration>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
// src/features/posts/components/post-list-client.tsx
|
|
97
|
+
'use client';
|
|
98
|
+
|
|
99
|
+
import { PostList } from '@/features/posts/components/post-list';
|
|
100
|
+
import { usePostListQuery } from '@/features/posts/hooks/use-post-list-query';
|
|
101
|
+
|
|
102
|
+
export function PostListClient() {
|
|
103
|
+
const { data = [] } = usePostListQuery();
|
|
104
|
+
|
|
105
|
+
return <PostList posts={data} />;
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
이 패턴은 "서버에서 먼저 채우고, 클라이언트가 같은 query를 이어받는다"는 흐름이 핵심입니다.
|
|
110
|
+
|
|
111
|
+
## 기본 예시 코드
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
// src/features/posts/api/get-post-list.server.ts
|
|
115
|
+
import { requestServer } from '@/shared/api/request.server';
|
|
116
|
+
|
|
117
|
+
type GetPostListResponse = {
|
|
118
|
+
items: { id: string; title: string }[];
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export async function getPostListServer() {
|
|
122
|
+
const response = await requestServer<GetPostListResponse>({
|
|
123
|
+
method: 'GET',
|
|
124
|
+
url: '/posts',
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return response.items;
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
// src/features/posts/api/get-post-list.client.ts
|
|
133
|
+
import { requestClient } from '@/shared/api/request.client';
|
|
134
|
+
|
|
135
|
+
type GetPostListResponse = {
|
|
136
|
+
items: { id: string; title: string }[];
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export async function getPostListClient() {
|
|
140
|
+
const response = await requestClient<GetPostListResponse>({
|
|
141
|
+
method: 'GET',
|
|
142
|
+
url: '/posts',
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
return response.items;
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
// src/features/posts/hooks/query-keys.ts
|
|
151
|
+
export const postQueryKeys = {
|
|
152
|
+
all: ['posts'] as const,
|
|
153
|
+
list: () => [...postQueryKeys.all, 'list'] as const,
|
|
154
|
+
};
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
```tsx
|
|
158
|
+
// src/features/posts/hooks/use-post-list-query.ts
|
|
159
|
+
'use client';
|
|
160
|
+
|
|
161
|
+
import { useQuery } from '@tanstack/react-query';
|
|
162
|
+
|
|
163
|
+
import { getPostListClient } from '@/features/posts/api/get-post-list.client';
|
|
164
|
+
import { postQueryKeys } from '@/features/posts/hooks/query-keys';
|
|
165
|
+
|
|
166
|
+
export function usePostListQuery() {
|
|
167
|
+
return useQuery({
|
|
168
|
+
queryKey: postQueryKeys.list(),
|
|
169
|
+
queryFn: getPostListClient,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
```tsx
|
|
175
|
+
// src/features/posts/hooks/use-create-post-mutation.ts
|
|
176
|
+
'use client';
|
|
177
|
+
|
|
178
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
179
|
+
|
|
180
|
+
import { createPostClient } from '@/features/posts/api/create-post.client';
|
|
181
|
+
import { postQueryKeys } from '@/features/posts/hooks/query-keys';
|
|
182
|
+
|
|
183
|
+
export function useCreatePostMutation() {
|
|
184
|
+
const queryClient = useQueryClient();
|
|
185
|
+
|
|
186
|
+
return useMutation({
|
|
187
|
+
mutationFn: createPostClient,
|
|
188
|
+
onSuccess: () => {
|
|
189
|
+
queryClient.invalidateQueries({
|
|
190
|
+
queryKey: postQueryKeys.all,
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## 한 줄 요약
|
|
198
|
+
|
|
199
|
+
공통 request는 네트워크 공통 처리만 맡고, 실제 도메인 의미는 feature API가 담당합니다.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# 에러와 UI 상태
|
|
2
|
+
|
|
3
|
+
## 원칙
|
|
4
|
+
|
|
5
|
+
- 에러는 전역 에러와 로컬 에러를 구분합니다.
|
|
6
|
+
- 인증 만료, 네트워크 장애, 서비스 장애처럼 앱 전체에 영향이 큰 것은 전역에서 다룹니다.
|
|
7
|
+
- 특정 목록 실패, 특정 액션 실패, 폼 제출 실패는 로컬에서 다룹니다.
|
|
8
|
+
|
|
9
|
+
## Why?
|
|
10
|
+
|
|
11
|
+
- 모든 에러를 전역에서 처리하면 사용자 맥락이 사라집니다.
|
|
12
|
+
- 반대로 모든 에러를 각 화면에서 처리하면 공통 대응이 깨집니다.
|
|
13
|
+
- 로딩도 범위에 맞춰 보여줘야 사용자가 무엇이 기다리는 중인지 이해할 수 있습니다.
|
|
14
|
+
|
|
15
|
+
## 로딩과 화면 상태
|
|
16
|
+
|
|
17
|
+
- 로딩 UI는 범위에 맞게 보여줍니다.
|
|
18
|
+
- 페이지 전체 대기면 페이지 로딩
|
|
19
|
+
- 일부 목록만 대기면 섹션 로딩
|
|
20
|
+
- 버튼만 진행 중이면 버튼 pending
|
|
21
|
+
|
|
22
|
+
화면 상태는 최소한 아래를 구분합니다.
|
|
23
|
+
|
|
24
|
+
- `loading`
|
|
25
|
+
- `error`
|
|
26
|
+
- `empty`
|
|
27
|
+
- `success`
|
|
28
|
+
|
|
29
|
+
## 모달 원칙
|
|
30
|
+
|
|
31
|
+
- 공통 모달은 전역 UI 계층에서 렌더링합니다.
|
|
32
|
+
- 필요한 화면이나 상위 계층이 모달을 열도록 구성합니다.
|
|
33
|
+
- fetch나 low-level 유틸이 모달을 직접 제어하지 않게 합니다.
|
|
34
|
+
|
|
35
|
+
## 전역 에러 핸들러 원칙
|
|
36
|
+
|
|
37
|
+
- 전역 에러 핸들러는 인증 만료, 네트워크 오류처럼 앱 공통 반응까지만 맡깁니다.
|
|
38
|
+
- 실제 화면별 fallback UI와 문구는 feature 가까이에서 처리합니다.
|
|
39
|
+
- 인증 이동은 이벤트 리스너, 공통 알림은 modal store 같은 전역 UI 계층에서 받게 구성합니다.
|
|
40
|
+
- 요청 취소나 abort는 전역 에러로 취급하지 않고 조용히 무시합니다.
|
|
41
|
+
|
|
42
|
+
## 예시 코드
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
// src/features/posts/components/post-list-section.tsx
|
|
46
|
+
'use client';
|
|
47
|
+
|
|
48
|
+
import { normalizeAppError } from '@/shared/api/app-error';
|
|
49
|
+
import { PostList } from '@/features/posts/components/post-list';
|
|
50
|
+
import { usePostListQuery } from '@/features/posts/hooks/use-post-list-query';
|
|
51
|
+
|
|
52
|
+
export function PostListSection() {
|
|
53
|
+
const { data = [], isPending, isError, error } = usePostListQuery();
|
|
54
|
+
|
|
55
|
+
if (isPending) {
|
|
56
|
+
return <p>불러오는 중...</p>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (isError) {
|
|
60
|
+
return <p>{normalizeAppError(error).message}</p>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (data.length === 0) {
|
|
64
|
+
return <p>등록된 게시글이 없습니다.</p>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return <PostList posts={data} />;
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
// src/app/providers.tsx
|
|
73
|
+
'use client';
|
|
74
|
+
|
|
75
|
+
import type { ReactNode } from 'react';
|
|
76
|
+
|
|
77
|
+
import { QueryProviders } from '@/shared/query/providers';
|
|
78
|
+
import { handleClientQueryError } from '@/shared/query/query-error-handler.client';
|
|
79
|
+
|
|
80
|
+
type AppProvidersProps = {
|
|
81
|
+
children: ReactNode;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export function AppProviders({ children }: AppProvidersProps) {
|
|
85
|
+
return <QueryProviders onGlobalError={handleClientQueryError}>{children}</QueryProviders>;
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
// src/shared/query/query-error-handler.client.ts
|
|
91
|
+
'use client';
|
|
92
|
+
|
|
93
|
+
import { APP_ERROR_CODES, normalizeAppError } from '@/shared/api/app-error';
|
|
94
|
+
import { dispatchUnauthorizedEvent } from '@/shared/auth/auth-events';
|
|
95
|
+
import { useModalStore } from '@/shared/ui/modal-store';
|
|
96
|
+
|
|
97
|
+
export function handleClientQueryError(error: unknown) {
|
|
98
|
+
const appError = normalizeAppError(error);
|
|
99
|
+
|
|
100
|
+
if (appError.code === APP_ERROR_CODES.REQUEST_ABORTED) return;
|
|
101
|
+
|
|
102
|
+
if (appError.code === APP_ERROR_CODES.UNAUTHORIZED) {
|
|
103
|
+
dispatchUnauthorizedEvent();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (appError.code === APP_ERROR_CODES.NETWORK_ERROR) {
|
|
108
|
+
useModalStore.getState().openModal({
|
|
109
|
+
title: '네트워크 오류',
|
|
110
|
+
description: '잠시 후 다시 시도해주세요.',
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (appError.code === APP_ERROR_CODES.FORBIDDEN) {
|
|
116
|
+
useModalStore.getState().openModal({
|
|
117
|
+
title: '접근 권한 없음',
|
|
118
|
+
description: '이 작업을 수행할 권한이 없습니다.',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
취소된 요청은 사용자가 직접 중단한 흐름이거나 화면 전환 과정에서 자연스럽게 생길 수 있어서, 보통 모달이나 토스트로 다시 보여주지 않습니다.
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
// src/shared/auth/auth-error-listener.tsx
|
|
128
|
+
'use client';
|
|
129
|
+
|
|
130
|
+
import { usePathname, useRouter } from 'next/navigation';
|
|
131
|
+
import { useEffect } from 'react';
|
|
132
|
+
|
|
133
|
+
import { AUTH_UNAUTHORIZED_EVENT } from '@/shared/auth/auth-events';
|
|
134
|
+
|
|
135
|
+
export function AuthErrorListener() {
|
|
136
|
+
const pathname = usePathname();
|
|
137
|
+
const router = useRouter();
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
function handleUnauthorized() {
|
|
141
|
+
if (pathname === '/login') return;
|
|
142
|
+
|
|
143
|
+
router.replace('/login');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
window.addEventListener(AUTH_UNAUTHORIZED_EVENT, handleUnauthorized);
|
|
147
|
+
|
|
148
|
+
return () => {
|
|
149
|
+
window.removeEventListener(AUTH_UNAUTHORIZED_EVENT, handleUnauthorized);
|
|
150
|
+
};
|
|
151
|
+
}, [pathname, router]);
|
|
152
|
+
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## 한 줄 요약
|
|
158
|
+
|
|
159
|
+
에러는 범위에 따라 나누고, 로딩은 범위에 맞게 보여주고, 전역 처리는 최대한 얇게 유지합니다.
|
package/templates/shared/docs//352/260/234/353/260/234/354/233/220/354/271/231/06-/355/217/274.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# 폼
|
|
2
|
+
|
|
3
|
+
## 원칙
|
|
4
|
+
|
|
5
|
+
- 검증은 `zod`, 폼 상태 관리는 `react-hook-form`이 맡도록 역할을 나눕니다.
|
|
6
|
+
- 생성 폼과 수정 폼은 책임이 다르면 분리합니다.
|
|
7
|
+
- 공통 `Form`/`Field` 컴포넌트는 반복 패턴이 충분히 생겼을 때만 만듭니다.
|
|
8
|
+
|
|
9
|
+
## Why?
|
|
10
|
+
|
|
11
|
+
- 검증 로직과 입력 상태를 분리하면 재사용과 테스트가 쉬워집니다.
|
|
12
|
+
- 생성과 수정은 비슷해 보여도 초기값, 제출 API, 검증 규칙이 달라지기 쉽습니다.
|
|
13
|
+
- 폼 제출 UX가 약하면 사용자는 지금 저장 중인지, 실패했는지 알기 어렵습니다.
|
|
14
|
+
|
|
15
|
+
## 제출 UX
|
|
16
|
+
|
|
17
|
+
- 제출 중 상태를 명확히 보여줍니다.
|
|
18
|
+
- 중복 제출을 막습니다.
|
|
19
|
+
- 실패 시 사용자가 이해할 수 있는 에러를 보여줍니다.
|
|
20
|
+
|
|
21
|
+
## 체크 기준
|
|
22
|
+
|
|
23
|
+
- 이 필드 패턴이 여러 폼에서 반복되는가
|
|
24
|
+
- 생성과 수정이 초기값, 검증, 제출 흐름이 같은가
|
|
25
|
+
- 에러 메시지가 입력 위치와 충분히 가깝게 보이는가
|
|
26
|
+
|
|
27
|
+
## 예시 코드
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// src/features/posts/hooks/use-create-post-mutation.ts
|
|
31
|
+
'use client';
|
|
32
|
+
|
|
33
|
+
import { useMutation } from '@tanstack/react-query';
|
|
34
|
+
|
|
35
|
+
import { createPostClient } from '@/features/posts/api/create-post.client';
|
|
36
|
+
|
|
37
|
+
export function useCreatePostMutation() {
|
|
38
|
+
return useMutation({
|
|
39
|
+
mutationFn: createPostClient,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
// src/features/posts/components/create-post-form.tsx
|
|
46
|
+
'use client';
|
|
47
|
+
|
|
48
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
49
|
+
import { useForm } from 'react-hook-form';
|
|
50
|
+
import { z } from 'zod';
|
|
51
|
+
|
|
52
|
+
import { useCreatePostMutation } from '@/features/posts/hooks/use-create-post-mutation';
|
|
53
|
+
import { TextInputField } from '@/shared/ui/text-input-field';
|
|
54
|
+
|
|
55
|
+
const createPostSchema = z.object({
|
|
56
|
+
title: z.string().min(1, '제목을 입력해주세요.'),
|
|
57
|
+
content: z.string().min(10, '내용은 10자 이상 입력해주세요.'),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
type CreatePostFormValues = z.infer<typeof createPostSchema>;
|
|
61
|
+
|
|
62
|
+
export function CreatePostForm() {
|
|
63
|
+
const mutation = useCreatePostMutation();
|
|
64
|
+
const {
|
|
65
|
+
register,
|
|
66
|
+
handleSubmit,
|
|
67
|
+
formState: { errors },
|
|
68
|
+
} = useForm<CreatePostFormValues>({
|
|
69
|
+
resolver: zodResolver(createPostSchema),
|
|
70
|
+
defaultValues: {
|
|
71
|
+
title: '',
|
|
72
|
+
content: '',
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const onSubmit = handleSubmit(async (values) => {
|
|
77
|
+
await mutation.mutateAsync(values);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
|
82
|
+
<TextInputField
|
|
83
|
+
label="제목"
|
|
84
|
+
required
|
|
85
|
+
error={errors.title?.message}
|
|
86
|
+
placeholder="제목을 입력하세요"
|
|
87
|
+
{...register('title')}
|
|
88
|
+
/>
|
|
89
|
+
|
|
90
|
+
<div className="flex flex-col gap-2">
|
|
91
|
+
<label htmlFor="content" className="text-sm font-semibold text-zinc-900">
|
|
92
|
+
내용
|
|
93
|
+
</label>
|
|
94
|
+
<textarea
|
|
95
|
+
id="content"
|
|
96
|
+
className="min-h-40 rounded-xl border border-zinc-300 px-4 py-3 text-sm text-zinc-950 outline-none transition focus:border-zinc-950"
|
|
97
|
+
{...register('content')}
|
|
98
|
+
/>
|
|
99
|
+
{errors.content ? <p className="text-sm text-rose-600">{errors.content.message}</p> : null}
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<button
|
|
103
|
+
type="submit"
|
|
104
|
+
disabled={mutation.isPending}
|
|
105
|
+
className="rounded-xl bg-zinc-950 px-4 py-3 text-sm font-medium text-white disabled:opacity-50"
|
|
106
|
+
>
|
|
107
|
+
{mutation.isPending ? '저장 중...' : '저장'}
|
|
108
|
+
</button>
|
|
109
|
+
</form>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## 한 줄 요약
|
|
115
|
+
|
|
116
|
+
폼은 검증과 상태 관리를 분리하고, 제출 중·실패 상태를 사용자가 분명히 알 수 있게 만듭니다.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# 스타일링과 접근성
|
|
2
|
+
|
|
3
|
+
## 원칙
|
|
4
|
+
|
|
5
|
+
- 공통 스타일은 공통 UI 컴포넌트에서 관리합니다.
|
|
6
|
+
- 기능 전용 스타일은 feature 가까이에 둡니다.
|
|
7
|
+
- spacing, color, typography는 반복 가능한 기준을 정해 사용합니다.
|
|
8
|
+
- 상태별 스타일도 일관되게 관리합니다.
|
|
9
|
+
|
|
10
|
+
## Why?
|
|
11
|
+
|
|
12
|
+
- 공통 스타일 기준이 없으면 화면마다 간격과 톤이 달라집니다.
|
|
13
|
+
- 스타일을 지나치게 전역화하면 feature 고유 UI까지 묶여버립니다.
|
|
14
|
+
- 접근성은 나중에 덧붙이기보다 기본 마크업에서부터 지키는 편이 훨씬 쉽습니다.
|
|
15
|
+
|
|
16
|
+
## 접근성 원칙
|
|
17
|
+
|
|
18
|
+
- 의미에 맞는 HTML 태그를 사용합니다.
|
|
19
|
+
- `input`은 항상 `label`과 연결합니다.
|
|
20
|
+
- 키보드만으로도 조작 가능해야 합니다.
|
|
21
|
+
- focus 스타일을 없애지 않습니다.
|
|
22
|
+
|
|
23
|
+
## 빠른 체크
|
|
24
|
+
|
|
25
|
+
- 이 UI가 마우스 없이도 조작 가능한가
|
|
26
|
+
- label 없이 입력 요소가 놓여 있지 않은가
|
|
27
|
+
- 성공/에러/비활성 상태가 스타일만 달라지는 것이 아니라 의미도 전달되는가
|
|
28
|
+
|
|
29
|
+
## 예시 코드
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
// src/shared/ui/text-input-field.tsx
|
|
33
|
+
import type { InputHTMLAttributes } from 'react';
|
|
34
|
+
|
|
35
|
+
type TextInputFieldProps = {
|
|
36
|
+
label: string;
|
|
37
|
+
required?: boolean;
|
|
38
|
+
error?: string;
|
|
39
|
+
helperText?: string;
|
|
40
|
+
} & InputHTMLAttributes<HTMLInputElement>;
|
|
41
|
+
|
|
42
|
+
export function TextInputField({
|
|
43
|
+
label,
|
|
44
|
+
required,
|
|
45
|
+
error,
|
|
46
|
+
helperText,
|
|
47
|
+
id,
|
|
48
|
+
className = '',
|
|
49
|
+
...props
|
|
50
|
+
}: TextInputFieldProps) {
|
|
51
|
+
const inputId = id ?? props.name;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="flex flex-col gap-2">
|
|
55
|
+
<label htmlFor={inputId} className="text-sm font-semibold text-zinc-900">
|
|
56
|
+
{label}
|
|
57
|
+
{required ? <span className="ml-1 text-rose-600">*</span> : null}
|
|
58
|
+
</label>
|
|
59
|
+
<input
|
|
60
|
+
id={inputId}
|
|
61
|
+
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}`}
|
|
62
|
+
{...props}
|
|
63
|
+
/>
|
|
64
|
+
{error ? <p className="text-sm text-rose-600">{error}</p> : null}
|
|
65
|
+
{!error && helperText ? <p className="text-sm text-zinc-500">{helperText}</p> : null}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## 한 줄 요약
|
|
72
|
+
|
|
73
|
+
스타일은 일관된 기준으로 관리하고, 접근성은 기본 동작이 자연스럽게 되도록 설계합니다.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# 네이밍, 설정, 포맷팅
|
|
2
|
+
|
|
3
|
+
## 원칙
|
|
4
|
+
|
|
5
|
+
- 이름만 봐도 역할이 보이게 짓습니다.
|
|
6
|
+
- 설정은 한 곳에서 읽고 검증합니다.
|
|
7
|
+
- 포맷팅은 공통 함수로 관리합니다.
|
|
8
|
+
|
|
9
|
+
## Why?
|
|
10
|
+
|
|
11
|
+
- 이름이 모호하면 코드 탐색 비용이 계속 쌓입니다.
|
|
12
|
+
- 환경변수를 여기저기서 직접 읽으면 누락과 오타를 찾기 어렵습니다.
|
|
13
|
+
- 날짜와 숫자 포맷팅을 컴포넌트마다 직접 처리하면 표현이 금방 깨집니다.
|
|
14
|
+
|
|
15
|
+
## 네이밍
|
|
16
|
+
|
|
17
|
+
- 파일명은 `kebab-case`
|
|
18
|
+
- 컴포넌트명은 `PascalCase`
|
|
19
|
+
- 훅은 `useXxx`
|
|
20
|
+
- boolean은 `is`, `has`, `can`
|
|
21
|
+
- 함수명은 동사 + 대상이 보이게 짓습니다.
|
|
22
|
+
- 애매한 이름보다 도메인 이름을 직접 사용합니다.
|
|
23
|
+
|
|
24
|
+
## 상수와 설정
|
|
25
|
+
|
|
26
|
+
- 반복되고 의미 있는 값만 상수로 분리합니다.
|
|
27
|
+
- 도메인 상수는 feature 내부에 둡니다.
|
|
28
|
+
- 진짜 전역 공통 상수만 `shared`에 둡니다.
|
|
29
|
+
- 환경변수와 설정값은 공통 config 계층에서 읽습니다.
|
|
30
|
+
- 필수 환경변수는 초기에 검증해서 조용히 누락되지 않게 합니다.
|
|
31
|
+
|
|
32
|
+
## 포맷팅
|
|
33
|
+
|
|
34
|
+
- 날짜, 숫자, 텍스트 포맷팅은 컴포넌트마다 직접 처리하지 않습니다.
|
|
35
|
+
- locale, timezone, 표현 형식은 공통 함수에서 일관되게 관리합니다.
|
|
36
|
+
- 절대 날짜와 상대 시간을 구분합니다.
|
|
37
|
+
- 포맷팅은 표시 책임만 갖고, 비즈니스 판단까지 맡기지 않습니다.
|
|
38
|
+
|
|
39
|
+
## 예시 코드
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// src/shared/config/env.server.ts
|
|
43
|
+
import 'server-only';
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
getOptionalUrlEnv,
|
|
47
|
+
getRequiredUrlEnv,
|
|
48
|
+
} from '@/shared/config/env-utils';
|
|
49
|
+
|
|
50
|
+
const publicApiBaseUrl = getRequiredUrlEnv('NEXT_PUBLIC_API_BASE_URL');
|
|
51
|
+
|
|
52
|
+
export const env = {
|
|
53
|
+
apiBaseUrl: getOptionalUrlEnv('API_BASE_URL') ?? publicApiBaseUrl,
|
|
54
|
+
publicApiBaseUrl,
|
|
55
|
+
};
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// src/shared/utils/format/date.ts
|
|
60
|
+
import type { ConfigType } from 'dayjs';
|
|
61
|
+
|
|
62
|
+
import { appDayjs } from '@/shared/lib/dayjs';
|
|
63
|
+
|
|
64
|
+
export type DateValue = ConfigType | null | undefined;
|
|
65
|
+
|
|
66
|
+
const EMPTY_FORMAT_VALUE = '-';
|
|
67
|
+
|
|
68
|
+
function formatDateValue(value: DateValue, pattern: string, fallback = EMPTY_FORMAT_VALUE) {
|
|
69
|
+
if (value === null || value === undefined) return fallback;
|
|
70
|
+
|
|
71
|
+
const date = appDayjs(value);
|
|
72
|
+
|
|
73
|
+
if (!date.isValid()) return fallback;
|
|
74
|
+
|
|
75
|
+
return date.format(pattern);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function formatDate(value: DateValue) {
|
|
79
|
+
return formatDateValue(value, 'YYYY-MM-DD');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function formatDateTime(value: DateValue) {
|
|
83
|
+
return formatDateValue(value, 'YYYY-MM-DD HH:mm');
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
// 좋은 이름 예시
|
|
89
|
+
const isPending = true;
|
|
90
|
+
const hasPermission = false;
|
|
91
|
+
|
|
92
|
+
function getPostListClient() {}
|
|
93
|
+
function useCreatePostMutation() {}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## 한 줄 요약
|
|
97
|
+
|
|
98
|
+
이름은 역할이 바로 보여야 하고, 설정은 한 곳에서 읽어야 하며, 포맷팅은 공통 함수로 통일합니다.
|