@farmzone/fz-template-react 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # @farmzone/fz-template-react
2
+
3
+ Farmzone React 프로젝트 보일러플레이트 생성 CLI.
4
+
5
+ ## 사용법
6
+
7
+ ```bash
8
+ npx @farmzone/fz-template-react <프로젝트명>
9
+ ```
10
+
11
+ 예시:
12
+
13
+ ```bash
14
+ npx @farmzone/fz-template-react my-app
15
+ cd my-app
16
+ npm install
17
+ npm run dev
18
+ ```
19
+
20
+ 프로젝트명을 생략하면 대화형으로 입력을 받습니다.
21
+
22
+ ```bash
23
+ npx @farmzone/fz-template-react
24
+ # 프로젝트 이름을 입력하세요: my-app
25
+ ```
26
+
27
+ ## 생성되는 프로젝트 구조
28
+
29
+ ```
30
+ my-app/
31
+ ├── src/
32
+ │ ├── app/
33
+ │ │ ├── layout/
34
+ │ │ │ ├── Layout.tsx # 사이드바 + Outlet 레이아웃
35
+ │ │ │ ├── Sidebar.tsx # 사이드바 컴포넌트
36
+ │ │ │ └── menu.ts # 메뉴 설정
37
+ │ │ ├── router/
38
+ │ │ │ └── Router.tsx
39
+ │ │ └── App.tsx # QueryClientProvider 루트
40
+ │ ├── pages/
41
+ │ │ ├── dashboard/
42
+ │ │ │ └── index.tsx
43
+ │ │ └── error/
44
+ │ │ ├── Error.tsx
45
+ │ │ └── NotFound.tsx
46
+ │ └── index.tsx
47
+ ├── .gitignore
48
+ ├── index.html
49
+ ├── index.css
50
+ ├── vite.config.ts
51
+ ├── tsconfig.json
52
+ └── package.json
53
+ ```
54
+
55
+ ## 포함된 기술 스택
56
+
57
+ | 항목 | 버전 |
58
+ |---|---|
59
+ | React | ^19 |
60
+ | Vite | ^7 |
61
+ | TypeScript | ~5.9 |
62
+ | Tailwind CSS | v4 |
63
+ | React Router | ^7 |
64
+ | TanStack Query | ^5 |
65
+ | @farmzone/fz-react-ui | latest |
66
+ | react-hook-form + zod | 최신 |
67
+
68
+ ## 메뉴 추가
69
+
70
+ `src/app/layout/menu.ts` 수정:
71
+
72
+ ```ts
73
+ import { LayoutDashboard, Users, ScrollText } from "lucide-react";
74
+
75
+ export const MENU_SECTIONS: MenuSection[] = [
76
+ {
77
+ items: [
78
+ { icon: LayoutDashboard, label: "대시보드", path: "/" },
79
+ ],
80
+ },
81
+ {
82
+ title: "관리",
83
+ items: [
84
+ { icon: Users, label: "사용자 관리", path: "/user" },
85
+ { icon: ScrollText, label: "로그", path: "/log" },
86
+ ],
87
+ },
88
+ ];
89
+ ```
90
+
91
+ ## 라우트 추가
92
+
93
+ `src/app/router/Router.tsx`의 `children` 배열에 추가:
94
+
95
+ ```tsx
96
+ children: [
97
+ { index: true, element: <DashboardPage /> },
98
+ { path: "user", element: <UserListPage /> },
99
+ { path: "user/:id", element: <UserDetailPage /> },
100
+ { path: "log", element: <LogPage /> },
101
+ ],
102
+ ```
103
+
104
+ ## npm 배포
105
+
106
+ ```bash
107
+ npm publish --access public
108
+ ```
package/bin/create.js ADDED
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createInterface } from "readline";
4
+ import {
5
+ existsSync,
6
+ mkdirSync,
7
+ readdirSync,
8
+ statSync,
9
+ copyFileSync,
10
+ readFileSync,
11
+ writeFileSync,
12
+ renameSync,
13
+ } from "fs";
14
+ import { join, dirname } from "path";
15
+ import { fileURLToPath } from "url";
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+
19
+ function prompt(question) {
20
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
21
+ return new Promise((resolve) => {
22
+ rl.question(question, (answer) => {
23
+ rl.close();
24
+ resolve(answer.trim());
25
+ });
26
+ });
27
+ }
28
+
29
+ function copyDir(src, dest) {
30
+ mkdirSync(dest, { recursive: true });
31
+ for (const entry of readdirSync(src)) {
32
+ const srcPath = join(src, entry);
33
+ const destPath = join(dest, entry);
34
+ if (statSync(srcPath).isDirectory()) {
35
+ copyDir(srcPath, destPath);
36
+ } else {
37
+ copyFileSync(srcPath, destPath);
38
+ }
39
+ }
40
+ }
41
+
42
+ function replaceInFile(filePath, search, replacement) {
43
+ if (!existsSync(filePath)) return;
44
+ const content = readFileSync(filePath, "utf-8");
45
+ writeFileSync(filePath, content.replaceAll(search, replacement));
46
+ }
47
+
48
+ async function main() {
49
+ console.log("\n🌱 Farmzone React Template\n");
50
+
51
+ let projectName = process.argv[2];
52
+
53
+ if (!projectName) {
54
+ projectName = await prompt("프로젝트 이름을 입력하세요: ");
55
+ }
56
+
57
+ if (!projectName) {
58
+ console.error("❌ 프로젝트 이름을 입력해야 합니다.");
59
+ process.exit(1);
60
+ }
61
+
62
+ if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(projectName)) {
63
+ console.error("❌ 프로젝트 이름은 소문자, 숫자, 하이픈(-)만 사용 가능하며 하이픈으로 시작하거나 끝날 수 없습니다.");
64
+ process.exit(1);
65
+ }
66
+
67
+ const targetDir = join(process.cwd(), projectName);
68
+
69
+ if (existsSync(targetDir)) {
70
+ console.error(`❌ '${projectName}' 디렉터리가 이미 존재합니다.`);
71
+ process.exit(1);
72
+ }
73
+
74
+ const templateDir = join(__dirname, "..", "template");
75
+
76
+ console.log(`\n🚀 '${projectName}' 프로젝트를 생성합니다...\n`);
77
+
78
+ // 템플릿 복사
79
+ copyDir(templateDir, targetDir);
80
+
81
+ // gitignore -> .gitignore 이름 변경 (npm publish 시 .gitignore는 자동으로 제외됨)
82
+ const gitignoreSrc = join(targetDir, "gitignore");
83
+ const gitignoreDest = join(targetDir, ".gitignore");
84
+ if (existsSync(gitignoreSrc)) renameSync(gitignoreSrc, gitignoreDest);
85
+
86
+ // __PROJECT_NAME__ 치환
87
+ replaceInFile(join(targetDir, "package.json"), "__PROJECT_NAME__", projectName);
88
+ replaceInFile(join(targetDir, "index.html"), "__PROJECT_NAME__", projectName);
89
+
90
+ console.log("✅ 프로젝트 생성 완료!\n");
91
+ console.log("──────────────────────────────");
92
+ console.log("다음 명령어로 시작하세요:\n");
93
+ console.log(` cd ${projectName}`);
94
+ console.log(` npm install`);
95
+ console.log(` npm run dev`);
96
+ console.log("──────────────────────────────\n");
97
+ }
98
+
99
+ main().catch((err) => {
100
+ console.error(err);
101
+ process.exit(1);
102
+ });
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@farmzone/fz-template-react",
3
+ "version": "0.0.1",
4
+ "description": "Farmzone React 프로젝트 보일러플레이트 생성 CLI",
5
+ "type": "module",
6
+ "bin": {
7
+ "fz-template-react": "./bin/create.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "template"
12
+ ],
13
+ "keywords": [
14
+ "farmzone",
15
+ "react",
16
+ "template",
17
+ "boilerplate",
18
+ "vite"
19
+ ],
20
+ "license": "UNLICENSED",
21
+ "engines": {
22
+ "node": ">=18"
23
+ }
24
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "printWidth": 110,
3
+ "tabWidth": 2,
4
+ "semi": true,
5
+ "bracketSpacing": true,
6
+ "singleQuote": false,
7
+ "jsxSingleQuote": false,
8
+ "trailingComma": "all"
9
+ }
@@ -0,0 +1,23 @@
1
+ import js from "@eslint/js";
2
+ import globals from "globals";
3
+ import reactHooks from "eslint-plugin-react-hooks";
4
+ import reactRefresh from "eslint-plugin-react-refresh";
5
+ import tseslint from "typescript-eslint";
6
+ import { defineConfig, globalIgnores } from "eslint/config";
7
+
8
+ export default defineConfig([
9
+ globalIgnores(["dist"]),
10
+ {
11
+ files: ["**/*.{ts,tsx}"],
12
+ extends: [
13
+ js.configs.recommended,
14
+ tseslint.configs.recommended,
15
+ reactHooks.configs.flat.recommended,
16
+ reactRefresh.configs.vite,
17
+ ],
18
+ languageOptions: {
19
+ ecmaVersion: 2020,
20
+ globals: globals.browser,
21
+ },
22
+ },
23
+ ]);
@@ -0,0 +1,32 @@
1
+ # 로그
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+
9
+ # 의존성
10
+ node_modules/
11
+ .pnp
12
+ .pnp.js
13
+
14
+ # 빌드 결과물
15
+ dist/
16
+ dist-ssr/
17
+
18
+ # 환경 변수
19
+ .env
20
+ .env.local
21
+ .env.*.local
22
+
23
+ # 에디터
24
+ .vscode/*
25
+ !.vscode/extensions.json
26
+ .idea
27
+ .DS_Store
28
+ *.suo
29
+ *.ntvs*
30
+ *.njsproj
31
+ *.sln
32
+ *.sw?
@@ -0,0 +1,19 @@
1
+ @import "tailwindcss";
2
+ @import "tw-animate-css";
3
+ @import "@farmzone/fz-react-ui/styles";
4
+
5
+ @custom-variant dark (&:is(.dark *));
6
+
7
+ @tailwind utilities;
8
+
9
+ @theme inline {
10
+ --color-main: #3b82f6;
11
+ --color-sub-lightgray: #f3f4f6;
12
+ --color-sub-darkgray: #6b7280;
13
+ --color-sub-darkgray-2: #374151;
14
+ --color-basic-gray: #9ca3af;
15
+ }
16
+
17
+ body {
18
+ font-family: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
19
+ }
@@ -0,0 +1,19 @@
1
+ <!doctype html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.ico" />
6
+ <link
7
+ rel="stylesheet"
8
+ as="style"
9
+ crossorigin
10
+ href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard-dynamic-subset.min.css"
11
+ />
12
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
13
+ <title>__PROJECT_NAME__</title>
14
+ </head>
15
+ <body>
16
+ <div id="root"></div>
17
+ <script type="module" src="/src/index.tsx"></script>
18
+ </body>
19
+ </html>
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "__PROJECT_NAME__",
3
+ "private": true,
4
+ "version": "0.0.1",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite --host 0.0.0.0 --port 3000",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "@farmzone/fz-react-ui": "latest",
14
+ "@hookform/resolvers": "^5.2.2",
15
+ "@tanstack/react-query": "^5.90.0",
16
+ "axios": "^1.13.0",
17
+ "lucide-react": "^1.16.0",
18
+ "react": "^19.0.0",
19
+ "react-dom": "^19.0.0",
20
+ "react-hook-form": "^7.68.0",
21
+ "react-router": "^7.11.0",
22
+ "zod": "^3.25.0"
23
+ },
24
+ "devDependencies": {
25
+ "@eslint/js": "^9.0.0",
26
+ "@tailwindcss/vite": "^4.1.0",
27
+ "@types/node": "^22.0.0",
28
+ "@types/react": "^19.0.0",
29
+ "@types/react-dom": "^19.0.0",
30
+ "@vitejs/plugin-react": "^5.1.0",
31
+ "eslint": "^9.0.0",
32
+ "eslint-plugin-react-hooks": "^5.0.0",
33
+ "eslint-plugin-react-refresh": "^0.4.0",
34
+ "globals": "^16.0.0",
35
+ "tailwindcss": "^4.0.0",
36
+ "tw-animate-css": "^1.0.0",
37
+ "typescript": "~5.9.0",
38
+ "typescript-eslint": "^8.0.0",
39
+ "vite": "^7.0.0"
40
+ }
41
+ }
@@ -0,0 +1,24 @@
1
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2
+
3
+ import Router from "@/app/router/Router";
4
+
5
+ const queryClient = new QueryClient({
6
+ defaultOptions: {
7
+ queries: {
8
+ staleTime: 0,
9
+ gcTime: 5 * 60 * 1000,
10
+ refetchOnMount: true,
11
+ refetchOnReconnect: false,
12
+ refetchOnWindowFocus: false,
13
+ retry: 1,
14
+ },
15
+ },
16
+ });
17
+
18
+ export default function App() {
19
+ return (
20
+ <QueryClientProvider client={queryClient}>
21
+ <Router />
22
+ </QueryClientProvider>
23
+ );
24
+ }
@@ -0,0 +1,16 @@
1
+ import { Outlet } from "react-router";
2
+ import { ToastProvider } from "@farmzone/fz-react-ui";
3
+
4
+ import Sidebar from "./Sidebar";
5
+
6
+ export default function Layout() {
7
+ return (
8
+ <div className="flex h-screen bg-gray-50">
9
+ <ToastProvider />
10
+ <Sidebar />
11
+ <main className="flex-1 overflow-y-auto">
12
+ <Outlet />
13
+ </main>
14
+ </div>
15
+ );
16
+ }
@@ -0,0 +1,53 @@
1
+ import { NavLink, useNavigate } from "react-router";
2
+ import { cn } from "@farmzone/fz-react-ui";
3
+
4
+ import { MENU_SECTIONS } from "./menu";
5
+
6
+ export default function Sidebar() {
7
+ const navigate = useNavigate();
8
+
9
+ return (
10
+ <aside className="flex h-screen w-56 shrink-0 flex-col border-r border-gray-200 bg-white">
11
+ {/* 로고 영역 */}
12
+ <div className="flex h-14 items-center border-b border-gray-200 px-5">
13
+ <button
14
+ onClick={() => navigate("/")}
15
+ className="text-base font-bold text-gray-900 transition-colors hover:text-blue-600"
16
+ >
17
+ PROJECT
18
+ </button>
19
+ </div>
20
+
21
+ {/* 메뉴 영역 */}
22
+ <nav className="flex-1 overflow-y-auto px-3 py-4">
23
+ {MENU_SECTIONS.map((section, i) => (
24
+ <div key={i} className="mb-4">
25
+ {section.title && (
26
+ <p className="mb-1 px-3 text-xs font-semibold uppercase tracking-wider text-gray-400">
27
+ {section.title}
28
+ </p>
29
+ )}
30
+ {section.items.map((item) => (
31
+ <NavLink
32
+ key={item.path}
33
+ to={item.path}
34
+ end={item.path === "/"}
35
+ className={({ isActive }) =>
36
+ cn(
37
+ "mb-0.5 flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
38
+ isActive
39
+ ? "bg-blue-50 text-blue-600"
40
+ : "text-gray-600 hover:bg-gray-100 hover:text-gray-900",
41
+ )
42
+ }
43
+ >
44
+ <item.icon size={18} strokeWidth={1.8} />
45
+ {item.label}
46
+ </NavLink>
47
+ ))}
48
+ </div>
49
+ ))}
50
+ </nav>
51
+ </aside>
52
+ );
53
+ }
@@ -0,0 +1,27 @@
1
+ import { LayoutDashboard } from "lucide-react";
2
+ import type { LucideIcon } from "lucide-react";
3
+
4
+ export interface MenuItem {
5
+ icon: LucideIcon;
6
+ label: string;
7
+ path: string;
8
+ }
9
+
10
+ export interface MenuSection {
11
+ title?: string;
12
+ items: MenuItem[];
13
+ }
14
+
15
+ export const MENU_SECTIONS: MenuSection[] = [
16
+ {
17
+ items: [{ icon: LayoutDashboard, label: "대시보드", path: "/" }],
18
+ },
19
+ // 메뉴 섹션 추가 예시:
20
+ // {
21
+ // title: "관리",
22
+ // items: [
23
+ // { icon: Users, label: "사용자 관리", path: "/user" },
24
+ // { icon: ScrollText, label: "로그", path: "/log" },
25
+ // ],
26
+ // },
27
+ ];
@@ -0,0 +1,28 @@
1
+ import { useMemo } from "react";
2
+ import { createBrowserRouter, RouterProvider } from "react-router";
3
+
4
+ import Layout from "@/app/layout/Layout";
5
+ import DashboardPage from "@/pages/dashboard";
6
+ import ErrorPage from "@/pages/error/Error";
7
+ import NotFoundPage from "@/pages/error/NotFound";
8
+
9
+ export default function Router() {
10
+ const router = useMemo(
11
+ () =>
12
+ createBrowserRouter([
13
+ {
14
+ path: "/",
15
+ element: <Layout />,
16
+ errorElement: <ErrorPage />,
17
+ children: [
18
+ { index: true, element: <DashboardPage /> },
19
+ // 여기에 라우트를 추가하세요
20
+ ],
21
+ },
22
+ { path: "*", element: <NotFoundPage /> },
23
+ ]),
24
+ [],
25
+ );
26
+
27
+ return <RouterProvider router={router} />;
28
+ }
@@ -0,0 +1,12 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+
4
+ import App from "@/app/App";
5
+
6
+ import "../index.css";
7
+
8
+ createRoot(document.getElementById("root")!).render(
9
+ <StrictMode>
10
+ <App />
11
+ </StrictMode>,
12
+ );
@@ -0,0 +1,8 @@
1
+ export default function DashboardPage() {
2
+ return (
3
+ <div className="p-8">
4
+ <h1 className="text-2xl font-bold text-gray-900">대시보드</h1>
5
+ <p className="mt-2 text-sm text-gray-500">환영합니다. 여기에 대시보드 콘텐츠를 추가하세요.</p>
6
+ </div>
7
+ );
8
+ }
@@ -0,0 +1,17 @@
1
+ import { useRouteError, useNavigate } from "react-router";
2
+ import { Button } from "@farmzone/fz-react-ui";
3
+
4
+ export default function ErrorPage() {
5
+ const error = useRouteError() as { statusText?: string; message?: string };
6
+ const navigate = useNavigate();
7
+
8
+ return (
9
+ <div className="flex h-screen flex-col items-center justify-center gap-4 bg-gray-50">
10
+ <h1 className="text-4xl font-bold text-gray-900">오류가 발생했습니다</h1>
11
+ <p className="text-gray-500">{error?.statusText ?? error?.message ?? "알 수 없는 오류"}</p>
12
+ <Button variant="outline" onClick={() => navigate("/")}>
13
+ 홈으로 돌아가기
14
+ </Button>
15
+ </div>
16
+ );
17
+ }
@@ -0,0 +1,17 @@
1
+ import { useNavigate } from "react-router";
2
+ import { Button } from "@farmzone/fz-react-ui";
3
+
4
+ export default function NotFoundPage() {
5
+ const navigate = useNavigate();
6
+
7
+ return (
8
+ <div className="flex h-screen flex-col items-center justify-center gap-3 bg-gray-50">
9
+ <p className="text-8xl font-bold text-gray-200">404</p>
10
+ <h1 className="text-xl font-semibold text-gray-700">페이지를 찾을 수 없습니다</h1>
11
+ <p className="text-sm text-gray-400">요청하신 페이지가 존재하지 않거나 이동되었습니다.</p>
12
+ <Button className="mt-2" onClick={() => navigate("/")}>
13
+ 홈으로 돌아가기
14
+ </Button>
15
+ </div>
16
+ );
17
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
+ "target": "ES2022",
5
+ "useDefineForClassFields": true,
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "types": ["vite/client"],
9
+ "skipLibCheck": true,
10
+
11
+ /* Bundler mode */
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "moduleDetection": "force",
16
+ "noEmit": true,
17
+ "jsx": "react-jsx",
18
+
19
+ /* Linting */
20
+ "strict": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "erasableSyntaxOnly": true,
24
+ "noFallthroughCasesInSwitch": true,
25
+ "noUncheckedSideEffectImports": true,
26
+ "baseUrl": ".",
27
+ "paths": {
28
+ "@/*": ["src/*"]
29
+ }
30
+ },
31
+ "include": ["src"]
32
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
+ "target": "ES2023",
5
+ "lib": ["ES2023"],
6
+ "module": "ESNext",
7
+ "types": ["node"],
8
+ "skipLibCheck": true,
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "verbatimModuleSyntax": true,
14
+ "moduleDetection": "force",
15
+ "noEmit": true,
16
+
17
+ /* Linting */
18
+ "strict": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "erasableSyntaxOnly": true,
22
+ "noFallthroughCasesInSwitch": true,
23
+ "noUncheckedSideEffectImports": true
24
+ },
25
+ "include": ["vite.config.ts"]
26
+ }
@@ -0,0 +1,13 @@
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+ import tailwindcss from "@tailwindcss/vite";
4
+ import path from "node:path";
5
+
6
+ export default defineConfig({
7
+ plugins: [react(), tailwindcss()],
8
+ resolve: {
9
+ alias: {
10
+ "@": path.resolve(__dirname, "src"),
11
+ },
12
+ },
13
+ });