create-mantiq 0.5.0 → 0.6.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/templates.ts +44 -9
- package/stubs/react/src/components/layout/nav-user.tsx.stub +1 -1
- package/stubs/react/src/components/layout/sidebar-data.ts.stub +1 -1
- package/stubs/svelte/src/lib/components/layout/sidebar-data.ts.stub +1 -1
- package/stubs/vue/src/components/layout/sidebar-data.ts.stub +1 -1
- package/src/kits/react.ts +0 -437
- package/src/kits/svelte.ts +0 -464
- package/src/kits/vue.ts +0 -532
- package/src/ui/shadcn.ts +0 -910
package/package.json
CHANGED
package/src/templates.ts
CHANGED
|
@@ -21,12 +21,12 @@ export function getTemplates(ctx: TemplateContext): Record<string, string> {
|
|
|
21
21
|
},
|
|
22
22
|
dependencies: {
|
|
23
23
|
'@mantiq/auth': '^0.1.2',
|
|
24
|
-
'@mantiq/cli': '^0.1.
|
|
25
|
-
'@mantiq/core': '^0.1.
|
|
26
|
-
'@mantiq/database': '^0.1.
|
|
24
|
+
'@mantiq/cli': '^0.1.6',
|
|
25
|
+
'@mantiq/core': '^0.1.4',
|
|
26
|
+
'@mantiq/database': '^0.1.4',
|
|
27
27
|
'@mantiq/events': '^0.1.2',
|
|
28
28
|
'@mantiq/filesystem': '^0.1.2',
|
|
29
|
-
'@mantiq/heartbeat': '^0.
|
|
29
|
+
'@mantiq/heartbeat': '^0.3.0',
|
|
30
30
|
'@mantiq/helpers': '^0.1.2',
|
|
31
31
|
'@mantiq/logging': '^0.1.2',
|
|
32
32
|
'@mantiq/queue': '^0.1.2',
|
|
@@ -34,6 +34,8 @@ export function getTemplates(ctx: TemplateContext): Record<string, string> {
|
|
|
34
34
|
'@mantiq/validation': '^0.1.2',
|
|
35
35
|
'@mantiq/mail': '^0.2.0',
|
|
36
36
|
'@mantiq/notify': '^0.1.0',
|
|
37
|
+
'@mantiq/search': '^0.1.0',
|
|
38
|
+
'@mantiq/health': '^0.1.0',
|
|
37
39
|
},
|
|
38
40
|
devDependencies: {
|
|
39
41
|
'bun-types': 'latest',
|
|
@@ -117,6 +119,7 @@ import { HeartbeatServiceProvider, HeartbeatMiddleware } from '@mantiq/heartbeat
|
|
|
117
119
|
import { RealtimeServiceProvider } from '@mantiq/realtime'
|
|
118
120
|
import { MailServiceProvider } from '@mantiq/mail'
|
|
119
121
|
import { NotificationServiceProvider } from '@mantiq/notify'
|
|
122
|
+
import { SearchServiceProvider } from '@mantiq/search'
|
|
120
123
|
import { DatabaseServiceProvider } from './app/Providers/DatabaseServiceProvider.ts'
|
|
121
124
|
|
|
122
125
|
// ── Load .env ─────────────────────────────────────────────────────────────────
|
|
@@ -150,6 +153,7 @@ await app.registerProviders([
|
|
|
150
153
|
RealtimeServiceProvider,
|
|
151
154
|
MailServiceProvider,
|
|
152
155
|
NotificationServiceProvider,
|
|
156
|
+
SearchServiceProvider,
|
|
153
157
|
])
|
|
154
158
|
await app.bootProviders()
|
|
155
159
|
|
|
@@ -456,6 +460,33 @@ export default {
|
|
|
456
460
|
'config/notify.ts': `export default {
|
|
457
461
|
channels: {},
|
|
458
462
|
}
|
|
463
|
+
`,
|
|
464
|
+
|
|
465
|
+
'config/search.ts': `export default {
|
|
466
|
+
default: 'collection',
|
|
467
|
+
prefix: '',
|
|
468
|
+
queue: false,
|
|
469
|
+
softDelete: false,
|
|
470
|
+
|
|
471
|
+
engines: {
|
|
472
|
+
collection: {
|
|
473
|
+
driver: 'collection' as const,
|
|
474
|
+
},
|
|
475
|
+
database: {
|
|
476
|
+
driver: 'database' as const,
|
|
477
|
+
},
|
|
478
|
+
// algolia: {
|
|
479
|
+
// driver: 'algolia' as const,
|
|
480
|
+
// applicationId: env('ALGOLIA_APP_ID', ''),
|
|
481
|
+
// apiKey: env('ALGOLIA_SECRET', ''),
|
|
482
|
+
// },
|
|
483
|
+
// meilisearch: {
|
|
484
|
+
// driver: 'meilisearch' as const,
|
|
485
|
+
// host: env('MEILISEARCH_HOST', 'http://127.0.0.1:7700'),
|
|
486
|
+
// apiKey: env('MEILISEARCH_KEY', ''),
|
|
487
|
+
// },
|
|
488
|
+
},
|
|
489
|
+
}
|
|
459
490
|
`,
|
|
460
491
|
|
|
461
492
|
'config/heartbeat.ts': `export default {
|
|
@@ -736,12 +767,12 @@ function applyKitOverrides(templates: Record<string, string>, ctx: TemplateConte
|
|
|
736
767
|
},
|
|
737
768
|
dependencies: {
|
|
738
769
|
'@mantiq/auth': '^0.1.2',
|
|
739
|
-
'@mantiq/cli': '^0.1.
|
|
740
|
-
'@mantiq/core': '^0.1.
|
|
741
|
-
'@mantiq/database': '^0.1.
|
|
770
|
+
'@mantiq/cli': '^0.1.6',
|
|
771
|
+
'@mantiq/core': '^0.1.4',
|
|
772
|
+
'@mantiq/database': '^0.1.4',
|
|
742
773
|
'@mantiq/events': '^0.1.2',
|
|
743
774
|
'@mantiq/filesystem': '^0.1.2',
|
|
744
|
-
'@mantiq/heartbeat': '^0.
|
|
775
|
+
'@mantiq/heartbeat': '^0.3.0',
|
|
745
776
|
'@mantiq/helpers': '^0.1.2',
|
|
746
777
|
'@mantiq/logging': '^0.1.2',
|
|
747
778
|
'@mantiq/queue': '^0.1.2',
|
|
@@ -749,7 +780,9 @@ function applyKitOverrides(templates: Record<string, string>, ctx: TemplateConte
|
|
|
749
780
|
'@mantiq/validation': '^0.1.2',
|
|
750
781
|
'@mantiq/mail': '^0.2.0',
|
|
751
782
|
'@mantiq/notify': '^0.1.0',
|
|
752
|
-
'@mantiq/
|
|
783
|
+
'@mantiq/search': '^0.1.0',
|
|
784
|
+
'@mantiq/health': '^0.1.0',
|
|
785
|
+
'@mantiq/vite': '^0.1.3',
|
|
753
786
|
...uiDeps,
|
|
754
787
|
},
|
|
755
788
|
devDependencies: {
|
|
@@ -826,6 +859,7 @@ import { HeartbeatServiceProvider, HeartbeatMiddleware } from '@mantiq/heartbeat
|
|
|
826
859
|
import { RealtimeServiceProvider } from '@mantiq/realtime'
|
|
827
860
|
import { MailServiceProvider } from '@mantiq/mail'
|
|
828
861
|
import { NotificationServiceProvider } from '@mantiq/notify'
|
|
862
|
+
import { SearchServiceProvider } from '@mantiq/search'
|
|
829
863
|
import { DatabaseServiceProvider } from './app/Providers/DatabaseServiceProvider.ts'
|
|
830
864
|
|
|
831
865
|
// ── Load .env ─────────────────────────────────────────────────────────────────
|
|
@@ -860,6 +894,7 @@ await app.registerProviders([
|
|
|
860
894
|
RealtimeServiceProvider,
|
|
861
895
|
MailServiceProvider,
|
|
862
896
|
NotificationServiceProvider,
|
|
897
|
+
SearchServiceProvider,
|
|
863
898
|
])
|
|
864
899
|
await app.bootProviders()
|
|
865
900
|
|
|
@@ -87,7 +87,7 @@ export function NavUser({ user, navigate, onLogout }: NavUserProps) {
|
|
|
87
87
|
</div>
|
|
88
88
|
</DropdownMenuLabel>
|
|
89
89
|
<DropdownMenuSeparator />
|
|
90
|
-
<DropdownMenuItem onClick={() => navigate('/account')}>
|
|
90
|
+
<DropdownMenuItem onClick={() => navigate('/account/profile')}>
|
|
91
91
|
<User className="mr-2 h-4 w-4" />
|
|
92
92
|
Account
|
|
93
93
|
</DropdownMenuItem>
|
package/src/kits/react.ts
DELETED
|
@@ -1,437 +0,0 @@
|
|
|
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
|
-
@custom-variant dark (&:where(.dark, .dark *));
|
|
25
|
-
|
|
26
|
-
@keyframes fadeUp {
|
|
27
|
-
from { opacity: 0; transform: translateY(8px); }
|
|
28
|
-
to { opacity: 1; transform: translateY(0); }
|
|
29
|
-
}
|
|
30
|
-
.animate-fade-up { animation: fadeUp 0.4s ease-out; }
|
|
31
|
-
`,
|
|
32
|
-
|
|
33
|
-
'src/pages.ts': `import Login from './pages/Login.tsx'
|
|
34
|
-
import Register from './pages/Register.tsx'
|
|
35
|
-
import Dashboard from './pages/Dashboard.tsx'
|
|
36
|
-
|
|
37
|
-
export const pages: Record<string, React.ComponentType<any>> = {
|
|
38
|
-
Login,
|
|
39
|
-
Register,
|
|
40
|
-
Dashboard,
|
|
41
|
-
}
|
|
42
|
-
`,
|
|
43
|
-
|
|
44
|
-
'src/lib/api.ts': `export async function api<T = any>(url: string, opts: RequestInit = {}): Promise<{ ok: boolean; status: number; data: T }> {
|
|
45
|
-
const res = await fetch(url, { ...opts, headers: { Accept: 'application/json', ...opts.headers } })
|
|
46
|
-
const data = res.headers.get('content-type')?.includes('json') ? await res.json() : null
|
|
47
|
-
return { ok: res.ok, status: res.status, data }
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function post(url: string, body: object) {
|
|
51
|
-
return api(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
|
52
|
-
}
|
|
53
|
-
`,
|
|
54
|
-
|
|
55
|
-
'src/main.tsx': `import './style.css'
|
|
56
|
-
import { hydrateRoot, createRoot } from 'react-dom/client'
|
|
57
|
-
import { MantiqApp } from './App.tsx'
|
|
58
|
-
import { pages } from './pages.ts'
|
|
59
|
-
|
|
60
|
-
const root = document.getElementById('app')!
|
|
61
|
-
const app = <MantiqApp pages={pages} />
|
|
62
|
-
|
|
63
|
-
// Hydrate if SSR content exists, otherwise CSR mount
|
|
64
|
-
root.innerHTML.trim() ? hydrateRoot(root, app) : createRoot(root).render(app)
|
|
65
|
-
`,
|
|
66
|
-
|
|
67
|
-
'src/ssr.tsx': `import { renderToString } from 'react-dom/server'
|
|
68
|
-
import { MantiqApp } from './App.tsx'
|
|
69
|
-
import { pages } from './pages.ts'
|
|
70
|
-
|
|
71
|
-
export function render(_url: string, data?: Record<string, any>) {
|
|
72
|
-
return { html: renderToString(<MantiqApp pages={pages} initialData={data} />) }
|
|
73
|
-
}
|
|
74
|
-
`,
|
|
75
|
-
|
|
76
|
-
'src/App.tsx': `import { useState, useCallback, useEffect } from 'react'
|
|
77
|
-
|
|
78
|
-
interface MantiqAppProps {
|
|
79
|
-
pages: Record<string, React.ComponentType<any>>
|
|
80
|
-
initialData?: Record<string, any>
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function initTheme() {
|
|
84
|
-
if (typeof window === 'undefined') return
|
|
85
|
-
const theme = localStorage.getItem('theme') ||
|
|
86
|
-
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
|
87
|
-
document.documentElement.classList.toggle('dark', theme === 'dark')
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
initTheme()
|
|
91
|
-
|
|
92
|
-
export function MantiqApp({ pages, initialData }: MantiqAppProps) {
|
|
93
|
-
const windowData = typeof window !== 'undefined' ? (window as any).__MANTIQ_DATA__ : {}
|
|
94
|
-
const initial = initialData ?? windowData
|
|
95
|
-
const [page, setPage] = useState<string>(initial._page ?? 'Login')
|
|
96
|
-
const [data, setData] = useState<Record<string, any>>(initial)
|
|
97
|
-
|
|
98
|
-
const navigate = useCallback(async (href: string) => {
|
|
99
|
-
const res = await fetch(href, {
|
|
100
|
-
headers: { 'X-Mantiq': 'true', Accept: 'application/json' },
|
|
101
|
-
})
|
|
102
|
-
const newData = await res.json()
|
|
103
|
-
setPage(newData._page)
|
|
104
|
-
setData(newData)
|
|
105
|
-
history.pushState(null, '', newData._url)
|
|
106
|
-
}, [])
|
|
107
|
-
|
|
108
|
-
useEffect(() => {
|
|
109
|
-
const handleClick = (e: MouseEvent) => {
|
|
110
|
-
const anchor = (e.target as HTMLElement).closest('a')
|
|
111
|
-
const href = anchor?.getAttribute('href')
|
|
112
|
-
if (!href?.startsWith('/') || anchor?.target || e.ctrlKey || e.metaKey) return
|
|
113
|
-
// Only intercept known SPA routes — let other links navigate normally
|
|
114
|
-
const spaRoutes = ['/login', '/register', '/dashboard']
|
|
115
|
-
if (!spaRoutes.some(r => href === r || href.startsWith(r + '?'))) return
|
|
116
|
-
e.preventDefault()
|
|
117
|
-
navigate(href)
|
|
118
|
-
}
|
|
119
|
-
const handlePop = () => navigate(location.pathname)
|
|
120
|
-
document.addEventListener('click', handleClick)
|
|
121
|
-
window.addEventListener('popstate', handlePop)
|
|
122
|
-
return () => {
|
|
123
|
-
document.removeEventListener('click', handleClick)
|
|
124
|
-
window.removeEventListener('popstate', handlePop)
|
|
125
|
-
}
|
|
126
|
-
}, [navigate])
|
|
127
|
-
|
|
128
|
-
const Page = pages[page]
|
|
129
|
-
return Page ? <Page {...data} navigate={navigate} /> : null
|
|
130
|
-
}
|
|
131
|
-
`,
|
|
132
|
-
|
|
133
|
-
'src/pages/Login.tsx': `import { useState } from 'react'
|
|
134
|
-
import { post } from '../lib/api.ts'
|
|
135
|
-
|
|
136
|
-
interface LoginProps {
|
|
137
|
-
appName?: string
|
|
138
|
-
navigate: (href: string) => void
|
|
139
|
-
[key: string]: any
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
export default function Login({ appName = '${ctx.name}', navigate }: LoginProps) {
|
|
143
|
-
const [email, setEmail] = useState('admin@example.com')
|
|
144
|
-
const [password, setPassword] = useState('password')
|
|
145
|
-
const [error, setError] = useState('')
|
|
146
|
-
const [loading, setLoading] = useState(false)
|
|
147
|
-
|
|
148
|
-
const handleSubmit = async (e: React.FormEvent) => {
|
|
149
|
-
e.preventDefault(); setError(''); setLoading(true)
|
|
150
|
-
const { ok, data } = await post('/login', { email, password })
|
|
151
|
-
if (ok) navigate('/dashboard')
|
|
152
|
-
else setError(data?.error ?? 'Login failed')
|
|
153
|
-
setLoading(false)
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return (
|
|
157
|
-
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center p-4">
|
|
158
|
-
<div className="animate-fade-up w-full max-w-sm">
|
|
159
|
-
<div className="text-center mb-8">
|
|
160
|
-
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{appName}</h2>
|
|
161
|
-
</div>
|
|
162
|
-
<div className="bg-white dark:bg-gray-900/50 rounded-xl border border-gray-200 dark:border-gray-800 p-8 space-y-6 shadow-sm">
|
|
163
|
-
<div>
|
|
164
|
-
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Welcome back</h1>
|
|
165
|
-
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Sign in to your account</p>
|
|
166
|
-
</div>
|
|
167
|
-
{error && <div className="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/30 text-red-600 dark:text-red-400 rounded-lg px-3.5 py-2.5 text-sm">{error}</div>}
|
|
168
|
-
<form onSubmit={handleSubmit} className="space-y-4">
|
|
169
|
-
<div className="space-y-1.5">
|
|
170
|
-
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label>
|
|
171
|
-
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required
|
|
172
|
-
className="w-full bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-lg px-3.5 py-2.5 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-600 focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/25 transition-all" />
|
|
173
|
-
</div>
|
|
174
|
-
<div className="space-y-1.5">
|
|
175
|
-
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Password</label>
|
|
176
|
-
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required
|
|
177
|
-
className="w-full bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-lg px-3.5 py-2.5 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-600 focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/25 transition-all" />
|
|
178
|
-
</div>
|
|
179
|
-
<button type="submit" disabled={loading}
|
|
180
|
-
className="w-full bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold text-sm py-2.5 rounded-lg transition-colors">
|
|
181
|
-
{loading ? 'Signing in...' : 'Sign in'}
|
|
182
|
-
</button>
|
|
183
|
-
</form>
|
|
184
|
-
<p className="text-sm text-gray-500 dark:text-gray-400 text-center">
|
|
185
|
-
Don't have an account? <a href="/register" className="text-emerald-600 dark:text-emerald-400 hover:text-emerald-500 dark:hover:text-emerald-300 font-medium">Register</a>
|
|
186
|
-
</p>
|
|
187
|
-
</div>
|
|
188
|
-
</div>
|
|
189
|
-
</div>
|
|
190
|
-
)
|
|
191
|
-
}
|
|
192
|
-
`,
|
|
193
|
-
|
|
194
|
-
'src/pages/Register.tsx': `import { useState } from 'react'
|
|
195
|
-
import { post } from '../lib/api.ts'
|
|
196
|
-
|
|
197
|
-
interface RegisterProps {
|
|
198
|
-
appName?: string
|
|
199
|
-
navigate: (href: string) => void
|
|
200
|
-
[key: string]: any
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
export default function Register({ appName = '${ctx.name}', navigate }: RegisterProps) {
|
|
204
|
-
const [name, setName] = useState('')
|
|
205
|
-
const [email, setEmail] = useState('')
|
|
206
|
-
const [password, setPassword] = useState('')
|
|
207
|
-
const [error, setError] = useState('')
|
|
208
|
-
const [loading, setLoading] = useState(false)
|
|
209
|
-
|
|
210
|
-
const handleSubmit = async (e: React.FormEvent) => {
|
|
211
|
-
e.preventDefault(); setError(''); setLoading(true)
|
|
212
|
-
const { ok, data } = await post('/register', { name, email, password })
|
|
213
|
-
if (ok) navigate('/dashboard')
|
|
214
|
-
else setError(data?.error?.message ?? data?.error ?? 'Registration failed')
|
|
215
|
-
setLoading(false)
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
return (
|
|
219
|
-
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center p-4">
|
|
220
|
-
<div className="animate-fade-up w-full max-w-sm">
|
|
221
|
-
<div className="text-center mb-8">
|
|
222
|
-
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{appName}</h2>
|
|
223
|
-
</div>
|
|
224
|
-
<div className="bg-white dark:bg-gray-900/50 rounded-xl border border-gray-200 dark:border-gray-800 p-8 space-y-6 shadow-sm">
|
|
225
|
-
<div>
|
|
226
|
-
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Create an account</h1>
|
|
227
|
-
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Get started with {appName}</p>
|
|
228
|
-
</div>
|
|
229
|
-
{error && <div className="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/30 text-red-600 dark:text-red-400 rounded-lg px-3.5 py-2.5 text-sm">{error}</div>}
|
|
230
|
-
<form onSubmit={handleSubmit} className="space-y-4">
|
|
231
|
-
<div className="space-y-1.5">
|
|
232
|
-
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
|
|
233
|
-
<input value={name} onChange={(e) => setName(e.target.value)} required
|
|
234
|
-
className="w-full bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-lg px-3.5 py-2.5 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-600 focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/25 transition-all" />
|
|
235
|
-
</div>
|
|
236
|
-
<div className="space-y-1.5">
|
|
237
|
-
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label>
|
|
238
|
-
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required
|
|
239
|
-
className="w-full bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-lg px-3.5 py-2.5 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-600 focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/25 transition-all" />
|
|
240
|
-
</div>
|
|
241
|
-
<div className="space-y-1.5">
|
|
242
|
-
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Password</label>
|
|
243
|
-
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required
|
|
244
|
-
className="w-full bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-lg px-3.5 py-2.5 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-600 focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/25 transition-all" />
|
|
245
|
-
</div>
|
|
246
|
-
<button type="submit" disabled={loading}
|
|
247
|
-
className="w-full bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold text-sm py-2.5 rounded-lg transition-colors">
|
|
248
|
-
{loading ? 'Creating account...' : 'Create account'}
|
|
249
|
-
</button>
|
|
250
|
-
</form>
|
|
251
|
-
<p className="text-sm text-gray-500 dark:text-gray-400 text-center">
|
|
252
|
-
Already have an account? <a href="/login" className="text-emerald-600 dark:text-emerald-400 hover:text-emerald-500 dark:hover:text-emerald-300 font-medium">Sign in</a>
|
|
253
|
-
</p>
|
|
254
|
-
</div>
|
|
255
|
-
</div>
|
|
256
|
-
</div>
|
|
257
|
-
)
|
|
258
|
-
}
|
|
259
|
-
`,
|
|
260
|
-
|
|
261
|
-
'src/pages/Dashboard.tsx': `import { useState, useEffect, useCallback } from 'react'
|
|
262
|
-
import { api, post } from '../lib/api.ts'
|
|
263
|
-
|
|
264
|
-
interface User { id: number; name: string; email: string; role: string }
|
|
265
|
-
|
|
266
|
-
interface DashboardProps {
|
|
267
|
-
appName?: string
|
|
268
|
-
currentUser?: User | null
|
|
269
|
-
users?: User[]
|
|
270
|
-
navigate: (href: string) => void
|
|
271
|
-
[key: string]: any
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
export default function Dashboard({ appName = '${ctx.name}', currentUser, users: initialUsers, navigate }: DashboardProps) {
|
|
275
|
-
const [users, setUsers] = useState<User[]>(initialUsers ?? [])
|
|
276
|
-
const [loading, setLoading] = useState(!initialUsers?.length)
|
|
277
|
-
const [sidebarOpen, setSidebarOpen] = useState(false)
|
|
278
|
-
const [collapsed, setCollapsed] = useState(false)
|
|
279
|
-
const [accountOpen, setAccountOpen] = useState(false)
|
|
280
|
-
const [isDark, setIsDark] = useState(() =>
|
|
281
|
-
typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : true
|
|
282
|
-
)
|
|
283
|
-
|
|
284
|
-
const toggleTheme = () => {
|
|
285
|
-
const dark = document.documentElement.classList.toggle('dark')
|
|
286
|
-
localStorage.setItem('theme', dark ? 'dark' : 'light')
|
|
287
|
-
setIsDark(dark)
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const fetchUsers = useCallback(async () => {
|
|
291
|
-
setLoading(true)
|
|
292
|
-
const { ok, data } = await api('/api/users')
|
|
293
|
-
if (ok) setUsers(data.data ?? [])
|
|
294
|
-
setLoading(false)
|
|
295
|
-
}, [])
|
|
296
|
-
|
|
297
|
-
useEffect(() => {
|
|
298
|
-
if (!initialUsers?.length) fetchUsers()
|
|
299
|
-
}, [fetchUsers, initialUsers])
|
|
300
|
-
|
|
301
|
-
const handleLogout = async () => {
|
|
302
|
-
await post('/logout', {})
|
|
303
|
-
navigate('/login')
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
return (
|
|
307
|
-
<div className="min-h-screen flex bg-gray-50 dark:bg-gray-950">
|
|
308
|
-
{/* Mobile overlay */}
|
|
309
|
-
{sidebarOpen && <div className="fixed inset-0 bg-black/50 z-30 lg:hidden" onClick={() => setSidebarOpen(false)} />}
|
|
310
|
-
|
|
311
|
-
{/* Sidebar */}
|
|
312
|
-
<aside className={\`fixed inset-y-0 left-0 \${sidebarOpen ? 'w-60 translate-x-0' : '-translate-x-full'} \${collapsed ? 'lg:w-16' : 'lg:w-60'} bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col z-40 transition-all duration-200 lg:translate-x-0\`}>
|
|
313
|
-
<div className="h-14 flex items-center px-5 border-b border-gray-200 dark:border-gray-800">
|
|
314
|
-
<span className={\`text-sm font-semibold text-gray-900 dark:text-white \${collapsed ? 'lg:hidden' : ''}\`}>{appName}</span>
|
|
315
|
-
</div>
|
|
316
|
-
<nav className="flex-1 px-3 py-3 space-y-0.5">
|
|
317
|
-
<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">
|
|
318
|
-
<svg className="w-4 h-4 text-gray-500 dark:text-gray-400 shrink-0" 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>
|
|
319
|
-
<span className={\`\${collapsed ? 'lg:hidden' : ''}\`}>Dashboard</span>
|
|
320
|
-
</a>
|
|
321
|
-
<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">
|
|
322
|
-
<svg className="w-4 h-4 shrink-0" 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>
|
|
323
|
-
<span className={\`\${collapsed ? 'lg:hidden' : ''}\`}>Users</span>
|
|
324
|
-
</a>
|
|
325
|
-
</nav>
|
|
326
|
-
|
|
327
|
-
{/* Collapse toggle */}
|
|
328
|
-
<div className="px-3 py-2 hidden lg:block">
|
|
329
|
-
<button onClick={() => setCollapsed(!collapsed)} className="flex items-center justify-center w-full px-2.5 py-2 rounded-lg text-sm text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
|
|
330
|
-
<span className="text-xs font-mono">{collapsed ? '>>' : '<<'}</span>
|
|
331
|
-
</button>
|
|
332
|
-
</div>
|
|
333
|
-
|
|
334
|
-
{/* Bottom links */}
|
|
335
|
-
<div className="px-3 py-3 mt-auto border-t border-gray-200 dark:border-gray-800 space-y-0.5">
|
|
336
|
-
<a href="https://github.com/mantiqjs/mantiq" target="_blank" rel="noopener" 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">
|
|
337
|
-
<svg className="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" /></svg>
|
|
338
|
-
<span className={\`\${collapsed ? 'lg:hidden' : ''}\`}>Documentation</span>
|
|
339
|
-
</a>
|
|
340
|
-
</div>
|
|
341
|
-
</aside>
|
|
342
|
-
|
|
343
|
-
{/* Main */}
|
|
344
|
-
<div className={\`flex-1 \${collapsed ? 'lg:ml-16' : 'lg:ml-60'} transition-all duration-200\`}>
|
|
345
|
-
{/* Top bar */}
|
|
346
|
-
<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">
|
|
347
|
-
<div className="flex items-center">
|
|
348
|
-
<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">
|
|
349
|
-
<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>
|
|
350
|
-
</button>
|
|
351
|
-
<h1 className="text-sm font-medium text-gray-900 dark:text-gray-200">Dashboard</h1>
|
|
352
|
-
</div>
|
|
353
|
-
<div className="flex items-center gap-3">
|
|
354
|
-
<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">
|
|
355
|
-
{isDark ? (
|
|
356
|
-
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
357
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
358
|
-
</svg>
|
|
359
|
-
) : (
|
|
360
|
-
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
361
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
|
362
|
-
</svg>
|
|
363
|
-
)}
|
|
364
|
-
</button>
|
|
365
|
-
{/* Account dropdown */}
|
|
366
|
-
<div className="relative">
|
|
367
|
-
<button onClick={() => setAccountOpen(!accountOpen)} className="flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
|
|
368
|
-
<div className="w-7 h-7 rounded-full bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
|
369
|
-
{currentUser?.name?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2)}
|
|
370
|
-
</div>
|
|
371
|
-
<span className="text-sm text-gray-700 dark:text-gray-300 hidden sm:block">{currentUser?.name}</span>
|
|
372
|
-
<svg className="w-3.5 h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
|
373
|
-
</button>
|
|
374
|
-
{accountOpen && (
|
|
375
|
-
<>
|
|
376
|
-
<div className="fixed inset-0 z-40" onClick={() => setAccountOpen(false)} />
|
|
377
|
-
<div className="absolute right-0 top-full mt-1 w-48 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg shadow-lg py-1 z-50">
|
|
378
|
-
<div className="px-3 py-2 border-b border-gray-100 dark:border-gray-800">
|
|
379
|
-
<div className="text-sm font-medium text-gray-900 dark:text-white">{currentUser?.name}</div>
|
|
380
|
-
<div className="text-xs text-gray-500 dark:text-gray-400">{currentUser?.email}</div>
|
|
381
|
-
</div>
|
|
382
|
-
<button onClick={handleLogout} className="w-full text-left px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white transition-colors">
|
|
383
|
-
Sign out
|
|
384
|
-
</button>
|
|
385
|
-
</div>
|
|
386
|
-
</>
|
|
387
|
-
)}
|
|
388
|
-
</div>
|
|
389
|
-
</div>
|
|
390
|
-
</header>
|
|
391
|
-
|
|
392
|
-
{/* Content */}
|
|
393
|
-
<main className="p-6 space-y-6 animate-fade-up">
|
|
394
|
-
<div className="bg-white dark:bg-gray-900/50 rounded-xl border border-gray-200 dark:border-gray-800 p-6">
|
|
395
|
-
<h2 className="text-lg font-bold text-gray-900 dark:text-white">Welcome back, {currentUser?.name}</h2>
|
|
396
|
-
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Here's what's happening with your application.</p>
|
|
397
|
-
</div>
|
|
398
|
-
|
|
399
|
-
<div id="users-section" className="bg-white dark:bg-gray-900/50 rounded-xl border border-gray-200 dark:border-gray-800 overflow-hidden">
|
|
400
|
-
<div className="px-5 py-4 border-b border-gray-200 dark:border-gray-800 flex items-center justify-between">
|
|
401
|
-
<h2 className="text-sm font-bold text-gray-900 dark:text-gray-200">Users</h2>
|
|
402
|
-
<span className="text-xs text-gray-500 dark:text-gray-400">{loading ? 'Loading...' : \`\${users.length} total\`}</span>
|
|
403
|
-
</div>
|
|
404
|
-
<table className="w-full text-sm">
|
|
405
|
-
<thead>
|
|
406
|
-
<tr className="border-b border-gray-100 dark:border-gray-800 text-left text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
407
|
-
<th className="px-5 py-3 font-medium">Name</th>
|
|
408
|
-
<th className="px-5 py-3 font-medium">Email</th>
|
|
409
|
-
<th className="px-5 py-3 font-medium">Role</th>
|
|
410
|
-
</tr>
|
|
411
|
-
</thead>
|
|
412
|
-
<tbody className="divide-y divide-gray-100 dark:divide-gray-800/60">
|
|
413
|
-
{users.map((u) => (
|
|
414
|
-
<tr key={u.id} className="hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors">
|
|
415
|
-
<td className="px-5 py-3 text-gray-900 dark:text-gray-200">{u.name}</td>
|
|
416
|
-
<td className="px-5 py-3 text-gray-500 dark:text-gray-400">{u.email}</td>
|
|
417
|
-
<td className="px-5 py-3">
|
|
418
|
-
<span className={\`text-[10px] px-2 py-0.5 rounded-full font-medium \${
|
|
419
|
-
u.role === 'admin' ? 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-500/20' : 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-700'
|
|
420
|
-
}\`}>{u.role}</span>
|
|
421
|
-
</td>
|
|
422
|
-
</tr>
|
|
423
|
-
))}
|
|
424
|
-
{users.length === 0 && !loading && (
|
|
425
|
-
<tr><td colSpan={3} className="px-5 py-8 text-center text-gray-400 dark:text-gray-600">No users found</td></tr>
|
|
426
|
-
)}
|
|
427
|
-
</tbody>
|
|
428
|
-
</table>
|
|
429
|
-
</div>
|
|
430
|
-
</main>
|
|
431
|
-
</div>
|
|
432
|
-
</div>
|
|
433
|
-
)
|
|
434
|
-
}
|
|
435
|
-
`,
|
|
436
|
-
}
|
|
437
|
-
}
|