create-mantiq 0.1.12 → 0.2.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-mantiq",
3
- "version": "0.1.12",
3
+ "version": "0.2.0",
4
4
  "description": "Scaffold a new MantiqJS application",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync } from 'node:fs'
3
3
  import { dirname, resolve } from 'node:path'
4
4
  import { randomBytes } from 'node:crypto'
5
5
  import { getTemplates } from './templates.ts'
6
+ import { Terminal } from './terminal.ts'
6
7
 
7
8
  // ── ANSI helpers ─────────────────────────────────────────────────────────────
8
9
  const R = '\x1b[0m'
@@ -10,7 +11,6 @@ const bold = (s: string) => `\x1b[1m${s}${R}`
10
11
  const dim = (s: string) => `\x1b[2m${s}${R}`
11
12
  const red = (s: string) => `\x1b[31m${s}${R}`
12
13
  const emerald = (s: string) => `\x1b[38;2;52;211;153m${s}${R}`
13
- const gray = (s: string) => `\x1b[90m${s}${R}`
14
14
 
15
15
  // ── Parse args ───────────────────────────────────────────────────────────────
16
16
  const rawArgs = process.argv.slice(2)
@@ -31,37 +31,34 @@ for (const arg of rawArgs) {
31
31
  }
32
32
  }
33
33
 
34
- const projectName = positional[0]
35
- const noGit = !!flags['no-git']
36
- const kit = flags['kit'] as string | undefined
37
34
  const validKits = ['react', 'vue', 'svelte'] as const
38
35
  type Kit = typeof validKits[number]
39
36
 
37
+ const projectName = positional[0]
38
+ const noGit = !!flags['no-git']
39
+ const isCI = !process.stdin.isTTY || !!flags['yes'] || !!flags['y']
40
+
40
41
  if (!projectName) {
41
42
  console.log(`
42
- ${emerald('mantiq')} ${dim('framework')}
43
+ ${emerald('·')}${bold('mantiq')} ${dim('framework')}
43
44
 
44
45
  ${bold('Usage:')}
45
46
  bun create mantiq ${emerald('<project-name>')} [options]
46
47
 
47
48
  ${bold('Options:')}
48
- --kit=${emerald('react|vue|svelte')} Add a frontend starter kit
49
+ --kit=${emerald('react|vue|svelte')} Frontend framework
50
+ --ui=${emerald('shadcn')} UI component library (React only)
49
51
  --no-git Skip git initialization
52
+ --yes Accept defaults (non-interactive)
50
53
 
51
54
  ${bold('Examples:')}
52
55
  bun create mantiq my-app
53
56
  bun create mantiq my-app --kit=react
54
- bun create mantiq my-app --kit=vue
55
- bun create mantiq my-app --kit=svelte
57
+ bun create mantiq my-app --kit=react --ui=shadcn
56
58
  `)
57
59
  process.exit(1)
58
60
  }
59
61
 
60
- if (kit && !validKits.includes(kit as Kit)) {
61
- console.error(`\n ${red('ERROR')} Invalid kit "${kit}". Valid options: ${validKits.join(', ')}\n`)
62
- process.exit(1)
63
- }
64
-
65
62
  const projectDir = resolve(process.cwd(), projectName)
66
63
 
67
64
  if (existsSync(projectDir)) {
@@ -69,12 +66,52 @@ if (existsSync(projectDir)) {
69
66
  process.exit(1)
70
67
  }
71
68
 
69
+ // ── Interactive prompts ──────────────────────────────────────────────────────
70
+ const term = new Terminal()
71
+
72
+ let kit: Kit | undefined = flags['kit'] as Kit | undefined
73
+ let ui: 'shadcn' | 'none' = (flags['ui'] as string) === 'shadcn' ? 'shadcn' : 'none'
74
+
75
+ if (!isCI && !kit) {
76
+ // Show branded header
77
+ term.header()
78
+
79
+ // Framework selection
80
+ const framework = await term.select('Which framework would you like to use?', [
81
+ { value: 'react', label: 'React', hint: 'Recommended' },
82
+ { value: 'vue', label: 'Vue' },
83
+ { value: 'svelte', label: 'Svelte' },
84
+ { value: 'none', label: 'None', hint: 'API only' },
85
+ ])
86
+
87
+ kit = framework === 'none' ? undefined : framework as Kit
88
+
89
+ // UI kit selection (React only)
90
+ if (kit === 'react' && !flags['ui']) {
91
+ const uiChoice = await term.select('Which UI kit?', [
92
+ { value: 'none', label: 'Plain Tailwind' },
93
+ { value: 'shadcn', label: 'shadcn/ui', hint: 'Radix + Tailwind' },
94
+ ])
95
+ ui = uiChoice as 'shadcn' | 'none'
96
+ }
97
+ } else {
98
+ // Validate flags
99
+ if (kit && !validKits.includes(kit as Kit)) {
100
+ console.error(`\n ${red('ERROR')} Invalid kit "${kit}". Valid options: ${validKits.join(', ')}\n`)
101
+ process.exit(1)
102
+ }
103
+ if (ui === 'shadcn' && kit !== 'react') {
104
+ console.error(`\n ${red('ERROR')} shadcn/ui is only available with --kit=react\n`)
105
+ process.exit(1)
106
+ }
107
+ term.header()
108
+ }
109
+
72
110
  // ── Generate ─────────────────────────────────────────────────────────────────
73
- const kitLabel = kit ? ` ${dim('with')} ${bold(kit)}` : ''
74
- console.log(`\n ${emerald('mantiq')} Creating ${bold(projectName)}${kitLabel}\n`)
111
+ term.step('Scaffolding project')
75
112
 
76
113
  const appKey = `base64:${randomBytes(32).toString('base64')}`
77
- const templates = getTemplates({ name: projectName, appKey, kit: kit as Kit | undefined })
114
+ const templates = getTemplates({ name: projectName, appKey, kit, ui })
78
115
 
79
116
  // Write all files
80
117
  const files = Object.keys(templates).sort()
@@ -82,88 +119,64 @@ for (const relativePath of files) {
82
119
  const fullPath = `${projectDir}/${relativePath}`
83
120
  mkdirSync(dirname(fullPath), { recursive: true })
84
121
  await Bun.write(fullPath, templates[relativePath]!)
85
-
86
- const display = relativePath.endsWith('.gitkeep') ? dim(relativePath) : relativePath
87
- console.log(` ${emerald('+')} ${display}`)
88
122
  }
123
+ console.log(` ${dim(`${files.length} files created`)}`)
89
124
 
90
125
  // ── Install dependencies ─────────────────────────────────────────────────────
91
- console.log(`\n ${bold('Installing dependencies...')}\n`)
126
+ const spin = term.spinner('Installing dependencies')
92
127
 
93
128
  const install = Bun.spawn(['bun', 'install'], {
94
129
  cwd: projectDir,
95
- stdout: 'inherit',
96
- stderr: 'inherit',
130
+ stdout: 'pipe',
131
+ stderr: 'pipe',
97
132
  })
98
133
  await install.exited
134
+ spin.stop('Dependencies installed')
99
135
 
100
136
  // ── Build frontend (if kit) ─────────────────────────────────────────────────
101
137
  if (kit) {
102
- console.log(`\n ${bold('Building frontend assets...')}\n`)
138
+ const buildSpin = term.spinner('Building frontend assets')
103
139
 
104
140
  const viteBuild = Bun.spawn(['npx', 'vite', 'build'], {
105
141
  cwd: projectDir,
106
- stdout: 'inherit',
107
- stderr: 'inherit',
142
+ stdout: 'pipe',
143
+ stderr: 'pipe',
108
144
  })
109
145
  await viteBuild.exited
110
146
 
111
147
  const ssrEntry = kit === 'react' ? 'src/ssr.tsx' : 'src/ssr.ts'
112
- console.log(`\n ${bold('Building SSR bundle...')}\n`)
113
-
114
148
  const ssrBuild = Bun.spawn(['npx', 'vite', 'build', '--ssr', ssrEntry, '--outDir', 'bootstrap/ssr'], {
115
149
  cwd: projectDir,
116
- stdout: 'inherit',
117
- stderr: 'inherit',
150
+ stdout: 'pipe',
151
+ stderr: 'pipe',
118
152
  })
119
153
  await ssrBuild.exited
154
+ buildSpin.stop('Frontend built')
120
155
  }
121
156
 
122
157
  // ── Git init ─────────────────────────────────────────────────────────────────
123
158
  if (!noGit) {
124
- const gitInit = Bun.spawn(['git', 'init'], {
125
- cwd: projectDir,
126
- stdout: 'pipe',
127
- stderr: 'pipe',
128
- })
129
- await gitInit.exited
130
-
131
- const gitAdd = Bun.spawn(['git', 'add', '-A'], {
132
- cwd: projectDir,
133
- stdout: 'pipe',
134
- stderr: 'pipe',
135
- })
136
- await gitAdd.exited
137
-
138
- const gitCommit = Bun.spawn(['git', 'commit', '-m', 'Initial commit — scaffolded with create-mantiq'], {
139
- cwd: projectDir,
140
- stdout: 'pipe',
141
- stderr: 'pipe',
142
- })
143
- await gitCommit.exited
144
-
145
- console.log(` ${dim('Initialized git repository.')}`)
159
+ const gitSpin = term.spinner('Initializing git')
160
+ const run = async (args: string[]) => {
161
+ const p = Bun.spawn(args, { cwd: projectDir, stdout: 'pipe', stderr: 'pipe' })
162
+ await p.exited
163
+ }
164
+ await run(['git', 'init'])
165
+ await run(['git', 'add', '-A'])
166
+ await run(['git', 'commit', '-m', 'Initial commit — scaffolded with create-mantiq'])
167
+ gitSpin.stop('Git initialized')
146
168
  }
147
169
 
148
170
  // ── Done ─────────────────────────────────────────────────────────────────────
149
- const frontendSteps = kit
150
- ? ` ${emerald('bun run')} dev:frontend ${dim('# start Vite dev server')}\n`
151
- : ''
152
-
153
- console.log(`
154
- ${emerald('mantiq')} ${bold(projectName)} ready.
155
-
156
- ${bold('Next steps:')}
157
-
158
- ${emerald('cd')} ${projectName}
159
- ${emerald('bun mantiq')} migrate ${dim('# run database migrations')}
160
- ${emerald('bun run')} dev ${dim('# start development server')}
161
- ${frontendSteps}
162
- ${bold('Useful commands:')}
163
-
164
- ${emerald('bun mantiq')} route:list ${dim('# list all registered routes')}
165
- ${emerald('bun mantiq')} make:model ${dim('# generate a new model')}
166
- ${emerald('bun mantiq')} about ${dim('# framework environment info')}
167
-
168
- ${dim('Dashboard http://localhost:3000/_heartbeat')}
169
- `)
171
+ const summaryLines = [
172
+ `${emerald('')} ${bold(projectName)} created`,
173
+ '',
174
+ `${dim('Framework')} ${kit ? bold(kit.charAt(0).toUpperCase() + kit.slice(1)) : dim('None (API only)')}`,
175
+ ...(kit === 'react' ? [`${dim('UI Kit')} ${ui === 'shadcn' ? bold('shadcn/ui') : dim('Plain Tailwind')}`] : []),
176
+ '',
177
+ `${emerald('cd')} ${projectName}`,
178
+ `${emerald('bun mantiq')} migrate`,
179
+ `${emerald('bun run')} dev`,
180
+ ]
181
+
182
+ term.box(summaryLines)
package/src/kits/react.ts CHANGED
@@ -274,6 +274,7 @@ interface DashboardProps {
274
274
  export default function Dashboard({ appName = '${ctx.name}', currentUser, users: initialUsers, navigate }: DashboardProps) {
275
275
  const [users, setUsers] = useState<User[]>(initialUsers ?? [])
276
276
  const [loading, setLoading] = useState(!initialUsers?.length)
277
+ const [sidebarOpen, setSidebarOpen] = useState(false)
277
278
  const [isDark, setIsDark] = useState(() =>
278
279
  typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : true
279
280
  )
@@ -302,27 +303,30 @@ export default function Dashboard({ appName = '${ctx.name}', currentUser, users:
302
303
 
303
304
  return (
304
305
  <div className="min-h-screen flex bg-gray-50 dark:bg-gray-950">
306
+ {/* Mobile overlay */}
307
+ {sidebarOpen && <div className="fixed inset-0 bg-black/50 z-30 lg:hidden" onClick={() => setSidebarOpen(false)} />}
308
+
305
309
  {/* Sidebar */}
306
- <aside className="w-60 fixed inset-y-0 left-0 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col z-30">
310
+ <aside className={\`fixed inset-y-0 left-0 w-60 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col z-40 transition-transform duration-200 lg:translate-x-0 \${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}\`}>
307
311
  <div className="h-14 flex items-center px-5 border-b border-gray-200 dark:border-gray-800">
308
312
  <span className="text-sm font-semibold text-gray-900 dark:text-white">{appName}</span>
309
313
  </div>
310
314
  <nav className="flex-1 px-3 py-3 space-y-0.5">
311
- <a href="/dashboard" className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm font-medium bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white">
315
+ <a href="/dashboard" onClick={() => setSidebarOpen(false)} className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm font-medium bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white">
312
316
  <svg className="w-4 h-4 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg>
313
317
  Dashboard
314
318
  </a>
315
- <a href="#users-section" onClick={(e) => { e.preventDefault(); document.getElementById('users-section')?.scrollIntoView({ behavior: 'smooth' }) }} className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
319
+ <a href="#users-section" onClick={(e) => { e.preventDefault(); setSidebarOpen(false); document.getElementById('users-section')?.scrollIntoView({ behavior: 'smooth' }) }} className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
316
320
  <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
317
321
  Users
318
322
  </a>
319
323
  </nav>
320
324
  <div className="px-3 py-3 mt-auto border-t border-gray-200 dark:border-gray-800 space-y-0.5">
321
- <a href="/_heartbeat" className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
325
+ <a href="/_heartbeat" onClick={() => setSidebarOpen(false)} className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
322
326
  <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064" /></svg>
323
327
  Heartbeat
324
328
  </a>
325
- <a href="/api/ping" className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
329
+ <a href="/api/ping" onClick={() => setSidebarOpen(false)} className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
326
330
  <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
327
331
  API Ping
328
332
  </a>
@@ -330,10 +334,15 @@ export default function Dashboard({ appName = '${ctx.name}', currentUser, users:
330
334
  </aside>
331
335
 
332
336
  {/* Main */}
333
- <div className="flex-1 ml-60">
337
+ <div className="flex-1 lg:ml-60">
334
338
  {/* Top bar */}
335
339
  <header className="h-14 border-b border-gray-200 dark:border-gray-800 bg-white/90 dark:bg-gray-950/90 backdrop-blur-md sticky top-0 z-20 flex items-center justify-between px-6">
336
- <h1 className="text-sm font-medium text-gray-900 dark:text-gray-200">Dashboard</h1>
340
+ <div className="flex items-center">
341
+ <button onClick={() => setSidebarOpen(!sidebarOpen)} className="p-1.5 rounded-lg text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800 lg:hidden mr-2">
342
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /></svg>
343
+ </button>
344
+ <h1 className="text-sm font-medium text-gray-900 dark:text-gray-200">Dashboard</h1>
345
+ </div>
337
346
  <div className="flex items-center gap-3">
338
347
  <button onClick={toggleTheme} className="p-1.5 rounded-lg text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors" title="Toggle theme">
339
348
  {isDark ? (
@@ -296,6 +296,7 @@ export function render(_url: string, data?: Record<string, any>) {
296
296
  let localUsers: any[] = users ?? []
297
297
  let loading = !users?.length
298
298
  let isDark = true
299
+ let sidebarOpen = false
299
300
 
300
301
  const navItems = [
301
302
  { label: 'Dashboard', icon: 'grid', href: '/dashboard', active: true },
@@ -333,8 +334,13 @@ export function render(_url: string, data?: Record<string, any>) {
333
334
  </script>
334
335
 
335
336
  <div class="min-h-screen flex bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100 transition-colors">
337
+ <!-- Mobile overlay -->
338
+ {#if sidebarOpen}
339
+ <div class="fixed inset-0 bg-black/50 z-30 lg:hidden" on:click={() => sidebarOpen = false}></div>
340
+ {/if}
341
+
336
342
  <!-- Sidebar -->
337
- <aside class="w-60 flex-shrink-0 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col fixed inset-y-0 left-0 z-30">
343
+ <aside class="fixed inset-y-0 left-0 w-60 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col z-40 transition-transform duration-200 lg:translate-x-0 {sidebarOpen ? 'translate-x-0' : '-translate-x-full'}">
338
344
  <!-- App name -->
339
345
  <div class="h-14 flex items-center px-5 border-b border-gray-200 dark:border-gray-800">
340
346
  <div class="flex items-center gap-2.5">
@@ -349,6 +355,7 @@ export function render(_url: string, data?: Record<string, any>) {
349
355
  <nav class="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
350
356
  {#each navItems as item}
351
357
  <a href={item.href}
358
+ on:click={() => sidebarOpen = false}
352
359
  class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors
353
360
  {item.active
354
361
  ? 'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
@@ -365,6 +372,7 @@ export function render(_url: string, data?: Record<string, any>) {
365
372
  <div class="px-3 py-4 border-t border-gray-200 dark:border-gray-800 space-y-1">
366
373
  {#each bottomLinks as link}
367
374
  <a href={link.href}
375
+ on:click={() => sidebarOpen = false}
368
376
  class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
369
377
  {#if link.icon === 'heart'}
370
378
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" /></svg>
@@ -378,10 +386,15 @@ export function render(_url: string, data?: Record<string, any>) {
378
386
  </aside>
379
387
 
380
388
  <!-- Main content -->
381
- <div class="flex-1 ml-60 flex flex-col min-h-screen">
389
+ <div class="flex-1 lg:ml-60 flex flex-col min-h-screen">
382
390
  <!-- Header -->
383
391
  <header class="h-14 flex items-center justify-between px-6 border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-950/90 backdrop-blur-md sticky top-0 z-20">
384
- <h1 class="text-sm font-semibold text-gray-900 dark:text-white">Dashboard</h1>
392
+ <div class="flex items-center">
393
+ <button on:click={() => sidebarOpen = !sidebarOpen} class="p-1.5 rounded-lg text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800 lg:hidden mr-2">
394
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /></svg>
395
+ </button>
396
+ <h1 class="text-sm font-semibold text-gray-900 dark:text-white">Dashboard</h1>
397
+ </div>
385
398
  <div class="flex items-center gap-3">
386
399
  <button on:click={toggleTheme} class="p-2 rounded-lg text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors" aria-label="Toggle theme">
387
400
  {#if isDark}
package/src/kits/vue.ts CHANGED
@@ -345,6 +345,7 @@ const appName = props.appName ?? '${ctx.name}'
345
345
  const users = ref(props.users ?? [])
346
346
  const loading = ref(!props.users?.length)
347
347
  const isDark = ref(true)
348
+ const sidebarOpen = ref(false)
348
349
  const nav = inject<(href: string) => void>('navigate', props.navigate)
349
350
 
350
351
  function toggleTheme() {
@@ -373,8 +374,14 @@ onMounted(() => {
373
374
 
374
375
  <template>
375
376
  <div class="min-h-screen flex bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100 transition-colors">
377
+ <!-- Mobile overlay -->
378
+ <div v-if="sidebarOpen" class="fixed inset-0 bg-black/50 z-30 lg:hidden" @click="sidebarOpen = false" />
379
+
376
380
  <!-- Sidebar -->
377
- <aside class="w-60 flex-shrink-0 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col fixed inset-y-0 left-0 z-30">
381
+ <aside
382
+ :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
383
+ class="fixed inset-y-0 left-0 w-60 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col z-40 transition-transform duration-200 lg:translate-x-0"
384
+ >
378
385
  <!-- App name -->
379
386
  <div class="h-14 flex items-center px-5 border-b border-gray-200 dark:border-gray-800">
380
387
  <span class="text-sm font-bold text-gray-900 dark:text-white">{{ appName }}</span>
@@ -384,6 +391,7 @@ onMounted(() => {
384
391
  <nav class="flex-1 px-3 py-4 space-y-1">
385
392
  <a
386
393
  href="/dashboard"
394
+ @click="sidebarOpen = false"
387
395
  class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium bg-emerald-50 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
388
396
  >
389
397
  <!-- Dashboard icon -->
@@ -398,6 +406,7 @@ onMounted(() => {
398
406
  <div class="px-3 py-4 border-t border-gray-200 dark:border-gray-800 space-y-1">
399
407
  <a
400
408
  href="/heartbeat"
409
+ @click="sidebarOpen = false"
401
410
  class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
402
411
  >
403
412
  <!-- Heart/pulse icon -->
@@ -408,6 +417,7 @@ onMounted(() => {
408
417
  </a>
409
418
  <a
410
419
  href="/api/ping"
420
+ @click="sidebarOpen = false"
411
421
  class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
412
422
  >
413
423
  <!-- Signal/wifi icon -->
@@ -420,10 +430,15 @@ onMounted(() => {
420
430
  </aside>
421
431
 
422
432
  <!-- Main area -->
423
- <div class="flex-1 ml-60 flex flex-col min-h-screen">
433
+ <div class="flex-1 lg:ml-60 flex flex-col min-h-screen">
424
434
  <!-- Header -->
425
435
  <header class="h-14 flex items-center justify-between px-6 border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-950/90 backdrop-blur-md sticky top-0 z-20">
426
- <h1 class="text-sm font-semibold text-gray-900 dark:text-white">Dashboard</h1>
436
+ <div class="flex items-center">
437
+ <button @click="sidebarOpen = !sidebarOpen" class="p-1.5 rounded-lg text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800 lg:hidden mr-2">
438
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /></svg>
439
+ </button>
440
+ <h1 class="text-sm font-semibold text-gray-900 dark:text-white">Dashboard</h1>
441
+ </div>
427
442
  <div class="flex items-center gap-3">
428
443
  <!-- Dark/Light toggle -->
429
444
  <button
package/src/templates.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { getReactTemplates } from './kits/react.ts'
2
2
  import { getVueTemplates } from './kits/vue.ts'
3
3
  import { getSvelteTemplates } from './kits/svelte.ts'
4
+ import { getShadcnTemplates } from './ui/shadcn.ts'
4
5
 
5
6
  export interface TemplateContext {
6
7
  name: string
7
8
  appKey: string
8
9
  kit?: 'react' | 'vue' | 'svelte'
10
+ ui?: 'shadcn' | 'none'
9
11
  }
10
12
 
11
13
  export function getTemplates(ctx: TemplateContext): Record<string, string> {
@@ -1202,4 +1204,17 @@ export default class UserSeeder extends Seeder {
1202
1204
  : getSvelteTemplates(ctx)
1203
1205
 
1204
1206
  Object.assign(templates, kitTemplates)
1207
+
1208
+ // ── Apply shadcn/ui overlay (React only) ──────────────────────────────
1209
+ if (ctx.ui === 'shadcn' && kit === 'react') {
1210
+ const shadcn = getShadcnTemplates(ctx)
1211
+
1212
+ // Merge shadcn deps into package.json
1213
+ const pkg = JSON.parse(templates['package.json']!)
1214
+ Object.assign(pkg.dependencies, shadcn.dependencies)
1215
+ templates['package.json'] = JSON.stringify(pkg, null, 2) + '\n'
1216
+
1217
+ // Merge shadcn files (overrides pages + adds components)
1218
+ Object.assign(templates, shadcn.files)
1219
+ }
1205
1220
  }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Interactive terminal UI — zero deps, raw ANSI + Bun stdin.
3
+ * Styled with emerald accent and box-drawing characters.
4
+ */
5
+
6
+ const ESC = '\x1b['
7
+ const R = '\x1b[0m'
8
+ const BOLD = '\x1b[1m'
9
+ const DIM = '\x1b[2m'
10
+ const EMERALD = '\x1b[38;2;52;211;153m'
11
+ const GRAY = '\x1b[90m'
12
+ const RED = '\x1b[31m'
13
+ const WHITE = '\x1b[37m'
14
+ const HIDE_CURSOR = `${ESC}?25l`
15
+ const SHOW_CURSOR = `${ESC}?25h`
16
+ const CLEAR_LINE = `${ESC}2K`
17
+ const UP = (n: number) => `${ESC}${n}A`
18
+
19
+ function write(s: string) { process.stdout.write(s) }
20
+
21
+ export interface SelectOption {
22
+ value: string
23
+ label: string
24
+ hint?: string
25
+ }
26
+
27
+ export class Terminal {
28
+ /** Show branded header box */
29
+ header(): void {
30
+ const w = 41
31
+ const pad = (s: string, len: number) => s + ' '.repeat(Math.max(0, len - stripAnsi(s).length))
32
+ write('\n')
33
+ write(` ${GRAY}┌${'─'.repeat(w)}┐${R}\n`)
34
+ write(` ${GRAY}│${R}${' '.repeat(w)}${GRAY}│${R}\n`)
35
+ write(` ${GRAY}│${R} ${EMERALD}·${R}${BOLD}mantiq${R}${' '.repeat(w - 10)}${GRAY}│${R}\n`)
36
+ write(` ${GRAY}│${R} ${DIM}create a new application${R}${' '.repeat(w - 28)}${GRAY}│${R}\n`)
37
+ write(` ${GRAY}│${R}${' '.repeat(w)}${GRAY}│${R}\n`)
38
+ write(` ${GRAY}└${'─'.repeat(w)}┘${R}\n`)
39
+ write('\n')
40
+ }
41
+
42
+ /** Arrow-key select prompt */
43
+ async select(label: string, options: SelectOption[]): Promise<string> {
44
+ let selected = 0
45
+ const render = () => {
46
+ let out = ` ${EMERALD}◆${R} ${BOLD}${label}${R}\n`
47
+ for (let i = 0; i < options.length; i++) {
48
+ const opt = options[i]!
49
+ const active = i === selected
50
+ const bullet = active ? `${EMERALD}●${R}` : `${GRAY}○${R}`
51
+ const text = active ? `${WHITE}${opt.label}${R}` : `${GRAY}${opt.label}${R}`
52
+ const hint = opt.hint ? ` ${DIM}${opt.hint}${R}` : ''
53
+ out += ` ${GRAY}│${R} ${bullet} ${text}${hint}\n`
54
+ }
55
+ out += ` ${GRAY}└${R}\n`
56
+ return out
57
+ }
58
+
59
+ // Initial render
60
+ const lines = options.length + 2
61
+ write(render())
62
+
63
+ // Raw mode for key input
64
+ if (process.stdin.isTTY) process.stdin.setRawMode(true)
65
+ write(HIDE_CURSOR)
66
+
67
+ return new Promise<string>((resolve) => {
68
+ const onData = (buf: Buffer) => {
69
+ const key = buf.toString()
70
+ if (key === '\x1b[A' || key === 'k') { // Up
71
+ selected = (selected - 1 + options.length) % options.length
72
+ } else if (key === '\x1b[B' || key === 'j') { // Down
73
+ selected = (selected + 1) % options.length
74
+ } else if (key === '\r' || key === '\n') { // Enter
75
+ process.stdin.removeListener('data', onData)
76
+ if (process.stdin.isTTY) process.stdin.setRawMode(false)
77
+ write(SHOW_CURSOR)
78
+ // Clear and rewrite as confirmed
79
+ write(UP(lines) + CLEAR_LINE)
80
+ for (let i = 0; i < lines; i++) write(`${CLEAR_LINE}\n`)
81
+ write(UP(lines))
82
+ write(` ${EMERALD}◇${R} ${label} ${EMERALD}${options[selected]!.label}${R}\n\n`)
83
+ resolve(options[selected]!.value)
84
+ return
85
+ } else if (key === '\x03') { // Ctrl+C
86
+ process.stdin.removeListener('data', onData)
87
+ if (process.stdin.isTTY) process.stdin.setRawMode(false)
88
+ write(SHOW_CURSOR + '\n')
89
+ process.exit(0)
90
+ }
91
+ // Re-render
92
+ write(UP(lines))
93
+ write(render())
94
+ }
95
+ process.stdin.on('data', onData)
96
+ process.stdin.resume()
97
+ })
98
+ }
99
+
100
+ /** Yes/No confirm prompt */
101
+ async confirm(label: string, defaultVal = false): Promise<boolean> {
102
+ const options: SelectOption[] = [
103
+ { value: 'yes', label: 'Yes' },
104
+ { value: 'no', label: 'No' },
105
+ ]
106
+ const result = await this.select(label, options)
107
+ return result === 'yes'
108
+ }
109
+
110
+ /** Progress spinner */
111
+ spinner(label: string): { stop: (text: string) => void } {
112
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
113
+ let i = 0
114
+ let running = true
115
+
116
+ const interval = setInterval(() => {
117
+ if (!running) return
118
+ write(`\r ${EMERALD}${frames[i % frames.length]}${R} ${label}`)
119
+ i++
120
+ }, 80)
121
+
122
+ return {
123
+ stop(text: string) {
124
+ running = false
125
+ clearInterval(interval)
126
+ write(`\r${CLEAR_LINE} ${EMERALD}✓${R} ${text}\n`)
127
+ },
128
+ }
129
+ }
130
+
131
+ /** Bordered summary box */
132
+ box(lines: string[]): void {
133
+ const stripped = lines.map(l => stripAnsi(l))
134
+ const maxLen = Math.max(...stripped.map(l => l.length), 30)
135
+ const w = maxLen + 4
136
+
137
+ write('\n')
138
+ write(` ${GRAY}┌${'─'.repeat(w)}┐${R}\n`)
139
+ for (const line of lines) {
140
+ const pad = w - stripAnsi(line).length - 2
141
+ write(` ${GRAY}│${R} ${line}${' '.repeat(Math.max(0, pad))} ${GRAY}│${R}\n`)
142
+ }
143
+ write(` ${GRAY}└${'─'.repeat(w)}┘${R}\n`)
144
+ write('\n')
145
+ }
146
+
147
+ /** Simple info line */
148
+ info(text: string): void {
149
+ write(` ${GRAY}│${R} ${text}\n`)
150
+ }
151
+
152
+ /** Step label */
153
+ step(text: string): void {
154
+ write(`\n ${EMERALD}▸${R} ${BOLD}${text}${R}\n\n`)
155
+ }
156
+ }
157
+
158
+ function stripAnsi(s: string): string {
159
+ return s.replace(/\x1b\[[0-9;]*m/g, '')
160
+ }