Allow owners to delete listing images #14
4 changed files with 133 additions and 7 deletions
|
|
@ -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.
|
||||
|
|
|
|||
71
app/api/listings/[id]/images/[imageId]/route.ts
Normal file
71
app/api/listings/[id]/images/[imageId]/route.ts
Normal 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';
|
||||
|
|
@ -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);
|
||||
|
|
@ -771,6 +804,15 @@ export default function EditListingPage({ params }: { params: { id: string } })
|
|||
{(img.size / 1024).toFixed(0)} KB · {img.mimeType || 'image/jpeg'}
|
||||
</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>
|
||||
|
|
|
|||
12
lib/i18n.ts
12
lib/i18n.ts
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue