Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

ย 

History

117 Commits
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

@m1kapp/kit

์‚ฌ์ด๋“œ ํ”„๋กœ์ ํŠธ๋ฅผ ์œ„ํ•œ UI ยท OG ยท PWA ยท Fetch ยท Utils ์˜ฌ์ธ์› ํ‚ท.

npm Hits

npm install @m1kapp/kit

Peer 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>
  );
}

๋ชจ๋“ˆ ๊ตฌ์„ฑ

ํ•˜๋‚˜์˜ 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๋กœ ๋”ฐ๋ผ์˜ด

UI

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" />

// ์ปค์Šคํ…€ 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>

ํ™•์ธ ์นด๋“œ (propose โ†’ confirm โ†’ execute)

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(์ทจ์†Œ์„ ) / loading

๋ฆฌ์ŠคํŠธ / ํƒ€์ž„๋ผ์ธ ํ–‰

import { 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 ํฌ๋ ˆ๋”ง์„ ์ž๋™ ๋‚ด์žฅํ•ฉ๋‹ˆ๋‹ค.

๋ฐฉ๋ฌธ์ž ํŠธ๋ž˜์ปค (m1k.app) โ€” ์„ ํƒ, ๊ธฐ๋ณธ OFF

Watermark/PoweredByKit ํ•˜๋‹จ ํฌ๋ ˆ๋”ง์ด ๋ฐฉ๋ฌธ์ž ์ˆ˜๋ฅผ ์ง‘๊ณ„ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. slug๊ฐ€ ์žˆ์„ ๋•Œ๋งŒ ์ผœ์ง€๊ณ , ์—†์œผ๋ฉด ์•„๋ฌด๊ฒƒ๋„ ์ „์†กํ•˜์ง€ ์•Š์•„์š”(๊ธฐ๋ณธ off). ์ง‘๊ณ„๋Š” ํŽ˜์ด์ง€๋ทฐ ์นด์šดํŠธ๋ฟ โ€” PII ์—†์Œ.

# 1) ์‚ฌ์ดํŠธ ๋“ฑ๋ก (๋ฌด๋กœ๊ทธ์ธ) โ†’ slug ๋ฐœ๊ธ‰
npx m1kkit track https://myside.app
# 2) Next.js๋ฉด .env ์— ํ•œ ์ค„ โ†’ ์ดํ›„ ์ž๋™ ์ง‘๊ณ„ (์Šค๋‹ˆํŽซ ๋ถ™์—ฌ๋„ฃ๊ธฐ ๋ถˆํ•„์š”)
NEXT_PUBLIC_M1K_SLUG=your-slug

Vite ๋“ฑ 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๋กœ ๋Œ ์ˆ˜ ์žˆ์–ด์š”.


OG Image

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} />

CLI๋กœ ํ•œ ๋ฒˆ์— (Next.js ๋ผ์šฐํŠธ ์—†์ด)

Vite ๋“ฑ ์„œ๋ฒ„ ์—†๋Š” SPA๋ผ app/og/route.tsx๋ฅผ ๋ชป ๋งŒ๋“ ๋‹ค๋ฉด, ๋นŒ๋“œ ์—†์ด CLI๋กœ ํŒŒ๋น„์ฝ˜๋ถ€ํ„ฐ OG ์ด๋ฏธ์ง€, manifest๊นŒ์ง€ ํ•œ ๋ฒˆ์— ์ƒ์„ฑํ•˜์„ธ์š”:

npx m1kkit favicon --appname="My App" --slogan="ํ•œ ์ค„ ์†Œ๊ฐœ" --color=#3b82f6 --domain=myapp.com

favicon.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>์— ๋ถ™์—ฌ๋„ฃ์„ ํƒœ๊ทธ ์Šค๋‹ˆํŽซ๋„ ์ฝ˜์†”์— ์ถœ๋ ฅ๋ฉ๋‹ˆ๋‹ค.

ํฐํŠธ & ์ด๋ชจ์ง€

import { loadPretendard, loadGoogleFont, createEmojiLoader } from "@m1kapp/kit";

const pretendard = await loadPretendard();           // Pretendard ํ•œ๊ตญ์–ด ํฐํŠธ
const roboto = await loadGoogleFont("Roboto", 700);  // Google Fonts
const loadEmoji = createEmojiLoader("twemoji");       // ์ด๋ชจ์ง€ fallback

PWA

Manifest

public/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 ์•„์ด์ฝ˜ ์ž๋™ ์ƒ์„ฑ
});

Viewport โ€” ํ•€์น˜ ์คŒ ์ฐจ๋‹จ

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) }๋ฅผ ์ž๋™ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค.

SVG ์•„์ด์ฝ˜

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} />

Fetch

์˜์กด์„ฑ ์ œ๋กœ. ์บ์‹ฑ ยท ์ค‘๋ณต์ œ๊ฑฐ ยท ์žฌ์‹œ๋„ ยท ํฌ์ปค์Šค revalidate๊ฐ€ ๋‚ด์žฅ๋œ fetch ์œ ํ‹ธ.

useFetch

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} />);

usePolling

์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ, ๋ผ์ด๋ธŒ ์Šค์ฝ”์–ด ๋“ฑ์— ์‚ฌ์šฉ.

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>

createApiClient

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" }
  }
}

Server

Next.js API route ์ „์šฉ. @m1kapp/kit/server๋กœ import โ€” ํด๋ผ์ด์–ธํŠธ ๋ฒˆ๋“ค์— ํฌํ•จ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

handler()

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 ์„œ๋ฒ„ ์˜ค๋ฅ˜

safely()

ํŠน์ • ์—๋Ÿฌ๋ฅผ 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);
});

์„œ๋ฒ„ ์œ ํ‹ธ (์˜์กด์„ฑ 0)

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")}`;

Utils

์ˆœ์ˆ˜ ํ•จ์ˆ˜ โ€” ์˜์กด์„ฑ ์—†์Œ, ์–ด๋””์„œ๋‚˜ 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 })             // ๊ฐ์ฒด ๋ฌธ๋ฒ• ์ง€์›

Hooks

import { useDebounce, useFormSubmit, useInView, useLocalStorage } from "@m1kapp/kit";

useDebounce

const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);

useEffect(() => {
  if (debouncedQuery) searchAPI(debouncedQuery); // ํƒ€์ดํ•‘ ๋ฉˆ์ถœ ๋•Œ๋งŒ ์‹คํ–‰
}, [debouncedQuery]);

useFormSubmit

๋ชจ๋“  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>

useInView

๋ฌดํ•œ์Šคํฌ๋กค ํŠธ๋ฆฌ๊ฑฐ, ๋ ˆ์ด์ง€ ๋กœ๋“œ, ๋“ฑ์žฅ ์• ๋‹ˆ๋ฉ”์ด์…˜์— ์‚ฌ์šฉ.

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>
);

useLocalStorage

์ƒˆ๋กœ๊ณ ์นจ ํ›„์—๋„ ์œ ์ง€๋˜๋Š” ๋กœ์ปฌ ์ƒํƒœ. SSR ์•ˆ์ „.

const [theme, setTheme, removeTheme] = useLocalStorage("theme", "light");

setTheme("dark");    // localStorage์— ์ €์žฅ
removeTheme();       // localStorage์—์„œ ์‚ญ์ œ, ์ดˆ๊ธฐ๊ฐ’์œผ๋กœ ๋ณต์›

About

๐Ÿงฐ ์‚ฌ์ด๋“œ ํ”„๋กœ์ ํŠธ์šฉ ์˜ฌ์ธ์› ํ‚ท โ€” UI ยท OG ์ด๋ฏธ์ง€ ยท PWA ยท Fetch ยท Utils. npm i @m1kapp/kit

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages