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 +1 -1
- package/src/index.ts +85 -72
- package/src/kits/react.ts +16 -7
- package/src/kits/svelte.ts +16 -3
- package/src/kits/vue.ts +18 -3
- package/src/templates.ts +15 -0
- package/src/terminal.ts +160 -0
- package/src/ui/shadcn.ts +1018 -0
package/package.json
CHANGED
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')}
|
|
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=
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
126
|
+
const spin = term.spinner('Installing dependencies')
|
|
92
127
|
|
|
93
128
|
const install = Bun.spawn(['bun', 'install'], {
|
|
94
129
|
cwd: projectDir,
|
|
95
|
-
stdout: '
|
|
96
|
-
stderr: '
|
|
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
|
-
|
|
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: '
|
|
107
|
-
stderr: '
|
|
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: '
|
|
117
|
-
stderr: '
|
|
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
|
|
125
|
-
|
|
126
|
-
stdout: 'pipe',
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
await
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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=
|
|
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
|
-
<
|
|
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 ? (
|
package/src/kits/svelte.ts
CHANGED
|
@@ -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="
|
|
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
|
-
<
|
|
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
|
|
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
|
-
<
|
|
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
|
}
|
package/src/terminal.ts
ADDED
|
@@ -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
|
+
}
|