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,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
|