create-mantiq 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,19 @@
1
+ # create-mantiq
2
+
3
+ Scaffold a new MantiqJS application with one command.
4
+
5
+ Part of [MantiqJS](https://github.com/abdullahkhan/mantiq) — a batteries-included TypeScript web framework for Bun.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ bun create mantiq my-app
11
+ ```
12
+
13
+ ## Documentation
14
+
15
+ See the [MantiqJS repository](https://github.com/abdullahkhan/mantiq) for full documentation.
16
+
17
+ ## License
18
+
19
+ MIT
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "create-mantiq",
3
+ "version": "0.0.1",
4
+ "description": "Scaffold a new MantiqJS application",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Abdullah Khan",
8
+ "homepage": "https://github.com/abdullahkhan/mantiq/tree/main/packages/create-mantiq",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/abdullahkhan/mantiq.git",
12
+ "directory": "packages/create-mantiq"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/abdullahkhan/mantiq/issues"
16
+ },
17
+ "keywords": [
18
+ "mantiq",
19
+ "mantiqjs",
20
+ "bun",
21
+ "typescript",
22
+ "framework",
23
+ "create-mantiq"
24
+ ],
25
+ "engines": {
26
+ "bun": ">=1.1.0"
27
+ },
28
+ "main": "./src/index.ts",
29
+ "exports": {
30
+ ".": "./src/index.ts"
31
+ },
32
+ "bin": {
33
+ "create-mantiq": "./src/index.ts"
34
+ },
35
+ "files": [
36
+ "src/",
37
+ "package.json",
38
+ "README.md"
39
+ ],
40
+ "scripts": {
41
+ "build": "bun build ./src/index.ts --outdir ./dist --target bun",
42
+ "typecheck": "tsc --noEmit",
43
+ "clean": "rm -rf dist"
44
+ },
45
+ "devDependencies": {
46
+ "bun-types": "latest",
47
+ "typescript": "^5.7.0"
48
+ }
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env bun
2
+ import { existsSync, mkdirSync } from 'node:fs'
3
+ import { dirname, resolve } from 'node:path'
4
+ import { randomBytes } from 'node:crypto'
5
+ import { getTemplates } from './templates.ts'
6
+
7
+ // ── ANSI helpers ─────────────────────────────────────────────────────────────
8
+ const bold = (s: string) => `\x1b[1m${s}\x1b[0m`
9
+ const green = (s: string) => `\x1b[32m${s}\x1b[0m`
10
+ const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`
11
+ const dim = (s: string) => `\x1b[2m${s}\x1b[0m`
12
+ const red = (s: string) => `\x1b[31m${s}\x1b[0m`
13
+
14
+ // ── Parse args ───────────────────────────────────────────────────────────────
15
+ const rawArgs = process.argv.slice(2)
16
+ const flags: Record<string, string | boolean> = {}
17
+ const positional: string[] = []
18
+
19
+ for (const arg of rawArgs) {
20
+ if (arg.startsWith('--')) {
21
+ const body = arg.slice(2)
22
+ const eqIdx = body.indexOf('=')
23
+ if (eqIdx !== -1) {
24
+ flags[body.slice(0, eqIdx)] = body.slice(eqIdx + 1)
25
+ } else {
26
+ flags[body] = true
27
+ }
28
+ } else if (!arg.startsWith('-')) {
29
+ positional.push(arg)
30
+ }
31
+ }
32
+
33
+ const projectName = positional[0]
34
+ const noGit = !!flags['no-git']
35
+ const kit = flags['kit'] as string | undefined
36
+ const validKits = ['react', 'vue', 'svelte'] as const
37
+ type Kit = typeof validKits[number]
38
+
39
+ if (!projectName) {
40
+ console.log(`
41
+ ${bold('create-mantiq')} — Scaffold a new MantiqJS application
42
+
43
+ ${bold('Usage:')}
44
+ bun create mantiq ${cyan('<project-name>')} [options]
45
+
46
+ ${bold('Options:')}
47
+ --kit=${cyan('react|vue|svelte')} Add a frontend starter kit
48
+ --no-git Skip git initialization
49
+
50
+ ${bold('Examples:')}
51
+ bun create mantiq my-app
52
+ bun create mantiq my-app --kit=react
53
+ bun create mantiq my-app --kit=vue
54
+ bun create mantiq my-app --kit=svelte
55
+ `)
56
+ process.exit(1)
57
+ }
58
+
59
+ if (kit && !validKits.includes(kit as Kit)) {
60
+ console.error(`\n ${red('ERROR')} Invalid kit "${kit}". Valid options: ${validKits.join(', ')}\n`)
61
+ process.exit(1)
62
+ }
63
+
64
+ const projectDir = resolve(process.cwd(), projectName)
65
+
66
+ if (existsSync(projectDir)) {
67
+ console.error(`\n ${red('ERROR')} Directory "${projectName}" already exists.\n`)
68
+ process.exit(1)
69
+ }
70
+
71
+ // ── Generate ─────────────────────────────────────────────────────────────────
72
+ const kitLabel = kit ? ` with ${bold(kit)} starter kit` : ''
73
+ console.log(`\n ${bold('Creating')} ${cyan(projectName)}${kitLabel}...\n`)
74
+
75
+ const appKey = `base64:${randomBytes(32).toString('base64')}`
76
+ const templates = getTemplates({ name: projectName, appKey, kit: kit as Kit | undefined })
77
+
78
+ // Write all files
79
+ const files = Object.keys(templates).sort()
80
+ for (const relativePath of files) {
81
+ const fullPath = `${projectDir}/${relativePath}`
82
+ mkdirSync(dirname(fullPath), { recursive: true })
83
+ await Bun.write(fullPath, templates[relativePath]!)
84
+
85
+ const display = relativePath.endsWith('.gitkeep') ? dim(relativePath) : green(relativePath)
86
+ console.log(` ${green('+')} ${display}`)
87
+ }
88
+
89
+ // ── Install dependencies ─────────────────────────────────────────────────────
90
+ console.log(`\n ${bold('Installing dependencies...')}\n`)
91
+
92
+ const install = Bun.spawn(['bun', 'install'], {
93
+ cwd: projectDir,
94
+ stdout: 'inherit',
95
+ stderr: 'inherit',
96
+ })
97
+ await install.exited
98
+
99
+ // ── Build frontend (if kit) ─────────────────────────────────────────────────
100
+ if (kit) {
101
+ console.log(`\n ${bold('Building frontend assets...')}\n`)
102
+
103
+ // Client build
104
+ const viteBuild = Bun.spawn(['npx', 'vite', 'build'], {
105
+ cwd: projectDir,
106
+ stdout: 'inherit',
107
+ stderr: 'inherit',
108
+ })
109
+ await viteBuild.exited
110
+
111
+ // SSR build
112
+ const ssrEntry = kit === 'react' ? 'src/ssr.tsx' : 'src/ssr.ts'
113
+ console.log(`\n ${bold('Building SSR bundle...')}\n`)
114
+
115
+ const ssrBuild = Bun.spawn(['npx', 'vite', 'build', '--ssr', ssrEntry, '--outDir', 'bootstrap/ssr'], {
116
+ cwd: projectDir,
117
+ stdout: 'inherit',
118
+ stderr: 'inherit',
119
+ })
120
+ await ssrBuild.exited
121
+ }
122
+
123
+ // ── Git init ─────────────────────────────────────────────────────────────────
124
+ if (!noGit) {
125
+ const gitInit = Bun.spawn(['git', 'init'], {
126
+ cwd: projectDir,
127
+ stdout: 'pipe',
128
+ stderr: 'pipe',
129
+ })
130
+ await gitInit.exited
131
+
132
+ const gitAdd = Bun.spawn(['git', 'add', '-A'], {
133
+ cwd: projectDir,
134
+ stdout: 'pipe',
135
+ stderr: 'pipe',
136
+ })
137
+ await gitAdd.exited
138
+
139
+ const gitCommit = Bun.spawn(['git', 'commit', '-m', 'Initial commit — scaffolded with create-mantiq'], {
140
+ cwd: projectDir,
141
+ stdout: 'pipe',
142
+ stderr: 'pipe',
143
+ })
144
+ await gitCommit.exited
145
+
146
+ console.log(` ${dim('Initialized git repository.')}`)
147
+ }
148
+
149
+ // ── Done ─────────────────────────────────────────────────────────────────────
150
+ const frontendSteps = kit
151
+ ? ` ${cyan('bun run')} dev:frontend ${dim('# start Vite dev server (in a second terminal)')}\n`
152
+ : ''
153
+
154
+ console.log(`
155
+ ${green('✓')} ${bold('Created')} ${cyan(projectName)} ${bold('successfully!')}
156
+
157
+ ${bold('Next steps:')}
158
+
159
+ ${cyan('cd')} ${projectName}
160
+ ${cyan('bun mantiq')} migrate ${dim('# run database migrations')}
161
+ ${cyan('bun run')} dev ${dim('# start development server')}
162
+ ${frontendSteps} ${cyan('bun mantiq')} tinker ${dim('# interactive REPL')}
163
+
164
+ ${bold('Included packages:')}
165
+
166
+ ${dim('core · database · auth · validation · filesystem · logging')}
167
+ ${dim('events · queue · realtime · heartbeat · helpers · cli')}
168
+
169
+ ${bold('Useful commands:')}
170
+
171
+ ${cyan('bun mantiq')} route:list ${dim('# list all registered routes')}
172
+ ${cyan('bun mantiq')} queue:work ${dim('# start processing queued jobs')}
173
+ ${cyan('bun mantiq')} make:model ${dim('# generate a new model')}
174
+ ${cyan('bun mantiq')} make:job ${dim('# generate a new job class')}
175
+
176
+ ${dim('Dashboard: http://localhost:3000/_heartbeat')}
177
+
178
+ ${dim('Happy building!')}
179
+ `)
@@ -0,0 +1,368 @@
1
+ import type { TemplateContext } from '../templates.ts'
2
+
3
+ export function getReactTemplates(ctx: TemplateContext): Record<string, string> {
4
+ return {
5
+ 'vite.config.ts': `import { defineConfig } from 'vite'
6
+ import react from '@vitejs/plugin-react'
7
+ import tailwindcss from '@tailwindcss/vite'
8
+
9
+ export default defineConfig({
10
+ plugins: [react(), tailwindcss()],
11
+ publicDir: false,
12
+ build: {
13
+ outDir: 'public/build',
14
+ manifest: true,
15
+ emptyOutDir: true,
16
+ rollupOptions: {
17
+ input: ['src/main.tsx', 'src/style.css'],
18
+ },
19
+ },
20
+ })
21
+ `,
22
+
23
+ 'src/style.css': `@import "tailwindcss";
24
+ `,
25
+
26
+ 'src/pages.ts': `import Login from './pages/Login.tsx'
27
+ import Register from './pages/Register.tsx'
28
+ import Dashboard from './pages/Dashboard.tsx'
29
+
30
+ export const pages: Record<string, React.ComponentType<any>> = {
31
+ Login,
32
+ Register,
33
+ Dashboard,
34
+ }
35
+ `,
36
+
37
+ 'src/lib/api.ts': `export async function api<T = any>(url: string, opts: RequestInit = {}): Promise<{ ok: boolean; status: number; data: T }> {
38
+ const res = await fetch(url, { ...opts, headers: { Accept: 'application/json', ...opts.headers } })
39
+ const data = res.headers.get('content-type')?.includes('json') ? await res.json() : null
40
+ return { ok: res.ok, status: res.status, data }
41
+ }
42
+
43
+ export function post(url: string, body: object) {
44
+ return api(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
45
+ }
46
+ `,
47
+
48
+ 'src/main.tsx': `import './style.css'
49
+ import { hydrateRoot, createRoot } from 'react-dom/client'
50
+ import { MantiqApp } from './App.tsx'
51
+ import { pages } from './pages.ts'
52
+
53
+ const root = document.getElementById('app')!
54
+ const app = <MantiqApp pages={pages} />
55
+
56
+ // Hydrate if SSR content exists, otherwise CSR mount
57
+ root.innerHTML.trim() ? hydrateRoot(root, app) : createRoot(root).render(app)
58
+ `,
59
+
60
+ 'src/ssr.tsx': `import { renderToString } from 'react-dom/server'
61
+ import { MantiqApp } from './App.tsx'
62
+ import { pages } from './pages.ts'
63
+
64
+ export function render(_url: string, data?: Record<string, any>) {
65
+ return { html: renderToString(<MantiqApp pages={pages} initialData={data} />) }
66
+ }
67
+ `,
68
+
69
+ 'src/App.tsx': `import { useState, useCallback, useEffect } from 'react'
70
+
71
+ interface MantiqAppProps {
72
+ pages: Record<string, React.ComponentType<any>>
73
+ initialData?: Record<string, any>
74
+ }
75
+
76
+ export function MantiqApp({ pages, initialData }: MantiqAppProps) {
77
+ const windowData = typeof window !== 'undefined' ? (window as any).__MANTIQ_DATA__ : {}
78
+ const initial = initialData ?? windowData
79
+ const [page, setPage] = useState<string>(initial._page ?? 'Login')
80
+ const [data, setData] = useState<Record<string, any>>(initial)
81
+
82
+ const navigate = useCallback(async (href: string) => {
83
+ const res = await fetch(href, {
84
+ headers: { 'X-Mantiq': 'true', Accept: 'application/json' },
85
+ })
86
+ const newData = await res.json()
87
+ setPage(newData._page)
88
+ setData(newData)
89
+ history.pushState(null, '', newData._url)
90
+ }, [])
91
+
92
+ useEffect(() => {
93
+ const handleClick = (e: MouseEvent) => {
94
+ const anchor = (e.target as HTMLElement).closest('a')
95
+ const href = anchor?.getAttribute('href')
96
+ if (!href?.startsWith('/') || anchor?.target || e.ctrlKey || e.metaKey) return
97
+ e.preventDefault()
98
+ navigate(href)
99
+ }
100
+ const handlePop = () => navigate(location.pathname)
101
+ document.addEventListener('click', handleClick)
102
+ window.addEventListener('popstate', handlePop)
103
+ return () => {
104
+ document.removeEventListener('click', handleClick)
105
+ window.removeEventListener('popstate', handlePop)
106
+ }
107
+ }, [navigate])
108
+
109
+ const Page = pages[page]
110
+ return Page ? <Page {...data} navigate={navigate} /> : null
111
+ }
112
+ `,
113
+
114
+ 'src/pages/Login.tsx': `import { useState } from 'react'
115
+ import { post } from '../lib/api.ts'
116
+
117
+ interface LoginProps {
118
+ appName?: string
119
+ navigate: (href: string) => void
120
+ [key: string]: any
121
+ }
122
+
123
+ export default function Login({ appName = '${ctx.name}', navigate }: LoginProps) {
124
+ const [email, setEmail] = useState('admin@example.com')
125
+ const [password, setPassword] = useState('password')
126
+ const [error, setError] = useState('')
127
+ const [loading, setLoading] = useState(false)
128
+
129
+ const handleSubmit = async (e: React.FormEvent) => {
130
+ e.preventDefault(); setError(''); setLoading(true)
131
+ const { ok, data } = await post('/login', { email, password })
132
+ if (ok) navigate('/dashboard')
133
+ else setError(data?.error ?? 'Login failed')
134
+ setLoading(false)
135
+ }
136
+
137
+ return (
138
+ <div className="min-h-screen bg-gray-950 flex">
139
+ <div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-indigo-950 via-gray-950 to-gray-950 items-center justify-center p-16 relative overflow-hidden">
140
+ <div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_40%,rgba(99,102,241,0.08),transparent_60%)]" />
141
+ <div className="relative space-y-6 max-w-md">
142
+ <div className="flex items-center gap-3">
143
+ <div className="w-12 h-12 rounded-xl bg-indigo-600/20 border border-indigo-500/30 flex items-center justify-center">
144
+ <svg className="w-6 h-6 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
145
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
146
+ </svg>
147
+ </div>
148
+ <span className="text-2xl font-bold text-white">{appName}</span>
149
+ </div>
150
+ <h2 className="text-4xl font-bold text-white leading-tight">Build something<br />amazing.</h2>
151
+ <p className="text-gray-400 text-lg leading-relaxed">
152
+ Session auth, encrypted cookies, CSRF protection — all wired up and ready to go.
153
+ </p>
154
+ </div>
155
+ </div>
156
+ <div className="flex-1 flex items-center justify-center p-8">
157
+ <div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md p-8 space-y-6">
158
+ <div>
159
+ <h1 className="text-xl font-bold text-white">Welcome back</h1>
160
+ <p className="text-sm text-gray-500 mt-1">Sign in to your account</p>
161
+ </div>
162
+ {error && <div className="bg-red-500/10 border border-red-500/30 text-red-400 rounded-lg px-3.5 py-2.5 text-sm">{error}</div>}
163
+ <form onSubmit={handleSubmit} className="space-y-4">
164
+ <div className="space-y-1">
165
+ <label className="block text-sm font-medium text-gray-400">Email</label>
166
+ <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required
167
+ className="w-full bg-gray-900 border border-gray-800 rounded-lg px-3.5 py-2.5 text-sm text-gray-100 placeholder-gray-600 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 transition-all" />
168
+ </div>
169
+ <div className="space-y-1">
170
+ <label className="block text-sm font-medium text-gray-400">Password</label>
171
+ <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required
172
+ className="w-full bg-gray-900 border border-gray-800 rounded-lg px-3.5 py-2.5 text-sm text-gray-100 placeholder-gray-600 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 transition-all" />
173
+ </div>
174
+ <button type="submit" disabled={loading}
175
+ className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold text-sm py-2.5 rounded-lg transition-colors">
176
+ Sign in
177
+ </button>
178
+ </form>
179
+ <p className="text-sm text-gray-500 text-center">
180
+ Don't have an account? <a href="/register" className="text-indigo-400 hover:text-indigo-300">Register</a>
181
+ </p>
182
+ </div>
183
+ </div>
184
+ </div>
185
+ )
186
+ }
187
+ `,
188
+
189
+ 'src/pages/Register.tsx': `import { useState } from 'react'
190
+ import { post } from '../lib/api.ts'
191
+
192
+ interface RegisterProps {
193
+ appName?: string
194
+ navigate: (href: string) => void
195
+ [key: string]: any
196
+ }
197
+
198
+ export default function Register({ appName = '${ctx.name}', navigate }: RegisterProps) {
199
+ const [name, setName] = useState('')
200
+ const [email, setEmail] = useState('')
201
+ const [password, setPassword] = useState('')
202
+ const [error, setError] = useState('')
203
+ const [loading, setLoading] = useState(false)
204
+
205
+ const handleSubmit = async (e: React.FormEvent) => {
206
+ e.preventDefault(); setError(''); setLoading(true)
207
+ const { ok, data } = await post('/register', { name, email, password })
208
+ if (ok) navigate('/dashboard')
209
+ else setError(data?.error?.message ?? data?.error ?? 'Registration failed')
210
+ setLoading(false)
211
+ }
212
+
213
+ return (
214
+ <div className="min-h-screen bg-gray-950 flex">
215
+ <div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-indigo-950 via-gray-950 to-gray-950 items-center justify-center p-16 relative overflow-hidden">
216
+ <div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_40%,rgba(99,102,241,0.08),transparent_60%)]" />
217
+ <div className="relative space-y-6 max-w-md">
218
+ <div className="flex items-center gap-3">
219
+ <div className="w-12 h-12 rounded-xl bg-indigo-600/20 border border-indigo-500/30 flex items-center justify-center">
220
+ <svg className="w-6 h-6 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
221
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
222
+ </svg>
223
+ </div>
224
+ <span className="text-2xl font-bold text-white">{appName}</span>
225
+ </div>
226
+ <h2 className="text-4xl font-bold text-white leading-tight">Build something<br />amazing.</h2>
227
+ <p className="text-gray-400 text-lg leading-relaxed">
228
+ Session auth, encrypted cookies, CSRF protection — all wired up and ready to go.
229
+ </p>
230
+ </div>
231
+ </div>
232
+ <div className="flex-1 flex items-center justify-center p-8">
233
+ <div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md p-8 space-y-6">
234
+ <div>
235
+ <h1 className="text-xl font-bold text-white">Create an account</h1>
236
+ <p className="text-sm text-gray-500 mt-1">Get started with {appName}</p>
237
+ </div>
238
+ {error && <div className="bg-red-500/10 border border-red-500/30 text-red-400 rounded-lg px-3.5 py-2.5 text-sm">{error}</div>}
239
+ <form onSubmit={handleSubmit} className="space-y-4">
240
+ <div className="space-y-1">
241
+ <label className="block text-sm font-medium text-gray-400">Name</label>
242
+ <input value={name} onChange={(e) => setName(e.target.value)} required
243
+ className="w-full bg-gray-900 border border-gray-800 rounded-lg px-3.5 py-2.5 text-sm text-gray-100 placeholder-gray-600 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 transition-all" />
244
+ </div>
245
+ <div className="space-y-1">
246
+ <label className="block text-sm font-medium text-gray-400">Email</label>
247
+ <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required
248
+ className="w-full bg-gray-900 border border-gray-800 rounded-lg px-3.5 py-2.5 text-sm text-gray-100 placeholder-gray-600 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 transition-all" />
249
+ </div>
250
+ <div className="space-y-1">
251
+ <label className="block text-sm font-medium text-gray-400">Password</label>
252
+ <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required
253
+ className="w-full bg-gray-900 border border-gray-800 rounded-lg px-3.5 py-2.5 text-sm text-gray-100 placeholder-gray-600 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 transition-all" />
254
+ </div>
255
+ <button type="submit" disabled={loading}
256
+ className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold text-sm py-2.5 rounded-lg transition-colors">
257
+ Create account
258
+ </button>
259
+ </form>
260
+ <p className="text-sm text-gray-500 text-center">
261
+ Already have an account? <a href="/login" className="text-indigo-400 hover:text-indigo-300">Sign in</a>
262
+ </p>
263
+ </div>
264
+ </div>
265
+ </div>
266
+ )
267
+ }
268
+ `,
269
+
270
+ 'src/pages/Dashboard.tsx': `import { useState, useEffect, useCallback } from 'react'
271
+ import { api, post } from '../lib/api.ts'
272
+
273
+ interface User { id: number; name: string; email: string; role: string }
274
+
275
+ interface DashboardProps {
276
+ appName?: string
277
+ currentUser?: User | null
278
+ users?: User[]
279
+ navigate: (href: string) => void
280
+ [key: string]: any
281
+ }
282
+
283
+ export default function Dashboard({ appName = '${ctx.name}', currentUser, users: initialUsers, navigate }: DashboardProps) {
284
+ const [users, setUsers] = useState<User[]>(initialUsers ?? [])
285
+ const [loading, setLoading] = useState(!initialUsers?.length)
286
+
287
+ const fetchUsers = useCallback(async () => {
288
+ setLoading(true)
289
+ const { ok, data } = await api('/api/users')
290
+ if (ok) setUsers(data.data ?? [])
291
+ setLoading(false)
292
+ }, [])
293
+
294
+ useEffect(() => {
295
+ if (!initialUsers?.length) fetchUsers()
296
+ }, [fetchUsers, initialUsers])
297
+
298
+ const handleLogout = async () => {
299
+ await post('/logout', {})
300
+ navigate('/login')
301
+ }
302
+
303
+ return (
304
+ <div className="min-h-screen bg-gray-950 text-gray-100">
305
+ <nav className="border-b border-gray-800/80 bg-gray-950/90 backdrop-blur-md sticky top-0 z-20">
306
+ <div className="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
307
+ <div className="flex items-center gap-2.5">
308
+ <div className="w-7 h-7 rounded-lg bg-indigo-600/20 border border-indigo-500/30 flex items-center justify-center">
309
+ <svg className="w-3.5 h-3.5 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
310
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
311
+ </svg>
312
+ </div>
313
+ <span className="text-sm font-bold text-white">{appName}</span>
314
+ </div>
315
+ <div className="flex items-center gap-3">
316
+ <span className="text-xs text-gray-400">{currentUser?.name}</span>
317
+ <button onClick={handleLogout}
318
+ className="text-xs text-gray-500 hover:text-white bg-gray-900 hover:bg-gray-800 border border-gray-800 rounded-lg px-3 py-1.5 transition-colors">
319
+ Logout
320
+ </button>
321
+ </div>
322
+ </div>
323
+ </nav>
324
+
325
+ <main className="max-w-5xl mx-auto px-6 py-8 space-y-6">
326
+ <div>
327
+ <h1 className="text-xl font-bold text-white">Dashboard</h1>
328
+ <p className="text-sm text-gray-500 mt-1">Welcome back, {currentUser?.name}.</p>
329
+ </div>
330
+
331
+ <div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
332
+ <div className="px-5 py-4 border-b border-gray-800 flex items-center justify-between">
333
+ <h2 className="text-sm font-bold text-gray-200">Users</h2>
334
+ <span className="text-xs text-gray-500">{loading ? 'Loading...' : \`\${users.length} total\`}</span>
335
+ </div>
336
+ <table className="w-full text-sm">
337
+ <thead>
338
+ <tr className="border-b border-gray-800 text-left text-xs text-gray-500 uppercase tracking-wider">
339
+ <th className="px-5 py-3 font-medium">Name</th>
340
+ <th className="px-5 py-3 font-medium">Email</th>
341
+ <th className="px-5 py-3 font-medium">Role</th>
342
+ </tr>
343
+ </thead>
344
+ <tbody className="divide-y divide-gray-800/60">
345
+ {users.map((u) => (
346
+ <tr key={u.id} className="hover:bg-gray-900/50 transition-colors">
347
+ <td className="px-5 py-3 text-gray-200">{u.name}</td>
348
+ <td className="px-5 py-3 text-gray-400">{u.email}</td>
349
+ <td className="px-5 py-3">
350
+ <span className={\`text-[10px] px-2 py-0.5 rounded-full font-medium \${
351
+ u.role === 'admin' ? 'bg-purple-500/15 text-purple-300 border border-purple-500/20' : 'bg-gray-800 text-gray-400 border border-gray-700'
352
+ }\`}>{u.role}</span>
353
+ </td>
354
+ </tr>
355
+ ))}
356
+ {users.length === 0 && !loading && (
357
+ <tr><td colSpan={3} className="px-5 py-8 text-center text-gray-600">No users found</td></tr>
358
+ )}
359
+ </tbody>
360
+ </table>
361
+ </div>
362
+ </main>
363
+ </div>
364
+ )
365
+ }
366
+ `,
367
+ }
368
+ }