ebm-skills 1.0.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/README.md +44 -0
- package/bin/cli.js +150 -0
- package/package.json +22 -0
- package/skills/ebm-auth/REFERENCE.md +299 -0
- package/skills/ebm-auth/SKILL.md +38 -0
- package/skills/ebm-form/REFERENCE.md +365 -0
- package/skills/ebm-form/SKILL.md +45 -0
- package/skills/ebm-init/REFERENCE.md +264 -0
- package/skills/ebm-init/SKILL.md +36 -0
- package/skills/ebm-table/REFERENCE.md +337 -0
- package/skills/ebm-table/SKILL.md +37 -0
- package/skills/ebm-thai/REFERENCE.md +127 -0
- package/skills/ebm-thai/SKILL.md +29 -0
- package/skills/ebm-upload/REFERENCE.md +521 -0
- package/skills/ebm-upload/SKILL.md +33 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# ebm-form Reference
|
|
2
|
+
|
|
3
|
+
## Input required from user
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
1. Form type — create/edit | search/filter
|
|
7
|
+
2. Feature name — e.g. "users", "products"
|
|
8
|
+
3. Prisma model — e.g. "User" (for create/edit) or skip for search forms
|
|
9
|
+
4. Fields — auto-detect from prisma/schema.prisma if exists
|
|
10
|
+
else: "name:string, email:email, role:select, active:boolean"
|
|
11
|
+
5. For select fields: options list — e.g. "admin,user,viewer"
|
|
12
|
+
6. For relation fields: related model + label/value fields
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Field type mapping
|
|
16
|
+
|
|
17
|
+
| Type | Component | Zod type |
|
|
18
|
+
|------|-----------|----------|
|
|
19
|
+
| `string` | `<input type="text">` | `z.string()` |
|
|
20
|
+
| `email` | `<input type="email">` | `z.string().email()` |
|
|
21
|
+
| `password` | `<input type="password">` | `z.string().min(8)` |
|
|
22
|
+
| `number` | `<input type="number">` | `z.coerce.number()` |
|
|
23
|
+
| `boolean` | `<input type="checkbox">` | `z.boolean()` |
|
|
24
|
+
| `date` | `<input type="date">` | `z.coerce.date()` |
|
|
25
|
+
| `select` | `<select>` | `z.enum([...options])` |
|
|
26
|
+
| `textarea` | `<textarea>` | `z.string()` |
|
|
27
|
+
| `file` | `<input type="file">` | `z.instanceof(File).optional()` |
|
|
28
|
+
| `richtext` | Tiptap editor | `z.string()` |
|
|
29
|
+
| `relation` | async `<select>` fetch from API | `z.number()` or `z.string()` |
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Files to generate
|
|
34
|
+
|
|
35
|
+
### Create/Edit form
|
|
36
|
+
|
|
37
|
+
| File | Purpose |
|
|
38
|
+
|------|---------|
|
|
39
|
+
| `src/components/forms/[Feature]Form.tsx` | Form component (create + edit mode) |
|
|
40
|
+
| `src/lib/schemas/[feature].schema.ts` | Zod schema (shared with API) |
|
|
41
|
+
| `src/app/api/[feature]/route.ts` | POST (create) endpoint |
|
|
42
|
+
| `src/app/api/[feature]/[id]/route.ts` | PUT (update) + DELETE endpoint |
|
|
43
|
+
|
|
44
|
+
### Search/Filter form
|
|
45
|
+
|
|
46
|
+
| File | Purpose |
|
|
47
|
+
|------|---------|
|
|
48
|
+
| `src/components/forms/[Feature]FilterForm.tsx` | Filter form component |
|
|
49
|
+
| `src/lib/schemas/[feature].schema.ts` | Zod schema for filter params |
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Template: `src/lib/schemas/[feature].schema.ts`
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
import { z } from 'zod'
|
|
57
|
+
|
|
58
|
+
export const create[Feature]Schema = z.object({
|
|
59
|
+
name: z.string().min(1, 'Name is required'),
|
|
60
|
+
email: z.string().email('Invalid email'),
|
|
61
|
+
// role: z.enum(['admin', 'user', 'viewer']),
|
|
62
|
+
// active: z.boolean().default(true),
|
|
63
|
+
// birthDate: z.coerce.date().optional(),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
export const update[Feature]Schema = create[Feature]Schema.partial()
|
|
67
|
+
|
|
68
|
+
export type Create[Feature]Input = z.infer<typeof create[Feature]Schema>
|
|
69
|
+
export type Update[Feature]Input = z.infer<typeof update[Feature]Schema>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Template: `src/components/forms/[Feature]Form.tsx`
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
'use client'
|
|
78
|
+
|
|
79
|
+
import { useForm } from 'react-hook-form'
|
|
80
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
81
|
+
import { useRouter } from 'next/navigation'
|
|
82
|
+
import { create[Feature]Schema, type Create[Feature]Input } from '@/lib/schemas/[feature].schema'
|
|
83
|
+
|
|
84
|
+
interface [Feature]FormProps {
|
|
85
|
+
defaultValues?: Partial<Create[Feature]Input>
|
|
86
|
+
id?: number | string // present = edit mode
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function [Feature]Form({ defaultValues, id }: [Feature]FormProps) {
|
|
90
|
+
const router = useRouter()
|
|
91
|
+
const isEdit = !!id
|
|
92
|
+
|
|
93
|
+
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<Create[Feature]Input>({
|
|
94
|
+
resolver: zodResolver(create[Feature]Schema),
|
|
95
|
+
defaultValues,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
async function onSubmit(data: Create[Feature]Input) {
|
|
99
|
+
const url = isEdit ? `/api/[feature]/${id}` : `/api/[feature]`
|
|
100
|
+
const method = isEdit ? 'PUT' : 'POST'
|
|
101
|
+
|
|
102
|
+
const res = await fetch(url, {
|
|
103
|
+
method,
|
|
104
|
+
headers: { 'Content-Type': 'application/json' },
|
|
105
|
+
body: JSON.stringify(data),
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
if (!res.ok) {
|
|
109
|
+
const err = await res.json()
|
|
110
|
+
alert(err.message ?? 'Something went wrong')
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
router.push('/dashboard/[feature]')
|
|
115
|
+
router.refresh()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5 max-w-lg">
|
|
120
|
+
{/* string field example */}
|
|
121
|
+
<div>
|
|
122
|
+
<label className="block text-sm font-medium text-slate-300 mb-1.5">Name</label>
|
|
123
|
+
<input
|
|
124
|
+
{...register('name')}
|
|
125
|
+
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2.5 text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors"
|
|
126
|
+
placeholder="Enter name"
|
|
127
|
+
/>
|
|
128
|
+
{errors.name && <p className="mt-1 text-xs text-red-400">{errors.name.message}</p>}
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{/* email field example */}
|
|
132
|
+
<div>
|
|
133
|
+
<label className="block text-sm font-medium text-slate-300 mb-1.5">Email</label>
|
|
134
|
+
<input
|
|
135
|
+
type="email"
|
|
136
|
+
{...register('email')}
|
|
137
|
+
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2.5 text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors"
|
|
138
|
+
placeholder="Enter email"
|
|
139
|
+
/>
|
|
140
|
+
{errors.email && <p className="mt-1 text-xs text-red-400">{errors.email.message}</p>}
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
{/* select field example
|
|
144
|
+
<div>
|
|
145
|
+
<label className="block text-sm font-medium text-slate-300 mb-1.5">Role</label>
|
|
146
|
+
<select {...register('role')}
|
|
147
|
+
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-blue-500">
|
|
148
|
+
<option value="admin">Admin</option>
|
|
149
|
+
<option value="user">User</option>
|
|
150
|
+
</select>
|
|
151
|
+
{errors.role && <p className="mt-1 text-xs text-red-400">{errors.role.message}</p>}
|
|
152
|
+
</div>
|
|
153
|
+
*/}
|
|
154
|
+
|
|
155
|
+
{/* boolean field example
|
|
156
|
+
<div className="flex items-center gap-3">
|
|
157
|
+
<input type="checkbox" id="active" {...register('active')}
|
|
158
|
+
className="w-4 h-4 rounded border-slate-600 bg-slate-900 text-blue-600 focus:ring-blue-500" />
|
|
159
|
+
<label htmlFor="active" className="text-sm font-medium text-slate-300">Active</label>
|
|
160
|
+
</div>
|
|
161
|
+
*/}
|
|
162
|
+
|
|
163
|
+
<div className="flex gap-3 pt-2">
|
|
164
|
+
<button
|
|
165
|
+
type="submit"
|
|
166
|
+
disabled={isSubmitting}
|
|
167
|
+
className="px-5 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-semibold rounded-lg transition-colors"
|
|
168
|
+
>
|
|
169
|
+
{isSubmitting ? 'Saving...' : isEdit ? 'Save Changes' : 'Create'}
|
|
170
|
+
</button>
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
onClick={() => router.back()}
|
|
174
|
+
className="px-5 py-2.5 bg-slate-700 hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors"
|
|
175
|
+
>
|
|
176
|
+
Cancel
|
|
177
|
+
</button>
|
|
178
|
+
</div>
|
|
179
|
+
</form>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Template: `src/app/api/[feature]/route.ts` (POST)
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
190
|
+
import { getServerSession } from 'next-auth'
|
|
191
|
+
import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options'
|
|
192
|
+
import { prisma } from '@/lib/prisma'
|
|
193
|
+
import { create[Feature]Schema } from '@/lib/schemas/[feature].schema'
|
|
194
|
+
|
|
195
|
+
export async function POST(req: NextRequest) {
|
|
196
|
+
const session = await getServerSession(authOptions)
|
|
197
|
+
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
198
|
+
|
|
199
|
+
const body = await req.json()
|
|
200
|
+
const parsed = create[Feature]Schema.safeParse(body)
|
|
201
|
+
if (!parsed.success) {
|
|
202
|
+
return NextResponse.json({ error: 'Validation failed', issues: parsed.error.issues }, { status: 422 })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const record = await prisma.[model].create({ data: parsed.data })
|
|
206
|
+
return NextResponse.json(record, { status: 201 })
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Template: `src/app/api/[feature]/[id]/route.ts` (PUT + DELETE)
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
214
|
+
import { getServerSession } from 'next-auth'
|
|
215
|
+
import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options'
|
|
216
|
+
import { prisma } from '@/lib/prisma'
|
|
217
|
+
import { update[Feature]Schema } from '@/lib/schemas/[feature].schema'
|
|
218
|
+
|
|
219
|
+
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
|
|
220
|
+
const session = await getServerSession(authOptions)
|
|
221
|
+
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
222
|
+
|
|
223
|
+
const body = await req.json()
|
|
224
|
+
const parsed = update[Feature]Schema.safeParse(body)
|
|
225
|
+
if (!parsed.success) {
|
|
226
|
+
return NextResponse.json({ error: 'Validation failed', issues: parsed.error.issues }, { status: 422 })
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const record = await prisma.[model].update({
|
|
230
|
+
where: { id: Number(params.id) },
|
|
231
|
+
data: parsed.data,
|
|
232
|
+
})
|
|
233
|
+
return NextResponse.json(record)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function DELETE(_req: NextRequest, { params }: { params: { id: string } }) {
|
|
237
|
+
const session = await getServerSession(authOptions)
|
|
238
|
+
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
239
|
+
|
|
240
|
+
await prisma.[model].delete({ where: { id: Number(params.id) } })
|
|
241
|
+
return NextResponse.json({ success: true })
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## Template: Search/Filter form
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
// src/components/forms/[Feature]FilterForm.tsx
|
|
251
|
+
'use client'
|
|
252
|
+
|
|
253
|
+
import { useForm } from 'react-hook-form'
|
|
254
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
255
|
+
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
|
|
256
|
+
import { z } from 'zod'
|
|
257
|
+
|
|
258
|
+
const filterSchema = z.object({
|
|
259
|
+
search: z.string().optional(),
|
|
260
|
+
// status: z.enum(['active', 'inactive', '']).optional(),
|
|
261
|
+
// dateFrom: z.string().optional(),
|
|
262
|
+
// dateTo: z.string().optional(),
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
type FilterInput = z.infer<typeof filterSchema>
|
|
266
|
+
|
|
267
|
+
export function [Feature]FilterForm() {
|
|
268
|
+
const router = useRouter()
|
|
269
|
+
const pathname = usePathname()
|
|
270
|
+
const searchParams = useSearchParams()
|
|
271
|
+
|
|
272
|
+
const { register, handleSubmit, reset } = useForm<FilterInput>({
|
|
273
|
+
resolver: zodResolver(filterSchema),
|
|
274
|
+
defaultValues: {
|
|
275
|
+
search: searchParams.get('search') ?? '',
|
|
276
|
+
},
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
function onSubmit(data: FilterInput) {
|
|
280
|
+
const params = new URLSearchParams()
|
|
281
|
+
Object.entries(data).forEach(([k, v]) => { if (v) params.set(k, v) })
|
|
282
|
+
params.set('page', '1')
|
|
283
|
+
router.push(`${pathname}?${params.toString()}`)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function onReset() {
|
|
287
|
+
reset()
|
|
288
|
+
router.push(pathname)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-wrap gap-3 items-end">
|
|
293
|
+
<div>
|
|
294
|
+
<label className="block text-xs text-slate-400 mb-1">Search</label>
|
|
295
|
+
<input
|
|
296
|
+
{...register('search')}
|
|
297
|
+
placeholder="Search..."
|
|
298
|
+
className="bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500 w-56"
|
|
299
|
+
/>
|
|
300
|
+
</div>
|
|
301
|
+
{/* Add more filter fields here */}
|
|
302
|
+
<button type="submit"
|
|
303
|
+
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition-colors">
|
|
304
|
+
Filter
|
|
305
|
+
</button>
|
|
306
|
+
<button type="button" onClick={onReset}
|
|
307
|
+
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
|
|
308
|
+
Reset
|
|
309
|
+
</button>
|
|
310
|
+
</form>
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
---
|
|
316
|
+
|
|
317
|
+
## UI Library variants
|
|
318
|
+
|
|
319
|
+
### shadcn/ui
|
|
320
|
+
Replace inputs with `<Input>`, `<Select>`, `<Checkbox>` from shadcn.
|
|
321
|
+
Wrap fields in `<FormField>`, `<FormControl>`, `<FormMessage>` from `@/components/ui/form`.
|
|
322
|
+
|
|
323
|
+
### Ant Design
|
|
324
|
+
```typescript
|
|
325
|
+
import { Form, Input, Select, Button } from 'antd'
|
|
326
|
+
// Use Form.useForm() instead of RHF
|
|
327
|
+
// Validation via Form rules prop
|
|
328
|
+
// Note: Ant Design has its own form system — skip RHF when using antd
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### MUI
|
|
332
|
+
```typescript
|
|
333
|
+
import { TextField, Select, Button } from '@mui/material'
|
|
334
|
+
// Keep RHF, use Controller wrapper for MUI components:
|
|
335
|
+
// <Controller name="field" control={control} render={({ field }) => <TextField {...field} />} />
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
## Required dependencies
|
|
341
|
+
|
|
342
|
+
```bash
|
|
343
|
+
npm install react-hook-form @hookform/resolvers zod
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## Post-generation summary
|
|
349
|
+
|
|
350
|
+
```
|
|
351
|
+
✓ Generated [N] files for [feature] form
|
|
352
|
+
|
|
353
|
+
Files:
|
|
354
|
+
src/components/forms/[Feature]Form.tsx
|
|
355
|
+
src/lib/schemas/[feature].schema.ts
|
|
356
|
+
src/app/api/[feature]/route.ts (POST)
|
|
357
|
+
src/app/api/[feature]/[id]/route.ts (PUT, DELETE)
|
|
358
|
+
|
|
359
|
+
TODO in generated files:
|
|
360
|
+
1. Uncomment/add field blocks in [Feature]Form.tsx
|
|
361
|
+
2. Add select options for enum fields
|
|
362
|
+
3. Update Prisma model name in API routes (marked with [model])
|
|
363
|
+
|
|
364
|
+
Install: npm install react-hook-form @hookform/resolvers zod
|
|
365
|
+
```
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ebm-form
|
|
3
|
+
description: Generate a create/edit form or search/filter form using React Hook Form and Zod. Auto-detects fields from prisma/schema.prisma when available. Also generates API routes (POST, PUT, DELETE) for create/edit forms. Reads UI library from ebm.config.json. Use when user invokes /ebm-form or asks to build a form, create page, edit page, or search/filter UI.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /ebm-form
|
|
7
|
+
|
|
8
|
+
### Step 1 — Ask form type
|
|
9
|
+
```
|
|
10
|
+
1. Form type: create/edit | search/filter
|
|
11
|
+
2. Feature name: e.g. "products"
|
|
12
|
+
3. Prisma model: e.g. "Product" (create/edit only)
|
|
13
|
+
4. Fields: auto-detect from prisma/schema.prisma if exists
|
|
14
|
+
else ask: "name:string, price:number, status:select, active:boolean"
|
|
15
|
+
5. For select fields: options list — e.g. "active,inactive,draft"
|
|
16
|
+
6. For relation fields: related model + label/value fields
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Step 2 — Read config
|
|
20
|
+
```
|
|
21
|
+
ebm.config.json → uiLib (shadcn | antd | mui | none)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Step 3 — Generate files
|
|
25
|
+
|
|
26
|
+
**create/edit → 4 files:**
|
|
27
|
+
```
|
|
28
|
+
src/components/forms/[Feature]Form.tsx
|
|
29
|
+
src/lib/schemas/[feature].schema.ts
|
|
30
|
+
src/app/api/[feature]/route.ts (POST)
|
|
31
|
+
src/app/api/[feature]/[id]/route.ts (PUT, DELETE)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**search/filter → 2 files:**
|
|
35
|
+
```
|
|
36
|
+
src/components/forms/[Feature]FilterForm.tsx
|
|
37
|
+
src/lib/schemas/[feature].schema.ts
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
See [REFERENCE.md](REFERENCE.md) for field type mapping, templates, and UI lib variants.
|
|
41
|
+
|
|
42
|
+
## Shared rules
|
|
43
|
+
- Always use React Hook Form + zodResolver
|
|
44
|
+
- Path alias: `@/*` → `./src/*` always
|
|
45
|
+
- Thai UI text: use formal Thai — see `/ebm-thai` glossary
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# ebm-init Reference
|
|
2
|
+
|
|
3
|
+
## Post-CLI scaffolding by project type
|
|
4
|
+
|
|
5
|
+
After `create-next-app` runs, scaffold additional files based on project type.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Type: landing (หน้าบ้านแสดงข้อมูล)
|
|
10
|
+
|
|
11
|
+
| File | Purpose |
|
|
12
|
+
|------|---------|
|
|
13
|
+
| `src/app/layout.tsx` | Root layout, imports globals.css |
|
|
14
|
+
| `src/app/page.tsx` | Hero/landing page |
|
|
15
|
+
| `src/app/(pages)/layout.tsx` | Content route group layout |
|
|
16
|
+
| `src/components/ui/Navbar.tsx` | Sticky nav: logo + links |
|
|
17
|
+
| `src/components/ui/Footer.tsx` | Footer with copyright |
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
// src/components/ui/Navbar.tsx
|
|
21
|
+
'use client'
|
|
22
|
+
import Link from 'next/link'
|
|
23
|
+
export function Navbar() {
|
|
24
|
+
return (
|
|
25
|
+
<nav className="sticky top-0 z-10 border-b border-slate-700/50 bg-slate-900/60 backdrop-blur-md">
|
|
26
|
+
<div className="mx-auto max-w-6xl px-6 flex items-center justify-between h-16">
|
|
27
|
+
<Link href="/" className="text-xl font-bold text-white">Logo</Link>
|
|
28
|
+
<div className="flex gap-6 text-sm text-slate-400">
|
|
29
|
+
<Link href="/" className="hover:text-white transition-colors">Home</Link>
|
|
30
|
+
<Link href="/about" className="hover:text-white transition-colors">About</Link>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</nav>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Type: backoffice (Dashboard)
|
|
41
|
+
|
|
42
|
+
| File | Purpose |
|
|
43
|
+
|------|---------|
|
|
44
|
+
| `src/app/(dashboard)/layout.tsx` | Sidebar + Header + children |
|
|
45
|
+
| `src/app/(dashboard)/dashboard/page.tsx` | Overview with stat cards |
|
|
46
|
+
| `src/components/layout/Sidebar.tsx` | Left nav with active state |
|
|
47
|
+
| `src/components/layout/Header.tsx` | Top bar: page title + user avatar |
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
// src/app/(dashboard)/layout.tsx
|
|
51
|
+
import { Sidebar } from '@/components/layout/Sidebar'
|
|
52
|
+
import { Header } from '@/components/layout/Header'
|
|
53
|
+
|
|
54
|
+
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
|
55
|
+
return (
|
|
56
|
+
<div className="flex h-screen bg-slate-900 text-white overflow-hidden">
|
|
57
|
+
<Sidebar />
|
|
58
|
+
<div className="flex flex-col flex-1 overflow-hidden">
|
|
59
|
+
<Header />
|
|
60
|
+
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
// src/components/layout/Sidebar.tsx
|
|
69
|
+
'use client'
|
|
70
|
+
import Link from 'next/link'
|
|
71
|
+
import { usePathname } from 'next/navigation'
|
|
72
|
+
|
|
73
|
+
const NAV = [
|
|
74
|
+
{ href: '/dashboard', label: 'Dashboard' },
|
|
75
|
+
{ href: '/dashboard/users', label: 'Users' },
|
|
76
|
+
{ href: '/dashboard/settings', label: 'Settings' },
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
export function Sidebar() {
|
|
80
|
+
const pathname = usePathname()
|
|
81
|
+
return (
|
|
82
|
+
<aside className="w-64 flex-shrink-0 bg-slate-800 border-r border-slate-700 flex flex-col">
|
|
83
|
+
<div className="h-16 flex items-center px-6 border-b border-slate-700">
|
|
84
|
+
<span className="text-xl font-bold text-white">Admin</span>
|
|
85
|
+
</div>
|
|
86
|
+
<nav className="flex-1 px-3 py-4 space-y-1">
|
|
87
|
+
{NAV.map((item) => (
|
|
88
|
+
<Link key={item.href} href={item.href}
|
|
89
|
+
className={`flex items-center px-3 py-2 rounded-lg text-sm transition-colors ${
|
|
90
|
+
pathname === item.href
|
|
91
|
+
? 'bg-blue-600 text-white'
|
|
92
|
+
: 'text-slate-400 hover:text-white hover:bg-slate-700'
|
|
93
|
+
}`}>
|
|
94
|
+
{item.label}
|
|
95
|
+
</Link>
|
|
96
|
+
))}
|
|
97
|
+
</nav>
|
|
98
|
+
</aside>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Type: both (landing + backoffice)
|
|
106
|
+
|
|
107
|
+
Root `src/app/layout.tsx` = minimal shell only (no nav/sidebar).
|
|
108
|
+
|
|
109
|
+
Route groups:
|
|
110
|
+
- `src/app/(public)/` — landing files (Type A layout pattern)
|
|
111
|
+
- `src/app/(dashboard)/` — admin files (Type B layout pattern)
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
// src/app/layout.tsx (Type C root — minimal)
|
|
115
|
+
import './globals.css'
|
|
116
|
+
export const metadata = { title: 'App', description: '' }
|
|
117
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
118
|
+
return (
|
|
119
|
+
<html lang="en">
|
|
120
|
+
<body className="bg-slate-900 text-white antialiased">{children}</body>
|
|
121
|
+
</html>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Tailwind v4 setup (if tailwind: true)
|
|
129
|
+
|
|
130
|
+
```css
|
|
131
|
+
/* src/app/globals.css */
|
|
132
|
+
@import "tailwindcss";
|
|
133
|
+
|
|
134
|
+
/* Primary color override — use ebm.config.json primaryColor */
|
|
135
|
+
@theme {
|
|
136
|
+
--color-primary: #3b82f6; /* replace with user's chosen color */
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```js
|
|
141
|
+
// postcss.config.mjs
|
|
142
|
+
const config = { plugins: { '@tailwindcss/postcss': {} } }
|
|
143
|
+
export default config
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Install: `tailwindcss @tailwindcss/postcss`
|
|
147
|
+
|
|
148
|
+
**Dark mode:** add `class="dark"` to `<html>` and use `dark:` variants
|
|
149
|
+
**Light mode:** default (no extra config needed)
|
|
150
|
+
**System mode:** toggle via JS `document.documentElement.classList`
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## UI Library setup
|
|
155
|
+
|
|
156
|
+
### shadcn/ui
|
|
157
|
+
```bash
|
|
158
|
+
npx shadcn@latest init
|
|
159
|
+
# Answer prompts: style=default, baseColor=slate, cssVariables=yes
|
|
160
|
+
```
|
|
161
|
+
Then install components as needed: `npx shadcn@latest add button input card`
|
|
162
|
+
|
|
163
|
+
### Ant Design
|
|
164
|
+
```bash
|
|
165
|
+
npm install antd @ant-design/icons
|
|
166
|
+
```
|
|
167
|
+
```tsx
|
|
168
|
+
// src/app/layout.tsx — wrap with AntdRegistry for SSR
|
|
169
|
+
import { AntdRegistry } from '@ant-design/nextjs-registry'
|
|
170
|
+
// ...
|
|
171
|
+
<AntdRegistry>{children}</AntdRegistry>
|
|
172
|
+
```
|
|
173
|
+
Install: `npm install @ant-design/nextjs-registry`
|
|
174
|
+
|
|
175
|
+
### MUI (Material UI)
|
|
176
|
+
```bash
|
|
177
|
+
npm install @mui/material @emotion/react @emotion/styled @mui/icons-material
|
|
178
|
+
```
|
|
179
|
+
```tsx
|
|
180
|
+
// src/app/layout.tsx — wrap with AppRouterCacheProvider
|
|
181
|
+
import { AppRouterCacheProvider } from '@mui/material-nextjs/v14-appRouter'
|
|
182
|
+
// ...
|
|
183
|
+
<AppRouterCacheProvider>{children}</AppRouterCacheProvider>
|
|
184
|
+
```
|
|
185
|
+
Install: `npm install @mui/material-nextjs`
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Database setup
|
|
190
|
+
|
|
191
|
+
### Prisma
|
|
192
|
+
```bash
|
|
193
|
+
npm install @prisma/client
|
|
194
|
+
npm install -D prisma
|
|
195
|
+
npx prisma init --datasource-provider [postgresql|mysql|sqlite]
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Drizzle
|
|
199
|
+
```bash
|
|
200
|
+
npm install drizzle-orm
|
|
201
|
+
npm install -D drizzle-kit
|
|
202
|
+
# PostgreSQL: npm install pg @types/pg
|
|
203
|
+
# MySQL: npm install mysql2
|
|
204
|
+
# SQLite: npm install better-sqlite3 @types/better-sqlite3
|
|
205
|
+
```
|
|
206
|
+
Create `drizzle.config.ts`:
|
|
207
|
+
```ts
|
|
208
|
+
import { defineConfig } from 'drizzle-kit'
|
|
209
|
+
export default defineConfig({
|
|
210
|
+
schema: './src/db/schema.ts',
|
|
211
|
+
out: './drizzle',
|
|
212
|
+
dialect: 'postgresql', // or mysql, sqlite
|
|
213
|
+
dbCredentials: { url: process.env.DATABASE_URL! },
|
|
214
|
+
})
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## tsconfig.json (generate if missing or missing paths)
|
|
220
|
+
```json
|
|
221
|
+
{
|
|
222
|
+
"compilerOptions": {
|
|
223
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
224
|
+
"allowJs": true,
|
|
225
|
+
"skipLibCheck": true,
|
|
226
|
+
"strict": false,
|
|
227
|
+
"noEmit": true,
|
|
228
|
+
"incremental": true,
|
|
229
|
+
"module": "esnext",
|
|
230
|
+
"esModuleInterop": true,
|
|
231
|
+
"resolveJsonModule": true,
|
|
232
|
+
"isolatedModules": true,
|
|
233
|
+
"jsx": "preserve",
|
|
234
|
+
"plugins": [{ "name": "next" }],
|
|
235
|
+
"paths": { "@/*": ["./src/*"] }
|
|
236
|
+
},
|
|
237
|
+
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
|
|
238
|
+
"exclude": ["node_modules"]
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## Post-generation summary template
|
|
245
|
+
```
|
|
246
|
+
✓ Project initialized with create-next-app
|
|
247
|
+
✓ Generated [N] scaffold files
|
|
248
|
+
✓ Saved config to ebm.config.json
|
|
249
|
+
|
|
250
|
+
Stack:
|
|
251
|
+
Type: [projectType]
|
|
252
|
+
Tailwind: [yes/no] — [colorMode], primary: [color]
|
|
253
|
+
UI lib: [uiLib]
|
|
254
|
+
DB: [orm] + [dbProvider]
|
|
255
|
+
|
|
256
|
+
Next steps:
|
|
257
|
+
1. npm install (or yarn/pnpm install)
|
|
258
|
+
2. Copy .env.example → .env.local
|
|
259
|
+
3. [if DB] npx prisma migrate dev --name init
|
|
260
|
+
4. npm run dev
|
|
261
|
+
|
|
262
|
+
[if backoffice or both]
|
|
263
|
+
→ Run /ebm-auth to add authentication
|
|
264
|
+
```
|