Fix image upload gallery: previews, delete, persistence
- Fix uploaded images showing as 404: /uploads/ was not excluded from the auth middleware matcher, so browsers were blocked from loading images. Added uploads to the matcher exclusion alongside images. - Ensure uploads directory exists at startup (mkdir -p in entrypoint + recursive mkdir in the upload POST handler). - Add DELETE /api/upload?url=... endpoint for admins to delete any image. - Simplify gallery to a single unified view (no stock/uploads filter tabs). Each image tile shows a red × delete button on hover with confirmation. Upload Image button also available inside the gallery modal. - Add Docker named volume for /app/public/uploads so uploaded images persist across container restarts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@ services:
|
||||
env_file: .env
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://superbowl:superbowl@127.0.0.1:5432/superbowl?schema=public
|
||||
volumes:
|
||||
- uploads:/app/public/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -25,3 +27,4 @@ services:
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
uploads:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
mkdir -p /app/public/uploads
|
||||
|
||||
echo "Running Prisma migrations..."
|
||||
npx prisma db push --skip-generate
|
||||
|
||||
|
||||
@@ -1,16 +1,41 @@
|
||||
/**
|
||||
* Upload API Route
|
||||
*
|
||||
* Handles image uploads, gallery listing, and image deletion.
|
||||
*
|
||||
* POST - Upload a new image (admin only)
|
||||
* GET - List all images from uploads/ and images/ as a flat array (admin only)
|
||||
* DELETE - Delete an image by URL query param (admin only)
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { writeFile, readdir } from 'fs/promises';
|
||||
import { writeFile, readdir, mkdir, unlink } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
/** Absolute path to the user-uploaded images directory */
|
||||
const UPLOADS_DIR = path.join(process.cwd(), 'public', 'uploads');
|
||||
|
||||
/** Absolute path to the stock images directory */
|
||||
const IMAGES_DIR = path.join(process.cwd(), 'public', 'images');
|
||||
|
||||
/** Allowed MIME types for upload validation (checked server-side) */
|
||||
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'];
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
/** Maximum upload file size in bytes (5 MB) */
|
||||
const MAX_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* POST /api/upload
|
||||
*
|
||||
* Accepts a multipart form upload with a single 'file' field.
|
||||
* Validates MIME type and file size server-side, sanitizes the filename,
|
||||
* appends a timestamp to prevent collisions, and writes to the uploads directory.
|
||||
*
|
||||
* @returns {{ url: string }} The public URL path of the uploaded file
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
@@ -26,7 +51,10 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return NextResponse.json({ error: 'Invalid file type. Allowed: PNG, JPEG, GIF, WebP, SVG' }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid file type. Allowed: PNG, JPEG, GIF, WebP, SVG' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size > MAX_SIZE) {
|
||||
@@ -42,6 +70,9 @@ export async function POST(request: Request) {
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
// Ensure the uploads directory exists before writing
|
||||
await mkdir(UPLOADS_DIR, { recursive: true });
|
||||
|
||||
await writeFile(path.join(UPLOADS_DIR, fileName), buffer);
|
||||
|
||||
return NextResponse.json({ url: `/uploads/${fileName}` });
|
||||
@@ -50,6 +81,14 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/upload
|
||||
*
|
||||
* Returns a flat array of all available images (both user uploads and stock)
|
||||
* without any category distinction. Each entry contains { url, name }.
|
||||
*
|
||||
* @returns {{ images: Array<{ url: string, name: string }> }}
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
@@ -57,14 +96,14 @@ export async function GET() {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
const images: { url: string; name: string; category: string }[] = [];
|
||||
const images: { url: string; name: string }[] = [];
|
||||
|
||||
// List uploaded images
|
||||
if (existsSync(UPLOADS_DIR)) {
|
||||
const uploads = await readdir(UPLOADS_DIR);
|
||||
for (const file of uploads) {
|
||||
if (/\.(png|jpe?g|gif|webp|svg)$/i.test(file)) {
|
||||
images.push({ url: `/uploads/${file}`, name: file, category: 'uploads' });
|
||||
images.push({ url: `/uploads/${file}`, name: file });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,7 +113,7 @@ export async function GET() {
|
||||
const stock = await readdir(IMAGES_DIR);
|
||||
for (const file of stock) {
|
||||
if (/\.(png|jpe?g|gif|webp|svg)$/i.test(file)) {
|
||||
images.push({ url: `/images/${file}`, name: file, category: 'stock' });
|
||||
images.push({ url: `/images/${file}`, name: file });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,3 +123,55 @@ export async function GET() {
|
||||
return NextResponse.json({ error: 'Failed to list images' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/upload?url=/uploads/filename.png
|
||||
*
|
||||
* Deletes an image file from disk. Only allows deletion of files within
|
||||
* /uploads/ or /images/ directories. Uses path.basename() to prevent
|
||||
* path traversal attacks.
|
||||
*
|
||||
* @param request - Must include a `url` query parameter (e.g., /uploads/foo.png)
|
||||
* @returns {{ success: true }} on success, or an error response
|
||||
*/
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const url = searchParams.get('url');
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Only allow deletion from known image directories
|
||||
if (!url.startsWith('/uploads/') && !url.startsWith('/images/')) {
|
||||
return NextResponse.json({ error: 'Invalid image path' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Extract only the filename to prevent path traversal (e.g., ../../etc/passwd)
|
||||
const fileName = path.basename(url);
|
||||
|
||||
// Determine the target directory based on the URL prefix
|
||||
const targetDir = url.startsWith('/uploads/') ? UPLOADS_DIR : IMAGES_DIR;
|
||||
const filePath = path.join(targetDir, fileName);
|
||||
|
||||
// Verify the resolved path is still within the expected directory
|
||||
if (!filePath.startsWith(targetDir)) {
|
||||
return NextResponse.json({ error: 'Invalid file path' }, { status: 400 });
|
||||
}
|
||||
|
||||
await unlink(filePath);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'ENOENT') {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Delete failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
/**
|
||||
* ImagePicker Component
|
||||
*
|
||||
* Provides a complete image selection UI with:
|
||||
* - Thumbnail preview of the currently selected image
|
||||
* - Text input for manual URL entry
|
||||
* - Direct upload button
|
||||
* - Gallery modal with unified view of all images (uploads + stock)
|
||||
* - In-gallery upload and per-image delete with confirmation
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
@@ -7,12 +18,14 @@ import { Modal } from './Modal';
|
||||
interface GalleryImage {
|
||||
url: string;
|
||||
name: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface ImagePickerProps {
|
||||
/** Field label displayed above the picker */
|
||||
label: string;
|
||||
/** Currently selected image URL */
|
||||
value: string;
|
||||
/** Callback when the selected image changes */
|
||||
onChange: (url: string) => void;
|
||||
}
|
||||
|
||||
@@ -21,10 +34,20 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
|
||||
const [gallery, setGallery] = useState<GalleryImage[]>([]);
|
||||
const [galleryLoading, setGalleryLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [filter, setFilter] = useState<'all' | 'uploads' | 'stock'>('all');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const galleryFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
/**
|
||||
* Handles file upload from either the inline button or the gallery modal.
|
||||
* Sends the file to POST /api/upload and updates the selected value on success.
|
||||
*
|
||||
* @param e - File input change event
|
||||
* @param fromGallery - If true, refreshes the gallery list after upload
|
||||
*/
|
||||
const handleUpload = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
fromGallery = false
|
||||
) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
@@ -46,17 +69,25 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
|
||||
|
||||
const data = await res.json();
|
||||
onChange(data.url);
|
||||
|
||||
// If uploaded from inside the gallery modal, refresh the list
|
||||
// so the new image appears immediately
|
||||
if (fromGallery) {
|
||||
await fetchGallery();
|
||||
}
|
||||
} catch {
|
||||
alert('Upload failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
if (galleryFileInputRef.current) galleryFileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const openGallery = async () => {
|
||||
setGalleryOpen(true);
|
||||
setGalleryLoading(true);
|
||||
/**
|
||||
* Fetches the full image list from GET /api/upload and updates local state.
|
||||
*/
|
||||
const fetchGallery = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/upload');
|
||||
if (res.ok) {
|
||||
@@ -64,15 +95,54 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
|
||||
setGallery(data.images || []);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setGalleryLoading(false);
|
||||
// Silently fail — the gallery will show as empty
|
||||
}
|
||||
};
|
||||
|
||||
const filteredGallery = filter === 'all'
|
||||
? gallery
|
||||
: gallery.filter((img) => img.category === filter);
|
||||
/**
|
||||
* Opens the gallery modal and loads the image list.
|
||||
*/
|
||||
const openGallery = async () => {
|
||||
setGalleryOpen(true);
|
||||
setGalleryLoading(true);
|
||||
await fetchGallery();
|
||||
setGalleryLoading(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes an image via DELETE /api/upload?url=... after user confirmation.
|
||||
* Removes the image from local gallery state on success. If the deleted
|
||||
* image was the currently selected value, clears the selection.
|
||||
*
|
||||
* @param imageUrl - The URL path of the image to delete (e.g., /uploads/foo.png)
|
||||
*/
|
||||
const handleDelete = async (imageUrl: string) => {
|
||||
if (!window.confirm(`Delete this image?\n${imageUrl}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/upload?url=${encodeURIComponent(imageUrl)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
alert(data.error || 'Delete failed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove from local state without re-fetching
|
||||
setGallery((prev) => prev.filter((img) => img.url !== imageUrl));
|
||||
|
||||
// Clear selection if the deleted image was active
|
||||
if (value === imageUrl) {
|
||||
onChange('');
|
||||
}
|
||||
} catch {
|
||||
alert('Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -82,12 +152,19 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
|
||||
{/* Thumbnail preview */}
|
||||
<div className="w-14 h-14 rounded-lg bg-gray-800 border border-gray-700/60 flex items-center justify-center overflow-hidden flex-shrink-0">
|
||||
{value ? (
|
||||
<Image src={value} alt={label} width={56} height={56} className="object-contain w-full h-full" />
|
||||
<Image
|
||||
src={value}
|
||||
alt={label}
|
||||
width={56}
|
||||
height={56}
|
||||
className="object-contain w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-gray-600 text-xs">None</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* URL text input */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
@@ -99,6 +176,7 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -126,62 +204,87 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hidden file input for inline upload */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
|
||||
onChange={handleUpload}
|
||||
onChange={(e) => handleUpload(e)}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Gallery Modal */}
|
||||
<Modal open={galleryOpen} onClose={() => setGalleryOpen(false)} title="Image Gallery">
|
||||
{/* Filter tabs */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{(['all', 'uploads', 'stock'] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`text-xs px-3 py-1 rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-800 text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'All' : f === 'uploads' ? 'Uploads' : 'Stock'}
|
||||
</button>
|
||||
))}
|
||||
{/* Upload button inside modal */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => galleryFileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="btn-secondary text-xs py-1 px-3"
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Upload Image'}
|
||||
</button>
|
||||
<input
|
||||
ref={galleryFileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
|
||||
onChange={(e) => handleUpload(e, true)}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Image grid */}
|
||||
{galleryLoading ? (
|
||||
<p className="text-gray-500 text-sm text-center py-8">Loading...</p>
|
||||
) : filteredGallery.length === 0 ? (
|
||||
) : gallery.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm text-center py-8">No images found</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 gap-2 max-h-80 overflow-y-auto">
|
||||
{filteredGallery.map((img) => (
|
||||
<button
|
||||
<div className="grid grid-cols-4 gap-2 max-h-96 overflow-y-auto">
|
||||
{gallery.map((img) => (
|
||||
<div
|
||||
key={img.url}
|
||||
onClick={() => {
|
||||
onChange(img.url);
|
||||
setGalleryOpen(false);
|
||||
}}
|
||||
className={`p-1 rounded-lg border transition-all hover:border-primary-500 ${
|
||||
value === img.url
|
||||
? 'border-primary-500 bg-primary-900/30'
|
||||
: 'border-gray-700 bg-gray-800'
|
||||
}`}
|
||||
className="relative group"
|
||||
>
|
||||
<Image
|
||||
src={img.url}
|
||||
alt={img.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="w-full aspect-square object-contain rounded"
|
||||
/>
|
||||
<span className="text-[9px] text-gray-500 truncate block mt-0.5">
|
||||
{img.name}
|
||||
</span>
|
||||
</button>
|
||||
{/* Clickable image tile */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(img.url);
|
||||
setGalleryOpen(false);
|
||||
}}
|
||||
className={`w-full p-1 rounded-lg border transition-all hover:border-primary-500 ${
|
||||
value === img.url
|
||||
? 'border-primary-500 bg-primary-900/30'
|
||||
: 'border-gray-700 bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{/* Use regular <img> to avoid Next.js optimization issues with dynamic uploads */}
|
||||
<img
|
||||
src={img.url}
|
||||
alt={img.name}
|
||||
className="w-full aspect-square object-contain rounded"
|
||||
/>
|
||||
<span className="text-[9px] text-gray-500 truncate block mt-0.5">
|
||||
{img.name}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Delete button — appears on hover in top-right corner */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(img.url);
|
||||
}}
|
||||
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-red-600 text-white text-xs
|
||||
flex items-center justify-center opacity-0 group-hover:opacity-100
|
||||
transition-opacity hover:bg-red-500"
|
||||
title="Delete image"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
+1
-1
@@ -53,6 +53,6 @@ export default withAuth(
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next/static|_next/image|favicon.ico|images).*)',
|
||||
'/((?!_next/static|_next/image|favicon.ico|images|uploads).*)',
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user