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 +19 -0
- package/package.json +49 -0
- package/src/index.ts +179 -0
- package/src/kits/react.ts +368 -0
- package/src/kits/svelte.ts +337 -0
- package/src/kits/vue.ts +346 -0
- package/src/templates.ts +997 -0
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
|
+
}
|