diff --git a/PROGRESS.md b/PROGRESS.md index 3b73a29..0f0bf0e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -78,6 +78,7 @@ - Added Swedish locale support across the app, language selector is now a flag dropdown (FI/SV/EN), and the new listing form/AI helper handle all three languages. - Site navbar now shows the new logo above the lomavuokraus.fi brand text on every page. - Language selector in the navbar aligned with other buttons and given higher-contrast styling. +- Listing edit page now lets owners delete individual images (with cover/order preserved), and a protected API endpoint handles image removal. - Security hardening: npm audit now passes cleanly after upgrading Prisma patch release and pinning `glob@10.5.0` via overrides to eliminate the glob CLI injection advisory in eslint tooling. - Listings now capture separate weekday/weekend prices and new amenities (microwave, free parking) across schema, API, UI, and seeds. - Deployed pricing/amenity update image `registry.halla-aho.net/thalla/lomavuokraus-web:bee691e` to staging and production. diff --git a/app/api/listings/[id]/images/[imageId]/route.ts b/app/api/listings/[id]/images/[imageId]/route.ts new file mode 100644 index 0000000..10fc59c --- /dev/null +++ b/app/api/listings/[id]/images/[imageId]/route.ts @@ -0,0 +1,71 @@ +import { Role } from '@prisma/client'; +import { NextResponse } from 'next/server'; +import { prisma } from '../../../../../../lib/prisma'; +import { requireAuth } from '../../../../../../lib/jwt'; + +export async function DELETE(req: Request, { params }: { params: { id: string; imageId: string } }) { + try { + const auth = await requireAuth(req); + const listing = await prisma.listing.findUnique({ + where: { id: params.id, removedAt: null }, + select: { + id: true, + ownerId: true, + status: true, + images: { orderBy: { order: 'asc' }, select: { id: true, isCover: true, order: true } }, + }, + }); + + if (!listing) { + return NextResponse.json({ error: 'Listing not found' }, { status: 404 }); + } + + const isOwner = listing.ownerId === auth.userId; + const isAdmin = auth.role === Role.ADMIN; + if (!isOwner && !isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const targetImage = listing.images.find((img) => img.id === params.imageId); + if (!targetImage) { + return NextResponse.json({ error: 'Image not found' }, { status: 404 }); + } + + if (listing.images.length <= 1) { + return NextResponse.json({ error: 'At least one image is required' }, { status: 400 }); + } + + const remaining = listing.images.filter((img) => img.id !== params.imageId); + const newCoverId = remaining.find((img) => img.isCover)?.id ?? remaining[0]?.id ?? null; + + await prisma.$transaction([ + prisma.listingImage.delete({ where: { id: params.imageId } }), + ...remaining.map((img, idx) => + prisma.listingImage.update({ + where: { id: img.id }, + data: { order: idx + 1, isCover: newCoverId ? img.id === newCoverId : img.isCover }, + }), + ), + ]); + + const updated = await prisma.listing.findUnique({ + where: { id: listing.id }, + select: { + images: { + orderBy: { order: 'asc' }, + select: { id: true, url: true, altText: true, order: true, isCover: true, size: true, mimeType: true }, + }, + }, + }); + + return NextResponse.json({ ok: true, images: updated?.images ?? [] }); + } catch (error: any) { + console.error('Delete listing image error', error); + if (String(error).includes('Unauthorized')) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + return NextResponse.json({ error: 'Failed to delete image' }, { status: 500 }); + } +} + +export const dynamic = 'force-dynamic'; diff --git a/app/listings/edit/[id]/page.tsx b/app/listings/edit/[id]/page.tsx index 5c90ade..f76de2a 100644 --- a/app/listings/edit/[id]/page.tsx +++ b/app/listings/edit/[id]/page.tsx @@ -8,6 +8,7 @@ import type { Locale } from '../../../../lib/i18n'; type ImageInput = { data?: string; url?: string; mimeType?: string; altText?: string }; type SelectedImage = { + id?: string; name: string; size: number; mimeType: string; @@ -148,6 +149,7 @@ export default function EditListingPage({ params }: { params: { id: string } }) setCoverImageIndex(coverIdx); setSelectedImages( listing.images.map((img: any) => ({ + id: img.id, name: img.altText || img.url || `image-${img.id}`, size: img.size || 0, mimeType: img.mimeType || 'image/jpeg', @@ -233,6 +235,37 @@ export default function EditListingPage({ params }: { params: { id: string } }) }); } + async function removeImageAt(index: number) { + const img = selectedImages[index]; + setError(null); + setMessage(null); + const remainingExisting = selectedImages.filter((i) => i.isExisting).length; + const hasNewImages = selectedImages.some((i, idx) => idx !== index && !i.isExisting); + + if (img?.isExisting && remainingExisting <= 1 && !hasNewImages) { + setError(t('imageRemoveLastError')); + return; + } + + if (img?.isExisting && img.id && remainingExisting > 1) { + try { + const res = await fetch(`/api/listings/${params.id}/images/${img.id}`, { method: 'DELETE' }); + const data = await res.json(); + if (!res.ok) { + setError(data.error || t('imageRemoveFailed')); + return; + } + } catch (err) { + setError(t('imageRemoveFailed')); + return; + } + } + + const next = selectedImages.filter((_, idx) => idx !== index); + setSelectedImages(next); + setCoverImageIndex(next.length ? Math.min(coverImageIndex, next.length) : 1); + } + async function handleFileChange(e: React.ChangeEvent) { const files = Array.from(e.target.files ?? []); setError(null); @@ -770,13 +803,22 @@ export default function EditListingPage({ params }: { params: { id: string } })
{(img.size / 1024).toFixed(0)} KB · {img.mimeType || 'image/jpeg'}
-
{t('coverChoice', { index: idx + 1 })}
- - ))} - - ) : ( -
{t('imagesHelp', { count: MAX_IMAGES, sizeMb: Math.floor(MAX_IMAGE_BYTES / 1024 / 1024) })}
- )} +
{t('coverChoice', { index: idx + 1 })}
+ {img.isExisting ?
{t('existingImageLabel')}
: null} +
+ + +
+ + ))} + + ) : ( +
{t('imagesHelp', { count: MAX_IMAGES, sizeMb: Math.floor(MAX_IMAGE_BYTES / 1024 / 1024) })}
+ )}