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.
@@ -0,0 +1,521 @@
1
+ # ebm-upload Reference
2
+
3
+ ## Input required from user
4
+
5
+ ```
6
+ 1. Upload mode — single | multiple
7
+ 2. Feature name — e.g. "avatar", "documents"
8
+ 3. Storage — local | s3
9
+ 4. Allowed types — e.g. "image/jpeg,image/png,application/pdf"
10
+ 5. Max file size — e.g. "5MB"
11
+ ```
12
+
13
+ ---
14
+
15
+ ## Files to generate
16
+
17
+ | File | Purpose |
18
+ |------|---------|
19
+ | `src/components/upload/[Feature]Uploader.tsx` | Drag & drop UI + progress + preview |
20
+ | `src/app/api/upload/[feature]/route.ts` | POST endpoint — validate + store |
21
+ | `src/lib/upload/[feature].config.ts` | Storage config + validation rules |
22
+
23
+ ---
24
+
25
+ ## Template: `src/lib/upload/[feature].config.ts`
26
+
27
+ ```typescript
28
+ export const [feature]UploadConfig = {
29
+ maxSize: 5 * 1024 * 1024, // 5MB — replace with user's value
30
+ allowedTypes: ['image/jpeg', 'image/png'], // replace with user's list
31
+ multiple: false, // true for multiple upload mode
32
+
33
+ // local storage
34
+ uploadDir: 'public/uploads/[feature]',
35
+ publicPath: '/uploads/[feature]',
36
+
37
+ // s3 (only used when STORAGE=s3)
38
+ s3Prefix: '[feature]/',
39
+ }
40
+
41
+ export type UploadResult = {
42
+ url: string
43
+ filename: string
44
+ size: number
45
+ mimeType: string
46
+ }
47
+ ```
48
+
49
+ ---
50
+
51
+ ## Template: `src/app/api/upload/[feature]/route.ts`
52
+
53
+ ### Local storage variant
54
+
55
+ ```typescript
56
+ import { NextRequest, NextResponse } from 'next/server'
57
+ import { getServerSession } from 'next-auth'
58
+ import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options'
59
+ import { writeFile, mkdir } from 'fs/promises'
60
+ import path from 'path'
61
+ import { [feature]UploadConfig } from '@/lib/upload/[feature].config'
62
+
63
+ export async function POST(req: NextRequest) {
64
+ const session = await getServerSession(authOptions)
65
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
66
+
67
+ const formData = await req.formData()
68
+ const files = formData.getAll('file') as File[]
69
+
70
+ if (!files.length) {
71
+ return NextResponse.json({ error: 'No file provided' }, { status: 400 })
72
+ }
73
+
74
+ const results = []
75
+
76
+ for (const file of files) {
77
+ if (!([feature]UploadConfig.allowedTypes as string[]).includes(file.type)) {
78
+ return NextResponse.json(
79
+ { error: `File type ${file.type} is not allowed` },
80
+ { status: 422 }
81
+ )
82
+ }
83
+
84
+ if (file.size > [feature]UploadConfig.maxSize) {
85
+ return NextResponse.json(
86
+ { error: `File exceeds max size of ${[feature]UploadConfig.maxSize / 1024 / 1024}MB` },
87
+ { status: 422 }
88
+ )
89
+ }
90
+
91
+ const bytes = await file.arrayBuffer()
92
+ const buffer = Buffer.from(bytes)
93
+
94
+ const ext = file.name.split('.').pop()
95
+ const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
96
+ const uploadDir = path.join(process.cwd(), [feature]UploadConfig.uploadDir)
97
+
98
+ await mkdir(uploadDir, { recursive: true })
99
+ await writeFile(path.join(uploadDir, filename), buffer)
100
+
101
+ results.push({
102
+ url: `${[feature]UploadConfig.publicPath}/${filename}`,
103
+ filename,
104
+ size: file.size,
105
+ mimeType: file.type,
106
+ })
107
+ }
108
+
109
+ return NextResponse.json([feature]UploadConfig.multiple ? results : results[0], { status: 201 })
110
+ }
111
+ ```
112
+
113
+ ### S3 storage variant
114
+
115
+ ```typescript
116
+ import { NextRequest, NextResponse } from 'next/server'
117
+ import { getServerSession } from 'next-auth'
118
+ import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options'
119
+ import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
120
+ import { [feature]UploadConfig } from '@/lib/upload/[feature].config'
121
+
122
+ const s3 = new S3Client({
123
+ region: process.env.S3_REGION ?? 'auto',
124
+ endpoint: process.env.S3_ENDPOINT || undefined,
125
+ credentials: {
126
+ accessKeyId: process.env.S3_ACCESS_KEY!,
127
+ secretAccessKey: process.env.S3_SECRET_KEY!,
128
+ },
129
+ })
130
+
131
+ export async function POST(req: NextRequest) {
132
+ const session = await getServerSession(authOptions)
133
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
134
+
135
+ const formData = await req.formData()
136
+ const files = formData.getAll('file') as File[]
137
+
138
+ if (!files.length) {
139
+ return NextResponse.json({ error: 'No file provided' }, { status: 400 })
140
+ }
141
+
142
+ const results = []
143
+
144
+ for (const file of files) {
145
+ if (!([feature]UploadConfig.allowedTypes as string[]).includes(file.type)) {
146
+ return NextResponse.json(
147
+ { error: `File type ${file.type} is not allowed` },
148
+ { status: 422 }
149
+ )
150
+ }
151
+
152
+ if (file.size > [feature]UploadConfig.maxSize) {
153
+ return NextResponse.json(
154
+ { error: `File exceeds max size of ${[feature]UploadConfig.maxSize / 1024 / 1024}MB` },
155
+ { status: 422 }
156
+ )
157
+ }
158
+
159
+ const bytes = await file.arrayBuffer()
160
+ const ext = file.name.split('.').pop()
161
+ const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
162
+ const key = `${[feature]UploadConfig.s3Prefix}${filename}`
163
+
164
+ await s3.send(new PutObjectCommand({
165
+ Bucket: process.env.S3_BUCKET!,
166
+ Key: key,
167
+ Body: Buffer.from(bytes),
168
+ ContentType: file.type,
169
+ }))
170
+
171
+ const url = `${process.env.S3_PUBLIC_URL}/${key}`
172
+ results.push({ url, filename, size: file.size, mimeType: file.type })
173
+ }
174
+
175
+ return NextResponse.json([feature]UploadConfig.multiple ? results : results[0], { status: 201 })
176
+ }
177
+ ```
178
+
179
+ ---
180
+
181
+ ## Template: `src/components/upload/[Feature]Uploader.tsx`
182
+
183
+ ### Single file mode
184
+
185
+ ```typescript
186
+ 'use client'
187
+
188
+ import { useCallback, useRef, useState } from 'react'
189
+
190
+ interface UploadResult {
191
+ url: string
192
+ filename: string
193
+ size: number
194
+ mimeType: string
195
+ }
196
+
197
+ interface [Feature]UploaderProps {
198
+ onUpload?: (result: UploadResult) => void
199
+ className?: string
200
+ }
201
+
202
+ export function [Feature]Uploader({ onUpload, className }: [Feature]UploaderProps) {
203
+ const inputRef = useRef<HTMLInputElement>(null)
204
+ const [dragging, setDragging] = useState(false)
205
+ const [uploading, setUploading] = useState(false)
206
+ const [progress, setProgress] = useState(0)
207
+ const [preview, setPreview] = useState<string | null>(null)
208
+ const [error, setError] = useState<string | null>(null)
209
+
210
+ const upload = useCallback(async (file: File) => {
211
+ setError(null)
212
+ setUploading(true)
213
+ setProgress(0)
214
+
215
+ if (file.type.startsWith('image/')) {
216
+ setPreview(URL.createObjectURL(file))
217
+ }
218
+
219
+ // Simulate progress (XMLHttpRequest for real progress tracking)
220
+ const interval = setInterval(() => {
221
+ setProgress((p) => Math.min(p + 10, 90))
222
+ }, 100)
223
+
224
+ try {
225
+ const formData = new FormData()
226
+ formData.append('file', file)
227
+
228
+ const res = await fetch('/api/upload/[feature]', { method: 'POST', body: formData })
229
+ clearInterval(interval)
230
+
231
+ if (!res.ok) {
232
+ const err = await res.json()
233
+ setError(err.error ?? 'Upload failed')
234
+ setPreview(null)
235
+ return
236
+ }
237
+
238
+ setProgress(100)
239
+ const result: UploadResult = await res.json()
240
+ onUpload?.(result)
241
+ } finally {
242
+ setUploading(false)
243
+ clearInterval(interval)
244
+ }
245
+ }, [onUpload])
246
+
247
+ const onDrop = useCallback((e: React.DragEvent) => {
248
+ e.preventDefault()
249
+ setDragging(false)
250
+ const file = e.dataTransfer.files[0]
251
+ if (file) upload(file)
252
+ }, [upload])
253
+
254
+ const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
255
+ const file = e.target.files?.[0]
256
+ if (file) upload(file)
257
+ }
258
+
259
+ return (
260
+ <div className={className}>
261
+ <div
262
+ onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
263
+ onDragLeave={() => setDragging(false)}
264
+ onDrop={onDrop}
265
+ onClick={() => inputRef.current?.click()}
266
+ className={`relative flex flex-col items-center justify-center border-2 border-dashed rounded-xl p-8 cursor-pointer transition-colors ${
267
+ dragging
268
+ ? 'border-blue-500 bg-blue-500/10'
269
+ : 'border-slate-600 hover:border-slate-500 bg-slate-900/50'
270
+ }`}
271
+ >
272
+ {preview ? (
273
+ <img src={preview} alt="Preview" className="max-h-40 rounded-lg object-contain mb-3" />
274
+ ) : (
275
+ <div className="text-slate-400 text-center">
276
+ <svg className="w-10 h-10 mx-auto mb-2 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
277
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
278
+ d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
279
+ </svg>
280
+ <p className="text-sm font-medium text-slate-300">Drop file here or click to browse</p>
281
+ <p className="text-xs text-slate-500 mt-1">Max size: [maxSize]</p>
282
+ </div>
283
+ )}
284
+
285
+ <input
286
+ ref={inputRef}
287
+ type="file"
288
+ className="hidden"
289
+ accept="[allowedTypes]"
290
+ onChange={onInputChange}
291
+ />
292
+ </div>
293
+
294
+ {uploading && (
295
+ <div className="mt-3">
296
+ <div className="flex justify-between text-xs text-slate-400 mb-1">
297
+ <span>Uploading...</span>
298
+ <span>{progress}%</span>
299
+ </div>
300
+ <div className="w-full bg-slate-700 rounded-full h-1.5">
301
+ <div
302
+ className="bg-blue-500 h-1.5 rounded-full transition-all duration-200"
303
+ style={{ width: `${progress}%` }}
304
+ />
305
+ </div>
306
+ </div>
307
+ )}
308
+
309
+ {error && <p className="mt-2 text-xs text-red-400">{error}</p>}
310
+ </div>
311
+ )
312
+ }
313
+ ```
314
+
315
+ ### Multiple file mode
316
+
317
+ ```typescript
318
+ 'use client'
319
+
320
+ import { useCallback, useRef, useState } from 'react'
321
+
322
+ interface UploadResult {
323
+ url: string
324
+ filename: string
325
+ size: number
326
+ mimeType: string
327
+ }
328
+
329
+ interface FileItem {
330
+ file: File
331
+ preview: string | null
332
+ progress: number
333
+ status: 'pending' | 'uploading' | 'done' | 'error'
334
+ result?: UploadResult
335
+ error?: string
336
+ }
337
+
338
+ interface [Feature]UploaderProps {
339
+ onUpload?: (results: UploadResult[]) => void
340
+ className?: string
341
+ }
342
+
343
+ export function [Feature]Uploader({ onUpload, className }: [Feature]UploaderProps) {
344
+ const inputRef = useRef<HTMLInputElement>(null)
345
+ const [dragging, setDragging] = useState(false)
346
+ const [items, setItems] = useState<FileItem[]>([])
347
+
348
+ const updateItem = (index: number, patch: Partial<FileItem>) => {
349
+ setItems((prev) => prev.map((item, i) => i === index ? { ...item, ...patch } : item))
350
+ }
351
+
352
+ const uploadFile = async (file: File, index: number) => {
353
+ updateItem(index, { status: 'uploading', progress: 10 })
354
+
355
+ const interval = setInterval(() => {
356
+ setItems((prev) =>
357
+ prev.map((item, i) =>
358
+ i === index && item.status === 'uploading'
359
+ ? { ...item, progress: Math.min(item.progress + 10, 90) }
360
+ : item
361
+ )
362
+ )
363
+ }, 100)
364
+
365
+ try {
366
+ const formData = new FormData()
367
+ formData.append('file', file)
368
+ const res = await fetch('/api/upload/[feature]', { method: 'POST', body: formData })
369
+ clearInterval(interval)
370
+
371
+ if (!res.ok) {
372
+ const err = await res.json()
373
+ updateItem(index, { status: 'error', error: err.error ?? 'Upload failed', progress: 0 })
374
+ return null
375
+ }
376
+
377
+ const result: UploadResult = await res.json()
378
+ updateItem(index, { status: 'done', progress: 100, result })
379
+ return result
380
+ } catch {
381
+ clearInterval(interval)
382
+ updateItem(index, { status: 'error', error: 'Upload failed', progress: 0 })
383
+ return null
384
+ }
385
+ }
386
+
387
+ const addFiles = useCallback(async (files: FileList | File[]) => {
388
+ const newItems: FileItem[] = Array.from(files).map((file) => ({
389
+ file,
390
+ preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : null,
391
+ progress: 0,
392
+ status: 'pending',
393
+ }))
394
+
395
+ setItems((prev) => {
396
+ const startIndex = prev.length
397
+ const next = [...prev, ...newItems]
398
+
399
+ // Upload all new files
400
+ newItems.forEach((_, i) => {
401
+ uploadFile(files[i] instanceof File ? files[i] as File : Array.from(files)[i], startIndex + i)
402
+ .then((result) => {
403
+ if (result) {
404
+ setItems((current) => {
405
+ const results = current.filter((x) => x.status === 'done').map((x) => x.result!)
406
+ onUpload?.(results)
407
+ return current
408
+ })
409
+ }
410
+ })
411
+ })
412
+
413
+ return next
414
+ })
415
+ }, [onUpload])
416
+
417
+ const onDrop = (e: React.DragEvent) => {
418
+ e.preventDefault()
419
+ setDragging(false)
420
+ addFiles(e.dataTransfer.files)
421
+ }
422
+
423
+ return (
424
+ <div className={className}>
425
+ <div
426
+ onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
427
+ onDragLeave={() => setDragging(false)}
428
+ onDrop={onDrop}
429
+ onClick={() => inputRef.current?.click()}
430
+ className={`flex flex-col items-center justify-center border-2 border-dashed rounded-xl p-6 cursor-pointer transition-colors ${
431
+ dragging ? 'border-blue-500 bg-blue-500/10' : 'border-slate-600 hover:border-slate-500 bg-slate-900/50'
432
+ }`}
433
+ >
434
+ <svg className="w-8 h-8 text-slate-500 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
435
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
436
+ d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
437
+ </svg>
438
+ <p className="text-sm font-medium text-slate-300">Drop files here or click to browse</p>
439
+ <p className="text-xs text-slate-500 mt-1">Max [maxSize] per file</p>
440
+ <input ref={inputRef} type="file" className="hidden" multiple accept="[allowedTypes]"
441
+ onChange={(e) => { if (e.target.files) addFiles(e.target.files) }} />
442
+ </div>
443
+
444
+ {items.length > 0 && (
445
+ <ul className="mt-3 space-y-2">
446
+ {items.map((item, i) => (
447
+ <li key={i} className="flex items-center gap-3 bg-slate-800 rounded-lg px-3 py-2">
448
+ {item.preview
449
+ ? <img src={item.preview} alt="" className="w-8 h-8 rounded object-cover flex-shrink-0" />
450
+ : <div className="w-8 h-8 rounded bg-slate-700 flex items-center justify-center flex-shrink-0">
451
+ <svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
452
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
453
+ </svg>
454
+ </div>
455
+ }
456
+ <div className="flex-1 min-w-0">
457
+ <p className="text-xs text-slate-300 truncate">{item.file.name}</p>
458
+ {item.status === 'uploading' && (
459
+ <div className="w-full bg-slate-700 rounded-full h-1 mt-1">
460
+ <div className="bg-blue-500 h-1 rounded-full transition-all" style={{ width: `${item.progress}%` }} />
461
+ </div>
462
+ )}
463
+ {item.status === 'error' && <p className="text-xs text-red-400 mt-0.5">{item.error}</p>}
464
+ </div>
465
+ <span className={`text-xs flex-shrink-0 ${
466
+ item.status === 'done' ? 'text-green-400' :
467
+ item.status === 'error' ? 'text-red-400' :
468
+ item.status === 'uploading' ? 'text-blue-400' : 'text-slate-500'
469
+ }`}>
470
+ {item.status === 'done' ? '✓' : item.status === 'error' ? '✗' : item.status === 'uploading' ? `${item.progress}%` : '—'}
471
+ </span>
472
+ </li>
473
+ ))}
474
+ </ul>
475
+ )}
476
+ </div>
477
+ )
478
+ }
479
+ ```
480
+
481
+ ---
482
+
483
+ ## S3 env vars (append to `.env.example`)
484
+
485
+ ```env
486
+ # Upload — S3-compatible (AWS S3 / Cloudflare R2 / MinIO)
487
+ S3_ENDPOINT= # leave blank for AWS, set for R2/MinIO e.g. https://xxx.r2.cloudflarestorage.com
488
+ S3_REGION=auto
489
+ S3_ACCESS_KEY=
490
+ S3_SECRET_KEY=
491
+ S3_BUCKET=
492
+ S3_PUBLIC_URL= # CDN or public endpoint e.g. https://cdn.example.com
493
+ ```
494
+
495
+ ## Required packages
496
+
497
+ ```bash
498
+ # S3 storage only
499
+ npm install @aws-sdk/client-s3
500
+ ```
501
+
502
+ ---
503
+
504
+ ## Post-generation summary
505
+
506
+ ```
507
+ ✓ Generated 3 files for [feature] upload
508
+
509
+ Files:
510
+ src/components/upload/[Feature]Uploader.tsx
511
+ src/app/api/upload/[feature]/route.ts
512
+ src/lib/upload/[feature].config.ts
513
+
514
+ [if s3]
515
+ Install: npm install @aws-sdk/client-s3
516
+ Env vars appended to .env.example
517
+
518
+ Usage:
519
+ import { [Feature]Uploader } from '@/components/upload/[Feature]Uploader'
520
+ <[Feature]Uploader onUpload={(result) => console.log(result.url)} />
521
+ ```
@@ -0,0 +1,33 @@
1
+ ---
2
+ name: ebm-upload
3
+ description: Generate a drag-and-drop file upload component with progress bar, image preview, MIME type validation, and file size limits. Supports single or multiple file upload and local disk or S3-compatible storage (AWS S3, Cloudflare R2, MinIO). Returns upload result to parent component. Use when user invokes /ebm-upload or asks to add file upload, image upload, or document upload.
4
+ ---
5
+
6
+ # /ebm-upload
7
+
8
+ ### Step 1 — Ask (in order)
9
+ ```
10
+ 1. Upload mode: single | multiple
11
+ 2. Feature name: e.g. "avatar", "documents"
12
+ 3. Storage: local | s3
13
+ 4. Allowed types: e.g. "image/jpeg,image/png,application/pdf"
14
+ 5. Max file size: e.g. "5MB"
15
+ ```
16
+
17
+ ### Step 2 — Generate 3 files
18
+ ```
19
+ src/components/upload/[Feature]Uploader.tsx — drag & drop UI + progress + image preview
20
+ src/app/api/upload/[feature]/route.ts — POST endpoint (validate + store)
21
+ src/lib/upload/[feature].config.ts — storage config + validation rules
22
+ ```
23
+
24
+ ### Step 3 — Post-generation
25
+ - If storage = s3: `npm install @aws-sdk/client-s3` + append S3 env vars to `.env.example`
26
+ - Print usage example
27
+
28
+ See [REFERENCE.md](REFERENCE.md) for full templates (local + S3 variants, single + multiple modes).
29
+
30
+ ## Shared rules
31
+ - Response: `{ url, filename, size, mimeType }` — parent component handles saving
32
+ - Path alias: `@/*` → `./src/*` always
33
+ - Thai UI text: use formal Thai — see `/ebm-thai` glossary