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:
2026-05-18 17:54:07 +02:00
parent b3097670c0
commit 676b173414
3 changed files with 178 additions and 1 deletions
+85 -1
View File
@@ -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 })
}
+22
View File
@@ -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)
}