feat: add product image upload to admin panel
- Add storage.ts utility (saveImage, deleteImageFile) for local disk operations - Add POST /api/admin/products/[id]/images: validates MIME type and 5MB limit, saves file, creates MediaAsset record - Add DELETE /api/admin/products/[id]/images?imageId=: removes file and DB record - Add Images section to product edit form (hidden for new products until saved) - Display images in square aspect-ratio grid matching storefront display - Support multi-file upload; hover to reveal delete button
This commit is contained in:
@@ -16,6 +16,12 @@ interface Category {
|
||||
name: string
|
||||
}
|
||||
|
||||
interface MediaAsset {
|
||||
id: string
|
||||
url: string
|
||||
altText: string | null
|
||||
}
|
||||
|
||||
interface ProductForm {
|
||||
typeId: string
|
||||
title: string
|
||||
@@ -49,12 +55,14 @@ export default function AdminProductEditPage() {
|
||||
|
||||
const [productTypes, setProductTypes] = useState<ProductType[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [images, setImages] = useState<MediaAsset[]>([])
|
||||
const [imageError, setImageError] = useState('')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
// Load types and categories
|
||||
Promise.all([
|
||||
fetch('/api/admin/product-types').then((r) => r.json()),
|
||||
fetch('/api/admin/categories').then((r) => r.json()),
|
||||
@@ -81,11 +89,43 @@ export default function AdminProductEditPage() {
|
||||
stock: p.stock != null ? String(p.stock) : '',
|
||||
categoryIds: p.categories.map((c: { categoryId: string }) => c.categoryId),
|
||||
})
|
||||
setImages(p.images || [])
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [isNew, params.id])
|
||||
|
||||
async function handleImageUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (!files.length) return
|
||||
setImageError('')
|
||||
setUploading(true)
|
||||
for (const file of files) {
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setImageError(`${file.name} supera il limite di 5MB`)
|
||||
continue
|
||||
}
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
const res = await fetch(`/api/admin/products/${params.id}/images`, { method: 'POST', body: fd })
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
setImageError(data.error || 'Upload fallito')
|
||||
} else {
|
||||
setImages((prev) => [...prev, data.image])
|
||||
}
|
||||
}
|
||||
setUploading(false)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
async function handleImageDelete(imageId: string) {
|
||||
const res = await fetch(`/api/admin/products/${params.id}/images?imageId=${imageId}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
setImages((prev) => prev.filter((img) => img.id !== imageId))
|
||||
}
|
||||
}
|
||||
|
||||
function generateSlug(title: string) {
|
||||
return title
|
||||
.toLowerCase()
|
||||
@@ -292,6 +332,50 @@ export default function AdminProductEditPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isNew && (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
|
||||
<h2 className="font-semibold">Images</h2>
|
||||
|
||||
{images.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{images.map((img) => (
|
||||
<div key={img.id} className="relative group">
|
||||
<img
|
||||
src={img.url}
|
||||
alt={img.altText || ''}
|
||||
className="w-full aspect-square object-cover rounded border border-gray-200"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleImageDelete(img.id)}
|
||||
className="absolute top-1 right-1 bg-red-600 text-white rounded-full w-6 h-6 text-xs hidden group-hover:flex items-center justify-center"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imageError && <p className="text-sm text-red-600">{imageError}</p>}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{uploading ? 'Caricamento...' : 'Aggiungi immagini'}
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
disabled={uploading}
|
||||
onChange={handleImageUpload}
|
||||
className="block text-sm text-gray-600 file:mr-3 file:py-1.5 file:px-3 file:rounded file:border-0 file:text-sm file:font-medium file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200 disabled:opacity-50"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">JPEG, PNG, WebP — max 5MB per file</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" loading={loading}>
|
||||
{isNew ? 'Create Product' : 'Save Changes'}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { saveImage, deleteImageFile } from '@/lib/storage'
|
||||
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
|
||||
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
|
||||
async function requireAdmin() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
|
||||
return user
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
const admin = await requireAdmin()
|
||||
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const product = await prisma.product.findUnique({ where: { id: params.id } })
|
||||
if (!product) return NextResponse.json({ error: 'Product not found' }, { status: 404 })
|
||||
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
if (!file) return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return NextResponse.json({ error: 'Only JPEG, PNG and WebP images are allowed' }, { status: 400 })
|
||||
}
|
||||
if (file.size > MAX_SIZE) {
|
||||
return NextResponse.json({ error: 'File too large (max 5MB)' }, { status: 400 })
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const url = await saveImage(params.id, buffer, file.name)
|
||||
|
||||
const asset = await prisma.mediaAsset.create({
|
||||
data: {
|
||||
productId: params.id,
|
||||
url,
|
||||
altText: product.title,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ image: asset })
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
const admin = await requireAdmin()
|
||||
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const imageId = searchParams.get('imageId')
|
||||
if (!imageId) return NextResponse.json({ error: 'imageId required' }, { status: 400 })
|
||||
|
||||
const asset = await prisma.mediaAsset.findUnique({ where: { id: imageId } })
|
||||
if (!asset || asset.productId !== params.id) {
|
||||
return NextResponse.json({ error: 'Image not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteImageFile(asset.url)
|
||||
} catch {
|
||||
// file may already be missing, continue to delete DB record
|
||||
}
|
||||
|
||||
await prisma.mediaAsset.delete({ where: { id: imageId } })
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { mkdir, writeFile, unlink } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
const UPLOAD_DIR = path.join(process.cwd(), 'public', 'uploads')
|
||||
|
||||
export async function saveImage(
|
||||
productId: string,
|
||||
buffer: Buffer,
|
||||
originalFilename: string
|
||||
): Promise<string> {
|
||||
const dir = path.join(UPLOAD_DIR, productId)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const safeName = originalFilename.replace(/[^a-z0-9._-]/gi, '_').toLowerCase()
|
||||
const filename = `${Date.now()}-${safeName}`
|
||||
await writeFile(path.join(dir, filename), buffer)
|
||||
return `/uploads/${productId}/${filename}`
|
||||
}
|
||||
|
||||
export async function deleteImageFile(url: string): Promise<void> {
|
||||
const filePath = path.join(process.cwd(), 'public', url)
|
||||
await unlink(filePath)
|
||||
}
|
||||
Reference in New Issue
Block a user