diff --git a/Dockerfile b/Dockerfile index b5c5d7a..f997695 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,8 @@ FROM node:${NODE_VERSION}-bookworm-slim AS builder WORKDIR /app ENV NEXT_TELEMETRY_DISABLED=1 ENV DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder" +ARG APP_VERSION=dev +ENV NEXT_PUBLIC_VERSION=$APP_VERSION COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npx prisma generate diff --git a/PROGRESS.md b/PROGRESS.md index af49ae8..6922d24 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -51,6 +51,12 @@ - Updated docs to fix Mermaid syntax and labels; Mermaid renders cleanly across all pages. - Local Docker cleanup: removed all stale images (including registry.halla-aho.net:443 tags); only current `3a5de63` and `latest` remain. - Listing details: right rail now surfaces quick facts + amenity icons; browse map given fixed height so OpenStreetMap tiles show reliably; footer links to privacy page with version indicator. +- Listing images now stored in DB (binary) with API serving `/api/images/:id`; upload limited to 6 images (5MB each) and seed pulls from `sampleimages/` if present. +- Sample listings flagged via `isSample`, seeded demo listings marked, and UI badges added to identify them. +- Privacy page localized (FI/EN) via i18n. +- Version hash now injected via build arg (`NEXT_PUBLIC_VERSION`) and shown in footer; build scripts updated. +- In-cluster Varnish cache added in Deployment to cache `/api/images/*` and static assets. +- Added `generate_images.py` and committed sample image assets for reseeding/rebuilds. To resume: 1) If desired, render diagrams locally: PlantUML in `docs/plantuml`, draw.io in `docs/drawio`. diff --git a/app/api/images/[id]/route.ts b/app/api/images/[id]/route.ts new file mode 100644 index 0000000..0772ab4 --- /dev/null +++ b/app/api/images/[id]/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '../../../../lib/prisma'; + +export async function GET(_req: Request, { params }: { params: { id: string } }) { + const image = await prisma.listingImage.findUnique({ + where: { id: params.id }, + select: { data: true, mimeType: true, url: true, updatedAt: true }, + }); + + if (!image) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (image.data) { + const res = new NextResponse(image.data, { + status: 200, + headers: { + 'Content-Type': image.mimeType || 'application/octet-stream', + 'Cache-Control': 'public, max-age=86400', + }, + }); + if (image.updatedAt) { + res.headers.set('Last-Modified', image.updatedAt.toUTCString()); + } + return res; + } + + if (image.url) { + return NextResponse.redirect(image.url, { status: 302 }); + } + + return NextResponse.json({ error: 'Image missing' }, { status: 404 }); +} diff --git a/app/api/listings/route.ts b/app/api/listings/route.ts index e5c2ffd..03c444c 100644 --- a/app/api/listings/route.ts +++ b/app/api/listings/route.ts @@ -3,6 +3,11 @@ import { ListingStatus, UserStatus, EvCharging, Prisma } from '@prisma/client'; import { prisma } from '../../../lib/prisma'; import { requireAuth } from '../../../lib/jwt'; import { resolveLocale } from '../../../lib/i18n'; +import { SAMPLE_LISTING_SLUGS } from '../../../lib/sampleListing'; + +const MAX_IMAGES = 6; +const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB per image +const SAMPLE_EMAIL = 'host@lomavuokraus.fi'; function normalizeEvCharging(input?: string | null): EvCharging { const value = String(input ?? 'NONE').toUpperCase(); @@ -11,6 +16,13 @@ function normalizeEvCharging(input?: string | null): EvCharging { return EvCharging.NONE; } +function resolveImageUrl(img: { id: string; url: string | null; size: number | null }) { + if (img.size && img.size > 0) { + return `/api/images/${img.id}`; + } + return img.url ?? null; +} + function pickTranslation(translations: T[], locale: string | null): T | null { if (!translations.length) return null; if (locale) { @@ -54,13 +66,19 @@ export async function GET(req: Request) { where, include: { translations: { select: { id: true, locale: true, title: true, slug: true, teaser: true, description: true } }, - images: { select: { id: true, url: true, altText: true, order: true, isCover: true }, orderBy: { order: 'asc' } }, + images: { select: { id: true, url: true, altText: true, order: true, isCover: true, size: true }, orderBy: { order: 'asc' } }, }, orderBy: { createdAt: 'desc' }, take: Number.isNaN(limit) ? 40 : limit, }); const payload = listings.map((listing) => { + const isSample = + listing.isSample || + listing.contactEmail === SAMPLE_EMAIL || + SAMPLE_LISTING_SLUGS.includes( + pickTranslation(listing.translations, locale)?.slug ?? listing.translations[0]?.slug ?? '', + ); const translation = pickTranslation(listing.translations, locale); const fallback = listing.translations[0]; return { @@ -87,16 +105,15 @@ export async function GET(req: Request) { bedrooms: listing.bedrooms, beds: listing.beds, bathrooms: listing.bathrooms, - priceHintPerNightCents: listing.priceHintPerNightCents, - coverImage: (listing.images.find((img) => img.isCover) ?? listing.images[0])?.url ?? null, + priceHintPerNightEuros: listing.priceHintPerNightEuros, + coverImage: resolveImageUrl(listing.images.find((img) => img.isCover) ?? listing.images[0] ?? { id: '', url: null, size: null }), + isSample, }; }); return NextResponse.json({ listings: payload }); } -const MAX_IMAGES = 10; - export async function POST(req: Request) { try { const auth = await requireAuth(req); @@ -125,13 +142,70 @@ export async function POST(req: Request) { const bedrooms = Number(body.bedrooms ?? 1); const beds = Number(body.beds ?? 1); const bathrooms = Number(body.bathrooms ?? 1); - const priceHintPerNightCents = body.priceHintPerNightCents ? Number(body.priceHintPerNightCents) : null; + const priceHintPerNightEuros = body.priceHintPerNightEuros !== undefined && body.priceHintPerNightEuros !== null && body.priceHintPerNightEuros !== '' ? Math.round(Number(body.priceHintPerNightEuros)) : null; const images = Array.isArray(body.images) ? body.images.slice(0, MAX_IMAGES) : []; + if (Array.isArray(body.images) && body.images.length > MAX_IMAGES) { + return NextResponse.json({ error: `Too many images (max ${MAX_IMAGES})` }, { status: 400 }); + } const coverImageIndex = Math.min(Math.max(Number(body.coverImageIndex ?? 1), 1), images.length || 1); + const parsedImages: { + data?: Buffer; + mimeType?: string | null; + size?: number | null; + url?: string | null; + altText?: string | null; + order: number; + isCover: boolean; + }[] = []; + + for (let idx = 0; idx < images.length; idx += 1) { + const img = images[idx]; + const altText = typeof img.altText === 'string' && img.altText.trim() ? img.altText.trim() : null; + const rawMime = typeof img.mimeType === 'string' ? img.mimeType : null; + const rawData = typeof img.data === 'string' ? img.data : null; + const rawUrl = typeof img.url === 'string' && img.url.trim() ? img.url.trim() : null; + let mimeType = rawMime; + let buffer: Buffer | null = null; + + if (rawData) { + const dataUrlMatch = rawData.match(/^data:(.*?);base64,(.*)$/); + if (dataUrlMatch) { + mimeType = mimeType || dataUrlMatch[1] || null; + buffer = Buffer.from(dataUrlMatch[2], 'base64'); + } else { + buffer = Buffer.from(rawData, 'base64'); + } + } + + const size = buffer ? buffer.length : null; + if (size && size > MAX_IMAGE_BYTES) { + return NextResponse.json({ error: `Image ${idx + 1} is too large (max ${Math.floor(MAX_IMAGE_BYTES / 1024 / 1024)}MB)` }, { status: 400 }); + } + + if (!buffer && !rawUrl) { + continue; + } + + parsedImages.push({ + data: buffer ?? undefined, + mimeType: mimeType || 'image/jpeg', + size, + url: buffer ? null : rawUrl, + altText, + order: idx + 1, + isCover: coverImageIndex === idx + 1, + }); + } + + if (parsedImages.length && !parsedImages.some((img) => img.isCover)) { + parsedImages[0].isCover = true; + } + const autoApprove = process.env.AUTO_APPROVE_LISTINGS === 'true' || auth.role === 'ADMIN'; const status = autoApprove ? ListingStatus.PUBLISHED : ListingStatus.PENDING; + const isSample = contactEmail.toLowerCase() === SAMPLE_EMAIL; const listing = await prisma.listing.create({ data: { @@ -157,12 +231,13 @@ export async function POST(req: Request) { byTheLake: Boolean(body.byTheLake), hasAirConditioning: Boolean(body.hasAirConditioning), evCharging: normalizeEvCharging(body.evCharging), - priceHintPerNightCents, + priceHintPerNightEuros, contactName, contactEmail, contactPhone: body.contactPhone ?? null, externalUrl: body.externalUrl ?? null, published: status === ListingStatus.PUBLISHED, + isSample, translations: { create: { locale, @@ -172,18 +247,13 @@ export async function POST(req: Request) { teaser: body.teaser ?? null, }, }, - images: images.length + images: parsedImages.length ? { - create: images.map((img: any, idx: number) => ({ - url: String(img.url ?? ''), - altText: img.altText ? String(img.altText) : null, - order: idx + 1, - isCover: coverImageIndex === idx + 1, - })), + create: parsedImages, } : undefined, }, - include: { translations: true, images: true }, + include: { translations: true, images: { select: { id: true, altText: true, order: true, isCover: true, size: true, url: true } } }, }); return NextResponse.json({ ok: true, listing }); diff --git a/app/globals.css b/app/globals.css index 30dd59f..5ba6582 100644 --- a/app/globals.css +++ b/app/globals.css @@ -296,6 +296,111 @@ p { font-size: 18px; } +.amenity-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.amenity-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.25); + background: linear-gradient(145deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0.04)); + cursor: pointer; + transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease, background 120ms ease; + color: var(--text); + width: 100%; + text-align: left; +} + +.amenity-option:hover { + border-color: var(--accent); + transform: translateY(-1px); +} + +.amenity-option.selected { + border-color: var(--accent-strong); + box-shadow: 0 12px 36px rgba(14, 165, 233, 0.2); + background: linear-gradient(145deg, rgba(34, 211, 238, 0.08), rgba(14, 165, 233, 0.12)); +} + +.amenity-option-meta { + display: flex; + align-items: center; + gap: 10px; + font-weight: 700; +} + +.amenity-emoji { + font-size: 20px; +} + +.amenity-name { + color: var(--text); +} + +.amenity-check { + width: 26px; + height: 26px; + border-radius: 10px; + border: 1px solid rgba(148, 163, 184, 0.4); + display: grid; + place-items: center; + font-weight: 800; + color: #0b1224; + background: rgba(255, 255, 255, 0.8); +} + +.amenity-ev { + border: 1px dashed rgba(148, 163, 184, 0.35); + border-radius: 12px; + padding: 12px 14px; + display: grid; + gap: 8px; +} + +.amenity-ev-label { + font-weight: 700; + color: var(--muted); +} + +.ev-toggle-group { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.ev-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(148, 163, 184, 0.25); + color: var(--text); + cursor: pointer; + font-weight: 700; + transition: border-color 120ms ease, background 120ms ease, transform 120ms ease; +} + +.ev-toggle:hover { + transform: translateY(-1px); + border-color: var(--accent); +} + +.ev-toggle.active { + background: var(--accent); + color: #0b1224; + border-color: var(--accent-strong); + box-shadow: 0 10px 28px rgba(34, 211, 238, 0.2); +} + .breadcrumb { color: var(--muted); font-size: 14px; diff --git a/app/listings/[slug]/page.tsx b/app/listings/[slug]/page.tsx index b44bf6d..31cc1af 100644 --- a/app/listings/[slug]/page.tsx +++ b/app/listings/[slug]/page.tsx @@ -2,7 +2,8 @@ import type { Metadata } from 'next'; import Link from 'next/link'; import { notFound } from 'next/navigation'; import { cookies, headers } from 'next/headers'; -import { getListingBySlug, DEFAULT_LOCALE } from '../../../lib/listings'; +import { getListingBySlug, DEFAULT_LOCALE, withResolvedListingImages } from '../../../lib/listings'; +import { SAMPLE_LISTING_SLUGS } from '../../../lib/sampleListing'; import { resolveLocale, t as translate } from '../../../lib/i18n'; type ListingPageProps = { @@ -33,13 +34,15 @@ export default async function ListingPage({ params }: ListingPageProps) { const locale = resolveLocale({ cookieLocale: cookieStore.get('locale')?.value, acceptLanguage: headers().get('accept-language') }); const t = (key: any, vars?: Record) => translate(locale, key as any, vars); - const translation = await getListingBySlug({ slug: params.slug, locale: locale ?? DEFAULT_LOCALE }); + const translationRaw = await getListingBySlug({ slug: params.slug, locale: locale ?? DEFAULT_LOCALE }); + const translation = translationRaw ? withResolvedListingImages(translationRaw) : null; if (!translation) { notFound(); } const { listing, title, description, teaser, locale: translationLocale } = translation; + const isSample = listing.isSample || listing.contactEmail === 'host@lomavuokraus.fi' || SAMPLE_LISTING_SLUGS.includes(params.slug); const amenities = [ listing.hasSauna ? { icon: amenityIcons.sauna, label: t('amenitySauna') } : null, listing.hasFireplace ? { icon: amenityIcons.fireplace, label: t('amenityFireplace') } : null, @@ -61,6 +64,11 @@ export default async function ListingPage({ params }: ListingPageProps) {
+ {isSample ? ( +
+ {t('sampleBadge')} +
+ ) : null}

{title}

{teaser ?? description}

{listing.addressNote ? ( @@ -77,14 +85,18 @@ export default async function ListingPage({ params }: ListingPageProps) { ) : null} {listing.images.length > 0 ? (
- {listing.images.map((img) => ( -
- {img.altText - {img.altText ? ( -
{img.altText}
- ) : null} -
- ))} + {listing.images + .filter((img) => Boolean(img.url)) + .map((img) => ( +
+ + {img.altText + + {img.altText ? ( +
{img.altText}
+ ) : null} +
+ ))}
) : null}
diff --git a/app/listings/new/page.tsx b/app/listings/new/page.tsx index 1265e4c..82e3ddb 100644 --- a/app/listings/new/page.tsx +++ b/app/listings/new/page.tsx @@ -4,7 +4,16 @@ import { useEffect, useState } from 'react'; import { useI18n } from '../../components/I18nProvider'; import type { Locale } from '../../../lib/i18n'; -type ImageInput = { url: string; altText?: string }; +type ImageInput = { data: string; mimeType: string; altText?: string }; +type SelectedImage = { + name: string; + size: number; + mimeType: string; + dataUrl: string; +}; + +const MAX_IMAGES = 6; +const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB per image export default function NewListingPage() { const { t, locale: uiLocale } = useI18n(); @@ -34,7 +43,7 @@ export default function NewListingPage() { const [byTheLake, setByTheLake] = useState(false); const [hasAirConditioning, setHasAirConditioning] = useState(false); const [evCharging, setEvCharging] = useState<'NONE' | 'FREE' | 'PAID'>('NONE'); - const [imagesText, setImagesText] = useState(''); + const [selectedImages, setSelectedImages] = useState([]); const [coverImageIndex, setCoverImageIndex] = useState(1); const [message, setMessage] = useState(null); const [error, setError] = useState(null); @@ -53,12 +62,63 @@ export default function NewListingPage() { .catch(() => setIsAuthed(false)); }, []); + function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + resolve({ + name: file.name, + size: file.size, + mimeType: file.type || 'image/jpeg', + dataUrl: String(reader.result), + }); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + } + + async function handleFileChange(e: React.ChangeEvent) { + const files = Array.from(e.target.files ?? []); + setError(null); + setMessage(null); + if (!files.length) return; + + if (files.length > MAX_IMAGES) { + setError(t('imagesTooMany', { count: MAX_IMAGES })); + return; + } + + const tooLarge = files.find((f) => f.size > MAX_IMAGE_BYTES); + if (tooLarge) { + setError(t('imagesTooLarge', { sizeMb: Math.floor(MAX_IMAGE_BYTES / 1024 / 1024) })); + return; + } + + try { + const parsed = await Promise.all(files.map(readFileAsDataUrl)); + setSelectedImages(parsed); + setCoverImageIndex(1); + } catch (err) { + setError(t('imagesReadFailed')); + } + } + + const amenityOptions = [ + { key: 'sauna', label: t('amenitySauna'), icon: '🧖', checked: hasSauna, toggle: setHasSauna }, + { key: 'fireplace', label: t('amenityFireplace'), icon: '🔥', checked: hasFireplace, toggle: setHasFireplace }, + { key: 'wifi', label: t('amenityWifi'), icon: '📶', checked: hasWifi, toggle: setHasWifi }, + { key: 'pets', label: t('amenityPets'), icon: '🐾', checked: petsAllowed, toggle: setPetsAllowed }, + { key: 'lake', label: t('amenityLake'), icon: '🌊', checked: byTheLake, toggle: setByTheLake }, + { key: 'ac', label: t('amenityAirConditioning'), icon: '❄️', checked: hasAirConditioning, toggle: setHasAirConditioning }, + ]; + function parseImages(): ImageInput[] { - return imagesText - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => ({ url: line })); + return selectedImages.map((img) => ({ + data: img.dataUrl, + mimeType: img.mimeType, + altText: img.name.replace(/[-_]/g, ' '), + })); } async function onSubmit(e: React.FormEvent) { @@ -67,6 +127,12 @@ export default function NewListingPage() { setError(null); setLoading(true); try { + if (selectedImages.length === 0) { + setError(t('imagesRequired')); + setLoading(false); + return; + } + const res = await fetch('/api/listings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -89,7 +155,7 @@ export default function NewListingPage() { bedrooms, beds, bathrooms, - priceHintPerNightCents: price === '' ? null : Number(price), + priceHintPerNightEuros: price === '' ? null : Math.round(Number(price)), hasSauna, hasFireplace, hasWifi, @@ -118,7 +184,7 @@ export default function NewListingPage() { setLongitude(''); setContactName(''); setContactEmail(''); - setImagesText(''); + setSelectedImages([]); setCoverImageIndex(1); } } catch (err) { @@ -194,23 +260,55 @@ export default function NewListingPage() {
@@ -223,49 +321,74 @@ export default function NewListingPage() { setLongitude(e.target.value === '' ? '' : Number(e.target.value))} step="0.000001" />
-
- - - - - -