์ฌ์ด๋ ํ๋ก์ ํธ๋ฅผ ์ํ UI ยท OG ยท PWA ยท Fetch ยท Utils ์ฌ์ธ์ ํท.
npm install @m1kapp/kitPeer dependencies: react >= 18, react-dom >= 18
Optional: @vercel/og >= 0.6 (Next.js ์ธ ํ๊ฒฝ์์ OG ์ด๋ฏธ์ง ์์ฑ ์)
// CSS ์๋ ์ฃผ์
โ ๋ณ๋ import ๋ถํ์
import { Watermark, AppShell, AppShellHeader, AppShellContent, Section, TabBar, Tab } from "@m1kapp/kit";
export default function App() {
return (
// Watermark๊ฐ ๋ทฐํฌํธ ์ ์ฒด ๋์ด/์ค์์ ๋ ฌ/ํ๋จ ํฌ๋ ๋ง์ ๊ฐ์ด ์ค๋๋ค.
// (ํฌ๋ ๋ง ์คํธ๋ฆฝ์ ์
ธ ๋ฐ๋ก ๋ฐ์ ๋ถ์ด์ ์
ธ๊ณผ ํ ๋ฉ์ด๋ฆฌ๋ก ์ค์์ ๋ ฌ๋จ โ ํ๋ฉด ์ง์ง ๋ฐ๋ฅ์ ๊ณ ์ ๋๋ ๊ฒ ์๋)
// AppShell๋ง ๋จ๋
์ผ๋ก ์ฐ๋ฉด ์ฝํ
์ธ ๋์ด๋งํผ ์ชผ๊ทธ๋ผ๋ค์ด์.
<Watermark color="#e2603f" text="myapp">
<AppShell>
<AppShellHeader>
<h1>My App</h1>
</AppShellHeader>
<AppShellContent>
<Section className="pt-4">{/* ์ฝํ
์ธ โ Divider๋ก ์น์
๊ตฌ๋ถ */}</Section>
</AppShellContent>
<TabBar>
<Tab href="/" icon={<HomeIcon />} label="ํ" />
</TabBar>
</AppShell>
</Watermark>
);
}InAppSheet ์์์ ๋ค์ InAppSheet๋ฅผ ์ด๋ฉด ๊ฐ์ AppShell์ ์ํธ๊ฐ ์๋์ผ๋ก ์์
๋๋ค. ๊ธฐ์กด open ยท onClose API ๊ทธ๋๋ก ์ฌ์ฉํฉ๋๋ค.
<InAppSheet open={open} onClose={() => setOpen(false)} title="AI ํผํ
" fullHeight>
<button onClick={() => setBrandOpen(true)}>๋ธ๋๋ ๊ณ ๋ฅด๊ธฐ</button>
<InAppSheet open={brandOpen && open} onClose={() => setBrandOpen(false)} title="๋ธ๋๋ ์ ํ" fullHeight>
<button onClick={() => setBrandOpen(false)}>์ ํํ๊ณ ๋์๊ฐ๊ธฐ</button>
</InAppSheet>
</InAppSheet>๋ค ์ํธ๋ ์ถ์๋๊ณ ํฐ์นยท์คํฌ๋กคยทํค๋ณด๋ ์ ๊ทผ์ด ์ ๊น๋๋ค. ๋ซ๊ธฐยท์ค์์ดํยทEscยท๋ธ๋ผ์ฐ์ ๋ค๋ก๊ฐ๊ธฐ๋ ๋งจ ์ ์ํธ๋ง ๋ซ์ต๋๋ค. ์๋ ์ํธ๋ ๊ณ์ ๋ง์ดํธ๋์ด ์ ํยท์
๋ ฅยท์คํฌ๋กค์ด ์ ์ง๋๊ณ , ๋์์ค๋ฉด ์ด์๋ ๋ฒํผ์ผ๋ก ํฌ์ปค์ค๊ฐ ๋ณต๊ทํฉ๋๋ค. ๋ถ๋ชจ๋ฅผ ๋ซ์ ๋๋ ์์์ open๋ ํจ๊ป ๊บผ ์ฃผ์ธ์.
์ํธ๋ง๋ค ํ์ฌ URL์ history ํญ๋ชฉ์ ์ถ๊ฐํ๋ฉฐ ๊ธฐ์กด history.state๋ ๋ณด์กดํฉ๋๋ค. ํ๋ฉด ์์ฒด๋ฅผ ๋ฐ๊พธ๋ ๋ผ์ฐํฐ ํ์๊ณผ ๋ณ๊ฐ์ธ ์ํธ์ฉ ๋ค๋ก๊ฐ๊ธฐ์
๋๋ค. ๋ฐ๋ชจ: ๋ผ์ด๋ธ๋ฌ๋ฆฌ โ UI โ InAppSheet โ 2๋จ ์ํธ ์ฒดํํ๊ธฐ.
ํ๋์ import๋ฉด ๋. UI ยท OG ยท PWA ยท Fetch ยท Utils๊ฐ ์ ๋ถ
@m1kapp/kit๋ฉ์ธ export์ ๋ค์ด ์์ต๋๋ค./server๋ง ๋ณ๋ ์๋ธํจ์ค์์. ์๋ธํจ์ค๋ฅผ ์ผ์ผ์ด ์ธ์ธ ํ์ ์์ดimport { ... } from "@m1kapp/kit"ํ๋๋ก ๋ค ๊บผ๋ด ์ฐ์ธ์.
| ๋ชจ๋ | import | ์ค๋ช |
|---|---|---|
| UI | @m1kapp/kit |
์ปดํฌ๋ํธ 45๊ฐ + ํ |
| OG Image | @m1kapp/kit |
OG ์ด๋ฏธ์ง ์์ฑ (์๋ฒ) |
| PWA | @m1kapp/kit |
manifest, viewport, ์ค์น ์ ๋ |
| Fetch | @m1kapp/kit |
์บ์ฑยท์ค๋ณต์ ๊ฑฐยท์ฌ์๋ fetch ์ ํธ |
| Utils | @m1kapp/kit |
๋ ์งยท์ซ์ ํฌ๋งท, ๋ฒ์ฉ ํ |
| Server | @m1kapp/kit/server |
Next.js API route ํธ๋ค๋ฌ ์ ํธ |
์์ผ๋ก ๋ง๋ค๊ธฐ ์ ์ ๋จผ์ ์ฐพ์๋ณด์ธ์. ํํ ํ๋ฉด์ ๋๋ถ๋ถ ์กฐํฉ์ผ๋ก ๋๋ฉ๋๋ค.
| ํ๊ณ ์ถ์ ๊ฒ | ์ด๋ ๊ฒ |
|---|---|
| ๋ชจ๋ฐ์ผ ์ฑ ์ ธ | AppShell + AppShellHeader / AppShellContent + TabBar/Tab + Watermark |
| ๋ชฉ๋ก + ๋ก๋ฉ + ๋น ํ๋ฉด | useFetch โ ๋ก๋ฉ์ด๋ฉด Skeleton, ๋น ๋ฐฐ์ด์ด๋ฉด EmptyState |
| ํผ ์ ์ฅ ํ ํผ๋๋ฐฑ | useFormSubmit + useToast (์ธ๋ผ์ธ ํ
์คํธ ๋์ ํ ์คํธ) |
| ์ค์ ํ๋ฉด | Section + Field(inline) + Switch |
| ์ธ๋ผ์ธ ํ ๊ธ (์ค๋/์ด๋ฒ์ฃผ) | SegmentedControl (ํ๋จ ๊ธ๋ก๋ฒ ๋ด๋น๋ TabBar) |
| ์ฑํ / AI ๋น์ | MessageList + ChatBubble + TypingIndicator |
| "์ด๋ ๊ฒ ํ ๊น์?" ํ์ธ | ActionCard (๋ฉ์์ง ํ๋ฆ ์), ๋ชจ๋ฌ์ด๋ฉด Dialog |
| ์ผ์ / ํ์๋ผ์ธ ํ | ListRow (sizeByMinutes๋ก ์์์๊ฐ ๋น๋ก ๋์ด) |
| ๋ฉ๋ชจ ์ URL ๋งํฌ | LinkifiedText |
| API ํธ์ถ | createApiClient (get/post/put/delete + ApiError) |
| ์ฃผ๊ธฐ์ ๊ฐฑ์ | usePolling (pauseOnHidden) |
| ํ ๋ง์ ํ ๋ฒ์ | <AppShell accent="#e2603f"> โ ๋ชจ๋ ์ปดํฌ๋ํธ๊ฐ --kit-accent๋ก ๋ฐ๋ผ์ด |
CSS๊ฐ import ์ ์๋ ์ฃผ์ ๋ฉ๋๋ค. ๋ณ๋ ์คํ์ผ์ํธ import ๋ถํ์.
import { AppShell, AppShellHeader, AppShellContent } from "@m1kapp/kit";
<AppShell> // ์ต๋ 430px ์ค์ ์ ๋ ฌ ๋ชจ๋ฐ์ผ ์ปจํ
์ด๋
<AppShellHeader>...</AppShellHeader> // ์๋จ sticky ํค๋
<AppShellContent>...</AppShellContent> // ์คํฌ๋กค ๊ฐ๋ฅํ ๋ณธ๋ฌธ
</AppShell>import { TabBar, Tab } from "@m1kapp/kit";
<TabBar>
<Tab
active={tab === "home"}
onClick={() => setTab("home")}
label="ํ"
icon={<HomeIcon />}
activeColor="#3b82f6" // ํ์ฑ ์์ ์์ ๋กญ๊ฒ ์ง์
/>
</TabBar>import { Avatar, Badge, StatChip, EmptyState, GrassMap } from "@m1kapp/kit";
// Avatar โ ์ด๋์
or ์ด๋ฏธ์ง, ์ด๋ฏธ์ง ๋ก๋ ์คํจ ์ ์ด๋์
๋ก ์๋ fallback
<Avatar src="/photo.jpg" fallback="MH" size="md" shape="circle" />
<Avatar fallback="MH" size="lg" shape="rounded" color="#3b82f6" />
// size: "xs" | "sm" | "md" | "lg" | "xl"
// shape: "circle" | "rounded"
// Badge โ ์ํ/์นดํ
๊ณ ๋ฆฌ ๋ ์ด๋ธ
<Badge variant="green">LIVE</Badge>
<Badge variant="red">์ค๋ฅ</Badge>
<Badge variant="blue" size="sm">์ ๋ณด</Badge>
// variant: "default" | "green" | "red" | "yellow" | "blue" | "purple" | "orange"
// StatChip โ ์ซ์ stat ๋ฑ์ง
<StatChip label="๋ฐฉ๋ฌธ์" value={1024} />
// EmptyState โ ๋น ๋ชฉ๋ก ํ๋ ์ด์คํ๋
<EmptyState message="์์ง ์๋ฌด๊ฒ๋ ์์ด์" />
// GrassMap โ GitHub ์คํ์ผ ํ๋ ํํธ๋งต
<GrassMap data={[{ date: "2025-04-19", count: 42 }]} accent="#3b82f6" />๋ก๋ฉ ํ๋ ์ด์คํ๋. className์ผ๋ก ํฌ๊ธฐ๋ฅผ ์ง์ ํฉ๋๋ค.
import { Skeleton } from "@m1kapp/kit";
// ํ
์คํธ ์ค
<Skeleton className="h-4 w-3/4" />
// ์นด๋ ๋ธ๋ก
<Skeleton className="h-32 w-full" rounded="xl" />
// ์๋ฐํ
<Skeleton className="h-10 w-10" rounded="full" />
// ์ค์ ํจํด
function PostCardSkeleton() {
return (
<div className="flex gap-3 p-4">
<Skeleton className="h-10 w-10" rounded="full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
);
}backdrop ํด๋ฆญ, ESC ํค, ์คํฌ๋กค ์ ๊ธ ์๋ ์ฒ๋ฆฌ.
import { Dialog } from "@m1kapp/kit";
// ๊ธฐ๋ณธ ์ฌ์ฉ
<Dialog open={open} onClose={() => setOpen(false)} title="์ค์ ">
<p className="text-sm text-zinc-500">๋ด์ฉ</p>
</Dialog>
// ํ์ธ ๋ค์ด์ผ๋ก๊ทธ
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)} title="์ญ์ ํ ๊น์?">
<p className="text-sm text-zinc-500">์ด ์์
์ ๋๋๋ฆด ์ ์์ด์.</p>
<div className="flex gap-2 mt-4">
<button onClick={handleDelete} className="...">์ญ์ </button>
<button onClick={() => setConfirmOpen(false)} className="...">์ทจ์</button>
</div>
</Dialog>
// size: "sm" (๊ธฐ๋ณธ) | "md" | "lg"
// persistent: true โ backdrop ํด๋ฆญ / ESC๋ก ๋ซ๊ธฐ ๋นํ์ฑํimport { Button, Tooltip, Typewriter, EmojiButton, EmojiPicker } from "@m1kapp/kit";
<Button onClick={fn}>์์ํ๊ธฐ</Button>
<Tooltip label="์ค๋ช
ํ
์คํธ">
<button>hover me</button>
</Tooltip>
<Typewriter words={["Hello", "World"]} color="#3b82f6" />
// ์ด๋ชจ์ง ์ ํ๊ธฐ
const [emoji, setEmoji] = useState("๐ ");
const [open, setOpen] = useState(false);
<EmojiButton emoji={emoji} onClick={() => setOpen(true)} />
<EmojiPicker open={open} onClose={() => setOpen(false)} current={emoji} onSelect={setEmoji} />import { ShareButton, useShare } from "@m1kapp/kit";
// ๋ฒํผ ๊ทธ๋๋ก ์ฌ์ฉ โ ๋ชจ๋ฐ์ผ์ ๋ค์ดํฐ๋ธ ๊ณต์ , ๋ฐ์คํฌํ์ ํด๋ฆฝ๋ณด๋ ๋ณต์ฌ
<ShareButton url="https://m1k.app" title="My App" />
// ์ค์นํ ์ ์๋ ๊ธฐ๊ธฐ๋ฉด "๋งํฌ ๋ณด๋ด๊ธฐ ยท ํ ํ๋ฉด์ ์ถ๊ฐ" ์ํธ๋ฅผ ๋์ด๋ค(AppShell ์์์)
<ShareButton url="https://m1k.app" title="My App" install={{ appName: "My App", iconSrc: "/icon-192.png" }} />
// ์ปค์คํ
UI
const { share, copied, canNativeShare } = useShare({ url: "https://m1k.app" });
<button onClick={() => share()}>{copied ? "๋ณต์ฌ๋จ!" : "๊ณต์ "}</button>import { ToastProvider, useToast } from "@m1kapp/kit";
// ์ฑ ๋ฃจํธ ๊ฐ์ธ๊ธฐ
<ToastProvider>
<App />
</ToastProvider>
// ์ด๋์๋
const toast = useToast();
toast("์ ์ฅ๋์ด์!", { variant: "success" });
toast("์ค๋ฅ๊ฐ ๋ฐ์ํ์ด์.", { variant: "error", duration: 4000 });
toast("๋งํฌ๊ฐ ๋ณต์ฌ๋์ด์."); // default (dark)
// variant: "default" | "success" | "error" | "info"๋คํฌ๋ชจ๋ + ์ปฌ๋ฌ ํ ๋ง ์ ํ๊ธฐ.
import { ThemeButton, ThemeDialog, THEME_SCRIPT, colors } from "@m1kapp/kit";
// layout.tsx โ ๋คํฌ๋ชจ๋ ๊น๋นก์ ๋ฐฉ์ง
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
</head>
// ํ
๋ง ๋ฒํผ + ๋ค์ด์ผ๋ก๊ทธ
const [themeOpen, setThemeOpen] = useState(false);
<ThemeButton color={color} dark={dark} onClick={() => setThemeOpen(true)} />
<ThemeDialog
open={themeOpen}
onClose={() => setThemeOpen(false)}
current={color}
onSelect={setColor}
dark={dark}
onDarkToggle={() => setDark(v => !v)}
/>
// ํ๋ ํธ
colors.blue // "#3b82f6"
colors.purple // "#a855f7"
colors.green // "#22c55e"
// blue | purple | green | orange | pink | red | yellow | cyan | slate | zinc์์ ์๋ OK. ํ๋ ํธ๋ ํ๋ฆฌ์
์ผ ๋ฟ์ด๊ณ , ThemeDialog์ palette / onSelect๋ ์ด๋ค hex๋ ๋ฐ์ต๋๋ค. ๊ทธ๋ฆฌ๊ณ SwitchยทSegmentedControlยทChatBubbleยทActionCardยทListRowยทLinkifiedText์ accent๋ --kit-accent CSS ๋ณ์๋ฅผ ๋ฐ๋ผ๊ฐ๋ฏ๋ก, ํ ๊ณณ๋ง ๋ฐ๊พธ๋ฉด ์ ์ฒด ํค์ด ๋ฐ๋๋๋ค.
// โ AppShell accent prop โ ๊ฐ์ฅ ์ฌ์ (ํ์ ์ ์ฒด์ ์ ์ฉ)
<AppShell accent="#e2603f">...</AppShell>
// โก ์๋ฌด ๋ํผ์๋ CSS ๋ณ์๋ก
<div style={{ "--kit-accent": "#e2603f" } as React.CSSProperties}>...</div>
// โข ์ปดํฌ๋ํธ๋ณ override
<Switch checked={on} onChange={setOn} accent="#e2603f" />import { Field, Switch } from "@m1kapp/kit";
// ๋ผ๋ฒจ๋ ์ธํ (stacked ๊ธฐ๋ณธ / inline / multiline)
<Field label="์ด๋ฆ" value={name} onChange={setName} placeholder="์ด๋ฆ" />
<Field label="์ด๋ฉ์ผ" value={email} inline readOnly />
<Field label="๋ฉ๋ชจ" value={memo} onChange={setMemo} multiline rows={3} hint="์์ ๋กญ๊ฒ ์ ์ด์ฃผ์ธ์" />
// on/off ํ ๊ธ โ accent๋ --kit-accent ์๋ ์ฐ๋
<Switch checked={on} onChange={setOn} aria-label="์๋ฆผ" />import { SegmentedControl } from "@m1kapp/kit";
<SegmentedControl
value={view}
onChange={setView}
options={[{ value: "today", label: "์ค๋" }, { value: "week", label: "์ด๋ฒ ์ฃผ" }]}
/>
// ์ธ๋ผ์ธ ํ ๊ธ์ฉ. ํ๋จ ๊ธ๋ก๋ฒ ๋ด๋น๊ฒ์ด์
์ TabBar๋ฅผ ์ฐ์ธ์.import { MessageList, ChatBubble, TypingIndicator } from "@m1kapp/kit";
import type { ChatMessage } from "@m1kapp/kit";
const messages: ChatMessage[] = [
{ role: "user", content: "๋ด์ผ 3์ ํ์ ์ก์์ค", timestamp: Date.now() },
{ role: "assistant", content: "๋ค, ์ก์๋๋ฆด๊ฒ์." },
];
// dayDivider: timestamp ๊ธฐ์ค ๋ ์ง๊ฐ ๋ฐ๋๋ฉด ๊ตฌ๋ถ์ ์๋ ์ฝ์
<MessageList messages={messages} dayDivider>
{pending && <TypingIndicator />}
</MessageList>
// ์ง์ ์ฐ๋ ค๋ฉด
<ChatBubble role="user">์ค๋ ์ผ์ ๋ณด์ฌ์ค</ChatBubble>
<ChatBubble role="assistant">3๊ฑด ์์ด์.</ChatBubble>LLM ํด์ฝ์ฒ๋ผ "์ ์ โ ํ์ธ โ ์คํ" ํ๋ฆ์ ๋ฉ์์ง ์์ ๋ฐ์ ๋. ๋ชจ๋ฌ์ด ์๋๋ผ ํ๋ฆ์ ์ธ๋ผ์ธ๋ฉ๋๋ค.
import { ActionCard } from "@m1kapp/kit";
const [state, setState] = useState<"pending" | "loading" | "done" | "cancelled">("pending");
<ActionCard
title="์ด๋ ๊ฒ ๊ธฐ๋กํด๋๊น์?"
state={state}
items={["๐ 6/4 15:00 ๋์์ธ ๋ฆฌ๋ทฐ", "๐ 6/4 (์ข
์ผ) ๋ง๊ฐ์ผ"]}
onConfirm={() => { setState("loading"); /* โฆ */ setState("done"); }}
onCancel={() => setState("cancelled")}
/>
// state๋ณ ์/๋ฌธ๊ตฌ ์๋: pending โ done(์ด๋ก) / cancelled(์ทจ์์ ) / loadingimport { ListRow } from "@m1kapp/kit";
<ListRow
accent="#7fc06a" // ์ผ์ชฝ ์ปฌ๋ฌ๋ฐ (์๋ต ์ --kit-accent)
lead="14:00" leadSub="15:00"
title="๋์์ธ ๋ฆฌ๋ทฐ" sub="๐ฅ ๊น์ํ"
trailing="โ ์ง๊ธ" active // ํ์ฌ ํญ๋ชฉ ๊ฐ์กฐ
heightScale={60 / 30} // ๋์ด ๋ฐฐ์จ (1=๊ธฐ๋ณธ, 46โ130px). ์ผ์ ์ ๋ถ/30์ ๋๊น
onClick={open}
/>import { LinkifiedText } from "@m1kapp/kit";
<LinkifiedText>{"ํ์๋ก: https://docs.google.com/โฆ\nํ์: meet.google.com/abc-def"}</LinkifiedText>
// http(s) URL + (๊ธฐ๋ณธ) meet.google.com ๊ฐ์ ๋๋ฉ์ธ/๊ฒฝ๋ก๋ฅผ ์๋ ๋งํฌ. ์ค๋ฐ๊ฟ ๋ณด์กด.import { Stepper, Collapsible, ProgressRing } from "@m1kapp/kit";
// ๋ค๋จ๊ณ ์งํ ํ์ (์์ ๋/๋ฉํฐํ์ด์ฆ ํ๋ก์ฐ)
<Stepper current={phase} onStepClick={setPhase} steps={[
{ label: "๋ถ์", icon: "๐" }, { label: "์ค๋น", icon: "๐ธ" }, { label: "์์ฑ", icon: "โจ" },
]} />
// ์ ๊ธฐ ์นด๋ (ํค๋+๋ฐฐ์ง+์ํ)
<Collapsible leading={1} title="ํ๋ฅด์๋ ๋ถ์" subtitle="์คํ์ผ ํ์
" completed
open={open === 1} onToggle={() => setOpen(open === 1 ? null : 1)}>
<p>๋ณธ๋ฌธโฆ</p>
</Collapsible>
// ์ํ ์งํ๋ฅ
<ProgressRing value={7} max={10}><span className="text-2xl font-black">7</span></ProgressRing>import { Select, ColorPicker, InlineEdit } from "@m1kapp/kit";
// ์ต์ปค๋ ๋๋กญ๋ค์ด (์นด์ดํธยท๋นํ์ฑ ์ต์
, ์ธ๋ถํด๋ฆญ/ESC ๋ซํ)
<Select value={cat} onChange={setCat} placeholder="๋์ด๋" options={[
{ value: "a", label: "์ด๊ธ", count: 12 }, { value: "b", label: "๊ณ ๊ธ", count: 0, disabled: true },
]} />
// ์ปฌ๋ฌ ํผ์ปค (ํ๋ฆฌ์
+ ์ปค์คํ
hex)
<ColorPicker value={color} onChange={setColor} />
// ํญํ์ฌ ํธ์ง (Enter ์ ์ฅ ยท Esc ์ทจ์)
<InlineEdit value={name} onChange={rename} className="text-lg font-bold" />import { CopyButton, CodeBlock, useCopy, BarList, Countdown, Carousel, Img } from "@m1kapp/kit";
<CopyButton text="npm i @m1kapp/kit">์ค์น ๋ณต์ฌ</CopyButton>
<CodeBlock label="install" code="npm i @m1kapp/kit" />
const { copied, copy } = useCopy(); // keyed: copy(text, "row-3")
// ๊ฐ๋ก ๋ง๋ ๋ถ์ ์ฐจํธ
<BarList items={[{ label: "/", value: 120 }, { label: "/about", value: 64, href: "/about" }]} />
// ์นด์ดํธ๋ค์ด (D-day)
<Countdown to="2026-12-31" onComplete={celebrate} />
// ์ค์์ดํ ์บ๋ฌ์
(controlled, ์ ์ธ๋์ผ์ดํฐ)
<Carousel count={slides.length} index={i} onChange={setI}>{slides[i]}</Carousel>
// ๋ค์ค URL ํด๋ฐฑ ์ด๋ฏธ์ง
<Img candidates={[avatarUrl, gravatar]} fallback={<Avatar fallback="MH" />} className="h-10 w-10 rounded-full" />import { Watermark } from "@m1kapp/kit";
<Watermark color="#3b82f6" text="myapp">
{children}
</Watermark>Watermark๋ ํ๋จ์ PoweredByKit ํฌ๋ ๋ง์ ์๋ ๋ด์ฅํฉ๋๋ค.
Watermark/PoweredByKit ํ๋จ ํฌ๋ ๋ง์ด ๋ฐฉ๋ฌธ์ ์๋ฅผ ์ง๊ณํ ์ ์์ต๋๋ค. slug๊ฐ ์์ ๋๋ง ์ผ์ง๊ณ , ์์ผ๋ฉด ์๋ฌด๊ฒ๋ ์ ์กํ์ง ์์์(๊ธฐ๋ณธ off). ์ง๊ณ๋ ํ์ด์ง๋ทฐ ์นด์ดํธ๋ฟ โ PII ์์.
# 1) ์ฌ์ดํธ ๋ฑ๋ก (๋ฌด๋ก๊ทธ์ธ) โ slug ๋ฐ๊ธ
npx m1kkit track https://myside.app# 2) Next.js๋ฉด .env ์ ํ ์ค โ ์ดํ ์๋ ์ง๊ณ (์ค๋ํซ ๋ถ์ฌ๋ฃ๊ธฐ ๋ถํ์)
NEXT_PUBLIC_M1K_SLUG=your-slugVite ๋ฑ Next.js๊ฐ ์๋ ์ฑ์ NEXT_PUBLIC_* env๊ฐ ์ ์ฝํ๋ trackSlug prop์ผ๋ก ์ง์ ์ง์ ํ์ธ์:
// ๋๊ธฐ / ๋ช
์ ์ง์
<Watermark track={false}>โฆ</Watermark> // ๋น์ฝ ๋
<Watermark trackSlug="your-slug">โฆ</Watermark> // env ๋์ ์ง์ ์ง์ (Vite๋ ์ด ๋ฐฉ์ ์ฌ์ฉ)
// PoweredByKit ๋จ๋
์ฌ์ฉ ์: <PoweredByKit slug="your-slug" track={false} />
npx m1kkit claim์ผ๋ก ์ต๋ช ๋ฑ๋ก ์ฌ์ดํธ๋ฅผ ๋ด m1k.app ๊ณ์ ์ ๊ท์ํ ์ ์์ด์. (.m1k.json์ ์ผํ์ฉ ํ ํฐ + ๋ก๊ทธ์ธ์ผ๋ก ์ธ์ฆ.) ๊ท์ํ๋ ๊น์ kit์ด ์ด ํ๋ก์ ํธ์์ ์ฝ๋๋ฅผ ์ผ๋ง๋ ์๊ปด์คฌ๋์ง ๋ถ์๋ ๊ฐ์ด ์ถ๋ ฅํด์ โ--no-stats๋ก ๋ ์ ์์ด์.
Next.js 14+๋ next/og๊ฐ ๋ด์ฅ๋์ด ์์ด ๋ณ๋ ์ค์น ๋ถํ์. ๊ทธ ์ธ ํ๊ฒฝ์ npm i @vercel/og.
// app/og/route.tsx
import { OGImage, loadPretendard } from "@m1kapp/kit";
import { ImageResponse } from "next/og";
export async function GET() {
const font = await loadPretendard();
return new ImageResponse(
<OGImage
type="default"
title="์ฌ์ด๋ ํ๋ก์ ํธ ์์ํ๊ธฐ"
sub="๋น ๋ฅด๊ฒ ๋ง๋ค๊ณ ๋น ๋ฅด๊ฒ ๋ฐฐ์ฐ๋"
badge="๐ NEW"
appName="myapp"
color="#3b82f6"
bg="dark" // "dark" | "gradient" | "blend"
domain="m1k.app"
/>,
{ width: 1200, height: 630, fonts: [font] }
);
}| type | ํฌ๊ธฐ | ์ฉ๋ |
|---|---|---|
default |
1200ร630 | ๊ธฐ๋ณธ OG |
article |
1200ร630 | ๋ธ๋ก๊ทธ ํฌ์คํธ โ author, date, category |
stat |
1200ร630 | ๋ง์ผ์คํค โ stat, label |
product |
1200ร630 | ์ ํ ์๊ฐ โ tagline, features[] |
match |
1200ร630 | ๊ฒฝ๊ธฐ ๊ฒฐ๊ณผ โ home, away, score |
square |
1200ร1200 | Instagram / SNS |
icon |
512ร512 | ์ฑ ์์ด์ฝ / favicon |
// article
<OGImage type="article" title="์ ๋ชฉ" author="minho" date="2025-04-19" category="Tutorial" sub="๋ถ์ " color={c} bg={bg} />
// stat
<OGImage type="stat" stat="1,000" label="๋ช
์ ๋ฐฉ๋ฌธ์" sub="๋ก ์นญ 3์ผ ๋ง์" badge="๐" color={c} bg={bg} />
// product
<OGImage type="product" title="@m1kapp/kit" tagline="์ฌ์ธ์ ํท" features={["๊ธฐ๋ฅ1", "๊ธฐ๋ฅ2"]} color={c} bg={bg} />Vite ๋ฑ ์๋ฒ ์๋ SPA๋ผ app/og/route.tsx๋ฅผ ๋ชป ๋ง๋ ๋ค๋ฉด, ๋น๋ ์์ด CLI๋ก
ํ๋น์ฝ๋ถํฐ OG ์ด๋ฏธ์ง, manifest๊น์ง ํ ๋ฒ์ ์์ฑํ์ธ์:
npx m1kkit favicon --appname="My App" --slogan="ํ ์ค ์๊ฐ" --color=#3b82f6 --domain=myapp.comfavicon.ico ยท apple-touch-icon.png ยท icon-192.png ยท icon-512.png ยท
icon-maskable-512.png ยท og-image.png(1200ร630, ์ default ํ
ํ๋ฆฟ ์ฌ์ฉ) ยท
manifest.json 7๊ฐ๊ฐ public/(์๋ ๊ฐ์ง)์ ํ ๋ฒ์ ์๊ธฐ๊ณ , <head>์ ๋ถ์ฌ๋ฃ์
ํ๊ทธ ์ค๋ํซ๋ ์ฝ์์ ์ถ๋ ฅ๋ฉ๋๋ค.
favicon ๋ค์์ ํ ๋ฒ ๋๋ฆฌ๋ฉด ํ ํ๋ฉด ์ฑ์ด ๋นํ๊ธฐ ๋ชจ๋์์๋ ์ด๋ฆฌ๊ณ , ์์ดํฐ์์ ํฐ ํ๋ฉด ๋์ ์คํ๋์๊ฐ ๋น๋๋ค.
npx m1kkit pwa # public/icon-512.png ๋ฅผ ํฐ ๋ฐฐ๊ฒฝ ๊ฐ์ด๋ฐ์
npx m1kkit pwa --bg=#0f0f1a # ์ฑ ์ฒซ ํ๋ฉด ์๊ณผ ๋ง์ถ๋ค(๋ค๋ฅด๋ฉด ์ด๋ฆด ๋ ๋ฒ์ฉ์ธ๋ค)sw.js ยท splash/<๊ฐ๋ก>x<์ธ๋ก>.png 13์ข
์ด ์๊ธฐ๊ณ , manifest.json ์ background_color(์๋๋ก์ด๋ ์คํ๋์)๋ฅผ ๊ฐ์ ์์ผ๋ก ๋ง์ถฅ๋๋ค.
Vite ๋ index.html <head> ์ ํ๊ทธ๊ฐ ๋ค์ด๊ฐ๊ณ (๋ค์ ๋๋ฆฌ๋ฉด ๊ต์ฒด), Next ๋ ์ถ๋ ฅ๋ ํ๊ทธ๋ฅผ layout ์ ๋ถ์ด์ธ์.
์๋น์ค์์ปค๋ localhost ์์ ๋ฑ๋กํ์ง ์์ต๋๋ค โ ํ์ธ์ ๋ฐฐํฌ ๋ค์.
<PWAInstallButton variant="chip" label="์ค์น" appName="ํ์จ๊ณ์ฐ๊ธฐ" iconSrc="/icon-192.png" />์ค์นํ ์ ์์ ๋๋ง ๋ณด์ด๊ณ , ๊ธฐ๊ธฐ๋ง๋ค ์์์ ๋ค๋ฅด๊ฒ ์๋ดํฉ๋๋ค. ๊ณ ์น ์ผ์ kit ์์ ๊ณ ์น๊ณ ์ฑ์ ๋ฒ์ ๋ง ์ฌ๋ฆฌ๋ฉด ๋ฉ๋๋ค.
| ๋ค์ด์จ ๊ณณ | ๋๋ฅด๋ฉด |
|---|---|
| ์๋๋ก์ด๋ ํฌ๋กฌ ยท PC ํฌ๋กฌ | ํฌ๋กฌ ์ค์น ์ฐฝ |
| ์์ดํฐ Safari | ์ข์ ์ + ๊ณต์ โ ํ ํ๋ฉด์ ์ถ๊ฐ โ ์ถ๊ฐ |
| ์นด์นด์คํก ยท ๋ผ์ธ ยท ์๋๋ก์ด๋ ์ธ์คํ ๋ฑ | ์ข์ ์ + [๊ธฐ๋ณธ ๋ธ๋ผ์ฐ์ ๋ก ์ด๊ธฐ] ํ ๋ฒ โ ์ด๋ฆฐ ๋ธ๋ผ์ฐ์ ์์ ๋ค์ ์ค์น |
| ์์ดํฐ ์ธ์คํ ยท ์ค๋ ๋ | ์ข์ ์ + ๊ทธ ์ฑ ๋ฉ๋ด ์ด๋ฆ๋๋ก ๋จ๊ณ(ยทยทยท โ ์ธ๋ถ ๋ธ๋ผ์ฐ์ ์์ ์ด๊ธฐ) + ๋งํฌ ๋ณต์ฌ |
| ์ด๋ฏธ ์ค์น ยท ์ง์ ์ ํจ | ๋ฒํผ์ด ์ฌ๋ผ์ง |
์ฑ ์ ๋ธ๋ผ์ฐ์ (์ธ์คํยท์นดํก)์์๋ ์ด๋ค ์น์ฑ๋ ๋ฐ๋ก ์ค์นํ ์ ์์ต๋๋ค โ ํ๋ซํผ ์ ์ฝ์ด๋ผ ํ ๋ฒ์ ๋ฐ๊นฅ ๋ธ๋ผ์ฐ์ ๋ก ๋์ด๊ฐ์ผ ํฉ๋๋ค.
๊ฐฑ์ : ์จ๋ผ์ธ์ผ๋ก ์ด๋ฉด ๋งค๋ฒ ์ ๋ฐฐํฌ(ํ์ด์ง๋ ๋คํธ์ํฌ ๋จผ์ ). ์ผ ๋ ์ฑ ๋ค์ ์ผ๋ ์ฑ์ ๋์์ฌ ๋ 30๋ถ์ ํ ๋ฒ ์ ๋ฐฐํฌ๋ฅผ ํ์ธํด ์๋ก๊ณ ์นจํฉ๋๋ค.
sw.js ๋ ๋ฐฐํฌ๋ง๋ค ๋ฐ๊ฟ ํ์๊ฐ ์๊ณ , kit ์ ์ฌ๋ฆฐ ๋ค npx m1kkit pwa ๋ฅผ ๋ค์ ๋๋ฆฌ๋ฉด ๊ทธ ํ์ ์๋น์ค์์ปค๋ก ๋ฐ๋๋๋ค.
import { loadPretendard, loadGoogleFont, createEmojiLoader } from "@m1kapp/kit";
const pretendard = await loadPretendard(); // Pretendard ํ๊ตญ์ด ํฐํธ
const roboto = await loadGoogleFont("Roboto", 700); // Google Fonts
const loadEmoji = createEmojiLoader("twemoji"); // ์ด๋ชจ์ง fallbackpublic/manifest.json ๋์ ์ฝ๋๋ก ๊ด๋ฆฌ. ์์ด์ฝ ์ด๋ฏธ์ง ํ์ผ ๋ถํ์.
// app/manifest.ts
import { createManifest } from "@m1kapp/kit";
export default createManifest({
name: "My App",
shortName: "App",
description: "What this app does",
themeColor: "#3b82f6", // ์์ด์ฝ ๋ฐฐ๊ฒฝ์์ผ๋ก๋ ์ฌ์ฉ
backgroundColor: "#ffffff",
icon: { text: "MA" }, // ํ
์คํธ๋ก 192ร192, 512ร512 SVG ์์ด์ฝ ์๋ ์์ฑ
});iOS 10+์์ ํ์น ์ค๊ณผ ์ธํ ์๋ ํ๋๋ฅผ ๋ง์ต๋๋ค. viewportFit: "cover"๋ก ๋
ธ์น / Dynamic Island ๊ธฐ๊ธฐ์์ safe area inset๋ ์ง์ํฉ๋๋ค.
// app/layout.tsx
import { mobileViewport } from "@m1kapp/kit";
export const viewport = mobileViewport;๋ด๋ถ์ ์ผ๋ก CSS touch-action: pan-x pan-y์ input { font-size: max(16px, 1em) }๋ฅผ ์๋ ์ ์ฉํฉ๋๋ค.
import { svgIcon } from "@m1kapp/kit";
const src = svgIcon("MA", { size: 192, bg: "#3b82f6", color: "#ffffff", radius: 0.25 });
// โ "data:image/svg+xml,..." โ <img src={src} /> ๋๋ manifest icons์ ๋ฐ๋ก ์ฌ์ฉAndroid๋ ๋ค์ดํฐ๋ธ ์ค์น ํ๋กฌํํธ, iOS๋ ํ ํ๋ฉด ์ถ๊ฐ ์๋ด ์ํธ๋ฅผ ์๋์ผ๋ก ๋์์ค๋๋ค.
import { PWAInstallButton, IOSInstallSheet, usePWAInstall } from "@m1kapp/kit";
// ๋ฒํผ ๊ทธ๋๋ก ์ฌ์ฉ
<PWAInstallButton appName="My App" iconSrc={iconSrc} label="์ฑ์ผ๋ก ์ค์น" />
// ์ปค์คํ
UI
const { state, install } = usePWAInstall();
// state: "android-ready" | "ios-safari" | "installed" | "unsupported"
if (state === "android-ready") {
return <button onClick={install}>์ค์น</button>;
}
if (state === "ios-safari") {
return <button onClick={() => setSheetOpen(true)}>์ค์น</button>;
}
// iOS ์๋ด ์ํธ (์ง์ ์ ์ด ์)
<IOSInstallSheet open={sheetOpen} onClose={() => setSheetOpen(false)} appName="My App" iconSrc={iconSrc} />์์กด์ฑ ์ ๋ก. ์บ์ฑ ยท ์ค๋ณต์ ๊ฑฐ ยท ์ฌ์๋ ยท ํฌ์ปค์ค revalidate๊ฐ ๋ด์ฅ๋ fetch ์ ํธ.
import { useFetch } from "@m1kapp/kit";
const { data, loading, error, refetch } = useFetch<User[]>("/api/users", {
staleTime: 30_000, // 30์ด ์บ์ โ ๊ฐ์ URL ์ค๋ณต ์์ฒญ ์์
retry: 2, // ๋คํธ์ํฌ ์ค๋ฅ ์ ์ง์ ๋ฐฑ์คํ๋ก 2ํ ์ฌ์๋
revalidateOnFocus: true, // ํญ ๋์์ค๋ฉด ์๋ ์ต์ ๋ฐ์ดํฐ
});
// ๋ก๋ฉ ์ฒ๋ฆฌ
if (loading && !data) return <PostListSkeleton />;
if (error) return <p>{error.message}</p>;
return data?.map(u => <UserCard key={u.id} user={u} />);์ค์๊ฐ ๋ฐ์ดํฐ, ๋ผ์ด๋ธ ์ค์ฝ์ด ๋ฑ์ ์ฌ์ฉ.
import { usePolling } from "@m1kapp/kit";
const { data, isRunning, start, stop } = usePolling(
() => fetch("/api/match/live").then(r => r.json()),
{
interval: 5000, // 5์ด๋ง๋ค
enabled: true, // ์์ ์ฌ๋ถ
pauseOnHidden: true, // ํญ ์จ๊ธฐ๋ฉด ์๋ ์ ์ง โ ๋ถํ์ํ ์์ฒญ ์์
}
);
<button onClick={() => isRunning ? stop() : start()}>
{isRunning ? "์ ์ง" : "์์"}
</button>baseURL๊ณผ ๊ณตํต ํค๋๋ฅผ ํ ๋ฒ๋ง ์ค์ ํ๋ฉด ํ์ ์์ ํ API ํด๋ผ์ด์ธํธ๊ฐ ๋ง๋ค์ด์ง๋๋ค.
// lib/api.ts
import { createApiClient, ApiError } from "@m1kapp/kit";
export const api = createApiClient("https://api.myapp.com", {
headers: { Authorization: `Bearer ${token}` },
onError: (err) => {
if (err.status === 401) signOut();
},
});
// ์ฌ์ฉ
const me = await api.get<User>("/users/me");
const post = await api.post<Post>("/posts", { title, body });
await api.put("/posts/1", { title: "์์ ๋ ์ ๋ชฉ" });
await api.delete("/posts/1");
// ์๋ฌ๋ ApiError๋ก ์ ๊ทํ
try {
await api.delete("/posts/1");
} catch (e) {
if (e instanceof ApiError) {
console.log(e.status, e.body); // 404, { error: "Not found" }
}
}Next.js API route ์ ์ฉ. @m1kapp/kit/server๋ก import โ ํด๋ผ์ด์ธํธ ๋ฒ๋ค์ ํฌํจ๋์ง ์์ต๋๋ค.
try/catch ์์ด ์๋ฌ๋ฅผ ์ฒ๋ฆฌํฉ๋๋ค. unauthorized(), notFound() ๋ฑ์ never๋ฅผ ๋ฐํํ๋ฏ๋ก TypeScript๊ฐ ์ ์ด ํ๋ฆ์ ์ ํํ ์ถ๋ก ํฉ๋๋ค.
import { handler, ok, created, unauthorized, forbidden, notFound, badRequest } from "@m1kapp/kit/server";
// Before โ
export async function GET(req: Request) {
const user = await currentUser();
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
try {
const data = await db.sites.findMany({ where: { userId: user.id } });
return Response.json(data);
} catch {
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
// After โ
export const GET = handler(async () => {
const user = await currentUser();
if (!user) unauthorized(); // throws โ 401
const data = await db.sites.findMany({ where: { userId: user.id } });
return ok(data); // 200 + JSON
// ์ฒ๋ฆฌ๋์ง ์์ ์๋ฌ โ 500 ์๋
});
export const POST = handler(async (req) => {
const user = await currentUser();
if (!user) unauthorized();
const { url } = await req.json();
if (!url) badRequest("url์ด ํ์ํด์"); // throws โ 400
const site = await db.sites.create({ data: { url, userId: user.id } });
return created(site); // 201 + JSON
});| ํจ์ | ์ํ | ์ค๋ช |
|---|---|---|
ok(data) |
200 | JSON ์๋ต |
created(data) |
201 | ์์ฑ ์๋ฃ |
noContent() |
204 | ๋ณธ๋ฌธ ์์ |
badRequest(msg?) |
400 | ์๋ชป๋ ์์ฒญ |
unauthorized(msg?) |
401 | ์ธ์ฆ ํ์ |
forbidden(msg?) |
403 | ๊ถํ ์์ |
notFound(msg?) |
404 | ๋ฆฌ์์ค ์์ |
conflict(msg?) |
409 | ์ถฉ๋ |
serverError(msg?) |
500 | ์๋ฒ ์ค๋ฅ |
ํน์ ์๋ฌ๋ฅผ try/catch ์์ด ์ฒ๋ฆฌํ๊ณ ์ถ์ ๋.
import { handler, ok, serverError, safely } from "@m1kapp/kit/server";
export const GET = handler(async () => {
const { ok: success, data, error } = await safely(() => db.users.findFirstOrThrow());
if (!success) return serverError("DB ์กฐํ ์คํจ");
return ok(data);
});API ๋ผ์ฐํธ์์ ์์ฃผ ์์ผ๋ก ๋ง๋ค๋ ๊ฒ๋ค โ ์ ๋ถ @m1kapp/kit/server์ ์์ด์.
import {
requireEnv, fetchWithRetry, withRetry, recoverJsonFromText,
scrapeOg, todayKST, dateInTz, idToSlug, slugToId, appHost,
} from "@m1kapp/kit/server";
// ํ์ env ๊ฒ์ฆ (์์ผ๋ฉด 500 throw, ์์ผ๋ฉด ํ์
๋ ๊ฐ์ฒด)
const { XAI_API_KEY } = requireEnv(["XAI_API_KEY"]);
// fetch + ํ์์์ + 429/5xx ์๋ ์ฌ์๋ (๋ง์ง๋ง ์๋ต ๋ฐํ)
const res = await fetchWithRetry(url, { headers: { authorization: `Bearer ${XAI_API_KEY}` } });
// ์๋ฌด async๋ ์ฌ์๋ (Neon ์ฝ๋์คํํธ ๋ฑ)
const rows = await withRetry(() => db.query.users.findMany(), {
shouldRetry: (e) => String(e).includes("fetch failed"),
});
// LLM ์๋ต์์ JSON ๋ณต๊ตฌ (```json ํ์คยทํธ๋ ์ผ๋ง ์ฝค๋งยท๋
ธ์ด์ฆ ์ ๊ฑฐ)
const data = recoverJsonFromText<{ items: string[] }>(llmReply);
// Open Graph ์คํฌ๋ํ
const og = await scrapeOg("example.com"); // { title, description, image, siteName, url }
// ํ์์กด ๋ ์ง
todayKST(); // "2026-06-04"
dateInTz(Date.now(), "America/New_York");
// base62 slug + ํธ์คํธ
idToSlug(42, 1000); // "..." (slugToId๋ก ์ญ๋ณํ)
const base = `https://${appHost("m1k.app")}`;์์ ํจ์ โ ์์กด์ฑ ์์, ์ด๋์๋ import.
import { relativeTime, formatNumber, formatPrice, cn, formatDuration, groupByDay } from "@m1kapp/kit";
// ์๋ ์๊ฐ
relativeTime(post.createdAt) // "3๋ถ ์ ", "์ด์ ", "2025. 4. 19."
// ์์ ์๊ฐ ํฌ๋งท
formatDuration(90_000) // "1๋ถ 30์ด"
formatDuration(3_661_000, { style: "clock" }) // "1:01:01"
// ๋ ์ง๋ณ ๊ทธ๋ฃนํ โ [{ date, label: "์ค๋"|"์ด์ "|"4์ 19์ผ (ํ )", items }]
groupByDay(logs, (l) => l.timestamp)
// ์ซ์ ํฌ๋งท
formatNumber(1_500) // "1.5์ฒ"
formatNumber(15_000) // "1.5๋ง"
formatNumber(150_000_000) // "1.5์ต"
// ๊ฐ๊ฒฉ ํฌ๋งท
formatPrice(9_900) // "โฉ9,900"
formatPrice(9.99, "USD") // "$9.99"
// ์กฐ๊ฑด๋ถ ํด๋์ค โ Tailwind ์ถฉ๋ ์๋ ํด๊ฒฐ (clsx + tailwind-merge ๋ด์ฅ)
cn("base", isActive && "active", err && "border-red-500")
// โ "base active"
cn("px-2 py-1", "px-4") // โ "py-1 px-4" (์ถฉ๋ ํด๊ฒฐ)
cn({ "opacity-50": disabled }) // ๊ฐ์ฒด ๋ฌธ๋ฒ ์ง์import { useDebounce, useFormSubmit, useInView, useLocalStorage } from "@m1kapp/kit";const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) searchAPI(debouncedQuery); // ํ์ดํ ๋ฉ์ถ ๋๋ง ์คํ
}, [debouncedQuery]);๋ชจ๋ form handler์ loading / error / try-catch / finally ๋ณด์ผ๋ฌํ๋ ์ดํธ๋ฅผ ์ ๊ฑฐํฉ๋๋ค.
const { submit, loading, error, data, reset } = useFormSubmit(
async (url: string) => api.post<Site>("/api/sites", { url }),
{ onSuccess: (site) => router.push(`/sites/${site.id}`) }
);
<form onSubmit={e => { e.preventDefault(); submit(inputValue); }}>
<input value={inputValue} onChange={...} />
{error && <p className="text-red-500 text-sm">{error.message}</p>}
<button disabled={loading}>{loading ? "๋ฑ๋ก ์คโฆ" : "๋ฑ๋ก"}</button>
</form>๋ฌดํ์คํฌ๋กค ํธ๋ฆฌ๊ฑฐ, ๋ ์ด์ง ๋ก๋, ๋ฑ์ฅ ์ ๋๋ฉ์ด์ ์ ์ฌ์ฉ.
const { ref, inView } = useInView({ threshold: 0.1, once: true });
useEffect(() => {
if (inView) fetchNextPage();
}, [inView]);
return (
<div>
{posts.map(p => <PostCard key={p.id} post={p} />)}
<div ref={ref} /> {/* ๋ฆฌ์คํธ ๋งจ ์๋ ์ผํฐ๋ฌ */}
</div>
);์๋ก๊ณ ์นจ ํ์๋ ์ ์ง๋๋ ๋ก์ปฌ ์ํ. SSR ์์ .
const [theme, setTheme, removeTheme] = useLocalStorage("theme", "light");
setTheme("dark"); // localStorage์ ์ ์ฅ
removeTheme(); // localStorage์์ ์ญ์ , ์ด๊ธฐ๊ฐ์ผ๋ก ๋ณต์