Repository files navigation

@m1kapp/kit

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

npmHits

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";exportdefaultfunctionApp(){return(// Watermark๊ฐ€ ๋ทฐํฌํŠธ ์ „์ฒด ๋†’์ด/์ค‘์•™์ •๋ ฌ/ํ•˜๋‹จ ํฌ๋ ˆ๋”ง์„ ๊ฐ™์ด ์ค๋‹ˆ๋‹ค.// (ํฌ๋ ˆ๋”ง ์ŠคํŠธ๋ฆฝ์€ ์…ธ ๋ฐ”๋กœ ๋ฐ‘์— ๋ถ™์–ด์„œ ์…ธ๊ณผ ํ•œ ๋ฉ์–ด๋ฆฌ๋กœ ์ค‘์•™์ •๋ ฌ๋จ โ€” ํ™”๋ฉด ์ง„์งœ ๋ฐ”๋‹ฅ์— ๊ณ ์ •๋˜๋Š” ๊ฒŒ ์•„๋‹˜)// AppShell๋งŒ ๋‹จ๋…์œผ๋กœ ์“ฐ๋ฉด ์ฝ˜ํ…์ธ  ๋†’์ด๋งŒํผ ์ชผ๊ทธ๋ผ๋“ค์–ด์š”.<Watermarkcolor="#e2603f"text="myapp"><AppShell><AppShellHeader><h1>My App</h1></AppShellHeader><AppShellContent><SectionclassName="pt-4">{/* ์ฝ˜ํ…์ธ  โ€” Divider๋กœ ์„น์…˜ ๊ตฌ๋ถ„ */}</Section></AppShellContent><TabBar><Tabhref="/"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/kitOG ์ด๋ฏธ์ง€ ์ƒ์„ฑ (์„œ๋ฒ„)
PWA@m1kapp/kitmanifest, viewport, ์„ค์น˜ ์œ ๋„
Fetch@m1kapp/kit์บ์‹ฑยท์ค‘๋ณต์ œ๊ฑฐยท์žฌ์‹œ๋„ fetch ์œ ํ‹ธ
Utils@m1kapp/kit๋‚ ์งœยท์ˆซ์ž ํฌ๋งท, ๋ฒ”์šฉ ํ›…
Server@m1kapp/kit/serverNext.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><Tabactive={tab==="home"}onClick={()=>setTab("home")}label="ํ™ˆ"icon={<HomeIcon/>}activeColor="#3b82f6"// ํ™œ์„ฑ ์ƒ‰์ƒ ์ž์œ ๋กญ๊ฒŒ ์ง€์ •/></TabBar>

๋ฐ์ดํ„ฐ ํ‘œ์‹œ

import{Avatar,Badge,StatChip,EmptyState,GrassMap}from"@m1kapp/kit";// Avatar โ€” ์ด๋‹ˆ์…œ or ์ด๋ฏธ์ง€, ์ด๋ฏธ์ง€ ๋กœ๋“œ ์‹คํŒจ ์‹œ ์ด๋‹ˆ์…œ๋กœ ์ž๋™ fallback<Avatarsrc="/photo.jpg"fallback="MH"size="md"shape="circle"/><Avatarfallback="MH"size="lg"shape="rounded"color="#3b82f6"/>// size: "xs" | "sm" | "md" | "lg" | "xl"// shape: "circle" | "rounded"// Badge โ€” ์ƒํƒœ/์นดํ…Œ๊ณ ๋ฆฌ ๋ ˆ์ด๋ธ”<Badgevariant="green">LIVE</Badge><Badgevariant="red">์˜ค๋ฅ˜</Badge><Badgevariant="blue"size="sm">์ •๋ณด</Badge>// variant: "default" | "green" | "red" | "yellow" | "blue" | "purple" | "orange"// StatChip โ€” ์ˆซ์ž stat ๋ฑƒ์ง€<StatChiplabel="๋ฐฉ๋ฌธ์ž"value={1024}/>// EmptyState โ€” ๋นˆ ๋ชฉ๋ก ํ”Œ๋ ˆ์ด์Šคํ™€๋”<EmptyStatemessage="์•„์ง ์•„๋ฌด๊ฒƒ๋„ ์—†์–ด์š”"/>// GrassMap โ€” GitHub ์Šคํƒ€์ผ ํ™œ๋™ ํžˆํŠธ๋งต<GrassMapdata={[{date: "2025-04-19",count: 42}]}accent="#3b82f6"/>

์Šค์ผˆ๋ ˆํ†ค

๋กœ๋”ฉ ํ”Œ๋ ˆ์ด์Šคํ™€๋”. className์œผ๋กœ ํฌ๊ธฐ๋ฅผ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค.

import{Skeleton}from"@m1kapp/kit";// ํ…์ŠคํŠธ ์ค„<SkeletonclassName="h-4 w-3/4"/>// ์นด๋“œ ๋ธ”๋ก<SkeletonclassName="h-32 w-full"rounded="xl"/>// ์•„๋ฐ”ํƒ€<SkeletonclassName="h-10 w-10"rounded="full"/>// ์‹ค์ „ ํŒจํ„ดfunctionPostCardSkeleton(){return(<divclassName="flex gap-3 p-4"><SkeletonclassName="h-10 w-10"rounded="full"/><divclassName="flex-1 space-y-2"><SkeletonclassName="h-4 w-2/3"/><SkeletonclassName="h-3 w-1/2"/></div></div>);}

๋ชจ๋‹ฌ / ๋‹ค์ด์–ผ๋กœ๊ทธ

backdrop ํด๋ฆญ, ESC ํ‚ค, ์Šคํฌ๋กค ์ž ๊ธˆ ์ž๋™ ์ฒ˜๋ฆฌ.

import{Dialog}from"@m1kapp/kit";// ๊ธฐ๋ณธ ์‚ฌ์šฉ<Dialogopen={open}onClose={()=>setOpen(false)}title="์„ค์ •"><pclassName="text-sm text-zinc-500">๋‚ด์šฉ</p></Dialog>// ํ™•์ธ ๋‹ค์ด์–ผ๋กœ๊ทธ<Dialogopen={confirmOpen}onClose={()=>setConfirmOpen(false)}title="์‚ญ์ œํ• ๊นŒ์š”?"><pclassName="text-sm text-zinc-500">์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์–ด์š”.</p><divclassName="flex gap-2 mt-4"><buttononClick={handleDelete}className="...">์‚ญ์ œ</button><buttononClick={()=>setConfirmOpen(false)}className="...">์ทจ์†Œ</button>
</div></Dialog>// size: "sm" (๊ธฐ๋ณธ) | "md" | "lg"// persistent: true โ€” backdrop ํด๋ฆญ / ESC๋กœ ๋‹ซ๊ธฐ ๋น„ํ™œ์„ฑํ™”

์ธํ„ฐ๋ž™์…˜

import{Button,Tooltip,Typewriter,EmojiButton,EmojiPicker}from"@m1kapp/kit";<ButtononClick={fn}>์‹œ์ž‘ํ•˜๊ธฐ</Button><Tooltiplabel="์„ค๋ช… ํ…์ŠคํŠธ"><button>hover me</button></Tooltip><Typewriterwords={["Hello","World"]}color="#3b82f6"/>// ์ด๋ชจ์ง€ ์„ ํƒ๊ธฐconst[emoji,setEmoji]=useState("๐Ÿ ");const[open,setOpen]=useState(false);<EmojiButtonemoji={emoji}onClick={()=>setOpen(true)}/><EmojiPickeropen={open}onClose={()=>setOpen(false)}current={emoji}onSelect={setEmoji}/>

๊ณต์œ 

import{ShareButton,useShare}from"@m1kapp/kit";// ๋ฒ„ํŠผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ โ€” ๋ชจ๋ฐ”์ผ์€ ๋„ค์ดํ‹ฐ๋ธŒ ๊ณต์œ , ๋ฐ์Šคํฌํƒ‘์€ ํด๋ฆฝ๋ณด๋“œ ๋ณต์‚ฌ<ShareButtonurl="https://m1k.app"title="My App"/>// ์ปค์Šคํ…€ UIconst{ share, copied, canNativeShare }=useShare({url: "https://m1k.app"});<buttononClick={()=>share()}>{copied ? "๋ณต์‚ฌ๋จ!" : "๊ณต์œ "}</button>

ํ† ์ŠคํŠธ

import{ToastProvider,useToast}from"@m1kapp/kit";// ์•ฑ ๋ฃจํŠธ ๊ฐ์‹ธ๊ธฐ<ToastProvider><App/></ToastProvider>// ์–ด๋””์„œ๋‚˜consttoast=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><scriptdangerouslySetInnerHTML={{__html: THEME_SCRIPT}}/></head>// ํ…Œ๋งˆ ๋ฒ„ํŠผ + ๋‹ค์ด์–ผ๋กœ๊ทธconst[themeOpen,setThemeOpen]=useState(false);<ThemeButtoncolor={color}dark={dark}onClick={()=>setThemeOpen(true)}/><ThemeDialogopen={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 โ€” ๊ฐ€์žฅ ์‰ฌ์›€ (ํ•˜์œ„ ์ „์ฒด์— ์ ์šฉ)<AppShellaccent="#e2603f">...</AppShell>// โ‘ก ์•„๋ฌด ๋ž˜ํผ์—๋‚˜ CSS ๋ณ€์ˆ˜๋กœ<divstyle={{"--kit-accent": "#e2603f"}asReact.CSSProperties}>...</div>// โ‘ข ์ปดํฌ๋„ŒํŠธ๋ณ„ override<Switchchecked={on}onChange={setOn}accent="#e2603f"/>

ํผ ยท ์„ค์ •

import{Field,Switch}from"@m1kapp/kit";// ๋ผ๋ฒจ๋“œ ์ธํ’‹ (stacked ๊ธฐ๋ณธ / inline / multiline)<Fieldlabel="์ด๋ฆ„"value={name}onChange={setName}placeholder="์ด๋ฆ„"/><Fieldlabel="์ด๋ฉ”์ผ"value={email}inlinereadOnly/><Fieldlabel="๋ฉ”๋ชจ"value={memo}onChange={setMemo}multilinerows={3}hint="์ž์œ ๋กญ๊ฒŒ ์ ์–ด์ฃผ์„ธ์š”"/>// on/off ํ† ๊ธ€ โ€” accent๋Š” --kit-accent ์ž๋™ ์—ฐ๋™<Switchchecked={on}onChange={setOn}aria-label="์•Œ๋ฆผ"/>

์„ธ๊ทธ๋จผํŠธ ํ† ๊ธ€

import{SegmentedControl}from"@m1kapp/kit";<SegmentedControlvalue={view}onChange={setView}options={[{value: "today",label: "์˜ค๋Š˜"},{value: "week",label: "์ด๋ฒˆ ์ฃผ"}]}/>// ์ธ๋ผ์ธ ํ† ๊ธ€์šฉ. ํ•˜๋‹จ ๊ธ€๋กœ๋ฒŒ ๋‚ด๋น„๊ฒŒ์ด์…˜์€ TabBar๋ฅผ ์“ฐ์„ธ์š”.

์ฑ„ํŒ… / ๋Œ€ํ™”

import{MessageList,ChatBubble,TypingIndicator}from"@m1kapp/kit";importtype{ChatMessage}from"@m1kapp/kit";constmessages: ChatMessage[]=[{role: "user",content: "๋‚ด์ผ 3์‹œ ํšŒ์˜ ์žก์•„์ค˜",timestamp: Date.now()},{role: "assistant",content: "๋„ค, ์žก์•„๋“œ๋ฆด๊ฒŒ์š”."},];// dayDivider: timestamp ๊ธฐ์ค€ ๋‚ ์งœ๊ฐ€ ๋ฐ”๋€Œ๋ฉด ๊ตฌ๋ถ„์„  ์ž๋™ ์‚ฝ์ž…<MessageListmessages={messages}dayDivider>{pending&&<TypingIndicator/>}</MessageList>// ์ง์ ‘ ์“ฐ๋ ค๋ฉด<ChatBubblerole="user">์˜ค๋Š˜์ผ์ •๋ณด์—ฌ์ค˜</ChatBubble><ChatBubblerole="assistant">3๊ฑด ์žˆ์–ด์š”.</ChatBubble>

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

LLM ํˆด์ฝœ์ฒ˜๋Ÿผ "์ œ์•ˆ โ†’ ํ™•์ธ โ†’ ์‹คํ–‰" ํ๋ฆ„์„ ๋ฉ”์‹œ์ง€ ์•ˆ์— ๋ฐ•์„ ๋•Œ. ๋ชจ๋‹ฌ์ด ์•„๋‹ˆ๋ผ ํ๋ฆ„์— ์ธ๋ผ์ธ๋ฉ๋‹ˆ๋‹ค.

import{ActionCard}from"@m1kapp/kit";const[state,setState]=useState<"pending"|"loading"|"done"|"cancelled">("pending");<ActionCardtitle="์ด๋ ‡๊ฒŒ ๊ธฐ๋กํ•ด๋‘˜๊นŒ์š”?"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";<ListRowaccent="#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";// ๋‹ค๋‹จ๊ณ„ ์ง„ํ–‰ ํ‘œ์‹œ (์œ„์ €๋“œ/๋ฉ€ํ‹ฐํŽ˜์ด์ฆˆ ํ”Œ๋กœ์šฐ)<Steppercurrent={phase}onStepClick={setPhase}steps={[{label: "๋ถ„์„",icon: "๐Ÿ“"},{label: "์ค€๋น„",icon: "๐Ÿ“ธ"},{label: "์ƒ์„ฑ",icon: "โœจ"},]}/>// ์ ‘๊ธฐ ์นด๋“œ (ํ—ค๋”+๋ฐฐ์ง€+์ƒํƒœ)<Collapsibleleading={1} title="ํŽ˜๋ฅด์†Œ๋‚˜ ๋ถ„์„"subtitle="์Šคํƒ€์ผ ํŒŒ์•…"completedopen={open ===1}onToggle={()=>setOpen(open===1 ? null : 1)}><p>๋ณธ๋ฌธโ€ฆ</p></Collapsible>// ์›ํ˜• ์ง„ํ–‰๋ฅ <ProgressRingvalue={7}max={10}><spanclassName="text-2xl font-black">7</span></ProgressRing>

์ž…๋ ฅ ยท ํŽธ์ง‘

import{Select,ColorPicker,InlineEdit}from"@m1kapp/kit";// ์•ต์ปค๋“œ ๋“œ๋กญ๋‹ค์šด (์นด์šดํŠธยท๋น„ํ™œ์„ฑ ์˜ต์…˜, ์™ธ๋ถ€ํด๋ฆญ/ESC ๋‹ซํž˜)<Selectvalue={cat}onChange={setCat}placeholder="๋‚œ์ด๋„"options={[{value: "a",label: "์ดˆ๊ธ‰",count: 12},{value: "b",label: "๊ณ ๊ธ‰",count: 0,disabled: true},]}/>// ์ปฌ๋Ÿฌ ํ”ผ์ปค (ํ”„๋ฆฌ์…‹ + ์ปค์Šคํ…€ hex)<ColorPickervalue={color}onChange={setColor}/>// ํƒญํ•˜์—ฌ ํŽธ์ง‘ (Enter ์ €์žฅ ยท Esc ์ทจ์†Œ)<InlineEditvalue={name}onChange={rename}className="text-lg font-bold"/>

๋ณต์‚ฌ ยท ๋ฐ์ดํ„ฐ

import{CopyButton,CodeBlock,useCopy,BarList,Countdown,Carousel,Img}from"@m1kapp/kit";<CopyButtontext="npm i @m1kapp/kit">์„ค์น˜ ๋ณต์‚ฌ</CopyButton><CodeBlocklabel="install"code="npm i @m1kapp/kit"/>const{ copied, copy }=useCopy();// keyed: copy(text, "row-3")// ๊ฐ€๋กœ ๋ง‰๋Œ€ ๋ถ„์„ ์ฐจํŠธ<BarListitems={[{label: "/",value: 120},{label: "/about",value: 64,href: "/about"}]}/>// ์นด์šดํŠธ๋‹ค์šด (D-day)<Countdownto="2026-12-31"onComplete={celebrate}/>// ์Šค์™€์ดํ”„ ์บ๋Ÿฌ์…€ (controlled, ์  ์ธ๋””์ผ€์ดํ„ฐ)<Carouselcount={slides.length}index={i}onChange={setI}>{slides[i]}</Carousel>// ๋‹ค์ค‘ URL ํด๋ฐฑ ์ด๋ฏธ์ง€<Imgcandidates={[avatarUrl,gravatar]}fallback={<Avatarfallback="MH"/>}className="h-10 w-10 rounded-full"/>

์›Œํ„ฐ๋งˆํฌ

import{Watermark}from"@m1kapp/kit";<Watermarkcolor="#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์œผ๋กœ ์ง์ ‘ ์ง€์ •ํ•˜์„ธ์š”:

// ๋„๊ธฐ / ๋ช…์‹œ ์ง€์ •<Watermarktrack={false}>โ€ฆ</Watermark>// ๋น„์ฝ˜ ๋”<WatermarktrackSlug="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.tsximport{OGImage,loadPretendard}from"@m1kapp/kit";import{ImageResponse}from"next/og";exportasyncfunctionGET(){constfont=awaitloadPretendard();returnnewImageResponse(<OGImagetype="default"title="์‚ฌ์ด๋“œ ํ”„๋กœ์ ํŠธ ์‹œ์ž‘ํ•˜๊ธฐ"sub="๋น ๋ฅด๊ฒŒ ๋งŒ๋“ค๊ณ  ๋น ๋ฅด๊ฒŒ ๋ฐฐ์šฐ๋Š”"badge="๐Ÿš€ NEW"appName="myapp"color="#3b82f6"bg="dark"// "dark" | "gradient" | "blend"domain="m1k.app"/>,{width: 1200,height: 630,fonts: [font]});}

ํ…œํ”Œ๋ฆฟ

typeํฌ๊ธฐ์šฉ๋„
default1200ร—630๊ธฐ๋ณธ OG
article1200ร—630๋ธ”๋กœ๊ทธ ํฌ์ŠคํŠธ โ€” author, date, category
stat1200ร—630๋งˆ์ผ์Šคํ†ค โ€” stat, label
product1200ร—630์ œํ’ˆ ์†Œ๊ฐœ โ€” tagline, features[]
match1200ร—630๊ฒฝ๊ธฐ ๊ฒฐ๊ณผ โ€” home, away, score
square1200ร—1200Instagram / SNS
icon512ร—512์•ฑ ์•„์ด์ฝ˜ / favicon
// article<OGImagetype="article"title="์ œ๋ชฉ"author="minho"date="2025-04-19"category="Tutorial"sub="๋ถ€์ œ"color={c}bg={bg}/>// stat<OGImagetype="stat"stat="1,000"label="๋ช…์˜ ๋ฐฉ๋ฌธ์ž"sub="๋ก ์นญ 3์ผ ๋งŒ์—"badge="๐ŸŽ‰"color={c}bg={bg}/>// product<OGImagetype="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";constpretendard=awaitloadPretendard();// Pretendard ํ•œ๊ตญ์–ด ํฐํŠธconstroboto=awaitloadGoogleFont("Roboto",700);// Google FontsconstloadEmoji=createEmojiLoader("twemoji");// ์ด๋ชจ์ง€ fallback

PWA

Manifest

public/manifest.json ๋Œ€์‹  ์ฝ”๋“œ๋กœ ๊ด€๋ฆฌ. ์•„์ด์ฝ˜ ์ด๋ฏธ์ง€ ํŒŒ์ผ ๋ถˆํ•„์š”.

// app/manifest.tsimport{createManifest}from"@m1kapp/kit";exportdefaultcreateManifest({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.tsximport{mobileViewport}from"@m1kapp/kit";exportconstviewport=mobileViewport;

๋‚ด๋ถ€์ ์œผ๋กœ CSS touch-action: pan-x pan-y์™€ input { font-size: max(16px, 1em) }๋ฅผ ์ž๋™ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค.

SVG ์•„์ด์ฝ˜

import{svgIcon}from"@m1kapp/kit";constsrc=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";// ๋ฒ„ํŠผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ<PWAInstallButtonappName="My App"iconSrc={iconSrc}label="์•ฑ์œผ๋กœ ์„ค์น˜"/>// ์ปค์Šคํ…€ UIconst{ state, install }=usePWAInstall();// state: "android-ready" | "ios-safari" | "installed" | "unsupported"if(state==="android-ready"){return<buttononClick={install}>์„ค์น˜</button>;}if(state==="ios-safari"){return<buttononClick={()=>setSheetOpen(true)}>์„ค์น˜</button>;}// iOS ์•ˆ๋‚ด ์‹œํŠธ (์ง์ ‘ ์ œ์–ด ์‹œ)<IOSInstallSheetopen={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>;returndata?.map(u=><UserCardkey={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,// ํƒญ ์ˆจ๊ธฐ๋ฉด ์ž๋™ ์ •์ง€ โ€” ๋ถˆํ•„์š”ํ•œ ์š”์ฒญ ์—†์Œ});<buttononClick={()=>isRunning ? stop() : start()}>{isRunning ? "์ •์ง€" : "์‹œ์ž‘"}</button>

createApiClient

baseURL๊ณผ ๊ณตํ†ต ํ—ค๋”๋ฅผ ํ•œ ๋ฒˆ๋งŒ ์„ค์ •ํ•˜๋ฉด ํƒ€์ž… ์•ˆ์ „ํ•œ API ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋งŒ๋“ค์–ด์ง‘๋‹ˆ๋‹ค.

// lib/api.tsimport{createApiClient,ApiError}from"@m1kapp/kit";exportconstapi=createApiClient("https://api.myapp.com",{headers: {Authorization: `Bearer ${token}`},onError: (err)=>{if(err.status===401)signOut();},});// ์‚ฌ์šฉconstme=awaitapi.get<User>("/users/me");constpost=awaitapi.post<Post>("/posts",{ title, body });awaitapi.put("/posts/1",{title: "์ˆ˜์ •๋œ ์ œ๋ชฉ"});awaitapi.delete("/posts/1");// ์—๋Ÿฌ๋Š” ApiError๋กœ ์ •๊ทœํ™”try{awaitapi.delete("/posts/1");}catch(e){if(einstanceofApiError){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 โŒexportasyncfunctionGET(req: Request){constuser=awaitcurrentUser();if(!user)returnResponse.json({error: "Unauthorized"},{status: 401});try{constdata=awaitdb.sites.findMany({where: {userId: user.id}});returnResponse.json(data);}catch{returnResponse.json({error: "Internal Server Error"},{status: 500});}}// After โœ…exportconstGET=handler(async()=>{constuser=awaitcurrentUser();if(!user)unauthorized();// throws โ†’ 401constdata=awaitdb.sites.findMany({where: {userId: user.id}});returnok(data);// 200 + JSON// ์ฒ˜๋ฆฌ๋˜์ง€ ์•Š์€ ์—๋Ÿฌ โ†’ 500 ์ž๋™});exportconstPOST=handler(async(req)=>{constuser=awaitcurrentUser();if(!user)unauthorized();const{ url }=awaitreq.json();if(!url)badRequest("url์ด ํ•„์š”ํ•ด์š”");// throws โ†’ 400constsite=awaitdb.sites.create({data: { url,userId: user.id}});returncreated(site);// 201 + JSON});

์‘๋‹ต ํ—ฌํผ

ํ•จ์ˆ˜์ƒํƒœ์„ค๋ช…
ok(data)200JSON ์‘๋‹ต
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";exportconstGET=handler(async()=>{const{ok: success, data, error }=awaitsafely(()=>db.users.findFirstOrThrow());if(!success)returnserverError("DB ์กฐํšŒ ์‹คํŒจ");returnok(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 ์ž๋™ ์žฌ์‹œ๋„ (๋งˆ์ง€๋ง‰ ์‘๋‹ต ๋ฐ˜ํ™˜)constres=awaitfetchWithRetry(url,{headers: {authorization: `Bearer ${XAI_API_KEY}`}});// ์•„๋ฌด async๋‚˜ ์žฌ์‹œ๋„ (Neon ์ฝœ๋“œ์Šคํƒ€ํŠธ ๋“ฑ)constrows=awaitwithRetry(()=>db.query.users.findMany(),{shouldRetry: (e)=>String(e).includes("fetch failed"),});// LLM ์‘๋‹ต์—์„œ JSON ๋ณต๊ตฌ (```json ํŽœ์ŠคยทํŠธ๋ ˆ์ผ๋ง ์ฝค๋งˆยท๋…ธ์ด์ฆˆ ์ œ๊ฑฐ)constdata=recoverJsonFromText<{items: string[]}>(llmReply);// Open Graph ์Šคํฌ๋ž˜ํ•‘constog=awaitscrapeOg("example.com");// { title, description, image, siteName, url }// ํƒ€์ž„์กด ๋‚ ์งœtodayKST();// "2026-06-04"dateInTz(Date.now(),"America/New_York");// base62 slug + ํ˜ธ์ŠคํŠธidToSlug(42,1000);// "..." (slugToId๋กœ ์—ญ๋ณ€ํ™˜)constbase=`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("");constdebouncedQuery=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}`)});<formonSubmit={e=>{e.preventDefault();submit(inputValue);}}><inputvalue={inputValue}onChange={...}/>{error&&<pclassName="text-red-500 text-sm">{error.message}</p>}<buttondisabled={loading}>{loading ? "๋“ฑ๋ก ์ค‘โ€ฆ" : "๋“ฑ๋ก"}</button></form>

useInView

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

const{ ref, inView }=useInView({threshold: 0.1,once: true});useEffect(()=>{if(inView)fetchNextPage();},[inView]);return(<div>{posts.map(p=><PostCardkey={p.id}post={p}/>)}<divref={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

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

@m1kapp/kit

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

npmHits

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";exportdefaultfunctionApp(){return(// Watermark๊ฐ€ ๋ทฐํฌํŠธ ์ „์ฒด ๋†’์ด/์ค‘์•™์ •๋ ฌ/ํ•˜๋‹จ ํฌ๋ ˆ๋”ง์„ ๊ฐ™์ด ์ค๋‹ˆ๋‹ค.// (ํฌ๋ ˆ๋”ง ์ŠคํŠธ๋ฆฝ์€ ์…ธ ๋ฐ”๋กœ ๋ฐ‘์— ๋ถ™์–ด์„œ ์…ธ๊ณผ ํ•œ ๋ฉ์–ด๋ฆฌ๋กœ ์ค‘์•™์ •๋ ฌ๋จ โ€” ํ™”๋ฉด ์ง„์งœ ๋ฐ”๋‹ฅ์— ๊ณ ์ •๋˜๋Š” ๊ฒŒ ์•„๋‹˜)// AppShell๋งŒ ๋‹จ๋…์œผ๋กœ ์“ฐ๋ฉด ์ฝ˜ํ…์ธ  ๋†’์ด๋งŒํผ ์ชผ๊ทธ๋ผ๋“ค์–ด์š”.<Watermarkcolor="#e2603f"text="myapp"><AppShell><AppShellHeader><h1>My App</h1></AppShellHeader><AppShellContent><SectionclassName="pt-4">{/* ์ฝ˜ํ…์ธ  โ€” Divider๋กœ ์„น์…˜ ๊ตฌ๋ถ„ */}</Section></AppShellContent><TabBar><Tabhref="/"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/kitOG ์ด๋ฏธ์ง€ ์ƒ์„ฑ (์„œ๋ฒ„)
PWA@m1kapp/kitmanifest, viewport, ์„ค์น˜ ์œ ๋„
Fetch@m1kapp/kit์บ์‹ฑยท์ค‘๋ณต์ œ๊ฑฐยท์žฌ์‹œ๋„ fetch ์œ ํ‹ธ
Utils@m1kapp/kit๋‚ ์งœยท์ˆซ์ž ํฌ๋งท, ๋ฒ”์šฉ ํ›…
Server@m1kapp/kit/serverNext.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><Tabactive={tab==="home"}onClick={()=>setTab("home")}label="ํ™ˆ"icon={<HomeIcon/>}activeColor="#3b82f6"// ํ™œ์„ฑ ์ƒ‰์ƒ ์ž์œ ๋กญ๊ฒŒ ์ง€์ •/></TabBar>

๋ฐ์ดํ„ฐ ํ‘œ์‹œ

import{Avatar,Badge,StatChip,EmptyState,GrassMap}from"@m1kapp/kit";// Avatar โ€” ์ด๋‹ˆ์…œ or ์ด๋ฏธ์ง€, ์ด๋ฏธ์ง€ ๋กœ๋“œ ์‹คํŒจ ์‹œ ์ด๋‹ˆ์…œ๋กœ ์ž๋™ fallback<Avatarsrc="/photo.jpg"fallback="MH"size="md"shape="circle"/><Avatarfallback="MH"size="lg"shape="rounded"color="#3b82f6"/>// size: "xs" | "sm" | "md" | "lg" | "xl"// shape: "circle" | "rounded"// Badge โ€” ์ƒํƒœ/์นดํ…Œ๊ณ ๋ฆฌ ๋ ˆ์ด๋ธ”<Badgevariant="green">LIVE</Badge><Badgevariant="red">์˜ค๋ฅ˜</Badge><Badgevariant="blue"size="sm">์ •๋ณด</Badge>// variant: "default" | "green" | "red" | "yellow" | "blue" | "purple" | "orange"// StatChip โ€” ์ˆซ์ž stat ๋ฑƒ์ง€<StatChiplabel="๋ฐฉ๋ฌธ์ž"value={1024}/>// EmptyState โ€” ๋นˆ ๋ชฉ๋ก ํ”Œ๋ ˆ์ด์Šคํ™€๋”<EmptyStatemessage="์•„์ง ์•„๋ฌด๊ฒƒ๋„ ์—†์–ด์š”"/>// GrassMap โ€” GitHub ์Šคํƒ€์ผ ํ™œ๋™ ํžˆํŠธ๋งต<GrassMapdata={[{date: "2025-04-19",count: 42}]}accent="#3b82f6"/>

์Šค์ผˆ๋ ˆํ†ค

๋กœ๋”ฉ ํ”Œ๋ ˆ์ด์Šคํ™€๋”. className์œผ๋กœ ํฌ๊ธฐ๋ฅผ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค.

import{Skeleton}from"@m1kapp/kit";// ํ…์ŠคํŠธ ์ค„<SkeletonclassName="h-4 w-3/4"/>// ์นด๋“œ ๋ธ”๋ก<SkeletonclassName="h-32 w-full"rounded="xl"/>// ์•„๋ฐ”ํƒ€<SkeletonclassName="h-10 w-10"rounded="full"/>// ์‹ค์ „ ํŒจํ„ดfunctionPostCardSkeleton(){return(<divclassName="flex gap-3 p-4"><SkeletonclassName="h-10 w-10"rounded="full"/><divclassName="flex-1 space-y-2"><SkeletonclassName="h-4 w-2/3"/><SkeletonclassName="h-3 w-1/2"/></div></div>);}

๋ชจ๋‹ฌ / ๋‹ค์ด์–ผ๋กœ๊ทธ

backdrop ํด๋ฆญ, ESC ํ‚ค, ์Šคํฌ๋กค ์ž ๊ธˆ ์ž๋™ ์ฒ˜๋ฆฌ.

import{Dialog}from"@m1kapp/kit";// ๊ธฐ๋ณธ ์‚ฌ์šฉ<Dialogopen={open}onClose={()=>setOpen(false)}title="์„ค์ •"><pclassName="text-sm text-zinc-500">๋‚ด์šฉ</p></Dialog>// ํ™•์ธ ๋‹ค์ด์–ผ๋กœ๊ทธ<Dialogopen={confirmOpen}onClose={()=>setConfirmOpen(false)}title="์‚ญ์ œํ• ๊นŒ์š”?"><pclassName="text-sm text-zinc-500">์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์–ด์š”.</p><divclassName="flex gap-2 mt-4"><buttononClick={handleDelete}className="...">์‚ญ์ œ</button><buttononClick={()=>setConfirmOpen(false)}className="...">์ทจ์†Œ</button>
</div></Dialog>// size: "sm" (๊ธฐ๋ณธ) | "md" | "lg"// persistent: true โ€” backdrop ํด๋ฆญ / ESC๋กœ ๋‹ซ๊ธฐ ๋น„ํ™œ์„ฑํ™”

์ธํ„ฐ๋ž™์…˜

import{Button,Tooltip,Typewriter,EmojiButton,EmojiPicker}from"@m1kapp/kit";<ButtononClick={fn}>์‹œ์ž‘ํ•˜๊ธฐ</Button><Tooltiplabel="์„ค๋ช… ํ…์ŠคํŠธ"><button>hover me</button></Tooltip><Typewriterwords={["Hello","World"]}color="#3b82f6"/>// ์ด๋ชจ์ง€ ์„ ํƒ๊ธฐconst[emoji,setEmoji]=useState("๐Ÿ ");const[open,setOpen]=useState(false);<EmojiButtonemoji={emoji}onClick={()=>setOpen(true)}/><EmojiPickeropen={open}onClose={()=>setOpen(false)}current={emoji}onSelect={setEmoji}/>

๊ณต์œ 

import{ShareButton,useShare}from"@m1kapp/kit";// ๋ฒ„ํŠผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ โ€” ๋ชจ๋ฐ”์ผ์€ ๋„ค์ดํ‹ฐ๋ธŒ ๊ณต์œ , ๋ฐ์Šคํฌํƒ‘์€ ํด๋ฆฝ๋ณด๋“œ ๋ณต์‚ฌ<ShareButtonurl="https://m1k.app"title="My App"/>// ์ปค์Šคํ…€ UIconst{ share, copied, canNativeShare }=useShare({url: "https://m1k.app"});<buttononClick={()=>share()}>{copied ? "๋ณต์‚ฌ๋จ!" : "๊ณต์œ "}</button>

ํ† ์ŠคํŠธ

import{ToastProvider,useToast}from"@m1kapp/kit";// ์•ฑ ๋ฃจํŠธ ๊ฐ์‹ธ๊ธฐ<ToastProvider><App/></ToastProvider>// ์–ด๋””์„œ๋‚˜consttoast=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><scriptdangerouslySetInnerHTML={{__html: THEME_SCRIPT}}/></head>// ํ…Œ๋งˆ ๋ฒ„ํŠผ + ๋‹ค์ด์–ผ๋กœ๊ทธconst[themeOpen,setThemeOpen]=useState(false);<ThemeButtoncolor={color}dark={dark}onClick={()=>setThemeOpen(true)}/><ThemeDialogopen={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 โ€” ๊ฐ€์žฅ ์‰ฌ์›€ (ํ•˜์œ„ ์ „์ฒด์— ์ ์šฉ)<AppShellaccent="#e2603f">...</AppShell>// โ‘ก ์•„๋ฌด ๋ž˜ํผ์—๋‚˜ CSS ๋ณ€์ˆ˜๋กœ<divstyle={{"--kit-accent": "#e2603f"}asReact.CSSProperties}>...</div>// โ‘ข ์ปดํฌ๋„ŒํŠธ๋ณ„ override<Switchchecked={on}onChange={setOn}accent="#e2603f"/>

ํผ ยท ์„ค์ •

import{Field,Switch}from"@m1kapp/kit";// ๋ผ๋ฒจ๋“œ ์ธํ’‹ (stacked ๊ธฐ๋ณธ / inline / multiline)<Fieldlabel="์ด๋ฆ„"value={name}onChange={setName}placeholder="์ด๋ฆ„"/><Fieldlabel="์ด๋ฉ”์ผ"value={email}inlinereadOnly/><Fieldlabel="๋ฉ”๋ชจ"value={memo}onChange={setMemo}multilinerows={3}hint="์ž์œ ๋กญ๊ฒŒ ์ ์–ด์ฃผ์„ธ์š”"/>// on/off ํ† ๊ธ€ โ€” accent๋Š” --kit-accent ์ž๋™ ์—ฐ๋™<Switchchecked={on}onChange={setOn}aria-label="์•Œ๋ฆผ"/>

์„ธ๊ทธ๋จผํŠธ ํ† ๊ธ€

import{SegmentedControl}from"@m1kapp/kit";<SegmentedControlvalue={view}onChange={setView}options={[{value: "today",label: "์˜ค๋Š˜"},{value: "week",label: "์ด๋ฒˆ ์ฃผ"}]}/>// ์ธ๋ผ์ธ ํ† ๊ธ€์šฉ. ํ•˜๋‹จ ๊ธ€๋กœ๋ฒŒ ๋‚ด๋น„๊ฒŒ์ด์…˜์€ TabBar๋ฅผ ์“ฐ์„ธ์š”.

์ฑ„ํŒ… / ๋Œ€ํ™”

import{MessageList,ChatBubble,TypingIndicator}from"@m1kapp/kit";importtype{ChatMessage}from"@m1kapp/kit";constmessages: ChatMessage[]=[{role: "user",content: "๋‚ด์ผ 3์‹œ ํšŒ์˜ ์žก์•„์ค˜",timestamp: Date.now()},{role: "assistant",content: "๋„ค, ์žก์•„๋“œ๋ฆด๊ฒŒ์š”."},];// dayDivider: timestamp ๊ธฐ์ค€ ๋‚ ์งœ๊ฐ€ ๋ฐ”๋€Œ๋ฉด ๊ตฌ๋ถ„์„  ์ž๋™ ์‚ฝ์ž…<MessageListmessages={messages}dayDivider>{pending&&<TypingIndicator/>}</MessageList>// ์ง์ ‘ ์“ฐ๋ ค๋ฉด<ChatBubblerole="user">์˜ค๋Š˜์ผ์ •๋ณด์—ฌ์ค˜</ChatBubble><ChatBubblerole="assistant">3๊ฑด ์žˆ์–ด์š”.</ChatBubble>

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

LLM ํˆด์ฝœ์ฒ˜๋Ÿผ "์ œ์•ˆ โ†’ ํ™•์ธ โ†’ ์‹คํ–‰" ํ๋ฆ„์„ ๋ฉ”์‹œ์ง€ ์•ˆ์— ๋ฐ•์„ ๋•Œ. ๋ชจ๋‹ฌ์ด ์•„๋‹ˆ๋ผ ํ๋ฆ„์— ์ธ๋ผ์ธ๋ฉ๋‹ˆ๋‹ค.

import{ActionCard}from"@m1kapp/kit";const[state,setState]=useState<"pending"|"loading"|"done"|"cancelled">("pending");<ActionCardtitle="์ด๋ ‡๊ฒŒ ๊ธฐ๋กํ•ด๋‘˜๊นŒ์š”?"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";<ListRowaccent="#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";// ๋‹ค๋‹จ๊ณ„ ์ง„ํ–‰ ํ‘œ์‹œ (์œ„์ €๋“œ/๋ฉ€ํ‹ฐํŽ˜์ด์ฆˆ ํ”Œ๋กœ์šฐ)<Steppercurrent={phase}onStepClick={setPhase}steps={[{label: "๋ถ„์„",icon: "๐Ÿ“"},{label: "์ค€๋น„",icon: "๐Ÿ“ธ"},{label: "์ƒ์„ฑ",icon: "โœจ"},]}/>// ์ ‘๊ธฐ ์นด๋“œ (ํ—ค๋”+๋ฐฐ์ง€+์ƒํƒœ)<Collapsibleleading={1} title="ํŽ˜๋ฅด์†Œ๋‚˜ ๋ถ„์„"subtitle="์Šคํƒ€์ผ ํŒŒ์•…"completedopen={open ===1}onToggle={()=>setOpen(open===1 ? null : 1)}><p>๋ณธ๋ฌธโ€ฆ</p></Collapsible>// ์›ํ˜• ์ง„ํ–‰๋ฅ <ProgressRingvalue={7}max={10}><spanclassName="text-2xl font-black">7</span></ProgressRing>

์ž…๋ ฅ ยท ํŽธ์ง‘

import{Select,ColorPicker,InlineEdit}from"@m1kapp/kit";// ์•ต์ปค๋“œ ๋“œ๋กญ๋‹ค์šด (์นด์šดํŠธยท๋น„ํ™œ์„ฑ ์˜ต์…˜, ์™ธ๋ถ€ํด๋ฆญ/ESC ๋‹ซํž˜)<Selectvalue={cat}onChange={setCat}placeholder="๋‚œ์ด๋„"options={[{value: "a",label: "์ดˆ๊ธ‰",count: 12},{value: "b",label: "๊ณ ๊ธ‰",count: 0,disabled: true},]}/>// ์ปฌ๋Ÿฌ ํ”ผ์ปค (ํ”„๋ฆฌ์…‹ + ์ปค์Šคํ…€ hex)<ColorPickervalue={color}onChange={setColor}/>// ํƒญํ•˜์—ฌ ํŽธ์ง‘ (Enter ์ €์žฅ ยท Esc ์ทจ์†Œ)<InlineEditvalue={name}onChange={rename}className="text-lg font-bold"/>

๋ณต์‚ฌ ยท ๋ฐ์ดํ„ฐ

import{CopyButton,CodeBlock,useCopy,BarList,Countdown,Carousel,Img}from"@m1kapp/kit";<CopyButtontext="npm i @m1kapp/kit">์„ค์น˜ ๋ณต์‚ฌ</CopyButton><CodeBlocklabel="install"code="npm i @m1kapp/kit"/>const{ copied, copy }=useCopy();// keyed: copy(text, "row-3")// ๊ฐ€๋กœ ๋ง‰๋Œ€ ๋ถ„์„ ์ฐจํŠธ<BarListitems={[{label: "/",value: 120},{label: "/about",value: 64,href: "/about"}]}/>// ์นด์šดํŠธ๋‹ค์šด (D-day)<Countdownto="2026-12-31"onComplete={celebrate}/>// ์Šค์™€์ดํ”„ ์บ๋Ÿฌ์…€ (controlled, ์  ์ธ๋””์ผ€์ดํ„ฐ)<Carouselcount={slides.length}index={i}onChange={setI}>{slides[i]}</Carousel>// ๋‹ค์ค‘ URL ํด๋ฐฑ ์ด๋ฏธ์ง€<Imgcandidates={[avatarUrl,gravatar]}fallback={<Avatarfallback="MH"/>}className="h-10 w-10 rounded-full"/>

์›Œํ„ฐ๋งˆํฌ

import{Watermark}from"@m1kapp/kit";<Watermarkcolor="#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์œผ๋กœ ์ง์ ‘ ์ง€์ •ํ•˜์„ธ์š”:

// ๋„๊ธฐ / ๋ช…์‹œ ์ง€์ •<Watermarktrack={false}>โ€ฆ</Watermark>// ๋น„์ฝ˜ ๋”<WatermarktrackSlug="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.tsximport{OGImage,loadPretendard}from"@m1kapp/kit";import{ImageResponse}from"next/og";exportasyncfunctionGET(){constfont=awaitloadPretendard();returnnewImageResponse(<OGImagetype="default"title="์‚ฌ์ด๋“œ ํ”„๋กœ์ ํŠธ ์‹œ์ž‘ํ•˜๊ธฐ"sub="๋น ๋ฅด๊ฒŒ ๋งŒ๋“ค๊ณ  ๋น ๋ฅด๊ฒŒ ๋ฐฐ์šฐ๋Š”"badge="๐Ÿš€ NEW"appName="myapp"color="#3b82f6"bg="dark"// "dark" | "gradient" | "blend"domain="m1k.app"/>,{width: 1200,height: 630,fonts: [font]});}

ํ…œํ”Œ๋ฆฟ

typeํฌ๊ธฐ์šฉ๋„
default1200ร—630๊ธฐ๋ณธ OG
article1200ร—630๋ธ”๋กœ๊ทธ ํฌ์ŠคํŠธ โ€” author, date, category
stat1200ร—630๋งˆ์ผ์Šคํ†ค โ€” stat, label
product1200ร—630์ œํ’ˆ ์†Œ๊ฐœ โ€” tagline, features[]
match1200ร—630๊ฒฝ๊ธฐ ๊ฒฐ๊ณผ โ€” home, away, score
square1200ร—1200Instagram / SNS
icon512ร—512์•ฑ ์•„์ด์ฝ˜ / favicon
// article<OGImagetype="article"title="์ œ๋ชฉ"author="minho"date="2025-04-19"category="Tutorial"sub="๋ถ€์ œ"color={c}bg={bg}/>// stat<OGImagetype="stat"stat="1,000"label="๋ช…์˜ ๋ฐฉ๋ฌธ์ž"sub="๋ก ์นญ 3์ผ ๋งŒ์—"badge="๐ŸŽ‰"color={c}bg={bg}/>// product<OGImagetype="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";constpretendard=awaitloadPretendard();// Pretendard ํ•œ๊ตญ์–ด ํฐํŠธconstroboto=awaitloadGoogleFont("Roboto",700);// Google FontsconstloadEmoji=createEmojiLoader("twemoji");// ์ด๋ชจ์ง€ fallback

PWA

Manifest

public/manifest.json ๋Œ€์‹  ์ฝ”๋“œ๋กœ ๊ด€๋ฆฌ. ์•„์ด์ฝ˜ ์ด๋ฏธ์ง€ ํŒŒ์ผ ๋ถˆํ•„์š”.

// app/manifest.tsimport{createManifest}from"@m1kapp/kit";exportdefaultcreateManifest({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.tsximport{mobileViewport}from"@m1kapp/kit";exportconstviewport=mobileViewport;

๋‚ด๋ถ€์ ์œผ๋กœ CSS touch-action: pan-x pan-y์™€ input { font-size: max(16px, 1em) }๋ฅผ ์ž๋™ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค.

SVG ์•„์ด์ฝ˜

import{svgIcon}from"@m1kapp/kit";constsrc=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";// ๋ฒ„ํŠผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ<PWAInstallButtonappName="My App"iconSrc={iconSrc}label="์•ฑ์œผ๋กœ ์„ค์น˜"/>// ์ปค์Šคํ…€ UIconst{ state, install }=usePWAInstall();// state: "android-ready" | "ios-safari" | "installed" | "unsupported"if(state==="android-ready"){return<buttononClick={install}>์„ค์น˜</button>;}if(state==="ios-safari"){return<buttononClick={()=>setSheetOpen(true)}>์„ค์น˜</button>;}// iOS ์•ˆ๋‚ด ์‹œํŠธ (์ง์ ‘ ์ œ์–ด ์‹œ)<IOSInstallSheetopen={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>;returndata?.map(u=><UserCardkey={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,// ํƒญ ์ˆจ๊ธฐ๋ฉด ์ž๋™ ์ •์ง€ โ€” ๋ถˆํ•„์š”ํ•œ ์š”์ฒญ ์—†์Œ});<buttononClick={()=>isRunning ? stop() : start()}>{isRunning ? "์ •์ง€" : "์‹œ์ž‘"}</button>

createApiClient

baseURL๊ณผ ๊ณตํ†ต ํ—ค๋”๋ฅผ ํ•œ ๋ฒˆ๋งŒ ์„ค์ •ํ•˜๋ฉด ํƒ€์ž… ์•ˆ์ „ํ•œ API ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋งŒ๋“ค์–ด์ง‘๋‹ˆ๋‹ค.

// lib/api.tsimport{createApiClient,ApiError}from"@m1kapp/kit";exportconstapi=createApiClient("https://api.myapp.com",{headers: {Authorization: `Bearer ${token}`},onError: (err)=>{if(err.status===401)signOut();},});// ์‚ฌ์šฉconstme=awaitapi.get<User>("/users/me");constpost=awaitapi.post<Post>("/posts",{ title, body });awaitapi.put("/posts/1",{title: "์ˆ˜์ •๋œ ์ œ๋ชฉ"});awaitapi.delete("/posts/1");// ์—๋Ÿฌ๋Š” ApiError๋กœ ์ •๊ทœํ™”try{awaitapi.delete("/posts/1");}catch(e){if(einstanceofApiError){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 โŒexportasyncfunctionGET(req: Request){constuser=awaitcurrentUser();if(!user)returnResponse.json({error: "Unauthorized"},{status: 401});try{constdata=awaitdb.sites.findMany({where: {userId: user.id}});returnResponse.json(data);}catch{returnResponse.json({error: "Internal Server Error"},{status: 500});}}// After โœ…exportconstGET=handler(async()=>{constuser=awaitcurrentUser();if(!user)unauthorized();// throws โ†’ 401constdata=awaitdb.sites.findMany({where: {userId: user.id}});returnok(data);// 200 + JSON// ์ฒ˜๋ฆฌ๋˜์ง€ ์•Š์€ ์—๋Ÿฌ โ†’ 500 ์ž๋™});exportconstPOST=handler(async(req)=>{constuser=awaitcurrentUser();if(!user)unauthorized();const{ url }=awaitreq.json();if(!url)badRequest("url์ด ํ•„์š”ํ•ด์š”");// throws โ†’ 400constsite=awaitdb.sites.create({data: { url,userId: user.id}});returncreated(site);// 201 + JSON});

์‘๋‹ต ํ—ฌํผ

ํ•จ์ˆ˜์ƒํƒœ์„ค๋ช…
ok(data)200JSON ์‘๋‹ต
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";exportconstGET=handler(async()=>{const{ok: success, data, error }=awaitsafely(()=>db.users.findFirstOrThrow());if(!success)returnserverError("DB ์กฐํšŒ ์‹คํŒจ");returnok(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 ์ž๋™ ์žฌ์‹œ๋„ (๋งˆ์ง€๋ง‰ ์‘๋‹ต ๋ฐ˜ํ™˜)constres=awaitfetchWithRetry(url,{headers: {authorization: `Bearer ${XAI_API_KEY}`}});// ์•„๋ฌด async๋‚˜ ์žฌ์‹œ๋„ (Neon ์ฝœ๋“œ์Šคํƒ€ํŠธ ๋“ฑ)constrows=awaitwithRetry(()=>db.query.users.findMany(),{shouldRetry: (e)=>String(e).includes("fetch failed"),});// LLM ์‘๋‹ต์—์„œ JSON ๋ณต๊ตฌ (```json ํŽœ์ŠคยทํŠธ๋ ˆ์ผ๋ง ์ฝค๋งˆยท๋…ธ์ด์ฆˆ ์ œ๊ฑฐ)constdata=recoverJsonFromText<{items: string[]}>(llmReply);// Open Graph ์Šคํฌ๋ž˜ํ•‘constog=awaitscrapeOg("example.com");// { title, description, image, siteName, url }// ํƒ€์ž„์กด ๋‚ ์งœtodayKST();// "2026-06-04"dateInTz(Date.now(),"America/New_York");// base62 slug + ํ˜ธ์ŠคํŠธidToSlug(42,1000);// "..." (slugToId๋กœ ์—ญ๋ณ€ํ™˜)constbase=`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("");constdebouncedQuery=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}`)});<formonSubmit={e=>{e.preventDefault();submit(inputValue);}}><inputvalue={inputValue}onChange={...}/>{error&&<pclassName="text-red-500 text-sm">{error.message}</p>}<buttondisabled={loading}>{loading ? "๋“ฑ๋ก ์ค‘โ€ฆ" : "๋“ฑ๋ก"}</button></form>

useInView

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

const{ ref, inView }=useInView({threshold: 0.1,once: true});useEffect(()=>{if(inView)fetchNextPage();},[inView]);return(<div>{posts.map(p=><PostCardkey={p.id}post={p}/>)}<divref={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

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

@m1kapp/kit

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

npmHits

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";exportdefaultfunctionApp(){return(// Watermark๊ฐ€ ๋ทฐํฌํŠธ ์ „์ฒด ๋†’์ด/์ค‘์•™์ •๋ ฌ/ํ•˜๋‹จ ํฌ๋ ˆ๋”ง์„ ๊ฐ™์ด ์ค๋‹ˆ๋‹ค.// (ํฌ๋ ˆ๋”ง ์ŠคํŠธ๋ฆฝ์€ ์…ธ ๋ฐ”๋กœ ๋ฐ‘์— ๋ถ™์–ด์„œ ์…ธ๊ณผ ํ•œ ๋ฉ์–ด๋ฆฌ๋กœ ์ค‘์•™์ •๋ ฌ๋จ โ€” ํ™”๋ฉด ์ง„์งœ ๋ฐ”๋‹ฅ์— ๊ณ ์ •๋˜๋Š” ๊ฒŒ ์•„๋‹˜)// AppShell๋งŒ ๋‹จ๋…์œผ๋กœ ์“ฐ๋ฉด ์ฝ˜ํ…์ธ  ๋†’์ด๋งŒํผ ์ชผ๊ทธ๋ผ๋“ค์–ด์š”.<Watermarkcolor="#e2603f"text="myapp"><AppShell><AppShellHeader><h1>My App</h1></AppShellHeader><AppShellContent><SectionclassName="pt-4">{/* ์ฝ˜ํ…์ธ  โ€” Divider๋กœ ์„น์…˜ ๊ตฌ๋ถ„ */}</Section></AppShellContent><TabBar><Tabhref="/"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/kitOG ์ด๋ฏธ์ง€ ์ƒ์„ฑ (์„œ๋ฒ„)
PWA@m1kapp/kitmanifest, viewport, ์„ค์น˜ ์œ ๋„
Fetch@m1kapp/kit์บ์‹ฑยท์ค‘๋ณต์ œ๊ฑฐยท์žฌ์‹œ๋„ fetch ์œ ํ‹ธ
Utils@m1kapp/kit๋‚ ์งœยท์ˆซ์ž ํฌ๋งท, ๋ฒ”์šฉ ํ›…
Server@m1kapp/kit/serverNext.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><Tabactive={tab==="home"}onClick={()=>setTab("home")}label="ํ™ˆ"icon={<HomeIcon/>}activeColor="#3b82f6"// ํ™œ์„ฑ ์ƒ‰์ƒ ์ž์œ ๋กญ๊ฒŒ ์ง€์ •/></TabBar>

๋ฐ์ดํ„ฐ ํ‘œ์‹œ

import{Avatar,Badge,StatChip,EmptyState,GrassMap}from"@m1kapp/kit";// Avatar โ€” ์ด๋‹ˆ์…œ or ์ด๋ฏธ์ง€, ์ด๋ฏธ์ง€ ๋กœ๋“œ ์‹คํŒจ ์‹œ ์ด๋‹ˆ์…œ๋กœ ์ž๋™ fallback<Avatarsrc="/photo.jpg"fallback="MH"size="md"shape="circle"/><Avatarfallback="MH"size="lg"shape="rounded"color="#3b82f6"/>// size: "xs" | "sm" | "md" | "lg" | "xl"// shape: "circle" | "rounded"// Badge โ€” ์ƒํƒœ/์นดํ…Œ๊ณ ๋ฆฌ ๋ ˆ์ด๋ธ”<Badgevariant="green">LIVE</Badge><Badgevariant="red">์˜ค๋ฅ˜</Badge><Badgevariant="blue"size="sm">์ •๋ณด</Badge>// variant: "default" | "green" | "red" | "yellow" | "blue" | "purple" | "orange"// StatChip โ€” ์ˆซ์ž stat ๋ฑƒ์ง€<StatChiplabel="๋ฐฉ๋ฌธ์ž"value={1024}/>// EmptyState โ€” ๋นˆ ๋ชฉ๋ก ํ”Œ๋ ˆ์ด์Šคํ™€๋”<EmptyStatemessage="์•„์ง ์•„๋ฌด๊ฒƒ๋„ ์—†์–ด์š”"/>// GrassMap โ€” GitHub ์Šคํƒ€์ผ ํ™œ๋™ ํžˆํŠธ๋งต<GrassMapdata={[{date: "2025-04-19",count: 42}]}accent="#3b82f6"/>

์Šค์ผˆ๋ ˆํ†ค

๋กœ๋”ฉ ํ”Œ๋ ˆ์ด์Šคํ™€๋”. className์œผ๋กœ ํฌ๊ธฐ๋ฅผ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค.

import{Skeleton}from"@m1kapp/kit";// ํ…์ŠคํŠธ ์ค„<SkeletonclassName="h-4 w-3/4"/>// ์นด๋“œ ๋ธ”๋ก<SkeletonclassName="h-32 w-full"rounded="xl"/>// ์•„๋ฐ”ํƒ€<SkeletonclassName="h-10 w-10"rounded="full"/>// ์‹ค์ „ ํŒจํ„ดfunctionPostCardSkeleton(){return(<divclassName="flex gap-3 p-4"><SkeletonclassName="h-10 w-10"rounded="full"/><divclassName="flex-1 space-y-2"><SkeletonclassName="h-4 w-2/3"/><SkeletonclassName="h-3 w-1/2"/></div></div>);}

๋ชจ๋‹ฌ / ๋‹ค์ด์–ผ๋กœ๊ทธ

backdrop ํด๋ฆญ, ESC ํ‚ค, ์Šคํฌ๋กค ์ž ๊ธˆ ์ž๋™ ์ฒ˜๋ฆฌ.

import{Dialog}from"@m1kapp/kit";// ๊ธฐ๋ณธ ์‚ฌ์šฉ<Dialogopen={open}onClose={()=>setOpen(false)}title="์„ค์ •"><pclassName="text-sm text-zinc-500">๋‚ด์šฉ</p></Dialog>// ํ™•์ธ ๋‹ค์ด์–ผ๋กœ๊ทธ<Dialogopen={confirmOpen}onClose={()=>setConfirmOpen(false)}title="์‚ญ์ œํ• ๊นŒ์š”?"><pclassName="text-sm text-zinc-500">์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์–ด์š”.</p><divclassName="flex gap-2 mt-4"><buttononClick={handleDelete}className="...">์‚ญ์ œ</button><buttononClick={()=>setConfirmOpen(false)}className="...">์ทจ์†Œ</button>
</div></Dialog>// size: "sm" (๊ธฐ๋ณธ) | "md" | "lg"// persistent: true โ€” backdrop ํด๋ฆญ / ESC๋กœ ๋‹ซ๊ธฐ ๋น„ํ™œ์„ฑํ™”

์ธํ„ฐ๋ž™์…˜

import{Button,Tooltip,Typewriter,EmojiButton,EmojiPicker}from"@m1kapp/kit";<ButtononClick={fn}>์‹œ์ž‘ํ•˜๊ธฐ</Button><Tooltiplabel="์„ค๋ช… ํ…์ŠคํŠธ"><button>hover me</button></Tooltip><Typewriterwords={["Hello","World"]}color="#3b82f6"/>// ์ด๋ชจ์ง€ ์„ ํƒ๊ธฐconst[emoji,setEmoji]=useState("๐Ÿ ");const[open,setOpen]=useState(false);<EmojiButtonemoji={emoji}onClick={()=>setOpen(true)}/><EmojiPickeropen={open}onClose={()=>setOpen(false)}current={emoji}onSelect={setEmoji}/>

๊ณต์œ 

import{ShareButton,useShare}from"@m1kapp/kit";// ๋ฒ„ํŠผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ โ€” ๋ชจ๋ฐ”์ผ์€ ๋„ค์ดํ‹ฐ๋ธŒ ๊ณต์œ , ๋ฐ์Šคํฌํƒ‘์€ ํด๋ฆฝ๋ณด๋“œ ๋ณต์‚ฌ<ShareButtonurl="https://m1k.app"title="My App"/>// ์ปค์Šคํ…€ UIconst{ share, copied, canNativeShare }=useShare({url: "https://m1k.app"});<buttononClick={()=>share()}>{copied ? "๋ณต์‚ฌ๋จ!" : "๊ณต์œ "}</button>

ํ† ์ŠคํŠธ

import{ToastProvider,useToast}from"@m1kapp/kit";// ์•ฑ ๋ฃจํŠธ ๊ฐ์‹ธ๊ธฐ<ToastProvider><App/></ToastProvider>// ์–ด๋””์„œ๋‚˜consttoast=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><scriptdangerouslySetInnerHTML={{__html: THEME_SCRIPT}}/></head>// ํ…Œ๋งˆ ๋ฒ„ํŠผ + ๋‹ค์ด์–ผ๋กœ๊ทธconst[themeOpen,setThemeOpen]=useState(false);<ThemeButtoncolor={color}dark={dark}onClick={()=>setThemeOpen(true)}/><ThemeDialogopen={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 โ€” ๊ฐ€์žฅ ์‰ฌ์›€ (ํ•˜์œ„ ์ „์ฒด์— ์ ์šฉ)<AppShellaccent="#e2603f">...</AppShell>// โ‘ก ์•„๋ฌด ๋ž˜ํผ์—๋‚˜ CSS ๋ณ€์ˆ˜๋กœ<divstyle={{"--kit-accent": "#e2603f"}asReact.CSSProperties}>...</div>// โ‘ข ์ปดํฌ๋„ŒํŠธ๋ณ„ override<Switchchecked={on}onChange={setOn}accent="#e2603f"/>

ํผ ยท ์„ค์ •

import{Field,Switch}from"@m1kapp/kit";// ๋ผ๋ฒจ๋“œ ์ธํ’‹ (stacked ๊ธฐ๋ณธ / inline / multiline)<Fieldlabel="์ด๋ฆ„"value={name}onChange={setName}placeholder="์ด๋ฆ„"/><Fieldlabel="์ด๋ฉ”์ผ"value={email}inlinereadOnly/><Fieldlabel="๋ฉ”๋ชจ"value={memo}onChange={setMemo}multilinerows={3}hint="์ž์œ ๋กญ๊ฒŒ ์ ์–ด์ฃผ์„ธ์š”"/>// on/off ํ† ๊ธ€ โ€” accent๋Š” --kit-accent ์ž๋™ ์—ฐ๋™<Switchchecked={on}onChange={setOn}aria-label="์•Œ๋ฆผ"/>

์„ธ๊ทธ๋จผํŠธ ํ† ๊ธ€

import{SegmentedControl}from"@m1kapp/kit";<SegmentedControlvalue={view}onChange={setView}options={[{value: "today",label: "์˜ค๋Š˜"},{value: "week",label: "์ด๋ฒˆ ์ฃผ"}]}/>// ์ธ๋ผ์ธ ํ† ๊ธ€์šฉ. ํ•˜๋‹จ ๊ธ€๋กœ๋ฒŒ ๋‚ด๋น„๊ฒŒ์ด์…˜์€ TabBar๋ฅผ ์“ฐ์„ธ์š”.

์ฑ„ํŒ… / ๋Œ€ํ™”

import{MessageList,ChatBubble,TypingIndicator}from"@m1kapp/kit";importtype{ChatMessage}from"@m1kapp/kit";constmessages: ChatMessage[]=[{role: "user",content: "๋‚ด์ผ 3์‹œ ํšŒ์˜ ์žก์•„์ค˜",timestamp: Date.now()},{role: "assistant",content: "๋„ค, ์žก์•„๋“œ๋ฆด๊ฒŒ์š”."},];// dayDivider: timestamp ๊ธฐ์ค€ ๋‚ ์งœ๊ฐ€ ๋ฐ”๋€Œ๋ฉด ๊ตฌ๋ถ„์„  ์ž๋™ ์‚ฝ์ž…<MessageListmessages={messages}dayDivider>{pending&&<TypingIndicator/>}</MessageList>// ์ง์ ‘ ์“ฐ๋ ค๋ฉด<ChatBubblerole="user">์˜ค๋Š˜์ผ์ •๋ณด์—ฌ์ค˜</ChatBubble><ChatBubblerole="assistant">3๊ฑด ์žˆ์–ด์š”.</ChatBubble>

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

LLM ํˆด์ฝœ์ฒ˜๋Ÿผ "์ œ์•ˆ โ†’ ํ™•์ธ โ†’ ์‹คํ–‰" ํ๋ฆ„์„ ๋ฉ”์‹œ์ง€ ์•ˆ์— ๋ฐ•์„ ๋•Œ. ๋ชจ๋‹ฌ์ด ์•„๋‹ˆ๋ผ ํ๋ฆ„์— ์ธ๋ผ์ธ๋ฉ๋‹ˆ๋‹ค.

import{ActionCard}from"@m1kapp/kit";const[state,setState]=useState<"pending"|"loading"|"done"|"cancelled">("pending");<ActionCardtitle="์ด๋ ‡๊ฒŒ ๊ธฐ๋กํ•ด๋‘˜๊นŒ์š”?"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";<ListRowaccent="#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";// ๋‹ค๋‹จ๊ณ„ ์ง„ํ–‰ ํ‘œ์‹œ (์œ„์ €๋“œ/๋ฉ€ํ‹ฐํŽ˜์ด์ฆˆ ํ”Œ๋กœ์šฐ)<Steppercurrent={phase}onStepClick={setPhase}steps={[{label: "๋ถ„์„",icon: "๐Ÿ“"},{label: "์ค€๋น„",icon: "๐Ÿ“ธ"},{label: "์ƒ์„ฑ",icon: "โœจ"},]}/>// ์ ‘๊ธฐ ์นด๋“œ (ํ—ค๋”+๋ฐฐ์ง€+์ƒํƒœ)<Collapsibleleading={1} title="ํŽ˜๋ฅด์†Œ๋‚˜ ๋ถ„์„"subtitle="์Šคํƒ€์ผ ํŒŒ์•…"completedopen={open ===1}onToggle={()=>setOpen(open===1 ? null : 1)}><p>๋ณธ๋ฌธโ€ฆ</p></Collapsible>// ์›ํ˜• ์ง„ํ–‰๋ฅ <ProgressRingvalue={7}max={10}><spanclassName="text-2xl font-black">7</span></ProgressRing>

์ž…๋ ฅ ยท ํŽธ์ง‘

import{Select,ColorPicker,InlineEdit}from"@m1kapp/kit";// ์•ต์ปค๋“œ ๋“œ๋กญ๋‹ค์šด (์นด์šดํŠธยท๋น„ํ™œ์„ฑ ์˜ต์…˜, ์™ธ๋ถ€ํด๋ฆญ/ESC ๋‹ซํž˜)<Selectvalue={cat}onChange={setCat}placeholder="๋‚œ์ด๋„"options={[{value: "a",label: "์ดˆ๊ธ‰",count: 12},{value: "b",label: "๊ณ ๊ธ‰",count: 0,disabled: true},]}/>// ์ปฌ๋Ÿฌ ํ”ผ์ปค (ํ”„๋ฆฌ์…‹ + ์ปค์Šคํ…€ hex)<ColorPickervalue={color}onChange={setColor}/>// ํƒญํ•˜์—ฌ ํŽธ์ง‘ (Enter ์ €์žฅ ยท Esc ์ทจ์†Œ)<InlineEditvalue={name}onChange={rename}className="text-lg font-bold"/>

๋ณต์‚ฌ ยท ๋ฐ์ดํ„ฐ

import{CopyButton,CodeBlock,useCopy,BarList,Countdown,Carousel,Img}from"@m1kapp/kit";<CopyButtontext="npm i @m1kapp/kit">์„ค์น˜ ๋ณต์‚ฌ</CopyButton><CodeBlocklabel="install"code="npm i @m1kapp/kit"/>const{ copied, copy }=useCopy();// keyed: copy(text, "row-3")// ๊ฐ€๋กœ ๋ง‰๋Œ€ ๋ถ„์„ ์ฐจํŠธ<BarListitems={[{label: "/",value: 120},{label: "/about",value: 64,href: "/about"}]}/>// ์นด์šดํŠธ๋‹ค์šด (D-day)<Countdownto="2026-12-31"onComplete={celebrate}/>// ์Šค์™€์ดํ”„ ์บ๋Ÿฌ์…€ (controlled, ์  ์ธ๋””์ผ€์ดํ„ฐ)<Carouselcount={slides.length}index={i}onChange={setI}>{slides[i]}</Carousel>// ๋‹ค์ค‘ URL ํด๋ฐฑ ์ด๋ฏธ์ง€<Imgcandidates={[avatarUrl,gravatar]}fallback={<Avatarfallback="MH"/>}className="h-10 w-10 rounded-full"/>

์›Œํ„ฐ๋งˆํฌ

import{Watermark}from"@m1kapp/kit";<Watermarkcolor="#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์œผ๋กœ ์ง์ ‘ ์ง€์ •ํ•˜์„ธ์š”:

// ๋„๊ธฐ / ๋ช…์‹œ ์ง€์ •<Watermarktrack={false}>โ€ฆ</Watermark>// ๋น„์ฝ˜ ๋”<WatermarktrackSlug="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.tsximport{OGImage,loadPretendard}from"@m1kapp/kit";import{ImageResponse}from"next/og";exportasyncfunctionGET(){constfont=awaitloadPretendard();returnnewImageResponse(<OGImagetype="default"title="์‚ฌ์ด๋“œ ํ”„๋กœ์ ํŠธ ์‹œ์ž‘ํ•˜๊ธฐ"sub="๋น ๋ฅด๊ฒŒ ๋งŒ๋“ค๊ณ  ๋น ๋ฅด๊ฒŒ ๋ฐฐ์šฐ๋Š”"badge="๐Ÿš€ NEW"appName="myapp"color="#3b82f6"bg="dark"// "dark" | "gradient" | "blend"domain="m1k.app"/>,{width: 1200,height: 630,fonts: [font]});}

ํ…œํ”Œ๋ฆฟ

typeํฌ๊ธฐ์šฉ๋„
default1200ร—630๊ธฐ๋ณธ OG
article1200ร—630๋ธ”๋กœ๊ทธ ํฌ์ŠคํŠธ โ€” author, date, category
stat1200ร—630๋งˆ์ผ์Šคํ†ค โ€” stat, label
product1200ร—630์ œํ’ˆ ์†Œ๊ฐœ โ€” tagline, features[]
match1200ร—630๊ฒฝ๊ธฐ ๊ฒฐ๊ณผ โ€” home, away, score
square1200ร—1200Instagram / SNS
icon512ร—512์•ฑ ์•„์ด์ฝ˜ / favicon
// article<OGImagetype="article"title="์ œ๋ชฉ"author="minho"date="2025-04-19"category="Tutorial"sub="๋ถ€์ œ"color={c}bg={bg}/>// stat<OGImagetype="stat"stat="1,000"label="๋ช…์˜ ๋ฐฉ๋ฌธ์ž"sub="๋ก ์นญ 3์ผ ๋งŒ์—"badge="๐ŸŽ‰"color={c}bg={bg}/>// product<OGImagetype="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";constpretendard=awaitloadPretendard();// Pretendard ํ•œ๊ตญ์–ด ํฐํŠธconstroboto=awaitloadGoogleFont("Roboto",700);// Google FontsconstloadEmoji=createEmojiLoader("twemoji");// ์ด๋ชจ์ง€ fallback

PWA

Manifest

public/manifest.json ๋Œ€์‹  ์ฝ”๋“œ๋กœ ๊ด€๋ฆฌ. ์•„์ด์ฝ˜ ์ด๋ฏธ์ง€ ํŒŒ์ผ ๋ถˆํ•„์š”.

// app/manifest.tsimport{createManifest}from"@m1kapp/kit";exportdefaultcreateManifest({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.tsximport{mobileViewport}from"@m1kapp/kit";exportconstviewport=mobileViewport;

๋‚ด๋ถ€์ ์œผ๋กœ CSS touch-action: pan-x pan-y์™€ input { font-size: max(16px, 1em) }๋ฅผ ์ž๋™ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค.

SVG ์•„์ด์ฝ˜

import{svgIcon}from"@m1kapp/kit";constsrc=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";// ๋ฒ„ํŠผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ<PWAInstallButtonappName="My App"iconSrc={iconSrc}label="์•ฑ์œผ๋กœ ์„ค์น˜"/>// ์ปค์Šคํ…€ UIconst{ state, install }=usePWAInstall();// state: "android-ready" | "ios-safari" | "installed" | "unsupported"if(state==="android-ready"){return<buttononClick={install}>์„ค์น˜</button>;}if(state==="ios-safari"){return<buttononClick={()=>setSheetOpen(true)}>์„ค์น˜</button>;}// iOS ์•ˆ๋‚ด ์‹œํŠธ (์ง์ ‘ ์ œ์–ด ์‹œ)<IOSInstallSheetopen={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>;returndata?.map(u=><UserCardkey={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,// ํƒญ ์ˆจ๊ธฐ๋ฉด ์ž๋™ ์ •์ง€ โ€” ๋ถˆํ•„์š”ํ•œ ์š”์ฒญ ์—†์Œ});<buttononClick={()=>isRunning ? stop() : start()}>{isRunning ? "์ •์ง€" : "์‹œ์ž‘"}</button>

createApiClient

baseURL๊ณผ ๊ณตํ†ต ํ—ค๋”๋ฅผ ํ•œ ๋ฒˆ๋งŒ ์„ค์ •ํ•˜๋ฉด ํƒ€์ž… ์•ˆ์ „ํ•œ API ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋งŒ๋“ค์–ด์ง‘๋‹ˆ๋‹ค.

// lib/api.tsimport{createApiClient,ApiError}from"@m1kapp/kit";exportconstapi=createApiClient("https://api.myapp.com",{headers: {Authorization: `Bearer ${token}`},onError: (err)=>{if(err.status===401)signOut();},});// ์‚ฌ์šฉconstme=awaitapi.get<User>("/users/me");constpost=awaitapi.post<Post>("/posts",{ title, body });awaitapi.put("/posts/1",{title: "์ˆ˜์ •๋œ ์ œ๋ชฉ"});awaitapi.delete("/posts/1");// ์—๋Ÿฌ๋Š” ApiError๋กœ ์ •๊ทœํ™”try{awaitapi.delete("/posts/1");}catch(e){if(einstanceofApiError){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 โŒexportasyncfunctionGET(req: Request){constuser=awaitcurrentUser();if(!user)returnResponse.json({error: "Unauthorized"},{status: 401});try{constdata=awaitdb.sites.findMany({where: {userId: user.id}});returnResponse.json(data);}catch{returnResponse.json({error: "Internal Server Error"},{status: 500});}}// After โœ…exportconstGET=handler(async()=>{constuser=awaitcurrentUser();if(!user)unauthorized();// throws โ†’ 401constdata=awaitdb.sites.findMany({where: {userId: user.id}});returnok(data);// 200 + JSON// ์ฒ˜๋ฆฌ๋˜์ง€ ์•Š์€ ์—๋Ÿฌ โ†’ 500 ์ž๋™});exportconstPOST=handler(async(req)=>{constuser=awaitcurrentUser();if(!user)unauthorized();const{ url }=awaitreq.json();if(!url)badRequest("url์ด ํ•„์š”ํ•ด์š”");// throws โ†’ 400constsite=awaitdb.sites.create({data: { url,userId: user.id}});returncreated(site);// 201 + JSON});

์‘๋‹ต ํ—ฌํผ

ํ•จ์ˆ˜์ƒํƒœ์„ค๋ช…
ok(data)200JSON ์‘๋‹ต
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";exportconstGET=handler(async()=>{const{ok: success, data, error }=awaitsafely(()=>db.users.findFirstOrThrow());if(!success)returnserverError("DB ์กฐํšŒ ์‹คํŒจ");returnok(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 ์ž๋™ ์žฌ์‹œ๋„ (๋งˆ์ง€๋ง‰ ์‘๋‹ต ๋ฐ˜ํ™˜)constres=awaitfetchWithRetry(url,{headers: {authorization: `Bearer ${XAI_API_KEY}`}});// ์•„๋ฌด async๋‚˜ ์žฌ์‹œ๋„ (Neon ์ฝœ๋“œ์Šคํƒ€ํŠธ ๋“ฑ)constrows=awaitwithRetry(()=>db.query.users.findMany(),{shouldRetry: (e)=>String(e).includes("fetch failed"),});// LLM ์‘๋‹ต์—์„œ JSON ๋ณต๊ตฌ (```json ํŽœ์ŠคยทํŠธ๋ ˆ์ผ๋ง ์ฝค๋งˆยท๋…ธ์ด์ฆˆ ์ œ๊ฑฐ)constdata=recoverJsonFromText<{items: string[]}>(llmReply);// Open Graph ์Šคํฌ๋ž˜ํ•‘constog=awaitscrapeOg("example.com");// { title, description, image, siteName, url }// ํƒ€์ž„์กด ๋‚ ์งœtodayKST();// "2026-06-04"dateInTz(Date.now(),"America/New_York");// base62 slug + ํ˜ธ์ŠคํŠธidToSlug(42,1000);// "..." (slugToId๋กœ ์—ญ๋ณ€ํ™˜)constbase=`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("");constdebouncedQuery=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}`)});<formonSubmit={e=>{e.preventDefault();submit(inputValue);}}><inputvalue={inputValue}onChange={...}/>{error&&<pclassName="text-red-500 text-sm">{error.message}</p>}<buttondisabled={loading}>{loading ? "๋“ฑ๋ก ์ค‘โ€ฆ" : "๋“ฑ๋ก"}</button></form>

useInView

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

const{ ref, inView }=useInView({threshold: 0.1,once: true});useEffect(()=>{if(inView)fetchNextPage();},[inView]);return(<div>{posts.map(p=><PostCardkey={p.id}post={p}/>)}<divref={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

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

@m1kapp/kit

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

npmHits

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";exportdefaultfunctionApp(){return(// Watermark๊ฐ€ ๋ทฐํฌํŠธ ์ „์ฒด ๋†’์ด/์ค‘์•™์ •๋ ฌ/ํ•˜๋‹จ ํฌ๋ ˆ๋”ง์„ ๊ฐ™์ด ์ค๋‹ˆ๋‹ค.// (ํฌ๋ ˆ๋”ง ์ŠคํŠธ๋ฆฝ์€ ์…ธ ๋ฐ”๋กœ ๋ฐ‘์— ๋ถ™์–ด์„œ ์…ธ๊ณผ ํ•œ ๋ฉ์–ด๋ฆฌ๋กœ ์ค‘์•™์ •๋ ฌ๋จ โ€” ํ™”๋ฉด ์ง„์งœ ๋ฐ”๋‹ฅ์— ๊ณ ์ •๋˜๋Š” ๊ฒŒ ์•„๋‹˜)// AppShell๋งŒ ๋‹จ๋…์œผ๋กœ ์“ฐ๋ฉด ์ฝ˜ํ…์ธ  ๋†’์ด๋งŒํผ ์ชผ๊ทธ๋ผ๋“ค์–ด์š”.<Watermarkcolor="#e2603f"text="myapp"><AppShell><AppShellHeader><h1>My App</h1></AppShellHeader><AppShellContent><SectionclassName="pt-4">{/* ์ฝ˜ํ…์ธ  โ€” Divider๋กœ ์„น์…˜ ๊ตฌ๋ถ„ */}</Section></AppShellContent><TabBar><Tabhref="/"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/kitOG ์ด๋ฏธ์ง€ ์ƒ์„ฑ (์„œ๋ฒ„)
PWA@m1kapp/kitmanifest, viewport, ์„ค์น˜ ์œ ๋„
Fetch@m1kapp/kit์บ์‹ฑยท์ค‘๋ณต์ œ๊ฑฐยท์žฌ์‹œ๋„ fetch ์œ ํ‹ธ
Utils@m1kapp/kit๋‚ ์งœยท์ˆซ์ž ํฌ๋งท, ๋ฒ”์šฉ ํ›…
Server@m1kapp/kit/serverNext.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><Tabactive={tab==="home"}onClick={()=>setTab("home")}label="ํ™ˆ"icon={<HomeIcon/>}activeColor="#3b82f6"// ํ™œ์„ฑ ์ƒ‰์ƒ ์ž์œ ๋กญ๊ฒŒ ์ง€์ •/></TabBar>

๋ฐ์ดํ„ฐ ํ‘œ์‹œ

import{Avatar,Badge,StatChip,EmptyState,GrassMap}from"@m1kapp/kit";// Avatar โ€” ์ด๋‹ˆ์…œ or ์ด๋ฏธ์ง€, ์ด๋ฏธ์ง€ ๋กœ๋“œ ์‹คํŒจ ์‹œ ์ด๋‹ˆ์…œ๋กœ ์ž๋™ fallback<Avatarsrc="/photo.jpg"fallback="MH"size="md"shape="circle"/><Avatarfallback="MH"size="lg"shape="rounded"color="#3b82f6"/>// size: "xs" | "sm" | "md" | "lg" | "xl"// shape: "circle" | "rounded"// Badge โ€” ์ƒํƒœ/์นดํ…Œ๊ณ ๋ฆฌ ๋ ˆ์ด๋ธ”<Badgevariant="green">LIVE</Badge><Badgevariant="red">์˜ค๋ฅ˜</Badge><Badgevariant="blue"size="sm">์ •๋ณด</Badge>// variant: "default" | "green" | "red" | "yellow" | "blue" | "purple" | "orange"// StatChip โ€” ์ˆซ์ž stat ๋ฑƒ์ง€<StatChiplabel="๋ฐฉ๋ฌธ์ž"value={1024}/>// EmptyState โ€” ๋นˆ ๋ชฉ๋ก ํ”Œ๋ ˆ์ด์Šคํ™€๋”<EmptyStatemessage="์•„์ง ์•„๋ฌด๊ฒƒ๋„ ์—†์–ด์š”"/>// GrassMap โ€” GitHub ์Šคํƒ€์ผ ํ™œ๋™ ํžˆํŠธ๋งต<GrassMapdata={[{date: "2025-04-19",count: 42}]}accent="#3b82f6"/>

์Šค์ผˆ๋ ˆํ†ค

๋กœ๋”ฉ ํ”Œ๋ ˆ์ด์Šคํ™€๋”. className์œผ๋กœ ํฌ๊ธฐ๋ฅผ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค.

import{Skeleton}from"@m1kapp/kit";// ํ…์ŠคํŠธ ์ค„<SkeletonclassName="h-4 w-3/4"/>// ์นด๋“œ ๋ธ”๋ก<SkeletonclassName="h-32 w-full"rounded="xl"/>// ์•„๋ฐ”ํƒ€<SkeletonclassName="h-10 w-10"rounded="full"/>// ์‹ค์ „ ํŒจํ„ดfunctionPostCardSkeleton(){return(<divclassName="flex gap-3 p-4"><SkeletonclassName="h-10 w-10"rounded="full"/><divclassName="flex-1 space-y-2"><SkeletonclassName="h-4 w-2/3"/><SkeletonclassName="h-3 w-1/2"/></div></div>);}

๋ชจ๋‹ฌ / ๋‹ค์ด์–ผ๋กœ๊ทธ

backdrop ํด๋ฆญ, ESC ํ‚ค, ์Šคํฌ๋กค ์ž ๊ธˆ ์ž๋™ ์ฒ˜๋ฆฌ.

import{Dialog}from"@m1kapp/kit";// ๊ธฐ๋ณธ ์‚ฌ์šฉ<Dialogopen={open}onClose={()=>setOpen(false)}title="์„ค์ •"><pclassName="text-sm text-zinc-500">๋‚ด์šฉ</p></Dialog>// ํ™•์ธ ๋‹ค์ด์–ผ๋กœ๊ทธ<Dialogopen={confirmOpen}onClose={()=>setConfirmOpen(false)}title="์‚ญ์ œํ• ๊นŒ์š”?"><pclassName="text-sm text-zinc-500">์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์–ด์š”.</p><divclassName="flex gap-2 mt-4"><buttononClick={handleDelete}className="...">์‚ญ์ œ</button><buttononClick={()=>setConfirmOpen(false)}className="...">์ทจ์†Œ</button>
</div></Dialog>// size: "sm" (๊ธฐ๋ณธ) | "md" | "lg"// persistent: true โ€” backdrop ํด๋ฆญ / ESC๋กœ ๋‹ซ๊ธฐ ๋น„ํ™œ์„ฑํ™”

์ธํ„ฐ๋ž™์…˜

import{Button,Tooltip,Typewriter,EmojiButton,EmojiPicker}from"@m1kapp/kit";<ButtononClick={fn}>์‹œ์ž‘ํ•˜๊ธฐ</Button><Tooltiplabel="์„ค๋ช… ํ…์ŠคํŠธ"><button>hover me</button></Tooltip><Typewriterwords={["Hello","World"]}color="#3b82f6"/>// ์ด๋ชจ์ง€ ์„ ํƒ๊ธฐconst[emoji,setEmoji]=useState("๐Ÿ ");const[open,setOpen]=useState(false);<EmojiButtonemoji={emoji}onClick={()=>setOpen(true)}/><EmojiPickeropen={open}onClose={()=>setOpen(false)}current={emoji}onSelect={setEmoji}/>

๊ณต์œ 

import{ShareButton,useShare}from"@m1kapp/kit";// ๋ฒ„ํŠผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ โ€” ๋ชจ๋ฐ”์ผ์€ ๋„ค์ดํ‹ฐ๋ธŒ ๊ณต์œ , ๋ฐ์Šคํฌํƒ‘์€ ํด๋ฆฝ๋ณด๋“œ ๋ณต์‚ฌ<ShareButtonurl="https://m1k.app"title="My App"/>// ์ปค์Šคํ…€ UIconst{ share, copied, canNativeShare }=useShare({url: "https://m1k.app"});<buttononClick={()=>share()}>{copied ? "๋ณต์‚ฌ๋จ!" : "๊ณต์œ "}</button>

ํ† ์ŠคํŠธ

import{ToastProvider,useToast}from"@m1kapp/kit";// ์•ฑ ๋ฃจํŠธ ๊ฐ์‹ธ๊ธฐ<ToastProvider><App/></ToastProvider>// ์–ด๋””์„œ๋‚˜consttoast=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><scriptdangerouslySetInnerHTML={{__html: THEME_SCRIPT}}/></head>// ํ…Œ๋งˆ ๋ฒ„ํŠผ + ๋‹ค์ด์–ผ๋กœ๊ทธconst[themeOpen,setThemeOpen]=useState(false);<ThemeButtoncolor={color}dark={dark}onClick={()=>setThemeOpen(true)}/><ThemeDialogopen={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 โ€” ๊ฐ€์žฅ ์‰ฌ์›€ (ํ•˜์œ„ ์ „์ฒด์— ์ ์šฉ)<AppShellaccent="#e2603f">...</AppShell>// โ‘ก ์•„๋ฌด ๋ž˜ํผ์—๋‚˜ CSS ๋ณ€์ˆ˜๋กœ<divstyle={{"--kit-accent": "#e2603f"}asReact.CSSProperties}>...</div>// โ‘ข ์ปดํฌ๋„ŒํŠธ๋ณ„ override<Switchchecked={on}onChange={setOn}accent="#e2603f"/>

ํผ ยท ์„ค์ •

import{Field,Switch}from"@m1kapp/kit";// ๋ผ๋ฒจ๋“œ ์ธํ’‹ (stacked ๊ธฐ๋ณธ / inline / multiline)<Fieldlabel="์ด๋ฆ„"value={name}onChange={setName}placeholder="์ด๋ฆ„"/><Fieldlabel="์ด๋ฉ”์ผ"value={email}inlinereadOnly/><Fieldlabel="๋ฉ”๋ชจ"value={memo}onChange={setMemo}multilinerows={3}hint="์ž์œ ๋กญ๊ฒŒ ์ ์–ด์ฃผ์„ธ์š”"/>// on/off ํ† ๊ธ€ โ€” accent๋Š” --kit-accent ์ž๋™ ์—ฐ๋™<Switchchecked={on}onChange={setOn}aria-label="์•Œ๋ฆผ"/>

์„ธ๊ทธ๋จผํŠธ ํ† ๊ธ€

import{SegmentedControl}from"@m1kapp/kit";<SegmentedControlvalue={view}onChange={setView}options={[{value: "today",label: "์˜ค๋Š˜"},{value: "week",label: "์ด๋ฒˆ ์ฃผ"}]}/>// ์ธ๋ผ์ธ ํ† ๊ธ€์šฉ. ํ•˜๋‹จ ๊ธ€๋กœ๋ฒŒ ๋‚ด๋น„๊ฒŒ์ด์…˜์€ TabBar๋ฅผ ์“ฐ์„ธ์š”.

์ฑ„ํŒ… / ๋Œ€ํ™”

import{MessageList,ChatBubble,TypingIndicator}from"@m1kapp/kit";importtype{ChatMessage}from"@m1kapp/kit";constmessages: ChatMessage[]=[{role: "user",content: "๋‚ด์ผ 3์‹œ ํšŒ์˜ ์žก์•„์ค˜",timestamp: Date.now()},{role: "assistant",content: "๋„ค, ์žก์•„๋“œ๋ฆด๊ฒŒ์š”."},];// dayDivider: timestamp ๊ธฐ์ค€ ๋‚ ์งœ๊ฐ€ ๋ฐ”๋€Œ๋ฉด ๊ตฌ๋ถ„์„  ์ž๋™ ์‚ฝ์ž…<MessageListmessages={messages}dayDivider>{pending&&<TypingIndicator/>}</MessageList>// ์ง์ ‘ ์“ฐ๋ ค๋ฉด<ChatBubblerole="user">์˜ค๋Š˜์ผ์ •๋ณด์—ฌ์ค˜</ChatBubble><ChatBubblerole="assistant">3๊ฑด ์žˆ์–ด์š”.</ChatBubble>

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

LLM ํˆด์ฝœ์ฒ˜๋Ÿผ "์ œ์•ˆ โ†’ ํ™•์ธ โ†’ ์‹คํ–‰" ํ๋ฆ„์„ ๋ฉ”์‹œ์ง€ ์•ˆ์— ๋ฐ•์„ ๋•Œ. ๋ชจ๋‹ฌ์ด ์•„๋‹ˆ๋ผ ํ๋ฆ„์— ์ธ๋ผ์ธ๋ฉ๋‹ˆ๋‹ค.

import{ActionCard}from"@m1kapp/kit";const[state,setState]=useState<"pending"|"loading"|"done"|"cancelled">("pending");<ActionCardtitle="์ด๋ ‡๊ฒŒ ๊ธฐ๋กํ•ด๋‘˜๊นŒ์š”?"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";<ListRowaccent="#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";// ๋‹ค๋‹จ๊ณ„ ์ง„ํ–‰ ํ‘œ์‹œ (์œ„์ €๋“œ/๋ฉ€ํ‹ฐํŽ˜์ด์ฆˆ ํ”Œ๋กœ์šฐ)<Steppercurrent={phase}onStepClick={setPhase}steps={[{label: "๋ถ„์„",icon: "๐Ÿ“"},{label: "์ค€๋น„",icon: "๐Ÿ“ธ"},{label: "์ƒ์„ฑ",icon: "โœจ"},]}/>// ์ ‘๊ธฐ ์นด๋“œ (ํ—ค๋”+๋ฐฐ์ง€+์ƒํƒœ)<Collapsibleleading={1} title="ํŽ˜๋ฅด์†Œ๋‚˜ ๋ถ„์„"subtitle="์Šคํƒ€์ผ ํŒŒ์•…"completedopen={open ===1}onToggle={()=>setOpen(open===1 ? null : 1)}><p>๋ณธ๋ฌธโ€ฆ</p></Collapsible>// ์›ํ˜• ์ง„ํ–‰๋ฅ <ProgressRingvalue={7}max={10}><spanclassName="text-2xl font-black">7</span></ProgressRing>

์ž…๋ ฅ ยท ํŽธ์ง‘

import{Select,ColorPicker,InlineEdit}from"@m1kapp/kit";// ์•ต์ปค๋“œ ๋“œ๋กญ๋‹ค์šด (์นด์šดํŠธยท๋น„ํ™œ์„ฑ ์˜ต์…˜, ์™ธ๋ถ€ํด๋ฆญ/ESC ๋‹ซํž˜)<Selectvalue={cat}onChange={setCat}placeholder="๋‚œ์ด๋„"options={[{value: "a",label: "์ดˆ๊ธ‰",count: 12},{value: "b",label: "๊ณ ๊ธ‰",count: 0,disabled: true},]}/>// ์ปฌ๋Ÿฌ ํ”ผ์ปค (ํ”„๋ฆฌ์…‹ + ์ปค์Šคํ…€ hex)<ColorPickervalue={color}onChange={setColor}/>// ํƒญํ•˜์—ฌ ํŽธ์ง‘ (Enter ์ €์žฅ ยท Esc ์ทจ์†Œ)<InlineEditvalue={name}onChange={rename}className="text-lg font-bold"/>

๋ณต์‚ฌ ยท ๋ฐ์ดํ„ฐ

import{CopyButton,CodeBlock,useCopy,BarList,Countdown,Carousel,Img}from"@m1kapp/kit";<CopyButtontext="npm i @m1kapp/kit">์„ค์น˜ ๋ณต์‚ฌ</CopyButton><CodeBlocklabel="install"code="npm i @m1kapp/kit"/>const{ copied, copy }=useCopy();// keyed: copy(text, "row-3")// ๊ฐ€๋กœ ๋ง‰๋Œ€ ๋ถ„์„ ์ฐจํŠธ<BarListitems={[{label: "/",value: 120},{label: "/about",value: 64,href: "/about"}]}/>// ์นด์šดํŠธ๋‹ค์šด (D-day)<Countdownto="2026-12-31"onComplete={celebrate}/>// ์Šค์™€์ดํ”„ ์บ๋Ÿฌ์…€ (controlled, ์  ์ธ๋””์ผ€์ดํ„ฐ)<Carouselcount={slides.length}index={i}onChange={setI}>{slides[i]}</Carousel>// ๋‹ค์ค‘ URL ํด๋ฐฑ ์ด๋ฏธ์ง€<Imgcandidates={[avatarUrl,gravatar]}fallback={<Avatarfallback="MH"/>}className="h-10 w-10 rounded-full"/>

์›Œํ„ฐ๋งˆํฌ

import{Watermark}from"@m1kapp/kit";<Watermarkcolor="#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์œผ๋กœ ์ง์ ‘ ์ง€์ •ํ•˜์„ธ์š”:

// ๋„๊ธฐ / ๋ช…์‹œ ์ง€์ •<Watermarktrack={false}>โ€ฆ</Watermark>// ๋น„์ฝ˜ ๋”<WatermarktrackSlug="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.tsximport{OGImage,loadPretendard}from"@m1kapp/kit";import{ImageResponse}from"next/og";exportasyncfunctionGET(){constfont=awaitloadPretendard();returnnewImageResponse(<OGImagetype="default"title="์‚ฌ์ด๋“œ ํ”„๋กœ์ ํŠธ ์‹œ์ž‘ํ•˜๊ธฐ"sub="๋น ๋ฅด๊ฒŒ ๋งŒ๋“ค๊ณ  ๋น ๋ฅด๊ฒŒ ๋ฐฐ์šฐ๋Š”"badge="๐Ÿš€ NEW"appName="myapp"color="#3b82f6"bg="dark"// "dark" | "gradient" | "blend"domain="m1k.app"/>,{width: 1200,height: 630,fonts: [font]});}

ํ…œํ”Œ๋ฆฟ

typeํฌ๊ธฐ์šฉ๋„
default1200ร—630๊ธฐ๋ณธ OG
article1200ร—630๋ธ”๋กœ๊ทธ ํฌ์ŠคํŠธ โ€” author, date, category
stat1200ร—630๋งˆ์ผ์Šคํ†ค โ€” stat, label
product1200ร—630์ œํ’ˆ ์†Œ๊ฐœ โ€” tagline, features[]
match1200ร—630๊ฒฝ๊ธฐ ๊ฒฐ๊ณผ โ€” home, away, score
square1200ร—1200Instagram / SNS
icon512ร—512์•ฑ ์•„์ด์ฝ˜ / favicon
// article<OGImagetype="article"title="์ œ๋ชฉ"author="minho"date="2025-04-19"category="Tutorial"sub="๋ถ€์ œ"color={c}bg={bg}/>// stat<OGImagetype="stat"stat="1,000"label="๋ช…์˜ ๋ฐฉ๋ฌธ์ž"sub="๋ก ์นญ 3์ผ ๋งŒ์—"badge="๐ŸŽ‰"color={c}bg={bg}/>// product<OGImagetype="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";constpretendard=awaitloadPretendard();// Pretendard ํ•œ๊ตญ์–ด ํฐํŠธconstroboto=awaitloadGoogleFont("Roboto",700);// Google FontsconstloadEmoji=createEmojiLoader("twemoji");// ์ด๋ชจ์ง€ fallback

PWA

Manifest

public/manifest.json ๋Œ€์‹  ์ฝ”๋“œ๋กœ ๊ด€๋ฆฌ. ์•„์ด์ฝ˜ ์ด๋ฏธ์ง€ ํŒŒ์ผ ๋ถˆํ•„์š”.

// app/manifest.tsimport{createManifest}from"@m1kapp/kit";exportdefaultcreateManifest({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.tsximport{mobileViewport}from"@m1kapp/kit";exportconstviewport=mobileViewport;

๋‚ด๋ถ€์ ์œผ๋กœ CSS touch-action: pan-x pan-y์™€ input { font-size: max(16px, 1em) }๋ฅผ ์ž๋™ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค.

SVG ์•„์ด์ฝ˜

import{svgIcon}from"@m1kapp/kit";constsrc=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";// ๋ฒ„ํŠผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ<PWAInstallButtonappName="My App"iconSrc={iconSrc}label="์•ฑ์œผ๋กœ ์„ค์น˜"/>// ์ปค์Šคํ…€ UIconst{ state, install }=usePWAInstall();// state: "android-ready" | "ios-safari" | "installed" | "unsupported"if(state==="android-ready"){return<buttononClick={install}>์„ค์น˜</button>;}if(state==="ios-safari"){return<buttononClick={()=>setSheetOpen(true)}>์„ค์น˜</button>;}// iOS ์•ˆ๋‚ด ์‹œํŠธ (์ง์ ‘ ์ œ์–ด ์‹œ)<IOSInstallSheetopen={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>;returndata?.map(u=><UserCardkey={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,// ํƒญ ์ˆจ๊ธฐ๋ฉด ์ž๋™ ์ •์ง€ โ€” ๋ถˆํ•„์š”ํ•œ ์š”์ฒญ ์—†์Œ});<buttononClick={()=>isRunning ? stop() : start()}>{isRunning ? "์ •์ง€" : "์‹œ์ž‘"}</button>

createApiClient

baseURL๊ณผ ๊ณตํ†ต ํ—ค๋”๋ฅผ ํ•œ ๋ฒˆ๋งŒ ์„ค์ •ํ•˜๋ฉด ํƒ€์ž… ์•ˆ์ „ํ•œ API ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋งŒ๋“ค์–ด์ง‘๋‹ˆ๋‹ค.

// lib/api.tsimport{createApiClient,ApiError}from"@m1kapp/kit";exportconstapi=createApiClient("https://api.myapp.com",{headers: {Authorization: `Bearer ${token}`},onError: (err)=>{if(err.status===401)signOut();},});// ์‚ฌ์šฉconstme=awaitapi.get<User>("/users/me");constpost=awaitapi.post<Post>("/posts",{ title, body });awaitapi.put("/posts/1",{title: "์ˆ˜์ •๋œ ์ œ๋ชฉ"});awaitapi.delete("/posts/1");// ์—๋Ÿฌ๋Š” ApiError๋กœ ์ •๊ทœํ™”try{awaitapi.delete("/posts/1");}catch(e){if(einstanceofApiError){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 โŒexportasyncfunctionGET(req: Request){constuser=awaitcurrentUser();if(!user)returnResponse.json({error: "Unauthorized"},{status: 401});try{constdata=awaitdb.sites.findMany({where: {userId: user.id}});returnResponse.json(data);}catch{returnResponse.json({error: "Internal Server Error"},{status: 500});}}// After โœ…exportconstGET=handler(async()=>{constuser=awaitcurrentUser();if(!user)unauthorized();// throws โ†’ 401constdata=awaitdb.sites.findMany({where: {userId: user.id}});returnok(data);// 200 + JSON// ์ฒ˜๋ฆฌ๋˜์ง€ ์•Š์€ ์—๋Ÿฌ โ†’ 500 ์ž๋™});exportconstPOST=handler(async(req)=>{constuser=awaitcurrentUser();if(!user)unauthorized();const{ url }=awaitreq.json();if(!url)badRequest("url์ด ํ•„์š”ํ•ด์š”");// throws โ†’ 400constsite=awaitdb.sites.create({data: { url,userId: user.id}});returncreated(site);// 201 + JSON});

์‘๋‹ต ํ—ฌํผ

ํ•จ์ˆ˜์ƒํƒœ์„ค๋ช…
ok(data)200JSON ์‘๋‹ต
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";exportconstGET=handler(async()=>{const{ok: success, data, error }=awaitsafely(()=>db.users.findFirstOrThrow());if(!success)returnserverError("DB ์กฐํšŒ ์‹คํŒจ");returnok(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 ์ž๋™ ์žฌ์‹œ๋„ (๋งˆ์ง€๋ง‰ ์‘๋‹ต ๋ฐ˜ํ™˜)constres=awaitfetchWithRetry(url,{headers: {authorization: `Bearer ${XAI_API_KEY}`}});// ์•„๋ฌด async๋‚˜ ์žฌ์‹œ๋„ (Neon ์ฝœ๋“œ์Šคํƒ€ํŠธ ๋“ฑ)constrows=awaitwithRetry(()=>db.query.users.findMany(),{shouldRetry: (e)=>String(e).includes("fetch failed"),});// LLM ์‘๋‹ต์—์„œ JSON ๋ณต๊ตฌ (```json ํŽœ์ŠคยทํŠธ๋ ˆ์ผ๋ง ์ฝค๋งˆยท๋…ธ์ด์ฆˆ ์ œ๊ฑฐ)constdata=recoverJsonFromText<{items: string[]}>(llmReply);// Open Graph ์Šคํฌ๋ž˜ํ•‘constog=awaitscrapeOg("example.com");// { title, description, image, siteName, url }// ํƒ€์ž„์กด ๋‚ ์งœtodayKST();// "2026-06-04"dateInTz(Date.now(),"America/New_York");// base62 slug + ํ˜ธ์ŠคํŠธidToSlug(42,1000);// "..." (slugToId๋กœ ์—ญ๋ณ€ํ™˜)constbase=`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("");constdebouncedQuery=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}`)});<formonSubmit={e=>{e.preventDefault();submit(inputValue);}}><inputvalue={inputValue}onChange={...}/>{error&&<pclassName="text-red-500 text-sm">{error.message}</p>}<buttondisabled={loading}>{loading ? "๋“ฑ๋ก ์ค‘โ€ฆ" : "๋“ฑ๋ก"}</button></form>

useInView

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

const{ ref, inView }=useInView({threshold: 0.1,once: true});useEffect(()=>{if(inView)fetchNextPage();},[inView]);return(<div>{posts.map(p=><PostCardkey={p.id}post={p}/>)}<divref={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

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

@m1kapp/kit

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

npmHits

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";exportdefaultfunctionApp(){return(// Watermark๊ฐ€ ๋ทฐํฌํŠธ ์ „์ฒด ๋†’์ด/์ค‘์•™์ •๋ ฌ/ํ•˜๋‹จ ํฌ๋ ˆ๋”ง์„ ๊ฐ™์ด ์ค๋‹ˆ๋‹ค.// (ํฌ๋ ˆ๋”ง ์ŠคํŠธ๋ฆฝ์€ ์…ธ ๋ฐ”๋กœ ๋ฐ‘์— ๋ถ™์–ด์„œ ์…ธ๊ณผ ํ•œ ๋ฉ์–ด๋ฆฌ๋กœ ์ค‘์•™์ •๋ ฌ๋จ โ€” ํ™”๋ฉด ์ง„์งœ ๋ฐ”๋‹ฅ์— ๊ณ ์ •๋˜๋Š” ๊ฒŒ ์•„๋‹˜)// AppShell๋งŒ ๋‹จ๋…์œผ๋กœ ์“ฐ๋ฉด ์ฝ˜ํ…์ธ  ๋†’์ด๋งŒํผ ์ชผ๊ทธ๋ผ๋“ค์–ด์š”.<Watermarkcolor="#e2603f"text="myapp"><AppShell><AppShellHeader><h1>My App</h1></AppShellHeader><AppShellContent><SectionclassName="pt-4">{/* ์ฝ˜ํ…์ธ  โ€” Divider๋กœ ์„น์…˜ ๊ตฌ๋ถ„ */}</Section></AppShellContent><TabBar><Tabhref="/"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/kitOG ์ด๋ฏธ์ง€ ์ƒ์„ฑ (์„œ๋ฒ„)
PWA@m1kapp/kitmanifest, viewport, ์„ค์น˜ ์œ ๋„
Fetch@m1kapp/kit์บ์‹ฑยท์ค‘๋ณต์ œ๊ฑฐยท์žฌ์‹œ๋„ fetch ์œ ํ‹ธ
Utils@m1kapp/kit๋‚ ์งœยท์ˆซ์ž ํฌ๋งท, ๋ฒ”์šฉ ํ›…
Server@m1kapp/kit/serverNext.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><Tabactive={tab==="home"}onClick={()=>setTab("home")}label="ํ™ˆ"icon={<HomeIcon/>}activeColor="#3b82f6"// ํ™œ์„ฑ ์ƒ‰์ƒ ์ž์œ ๋กญ๊ฒŒ ์ง€์ •/></TabBar>

๋ฐ์ดํ„ฐ ํ‘œ์‹œ

import{Avatar,Badge,StatChip,EmptyState,GrassMap}from"@m1kapp/kit";// Avatar โ€” ์ด๋‹ˆ์…œ or ์ด๋ฏธ์ง€, ์ด๋ฏธ์ง€ ๋กœ๋“œ ์‹คํŒจ ์‹œ ์ด๋‹ˆ์…œ๋กœ ์ž๋™ fallback<Avatarsrc="/photo.jpg"fallback="MH"size="md"shape="circle"/><Avatarfallback="MH"size="lg"shape="rounded"color="#3b82f6"/>// size: "xs" | "sm" | "md" | "lg" | "xl"// shape: "circle" | "rounded"// Badge โ€” ์ƒํƒœ/์นดํ…Œ๊ณ ๋ฆฌ ๋ ˆ์ด๋ธ”<Badgevariant="green">LIVE</Badge><Badgevariant="red">์˜ค๋ฅ˜</Badge><Badgevariant="blue"size="sm">์ •๋ณด</Badge>// variant: "default" | "green" | "red" | "yellow" | "blue" | "purple" | "orange"// StatChip โ€” ์ˆซ์ž stat ๋ฑƒ์ง€<StatChiplabel="๋ฐฉ๋ฌธ์ž"value={1024}/>// EmptyState โ€” ๋นˆ ๋ชฉ๋ก ํ”Œ๋ ˆ์ด์Šคํ™€๋”<EmptyStatemessage="์•„์ง ์•„๋ฌด๊ฒƒ๋„ ์—†์–ด์š”"/>// GrassMap โ€” GitHub ์Šคํƒ€์ผ ํ™œ๋™ ํžˆํŠธ๋งต<GrassMapdata={[{date: "2025-04-19",count: 42}]}accent="#3b82f6"/>

์Šค์ผˆ๋ ˆํ†ค

๋กœ๋”ฉ ํ”Œ๋ ˆ์ด์Šคํ™€๋”. className์œผ๋กœ ํฌ๊ธฐ๋ฅผ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค.

import{Skeleton}from"@m1kapp/kit";// ํ…์ŠคํŠธ ์ค„<SkeletonclassName="h-4 w-3/4"/>// ์นด๋“œ ๋ธ”๋ก<SkeletonclassName="h-32 w-full"rounded="xl"/>// ์•„๋ฐ”ํƒ€<SkeletonclassName="h-10 w-10"rounded="full"/>// ์‹ค์ „ ํŒจํ„ดfunctionPostCardSkeleton(){return(<divclassName="flex gap-3 p-4"><SkeletonclassName="h-10 w-10"rounded="full"/><divclassName="flex-1 space-y-2"><SkeletonclassName="h-4 w-2/3"/><SkeletonclassName="h-3 w-1/2"/></div></div>);}

๋ชจ๋‹ฌ / ๋‹ค์ด์–ผ๋กœ๊ทธ

backdrop ํด๋ฆญ, ESC ํ‚ค, ์Šคํฌ๋กค ์ž ๊ธˆ ์ž๋™ ์ฒ˜๋ฆฌ.

import{Dialog}from"@m1kapp/kit";// ๊ธฐ๋ณธ ์‚ฌ์šฉ<Dialogopen={open}onClose={()=>setOpen(false)}title="์„ค์ •"><pclassName="text-sm text-zinc-500">๋‚ด์šฉ</p></Dialog>// ํ™•์ธ ๋‹ค์ด์–ผ๋กœ๊ทธ<Dialogopen={confirmOpen}onClose={()=>setConfirmOpen(false)}title="์‚ญ์ œํ• ๊นŒ์š”?"><pclassName="text-sm text-zinc-500">์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์–ด์š”.</p><divclassName="flex gap-2 mt-4"><buttononClick={handleDelete}className="...">์‚ญ์ œ</button><buttononClick={()=>setConfirmOpen(false)}className="...">์ทจ์†Œ</button>
</div></Dialog>// size: "sm" (๊ธฐ๋ณธ) | "md" | "lg"// persistent: true โ€” backdrop ํด๋ฆญ / ESC๋กœ ๋‹ซ๊ธฐ ๋น„ํ™œ์„ฑํ™”

์ธํ„ฐ๋ž™์…˜

import{Button,Tooltip,Typewriter,EmojiButton,EmojiPicker}from"@m1kapp/kit";<ButtononClick={fn}>์‹œ์ž‘ํ•˜๊ธฐ</Button><Tooltiplabel="์„ค๋ช… ํ…์ŠคํŠธ"><button>hover me</button></Tooltip><Typewriterwords={["Hello","World"]}color="#3b82f6"/>// ์ด๋ชจ์ง€ ์„ ํƒ๊ธฐconst[emoji,setEmoji]=useState("๐Ÿ ");const[open,setOpen]=useState(false);<EmojiButtonemoji={emoji}onClick={()=>setOpen(true)}/><EmojiPickeropen={open}onClose={()=>setOpen(false)}current={emoji}onSelect={setEmoji}/>

๊ณต์œ 

import{ShareButton,useShare}from"@m1kapp/kit";// ๋ฒ„ํŠผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ โ€” ๋ชจ๋ฐ”์ผ์€ ๋„ค์ดํ‹ฐ๋ธŒ ๊ณต์œ , ๋ฐ์Šคํฌํƒ‘์€ ํด๋ฆฝ๋ณด๋“œ ๋ณต์‚ฌ<ShareButtonurl="https://m1k.app"title="My App"/>// ์ปค์Šคํ…€ UIconst{ share, copied, canNativeShare }=useShare({url: "https://m1k.app"});<buttononClick={()=>share()}>{copied ? "๋ณต์‚ฌ๋จ!" : "๊ณต์œ "}</button>

ํ† ์ŠคํŠธ

import{ToastProvider,useToast}from"@m1kapp/kit";// ์•ฑ ๋ฃจํŠธ ๊ฐ์‹ธ๊ธฐ<ToastProvider><App/></ToastProvider>// ์–ด๋””์„œ๋‚˜consttoast=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><scriptdangerouslySetInnerHTML={{__html: THEME_SCRIPT}}/></head>// ํ…Œ๋งˆ ๋ฒ„ํŠผ + ๋‹ค์ด์–ผ๋กœ๊ทธconst[themeOpen,setThemeOpen]=useState(false);<ThemeButtoncolor={color}dark={dark}onClick={()=>setThemeOpen(true)}/><ThemeDialogopen={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 โ€” ๊ฐ€์žฅ ์‰ฌ์›€ (ํ•˜์œ„ ์ „์ฒด์— ์ ์šฉ)<AppShellaccent="#e2603f">...</AppShell>// โ‘ก ์•„๋ฌด ๋ž˜ํผ์—๋‚˜ CSS ๋ณ€์ˆ˜๋กœ<divstyle={{"--kit-accent": "#e2603f"}asReact.CSSProperties}>...</div>// โ‘ข ์ปดํฌ๋„ŒํŠธ๋ณ„ override<Switchchecked={on}onChange={setOn}accent="#e2603f"/>

ํผ ยท ์„ค์ •

import{Field,Switch}from"@m1kapp/kit";// ๋ผ๋ฒจ๋“œ ์ธํ’‹ (stacked ๊ธฐ๋ณธ / inline / multiline)<Fieldlabel="์ด๋ฆ„"value={name}onChange={setName}placeholder="์ด๋ฆ„"/><Fieldlabel="์ด๋ฉ”์ผ"value={email}inlinereadOnly/><Fieldlabel="๋ฉ”๋ชจ"value={memo}onChange={setMemo}multilinerows={3}hint="์ž์œ ๋กญ๊ฒŒ ์ ์–ด์ฃผ์„ธ์š”"/>// on/off ํ† ๊ธ€ โ€” accent๋Š” --kit-accent ์ž๋™ ์—ฐ๋™<Switchchecked={on}onChange={setOn}aria-label="์•Œ๋ฆผ"/>

์„ธ๊ทธ๋จผํŠธ ํ† ๊ธ€

import{SegmentedControl}from"@m1kapp/kit";<SegmentedControlvalue={view}onChange={setView}options={[{value: "today",label: "์˜ค๋Š˜"},{value: "week",label: "์ด๋ฒˆ ์ฃผ"}]}/>// ์ธ๋ผ์ธ ํ† ๊ธ€์šฉ. ํ•˜๋‹จ ๊ธ€๋กœ๋ฒŒ ๋‚ด๋น„๊ฒŒ์ด์…˜์€ TabBar๋ฅผ ์“ฐ์„ธ์š”.

์ฑ„ํŒ… / ๋Œ€ํ™”

import{MessageList,ChatBubble,TypingIndicator}from"@m1kapp/kit";importtype{ChatMessage}from"@m1kapp/kit";constmessages: ChatMessage[]=[{role: "user",content: "๋‚ด์ผ 3์‹œ ํšŒ์˜ ์žก์•„์ค˜",timestamp: Date.now()},{role: "assistant",content: "๋„ค, ์žก์•„๋“œ๋ฆด๊ฒŒ์š”."},];// dayDivider: timestamp ๊ธฐ์ค€ ๋‚ ์งœ๊ฐ€ ๋ฐ”๋€Œ๋ฉด ๊ตฌ๋ถ„์„  ์ž๋™ ์‚ฝ์ž…<MessageListmessages={messages}dayDivider>{pending&&<TypingIndicator/>}</MessageList>// ์ง์ ‘ ์“ฐ๋ ค๋ฉด<ChatBubblerole="user">์˜ค๋Š˜์ผ์ •๋ณด์—ฌ์ค˜</ChatBubble><ChatBubblerole="assistant">3๊ฑด ์žˆ์–ด์š”.</ChatBubble>

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

LLM ํˆด์ฝœ์ฒ˜๋Ÿผ "์ œ์•ˆ โ†’ ํ™•์ธ โ†’ ์‹คํ–‰" ํ๋ฆ„์„ ๋ฉ”์‹œ์ง€ ์•ˆ์— ๋ฐ•์„ ๋•Œ. ๋ชจ๋‹ฌ์ด ์•„๋‹ˆ๋ผ ํ๋ฆ„์— ์ธ๋ผ์ธ๋ฉ๋‹ˆ๋‹ค.

import{ActionCard}from"@m1kapp/kit";const[state,setState]=useState<"pending"|"loading"|"done"|"cancelled">("pending");<ActionCardtitle="์ด๋ ‡๊ฒŒ ๊ธฐ๋กํ•ด๋‘˜๊นŒ์š”?"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";<ListRowaccent="#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";// ๋‹ค๋‹จ๊ณ„ ์ง„ํ–‰ ํ‘œ์‹œ (์œ„์ €๋“œ/๋ฉ€ํ‹ฐํŽ˜์ด์ฆˆ ํ”Œ๋กœ์šฐ)<Steppercurrent={phase}onStepClick={setPhase}steps={[{label: "๋ถ„์„",icon: "๐Ÿ“"},{label: "์ค€๋น„",icon: "๐Ÿ“ธ"},{label: "์ƒ์„ฑ",icon: "โœจ"},]}/>// ์ ‘๊ธฐ ์นด๋“œ (ํ—ค๋”+๋ฐฐ์ง€+์ƒํƒœ)<Collapsibleleading={1} title="ํŽ˜๋ฅด์†Œ๋‚˜ ๋ถ„์„"subtitle="์Šคํƒ€์ผ ํŒŒ์•…"completedopen={open ===1}onToggle={()=>setOpen(open===1 ? null : 1)}><p>๋ณธ๋ฌธโ€ฆ</p></Collapsible>// ์›ํ˜• ์ง„ํ–‰๋ฅ <ProgressRingvalue={7}max={10}><spanclassName="text-2xl font-black">7</span></ProgressRing>

์ž…๋ ฅ ยท ํŽธ์ง‘

import{Select,ColorPicker,InlineEdit}from"@m1kapp/kit";// ์•ต์ปค๋“œ ๋“œ๋กญ๋‹ค์šด (์นด์šดํŠธยท๋น„ํ™œ์„ฑ ์˜ต์…˜, ์™ธ๋ถ€ํด๋ฆญ/ESC ๋‹ซํž˜)<Selectvalue={cat}onChange={setCat}placeholder="๋‚œ์ด๋„"options={[{value: "a",label: "์ดˆ๊ธ‰",count: 12},{value: "b",label: "๊ณ ๊ธ‰",count: 0,disabled: true},]}/>// ์ปฌ๋Ÿฌ ํ”ผ์ปค (ํ”„๋ฆฌ์…‹ + ์ปค์Šคํ…€ hex)<ColorPickervalue={color}onChange={setColor}/>// ํƒญํ•˜์—ฌ ํŽธ์ง‘ (Enter ์ €์žฅ ยท Esc ์ทจ์†Œ)<InlineEditvalue={name}onChange={rename}className="text-lg font-bold"/>

๋ณต์‚ฌ ยท ๋ฐ์ดํ„ฐ

import{CopyButton,CodeBlock,useCopy,BarList,Countdown,Carousel,Img}from"@m1kapp/kit";<CopyButtontext="npm i @m1kapp/kit">์„ค์น˜ ๋ณต์‚ฌ</CopyButton><CodeBlocklabel="install"code="npm i @m1kapp/kit"/>const{ copied, copy }=useCopy();// keyed: copy(text, "row-3")// ๊ฐ€๋กœ ๋ง‰๋Œ€ ๋ถ„์„ ์ฐจํŠธ<BarListitems={[{label: "/",value: 120},{label: "/about",value: 64,href: "/about"}]}/>// ์นด์šดํŠธ๋‹ค์šด (D-day)<Countdownto="2026-12-31"onComplete={celebrate}/>// ์Šค์™€์ดํ”„ ์บ๋Ÿฌ์…€ (controlled, ์  ์ธ๋””์ผ€์ดํ„ฐ)<Carouselcount={slides.length}index={i}onChange={setI}>{slides[i]}</Carousel>// ๋‹ค์ค‘ URL ํด๋ฐฑ ์ด๋ฏธ์ง€<Imgcandidates={[avatarUrl,gravatar]}fallback={<Avatarfallback="MH"/>}className="h-10 w-10 rounded-full"/>

์›Œํ„ฐ๋งˆํฌ

import{Watermark}from"@m1kapp/kit";<Watermarkcolor="#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์œผ๋กœ ์ง์ ‘ ์ง€์ •ํ•˜์„ธ์š”:

// ๋„๊ธฐ / ๋ช…์‹œ ์ง€์ •<Watermarktrack={false}>โ€ฆ</Watermark>// ๋น„์ฝ˜ ๋”<WatermarktrackSlug="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.tsximport{OGImage,loadPretendard}from"@m1kapp/kit";import{ImageResponse}from"next/og";exportasyncfunctionGET(){constfont=awaitloadPretendard();returnnewImageResponse(<OGImagetype="default"title="์‚ฌ์ด๋“œ ํ”„๋กœ์ ํŠธ ์‹œ์ž‘ํ•˜๊ธฐ"sub="๋น ๋ฅด๊ฒŒ ๋งŒ๋“ค๊ณ  ๋น ๋ฅด๊ฒŒ ๋ฐฐ์šฐ๋Š”"badge="๐Ÿš€ NEW"appName="myapp"color="#3b82f6"bg="dark"// "dark" | "gradient" | "blend"domain="m1k.app"/>,{width: 1200,height: 630,fonts: [font]});}

ํ…œํ”Œ๋ฆฟ

typeํฌ๊ธฐ์šฉ๋„
default1200ร—630๊ธฐ๋ณธ OG
article1200ร—630๋ธ”๋กœ๊ทธ ํฌ์ŠคํŠธ โ€” author, date, category
stat1200ร—630๋งˆ์ผ์Šคํ†ค โ€” stat, label
product1200ร—630์ œํ’ˆ ์†Œ๊ฐœ โ€” tagline, features[]
match1200ร—630๊ฒฝ๊ธฐ ๊ฒฐ๊ณผ โ€” home, away, score
square1200ร—1200Instagram / SNS
icon512ร—512์•ฑ ์•„์ด์ฝ˜ / favicon
// article<OGImagetype="article"title="์ œ๋ชฉ"author="minho"date="2025-04-19"category="Tutorial"sub="๋ถ€์ œ"color={c}bg={bg}/>// stat<OGImagetype="stat"stat="1,000"label="๋ช…์˜ ๋ฐฉ๋ฌธ์ž"sub="๋ก ์นญ 3์ผ ๋งŒ์—"badge="๐ŸŽ‰"color={c}bg={bg}/>// product<OGImagetype="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";constpretendard=awaitloadPretendard();// Pretendard ํ•œ๊ตญ์–ด ํฐํŠธconstroboto=awaitloadGoogleFont("Roboto",700);// Google FontsconstloadEmoji=createEmojiLoader("twemoji");// ์ด๋ชจ์ง€ fallback

PWA

Manifest

public/manifest.json ๋Œ€์‹  ์ฝ”๋“œ๋กœ ๊ด€๋ฆฌ. ์•„์ด์ฝ˜ ์ด๋ฏธ์ง€ ํŒŒ์ผ ๋ถˆํ•„์š”.

// app/manifest.tsimport{createManifest}from"@m1kapp/kit";exportdefaultcreateManifest({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.tsximport{mobileViewport}from"@m1kapp/kit";exportconstviewport=mobileViewport;

๋‚ด๋ถ€์ ์œผ๋กœ CSS touch-action: pan-x pan-y์™€ input { font-size: max(16px, 1em) }๋ฅผ ์ž๋™ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค.

SVG ์•„์ด์ฝ˜

import{svgIcon}from"@m1kapp/kit";constsrc=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";// ๋ฒ„ํŠผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ<PWAInstallButtonappName="My App"iconSrc={iconSrc}label="์•ฑ์œผ๋กœ ์„ค์น˜"/>// ์ปค์Šคํ…€ UIconst{ state, install }=usePWAInstall();// state: "android-ready" | "ios-safari" | "installed" | "unsupported"if(state==="android-ready"){return<buttononClick={install}>์„ค์น˜</button>;}if(state==="ios-safari"){return<buttononClick={()=>setSheetOpen(true)}>์„ค์น˜</button>;}// iOS ์•ˆ๋‚ด ์‹œํŠธ (์ง์ ‘ ์ œ์–ด ์‹œ)<IOSInstallSheetopen={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>;returndata?.map(u=><UserCardkey={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,// ํƒญ ์ˆจ๊ธฐ๋ฉด ์ž๋™ ์ •์ง€ โ€” ๋ถˆํ•„์š”ํ•œ ์š”์ฒญ ์—†์Œ});<buttononClick={()=>isRunning ? stop() : start()}>{isRunning ? "์ •์ง€" : "์‹œ์ž‘"}</button>

createApiClient

baseURL๊ณผ ๊ณตํ†ต ํ—ค๋”๋ฅผ ํ•œ ๋ฒˆ๋งŒ ์„ค์ •ํ•˜๋ฉด ํƒ€์ž… ์•ˆ์ „ํ•œ API ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋งŒ๋“ค์–ด์ง‘๋‹ˆ๋‹ค.

// lib/api.tsimport{createApiClient,ApiError}from"@m1kapp/kit";exportconstapi=createApiClient("https://api.myapp.com",{headers: {Authorization: `Bearer ${token}`},onError: (err)=>{if(err.status===401)signOut();},});// ์‚ฌ์šฉconstme=awaitapi.get<User>("/users/me");constpost=awaitapi.post<Post>("/posts",{ title, body });awaitapi.put("/posts/1",{title: "์ˆ˜์ •๋œ ์ œ๋ชฉ"});awaitapi.delete("/posts/1");// ์—๋Ÿฌ๋Š” ApiError๋กœ ์ •๊ทœํ™”try{awaitapi.delete("/posts/1");}catch(e){if(einstanceofApiError){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 โŒexportasyncfunctionGET(req: Request){constuser=awaitcurrentUser();if(!user)returnResponse.json({error: "Unauthorized"},{status: 401});try{constdata=awaitdb.sites.findMany({where: {userId: user.id}});returnResponse.json(data);}catch{returnResponse.json({error: "Internal Server Error"},{status: 500});}}// After โœ…exportconstGET=handler(async()=>{constuser=awaitcurrentUser();if(!user)unauthorized();// throws โ†’ 401constdata=awaitdb.sites.findMany({where: {userId: user.id}});returnok(data);// 200 + JSON// ์ฒ˜๋ฆฌ๋˜์ง€ ์•Š์€ ์—๋Ÿฌ โ†’ 500 ์ž๋™});exportconstPOST=handler(async(req)=>{constuser=awaitcurrentUser();if(!user)unauthorized();const{ url }=awaitreq.json();if(!url)badRequest("url์ด ํ•„์š”ํ•ด์š”");// throws โ†’ 400constsite=awaitdb.sites.create({data: { url,userId: user.id}});returncreated(site);// 201 + JSON});

์‘๋‹ต ํ—ฌํผ

ํ•จ์ˆ˜์ƒํƒœ์„ค๋ช…
ok(data)200JSON ์‘๋‹ต
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";exportconstGET=handler(async()=>{const{ok: success, data, error }=awaitsafely(()=>db.users.findFirstOrThrow());if(!success)returnserverError("DB ์กฐํšŒ ์‹คํŒจ");returnok(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 ์ž๋™ ์žฌ์‹œ๋„ (๋งˆ์ง€๋ง‰ ์‘๋‹ต ๋ฐ˜ํ™˜)constres=awaitfetchWithRetry(url,{headers: {authorization: `Bearer ${XAI_API_KEY}`}});// ์•„๋ฌด async๋‚˜ ์žฌ์‹œ๋„ (Neon ์ฝœ๋“œ์Šคํƒ€ํŠธ ๋“ฑ)constrows=awaitwithRetry(()=>db.query.users.findMany(),{shouldRetry: (e)=>String(e).includes("fetch failed"),});// LLM ์‘๋‹ต์—์„œ JSON ๋ณต๊ตฌ (```json ํŽœ์ŠคยทํŠธ๋ ˆ์ผ๋ง ์ฝค๋งˆยท๋…ธ์ด์ฆˆ ์ œ๊ฑฐ)constdata=recoverJsonFromText<{items: string[]}>(llmReply);// Open Graph ์Šคํฌ๋ž˜ํ•‘constog=awaitscrapeOg("example.com");// { title, description, image, siteName, url }// ํƒ€์ž„์กด ๋‚ ์งœtodayKST();// "2026-06-04"dateInTz(Date.now(),"America/New_York");// base62 slug + ํ˜ธ์ŠคํŠธidToSlug(42,1000);// "..." (slugToId๋กœ ์—ญ๋ณ€ํ™˜)constbase=`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("");constdebouncedQuery=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}`)});<formonSubmit={e=>{e.preventDefault();submit(inputValue);}}><inputvalue={inputValue}onChange={...}/>{error&&<pclassName="text-red-500 text-sm">{error.message}</p>}<buttondisabled={loading}>{loading ? "๋“ฑ๋ก ์ค‘โ€ฆ" : "๋“ฑ๋ก"}</button></form>

useInView

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

const{ ref, inView }=useInView({threshold: 0.1,once: true});useEffect(()=>{if(inView)fetchNextPage();},[inView]);return(<div>{posts.map(p=><PostCardkey={p.id}post={p}/>)}<divref={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

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

@m1kapp/kit

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

npmHits

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";exportdefaultfunctionApp(){return(// Watermark๊ฐ€ ๋ทฐํฌํŠธ ์ „์ฒด ๋†’์ด/์ค‘์•™์ •๋ ฌ/ํ•˜๋‹จ ํฌ๋ ˆ๋”ง์„ ๊ฐ™์ด ์ค๋‹ˆ๋‹ค.// (ํฌ๋ ˆ๋”ง ์ŠคํŠธ๋ฆฝ์€ ์…ธ ๋ฐ”๋กœ ๋ฐ‘์— ๋ถ™์–ด์„œ ์…ธ๊ณผ ํ•œ ๋ฉ์–ด๋ฆฌ๋กœ ์ค‘์•™์ •๋ ฌ๋จ โ€” ํ™”๋ฉด ์ง„์งœ ๋ฐ”๋‹ฅ์— ๊ณ ์ •๋˜๋Š” ๊ฒŒ ์•„๋‹˜)// AppShell๋งŒ ๋‹จ๋…์œผ๋กœ ์“ฐ๋ฉด ์ฝ˜ํ…์ธ  ๋†’์ด๋งŒํผ ์ชผ๊ทธ๋ผ๋“ค์–ด์š”.<Watermarkcolor="#e2603f"text="myapp"><AppShell><AppShellHeader><h1>My App</h1></AppShellHeader><AppShellContent><SectionclassName="pt-4">{/* ์ฝ˜ํ…์ธ  โ€” Divider๋กœ ์„น์…˜ ๊ตฌ๋ถ„ */}</Section></AppShellContent><TabBar><Tabhref="/"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/kitOG ์ด๋ฏธ์ง€ ์ƒ์„ฑ (์„œ๋ฒ„)
PWA@m1kapp/kitmanifest, viewport, ์„ค์น˜ ์œ ๋„
Fetch@m1kapp/kit์บ์‹ฑยท์ค‘๋ณต์ œ๊ฑฐยท์žฌ์‹œ๋„ fetch ์œ ํ‹ธ
Utils@m1kapp/kit๋‚ ์งœยท์ˆซ์ž ํฌ๋งท, ๋ฒ”์šฉ ํ›…
Server@m1kapp/kit/serverNext.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><Tabactive={tab==="home"}onClick={()=>setTab("home")}label="ํ™ˆ"icon={<HomeIcon/>}activeColor="#3b82f6"// ํ™œ์„ฑ ์ƒ‰์ƒ ์ž์œ ๋กญ๊ฒŒ ์ง€์ •/></TabBar>

๋ฐ์ดํ„ฐ ํ‘œ์‹œ

import{Avatar,Badge,StatChip,EmptyState,GrassMap}from"@m1kapp/kit";// Avatar โ€” ์ด๋‹ˆ์…œ or ์ด๋ฏธ์ง€, ์ด๋ฏธ์ง€ ๋กœ๋“œ ์‹คํŒจ ์‹œ ์ด๋‹ˆ์…œ๋กœ ์ž๋™ fallback<Avatarsrc="/photo.jpg"fallback="MH"size="md"shape="circle"/><Avatarfallback="MH"size="lg"shape="rounded"color="#3b82f6"/>// size: "xs" | "sm" | "md" | "lg" | "xl"// shape: "circle" | "rounded"// Badge โ€” ์ƒํƒœ/์นดํ…Œ๊ณ ๋ฆฌ ๋ ˆ์ด๋ธ”<Badgevariant="green">LIVE</Badge><Badgevariant="red">์˜ค๋ฅ˜</Badge><Badgevariant="blue"size="sm">์ •๋ณด</Badge>// variant: "default" | "green" | "red" | "yellow" | "blue" | "purple" | "orange"// StatChip โ€” ์ˆซ์ž stat ๋ฑƒ์ง€<StatChiplabel="๋ฐฉ๋ฌธ์ž"value={1024}/>// EmptyState โ€” ๋นˆ ๋ชฉ๋ก ํ”Œ๋ ˆ์ด์Šคํ™€๋”<EmptyStatemessage="์•„์ง ์•„๋ฌด๊ฒƒ๋„ ์—†์–ด์š”"/>// GrassMap โ€” GitHub ์Šคํƒ€์ผ ํ™œ๋™ ํžˆํŠธ๋งต<GrassMapdata={[{date: "2025-04-19",count: 42}]}accent="#3b82f6"/>

์Šค์ผˆ๋ ˆํ†ค

๋กœ๋”ฉ ํ”Œ๋ ˆ์ด์Šคํ™€๋”. className์œผ๋กœ ํฌ๊ธฐ๋ฅผ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค.

import{Skeleton}from"@m1kapp/kit";// ํ…์ŠคํŠธ ์ค„<SkeletonclassName="h-4 w-3/4"/>// ์นด๋“œ ๋ธ”๋ก<SkeletonclassName="h-32 w-full"rounded="xl"/>// ์•„๋ฐ”ํƒ€<SkeletonclassName="h-10 w-10"rounded="full"/>// ์‹ค์ „ ํŒจํ„ดfunctionPostCardSkeleton(){return(<divclassName="flex gap-3 p-4"><SkeletonclassName="h-10 w-10"rounded="full"/><divclassName="flex-1 space-y-2"><SkeletonclassName="h-4 w-2/3"/><SkeletonclassName="h-3 w-1/2"/></div></div>);}

๋ชจ๋‹ฌ / ๋‹ค์ด์–ผ๋กœ๊ทธ

backdrop ํด๋ฆญ, ESC ํ‚ค, ์Šคํฌ๋กค ์ž ๊ธˆ ์ž๋™ ์ฒ˜๋ฆฌ.

import{Dialog}from"@m1kapp/kit";// ๊ธฐ๋ณธ ์‚ฌ์šฉ<Dialogopen={open}onClose={()=>setOpen(false)}title="์„ค์ •"><pclassName="text-sm text-zinc-500">๋‚ด์šฉ</p></Dialog>// ํ™•์ธ ๋‹ค์ด์–ผ๋กœ๊ทธ<Dialogopen={confirmOpen}onClose={()=>setConfirmOpen(false)}title="์‚ญ์ œํ• ๊นŒ์š”?"><pclassName="text-sm text-zinc-500">์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์–ด์š”.</p><divclassName="flex gap-2 mt-4"><buttononClick={handleDelete}className="...">์‚ญ์ œ</button><buttononClick={()=>setConfirmOpen(false)}className="...">์ทจ์†Œ</button>
</div></Dialog>// size: "sm" (๊ธฐ๋ณธ) | "md" | "lg"// persistent: true โ€” backdrop ํด๋ฆญ / ESC๋กœ ๋‹ซ๊ธฐ ๋น„ํ™œ์„ฑํ™”

์ธํ„ฐ๋ž™์…˜

import{Button,Tooltip,Typewriter,EmojiButton,EmojiPicker}from"@m1kapp/kit";<ButtononClick={fn}>์‹œ์ž‘ํ•˜๊ธฐ</Button><Tooltiplabel="์„ค๋ช… ํ…์ŠคํŠธ"><button>hover me</button></Tooltip><Typewriterwords={["Hello","World"]}color="#3b82f6"/>// ์ด๋ชจ์ง€ ์„ ํƒ๊ธฐconst[emoji,setEmoji]=useState("๐Ÿ ");const[open,setOpen]=useState(false);<EmojiButtonemoji={emoji}onClick={()=>setOpen(true)}/><EmojiPickeropen={open}onClose={()=>setOpen(false)}current={emoji}onSelect={setEmoji}/>

๊ณต์œ 

import{ShareButton,useShare}from"@m1kapp/kit";// ๋ฒ„ํŠผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ โ€” ๋ชจ๋ฐ”์ผ์€ ๋„ค์ดํ‹ฐ๋ธŒ ๊ณต์œ , ๋ฐ์Šคํฌํƒ‘์€ ํด๋ฆฝ๋ณด๋“œ ๋ณต์‚ฌ<ShareButtonurl="https://m1k.app"title="My App"/>// ์ปค์Šคํ…€ UIconst{ share, copied, canNativeShare }=useShare({url: "https://m1k.app"});<buttononClick={()=>share()}>{copied ? "๋ณต์‚ฌ๋จ!" : "๊ณต์œ "}</button>

ํ† ์ŠคํŠธ

import{ToastProvider,useToast}from"@m1kapp/kit";// ์•ฑ ๋ฃจํŠธ ๊ฐ์‹ธ๊ธฐ<ToastProvider><App/></ToastProvider>// ์–ด๋””์„œ๋‚˜consttoast=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><scriptdangerouslySetInnerHTML={{__html: THEME_SCRIPT}}/></head>// ํ…Œ๋งˆ ๋ฒ„ํŠผ + ๋‹ค์ด์–ผ๋กœ๊ทธconst[themeOpen,setThemeOpen]=useState(false);<ThemeButtoncolor={color}dark={dark}onClick={()=>setThemeOpen(true)}/><ThemeDialogopen={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 โ€” ๊ฐ€์žฅ ์‰ฌ์›€ (ํ•˜์œ„ ์ „์ฒด์— ์ ์šฉ)<AppShellaccent="#e2603f">...</AppShell>// โ‘ก ์•„๋ฌด ๋ž˜ํผ์—๋‚˜ CSS ๋ณ€์ˆ˜๋กœ<divstyle={{"--kit-accent": "#e2603f"}asReact.CSSProperties}>...</div>// โ‘ข ์ปดํฌ๋„ŒํŠธ๋ณ„ override<Switchchecked={on}onChange={setOn}accent="#e2603f"/>

ํผ ยท ์„ค์ •

import{Field,Switch}from"@m1kapp/kit";// ๋ผ๋ฒจ๋“œ ์ธํ’‹ (stacked ๊ธฐ๋ณธ / inline / multiline)<Fieldlabel="์ด๋ฆ„"value={name}onChange={setName}placeholder="์ด๋ฆ„"/><Fieldlabel="์ด๋ฉ”์ผ"value={email}inlinereadOnly/><Fieldlabel="๋ฉ”๋ชจ"value={memo}onChange={setMemo}multilinerows={3}hint="์ž์œ ๋กญ๊ฒŒ ์ ์–ด์ฃผ์„ธ์š”"/>// on/off ํ† ๊ธ€ โ€” accent๋Š” --kit-accent ์ž๋™ ์—ฐ๋™<Switchchecked={on}onChange={setOn}aria-label="์•Œ๋ฆผ"/>

์„ธ๊ทธ๋จผํŠธ ํ† ๊ธ€

import{SegmentedControl}from"@m1kapp/kit";<SegmentedControlvalue={view}onChange={setView}options={[{value: "today",label: "์˜ค๋Š˜"},{value: "week",label: "์ด๋ฒˆ ์ฃผ"}]}/>// ์ธ๋ผ์ธ ํ† ๊ธ€์šฉ. ํ•˜๋‹จ ๊ธ€๋กœ๋ฒŒ ๋‚ด๋น„๊ฒŒ์ด์…˜์€ TabBar๋ฅผ ์“ฐ์„ธ์š”.

์ฑ„ํŒ… / ๋Œ€ํ™”

import{MessageList,ChatBubble,TypingIndicator}from"@m1kapp/kit";importtype{ChatMessage}from"@m1kapp/kit";constmessages: ChatMessage[]=[{role: "user",content: "๋‚ด์ผ 3์‹œ ํšŒ์˜ ์žก์•„์ค˜",timestamp: Date.now()},{role: "assistant",content: "๋„ค, ์žก์•„๋“œ๋ฆด๊ฒŒ์š”."},];// dayDivider: timestamp ๊ธฐ์ค€ ๋‚ ์งœ๊ฐ€ ๋ฐ”๋€Œ๋ฉด ๊ตฌ๋ถ„์„  ์ž๋™ ์‚ฝ์ž…<MessageListmessages={messages}dayDivider>{pending&&<TypingIndicator/>}</MessageList>// ์ง์ ‘ ์“ฐ๋ ค๋ฉด<ChatBubblerole="user">์˜ค๋Š˜์ผ์ •๋ณด์—ฌ์ค˜</ChatBubble><ChatBubblerole="assistant">3๊ฑด ์žˆ์–ด์š”.</ChatBubble>

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

LLM ํˆด์ฝœ์ฒ˜๋Ÿผ "์ œ์•ˆ โ†’ ํ™•์ธ โ†’ ์‹คํ–‰" ํ๋ฆ„์„ ๋ฉ”์‹œ์ง€ ์•ˆ์— ๋ฐ•์„ ๋•Œ. ๋ชจ๋‹ฌ์ด ์•„๋‹ˆ๋ผ ํ๋ฆ„์— ์ธ๋ผ์ธ๋ฉ๋‹ˆ๋‹ค.

import{ActionCard}from"@m1kapp/kit";const[state,setState]=useState<"pending"|"loading"|"done"|"cancelled">("pending");<ActionCardtitle="์ด๋ ‡๊ฒŒ ๊ธฐ๋กํ•ด๋‘˜๊นŒ์š”?"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";<ListRowaccent="#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";// ๋‹ค๋‹จ๊ณ„ ์ง„ํ–‰ ํ‘œ์‹œ (์œ„์ €๋“œ/๋ฉ€ํ‹ฐํŽ˜์ด์ฆˆ ํ”Œ๋กœ์šฐ)<Steppercurrent={phase}onStepClick={setPhase}steps={[{label: "๋ถ„์„",icon: "๐Ÿ“"},{label: "์ค€๋น„",icon: "๐Ÿ“ธ"},{label: "์ƒ์„ฑ",icon: "โœจ"},]}/>// ์ ‘๊ธฐ ์นด๋“œ (ํ—ค๋”+๋ฐฐ์ง€+์ƒํƒœ)<Collapsibleleading={1} title="ํŽ˜๋ฅด์†Œ๋‚˜ ๋ถ„์„"subtitle="์Šคํƒ€์ผ ํŒŒ์•…"completedopen={open ===1}onToggle={()=>setOpen(open===1 ? null : 1)}><p>๋ณธ๋ฌธโ€ฆ</p></Collapsible>// ์›ํ˜• ์ง„ํ–‰๋ฅ <ProgressRingvalue={7}max={10}><spanclassName="text-2xl font-black">7</span></ProgressRing>

์ž…๋ ฅ ยท ํŽธ์ง‘

import{Select,ColorPicker,InlineEdit}from"@m1kapp/kit";// ์•ต์ปค๋“œ ๋“œ๋กญ๋‹ค์šด (์นด์šดํŠธยท๋น„ํ™œ์„ฑ ์˜ต์…˜, ์™ธ๋ถ€ํด๋ฆญ/ESC ๋‹ซํž˜)<Selectvalue={cat}onChange={setCat}placeholder="๋‚œ์ด๋„"options={[{value: "a",label: "์ดˆ๊ธ‰",count: 12},{value: "b",label: "๊ณ ๊ธ‰",count: 0,disabled: true},]}/>// ์ปฌ๋Ÿฌ ํ”ผ์ปค (ํ”„๋ฆฌ์…‹ + ์ปค์Šคํ…€ hex)<ColorPickervalue={color}onChange={setColor}/>// ํƒญํ•˜์—ฌ ํŽธ์ง‘ (Enter ์ €์žฅ ยท Esc ์ทจ์†Œ)<InlineEditvalue={name}onChange={rename}className="text-lg font-bold"/>

๋ณต์‚ฌ ยท ๋ฐ์ดํ„ฐ

import{CopyButton,CodeBlock,useCopy,BarList,Countdown,Carousel,Img}from"@m1kapp/kit";<CopyButtontext="npm i @m1kapp/kit">์„ค์น˜ ๋ณต์‚ฌ</CopyButton><CodeBlocklabel="install"code="npm i @m1kapp/kit"/>const{ copied, copy }=useCopy();// keyed: copy(text, "row-3")// ๊ฐ€๋กœ ๋ง‰๋Œ€ ๋ถ„์„ ์ฐจํŠธ<BarListitems={[{label: "/",value: 120},{label: "/about",value: 64,href: "/about"}]}/>// ์นด์šดํŠธ๋‹ค์šด (D-day)<Countdownto="2026-12-31"onComplete={celebrate}/>// ์Šค์™€์ดํ”„ ์บ๋Ÿฌ์…€ (controlled, ์  ์ธ๋””์ผ€์ดํ„ฐ)<Carouselcount={slides.length}index={i}onChange={setI}>{slides[i]}</Carousel>// ๋‹ค์ค‘ URL ํด๋ฐฑ ์ด๋ฏธ์ง€<Imgcandidates={[avatarUrl,gravatar]}fallback={<Avatarfallback="MH"/>}className="h-10 w-10 rounded-full"/>

์›Œํ„ฐ๋งˆํฌ

import{Watermark}from"@m1kapp/kit";<Watermarkcolor="#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์œผ๋กœ ์ง์ ‘ ์ง€์ •ํ•˜์„ธ์š”:

// ๋„๊ธฐ / ๋ช…์‹œ ์ง€์ •<Watermarktrack={false}>โ€ฆ</Watermark>// ๋น„์ฝ˜ ๋”<WatermarktrackSlug="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.tsximport{OGImage,loadPretendard}from"@m1kapp/kit";import{ImageResponse}from"next/og";exportasyncfunctionGET(){constfont=awaitloadPretendard();returnnewImageResponse(<OGImagetype="default"title="์‚ฌ์ด๋“œ ํ”„๋กœ์ ํŠธ ์‹œ์ž‘ํ•˜๊ธฐ"sub="๋น ๋ฅด๊ฒŒ ๋งŒ๋“ค๊ณ  ๋น ๋ฅด๊ฒŒ ๋ฐฐ์šฐ๋Š”"badge="๐Ÿš€ NEW"appName="myapp"color="#3b82f6"bg="dark"// "dark" | "gradient" | "blend"domain="m1k.app"/>,{width: 1200,height: 630,fonts: [font]});}

ํ…œํ”Œ๋ฆฟ

typeํฌ๊ธฐ์šฉ๋„
default1200ร—630๊ธฐ๋ณธ OG
article1200ร—630๋ธ”๋กœ๊ทธ ํฌ์ŠคํŠธ โ€” author, date, category
stat1200ร—630๋งˆ์ผ์Šคํ†ค โ€” stat, label
product1200ร—630์ œํ’ˆ ์†Œ๊ฐœ โ€” tagline, features[]
match1200ร—630๊ฒฝ๊ธฐ ๊ฒฐ๊ณผ โ€” home, away, score
square1200ร—1200Instagram / SNS
icon512ร—512์•ฑ ์•„์ด์ฝ˜ / favicon
// article<OGImagetype="article"title="์ œ๋ชฉ"author="minho"date="2025-04-19"category="Tutorial"sub="๋ถ€์ œ"color={c}bg={bg}/>// stat<OGImagetype="stat"stat="1,000"label="๋ช…์˜ ๋ฐฉ๋ฌธ์ž"sub="๋ก ์นญ 3์ผ ๋งŒ์—"badge="๐ŸŽ‰"color={c}bg={bg}/>// product<OGImagetype="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";constpretendard=awaitloadPretendard();// Pretendard ํ•œ๊ตญ์–ด ํฐํŠธconstroboto=awaitloadGoogleFont("Roboto",700);// Google FontsconstloadEmoji=createEmojiLoader("twemoji");// ์ด๋ชจ์ง€ fallback

PWA

Manifest

public/manifest.json ๋Œ€์‹  ์ฝ”๋“œ๋กœ ๊ด€๋ฆฌ. ์•„์ด์ฝ˜ ์ด๋ฏธ์ง€ ํŒŒ์ผ ๋ถˆํ•„์š”.

// app/manifest.tsimport{createManifest}from"@m1kapp/kit";exportdefaultcreateManifest({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.tsximport{mobileViewport}from"@m1kapp/kit";exportconstviewport=mobileViewport;

๋‚ด๋ถ€์ ์œผ๋กœ CSS touch-action: pan-x pan-y์™€ input { font-size: max(16px, 1em) }๋ฅผ ์ž๋™ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค.

SVG ์•„์ด์ฝ˜

import{svgIcon}from"@m1kapp/kit";constsrc=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";// ๋ฒ„ํŠผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ<PWAInstallButtonappName="My App"iconSrc={iconSrc}label="์•ฑ์œผ๋กœ ์„ค์น˜"/>// ์ปค์Šคํ…€ UIconst{ state, install }=usePWAInstall();// state: "android-ready" | "ios-safari" | "installed" | "unsupported"if(state==="android-ready"){return<buttononClick={install}>์„ค์น˜</button>;}if(state==="ios-safari"){return<buttononClick={()=>setSheetOpen(true)}>์„ค์น˜</button>;}// iOS ์•ˆ๋‚ด ์‹œํŠธ (์ง์ ‘ ์ œ์–ด ์‹œ)<IOSInstallSheetopen={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>;returndata?.map(u=><UserCardkey={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,// ํƒญ ์ˆจ๊ธฐ๋ฉด ์ž๋™ ์ •์ง€ โ€” ๋ถˆํ•„์š”ํ•œ ์š”์ฒญ ์—†์Œ});<buttononClick={()=>isRunning ? stop() : start()}>{isRunning ? "์ •์ง€" : "์‹œ์ž‘"}</button>

createApiClient

baseURL๊ณผ ๊ณตํ†ต ํ—ค๋”๋ฅผ ํ•œ ๋ฒˆ๋งŒ ์„ค์ •ํ•˜๋ฉด ํƒ€์ž… ์•ˆ์ „ํ•œ API ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋งŒ๋“ค์–ด์ง‘๋‹ˆ๋‹ค.

// lib/api.tsimport{createApiClient,ApiError}from"@m1kapp/kit";exportconstapi=createApiClient("https://api.myapp.com",{headers: {Authorization: `Bearer ${token}`},onError: (err)=>{if(err.status===401)signOut();},});// ์‚ฌ์šฉconstme=awaitapi.get<User>("/users/me");constpost=awaitapi.post<Post>("/posts",{ title, body });awaitapi.put("/posts/1",{title: "์ˆ˜์ •๋œ ์ œ๋ชฉ"});awaitapi.delete("/posts/1");// ์—๋Ÿฌ๋Š” ApiError๋กœ ์ •๊ทœํ™”try{awaitapi.delete("/posts/1");}catch(e){if(einstanceofApiError){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 โŒexportasyncfunctionGET(req: Request){constuser=awaitcurrentUser();if(!user)returnResponse.json({error: "Unauthorized"},{status: 401});try{constdata=awaitdb.sites.findMany({where: {userId: user.id}});returnResponse.json(data);}catch{returnResponse.json({error: "Internal Server Error"},{status: 500});}}// After โœ…exportconstGET=handler(async()=>{constuser=awaitcurrentUser();if(!user)unauthorized();// throws โ†’ 401constdata=awaitdb.sites.findMany({where: {userId: user.id}});returnok(data);// 200 + JSON// ์ฒ˜๋ฆฌ๋˜์ง€ ์•Š์€ ์—๋Ÿฌ โ†’ 500 ์ž๋™});exportconstPOST=handler(async(req)=>{constuser=awaitcurrentUser();if(!user)unauthorized();const{ url }=awaitreq.json();if(!url)badRequest("url์ด ํ•„์š”ํ•ด์š”");// throws โ†’ 400constsite=awaitdb.sites.create({data: { url,userId: user.id}});returncreated(site);// 201 + JSON});

์‘๋‹ต ํ—ฌํผ

ํ•จ์ˆ˜์ƒํƒœ์„ค๋ช…
ok(data)200JSON ์‘๋‹ต
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";exportconstGET=handler(async()=>{const{ok: success, data, error }=awaitsafely(()=>db.users.findFirstOrThrow());if(!success)returnserverError("DB ์กฐํšŒ ์‹คํŒจ");returnok(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 ์ž๋™ ์žฌ์‹œ๋„ (๋งˆ์ง€๋ง‰ ์‘๋‹ต ๋ฐ˜ํ™˜)constres=awaitfetchWithRetry(url,{headers: {authorization: `Bearer ${XAI_API_KEY}`}});// ์•„๋ฌด async๋‚˜ ์žฌ์‹œ๋„ (Neon ์ฝœ๋“œ์Šคํƒ€ํŠธ ๋“ฑ)constrows=awaitwithRetry(()=>db.query.users.findMany(),{shouldRetry: (e)=>String(e).includes("fetch failed"),});// LLM ์‘๋‹ต์—์„œ JSON ๋ณต๊ตฌ (```json ํŽœ์ŠคยทํŠธ๋ ˆ์ผ๋ง ์ฝค๋งˆยท๋…ธ์ด์ฆˆ ์ œ๊ฑฐ)constdata=recoverJsonFromText<{items: string[]}>(llmReply);// Open Graph ์Šคํฌ๋ž˜ํ•‘constog=awaitscrapeOg("example.com");// { title, description, image, siteName, url }// ํƒ€์ž„์กด ๋‚ ์งœtodayKST();// "2026-06-04"dateInTz(Date.now(),"America/New_York");// base62 slug + ํ˜ธ์ŠคํŠธidToSlug(42,1000);// "..." (slugToId๋กœ ์—ญ๋ณ€ํ™˜)constbase=`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("");constdebouncedQuery=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}`)});<formonSubmit={e=>{e.preventDefault();submit(inputValue);}}><inputvalue={inputValue}onChange={...}/>{error&&<pclassName="text-red-500 text-sm">{error.message}</p>}<buttondisabled={loading}>{loading ? "๋“ฑ๋ก ์ค‘โ€ฆ" : "๋“ฑ๋ก"}</button></form>

useInView

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

const{ ref, inView }=useInView({threshold: 0.1,once: true});useEffect(()=>{if(inView)fetchNextPage();},[inView]);return(<div>{posts.map(p=><PostCardkey={p.id}post={p}/>)}<divref={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

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

@m1kapp/kit

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

npmHits

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";exportdefaultfunctionApp(){return(// Watermark๊ฐ€ ๋ทฐํฌํŠธ ์ „์ฒด ๋†’์ด/์ค‘์•™์ •๋ ฌ/ํ•˜๋‹จ ํฌ๋ ˆ๋”ง์„ ๊ฐ™์ด ์ค๋‹ˆ๋‹ค.// (ํฌ๋ ˆ๋”ง ์ŠคํŠธ๋ฆฝ์€ ์…ธ ๋ฐ”๋กœ ๋ฐ‘์— ๋ถ™์–ด์„œ ์…ธ๊ณผ ํ•œ ๋ฉ์–ด๋ฆฌ๋กœ ์ค‘์•™์ •๋ ฌ๋จ โ€” ํ™”๋ฉด ์ง„์งœ ๋ฐ”๋‹ฅ์— ๊ณ ์ •๋˜๋Š” ๊ฒŒ ์•„๋‹˜)// AppShell๋งŒ ๋‹จ๋…์œผ๋กœ ์“ฐ๋ฉด ์ฝ˜ํ…์ธ  ๋†’์ด๋งŒํผ ์ชผ๊ทธ๋ผ๋“ค์–ด์š”.<Watermarkcolor="#e2603f"text="myapp"><AppShell><AppShellHeader><h1>My App</h1></AppShellHeader><AppShellContent><SectionclassName="pt-4">{/* ์ฝ˜ํ…์ธ  โ€” Divider๋กœ ์„น์…˜ ๊ตฌ๋ถ„ */}</Section></AppShellContent><TabBar><Tabhref="/"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/kitOG ์ด๋ฏธ์ง€ ์ƒ์„ฑ (์„œ๋ฒ„)
PWA@m1kapp/kitmanifest, viewport, ์„ค์น˜ ์œ ๋„
Fetch@m1kapp/kit์บ์‹ฑยท์ค‘๋ณต์ œ๊ฑฐยท์žฌ์‹œ๋„ fetch ์œ ํ‹ธ
Utils@m1kapp/kit๋‚ ์งœยท์ˆซ์ž ํฌ๋งท, ๋ฒ”์šฉ ํ›…
Server@m1kapp/kit/serverNext.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><Tabactive={tab==="home"}onClick={()=>setTab("home")}label="ํ™ˆ"icon={<HomeIcon/>}activeColor="#3b82f6"// ํ™œ์„ฑ ์ƒ‰์ƒ ์ž์œ ๋กญ๊ฒŒ ์ง€์ •/></TabBar>

๋ฐ์ดํ„ฐ ํ‘œ์‹œ

import{Avatar,Badge,StatChip,EmptyState,GrassMap}from"@m1kapp/kit";// Avatar โ€” ์ด๋‹ˆ์…œ or ์ด๋ฏธ์ง€, ์ด๋ฏธ์ง€ ๋กœ๋“œ ์‹คํŒจ ์‹œ ์ด๋‹ˆ์…œ๋กœ ์ž๋™ fallback<Avatarsrc="/photo.jpg"fallback="MH"size="md"shape="circle"/><Avatarfallback="MH"size="lg"shape="rounded"color="#3b82f6"/>// size: "xs" | "sm" | "md" | "lg" | "xl"// shape: "circle" | "rounded"// Badge โ€” ์ƒํƒœ/์นดํ…Œ๊ณ ๋ฆฌ ๋ ˆ์ด๋ธ”<Badgevariant="green">LIVE</Badge><Badgevariant="red">์˜ค๋ฅ˜</Badge><Badgevariant="blue"size="sm">์ •๋ณด</Badge>// variant: "default" | "green" | "red" | "yellow" | "blue" | "purple" | "orange"// StatChip โ€” ์ˆซ์ž stat ๋ฑƒ์ง€<StatChiplabel="๋ฐฉ๋ฌธ์ž"value={1024}/>// EmptyState โ€” ๋นˆ ๋ชฉ๋ก ํ”Œ๋ ˆ์ด์Šคํ™€๋”<EmptyStatemessage="์•„์ง ์•„๋ฌด๊ฒƒ๋„ ์—†์–ด์š”"/>// GrassMap โ€” GitHub ์Šคํƒ€์ผ ํ™œ๋™ ํžˆํŠธ๋งต<GrassMapdata={[{date: "2025-04-19",count: 42}]}accent="#3b82f6"/>

์Šค์ผˆ๋ ˆํ†ค

๋กœ๋”ฉ ํ”Œ๋ ˆ์ด์Šคํ™€๋”. className์œผ๋กœ ํฌ๊ธฐ๋ฅผ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค.

import{Skeleton}from"@m1kapp/kit";// ํ…์ŠคํŠธ ์ค„<SkeletonclassName="h-4 w-3/4"/>// ์นด๋“œ ๋ธ”๋ก<SkeletonclassName="h-32 w-full"rounded="xl"/>// ์•„๋ฐ”ํƒ€<SkeletonclassName="h-10 w-10"rounded="full"/>// ์‹ค์ „ ํŒจํ„ดfunctionPostCardSkeleton(){return(<divclassName="flex gap-3 p-4"><SkeletonclassName="h-10 w-10"rounded="full"/><divclassName="flex-1 space-y-2"><SkeletonclassName="h-4 w-2/3"/><SkeletonclassName="h-3 w-1/2"/></div></div>);}

๋ชจ๋‹ฌ / ๋‹ค์ด์–ผ๋กœ๊ทธ

backdrop ํด๋ฆญ, ESC ํ‚ค, ์Šคํฌ๋กค ์ž ๊ธˆ ์ž๋™ ์ฒ˜๋ฆฌ.

import{Dialog}from"@m1kapp/kit";// ๊ธฐ๋ณธ ์‚ฌ์šฉ<Dialogopen={open}onClose={()=>setOpen(false)}title="์„ค์ •"><pclassName="text-sm text-zinc-500">๋‚ด์šฉ</p></Dialog>// ํ™•์ธ ๋‹ค์ด์–ผ๋กœ๊ทธ<Dialogopen={confirmOpen}onClose={()=>setConfirmOpen(false)}title="์‚ญ์ œํ• ๊นŒ์š”?"><pclassName="text-sm text-zinc-500">์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์–ด์š”.</p><divclassName="flex gap-2 mt-4"><buttononClick={handleDelete}className="...">์‚ญ์ œ</button><buttononClick={()=>setConfirmOpen(false)}className="...">์ทจ์†Œ</button>
</div></Dialog>// size: "sm" (๊ธฐ๋ณธ) | "md" | "lg"// persistent: true โ€” backdrop ํด๋ฆญ / ESC๋กœ ๋‹ซ๊ธฐ ๋น„ํ™œ์„ฑํ™”

์ธํ„ฐ๋ž™์…˜

import{Button,Tooltip,Typewriter,EmojiButton,EmojiPicker}from"@m1kapp/kit";<ButtononClick={fn}>์‹œ์ž‘ํ•˜๊ธฐ</Button><Tooltiplabel="์„ค๋ช… ํ…์ŠคํŠธ"><button>hover me</button></Tooltip><Typewriterwords={["Hello","World"]}color="#3b82f6"/>// ์ด๋ชจ์ง€ ์„ ํƒ๊ธฐconst[emoji,setEmoji]=useState("๐Ÿ ");const[open,setOpen]=useState(false);<EmojiButtonemoji={emoji}onClick={()=>setOpen(true)}/><EmojiPickeropen={open}onClose={()=>setOpen(false)}current={emoji}onSelect={setEmoji}/>

๊ณต์œ 

import{ShareButton,useShare}from"@m1kapp/kit";// ๋ฒ„ํŠผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ โ€” ๋ชจ๋ฐ”์ผ์€ ๋„ค์ดํ‹ฐ๋ธŒ ๊ณต์œ , ๋ฐ์Šคํฌํƒ‘์€ ํด๋ฆฝ๋ณด๋“œ ๋ณต์‚ฌ<ShareButtonurl="https://m1k.app"title="My App"/>// ์ปค์Šคํ…€ UIconst{ share, copied, canNativeShare }=useShare({url: "https://m1k.app"});<buttononClick={()=>share()}>{copied ? "๋ณต์‚ฌ๋จ!" : "๊ณต์œ "}</button>

ํ† ์ŠคํŠธ

import{ToastProvider,useToast}from"@m1kapp/kit";// ์•ฑ ๋ฃจํŠธ ๊ฐ์‹ธ๊ธฐ<ToastProvider><App/></ToastProvider>// ์–ด๋””์„œ๋‚˜consttoast=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><scriptdangerouslySetInnerHTML={{__html: THEME_SCRIPT}}/></head>// ํ…Œ๋งˆ ๋ฒ„ํŠผ + ๋‹ค์ด์–ผ๋กœ๊ทธconst[themeOpen,setThemeOpen]=useState(false);<ThemeButtoncolor={color}dark={dark}onClick={()=>setThemeOpen(true)}/><ThemeDialogopen={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 โ€” ๊ฐ€์žฅ ์‰ฌ์›€ (ํ•˜์œ„ ์ „์ฒด์— ์ ์šฉ)<AppShellaccent="#e2603f">...</AppShell>// โ‘ก ์•„๋ฌด ๋ž˜ํผ์—๋‚˜ CSS ๋ณ€์ˆ˜๋กœ<divstyle={{"--kit-accent": "#e2603f"}asReact.CSSProperties}>...</div>// โ‘ข ์ปดํฌ๋„ŒํŠธ๋ณ„ override<Switchchecked={on}onChange={setOn}accent="#e2603f"/>

ํผ ยท ์„ค์ •

import{Field,Switch}from"@m1kapp/kit";// ๋ผ๋ฒจ๋“œ ์ธํ’‹ (stacked ๊ธฐ๋ณธ / inline / multiline)<Fieldlabel="์ด๋ฆ„"value={name}onChange={setName}placeholder="์ด๋ฆ„"/><Fieldlabel="์ด๋ฉ”์ผ"value={email}inlinereadOnly/><Fieldlabel="๋ฉ”๋ชจ"value={memo}onChange={setMemo}multilinerows={3}hint="์ž์œ ๋กญ๊ฒŒ ์ ์–ด์ฃผ์„ธ์š”"/>// on/off ํ† ๊ธ€ โ€” accent๋Š” --kit-accent ์ž๋™ ์—ฐ๋™<Switchchecked={on}onChange={setOn}aria-label="์•Œ๋ฆผ"/>

์„ธ๊ทธ๋จผํŠธ ํ† ๊ธ€

import{SegmentedControl}from"@m1kapp/kit";<SegmentedControlvalue={view}onChange={setView}options={[{value: "today",label: "์˜ค๋Š˜"},{value: "week",label: "์ด๋ฒˆ ์ฃผ"}]}/>// ์ธ๋ผ์ธ ํ† ๊ธ€์šฉ. ํ•˜๋‹จ ๊ธ€๋กœ๋ฒŒ ๋‚ด๋น„๊ฒŒ์ด์…˜์€ TabBar๋ฅผ ์“ฐ์„ธ์š”.

์ฑ„ํŒ… / ๋Œ€ํ™”

import{MessageList,ChatBubble,TypingIndicator}from"@m1kapp/kit";importtype{ChatMessage}from"@m1kapp/kit";constmessages: ChatMessage[]=[{role: "user",content: "๋‚ด์ผ 3์‹œ ํšŒ์˜ ์žก์•„์ค˜",timestamp: Date.now()},{role: "assistant",content: "๋„ค, ์žก์•„๋“œ๋ฆด๊ฒŒ์š”."},];// dayDivider: timestamp ๊ธฐ์ค€ ๋‚ ์งœ๊ฐ€ ๋ฐ”๋€Œ๋ฉด ๊ตฌ๋ถ„์„  ์ž๋™ ์‚ฝ์ž…<MessageListmessages={messages}dayDivider>{pending&&<TypingIndicator/>}</MessageList>// ์ง์ ‘ ์“ฐ๋ ค๋ฉด<ChatBubblerole="user">์˜ค๋Š˜์ผ์ •๋ณด์—ฌ์ค˜</ChatBubble><ChatBubblerole="assistant">3๊ฑด ์žˆ์–ด์š”.</ChatBubble>

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

LLM ํˆด์ฝœ์ฒ˜๋Ÿผ "์ œ์•ˆ โ†’ ํ™•์ธ โ†’ ์‹คํ–‰" ํ๋ฆ„์„ ๋ฉ”์‹œ์ง€ ์•ˆ์— ๋ฐ•์„ ๋•Œ. ๋ชจ๋‹ฌ์ด ์•„๋‹ˆ๋ผ ํ๋ฆ„์— ์ธ๋ผ์ธ๋ฉ๋‹ˆ๋‹ค.

import{ActionCard}from"@m1kapp/kit";const[state,setState]=useState<"pending"|"loading"|"done"|"cancelled">("pending");<ActionCardtitle="์ด๋ ‡๊ฒŒ ๊ธฐ๋กํ•ด๋‘˜๊นŒ์š”?"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";<ListRowaccent="#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";// ๋‹ค๋‹จ๊ณ„ ์ง„ํ–‰ ํ‘œ์‹œ (์œ„์ €๋“œ/๋ฉ€ํ‹ฐํŽ˜์ด์ฆˆ ํ”Œ๋กœ์šฐ)<Steppercurrent={phase}onStepClick={setPhase}steps={[{label: "๋ถ„์„",icon: "๐Ÿ“"},{label: "์ค€๋น„",icon: "๐Ÿ“ธ"},{label: "์ƒ์„ฑ",icon: "โœจ"},]}/>// ์ ‘๊ธฐ ์นด๋“œ (ํ—ค๋”+๋ฐฐ์ง€+์ƒํƒœ)<Collapsibleleading={1} title="ํŽ˜๋ฅด์†Œ๋‚˜ ๋ถ„์„"subtitle="์Šคํƒ€์ผ ํŒŒ์•…"completedopen={open ===1}onToggle={()=>setOpen(open===1 ? null : 1)}><p>๋ณธ๋ฌธโ€ฆ</p></Collapsible>// ์›ํ˜• ์ง„ํ–‰๋ฅ <ProgressRingvalue={7}max={10}><spanclassName="text-2xl font-black">7</span></ProgressRing>

์ž…๋ ฅ ยท ํŽธ์ง‘

import{Select,ColorPicker,InlineEdit}from"@m1kapp/kit";// ์•ต์ปค๋“œ ๋“œ๋กญ๋‹ค์šด (์นด์šดํŠธยท๋น„ํ™œ์„ฑ ์˜ต์…˜, ์™ธ๋ถ€ํด๋ฆญ/ESC ๋‹ซํž˜)<Selectvalue={cat}onChange={setCat}placeholder="๋‚œ์ด๋„"options={[{value: "a",label: "์ดˆ๊ธ‰",count: 12},{value: "b",label: "๊ณ ๊ธ‰",count: 0,disabled: true},]}/>// ์ปฌ๋Ÿฌ ํ”ผ์ปค (ํ”„๋ฆฌ์…‹ + ์ปค์Šคํ…€ hex)<ColorPickervalue={color}onChange={setColor}/>// ํƒญํ•˜์—ฌ ํŽธ์ง‘ (Enter ์ €์žฅ ยท Esc ์ทจ์†Œ)<InlineEditvalue={name}onChange={rename}className="text-lg font-bold"/>

๋ณต์‚ฌ ยท ๋ฐ์ดํ„ฐ

import{CopyButton,CodeBlock,useCopy,BarList,Countdown,Carousel,Img}from"@m1kapp/kit";<CopyButtontext="npm i @m1kapp/kit">์„ค์น˜ ๋ณต์‚ฌ</CopyButton><CodeBlocklabel="install"code="npm i @m1kapp/kit"/>const{ copied, copy }=useCopy();// keyed: copy(text, "row-3")// ๊ฐ€๋กœ ๋ง‰๋Œ€ ๋ถ„์„ ์ฐจํŠธ<BarListitems={[{label: "/",value: 120},{label: "/about",value: 64,href: "/about"}]}/>// ์นด์šดํŠธ๋‹ค์šด (D-day)<Countdownto="2026-12-31"onComplete={celebrate}/>// ์Šค์™€์ดํ”„ ์บ๋Ÿฌ์…€ (controlled, ์  ์ธ๋””์ผ€์ดํ„ฐ)<Carouselcount={slides.length}index={i}onChange={setI}>{slides[i]}</Carousel>// ๋‹ค์ค‘ URL ํด๋ฐฑ ์ด๋ฏธ์ง€<Imgcandidates={[avatarUrl,gravatar]}fallback={<Avatarfallback="MH"/>}className="h-10 w-10 rounded-full"/>

์›Œํ„ฐ๋งˆํฌ

import{Watermark}from"@m1kapp/kit";<Watermarkcolor="#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์œผ๋กœ ์ง์ ‘ ์ง€์ •ํ•˜์„ธ์š”:

// ๋„๊ธฐ / ๋ช…์‹œ ์ง€์ •<Watermarktrack={false}>โ€ฆ</Watermark>// ๋น„์ฝ˜ ๋”<WatermarktrackSlug="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.tsximport{OGImage,loadPretendard}from"@m1kapp/kit";import{ImageResponse}from"next/og";exportasyncfunctionGET(){constfont=awaitloadPretendard();returnnewImageResponse(<OGImagetype="default"title="์‚ฌ์ด๋“œ ํ”„๋กœ์ ํŠธ ์‹œ์ž‘ํ•˜๊ธฐ"sub="๋น ๋ฅด๊ฒŒ ๋งŒ๋“ค๊ณ  ๋น ๋ฅด๊ฒŒ ๋ฐฐ์šฐ๋Š”"badge="๐Ÿš€ NEW"appName="myapp"color="#3b82f6"bg="dark"// "dark" | "gradient" | "blend"domain="m1k.app"/>,{width: 1200,height: 630,fonts: [font]});}

ํ…œํ”Œ๋ฆฟ

typeํฌ๊ธฐ์šฉ๋„
default1200ร—630๊ธฐ๋ณธ OG
article1200ร—630๋ธ”๋กœ๊ทธ ํฌ์ŠคํŠธ โ€” author, date, category
stat1200ร—630๋งˆ์ผ์Šคํ†ค โ€” stat, label
product1200ร—630์ œํ’ˆ ์†Œ๊ฐœ โ€” tagline, features[]
match1200ร—630๊ฒฝ๊ธฐ ๊ฒฐ๊ณผ โ€” home, away, score
square1200ร—1200Instagram / SNS
icon512ร—512์•ฑ ์•„์ด์ฝ˜ / favicon
// article<OGImagetype="article"title="์ œ๋ชฉ"author="minho"date="2025-04-19"category="Tutorial"sub="๋ถ€์ œ"color={c}bg={bg}/>// stat<OGImagetype="stat"stat="1,000"label="๋ช…์˜ ๋ฐฉ๋ฌธ์ž"sub="๋ก ์นญ 3์ผ ๋งŒ์—"badge="๐ŸŽ‰"color={c}bg={bg}/>// product<OGImagetype="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";constpretendard=awaitloadPretendard();// Pretendard ํ•œ๊ตญ์–ด ํฐํŠธconstroboto=awaitloadGoogleFont("Roboto",700);// Google FontsconstloadEmoji=createEmojiLoader("twemoji");// ์ด๋ชจ์ง€ fallback

PWA

Manifest

public/manifest.json ๋Œ€์‹  ์ฝ”๋“œ๋กœ ๊ด€๋ฆฌ. ์•„์ด์ฝ˜ ์ด๋ฏธ์ง€ ํŒŒ์ผ ๋ถˆํ•„์š”.

// app/manifest.tsimport{createManifest}from"@m1kapp/kit";exportdefaultcreateManifest({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.tsximport{mobileViewport}from"@m1kapp/kit";exportconstviewport=mobileViewport;

๋‚ด๋ถ€์ ์œผ๋กœ CSS touch-action: pan-x pan-y์™€ input { font-size: max(16px, 1em) }๋ฅผ ์ž๋™ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค.

SVG ์•„์ด์ฝ˜

import{svgIcon}from"@m1kapp/kit";constsrc=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";// ๋ฒ„ํŠผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ<PWAInstallButtonappName="My App"iconSrc={iconSrc}label="์•ฑ์œผ๋กœ ์„ค์น˜"/>// ์ปค์Šคํ…€ UIconst{ state, install }=usePWAInstall();// state: "android-ready" | "ios-safari" | "installed" | "unsupported"if(state==="android-ready"){return<buttononClick={install}>์„ค์น˜</button>;}if(state==="ios-safari"){return<buttononClick={()=>setSheetOpen(true)}>์„ค์น˜</button>;}// iOS ์•ˆ๋‚ด ์‹œํŠธ (์ง์ ‘ ์ œ์–ด ์‹œ)<IOSInstallSheetopen={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>;returndata?.map(u=><UserCardkey={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,// ํƒญ ์ˆจ๊ธฐ๋ฉด ์ž๋™ ์ •์ง€ โ€” ๋ถˆํ•„์š”ํ•œ ์š”์ฒญ ์—†์Œ});<buttononClick={()=>isRunning ? stop() : start()}>{isRunning ? "์ •์ง€" : "์‹œ์ž‘"}</button>

createApiClient

baseURL๊ณผ ๊ณตํ†ต ํ—ค๋”๋ฅผ ํ•œ ๋ฒˆ๋งŒ ์„ค์ •ํ•˜๋ฉด ํƒ€์ž… ์•ˆ์ „ํ•œ API ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋งŒ๋“ค์–ด์ง‘๋‹ˆ๋‹ค.

// lib/api.tsimport{createApiClient,ApiError}from"@m1kapp/kit";exportconstapi=createApiClient("https://api.myapp.com",{headers: {Authorization: `Bearer ${token}`},onError: (err)=>{if(err.status===401)signOut();},});// ์‚ฌ์šฉconstme=awaitapi.get<User>("/users/me");constpost=awaitapi.post<Post>("/posts",{ title, body });awaitapi.put("/posts/1",{title: "์ˆ˜์ •๋œ ์ œ๋ชฉ"});awaitapi.delete("/posts/1");// ์—๋Ÿฌ๋Š” ApiError๋กœ ์ •๊ทœํ™”try{awaitapi.delete("/posts/1");}catch(e){if(einstanceofApiError){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 โŒexportasyncfunctionGET(req: Request){constuser=awaitcurrentUser();if(!user)returnResponse.json({error: "Unauthorized"},{status: 401});try{constdata=awaitdb.sites.findMany({where: {userId: user.id}});returnResponse.json(data);}catch{returnResponse.json({error: "Internal Server Error"},{status: 500});}}// After โœ…exportconstGET=handler(async()=>{constuser=awaitcurrentUser();if(!user)unauthorized();// throws โ†’ 401constdata=awaitdb.sites.findMany({where: {userId: user.id}});returnok(data);// 200 + JSON// ์ฒ˜๋ฆฌ๋˜์ง€ ์•Š์€ ์—๋Ÿฌ โ†’ 500 ์ž๋™});exportconstPOST=handler(async(req)=>{constuser=awaitcurrentUser();if(!user)unauthorized();const{ url }=awaitreq.json();if(!url)badRequest("url์ด ํ•„์š”ํ•ด์š”");// throws โ†’ 400constsite=awaitdb.sites.create({data: { url,userId: user.id}});returncreated(site);// 201 + JSON});

์‘๋‹ต ํ—ฌํผ

ํ•จ์ˆ˜์ƒํƒœ์„ค๋ช…
ok(data)200JSON ์‘๋‹ต
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";exportconstGET=handler(async()=>{const{ok: success, data, error }=awaitsafely(()=>db.users.findFirstOrThrow());if(!success)returnserverError("DB ์กฐํšŒ ์‹คํŒจ");returnok(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 ์ž๋™ ์žฌ์‹œ๋„ (๋งˆ์ง€๋ง‰ ์‘๋‹ต ๋ฐ˜ํ™˜)constres=awaitfetchWithRetry(url,{headers: {authorization: `Bearer ${XAI_API_KEY}`}});// ์•„๋ฌด async๋‚˜ ์žฌ์‹œ๋„ (Neon ์ฝœ๋“œ์Šคํƒ€ํŠธ ๋“ฑ)constrows=awaitwithRetry(()=>db.query.users.findMany(),{shouldRetry: (e)=>String(e).includes("fetch failed"),});// LLM ์‘๋‹ต์—์„œ JSON ๋ณต๊ตฌ (```json ํŽœ์ŠคยทํŠธ๋ ˆ์ผ๋ง ์ฝค๋งˆยท๋…ธ์ด์ฆˆ ์ œ๊ฑฐ)constdata=recoverJsonFromText<{items: string[]}>(llmReply);// Open Graph ์Šคํฌ๋ž˜ํ•‘constog=awaitscrapeOg("example.com");// { title, description, image, siteName, url }// ํƒ€์ž„์กด ๋‚ ์งœtodayKST();// "2026-06-04"dateInTz(Date.now(),"America/New_York");// base62 slug + ํ˜ธ์ŠคํŠธidToSlug(42,1000);// "..." (slugToId๋กœ ์—ญ๋ณ€ํ™˜)constbase=`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("");constdebouncedQuery=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}`)});<formonSubmit={e=>{e.preventDefault();submit(inputValue);}}><inputvalue={inputValue}onChange={...}/>{error&&<pclassName="text-red-500 text-sm">{error.message}</p>}<buttondisabled={loading}>{loading ? "๋“ฑ๋ก ์ค‘โ€ฆ" : "๋“ฑ๋ก"}</button></form>

useInView

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

const{ ref, inView }=useInView({threshold: 0.1,once: true});useEffect(()=>{if(inView)fetchNextPage();},[inView]);return(<div>{posts.map(p=><PostCardkey={p.id}post={p}/>)}<divref={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

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

@m1kapp/kit

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

npmHits

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";exportdefaultfunctionApp(){return(// Watermark๊ฐ€ ๋ทฐํฌํŠธ ์ „์ฒด ๋†’์ด/์ค‘์•™์ •๋ ฌ/ํ•˜๋‹จ ํฌ๋ ˆ๋”ง์„ ๊ฐ™์ด ์ค๋‹ˆ๋‹ค.// (ํฌ๋ ˆ๋”ง ์ŠคํŠธ๋ฆฝ์€ ์…ธ ๋ฐ”๋กœ ๋ฐ‘์— ๋ถ™์–ด์„œ ์…ธ๊ณผ ํ•œ ๋ฉ์–ด๋ฆฌ๋กœ ์ค‘์•™์ •๋ ฌ๋จ โ€” ํ™”๋ฉด ์ง„์งœ ๋ฐ”๋‹ฅ์— ๊ณ ์ •๋˜๋Š” ๊ฒŒ ์•„๋‹˜)// AppShell๋งŒ ๋‹จ๋…์œผ๋กœ ์“ฐ๋ฉด ์ฝ˜ํ…์ธ  ๋†’์ด๋งŒํผ ์ชผ๊ทธ๋ผ๋“ค์–ด์š”.<Watermarkcolor="#e2603f"text="myapp"><AppShell><AppShellHeader><h1>My App</h1></AppShellHeader><AppShellContent><SectionclassName="pt-4">{/* ์ฝ˜ํ…์ธ  โ€” Divider๋กœ ์„น์…˜ ๊ตฌ๋ถ„ */}</Section></AppShellContent><TabBar><Tabhref="/"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/kitOG ์ด๋ฏธ์ง€ ์ƒ์„ฑ (์„œ๋ฒ„)
PWA@m1kapp/kitmanifest, viewport, ์„ค์น˜ ์œ ๋„
Fetch@m1kapp/kit์บ์‹ฑยท์ค‘๋ณต์ œ๊ฑฐยท์žฌ์‹œ๋„ fetch ์œ ํ‹ธ
Utils@m1kapp/kit๋‚ ์งœยท์ˆซ์ž ํฌ๋งท, ๋ฒ”์šฉ ํ›…
Server@m1kapp/kit/serverNext.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><Tabactive={tab==="home"}onClick={()=>setTab("home")}label="ํ™ˆ"icon={<HomeIcon/>}activeColor="#3b82f6"// ํ™œ์„ฑ ์ƒ‰์ƒ ์ž์œ ๋กญ๊ฒŒ ์ง€์ •/></TabBar>

๋ฐ์ดํ„ฐ ํ‘œ์‹œ

import{Avatar,Badge,StatChip,EmptyState,GrassMap}from"@m1kapp/kit";// Avatar โ€” ์ด๋‹ˆ์…œ or ์ด๋ฏธ์ง€, ์ด๋ฏธ์ง€ ๋กœ๋“œ ์‹คํŒจ ์‹œ ์ด๋‹ˆ์…œ๋กœ ์ž๋™ fallback<Avatarsrc="/photo.jpg"fallback="MH"size="md"shape="circle"/><Avatarfallback="MH"size="lg"shape="rounded"color="#3b82f6"/>// size: "xs" | "sm" | "md" | "lg" | "xl"// shape: "circle" | "rounded"// Badge โ€” ์ƒํƒœ/์นดํ…Œ๊ณ ๋ฆฌ ๋ ˆ์ด๋ธ”<Badgevariant="green">LIVE</Badge><Badgevariant="red">์˜ค๋ฅ˜</Badge><Badgevariant="blue"size="sm">์ •๋ณด</Badge>// variant: "default" | "green" | "red" | "yellow" | "blue" | "purple" | "orange"// StatChip โ€” ์ˆซ์ž stat ๋ฑƒ์ง€<StatChiplabel="๋ฐฉ๋ฌธ์ž"value={1024}/>// EmptyState โ€” ๋นˆ ๋ชฉ๋ก ํ”Œ๋ ˆ์ด์Šคํ™€๋”<EmptyStatemessage="์•„์ง ์•„๋ฌด๊ฒƒ๋„ ์—†์–ด์š”"/>// GrassMap โ€” GitHub ์Šคํƒ€์ผ ํ™œ๋™ ํžˆํŠธ๋งต<GrassMapdata={[{date: "2025-04-19",count: 42}]}accent="#3b82f6"/>

์Šค์ผˆ๋ ˆํ†ค

๋กœ๋”ฉ ํ”Œ๋ ˆ์ด์Šคํ™€๋”. className์œผ๋กœ ํฌ๊ธฐ๋ฅผ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค.

import{Skeleton}from"@m1kapp/kit";// ํ…์ŠคํŠธ ์ค„<SkeletonclassName="h-4 w-3/4"/>// ์นด๋“œ ๋ธ”๋ก<SkeletonclassName="h-32 w-full"rounded="xl"/>// ์•„๋ฐ”ํƒ€<SkeletonclassName="h-10 w-10"rounded="full"/>// ์‹ค์ „ ํŒจํ„ดfunctionPostCardSkeleton(){return(<divclassName="flex gap-3 p-4"><SkeletonclassName="h-10 w-10"rounded="full"/><divclassName="flex-1 space-y-2"><SkeletonclassName="h-4 w-2/3"/><SkeletonclassName="h-3 w-1/2"/></div></div>);}

๋ชจ๋‹ฌ / ๋‹ค์ด์–ผ๋กœ๊ทธ

backdrop ํด๋ฆญ, ESC ํ‚ค, ์Šคํฌ๋กค ์ž ๊ธˆ ์ž๋™ ์ฒ˜๋ฆฌ.

import{Dialog}from"@m1kapp/kit";// ๊ธฐ๋ณธ ์‚ฌ์šฉ<Dialogopen={open}onClose={()=>setOpen(false)}title="์„ค์ •"><pclassName="text-sm text-zinc-500">๋‚ด์šฉ</p></Dialog>// ํ™•์ธ ๋‹ค์ด์–ผ๋กœ๊ทธ<Dialogopen={confirmOpen}onClose={()=>setConfirmOpen(false)}title="์‚ญ์ œํ• ๊นŒ์š”?"><pclassName="text-sm text-zinc-500">์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์–ด์š”.</p><divclassName="flex gap-2 mt-4"><buttononClick={handleDelete}className="...">์‚ญ์ œ</button><buttononClick={()=>setConfirmOpen(false)}className="...">์ทจ์†Œ</button>
</div></Dialog>// size: "sm" (๊ธฐ๋ณธ) | "md" | "lg"// persistent: true โ€” backdrop ํด๋ฆญ / ESC๋กœ ๋‹ซ๊ธฐ ๋น„ํ™œ์„ฑํ™”

์ธํ„ฐ๋ž™์…˜

import{Button,Tooltip,Typewriter,EmojiButton,EmojiPicker}from"@m1kapp/kit";<ButtononClick={fn}>์‹œ์ž‘ํ•˜๊ธฐ</Button><Tooltiplabel="์„ค๋ช… ํ…์ŠคํŠธ"><button>hover me</button></Tooltip><Typewriterwords={["Hello","World"]}color="#3b82f6"/>// ์ด๋ชจ์ง€ ์„ ํƒ๊ธฐconst[emoji,setEmoji]=useState("๐Ÿ ");const[open,setOpen]=useState(false);<EmojiButtonemoji={emoji}onClick={()=>setOpen(true)}/><EmojiPickeropen={open}onClose={()=>setOpen(false)}current={emoji}onSelect={setEmoji}/>

๊ณต์œ 

import{ShareButton,useShare}from"@m1kapp/kit";// ๋ฒ„ํŠผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ โ€” ๋ชจ๋ฐ”์ผ์€ ๋„ค์ดํ‹ฐ๋ธŒ ๊ณต์œ , ๋ฐ์Šคํฌํƒ‘์€ ํด๋ฆฝ๋ณด๋“œ ๋ณต์‚ฌ<ShareButtonurl="https://m1k.app"title="My App"/>// ์ปค์Šคํ…€ UIconst{ share, copied, canNativeShare }=useShare({url: "https://m1k.app"});<buttononClick={()=>share()}>{copied ? "๋ณต์‚ฌ๋จ!" : "๊ณต์œ "}</button>

ํ† ์ŠคํŠธ

import{ToastProvider,useToast}from"@m1kapp/kit";// ์•ฑ ๋ฃจํŠธ ๊ฐ์‹ธ๊ธฐ<ToastProvider><App/></ToastProvider>// ์–ด๋””์„œ๋‚˜consttoast=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><scriptdangerouslySetInnerHTML={{__html: THEME_SCRIPT}}/></head>// ํ…Œ๋งˆ ๋ฒ„ํŠผ + ๋‹ค์ด์–ผ๋กœ๊ทธconst[themeOpen,setThemeOpen]=useState(false);<ThemeButtoncolor={color}dark={dark}onClick={()=>setThemeOpen(true)}/><ThemeDialogopen={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 โ€” ๊ฐ€์žฅ ์‰ฌ์›€ (ํ•˜์œ„ ์ „์ฒด์— ์ ์šฉ)<AppShellaccent="#e2603f">...</AppShell>// โ‘ก ์•„๋ฌด ๋ž˜ํผ์—๋‚˜ CSS ๋ณ€์ˆ˜๋กœ<divstyle={{"--kit-accent": "#e2603f"}asReact.CSSProperties}>...</div>// โ‘ข ์ปดํฌ๋„ŒํŠธ๋ณ„ override<Switchchecked={on}onChange={setOn}accent="#e2603f"/>

ํผ ยท ์„ค์ •

import{Field,Switch}from"@m1kapp/kit";// ๋ผ๋ฒจ๋“œ ์ธํ’‹ (stacked ๊ธฐ๋ณธ / inline / multiline)<Fieldlabel="์ด๋ฆ„"value={name}onChange={setName}placeholder="์ด๋ฆ„"/><Fieldlabel="์ด๋ฉ”์ผ"value={email}inlinereadOnly/><Fieldlabel="๋ฉ”๋ชจ"value={memo}onChange={setMemo}multilinerows={3}hint="์ž์œ ๋กญ๊ฒŒ ์ ์–ด์ฃผ์„ธ์š”"/>// on/off ํ† ๊ธ€ โ€” accent๋Š” --kit-accent ์ž๋™ ์—ฐ๋™<Switchchecked={on}onChange={setOn}aria-label="์•Œ๋ฆผ"/>

์„ธ๊ทธ๋จผํŠธ ํ† ๊ธ€

import{SegmentedControl}from"@m1kapp/kit";<SegmentedControlvalue={view}onChange={setView}options={[{value: "today",label: "์˜ค๋Š˜"},{value: "week",label: "์ด๋ฒˆ ์ฃผ"}]}/>// ์ธ๋ผ์ธ ํ† ๊ธ€์šฉ. ํ•˜๋‹จ ๊ธ€๋กœ๋ฒŒ ๋‚ด๋น„๊ฒŒ์ด์…˜์€ TabBar๋ฅผ ์“ฐ์„ธ์š”.

์ฑ„ํŒ… / ๋Œ€ํ™”

import{MessageList,ChatBubble,TypingIndicator}from"@m1kapp/kit";importtype{ChatMessage}from"@m1kapp/kit";constmessages: ChatMessage[]=[{role: "user",content: "๋‚ด์ผ 3์‹œ ํšŒ์˜ ์žก์•„์ค˜",timestamp: Date.now()},{role: "assistant",content: "๋„ค, ์žก์•„๋“œ๋ฆด๊ฒŒ์š”."},];// dayDivider: timestamp ๊ธฐ์ค€ ๋‚ ์งœ๊ฐ€ ๋ฐ”๋€Œ๋ฉด ๊ตฌ๋ถ„์„  ์ž๋™ ์‚ฝ์ž…<MessageListmessages={messages}dayDivider>{pending&&<TypingIndicator/>}</MessageList>// ์ง์ ‘ ์“ฐ๋ ค๋ฉด<ChatBubblerole="user">์˜ค๋Š˜์ผ์ •๋ณด์—ฌ์ค˜</ChatBubble><ChatBubblerole="assistant">3๊ฑด ์žˆ์–ด์š”.</ChatBubble>

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

LLM ํˆด์ฝœ์ฒ˜๋Ÿผ "์ œ์•ˆ โ†’ ํ™•์ธ โ†’ ์‹คํ–‰" ํ๋ฆ„์„ ๋ฉ”์‹œ์ง€ ์•ˆ์— ๋ฐ•์„ ๋•Œ. ๋ชจ๋‹ฌ์ด ์•„๋‹ˆ๋ผ ํ๋ฆ„์— ์ธ๋ผ์ธ๋ฉ๋‹ˆ๋‹ค.

import{ActionCard}from"@m1kapp/kit";const[state,setState]=useState<"pending"|"loading"|"done"|"cancelled">("pending");<ActionCardtitle="์ด๋ ‡๊ฒŒ ๊ธฐ๋กํ•ด๋‘˜๊นŒ์š”?"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";<ListRowaccent="#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";// ๋‹ค๋‹จ๊ณ„ ์ง„ํ–‰ ํ‘œ์‹œ (์œ„์ €๋“œ/๋ฉ€ํ‹ฐํŽ˜์ด์ฆˆ ํ”Œ๋กœ์šฐ)<Steppercurrent={phase}onStepClick={setPhase}steps={[{label: "๋ถ„์„",icon: "๐Ÿ“"},{label: "์ค€๋น„",icon: "๐Ÿ“ธ"},{label: "์ƒ์„ฑ",icon: "โœจ"},]}/>// ์ ‘๊ธฐ ์นด๋“œ (ํ—ค๋”+๋ฐฐ์ง€+์ƒํƒœ)<Collapsibleleading={1} title="ํŽ˜๋ฅด์†Œ๋‚˜ ๋ถ„์„"subtitle="์Šคํƒ€์ผ ํŒŒ์•…"completedopen={open ===1}onToggle={()=>setOpen(open===1 ? null : 1)}><p>๋ณธ๋ฌธโ€ฆ</p></Collapsible>// ์›ํ˜• ์ง„ํ–‰๋ฅ <ProgressRingvalue={7}max={10}><spanclassName="text-2xl font-black">7</span></ProgressRing>

์ž…๋ ฅ ยท ํŽธ์ง‘

import{Select,ColorPicker,InlineEdit}from"@m1kapp/kit";// ์•ต์ปค๋“œ ๋“œ๋กญ๋‹ค์šด (์นด์šดํŠธยท๋น„ํ™œ์„ฑ ์˜ต์…˜, ์™ธ๋ถ€ํด๋ฆญ/ESC ๋‹ซํž˜)<Selectvalue={cat}onChange={setCat}placeholder="๋‚œ์ด๋„"options={[{value: "a",label: "์ดˆ๊ธ‰",count: 12},{value: "b",label: "๊ณ ๊ธ‰",count: 0,disabled: true},]}/>// ์ปฌ๋Ÿฌ ํ”ผ์ปค (ํ”„๋ฆฌ์…‹ + ์ปค์Šคํ…€ hex)<ColorPickervalue={color}onChange={setColor}/>// ํƒญํ•˜์—ฌ ํŽธ์ง‘ (Enter ์ €์žฅ ยท Esc ์ทจ์†Œ)<InlineEditvalue={name}onChange={rename}className="text-lg font-bold"/>

๋ณต์‚ฌ ยท ๋ฐ์ดํ„ฐ

import{CopyButton,CodeBlock,useCopy,BarList,Countdown,Carousel,Img}from"@m1kapp/kit";<CopyButtontext="npm i @m1kapp/kit">์„ค์น˜ ๋ณต์‚ฌ</CopyButton><CodeBlocklabel="install"code="npm i @m1kapp/kit"/>const{ copied, copy }=useCopy();// keyed: copy(text, "row-3")// ๊ฐ€๋กœ ๋ง‰๋Œ€ ๋ถ„์„ ์ฐจํŠธ<BarListitems={[{label: "/",value: 120},{label: "/about",value: 64,href: "/about"}]}/>// ์นด์šดํŠธ๋‹ค์šด (D-day)<Countdownto="2026-12-31"onComplete={celebrate}/>// ์Šค์™€์ดํ”„ ์บ๋Ÿฌ์…€ (controlled, ์  ์ธ๋””์ผ€์ดํ„ฐ)<Carouselcount={slides.length}index={i}onChange={setI}>{slides[i]}</Carousel>// ๋‹ค์ค‘ URL ํด๋ฐฑ ์ด๋ฏธ์ง€<Imgcandidates={[avatarUrl,gravatar]}fallback={<Avatarfallback="MH"/>}className="h-10 w-10 rounded-full"/>

์›Œํ„ฐ๋งˆํฌ

import{Watermark}from"@m1kapp/kit";<Watermarkcolor="#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์œผ๋กœ ์ง์ ‘ ์ง€์ •ํ•˜์„ธ์š”:

// ๋„๊ธฐ / ๋ช…์‹œ ์ง€์ •<Watermarktrack={false}>โ€ฆ</Watermark>// ๋น„์ฝ˜ ๋”<WatermarktrackSlug="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.tsximport{OGImage,loadPretendard}from"@m1kapp/kit";import{ImageResponse}from"next/og";exportasyncfunctionGET(){constfont=awaitloadPretendard();returnnewImageResponse(<OGImagetype="default"title="์‚ฌ์ด๋“œ ํ”„๋กœ์ ํŠธ ์‹œ์ž‘ํ•˜๊ธฐ"sub="๋น ๋ฅด๊ฒŒ ๋งŒ๋“ค๊ณ  ๋น ๋ฅด๊ฒŒ ๋ฐฐ์šฐ๋Š”"badge="๐Ÿš€ NEW"appName="myapp"color="#3b82f6"bg="dark"// "dark" | "gradient" | "blend"domain="m1k.app"/>,{width: 1200,height: 630,fonts: [font]});}

ํ…œํ”Œ๋ฆฟ

typeํฌ๊ธฐ์šฉ๋„
default1200ร—630๊ธฐ๋ณธ OG
article1200ร—630๋ธ”๋กœ๊ทธ ํฌ์ŠคํŠธ โ€” author, date, category
stat1200ร—630๋งˆ์ผ์Šคํ†ค โ€” stat, label
product1200ร—630์ œํ’ˆ ์†Œ๊ฐœ โ€” tagline, features[]
match1200ร—630๊ฒฝ๊ธฐ ๊ฒฐ๊ณผ โ€” home, away, score
square1200ร—1200Instagram / SNS
icon512ร—512์•ฑ ์•„์ด์ฝ˜ / favicon
// article<OGImagetype="article"title="์ œ๋ชฉ"author="minho"date="2025-04-19"category="Tutorial"sub="๋ถ€์ œ"color={c}bg={bg}/>// stat<OGImagetype="stat"stat="1,000"label="๋ช…์˜ ๋ฐฉ๋ฌธ์ž"sub="๋ก ์นญ 3์ผ ๋งŒ์—"badge="๐ŸŽ‰"color={c}bg={bg}/>// product<OGImagetype="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";constpretendard=awaitloadPretendard();// Pretendard ํ•œ๊ตญ์–ด ํฐํŠธconstroboto=awaitloadGoogleFont("Roboto",700);// Google FontsconstloadEmoji=createEmojiLoader("twemoji");// ์ด๋ชจ์ง€ fallback

PWA

Manifest

public/manifest.json ๋Œ€์‹  ์ฝ”๋“œ๋กœ ๊ด€๋ฆฌ. ์•„์ด์ฝ˜ ์ด๋ฏธ์ง€ ํŒŒ์ผ ๋ถˆํ•„์š”.

// app/manifest.tsimport{createManifest}from"@m1kapp/kit";exportdefaultcreateManifest({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.tsximport{mobileViewport}from"@m1kapp/kit";exportconstviewport=mobileViewport;

๋‚ด๋ถ€์ ์œผ๋กœ CSS touch-action: pan-x pan-y์™€ input { font-size: max(16px, 1em) }๋ฅผ ์ž๋™ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค.

SVG ์•„์ด์ฝ˜

import{svgIcon}from"@m1kapp/kit";constsrc=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";// ๋ฒ„ํŠผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ<PWAInstallButtonappName="My App"iconSrc={iconSrc}label="์•ฑ์œผ๋กœ ์„ค์น˜"/>// ์ปค์Šคํ…€ UIconst{ state, install }=usePWAInstall();// state: "android-ready" | "ios-safari" | "installed" | "unsupported"if(state==="android-ready"){return<buttononClick={install}>์„ค์น˜</button>;}if(state==="ios-safari"){return<buttononClick={()=>setSheetOpen(true)}>์„ค์น˜</button>;}// iOS ์•ˆ๋‚ด ์‹œํŠธ (์ง์ ‘ ์ œ์–ด ์‹œ)<IOSInstallSheetopen={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>;returndata?.map(u=><UserCardkey={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,// ํƒญ ์ˆจ๊ธฐ๋ฉด ์ž๋™ ์ •์ง€ โ€” ๋ถˆํ•„์š”ํ•œ ์š”์ฒญ ์—†์Œ});<buttononClick={()=>isRunning ? stop() : start()}>{isRunning ? "์ •์ง€" : "์‹œ์ž‘"}</button>

createApiClient

baseURL๊ณผ ๊ณตํ†ต ํ—ค๋”๋ฅผ ํ•œ ๋ฒˆ๋งŒ ์„ค์ •ํ•˜๋ฉด ํƒ€์ž… ์•ˆ์ „ํ•œ API ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋งŒ๋“ค์–ด์ง‘๋‹ˆ๋‹ค.

// lib/api.tsimport{createApiClient,ApiError}from"@m1kapp/kit";exportconstapi=createApiClient("https://api.myapp.com",{headers: {Authorization: `Bearer ${token}`},onError: (err)=>{if(err.status===401)signOut();},});// ์‚ฌ์šฉconstme=awaitapi.get<User>("/users/me");constpost=awaitapi.post<Post>("/posts",{ title, body });awaitapi.put("/posts/1",{title: "์ˆ˜์ •๋œ ์ œ๋ชฉ"});awaitapi.delete("/posts/1");// ์—๋Ÿฌ๋Š” ApiError๋กœ ์ •๊ทœํ™”try{awaitapi.delete("/posts/1");}catch(e){if(einstanceofApiError){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 โŒexportasyncfunctionGET(req: Request){constuser=awaitcurrentUser();if(!user)returnResponse.json({error: "Unauthorized"},{status: 401});try{constdata=awaitdb.sites.findMany({where: {userId: user.id}});returnResponse.json(data);}catch{returnResponse.json({error: "Internal Server Error"},{status: 500});}}// After โœ…exportconstGET=handler(async()=>{constuser=awaitcurrentUser();if(!user)unauthorized();// throws โ†’ 401constdata=awaitdb.sites.findMany({where: {userId: user.id}});returnok(data);// 200 + JSON// ์ฒ˜๋ฆฌ๋˜์ง€ ์•Š์€ ์—๋Ÿฌ โ†’ 500 ์ž๋™});exportconstPOST=handler(async(req)=>{constuser=awaitcurrentUser();if(!user)unauthorized();const{ url }=awaitreq.json();if(!url)badRequest("url์ด ํ•„์š”ํ•ด์š”");// throws โ†’ 400constsite=awaitdb.sites.create({data: { url,userId: user.id}});returncreated(site);// 201 + JSON});

์‘๋‹ต ํ—ฌํผ

ํ•จ์ˆ˜์ƒํƒœ์„ค๋ช…
ok(data)200JSON ์‘๋‹ต
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";exportconstGET=handler(async()=>{const{ok: success, data, error }=awaitsafely(()=>db.users.findFirstOrThrow());if(!success)returnserverError("DB ์กฐํšŒ ์‹คํŒจ");returnok(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 ์ž๋™ ์žฌ์‹œ๋„ (๋งˆ์ง€๋ง‰ ์‘๋‹ต ๋ฐ˜ํ™˜)constres=awaitfetchWithRetry(url,{headers: {authorization: `Bearer ${XAI_API_KEY}`}});// ์•„๋ฌด async๋‚˜ ์žฌ์‹œ๋„ (Neon ์ฝœ๋“œ์Šคํƒ€ํŠธ ๋“ฑ)constrows=awaitwithRetry(()=>db.query.users.findMany(),{shouldRetry: (e)=>String(e).includes("fetch failed"),});// LLM ์‘๋‹ต์—์„œ JSON ๋ณต๊ตฌ (```json ํŽœ์ŠคยทํŠธ๋ ˆ์ผ๋ง ์ฝค๋งˆยท๋…ธ์ด์ฆˆ ์ œ๊ฑฐ)constdata=recoverJsonFromText<{items: string[]}>(llmReply);// Open Graph ์Šคํฌ๋ž˜ํ•‘constog=awaitscrapeOg("example.com");// { title, description, image, siteName, url }// ํƒ€์ž„์กด ๋‚ ์งœtodayKST();// "2026-06-04"dateInTz(Date.now(),"America/New_York");// base62 slug + ํ˜ธ์ŠคํŠธidToSlug(42,1000);// "..." (slugToId๋กœ ์—ญ๋ณ€ํ™˜)constbase=`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("");constdebouncedQuery=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}`)});<formonSubmit={e=>{e.preventDefault();submit(inputValue);}}><inputvalue={inputValue}onChange={...}/>{error&&<pclassName="text-red-500 text-sm">{error.message}</p>}<buttondisabled={loading}>{loading ? "๋“ฑ๋ก ์ค‘โ€ฆ" : "๋“ฑ๋ก"}</button></form>

useInView

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

const{ ref, inView }=useInView({threshold: 0.1,once: true});useEffect(()=>{if(inView)fetchNextPage();},[inView]);return(<div>{posts.map(p=><PostCardkey={p.id}post={p}/>)}<divref={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

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages