Skip to content

m1kapp/kit

Repository files navigation

@m1kapp/kit

사이드 프로젝트를 위한 UI · OG · PWA · Fetch · Utils 올인원 킷.

npm Hits

npm install @m1kapp/kit

Peer dependencies: react >= 18, react-dom >= 18
Optional: @vercel/og >= 0.6 (Next.js 외 환경에서 OG 이미지 생성 시)


빠른 시작

// CSS 자동 주입 — 별도 import 불필요
import { AppShell, AppShellHeader, AppShellContent, TabBar, Tab } from "@m1kapp/kit";

export default function App() {
  return (
    <AppShell>
      <AppShellHeader>
        <h1>My App</h1>
      </AppShellHeader>
      <AppShellContent>
        {/* 콘텐츠 */}
      </AppShellContent>
      <TabBar>
        <Tab href="/" icon={<HomeIcon />} label="홈" />
      </TabBar>
    </AppShell>
  );
}

모듈 구성

모듈 import 설명
UI @m1kapp/kit 컴포넌트 24개 + 훅
OG Image @m1kapp/kit OG 이미지 생성 (서버)
PWA @m1kapp/kit manifest, viewport, 설치 유도
Fetch @m1kapp/kit 캐싱·중복제거·재시도 fetch 유틸
Utils @m1kapp/kit 날짜·숫자 포맷, 범용 훅
Server @m1kapp/kit/server Next.js API route 핸들러 유틸

UI

CSS가 import 시 자동 주입됩니다. 별도 스타일시트 import 불필요.

레이아웃

import { AppShell, AppShellHeader, AppShellContent } from "@m1kapp/kit";

<AppShell>                    // 최대 430px 중앙 정렬 모바일 컨테이너
  <AppShellHeader>...</AppShellHeader>    // 상단 sticky 헤더
  <AppShellContent>...</AppShellContent> // 스크롤 가능한 본문
</AppShell>

내비게이션

import { TabBar, Tab } from "@m1kapp/kit";

<TabBar>
  <Tab
    active={tab === "home"}
    onClick={() => setTab("home")}
    label="홈"
    icon={<HomeIcon />}
    activeColor="#3b82f6"   // 활성 색상 자유롭게 지정
  />
</TabBar>

데이터 표시

import { Avatar, Badge, StatChip, EmptyState, GrassMap } from "@m1kapp/kit";

// Avatar — 이니셜 or 이미지, 이미지 로드 실패 시 이니셜로 자동 fallback
<Avatar src="/photo.jpg" fallback="MH" size="md" shape="circle" />
<Avatar fallback="MH" size="lg" shape="rounded" color="#3b82f6" />
// size: "xs" | "sm" | "md" | "lg" | "xl"
// shape: "circle" | "rounded"

// Badge — 상태/카테고리 레이블
<Badge variant="green">LIVE</Badge>
<Badge variant="red">오류</Badge>
<Badge variant="blue" size="sm">정보</Badge>
// variant: "default" | "green" | "red" | "yellow" | "blue" | "purple" | "orange"

// StatChip — 숫자 stat 뱃지
<StatChip label="방문자" value={1024} />

// EmptyState — 빈 목록 플레이스홀더
<EmptyState message="아직 아무것도 없어요" />

// GrassMap — GitHub 스타일 활동 히트맵
<GrassMap data={[{ date: "2025-04-19", count: 42 }]} accent="#3b82f6" />

스켈레톤

로딩 플레이스홀더. className으로 크기를 지정합니다.

import { Skeleton } from "@m1kapp/kit";

// 텍스트 줄
<Skeleton className="h-4 w-3/4" />

// 카드 블록
<Skeleton className="h-32 w-full" rounded="xl" />

// 아바타
<Skeleton className="h-10 w-10" rounded="full" />

// 실전 패턴
function PostCardSkeleton() {
  return (
    <div className="flex gap-3 p-4">
      <Skeleton className="h-10 w-10" rounded="full" />
      <div className="flex-1 space-y-2">
        <Skeleton className="h-4 w-2/3" />
        <Skeleton className="h-3 w-1/2" />
      </div>
    </div>
  );
}

모달 / 다이얼로그

backdrop 클릭, ESC 키, 스크롤 잠금 자동 처리.

import { Dialog } from "@m1kapp/kit";

// 기본 사용
<Dialog open={open} onClose={() => setOpen(false)} title="설정">
  <p className="text-sm text-zinc-500">내용</p>
</Dialog>

// 확인 다이얼로그
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)} title="삭제할까요?">
  <p className="text-sm text-zinc-500">이 작업은 되돌릴 수 없어요.</p>
  <div className="flex gap-2 mt-4">
    <button onClick={handleDelete} className="...">삭제</button>
    <button onClick={() => setConfirmOpen(false)} className="...">취소</button>
  </div>
</Dialog>

// size: "sm" (기본) | "md" | "lg"
// persistent: true — backdrop 클릭 / ESC로 닫기 비활성화

인터랙션

import { Button, Tooltip, Typewriter, EmojiButton, EmojiPicker } from "@m1kapp/kit";

<Button onClick={fn}>시작하기</Button>

<Tooltip label="설명 텍스트">
  <button>hover me</button>
</Tooltip>

<Typewriter words={["Hello", "World"]} color="#3b82f6" />

// 이모지 선택기
const [emoji, setEmoji] = useState("🏠");
const [open, setOpen] = useState(false);
<EmojiButton emoji={emoji} onClick={() => setOpen(true)} />
<EmojiPicker open={open} onClose={() => setOpen(false)} current={emoji} onSelect={setEmoji} />

공유

import { ShareButton, useShare } from "@m1kapp/kit";

// 버튼 그대로 사용 — 모바일은 네이티브 공유, 데스크탑은 클립보드 복사
<ShareButton url="https://m1k.app" title="My App" />

// 커스텀 UI
const { share, copied, canNativeShare } = useShare({ url: "https://m1k.app" });
<button onClick={() => share()}>{copied ? "복사됨!" : "공유"}</button>

토스트

import { ToastProvider, useToast } from "@m1kapp/kit";

// 앱 루트 감싸기
<ToastProvider>
  <App />
</ToastProvider>

// 어디서나
const toast = useToast();
toast("저장됐어요!", { variant: "success" });
toast("오류가 발생했어요.", { variant: "error", duration: 4000 });
toast("링크가 복사됐어요.");  // default (dark)
// variant: "default" | "success" | "error" | "info"

테마

다크모드 + 컬러 테마 선택기.

import { ThemeButton, ThemeDialog, THEME_SCRIPT, colors } from "@m1kapp/kit";

// layout.tsx — 다크모드 깜빡임 방지
<head>
  <script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
</head>

// 테마 버튼 + 다이얼로그
const [themeOpen, setThemeOpen] = useState(false);
<ThemeButton color={color} dark={dark} onClick={() => setThemeOpen(true)} />
<ThemeDialog
  open={themeOpen}
  onClose={() => setThemeOpen(false)}
  current={color}
  onSelect={setColor}
  dark={dark}
  onDarkToggle={() => setDark(v => !v)}
/>

// 팔레트
colors.blue    // "#3b82f6"
colors.purple  // "#a855f7"
colors.green   // "#22c55e"
// blue | purple | green | orange | pink | red | yellow | cyan | slate | zinc

워터마크

import { Watermark } from "@m1kapp/kit";

<Watermark color="#3b82f6" text="myapp">
  {children}
</Watermark>

OG Image

Next.js 14+는 next/og가 내장되어 있어 별도 설치 불필요. 그 외 환경은 npm i @vercel/og.

// app/og/route.tsx
import { OGImage, loadPretendard } from "@m1kapp/kit";
import { ImageResponse } from "next/og";

export async function GET() {
  const font = await loadPretendard();

  return new ImageResponse(
    <OGImage
      type="default"
      title="사이드 프로젝트 시작하기"
      sub="빠르게 만들고 빠르게 배우는"
      badge="🚀 NEW"
      appName="myapp"
      color="#3b82f6"
      bg="dark"          // "dark" | "gradient" | "blend"
      domain="m1k.app"
    />,
    { width: 1200, height: 630, fonts: [font] }
  );
}

템플릿

type 크기 용도
default 1200×630 기본 OG
article 1200×630 블로그 포스트 — author, date, category
stat 1200×630 마일스톤 — stat, label
product 1200×630 제품 소개 — tagline, features[]
match 1200×630 경기 결과 — home, away, score
square 1200×1200 Instagram / SNS
icon 512×512 앱 아이콘 / favicon
// article
<OGImage type="article" title="제목" author="minho" date="2025-04-19" category="Tutorial" sub="부제" color={c} bg={bg} />

// stat
<OGImage type="stat" stat="1,000" label="명의 방문자" sub="론칭 3일 만에" badge="🎉" color={c} bg={bg} />

// product
<OGImage type="product" title="@m1kapp/kit" tagline="올인원 킷" features={["기능1", "기능2"]} color={c} bg={bg} />

폰트 & 이모지

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

const pretendard = await loadPretendard();           // Pretendard 한국어 폰트
const roboto = await loadGoogleFont("Roboto", 700);  // Google Fonts
const loadEmoji = createEmojiLoader("twemoji");       // 이모지 fallback

PWA

Manifest

public/manifest.json 대신 코드로 관리. 아이콘 이미지 파일 불필요.

// app/manifest.ts
import { createManifest } from "@m1kapp/kit";

export default createManifest({
  name: "My App",
  shortName: "App",
  description: "What this app does",
  themeColor: "#3b82f6",      // 아이콘 배경색으로도 사용
  backgroundColor: "#ffffff",
  icon: { text: "MA" },       // 텍스트로 192×192, 512×512 SVG 아이콘 자동 생성
});

Viewport — 핀치 줌 차단

iOS 10+에서 핀치 줌과 인풋 자동 확대를 막습니다. viewportFit: "cover"로 노치 / Dynamic Island 기기에서 safe area inset도 지원합니다.

// app/layout.tsx
import { mobileViewport } from "@m1kapp/kit";

export const viewport = mobileViewport;

내부적으로 CSS touch-action: pan-x pan-yinput { font-size: max(16px, 1em) }를 자동 적용합니다.

SVG 아이콘

import { svgIcon } from "@m1kapp/kit";

const src = svgIcon("MA", { size: 192, bg: "#3b82f6", color: "#ffffff", radius: 0.25 });
// → "data:image/svg+xml,..." — <img src={src} /> 또는 manifest icons에 바로 사용

앱 설치 유도

Android는 네이티브 설치 프롬프트, iOS는 홈 화면 추가 안내 시트를 자동으로 띄워줍니다.

import { PWAInstallButton, IOSInstallSheet, usePWAInstall } from "@m1kapp/kit";

// 버튼 그대로 사용
<PWAInstallButton appName="My App" iconSrc={iconSrc} label="앱으로 설치" />

// 커스텀 UI
const { state, install } = usePWAInstall();
// state: "android-ready" | "ios-safari" | "installed" | "unsupported"

if (state === "android-ready") {
  return <button onClick={install}>설치</button>;
}
if (state === "ios-safari") {
  return <button onClick={() => setSheetOpen(true)}>설치</button>;
}

// iOS 안내 시트 (직접 제어 시)
<IOSInstallSheet open={sheetOpen} onClose={() => setSheetOpen(false)} appName="My App" iconSrc={iconSrc} />

Fetch

의존성 제로. 캐싱 · 중복제거 · 재시도 · 포커스 revalidate가 내장된 fetch 유틸.

useFetch

import { useFetch } from "@m1kapp/kit";

const { data, loading, error, refetch } = useFetch<User[]>("/api/users", {
  staleTime: 30_000,        // 30초 캐시 — 같은 URL 중복 요청 없음
  retry: 2,                 // 네트워크 오류 시 지수 백오프로 2회 재시도
  revalidateOnFocus: true,  // 탭 돌아오면 자동 최신 데이터
});

// 로딩 처리
if (loading && !data) return <PostListSkeleton />;
if (error) return <p>{error.message}</p>;
return data?.map(u => <UserCard key={u.id} user={u} />);

usePolling

실시간 데이터, 라이브 스코어 등에 사용.

import { usePolling } from "@m1kapp/kit";

const { data, isRunning, start, stop } = usePolling(
  () => fetch("/api/match/live").then(r => r.json()),
  {
    interval: 5000,       // 5초마다
    enabled: true,        // 시작 여부
    pauseOnHidden: true,  // 탭 숨기면 자동 정지 — 불필요한 요청 없음
  }
);

<button onClick={() => isRunning ? stop() : start()}>
  {isRunning ? "정지" : "시작"}
</button>

createApiClient

baseURL과 공통 헤더를 한 번만 설정하면 타입 안전한 API 클라이언트가 만들어집니다.

// lib/api.ts
import { createApiClient, ApiError } from "@m1kapp/kit";

export const api = createApiClient("https://api.myapp.com", {
  headers: { Authorization: `Bearer ${token}` },
  onError: (err) => {
    if (err.status === 401) signOut();
  },
});

// 사용
const me   = await api.get<User>("/users/me");
const post = await api.post<Post>("/posts", { title, body });
await api.put("/posts/1", { title: "수정된 제목" });
await api.delete("/posts/1");

// 에러는 ApiError로 정규화
try {
  await api.delete("/posts/1");
} catch (e) {
  if (e instanceof ApiError) {
    console.log(e.status, e.body); // 404, { error: "Not found" }
  }
}

Server

Next.js API route 전용. @m1kapp/kit/server로 import — 클라이언트 번들에 포함되지 않습니다.

handler()

try/catch 없이 에러를 처리합니다. unauthorized(), notFound() 등은 never를 반환하므로 TypeScript가 제어 흐름을 정확히 추론합니다.

import { handler, ok, created, unauthorized, forbidden, notFound, badRequest } from "@m1kapp/kit/server";

// Before ❌
export async function GET(req: Request) {
  const user = await currentUser();
  if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
  try {
    const data = await db.sites.findMany({ where: { userId: user.id } });
    return Response.json(data);
  } catch {
    return Response.json({ error: "Internal Server Error" }, { status: 500 });
  }
}

// After ✅
export const GET = handler(async () => {
  const user = await currentUser();
  if (!user) unauthorized();               // throws → 401

  const data = await db.sites.findMany({ where: { userId: user.id } });
  return ok(data);                         // 200 + JSON
  // 처리되지 않은 에러 → 500 자동
});

export const POST = handler(async (req) => {
  const user = await currentUser();
  if (!user) unauthorized();

  const { url } = await req.json();
  if (!url) badRequest("url이 필요해요");  // throws → 400

  const site = await db.sites.create({ data: { url, userId: user.id } });
  return created(site);                    // 201 + JSON
});

응답 헬퍼

함수 상태 설명
ok(data) 200 JSON 응답
created(data) 201 생성 완료
noContent() 204 본문 없음
badRequest(msg?) 400 잘못된 요청
unauthorized(msg?) 401 인증 필요
forbidden(msg?) 403 권한 없음
notFound(msg?) 404 리소스 없음
conflict(msg?) 409 충돌
serverError(msg?) 500 서버 오류

safely()

특정 에러를 try/catch 없이 처리하고 싶을 때.

import { handler, ok, serverError, safely } from "@m1kapp/kit/server";

export const GET = handler(async () => {
  const { ok: success, data, error } = await safely(() => db.users.findFirstOrThrow());
  if (!success) return serverError("DB 조회 실패");
  return ok(data);
});

Utils

순수 함수 — 의존성 없음, 어디서나 import.

import { relativeTime, formatNumber, formatPrice, cn } from "@m1kapp/kit";

// 상대 시간
relativeTime(post.createdAt)               // "3분 전", "어제", "2025. 4. 19."

// 숫자 포맷
formatNumber(1_500)                        // "1.5천"
formatNumber(15_000)                       // "1.5만"
formatNumber(150_000_000)                  // "1.5억"

// 가격 포맷
formatPrice(9_900)                         // "₩9,900"
formatPrice(9.99, "USD")                   // "$9.99"

// 조건부 클래스 — Tailwind 충돌 자동 해결 (clsx + tailwind-merge 내장)
cn("base", isActive && "active", err && "border-red-500")
// → "base active"
cn("px-2 py-1", "px-4")                   // → "py-1 px-4"  (충돌 해결)
cn({ "opacity-50": disabled })             // 객체 문법 지원

Hooks

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

useDebounce

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

useEffect(() => {
  if (debouncedQuery) searchAPI(debouncedQuery); // 타이핑 멈출 때만 실행
}, [debouncedQuery]);

useFormSubmit

모든 form handler의 loading / error / try-catch / finally 보일러플레이트를 제거합니다.

const { submit, loading, error, data, reset } = useFormSubmit(
  async (url: string) => api.post<Site>("/api/sites", { url }),
  { onSuccess: (site) => router.push(`/sites/${site.id}`) }
);

<form onSubmit={e => { e.preventDefault(); submit(inputValue); }}>
  <input value={inputValue} onChange={...} />
  {error && <p className="text-red-500 text-sm">{error.message}</p>}
  <button disabled={loading}>{loading ? "등록 중…" : "등록"}</button>
</form>

useInView

무한스크롤 트리거, 레이지 로드, 등장 애니메이션에 사용.

const { ref, inView } = useInView({ threshold: 0.1, once: true });

useEffect(() => {
  if (inView) fetchNextPage();
}, [inView]);

return (
  <div>
    {posts.map(p => <PostCard key={p.id} post={p} />)}
    <div ref={ref} />  {/* 리스트 맨 아래 센티넬 */}
  </div>
);

useLocalStorage

새로고침 후에도 유지되는 로컬 상태. SSR 안전.

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

setTheme("dark");    // localStorage에 저장
removeTheme();       // localStorage에서 삭제, 초기값으로 복원

Releases

No releases published

Packages

 
 
 

Contributors