Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ NEXT_PUBLIC_SENTRY_DSN=your_sentry_dsn
- **[⚡ Lazy Loading](./docs/lazy-loading-system.md)** - Bundle-splitting and lazy-loading patterns
- **[🔒 CSP & Security Headers](./docs/csp.md)** - Content Security Policy configuration
- **[🛡️ Phishing Denylist](./docs/phishing-denylist.md)** - Suspicious-domain protections
- **[🖼️ Gallery](./docs/gallery.md)** - Image gallery validation and translations

### Repository Structure

Expand Down
86 changes: 86 additions & 0 deletions docs/gallery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Gallery

`ImageGallery` (`src/components/property/ImageGallery.tsx`) renders the photos of a
property: one large image, a strip of thumbnails and the `ImageLightbox` that opens
over them. It is rendered by `PropertyDetailServer` from `property.images`.

```tsx
<ImageGallery
images={property.images} // the image list, straight from the API
propertyName={property.name} // used as the alternative text of every image
/>
```

The component is a client component: it holds the lightbox state and the loading
skeleton of the main image.

## Validation

The image list reaches the browser from the API, so it is validated before
anything is rendered. The rules live in `src/lib/galleryValidation.ts` and are
implemented with a zod schema (`galleryImageSchema`) plus a cross entry check for
duplicates:

| Rule | Reason code | What happens |
| --- | --- | --- |
| The entry is text | `notAString` | The entry is skipped |
| The entry is not empty once trimmed | `empty` | The entry is skipped |
| The entry is an `http(s)` URL or a path that starts with `/` | `invalidSource` | The entry is skipped |
| The address was already used | `duplicate` | The entry is skipped |
| The gallery is longer than `GALLERY_MAX_IMAGES` (50) | — | The extra images are not rendered and counted |

`next/image` only accepts an absolute `http(s)` URL or a path that starts with a
slash, so a relative path without the leading slash and a `data:` URL are rejected
before they can turn into a broken image.

Every rejected entry is reported to the user in a `role="status"` notice above the
gallery, together with the reason it was skipped. A gallery whose images are all
invalid shows that notice and the empty state.

`validateGalleryImages` returns the renderable images, the rejected entries with
their reason and the number of images that did not fit into the maximum:

```ts
const { images, issues, droppedCount } = validateGalleryImages(property.images)
```

## Translations

Every string the gallery renders comes from the `gallery` section of the `common`
namespace (`src/locales/<locale>/common.json`). All seven shipped languages carry
the same keys, which the translation completeness test enforces.

| Key | Used for |
| --- | --- |
| `gallery.label` | Accessible name of the gallery |
| `gallery.noImages` | Empty state headline |
| `gallery.noImagesDescription` | Empty state explanation |
| `gallery.viewAll` | Overlay on the main image, pluralised on the image count |
| `gallery.moreImages` | The "more images" tile under the thumbnails |
| `gallery.imageAlt` | Alternative text of a thumbnail |
| `gallery.mainImageAlt` | Alternative text of the main image |
| `gallery.defaultPropertyName` | Fallback when the property has no name |
| `gallery.lightboxLabel` | Accessible name of the lightbox dialog |
| `gallery.lightboxImageAlt` | Alternative text of the lightbox image |
| `gallery.thumbnailAlt` | Alternative text of a lightbox thumbnail |
| `gallery.closeLightbox` | Accessible name of the close button |
| `gallery.previousImage` | Accessible name of the previous button |
| `gallery.nextImage` | Accessible name of the next button |
| `gallery.counter` | The "1 / 3" position indicator |
| `gallery.invalidImagesTitle` | Headline of the validation notice |
| `gallery.imagesDropped` | How many images were not rendered |
| `gallery.issue.*` | The reason an entry was skipped |

Counted strings use the i18next plural form (`key` plus `key_one`), and the base
key is kept as well so languages with more plural categories than English — Arabic
and Hebrew — still resolve to a sentence instead of a raw key.

## Tests

- `src/components/property/__tests__/ImageGallery.test.tsx`: rendering, thumbnails
and the lightbox.
- `src/components/property/__tests__/ImageGallery.validation.test.tsx`: which images
are rendered, which are skipped and what the user is told about it.
- `src/components/property/__tests__/ImageGallery.i18n.test.tsx`: the translated
strings, the plural forms and the key parity between the languages.
- `src/lib/__tests__/galleryValidation.test.ts`: the validation rules themselves.
96 changes: 77 additions & 19 deletions src/components/property/ImageGallery.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
'use client';

import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import Image from 'next/image';
import { useTranslation } from 'react-i18next';
import { ImageLightbox } from './ImageLightbox';
import { ErrorBoundary } from '@/components/ui/SimpleErrorBoundary';
import {
GALLERY_MAX_IMAGES,
normalizeGalleryPropertyName,
validateGalleryImages,
} from '@/lib/galleryValidation';

interface ImageGalleryProps {
images: string[];
Expand All @@ -26,12 +32,25 @@ const ImageGalleryContent: React.FC<ImageGalleryProps> = ({
images,
propertyName
}) => {
const { t } = useTranslation('common');
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
// Issue #1253 — the gallery had no loading state, so an image that was still
// being fetched left an empty grey box. The skeleton is drawn over the
// image until the browser reports it has loaded.
const [mainImageLoaded, setMainImageLoaded] = useState(false);

// Issue #1265 — the image list comes from the API and cannot be trusted, so
// it is validated before anything is rendered. Entries that are broken,
// empty or duplicated are skipped and reported to the user instead of
// turning into broken images.
const validation = useMemo(() => validateGalleryImages(images), [images]);
const galleryImages = validation.images;

// Issue #1264 — the property name is the alternative text of every image,
// so a missing name falls back to a generic, translated label.
const accessibleName =
normalizeGalleryPropertyName(propertyName) || t('gallery.defaultPropertyName');

const openLightbox = (index: number) => {
setLightboxIndex(index);
};
Expand All @@ -40,14 +59,49 @@ const ImageGalleryContent: React.FC<ImageGalleryProps> = ({
setLightboxIndex(null);
};

if (!images || images.length === 0) {
const renderValidationNotice = () => {
const { issues, droppedCount } = validation;
if (issues.length === 0 && droppedCount === 0) return null;

return (
<div className="relative rounded-xl overflow-hidden bg-gray-200 dark:bg-gray-700 h-[400px] flex items-center justify-center">
<div className="text-center">
<svg className="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="text-gray-500 dark:text-gray-400">No images available</p>
<div
role="status"
aria-live="polite"
className="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
>
<p className="font-medium">{t('gallery.invalidImagesTitle')}</p>
{issues.length > 0 && (
<ul className="mt-2 list-disc space-y-1 pl-5">
{issues.map((issue) => (
<li key={issue.index}>
{t(`gallery.issue.${issue.reason}`)}
</li>
))}
</ul>
)}
{droppedCount > 0 && (
<p className="mt-2">
{t('gallery.imagesDropped', { count: droppedCount, max: GALLERY_MAX_IMAGES })}
</p>
)}
</div>
);
};

if (galleryImages.length === 0) {
return (
<div className="space-y-4">
{renderValidationNotice()}
<div className="relative rounded-xl overflow-hidden bg-gray-200 dark:bg-gray-700 h-[400px] flex items-center justify-center">
<div className="text-center">
<svg className="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="text-gray-500 dark:text-gray-400">{t('gallery.noImages')}</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t('gallery.noImagesDescription')}
</p>
</div>
</div>
</div>
);
Expand All @@ -56,11 +110,13 @@ const ImageGalleryContent: React.FC<ImageGalleryProps> = ({
return (
<>
<div className="space-y-4">
{renderValidationNotice()}

{/* Main Image */}
<div className="relative rounded-xl overflow-hidden cursor-pointer group">
<Image
src={images[0]}
alt={propertyName}
src={galleryImages[0]}
alt={t('gallery.mainImageAlt', { property: accessibleName })}
width={800}
height={600}
className="w-full h-[400px] object-cover transition-transform duration-300 group-hover:scale-105"
Expand All @@ -80,15 +136,17 @@ const ImageGalleryContent: React.FC<ImageGalleryProps> = ({
{/* Overlay with image count */}
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-300 flex items-center justify-center">
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-white/90 backdrop-blur-sm px-4 py-2 rounded-lg">
<p className="text-gray-900 font-medium">Click to view all {images.length} images</p>
<p className="text-gray-900 font-medium">
{t('gallery.viewAll', { count: galleryImages.length })}
</p>
</div>
</div>
</div>

{/* Thumbnail Gallery */}
{images.length > 1 && (
{galleryImages.length > 1 && (
<div className="grid grid-cols-4 gap-2">
{images.slice(1, 5).map((image, index) => {
{galleryImages.slice(1, 5).map((image, index) => {
const actualIndex = index + 1;
return (
<div
Expand All @@ -98,15 +156,15 @@ const ImageGalleryContent: React.FC<ImageGalleryProps> = ({
>
<Image
src={image}
alt={`${propertyName} - Image ${actualIndex + 1}`}
alt={t('gallery.imageAlt', { property: accessibleName, index: actualIndex + 1 })}
width={200}
height={150}
className="w-full h-24 object-cover transition-transform duration-300 group-hover:scale-110"
/>

{/* Hover overlay */}
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all duration-300 flex items-center justify-center">
<svg className="w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-all duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
</svg>
</div>
Expand All @@ -115,14 +173,14 @@ const ImageGalleryContent: React.FC<ImageGalleryProps> = ({
})}

{/* Show more indicator if there are more than 5 images */}
{images.length > 5 && (
{galleryImages.length > 5 && (
<div className="relative rounded-lg overflow-hidden bg-gray-200 dark:bg-gray-700 flex items-center justify-center cursor-pointer group">
<div className="text-center">
<div className="text-2xl font-bold text-gray-600 dark:text-gray-400 mb-1">
+{images.length - 5}
+{galleryImages.length - 5}
</div>
<div className="text-xs text-gray-500 dark:text-gray-500">
more
{t('gallery.moreImages', { count: galleryImages.length - 5 })}
</div>
</div>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all duration-300" />
Expand All @@ -134,7 +192,7 @@ const ImageGalleryContent: React.FC<ImageGalleryProps> = ({

{/* Lightbox */}
<ImageLightbox
images={images}
images={galleryImages}
initialIndex={lightboxIndex || 0}
isOpen={lightboxIndex !== null}
onClose={closeLightbox}
Expand Down
Loading