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
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://superbowl:superbowl@127.0.0.1:5432/superbowl?schema=public
|
- DATABASE_URL=postgresql://superbowl:superbowl@127.0.0.1:5432/superbowl?schema=public
|
||||||
|
volumes:
|
||||||
|
- uploads:/app/public/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -25,3 +27,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
uploads:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
mkdir -p /app/public/uploads
|
||||||
|
|
||||||
echo "Running Prisma migrations..."
|
echo "Running Prisma migrations..."
|
||||||
npx prisma db push --skip-generate
|
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 { NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
import { authOptions } from '@/lib/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 path from 'path';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
|
|
||||||
|
/** Absolute path to the user-uploaded images directory */
|
||||||
const UPLOADS_DIR = path.join(process.cwd(), 'public', 'uploads');
|
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');
|
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 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) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
@@ -26,7 +51,10 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
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) {
|
if (file.size > MAX_SIZE) {
|
||||||
@@ -42,6 +70,9 @@ export async function POST(request: Request) {
|
|||||||
const bytes = await file.arrayBuffer();
|
const bytes = await file.arrayBuffer();
|
||||||
const buffer = Buffer.from(bytes);
|
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);
|
await writeFile(path.join(UPLOADS_DIR, fileName), buffer);
|
||||||
|
|
||||||
return NextResponse.json({ url: `/uploads/${fileName}` });
|
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() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
@@ -57,14 +96,14 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const images: { url: string; name: string; category: string }[] = [];
|
const images: { url: string; name: string }[] = [];
|
||||||
|
|
||||||
// List uploaded images
|
// List uploaded images
|
||||||
if (existsSync(UPLOADS_DIR)) {
|
if (existsSync(UPLOADS_DIR)) {
|
||||||
const uploads = await readdir(UPLOADS_DIR);
|
const uploads = await readdir(UPLOADS_DIR);
|
||||||
for (const file of uploads) {
|
for (const file of uploads) {
|
||||||
if (/\.(png|jpe?g|gif|webp|svg)$/i.test(file)) {
|
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);
|
const stock = await readdir(IMAGES_DIR);
|
||||||
for (const file of stock) {
|
for (const file of stock) {
|
||||||
if (/\.(png|jpe?g|gif|webp|svg)$/i.test(file)) {
|
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 });
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
@@ -7,12 +18,14 @@ import { Modal } from './Modal';
|
|||||||
interface GalleryImage {
|
interface GalleryImage {
|
||||||
url: string;
|
url: string;
|
||||||
name: string;
|
name: string;
|
||||||
category: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImagePickerProps {
|
interface ImagePickerProps {
|
||||||
|
/** Field label displayed above the picker */
|
||||||
label: string;
|
label: string;
|
||||||
|
/** Currently selected image URL */
|
||||||
value: string;
|
value: string;
|
||||||
|
/** Callback when the selected image changes */
|
||||||
onChange: (url: string) => void;
|
onChange: (url: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,10 +34,20 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
|
|||||||
const [gallery, setGallery] = useState<GalleryImage[]>([]);
|
const [gallery, setGallery] = useState<GalleryImage[]>([]);
|
||||||
const [galleryLoading, setGalleryLoading] = useState(false);
|
const [galleryLoading, setGalleryLoading] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [filter, setFilter] = useState<'all' | 'uploads' | 'stock'>('all');
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
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];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
@@ -46,17 +69,25 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
|
|||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
onChange(data.url);
|
onChange(data.url);
|
||||||
|
|
||||||
|
// If uploaded from inside the gallery modal, refresh the list
|
||||||
|
// so the new image appears immediately
|
||||||
|
if (fromGallery) {
|
||||||
|
await fetchGallery();
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert('Upload failed');
|
alert('Upload failed');
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
if (galleryFileInputRef.current) galleryFileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openGallery = async () => {
|
/**
|
||||||
setGalleryOpen(true);
|
* Fetches the full image list from GET /api/upload and updates local state.
|
||||||
setGalleryLoading(true);
|
*/
|
||||||
|
const fetchGallery = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/upload');
|
const res = await fetch('/api/upload');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -64,15 +95,54 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
|
|||||||
setGallery(data.images || []);
|
setGallery(data.images || []);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// Silently fail — the gallery will show as empty
|
||||||
} finally {
|
|
||||||
setGalleryLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredGallery = filter === 'all'
|
/**
|
||||||
? gallery
|
* Opens the gallery modal and loads the image list.
|
||||||
: gallery.filter((img) => img.category === filter);
|
*/
|
||||||
|
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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -82,12 +152,19 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
|
|||||||
{/* Thumbnail preview */}
|
{/* 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">
|
<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 ? (
|
{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>
|
<span className="text-gray-600 text-xs">None</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* URL text input */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -99,6 +176,7 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -126,62 +204,87 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hidden file input for inline upload */}
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
|
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
|
||||||
onChange={handleUpload}
|
onChange={(e) => handleUpload(e)}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Gallery Modal */}
|
||||||
<Modal open={galleryOpen} onClose={() => setGalleryOpen(false)} title="Image Gallery">
|
<Modal open={galleryOpen} onClose={() => setGalleryOpen(false)} title="Image Gallery">
|
||||||
{/* Filter tabs */}
|
{/* Upload button inside modal */}
|
||||||
<div className="flex gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
{(['all', 'uploads', 'stock'] as const).map((f) => (
|
|
||||||
<button
|
<button
|
||||||
key={f}
|
type="button"
|
||||||
onClick={() => setFilter(f)}
|
onClick={() => galleryFileInputRef.current?.click()}
|
||||||
className={`text-xs px-3 py-1 rounded-full transition-colors ${
|
disabled={uploading}
|
||||||
filter === f
|
className="btn-secondary text-xs py-1 px-3"
|
||||||
? 'bg-primary-600 text-white'
|
|
||||||
: 'bg-gray-800 text-gray-400 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{f === 'all' ? 'All' : f === 'uploads' ? 'Uploads' : 'Stock'}
|
{uploading ? 'Uploading...' : 'Upload Image'}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
{/* Image grid */}
|
||||||
{galleryLoading ? (
|
{galleryLoading ? (
|
||||||
<p className="text-gray-500 text-sm text-center py-8">Loading...</p>
|
<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>
|
<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">
|
<div className="grid grid-cols-4 gap-2 max-h-96 overflow-y-auto">
|
||||||
{filteredGallery.map((img) => (
|
{gallery.map((img) => (
|
||||||
<button
|
<div
|
||||||
key={img.url}
|
key={img.url}
|
||||||
|
className="relative group"
|
||||||
|
>
|
||||||
|
{/* Clickable image tile */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange(img.url);
|
onChange(img.url);
|
||||||
setGalleryOpen(false);
|
setGalleryOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`p-1 rounded-lg border transition-all hover:border-primary-500 ${
|
className={`w-full p-1 rounded-lg border transition-all hover:border-primary-500 ${
|
||||||
value === img.url
|
value === img.url
|
||||||
? 'border-primary-500 bg-primary-900/30'
|
? 'border-primary-500 bg-primary-900/30'
|
||||||
: 'border-gray-700 bg-gray-800'
|
: 'border-gray-700 bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Image
|
{/* Use regular <img> to avoid Next.js optimization issues with dynamic uploads */}
|
||||||
|
<img
|
||||||
src={img.url}
|
src={img.url}
|
||||||
alt={img.name}
|
alt={img.name}
|
||||||
width={80}
|
|
||||||
height={80}
|
|
||||||
className="w-full aspect-square object-contain rounded"
|
className="w-full aspect-square object-contain rounded"
|
||||||
/>
|
/>
|
||||||
<span className="text-[9px] text-gray-500 truncate block mt-0.5">
|
<span className="text-[9px] text-gray-500 truncate block mt-0.5">
|
||||||
{img.name}
|
{img.name}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+1
-1
@@ -53,6 +53,6 @@ export default withAuth(
|
|||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
'/((?!_next/static|_next/image|favicon.ico|images).*)',
|
'/((?!_next/static|_next/image|favicon.ico|images|uploads).*)',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user