@m1kapp/kit 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/README.md +627 -0
- package/dist/index.d.mts +898 -0
- package/dist/index.d.ts +898 -0
- package/dist/index.js +12 -0
- package/dist/index.mjs +12 -0
- package/dist/server.d.mts +71 -0
- package/dist/server.d.ts +71 -0
- package/dist/server.js +1 -0
- package/dist/server.mjs +1 -0
- package/package.json +66 -0
package/README.md
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
# @m1kapp/kit
|
|
2
|
+
|
|
3
|
+
사이드 프로젝트를 위한 UI · OG · PWA · Fetch · Utils 올인원 킷.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @m1kapp/kit
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
**Peer dependencies:** `react >= 18`, `react-dom >= 18`
|
|
10
|
+
**Optional:** `@vercel/og >= 0.6` (Next.js 외 환경에서 OG 이미지 생성 시)
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 빠른 시작
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
// CSS 자동 주입 — 별도 import 불필요
|
|
18
|
+
import { AppShell, AppShellHeader, AppShellContent, TabBar, Tab } from "@m1kapp/kit";
|
|
19
|
+
|
|
20
|
+
export default function App() {
|
|
21
|
+
return (
|
|
22
|
+
<AppShell>
|
|
23
|
+
<AppShellHeader>
|
|
24
|
+
<h1>My App</h1>
|
|
25
|
+
</AppShellHeader>
|
|
26
|
+
<AppShellContent>
|
|
27
|
+
{/* 콘텐츠 */}
|
|
28
|
+
</AppShellContent>
|
|
29
|
+
<TabBar>
|
|
30
|
+
<Tab href="/" icon={<HomeIcon />} label="홈" />
|
|
31
|
+
</TabBar>
|
|
32
|
+
</AppShell>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## 모듈 구성
|
|
40
|
+
|
|
41
|
+
| 모듈 | import | 설명 |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| UI | `@m1kapp/kit` | 컴포넌트 24개 + 훅 |
|
|
44
|
+
| OG Image | `@m1kapp/kit` | OG 이미지 생성 (서버) |
|
|
45
|
+
| PWA | `@m1kapp/kit` | manifest, viewport, 설치 유도 |
|
|
46
|
+
| Fetch | `@m1kapp/kit` | 캐싱·중복제거·재시도 fetch 유틸 |
|
|
47
|
+
| Utils | `@m1kapp/kit` | 날짜·숫자 포맷, 범용 훅 |
|
|
48
|
+
| **Server** | `@m1kapp/kit/server` | Next.js API route 핸들러 유틸 |
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## UI
|
|
53
|
+
|
|
54
|
+
CSS가 import 시 자동 주입됩니다. 별도 스타일시트 import 불필요.
|
|
55
|
+
|
|
56
|
+
### 레이아웃
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
import { AppShell, AppShellHeader, AppShellContent } from "@m1kapp/kit";
|
|
60
|
+
|
|
61
|
+
<AppShell> // 최대 430px 중앙 정렬 모바일 컨테이너
|
|
62
|
+
<AppShellHeader>...</AppShellHeader> // 상단 sticky 헤더
|
|
63
|
+
<AppShellContent>...</AppShellContent> // 스크롤 가능한 본문
|
|
64
|
+
</AppShell>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 내비게이션
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
import { TabBar, Tab } from "@m1kapp/kit";
|
|
71
|
+
|
|
72
|
+
<TabBar>
|
|
73
|
+
<Tab
|
|
74
|
+
active={tab === "home"}
|
|
75
|
+
onClick={() => setTab("home")}
|
|
76
|
+
label="홈"
|
|
77
|
+
icon={<HomeIcon />}
|
|
78
|
+
activeColor="#3b82f6" // 활성 색상 자유롭게 지정
|
|
79
|
+
/>
|
|
80
|
+
</TabBar>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 데이터 표시
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
import { Avatar, Badge, StatChip, EmptyState, GrassMap } from "@m1kapp/kit";
|
|
87
|
+
|
|
88
|
+
// Avatar — 이니셜 or 이미지, 이미지 로드 실패 시 이니셜로 자동 fallback
|
|
89
|
+
<Avatar src="/photo.jpg" fallback="MH" size="md" shape="circle" />
|
|
90
|
+
<Avatar fallback="MH" size="lg" shape="rounded" color="#3b82f6" />
|
|
91
|
+
// size: "xs" | "sm" | "md" | "lg" | "xl"
|
|
92
|
+
// shape: "circle" | "rounded"
|
|
93
|
+
|
|
94
|
+
// Badge — 상태/카테고리 레이블
|
|
95
|
+
<Badge variant="green">LIVE</Badge>
|
|
96
|
+
<Badge variant="red">오류</Badge>
|
|
97
|
+
<Badge variant="blue" size="sm">정보</Badge>
|
|
98
|
+
// variant: "default" | "green" | "red" | "yellow" | "blue" | "purple" | "orange"
|
|
99
|
+
|
|
100
|
+
// StatChip — 숫자 stat 뱃지
|
|
101
|
+
<StatChip label="방문자" value={1024} />
|
|
102
|
+
|
|
103
|
+
// EmptyState — 빈 목록 플레이스홀더
|
|
104
|
+
<EmptyState message="아직 아무것도 없어요" />
|
|
105
|
+
|
|
106
|
+
// GrassMap — GitHub 스타일 활동 히트맵
|
|
107
|
+
<GrassMap data={[{ date: "2025-04-19", count: 42 }]} accent="#3b82f6" />
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### 스켈레톤
|
|
111
|
+
|
|
112
|
+
로딩 플레이스홀더. `className`으로 크기를 지정합니다.
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
import { Skeleton } from "@m1kapp/kit";
|
|
116
|
+
|
|
117
|
+
// 텍스트 줄
|
|
118
|
+
<Skeleton className="h-4 w-3/4" />
|
|
119
|
+
|
|
120
|
+
// 카드 블록
|
|
121
|
+
<Skeleton className="h-32 w-full" rounded="xl" />
|
|
122
|
+
|
|
123
|
+
// 아바타
|
|
124
|
+
<Skeleton className="h-10 w-10" rounded="full" />
|
|
125
|
+
|
|
126
|
+
// 실전 패턴
|
|
127
|
+
function PostCardSkeleton() {
|
|
128
|
+
return (
|
|
129
|
+
<div className="flex gap-3 p-4">
|
|
130
|
+
<Skeleton className="h-10 w-10" rounded="full" />
|
|
131
|
+
<div className="flex-1 space-y-2">
|
|
132
|
+
<Skeleton className="h-4 w-2/3" />
|
|
133
|
+
<Skeleton className="h-3 w-1/2" />
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### 모달 / 다이얼로그
|
|
141
|
+
|
|
142
|
+
backdrop 클릭, ESC 키, 스크롤 잠금 자동 처리.
|
|
143
|
+
|
|
144
|
+
```tsx
|
|
145
|
+
import { Dialog } from "@m1kapp/kit";
|
|
146
|
+
|
|
147
|
+
// 기본 사용
|
|
148
|
+
<Dialog open={open} onClose={() => setOpen(false)} title="설정">
|
|
149
|
+
<p className="text-sm text-zinc-500">내용</p>
|
|
150
|
+
</Dialog>
|
|
151
|
+
|
|
152
|
+
// 확인 다이얼로그
|
|
153
|
+
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)} title="삭제할까요?">
|
|
154
|
+
<p className="text-sm text-zinc-500">이 작업은 되돌릴 수 없어요.</p>
|
|
155
|
+
<div className="flex gap-2 mt-4">
|
|
156
|
+
<button onClick={handleDelete} className="...">삭제</button>
|
|
157
|
+
<button onClick={() => setConfirmOpen(false)} className="...">취소</button>
|
|
158
|
+
</div>
|
|
159
|
+
</Dialog>
|
|
160
|
+
|
|
161
|
+
// size: "sm" (기본) | "md" | "lg"
|
|
162
|
+
// persistent: true — backdrop 클릭 / ESC로 닫기 비활성화
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### 인터랙션
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
import { Button, Tooltip, Typewriter, EmojiButton, EmojiPicker } from "@m1kapp/kit";
|
|
169
|
+
|
|
170
|
+
<Button onClick={fn}>시작하기</Button>
|
|
171
|
+
|
|
172
|
+
<Tooltip label="설명 텍스트">
|
|
173
|
+
<button>hover me</button>
|
|
174
|
+
</Tooltip>
|
|
175
|
+
|
|
176
|
+
<Typewriter words={["Hello", "World"]} color="#3b82f6" />
|
|
177
|
+
|
|
178
|
+
// 이모지 선택기
|
|
179
|
+
const [emoji, setEmoji] = useState("🏠");
|
|
180
|
+
const [open, setOpen] = useState(false);
|
|
181
|
+
<EmojiButton emoji={emoji} onClick={() => setOpen(true)} />
|
|
182
|
+
<EmojiPicker open={open} onClose={() => setOpen(false)} current={emoji} onSelect={setEmoji} />
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### 공유
|
|
186
|
+
|
|
187
|
+
```tsx
|
|
188
|
+
import { ShareButton, useShare } from "@m1kapp/kit";
|
|
189
|
+
|
|
190
|
+
// 버튼 그대로 사용 — 모바일은 네이티브 공유, 데스크탑은 클립보드 복사
|
|
191
|
+
<ShareButton url="https://m1k.app" title="My App" />
|
|
192
|
+
|
|
193
|
+
// 커스텀 UI
|
|
194
|
+
const { share, copied, canNativeShare } = useShare({ url: "https://m1k.app" });
|
|
195
|
+
<button onClick={() => share()}>{copied ? "복사됨!" : "공유"}</button>
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### 토스트
|
|
199
|
+
|
|
200
|
+
```tsx
|
|
201
|
+
import { ToastProvider, useToast } from "@m1kapp/kit";
|
|
202
|
+
|
|
203
|
+
// 앱 루트 감싸기
|
|
204
|
+
<ToastProvider>
|
|
205
|
+
<App />
|
|
206
|
+
</ToastProvider>
|
|
207
|
+
|
|
208
|
+
// 어디서나
|
|
209
|
+
const toast = useToast();
|
|
210
|
+
toast("저장됐어요!", { variant: "success" });
|
|
211
|
+
toast("오류가 발생했어요.", { variant: "error", duration: 4000 });
|
|
212
|
+
toast("링크가 복사됐어요."); // default (dark)
|
|
213
|
+
// variant: "default" | "success" | "error" | "info"
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### 테마
|
|
217
|
+
|
|
218
|
+
다크모드 + 컬러 테마 선택기.
|
|
219
|
+
|
|
220
|
+
```tsx
|
|
221
|
+
import { ThemeButton, ThemeDialog, THEME_SCRIPT, colors } from "@m1kapp/kit";
|
|
222
|
+
|
|
223
|
+
// layout.tsx — 다크모드 깜빡임 방지
|
|
224
|
+
<head>
|
|
225
|
+
<script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
|
|
226
|
+
</head>
|
|
227
|
+
|
|
228
|
+
// 테마 버튼 + 다이얼로그
|
|
229
|
+
const [themeOpen, setThemeOpen] = useState(false);
|
|
230
|
+
<ThemeButton color={color} dark={dark} onClick={() => setThemeOpen(true)} />
|
|
231
|
+
<ThemeDialog
|
|
232
|
+
open={themeOpen}
|
|
233
|
+
onClose={() => setThemeOpen(false)}
|
|
234
|
+
current={color}
|
|
235
|
+
onSelect={setColor}
|
|
236
|
+
dark={dark}
|
|
237
|
+
onDarkToggle={() => setDark(v => !v)}
|
|
238
|
+
/>
|
|
239
|
+
|
|
240
|
+
// 팔레트
|
|
241
|
+
colors.blue // "#3b82f6"
|
|
242
|
+
colors.purple // "#a855f7"
|
|
243
|
+
colors.green // "#22c55e"
|
|
244
|
+
// blue | purple | green | orange | pink | red | yellow | cyan | slate | zinc
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### 워터마크
|
|
248
|
+
|
|
249
|
+
```tsx
|
|
250
|
+
import { Watermark } from "@m1kapp/kit";
|
|
251
|
+
|
|
252
|
+
<Watermark color="#3b82f6" text="myapp">
|
|
253
|
+
{children}
|
|
254
|
+
</Watermark>
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
## OG Image
|
|
260
|
+
|
|
261
|
+
Next.js 14+는 `next/og`가 내장되어 있어 별도 설치 불필요. 그 외 환경은 `npm i @vercel/og`.
|
|
262
|
+
|
|
263
|
+
```tsx
|
|
264
|
+
// app/og/route.tsx
|
|
265
|
+
import { OGImage, loadPretendard } from "@m1kapp/kit";
|
|
266
|
+
import { ImageResponse } from "next/og";
|
|
267
|
+
|
|
268
|
+
export async function GET() {
|
|
269
|
+
const font = await loadPretendard();
|
|
270
|
+
|
|
271
|
+
return new ImageResponse(
|
|
272
|
+
<OGImage
|
|
273
|
+
type="default"
|
|
274
|
+
title="사이드 프로젝트 시작하기"
|
|
275
|
+
sub="빠르게 만들고 빠르게 배우는"
|
|
276
|
+
badge="🚀 NEW"
|
|
277
|
+
appName="myapp"
|
|
278
|
+
color="#3b82f6"
|
|
279
|
+
bg="dark" // "dark" | "gradient" | "blend"
|
|
280
|
+
domain="m1k.app"
|
|
281
|
+
/>,
|
|
282
|
+
{ width: 1200, height: 630, fonts: [font] }
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### 템플릿
|
|
288
|
+
|
|
289
|
+
| type | 크기 | 용도 |
|
|
290
|
+
|---|---|---|
|
|
291
|
+
| `default` | 1200×630 | 기본 OG |
|
|
292
|
+
| `article` | 1200×630 | 블로그 포스트 — author, date, category |
|
|
293
|
+
| `stat` | 1200×630 | 마일스톤 — stat, label |
|
|
294
|
+
| `product` | 1200×630 | 제품 소개 — tagline, features[] |
|
|
295
|
+
| `match` | 1200×630 | 경기 결과 — home, away, score |
|
|
296
|
+
| `square` | 1200×1200 | Instagram / SNS |
|
|
297
|
+
| `icon` | 512×512 | 앱 아이콘 / favicon |
|
|
298
|
+
|
|
299
|
+
```tsx
|
|
300
|
+
// article
|
|
301
|
+
<OGImage type="article" title="제목" author="minho" date="2025-04-19" category="Tutorial" sub="부제" color={c} bg={bg} />
|
|
302
|
+
|
|
303
|
+
// stat
|
|
304
|
+
<OGImage type="stat" stat="1,000" label="명의 방문자" sub="론칭 3일 만에" badge="🎉" color={c} bg={bg} />
|
|
305
|
+
|
|
306
|
+
// product
|
|
307
|
+
<OGImage type="product" title="@m1kapp/kit" tagline="올인원 킷" features={["기능1", "기능2"]} color={c} bg={bg} />
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### 폰트 & 이모지
|
|
311
|
+
|
|
312
|
+
```tsx
|
|
313
|
+
import { loadPretendard, loadGoogleFont, createEmojiLoader } from "@m1kapp/kit";
|
|
314
|
+
|
|
315
|
+
const pretendard = await loadPretendard(); // Pretendard 한국어 폰트
|
|
316
|
+
const roboto = await loadGoogleFont("Roboto", 700); // Google Fonts
|
|
317
|
+
const loadEmoji = createEmojiLoader("twemoji"); // 이모지 fallback
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## PWA
|
|
323
|
+
|
|
324
|
+
### Manifest
|
|
325
|
+
|
|
326
|
+
`public/manifest.json` 대신 코드로 관리. 아이콘 이미지 파일 불필요.
|
|
327
|
+
|
|
328
|
+
```ts
|
|
329
|
+
// app/manifest.ts
|
|
330
|
+
import { createManifest } from "@m1kapp/kit";
|
|
331
|
+
|
|
332
|
+
export default createManifest({
|
|
333
|
+
name: "My App",
|
|
334
|
+
shortName: "App",
|
|
335
|
+
description: "What this app does",
|
|
336
|
+
themeColor: "#3b82f6", // 아이콘 배경색으로도 사용
|
|
337
|
+
backgroundColor: "#ffffff",
|
|
338
|
+
icon: { text: "MA" }, // 텍스트로 192×192, 512×512 SVG 아이콘 자동 생성
|
|
339
|
+
});
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
### Viewport — 핀치 줌 차단
|
|
343
|
+
|
|
344
|
+
iOS 10+에서 핀치 줌과 인풋 자동 확대를 막습니다.
|
|
345
|
+
|
|
346
|
+
```ts
|
|
347
|
+
// app/layout.tsx
|
|
348
|
+
import { mobileViewport } from "@m1kapp/kit";
|
|
349
|
+
|
|
350
|
+
export const viewport = mobileViewport;
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
내부적으로 CSS `touch-action: pan-x pan-y`와 `input { font-size: max(16px, 1em) }`를 자동 적용합니다.
|
|
354
|
+
|
|
355
|
+
### SVG 아이콘
|
|
356
|
+
|
|
357
|
+
```ts
|
|
358
|
+
import { svgIcon } from "@m1kapp/kit";
|
|
359
|
+
|
|
360
|
+
const src = svgIcon("MA", { size: 192, bg: "#3b82f6", color: "#ffffff", radius: 0.25 });
|
|
361
|
+
// → "data:image/svg+xml,..." — <img src={src} /> 또는 manifest icons에 바로 사용
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### 앱 설치 유도
|
|
365
|
+
|
|
366
|
+
Android는 네이티브 설치 프롬프트, iOS는 홈 화면 추가 안내 시트를 자동으로 띄워줍니다.
|
|
367
|
+
|
|
368
|
+
```tsx
|
|
369
|
+
import { PWAInstallButton, IOSInstallSheet, usePWAInstall } from "@m1kapp/kit";
|
|
370
|
+
|
|
371
|
+
// 버튼 그대로 사용
|
|
372
|
+
<PWAInstallButton appName="My App" iconSrc={iconSrc} label="앱으로 설치" />
|
|
373
|
+
|
|
374
|
+
// 커스텀 UI
|
|
375
|
+
const { state, install } = usePWAInstall();
|
|
376
|
+
// state: "android-ready" | "ios-safari" | "installed" | "unsupported"
|
|
377
|
+
|
|
378
|
+
if (state === "android-ready") {
|
|
379
|
+
return <button onClick={install}>설치</button>;
|
|
380
|
+
}
|
|
381
|
+
if (state === "ios-safari") {
|
|
382
|
+
return <button onClick={() => setSheetOpen(true)}>설치</button>;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// iOS 안내 시트 (직접 제어 시)
|
|
386
|
+
<IOSInstallSheet open={sheetOpen} onClose={() => setSheetOpen(false)} appName="My App" iconSrc={iconSrc} />
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
---
|
|
390
|
+
|
|
391
|
+
## Fetch
|
|
392
|
+
|
|
393
|
+
의존성 제로. 캐싱 · 중복제거 · 재시도 · 포커스 revalidate가 내장된 fetch 유틸.
|
|
394
|
+
|
|
395
|
+
### useFetch
|
|
396
|
+
|
|
397
|
+
```tsx
|
|
398
|
+
import { useFetch } from "@m1kapp/kit";
|
|
399
|
+
|
|
400
|
+
const { data, loading, error, refetch } = useFetch<User[]>("/api/users", {
|
|
401
|
+
staleTime: 30_000, // 30초 캐시 — 같은 URL 중복 요청 없음
|
|
402
|
+
retry: 2, // 네트워크 오류 시 지수 백오프로 2회 재시도
|
|
403
|
+
revalidateOnFocus: true, // 탭 돌아오면 자동 최신 데이터
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// 로딩 처리
|
|
407
|
+
if (loading && !data) return <PostListSkeleton />;
|
|
408
|
+
if (error) return <p>{error.message}</p>;
|
|
409
|
+
return data?.map(u => <UserCard key={u.id} user={u} />);
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### usePolling
|
|
413
|
+
|
|
414
|
+
실시간 데이터, 라이브 스코어 등에 사용.
|
|
415
|
+
|
|
416
|
+
```tsx
|
|
417
|
+
import { usePolling } from "@m1kapp/kit";
|
|
418
|
+
|
|
419
|
+
const { data, isRunning, start, stop } = usePolling(
|
|
420
|
+
() => fetch("/api/match/live").then(r => r.json()),
|
|
421
|
+
{
|
|
422
|
+
interval: 5000, // 5초마다
|
|
423
|
+
enabled: true, // 시작 여부
|
|
424
|
+
pauseOnHidden: true, // 탭 숨기면 자동 정지 — 불필요한 요청 없음
|
|
425
|
+
}
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
<button onClick={() => isRunning ? stop() : start()}>
|
|
429
|
+
{isRunning ? "정지" : "시작"}
|
|
430
|
+
</button>
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
### createApiClient
|
|
434
|
+
|
|
435
|
+
baseURL과 공통 헤더를 한 번만 설정하면 타입 안전한 API 클라이언트가 만들어집니다.
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
// lib/api.ts
|
|
439
|
+
import { createApiClient, ApiError } from "@m1kapp/kit";
|
|
440
|
+
|
|
441
|
+
export const api = createApiClient("https://api.myapp.com", {
|
|
442
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
443
|
+
onError: (err) => {
|
|
444
|
+
if (err.status === 401) signOut();
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// 사용
|
|
449
|
+
const me = await api.get<User>("/users/me");
|
|
450
|
+
const post = await api.post<Post>("/posts", { title, body });
|
|
451
|
+
await api.put("/posts/1", { title: "수정된 제목" });
|
|
452
|
+
await api.delete("/posts/1");
|
|
453
|
+
|
|
454
|
+
// 에러는 ApiError로 정규화
|
|
455
|
+
try {
|
|
456
|
+
await api.delete("/posts/1");
|
|
457
|
+
} catch (e) {
|
|
458
|
+
if (e instanceof ApiError) {
|
|
459
|
+
console.log(e.status, e.body); // 404, { error: "Not found" }
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
---
|
|
465
|
+
|
|
466
|
+
## Server
|
|
467
|
+
|
|
468
|
+
Next.js API route 전용. `@m1kapp/kit/server`로 import — 클라이언트 번들에 포함되지 않습니다.
|
|
469
|
+
|
|
470
|
+
### handler()
|
|
471
|
+
|
|
472
|
+
try/catch 없이 에러를 처리합니다. `unauthorized()`, `notFound()` 등은 `never`를 반환하므로 TypeScript가 제어 흐름을 정확히 추론합니다.
|
|
473
|
+
|
|
474
|
+
```ts
|
|
475
|
+
import { handler, ok, created, unauthorized, forbidden, notFound, badRequest } from "@m1kapp/kit/server";
|
|
476
|
+
|
|
477
|
+
// Before ❌
|
|
478
|
+
export async function GET(req: Request) {
|
|
479
|
+
const user = await currentUser();
|
|
480
|
+
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
481
|
+
try {
|
|
482
|
+
const data = await db.sites.findMany({ where: { userId: user.id } });
|
|
483
|
+
return Response.json(data);
|
|
484
|
+
} catch {
|
|
485
|
+
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// After ✅
|
|
490
|
+
export const GET = handler(async () => {
|
|
491
|
+
const user = await currentUser();
|
|
492
|
+
if (!user) unauthorized(); // throws → 401
|
|
493
|
+
|
|
494
|
+
const data = await db.sites.findMany({ where: { userId: user.id } });
|
|
495
|
+
return ok(data); // 200 + JSON
|
|
496
|
+
// 처리되지 않은 에러 → 500 자동
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
export const POST = handler(async (req) => {
|
|
500
|
+
const user = await currentUser();
|
|
501
|
+
if (!user) unauthorized();
|
|
502
|
+
|
|
503
|
+
const { url } = await req.json();
|
|
504
|
+
if (!url) badRequest("url이 필요해요"); // throws → 400
|
|
505
|
+
|
|
506
|
+
const site = await db.sites.create({ data: { url, userId: user.id } });
|
|
507
|
+
return created(site); // 201 + JSON
|
|
508
|
+
});
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
### 응답 헬퍼
|
|
512
|
+
|
|
513
|
+
| 함수 | 상태 | 설명 |
|
|
514
|
+
|---|---|---|
|
|
515
|
+
| `ok(data)` | 200 | JSON 응답 |
|
|
516
|
+
| `created(data)` | 201 | 생성 완료 |
|
|
517
|
+
| `noContent()` | 204 | 본문 없음 |
|
|
518
|
+
| `badRequest(msg?)` | 400 | 잘못된 요청 |
|
|
519
|
+
| `unauthorized(msg?)` | 401 | 인증 필요 |
|
|
520
|
+
| `forbidden(msg?)` | 403 | 권한 없음 |
|
|
521
|
+
| `notFound(msg?)` | 404 | 리소스 없음 |
|
|
522
|
+
| `conflict(msg?)` | 409 | 충돌 |
|
|
523
|
+
| `serverError(msg?)` | 500 | 서버 오류 |
|
|
524
|
+
|
|
525
|
+
### safely()
|
|
526
|
+
|
|
527
|
+
특정 에러를 try/catch 없이 처리하고 싶을 때.
|
|
528
|
+
|
|
529
|
+
```ts
|
|
530
|
+
import { handler, ok, serverError, safely } from "@m1kapp/kit/server";
|
|
531
|
+
|
|
532
|
+
export const GET = handler(async () => {
|
|
533
|
+
const { ok: success, data, error } = await safely(() => db.users.findFirstOrThrow());
|
|
534
|
+
if (!success) return serverError("DB 조회 실패");
|
|
535
|
+
return ok(data);
|
|
536
|
+
});
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
---
|
|
540
|
+
|
|
541
|
+
## Utils
|
|
542
|
+
|
|
543
|
+
순수 함수 — 의존성 없음, 어디서나 import.
|
|
544
|
+
|
|
545
|
+
```ts
|
|
546
|
+
import { relativeTime, formatNumber, formatPrice, cn } from "@m1kapp/kit";
|
|
547
|
+
|
|
548
|
+
// 상대 시간
|
|
549
|
+
relativeTime(post.createdAt) // "3분 전", "어제", "2025. 4. 19."
|
|
550
|
+
|
|
551
|
+
// 숫자 포맷
|
|
552
|
+
formatNumber(1_500) // "1.5천"
|
|
553
|
+
formatNumber(15_000) // "1.5만"
|
|
554
|
+
formatNumber(150_000_000) // "1.5억"
|
|
555
|
+
|
|
556
|
+
// 가격 포맷
|
|
557
|
+
formatPrice(9_900) // "₩9,900"
|
|
558
|
+
formatPrice(9.99, "USD") // "$9.99"
|
|
559
|
+
|
|
560
|
+
// 조건부 클래스
|
|
561
|
+
cn("base", isActive && "active", err && "border-red-500")
|
|
562
|
+
// → "base active"
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
## Hooks
|
|
566
|
+
|
|
567
|
+
```ts
|
|
568
|
+
import { useDebounce, useFormSubmit, useInView, useLocalStorage } from "@m1kapp/kit";
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
### useDebounce
|
|
572
|
+
|
|
573
|
+
```ts
|
|
574
|
+
const [query, setQuery] = useState("");
|
|
575
|
+
const debouncedQuery = useDebounce(query, 300);
|
|
576
|
+
|
|
577
|
+
useEffect(() => {
|
|
578
|
+
if (debouncedQuery) searchAPI(debouncedQuery); // 타이핑 멈출 때만 실행
|
|
579
|
+
}, [debouncedQuery]);
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
### useFormSubmit
|
|
583
|
+
|
|
584
|
+
모든 form handler의 loading / error / try-catch / finally 보일러플레이트를 제거합니다.
|
|
585
|
+
|
|
586
|
+
```ts
|
|
587
|
+
const { submit, loading, error, data, reset } = useFormSubmit(
|
|
588
|
+
async (url: string) => api.post<Site>("/api/sites", { url }),
|
|
589
|
+
{ onSuccess: (site) => router.push(`/sites/${site.id}`) }
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
<form onSubmit={e => { e.preventDefault(); submit(inputValue); }}>
|
|
593
|
+
<input value={inputValue} onChange={...} />
|
|
594
|
+
{error && <p className="text-red-500 text-sm">{error.message}</p>}
|
|
595
|
+
<button disabled={loading}>{loading ? "등록 중…" : "등록"}</button>
|
|
596
|
+
</form>
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
### useInView
|
|
600
|
+
|
|
601
|
+
무한스크롤 트리거, 레이지 로드, 등장 애니메이션에 사용.
|
|
602
|
+
|
|
603
|
+
```tsx
|
|
604
|
+
const { ref, inView } = useInView({ threshold: 0.1, once: true });
|
|
605
|
+
|
|
606
|
+
useEffect(() => {
|
|
607
|
+
if (inView) fetchNextPage();
|
|
608
|
+
}, [inView]);
|
|
609
|
+
|
|
610
|
+
return (
|
|
611
|
+
<div>
|
|
612
|
+
{posts.map(p => <PostCard key={p.id} post={p} />)}
|
|
613
|
+
<div ref={ref} /> {/* 리스트 맨 아래 센티넬 */}
|
|
614
|
+
</div>
|
|
615
|
+
);
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
### useLocalStorage
|
|
619
|
+
|
|
620
|
+
새로고침 후에도 유지되는 로컬 상태. SSR 안전.
|
|
621
|
+
|
|
622
|
+
```ts
|
|
623
|
+
const [theme, setTheme, removeTheme] = useLocalStorage("theme", "light");
|
|
624
|
+
|
|
625
|
+
setTheme("dark"); // localStorage에 저장
|
|
626
|
+
removeTheme(); // localStorage에서 삭제, 초기값으로 복원
|
|
627
|
+
```
|