create-vite-react-boot 1.0.9
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 +0 -0
- package/bin/cli.js +279 -0
- package/lib/apply.js +314 -0
- package/lib/files.js +290 -0
- package/lib/utils.js +13 -0
- package/package.json +23 -0
package/README.md
ADDED
File without changes
|
package/bin/cli.js
ADDED
@@ -0,0 +1,279 @@
|
|
1
|
+
#!/usr/bin/env node
|
2
|
+
import { fileURLToPath } from "url";
|
3
|
+
import path from "path";
|
4
|
+
import fs from "fs";
|
5
|
+
import prompts from "prompts";
|
6
|
+
import spawn from "cross-spawn"; // ← 크로스플랫폼 스폰
|
7
|
+
import { green, yellow, red, cyan, bold } from "kolorist";
|
8
|
+
import { applyScaffold } from "../lib/apply.js";
|
9
|
+
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
11
|
+
const cwd = process.cwd();
|
12
|
+
|
13
|
+
function run(cmd, args = [], options = {}) {
|
14
|
+
// cross-spawn은 Windows에서 .cmd/.ps1 자동 해결. shell:false로 충분.
|
15
|
+
const child = spawn(cmd, args, { stdio: "inherit", shell: false, ...options });
|
16
|
+
const code = child.status ?? 0;
|
17
|
+
if (code !== 0) process.exit(code);
|
18
|
+
}
|
19
|
+
|
20
|
+
function detectPM() {
|
21
|
+
const ua = process.env.npm_config_user_agent || "";
|
22
|
+
if (ua.includes("pnpm")) return "pnpm";
|
23
|
+
if (ua.includes("yarn")) return "yarn";
|
24
|
+
return "npm";
|
25
|
+
}
|
26
|
+
|
27
|
+
function ensureDir(dir) {
|
28
|
+
fs.mkdirSync(dir, { recursive: true });
|
29
|
+
}
|
30
|
+
|
31
|
+
function write(p, content) {
|
32
|
+
ensureDir(path.dirname(p));
|
33
|
+
fs.writeFileSync(p, content);
|
34
|
+
}
|
35
|
+
|
36
|
+
function writeJSON(p, obj) {
|
37
|
+
write(p, JSON.stringify(obj, null, 2) + "\n");
|
38
|
+
}
|
39
|
+
|
40
|
+
function isEmptyDir(dir) {
|
41
|
+
return !fs.existsSync(dir) || fs.readdirSync(dir).length === 0;
|
42
|
+
}
|
43
|
+
|
44
|
+
(async function main() {
|
45
|
+
const argName = process.argv[2];
|
46
|
+
|
47
|
+
const { name } = await prompts(
|
48
|
+
[
|
49
|
+
{
|
50
|
+
type: argName ? null : "text",
|
51
|
+
name: "name",
|
52
|
+
message: "프로젝트 이름?",
|
53
|
+
initial: "myapp",
|
54
|
+
validate: (v) =>
|
55
|
+
!v || /[\\/:*?"<>|]/.test(v) ? "유효한 폴더명을 입력하세요." : true
|
56
|
+
}
|
57
|
+
],
|
58
|
+
{ onCancel: () => process.exit(1) }
|
59
|
+
);
|
60
|
+
|
61
|
+
const appName = argName || name;
|
62
|
+
if (!appName) {
|
63
|
+
console.log(red("✖ 프로젝트 이름이 비어있습니다."));
|
64
|
+
process.exit(1);
|
65
|
+
}
|
66
|
+
|
67
|
+
const target = path.resolve(cwd, appName);
|
68
|
+
|
69
|
+
// 같은 폴더에서 다시 실행 방지: 이미 Vite 구조면 중단
|
70
|
+
if (fs.existsSync(target) && !isEmptyDir(target)) {
|
71
|
+
console.log(red(`✖ 대상 폴더가 비어있지 않습니다: ${target}`));
|
72
|
+
process.exit(1);
|
73
|
+
}
|
74
|
+
|
75
|
+
const pm = detectPM();
|
76
|
+
|
77
|
+
// ─────────────────────────────────────────────
|
78
|
+
// 1) Vite + React + TS 기본 구조를 '직접' 생성 (무프롬프트)
|
79
|
+
// ─────────────────────────────────────────────
|
80
|
+
console.log(cyan(`\n▶ Vite + React + TypeScript 기본 파일 생성…`));
|
81
|
+
ensureDir(target);
|
82
|
+
|
83
|
+
// package.json
|
84
|
+
const pkgJson = {
|
85
|
+
name: appName,
|
86
|
+
private: true,
|
87
|
+
version: "0.0.0",
|
88
|
+
type: "module",
|
89
|
+
scripts: {
|
90
|
+
dev: "vite",
|
91
|
+
build: "tsc -b && vite build",
|
92
|
+
preview: "vite preview"
|
93
|
+
},
|
94
|
+
dependencies: {
|
95
|
+
react: "^18.3.1",
|
96
|
+
"react-dom": "^18.3.1",
|
97
|
+
"react-router-dom": "^6.26.1",
|
98
|
+
axios: "^1.7.7"
|
99
|
+
},
|
100
|
+
devDependencies: {
|
101
|
+
typescript: "^5.5.4",
|
102
|
+
vite: "^5.4.2",
|
103
|
+
"@vitejs/plugin-react": "^4.3.1",
|
104
|
+
tailwindcss: "^3.4.10",
|
105
|
+
postcss: "^8.4.47",
|
106
|
+
autoprefixer: "^10.4.20"
|
107
|
+
}
|
108
|
+
};
|
109
|
+
writeJSON(path.join(target, "package.json"), pkgJson);
|
110
|
+
|
111
|
+
// tsconfig들
|
112
|
+
write(
|
113
|
+
path.join(target, "tsconfig.json"),
|
114
|
+
`{
|
115
|
+
"compilerOptions": {
|
116
|
+
"target": "ES2020",
|
117
|
+
"useDefineForClassFields": true,
|
118
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
119
|
+
"module": "ESNext",
|
120
|
+
"skipLibCheck": true,
|
121
|
+
"moduleResolution": "Bundler",
|
122
|
+
"resolveJsonModule": true,
|
123
|
+
"isolatedModules": true,
|
124
|
+
"jsx": "react-jsx",
|
125
|
+
"baseUrl": "."
|
126
|
+
},
|
127
|
+
"include": ["src"]
|
128
|
+
}
|
129
|
+
`
|
130
|
+
);
|
131
|
+
write(
|
132
|
+
path.join(target, "tsconfig.node.json"),
|
133
|
+
`{
|
134
|
+
"compilerOptions": {
|
135
|
+
"composite": true,
|
136
|
+
"module": "ESNext",
|
137
|
+
"moduleResolution": "Bundler",
|
138
|
+
"allowSyntheticDefaultImports": true
|
139
|
+
},
|
140
|
+
"include": ["vite.config.ts"]
|
141
|
+
}
|
142
|
+
`
|
143
|
+
);
|
144
|
+
|
145
|
+
// vite.config.ts
|
146
|
+
write(
|
147
|
+
path.join(target, "vite.config.ts"),
|
148
|
+
`import { defineConfig } from 'vite'
|
149
|
+
import react from '@vitejs/plugin-react'
|
150
|
+
export default defineConfig({ plugins: [react()] })
|
151
|
+
`
|
152
|
+
);
|
153
|
+
|
154
|
+
// .gitignore (Windows/Unix 공통)
|
155
|
+
write(
|
156
|
+
path.join(target, ".gitignore"),
|
157
|
+
`node_modules
|
158
|
+
dist
|
159
|
+
.cache
|
160
|
+
.vscode
|
161
|
+
.DS_Store
|
162
|
+
`
|
163
|
+
);
|
164
|
+
|
165
|
+
// index.html
|
166
|
+
write(
|
167
|
+
path.join(target, "index.html"),
|
168
|
+
`<!doctype html>
|
169
|
+
<html lang="ko">
|
170
|
+
<head>
|
171
|
+
<meta charset="UTF-8" />
|
172
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
173
|
+
<title>${appName}</title>
|
174
|
+
</head>
|
175
|
+
<body>
|
176
|
+
<div id="root"></div>
|
177
|
+
<script type="module" src="/src/main.tsx"></script>
|
178
|
+
</body>
|
179
|
+
</html>
|
180
|
+
`
|
181
|
+
);
|
182
|
+
|
183
|
+
// Tailwind
|
184
|
+
write(
|
185
|
+
path.join(target, "postcss.config.js"),
|
186
|
+
`export default { plugins: { tailwindcss: {}, autoprefixer: {} } }
|
187
|
+
`
|
188
|
+
);
|
189
|
+
write(
|
190
|
+
path.join(target, "tailwind.config.ts"),
|
191
|
+
`import type { Config } from 'tailwindcss'
|
192
|
+
export default {
|
193
|
+
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
194
|
+
theme: {
|
195
|
+
extend: {
|
196
|
+
colors: { ink:'#0b1220', muted:'#667085', bg:'#f8fafc', pri:'#2563eb' }
|
197
|
+
}
|
198
|
+
},
|
199
|
+
plugins: []
|
200
|
+
} satisfies Config
|
201
|
+
`
|
202
|
+
);
|
203
|
+
|
204
|
+
// src 엔트리
|
205
|
+
write(
|
206
|
+
path.join(target, "src", "index.css"),
|
207
|
+
`@tailwind base;
|
208
|
+
@tailwind components;
|
209
|
+
@tailwind utilities;
|
210
|
+
|
211
|
+
@layer base {
|
212
|
+
html,body,#root{height:100%}
|
213
|
+
body{font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif}
|
214
|
+
body{@apply bg-bg text-ink antialiased}
|
215
|
+
a{@apply text-pri}
|
216
|
+
}
|
217
|
+
@layer components {
|
218
|
+
.card{@apply bg-white border border-gray-200 rounded-xl p-5 shadow-sm}
|
219
|
+
.btn{@apply inline-flex items-center justify-center rounded-lg border px-3.5 py-2.5 text-sm font-medium}
|
220
|
+
.btn-primary{@apply bg-pri text-white border-pri hover:opacity-90}
|
221
|
+
.input{@apply w-full rounded-lg border border-gray-200 px-3.5 py-2.5 text-sm outline-none focus:ring-2 focus:ring-pri/20}
|
222
|
+
.header{@apply sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-gray-200}
|
223
|
+
.container{@apply max-w-5xl mx-auto px-4}
|
224
|
+
}
|
225
|
+
`
|
226
|
+
);
|
227
|
+
write(
|
228
|
+
path.join(target, "src", "main.tsx"),
|
229
|
+
`import React from 'react'
|
230
|
+
import ReactDOM from 'react-dom/client'
|
231
|
+
import { BrowserRouter } from 'react-router-dom'
|
232
|
+
import App from './App'
|
233
|
+
import './index.css'
|
234
|
+
import { AuthProvider } from './lib/auth'
|
235
|
+
|
236
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
237
|
+
<React.StrictMode>
|
238
|
+
<AuthProvider>
|
239
|
+
<BrowserRouter>
|
240
|
+
<App />
|
241
|
+
</BrowserRouter>
|
242
|
+
</AuthProvider>
|
243
|
+
</React.StrictMode>
|
244
|
+
)
|
245
|
+
`
|
246
|
+
);
|
247
|
+
write(
|
248
|
+
path.join(target, "src", "App.tsx"),
|
249
|
+
`import Header from './components/Header'
|
250
|
+
import RoutesView from './routes/index'
|
251
|
+
export default function App(){
|
252
|
+
return <>
|
253
|
+
<Header />
|
254
|
+
<main className="container py-6">
|
255
|
+
<RoutesView />
|
256
|
+
</main>
|
257
|
+
</>
|
258
|
+
}
|
259
|
+
`
|
260
|
+
);
|
261
|
+
|
262
|
+
// ─────────────────────────────────────────────
|
263
|
+
// 2) 1회 설치 (cross-spawn으로 PM별 자동 처리)
|
264
|
+
// ─────────────────────────────────────────────
|
265
|
+
console.log(yellow(`\n▶ 의존성 설치 (${pm} install)…`));
|
266
|
+
run(pm, ["install"], { cwd: target });
|
267
|
+
|
268
|
+
// ─────────────────────────────────────────────
|
269
|
+
// 3) 인증/라우팅/axios/Tailwind 추가 파일 (파일만 생성, 설치 없음)
|
270
|
+
// ─────────────────────────────────────────────
|
271
|
+
console.log(yellow("\n▶ 인증/라우팅/Tailwind/axios 스캐폴딩 적용…"));
|
272
|
+
await applyScaffold({ root: target });
|
273
|
+
|
274
|
+
console.log(green("\n✅ 완료!\n"));
|
275
|
+
console.log(`${bold("다음 명령을 실행하세요:")}
|
276
|
+
cd ${appName}
|
277
|
+
${pm} run dev
|
278
|
+
`);
|
279
|
+
})();
|
package/lib/apply.js
ADDED
@@ -0,0 +1,314 @@
|
|
1
|
+
import path from "path";
|
2
|
+
import fs from "fs";
|
3
|
+
|
4
|
+
function ensureDir(d) {
|
5
|
+
fs.mkdirSync(d, { recursive: true });
|
6
|
+
}
|
7
|
+
function write(p, content) {
|
8
|
+
ensureDir(path.dirname(p));
|
9
|
+
fs.writeFileSync(p, content);
|
10
|
+
}
|
11
|
+
|
12
|
+
export async function applyScaffold({ root /*, template*/ }) {
|
13
|
+
// 1) tsconfig 별칭 설정 제거 (아무 것도 안 함)
|
14
|
+
|
15
|
+
// 2) 소스 파일 생성
|
16
|
+
const files = getFiles();
|
17
|
+
|
18
|
+
write(path.join(root, "src", "routes", "index.tsx"), files.routesIndex);
|
19
|
+
write(path.join(root, "src", "components", "Header.tsx"), files.header);
|
20
|
+
write(path.join(root, "src", "components", "ProtectedRoute.tsx"), files.protectedRoute);
|
21
|
+
write(path.join(root, "src", "pages", "Home.tsx"), files.home);
|
22
|
+
write(path.join(root, "src", "pages", "Login.tsx"), files.login);
|
23
|
+
write(path.join(root, "src", "pages", "Register.tsx"), files.register);
|
24
|
+
write(path.join(root, "src", "pages", "Dashboard.tsx"), files.dashboard);
|
25
|
+
write(path.join(root, "src", "lib", "storage.ts"), files.storage);
|
26
|
+
write(path.join(root, "src", "lib", "auth.tsx"), files.authCtx);
|
27
|
+
write(path.join(root, "src", "api", "client.ts"), files.axiosClient);
|
28
|
+
write(path.join(root, "src", "api", "auth.ts"), files.axiosAuthApi);
|
29
|
+
|
30
|
+
// 3) 끝 (App.tsx / main.tsx는 이미 cli에서 생성)
|
31
|
+
}
|
32
|
+
|
33
|
+
function getFiles() {
|
34
|
+
return {
|
35
|
+
routesIndex: `import { Routes, Route } from 'react-router-dom'
|
36
|
+
import Home from "../pages/Home"
|
37
|
+
import Login from "../pages/Login"
|
38
|
+
import Register from "../pages/Register"
|
39
|
+
import Dashboard from "../pages/Dashboard"
|
40
|
+
import ProtectedRoute from "../components/ProtectedRoute"
|
41
|
+
|
42
|
+
export default function RoutesView(){
|
43
|
+
return (
|
44
|
+
<Routes>
|
45
|
+
<Route path="/" element={<Home/>} />
|
46
|
+
<Route path="/login" element={<Login/>} />
|
47
|
+
<Route path="/register" element={<Register/>} />
|
48
|
+
<Route path="/dashboard" element={
|
49
|
+
<ProtectedRoute><Dashboard/></ProtectedRoute>
|
50
|
+
} />
|
51
|
+
<Route path="*" element={<Home/>} />
|
52
|
+
</Routes>
|
53
|
+
)
|
54
|
+
}
|
55
|
+
`,
|
56
|
+
header: `import { Link, useNavigate } from 'react-router-dom'
|
57
|
+
import { useAuth } from "../lib/auth"
|
58
|
+
|
59
|
+
export default function Header(){
|
60
|
+
const { user, logout } = useAuth()
|
61
|
+
const nav = useNavigate()
|
62
|
+
return (
|
63
|
+
<header className="header">
|
64
|
+
<div className="container flex h-14 items-center gap-4">
|
65
|
+
<Link to="/" className="font-semibold">MyApp</Link>
|
66
|
+
<nav className="hidden md:flex items-center gap-4 text-sm">
|
67
|
+
<Link to="/">Home</Link>
|
68
|
+
{user && <Link to="/dashboard">Dashboard</Link>}
|
69
|
+
</nav>
|
70
|
+
<div className="ml-auto flex items-center gap-2">
|
71
|
+
{user ? (
|
72
|
+
<>
|
73
|
+
<span className="text-sm text-muted">{user.name}님</span>
|
74
|
+
<button className="btn" onClick={()=>{logout(); nav('/')}}>Logout</button>
|
75
|
+
</>
|
76
|
+
) : (
|
77
|
+
<>
|
78
|
+
<Link className="btn" to="/login">Login</Link>
|
79
|
+
<Link className="btn btn-primary" to="/register">Sign up</Link>
|
80
|
+
</>
|
81
|
+
)}
|
82
|
+
</div>
|
83
|
+
</div>
|
84
|
+
</header>
|
85
|
+
)
|
86
|
+
}
|
87
|
+
`,
|
88
|
+
protectedRoute: `import { ReactNode } from 'react'
|
89
|
+
import { Navigate, useLocation } from 'react-router-dom'
|
90
|
+
import { useAuth } from "../lib/auth"
|
91
|
+
|
92
|
+
export default function ProtectedRoute({children}:{children:ReactNode}){
|
93
|
+
const { user } = useAuth()
|
94
|
+
const loc = useLocation()
|
95
|
+
if(!user) return <Navigate to="/login" replace state={{ from: loc }} />
|
96
|
+
return <>{children}</>
|
97
|
+
}
|
98
|
+
`,
|
99
|
+
home: `export default function Home(){
|
100
|
+
return <section className="card">
|
101
|
+
<h2 className="text-xl font-semibold">홈</h2>
|
102
|
+
<p className="mt-2 text-muted">Tailwind + axios + 로그인/회원가입/보호 라우트 기본 제공</p>
|
103
|
+
</section>
|
104
|
+
}
|
105
|
+
`,
|
106
|
+
login: `import { FormEvent, useState } from 'react'
|
107
|
+
import { useAuth } from '../lib/auth'
|
108
|
+
import { useLocation, useNavigate, Link } from 'react-router-dom'
|
109
|
+
|
110
|
+
export default function Login(){
|
111
|
+
const { login, loading, error } = useAuth()
|
112
|
+
const [email,setEmail] = useState('')
|
113
|
+
const [password,setPassword] = useState('')
|
114
|
+
const nav = useNavigate()
|
115
|
+
const loc = useLocation() as any
|
116
|
+
const from = loc.state?.from?.pathname || '/dashboard'
|
117
|
+
|
118
|
+
async function onSubmit(e:FormEvent){
|
119
|
+
e.preventDefault()
|
120
|
+
await login(email, password)
|
121
|
+
nav(from, { replace:true })
|
122
|
+
}
|
123
|
+
|
124
|
+
return <div className="mx-auto max-w-md card">
|
125
|
+
<h2 className="text-xl font-semibold">로그인</h2>
|
126
|
+
{error && <p className="mt-2 text-red-600 text-sm">{error}</p>}
|
127
|
+
<form onSubmit={onSubmit} className="mt-4 space-y-3">
|
128
|
+
<div>
|
129
|
+
<label className="text-sm">이메일</label>
|
130
|
+
<input className="input mt-1" value={email} onChange={e=>setEmail(e.target.value)} />
|
131
|
+
</div>
|
132
|
+
<div>
|
133
|
+
<label className="text-sm">비밀번호</label>
|
134
|
+
<input className="input mt-1" type="password" value={password} onChange={e=>setPassword(e.target.value)} />
|
135
|
+
</div>
|
136
|
+
<div className="flex items-center justify-between pt-2">
|
137
|
+
<button className="btn btn-primary" disabled={loading}>{loading?'로그인 중…':'로그인'}</button>
|
138
|
+
<Link to="/register" className="text-sm">회원가입</Link>
|
139
|
+
</div>
|
140
|
+
</form>
|
141
|
+
</div>
|
142
|
+
}
|
143
|
+
`,
|
144
|
+
register: `import { FormEvent, useState } from 'react'
|
145
|
+
import { useAuth } from '../lib/auth'
|
146
|
+
import { useNavigate, Link } from 'react-router-dom'
|
147
|
+
|
148
|
+
export default function Register(){
|
149
|
+
const { register, loading, error } = useAuth()
|
150
|
+
const [name,setName] = useState('')
|
151
|
+
const [email,setEmail] = useState('')
|
152
|
+
const [password,setPassword] = useState('')
|
153
|
+
const nav = useNavigate()
|
154
|
+
|
155
|
+
async function onSubmit(e:FormEvent){
|
156
|
+
e.preventDefault()
|
157
|
+
await register(email, name, password)
|
158
|
+
nav('/dashboard', { replace:true })
|
159
|
+
}
|
160
|
+
|
161
|
+
return <div className="mx-auto max-w-md card">
|
162
|
+
<h2 className="text-xl font-semibold">회원가입</h2>
|
163
|
+
{error && <p className="mt-2 text-red-600 text-sm">{error}</p>}
|
164
|
+
<form onSubmit={onSubmit} className="mt-4 space-y-3">
|
165
|
+
<div>
|
166
|
+
<label className="text-sm">이름</label>
|
167
|
+
<input className="input mt-1" value={name} onChange={e=>setName(e.target.value)} />
|
168
|
+
</div>
|
169
|
+
<div>
|
170
|
+
<label className="text-sm">이메일</label>
|
171
|
+
<input className="input mt-1" value={email} onChange={e=>setEmail(e.target.value)} />
|
172
|
+
</div>
|
173
|
+
<div>
|
174
|
+
<label className="text-sm">비밀번호</label>
|
175
|
+
<input className="input mt-1" type="password" value={password} onChange={e=>setPassword(e.target.value)} />
|
176
|
+
</div>
|
177
|
+
<div className="flex items-center justify-between pt-2">
|
178
|
+
<button className="btn btn-primary" disabled={loading}>{loading?'가입 중…':'가입하기'}</button>
|
179
|
+
<Link to="/login" className="text-sm">로그인</Link>
|
180
|
+
</div>
|
181
|
+
</form>
|
182
|
+
</div>
|
183
|
+
}
|
184
|
+
`,
|
185
|
+
dashboard: `import { useAuth } from '../lib/auth'
|
186
|
+
export default function Dashboard(){
|
187
|
+
const { user } = useAuth()
|
188
|
+
return <section className="card">
|
189
|
+
<h2 className="text-xl font-semibold">대시보드</h2>
|
190
|
+
<p className="mt-2">환영합니다, <strong>{user?.name}</strong> 님!</p>
|
191
|
+
</section>
|
192
|
+
}
|
193
|
+
`,
|
194
|
+
storage: `export function getJSON<T>(k:string, fallback:T):T{
|
195
|
+
try{ const raw = localStorage.getItem(k); return raw ? JSON.parse(raw) as T : fallback }
|
196
|
+
catch{ return fallback }
|
197
|
+
}
|
198
|
+
export function setJSON(k:string, v:unknown){ localStorage.setItem(k, JSON.stringify(v)) }
|
199
|
+
`,
|
200
|
+
authCtx: `// src/lib/auth.tsx
|
201
|
+
import React, { createContext, useContext, useMemo, useState } from 'react'
|
202
|
+
import { getJSON, setJSON } from './storage'
|
203
|
+
|
204
|
+
type Session = { id: string; email: string; name: string } | null
|
205
|
+
type User = { id: string; email: string; name: string; password: string }
|
206
|
+
|
207
|
+
type AuthCtx = {
|
208
|
+
user: Session
|
209
|
+
login: (email: string, password: string) => Promise<void>
|
210
|
+
register: (email: string, name: string, password: string) => Promise<void>
|
211
|
+
logout: () => void
|
212
|
+
loading: boolean
|
213
|
+
error: string | null
|
214
|
+
}
|
215
|
+
|
216
|
+
const AuthContext = createContext<AuthCtx | null>(null)
|
217
|
+
|
218
|
+
const SESSION_KEY = 'session_v1'
|
219
|
+
const USERS_KEY = 'users_v1'
|
220
|
+
|
221
|
+
// 로컬 유틸
|
222
|
+
function getUsers(): User[] {
|
223
|
+
return getJSON<User[]>(USERS_KEY, [])
|
224
|
+
}
|
225
|
+
function saveUsers(users: User[]) {
|
226
|
+
setJSON(USERS_KEY, users)
|
227
|
+
}
|
228
|
+
function uuid() {
|
229
|
+
// 브라우저 지원 시
|
230
|
+
// @ts-ignore
|
231
|
+
return (globalThis.crypto?.randomUUID?.() as string) || 'id_' + Date.now() + '_' + Math.random().toString(16).slice(2)
|
232
|
+
}
|
233
|
+
|
234
|
+
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
235
|
+
const [user, setUser] = useState<Session>(() => getJSON<Session>(SESSION_KEY, null))
|
236
|
+
const [loading, setLoading] = useState(false)
|
237
|
+
const [error, setError] = useState<string | null>(null)
|
238
|
+
|
239
|
+
async function login(email: string, password: string) {
|
240
|
+
setLoading(true); setError(null)
|
241
|
+
try {
|
242
|
+
const users = getUsers()
|
243
|
+
const u = users.find(x => x.email === email && x.password === password)
|
244
|
+
if (!u) throw new Error('이메일 또는 비밀번호가 올바르지 않습니다.')
|
245
|
+
const session: Session = { id: u.id, email: u.email, name: u.name }
|
246
|
+
setUser(session); setJSON(SESSION_KEY, session)
|
247
|
+
} catch (e: any) {
|
248
|
+
setError(e?.message ?? '로그인 실패'); throw e
|
249
|
+
} finally {
|
250
|
+
setLoading(false)
|
251
|
+
}
|
252
|
+
}
|
253
|
+
|
254
|
+
async function register(email: string, name: string, password: string) {
|
255
|
+
setLoading(true); setError(null)
|
256
|
+
try {
|
257
|
+
const users = getUsers()
|
258
|
+
if (users.some(u => u.email === email)) {
|
259
|
+
throw new Error('이미 가입된 이메일입니다.')
|
260
|
+
}
|
261
|
+
const u: User = { id: uuid(), email, name, password }
|
262
|
+
users.push(u); saveUsers(users)
|
263
|
+
const session: Session = { id: u.id, email: u.email, name: u.name }
|
264
|
+
setUser(session); setJSON(SESSION_KEY, session)
|
265
|
+
} catch (e: any) {
|
266
|
+
setError(e?.message ?? '회원가입 실패'); throw e
|
267
|
+
} finally {
|
268
|
+
setLoading(false)
|
269
|
+
}
|
270
|
+
}
|
271
|
+
|
272
|
+
function logout() {
|
273
|
+
setUser(null); setJSON(SESSION_KEY, null)
|
274
|
+
}
|
275
|
+
|
276
|
+
const value = useMemo(
|
277
|
+
() => ({ user, login, register, logout, loading, error }),
|
278
|
+
[user, loading, error]
|
279
|
+
)
|
280
|
+
|
281
|
+
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
282
|
+
}
|
283
|
+
|
284
|
+
export function useAuth() {
|
285
|
+
const ctx = useContext(AuthContext)
|
286
|
+
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
287
|
+
return ctx
|
288
|
+
}
|
289
|
+
|
290
|
+
`,
|
291
|
+
axiosClient: `import axios from 'axios'
|
292
|
+
export const api = axios.create({ baseURL: '/api', timeout: 8000 })
|
293
|
+
api.interceptors.request.use(cfg=>{
|
294
|
+
const raw = localStorage.getItem('session_v1')
|
295
|
+
if(raw){
|
296
|
+
const token = (JSON.parse(raw) as { id:string }).id
|
297
|
+
cfg.headers.Authorization = 'Bearer ' + token
|
298
|
+
}
|
299
|
+
return cfg
|
300
|
+
})
|
301
|
+
`,
|
302
|
+
axiosAuthApi: `import { api } from './client'
|
303
|
+
type Session = { id:string; email:string; name:string }
|
304
|
+
export async function register(email:string, name:string, password:string):Promise<Session>{
|
305
|
+
const { data } = await api.post('/register', { email, name, password })
|
306
|
+
return data
|
307
|
+
}
|
308
|
+
export async function login(email:string, password:string):Promise<Session>{
|
309
|
+
const { data } = await api.post('/login', { email, password })
|
310
|
+
return data
|
311
|
+
}
|
312
|
+
`,
|
313
|
+
};
|
314
|
+
}
|
package/lib/files.js
ADDED
@@ -0,0 +1,290 @@
|
|
1
|
+
export const files = {
|
2
|
+
postcss: `export default { plugins: { tailwindcss: {}, autoprefixer: {} } }`,
|
3
|
+
tailwind: `import type { Config } from 'tailwindcss'
|
4
|
+
export default {
|
5
|
+
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
6
|
+
theme: {
|
7
|
+
extend: {
|
8
|
+
colors:{ ink:'#0b1220', muted:'#667085', bg:'#f8fafc', pri:'#2563eb' }
|
9
|
+
}
|
10
|
+
},
|
11
|
+
plugins: []
|
12
|
+
} satisfies Config
|
13
|
+
`,
|
14
|
+
indexCss: `@tailwind base;
|
15
|
+
@tailwind components;
|
16
|
+
@tailwind utilities;
|
17
|
+
|
18
|
+
@layer base {
|
19
|
+
html,body,#root{height:100%}
|
20
|
+
body{@apply bg-bg text-ink antialiased}
|
21
|
+
a{@apply text-pri}
|
22
|
+
}
|
23
|
+
@layer components {
|
24
|
+
.card{@apply bg-white border border-gray-200 rounded-xl p-5 shadow-sm}
|
25
|
+
.btn{@apply inline-flex items-center justify-center rounded-lg border px-3.5 py-2.5 text-sm font-medium}
|
26
|
+
.btn-primary{@apply bg-pri text-white border-pri hover:opacity-90}
|
27
|
+
.input{@apply w-full rounded-lg border border-gray-200 px-3.5 py-2.5 text-sm outline-none focus:ring-2 focus:ring-pri/20}
|
28
|
+
.header{@apply sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-gray-200}
|
29
|
+
.container{@apply max-w-5xl mx-auto px-4}
|
30
|
+
}
|
31
|
+
`,
|
32
|
+
appTsx: `import Header from './components/Header'
|
33
|
+
import RoutesView from './routes'
|
34
|
+
export default function App(){
|
35
|
+
return <>
|
36
|
+
<Header />
|
37
|
+
<main className="container py-6">
|
38
|
+
<RoutesView />
|
39
|
+
</main>
|
40
|
+
</>
|
41
|
+
}
|
42
|
+
`,
|
43
|
+
routesIndex: `import { Routes, Route } from 'react-router-dom'
|
44
|
+
import Home from '@/pages/Home'
|
45
|
+
import Login from '@/pages/Login'
|
46
|
+
import Register from '@/pages/Register'
|
47
|
+
import Dashboard from '@/pages/Dashboard'
|
48
|
+
import ProtectedRoute from '@/components/ProtectedRoute'
|
49
|
+
|
50
|
+
export default function RoutesView(){
|
51
|
+
return (
|
52
|
+
<Routes>
|
53
|
+
<Route path="/" element={<Home/>} />
|
54
|
+
<Route path="/login" element={<Login/>} />
|
55
|
+
<Route path="/register" element={<Register/>} />
|
56
|
+
<Route path="/dashboard" element={
|
57
|
+
<ProtectedRoute><Dashboard/></ProtectedRoute>
|
58
|
+
} />
|
59
|
+
<Route path="*" element={<Home/>} />
|
60
|
+
</Routes>
|
61
|
+
)
|
62
|
+
}
|
63
|
+
`,
|
64
|
+
header: `import { Link, useNavigate } from 'react-router-dom'
|
65
|
+
import { useAuth } from '@/lib/auth'
|
66
|
+
|
67
|
+
export default function Header(){
|
68
|
+
const { user, logout } = useAuth()
|
69
|
+
const nav = useNavigate()
|
70
|
+
return (
|
71
|
+
<header className="header">
|
72
|
+
<div className="container flex h-14 items-center gap-4">
|
73
|
+
<Link to="/" className="font-semibold">MyApp</Link>
|
74
|
+
<nav className="hidden md:flex items-center gap-4 text-sm">
|
75
|
+
<Link to="/">Home</Link>
|
76
|
+
{user && <Link to="/dashboard">Dashboard</Link>}
|
77
|
+
</nav>
|
78
|
+
<div className="ml-auto flex items-center gap-2">
|
79
|
+
{user ? (
|
80
|
+
<>
|
81
|
+
<span className="text-sm text-muted">{user.name}님</span>
|
82
|
+
<button className="btn" onClick={()=>{logout(); nav('/')}}>Logout</button>
|
83
|
+
</>
|
84
|
+
) : (
|
85
|
+
<>
|
86
|
+
<Link className="btn" to="/login">Login</Link>
|
87
|
+
<Link className="btn btn-primary" to="/register">Sign up</Link>
|
88
|
+
</>
|
89
|
+
)}
|
90
|
+
</div>
|
91
|
+
</div>
|
92
|
+
</header>
|
93
|
+
)
|
94
|
+
}
|
95
|
+
`,
|
96
|
+
protectedRoute: `import { ReactNode } from 'react'
|
97
|
+
import { Navigate, useLocation } from 'react-router-dom'
|
98
|
+
import { useAuth } from '@/lib/auth'
|
99
|
+
|
100
|
+
export default function ProtectedRoute({children}:{children:ReactNode}){
|
101
|
+
const { user } = useAuth()
|
102
|
+
const loc = useLocation()
|
103
|
+
if(!user) return <Navigate to="/login" replace state={{ from: loc }} />
|
104
|
+
return <>{children}</>
|
105
|
+
}
|
106
|
+
`,
|
107
|
+
home: `export default function Home(){
|
108
|
+
return <section className="card">
|
109
|
+
<h2 className="text-xl font-semibold">홈</h2>
|
110
|
+
<p className="mt-2 text-muted">Tailwind + axios + 로그인/회원가입/보호 라우트 기본 제공</p>
|
111
|
+
</section>
|
112
|
+
}
|
113
|
+
`,
|
114
|
+
login: `import { FormEvent, useState } from 'react'
|
115
|
+
import { useAuth } from '@/lib/auth'
|
116
|
+
import { useLocation, useNavigate, Link } from 'react-router-dom'
|
117
|
+
|
118
|
+
export default function Login(){
|
119
|
+
const { login, loading, error } = useAuth()
|
120
|
+
const [email,setEmail] = useState('')
|
121
|
+
const [password,setPassword] = useState('')
|
122
|
+
const nav = useNavigate()
|
123
|
+
const loc = useLocation() as any
|
124
|
+
const from = loc.state?.from?.pathname || '/dashboard'
|
125
|
+
|
126
|
+
async function onSubmit(e:FormEvent){
|
127
|
+
e.preventDefault()
|
128
|
+
await login(email, password)
|
129
|
+
nav(from, { replace:true })
|
130
|
+
}
|
131
|
+
|
132
|
+
return <div className="mx-auto max-w-md card">
|
133
|
+
<h2 className="text-xl font-semibold">로그인</h2>
|
134
|
+
{error && <p className="mt-2 text-red-600 text-sm">{error}</p>}
|
135
|
+
<form onSubmit={onSubmit} className="mt-4 space-y-3">
|
136
|
+
<div>
|
137
|
+
<label className="text-sm">이메일</label>
|
138
|
+
<input className="input mt-1" value={email} onChange={e=>setEmail(e.target.value)} />
|
139
|
+
</div>
|
140
|
+
<div>
|
141
|
+
<label className="text-sm">비밀번호</label>
|
142
|
+
<input className="input mt-1" type="password" value={password} onChange={e=>setPassword(e.target.value)} />
|
143
|
+
</div>
|
144
|
+
<div className="flex items-center justify-between pt-2">
|
145
|
+
<button className="btn btn-primary" disabled={loading}>{loading?'로그인 중…':'로그인'}</button>
|
146
|
+
<Link to="/register" className="text-sm">회원가입</Link>
|
147
|
+
</div>
|
148
|
+
</form>
|
149
|
+
</div>
|
150
|
+
}
|
151
|
+
`,
|
152
|
+
register: `import { FormEvent, useState } from 'react'
|
153
|
+
import { useAuth } from '@/lib/auth'
|
154
|
+
import { useNavigate, Link } from 'react-router-dom'
|
155
|
+
|
156
|
+
export default function Register(){
|
157
|
+
const { register, loading, error } = useAuth()
|
158
|
+
const [name,setName] = useState('')
|
159
|
+
const [email,setEmail] = useState('')
|
160
|
+
const [password,setPassword] = useState('')
|
161
|
+
const nav = useNavigate()
|
162
|
+
|
163
|
+
async function onSubmit(e:FormEvent){
|
164
|
+
e.preventDefault()
|
165
|
+
await register(email, name, password)
|
166
|
+
nav('/dashboard', { replace:true })
|
167
|
+
}
|
168
|
+
|
169
|
+
return <div className="mx-auto max-w-md card">
|
170
|
+
<h2 className="text-xl font-semibold">회원가입</h2>
|
171
|
+
{error && <p className="mt-2 text-red-600 text-sm">{error}</p>}
|
172
|
+
<form onSubmit={onSubmit} className="mt-4 space-y-3">
|
173
|
+
<div>
|
174
|
+
<label className="text-sm">이름</label>
|
175
|
+
<input className="input mt-1" value={name} onChange={e=>setName(e.target.value)} />
|
176
|
+
</div>
|
177
|
+
<div>
|
178
|
+
<label className="text-sm">이메일</label>
|
179
|
+
<input className="input mt-1" value={email} onChange={e=>setEmail(e.target.value)} />
|
180
|
+
</div>
|
181
|
+
<div>
|
182
|
+
<label className="text-sm">비밀번호</label>
|
183
|
+
<input className="input mt-1" type="password" value={password} onChange={e=>setPassword(e.target.value)} />
|
184
|
+
</div>
|
185
|
+
<div className="flex items-center justify-between pt-2">
|
186
|
+
<button className="btn btn-primary" disabled={loading}>{loading?'가입 중…':'가입하기'}</button>
|
187
|
+
<Link to="/login" className="text-sm">로그인</Link>
|
188
|
+
</div>
|
189
|
+
</form>
|
190
|
+
</div>
|
191
|
+
}
|
192
|
+
`,
|
193
|
+
dashboard: `import { useAuth } from '@/lib/auth'
|
194
|
+
export default function Dashboard(){
|
195
|
+
const { user } = useAuth()
|
196
|
+
return <section className="card">
|
197
|
+
<h2 className="text-xl font-semibold">대시보드</h2>
|
198
|
+
<p className="mt-2">환영합니다, <strong>{user?.name}</strong> 님!</p>
|
199
|
+
</section>
|
200
|
+
}
|
201
|
+
`,
|
202
|
+
storage: `export function getJSON<T>(k:string, fallback:T):T{
|
203
|
+
try{ const raw = localStorage.getItem(k); return raw ? JSON.parse(raw) as T : fallback }
|
204
|
+
catch{ return fallback }
|
205
|
+
}
|
206
|
+
export function setJSON(k:string, v:unknown){ localStorage.setItem(k, JSON.stringify(v)) }
|
207
|
+
`,
|
208
|
+
authCtx: `import { createContext, useContext, useMemo, useState } from 'react'
|
209
|
+
import * as api from '@/api/auth'
|
210
|
+
import { getJSON, setJSON } from './storage'
|
211
|
+
|
212
|
+
type Session = { id:string; email:string; name:string } | null
|
213
|
+
type AuthCtx = {
|
214
|
+
user: Session
|
215
|
+
login:(email:string, password:string)=>Promise<void>
|
216
|
+
register:(email:string, name:string, password:string)=>Promise<void>
|
217
|
+
logout:()=>void
|
218
|
+
loading:boolean
|
219
|
+
error:string | null
|
220
|
+
}
|
221
|
+
const AuthContext = createContext<AuthCtx | null>(null)
|
222
|
+
const KEY = 'session_v1'
|
223
|
+
|
224
|
+
export function AuthProvider({children}:{children:React.ReactNode}){
|
225
|
+
const [user, setUser] = useState<Session>(()=>getJSON(KEY, null))
|
226
|
+
const [loading, setLoading] = useState(false)
|
227
|
+
const [error, setError] = useState<string|null>(null)
|
228
|
+
|
229
|
+
async function login(email:string, password:string){
|
230
|
+
setLoading(true); setError(null)
|
231
|
+
try{ const u = await api.login(email,password); setUser(u); setJSON(KEY,u) }
|
232
|
+
catch(e:any){ setError(e?.message ?? '로그인 실패'); throw e }
|
233
|
+
finally{ setLoading(false) }
|
234
|
+
}
|
235
|
+
async function register(email:string, name:string, password:string){
|
236
|
+
setLoading(true); setError(null)
|
237
|
+
try{ const u = await api.register(email,name,password); setUser(u); setJSON(KEY,u) }
|
238
|
+
catch(e:any){ setError(e?.message ?? '회원가입 실패'); throw e }
|
239
|
+
finally{ setLoading(false) }
|
240
|
+
}
|
241
|
+
function logout(){ setUser(null); setJSON(KEY,null) }
|
242
|
+
const value = useMemo(()=>({user,login,register,logout,loading,error}),[user,loading,error])
|
243
|
+
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
244
|
+
}
|
245
|
+
export function useAuth(){
|
246
|
+
const ctx = useContext(AuthContext)
|
247
|
+
if(!ctx) throw new Error('useAuth must be used within AuthProvider')
|
248
|
+
return ctx
|
249
|
+
}
|
250
|
+
`,
|
251
|
+
axiosClient: `import axios from 'axios'
|
252
|
+
export const api = axios.create({ baseURL: '/api', timeout: 8000 })
|
253
|
+
api.interceptors.request.use(cfg=>{
|
254
|
+
const raw = localStorage.getItem('session_v1')
|
255
|
+
if(raw){
|
256
|
+
const token = (JSON.parse(raw) as { id:string }).id
|
257
|
+
cfg.headers.Authorization = 'Bearer ' + token
|
258
|
+
}
|
259
|
+
return cfg
|
260
|
+
})
|
261
|
+
`,
|
262
|
+
axiosAuthApi: `import { api } from './client'
|
263
|
+
type Session = { id:string; email:string; name:string }
|
264
|
+
export async function register(email:string, name:string, password:string):Promise<Session>{
|
265
|
+
const { data } = await api.post('/register', { email, name, password })
|
266
|
+
return data
|
267
|
+
}
|
268
|
+
export async function login(email:string, password:string):Promise<Session>{
|
269
|
+
const { data } = await api.post('/login', { email, password })
|
270
|
+
return data
|
271
|
+
}
|
272
|
+
`,
|
273
|
+
mainTsx: `import React from 'react'
|
274
|
+
import ReactDOM from 'react-dom/client'
|
275
|
+
import { BrowserRouter } from 'react-router-dom'
|
276
|
+
import App from './App'
|
277
|
+
import './index.css'
|
278
|
+
import { AuthProvider } from './lib/auth'
|
279
|
+
|
280
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
281
|
+
<React.StrictMode>
|
282
|
+
<AuthProvider>
|
283
|
+
<BrowserRouter>
|
284
|
+
<App />
|
285
|
+
</BrowserRouter>
|
286
|
+
</AuthProvider>
|
287
|
+
</React.StrictMode>
|
288
|
+
)
|
289
|
+
`
|
290
|
+
}
|
package/lib/utils.js
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
import fs from "fs";
|
2
|
+
import path from "path";
|
3
|
+
|
4
|
+
export function write(p, content) {
|
5
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
6
|
+
fs.writeFileSync(p, content);
|
7
|
+
}
|
8
|
+
export function readJSON(p) {
|
9
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
10
|
+
}
|
11
|
+
export function writeJSON(p, obj) {
|
12
|
+
fs.writeFileSync(p, JSON.stringify(obj, null, 2) + "\n");
|
13
|
+
}
|
package/package.json
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
{
|
2
|
+
"name": "create-vite-react-boot",
|
3
|
+
"version": "1.0.9",
|
4
|
+
"description": "Create a Vite + React + TS app with Tailwind, axios, AuthContext, login/register, routing.",
|
5
|
+
"type": "module",
|
6
|
+
"bin": {
|
7
|
+
"create-vite-react-boot": "./bin/cli.js"
|
8
|
+
},
|
9
|
+
"files": [
|
10
|
+
"bin",
|
11
|
+
"lib",
|
12
|
+
"README.md"
|
13
|
+
],
|
14
|
+
"license": "MIT",
|
15
|
+
"engines": {
|
16
|
+
"node": ">=18.0.0"
|
17
|
+
},
|
18
|
+
"dependencies": {
|
19
|
+
"cross-spawn": "^7.0.3",
|
20
|
+
"kolorist": "^1.8.0",
|
21
|
+
"prompts": "^2.4.2"
|
22
|
+
}
|
23
|
+
}
|