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
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MediaAsset {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
altText: string | null
|
||||||
|
}
|
||||||
|
|
||||||
interface ProductForm {
|
interface ProductForm {
|
||||||
typeId: string
|
typeId: string
|
||||||
title: string
|
title: string
|
||||||
@@ -49,12 +55,14 @@ export default function AdminProductEditPage() {
|
|||||||
|
|
||||||
const [productTypes, setProductTypes] = useState<ProductType[]>([])
|
const [productTypes, setProductTypes] = useState<ProductType[]>([])
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
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 [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [success, setSuccess] = useState('')
|
const [success, setSuccess] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load types and categories
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch('/api/admin/product-types').then((r) => r.json()),
|
fetch('/api/admin/product-types').then((r) => r.json()),
|
||||||
fetch('/api/admin/categories').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) : '',
|
stock: p.stock != null ? String(p.stock) : '',
|
||||||
categoryIds: p.categories.map((c: { categoryId: string }) => c.categoryId),
|
categoryIds: p.categories.map((c: { categoryId: string }) => c.categoryId),
|
||||||
})
|
})
|
||||||
|
setImages(p.images || [])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [isNew, params.id])
|
}, [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) {
|
function generateSlug(title: string) {
|
||||||
return title
|
return title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -292,6 +332,50 @@ export default function AdminProductEditPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex gap-3">
|
||||||
<Button type="submit" loading={loading}>
|
<Button type="submit" loading={loading}>
|
||||||
{isNew ? 'Create Product' : 'Save Changes'}
|
{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