Allow owners to delete listing images #14

Merged
thalla merged 1 commit from feature/listing-image-delete into master 2025-12-20 12:47:39 +02:00
4 changed files with 133 additions and 7 deletions

View file

@ -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.

View file

@ -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';

View file

@ -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<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
setError(null);
@ -770,13 +803,22 @@ export default function EditListingPage({ params }: { params: { id: string } })
<div style={{ fontSize: 12, color: '#cbd5e1' }}>
{(img.size / 1024).toFixed(0)} KB · {img.mimeType || 'image/jpeg'}
</div>
<div style={{ fontSize: 12, marginTop: 4 }}>{t('coverChoice', { index: idx + 1 })}</div>
</div>
))}
</div>
) : (
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('imagesHelp', { count: MAX_IMAGES, sizeMb: Math.floor(MAX_IMAGE_BYTES / 1024 / 1024) })}</div>
)}
<div style={{ fontSize: 12, marginTop: 4 }}>{t('coverChoice', { index: idx + 1 })}</div>
{img.isExisting ? <div style={{ fontSize: 12, color: '#94a3b8' }}>{t('existingImageLabel')}</div> : null}
<div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
<button type="button" className="button secondary" onClick={() => setCoverImageIndex(idx + 1)}>
{t('makeCover')}
</button>
<button type="button" className="button secondary" onClick={() => removeImageAt(idx)}>
{t('remove')}
</button>
</div>
</div>
))}
</div>
) : (
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('imagesHelp', { count: MAX_IMAGES, sizeMb: Math.floor(MAX_IMAGE_BYTES / 1024 / 1024) })}</div>
)}
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<button className="button secondary" type="button" disabled={loading} onClick={(e) => submitListing(true, e)}>
{loading ? t('saving') : t('saveDraft')}

View file

@ -244,6 +244,10 @@ const baseMessages = {
coverImageLabel: 'Cover image order',
coverImageHelp: '1-based index of the uploaded images (defaults to 1)',
coverChoice: 'Cover position {index}',
makeCover: 'Make cover',
existingImageLabel: 'Existing image',
imageRemoveLastError: 'Add another image before removing the last one.',
imageRemoveFailed: 'Failed to remove image.',
submitListing: 'Create listing',
submittingListing: 'Submitting…',
createListingSuccess: 'Listing created with id {id} (status: {status})',
@ -573,6 +577,10 @@ const baseMessages = {
coverImageLabel: 'Kansikuvan järjestys',
coverImageHelp: 'Monettako ladatuista kuvista käytetään kansikuvana (oletus 1)',
coverChoice: 'Kansipaikka {index}',
makeCover: 'Aseta kansikuvaksi',
existingImageLabel: 'Nykyinen kuva',
imageRemoveLastError: 'Lisää toinen kuva ennen viimeisen poistamista.',
imageRemoveFailed: 'Kuvan poistaminen epäonnistui.',
submitListing: 'Luo kohde',
submittingListing: 'Lähetetään…',
createListingSuccess: 'Kohde luotu id:llä {id} (tila: {status})',
@ -750,6 +758,10 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
priceStartingFromShort: 'Från {price}€ / natt',
priceWeekdayShort: '{price}€ vardag',
priceWeekendShort: '{price}€ helg',
makeCover: 'Gör till omslag',
existingImageLabel: 'Befintlig bild',
imageRemoveLastError: 'Lägg till en annan bild innan du tar bort den sista.',
imageRemoveFailed: 'Det gick inte att ta bort bilden.',
priceNotSet: 'Ej angivet',
listingPrices: 'Pris (från)',
listingContact: 'Kontakt',