Files
goodgo-platform/apps/web/components/listings/image-lightbox.tsx
Ho Ngoc Hai 8ca64e3267 feat(web): add saved searches, image lightbox, and web vitals tracking
New features:
- Saved searches dashboard page with CRUD hooks and API client
- Image lightbox component for property gallery full-screen viewing
- Web vitals provider and reporting utilities for performance monitoring
- Image blur placeholder generation utility

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-11 01:39:22 +07:00

350 lines
11 KiB
TypeScript

'use client';
import Image from 'next/image';
import * as React from 'react';
import type { PropertyMedia } from '@/lib/listings-api';
import { cn } from '@/lib/utils';
// ─── Types ──────────────────────────────────────────────
interface ImageLightboxProps {
images: PropertyMedia[];
initialIndex?: number;
open: boolean;
onClose: () => void;
}
// ─── Swipe hook ─────────────────────────────────────────
function useSwipe(onSwipeLeft: () => void, onSwipeRight: () => void) {
const touchStart = React.useRef<{ x: number; y: number } | null>(null);
const touchEnd = React.useRef<{ x: number; y: number } | null>(null);
const minSwipeDistance = 50;
const onTouchStart = React.useCallback((e: React.TouchEvent) => {
const touch = e.targetTouches[0];
if (!touch) return;
touchEnd.current = null;
touchStart.current = { x: touch.clientX, y: touch.clientY };
}, []);
const onTouchMove = React.useCallback((e: React.TouchEvent) => {
const touch = e.targetTouches[0];
if (!touch) return;
touchEnd.current = { x: touch.clientX, y: touch.clientY };
}, []);
const onTouchEnd = React.useCallback(() => {
if (!touchStart.current || !touchEnd.current) return;
const distanceX = touchStart.current.x - touchEnd.current.x;
const distanceY = Math.abs(touchStart.current.y - touchEnd.current.y);
// Only consider horizontal swipes (ignore vertical scrolling)
if (Math.abs(distanceX) > minSwipeDistance && Math.abs(distanceX) > distanceY) {
if (distanceX > 0) {
onSwipeLeft();
} else {
onSwipeRight();
}
}
}, [onSwipeLeft, onSwipeRight]);
return { onTouchStart, onTouchMove, onTouchEnd };
}
// ─── Focus trap hook ────────────────────────────────────
function useFocusTrap(containerRef: React.RefObject<HTMLDivElement | null>, active: boolean) {
React.useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const focusableSelector =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
// Focus the first focusable element
const firstFocusable = container.querySelector<HTMLElement>(focusableSelector);
firstFocusable?.focus();
function handleKeyDown(e: KeyboardEvent) {
if (e.key !== 'Tab' || !container) return;
const focusableElements = container.querySelectorAll<HTMLElement>(focusableSelector);
if (focusableElements.length === 0) return;
const first = focusableElements[0] as HTMLElement | undefined;
const last = focusableElements[focusableElements.length - 1] as HTMLElement | undefined;
if (!first || !last) return;
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
previouslyFocused?.focus();
};
}, [containerRef, active]);
}
// ─── Component ──────────────────────────────────────────
export function ImageLightbox({ images, initialIndex = 0, open, onClose }: ImageLightboxProps) {
const [currentIndex, setCurrentIndex] = React.useState(initialIndex);
const [isImageLoaded, setIsImageLoaded] = React.useState(false);
const containerRef = React.useRef<HTMLDivElement>(null);
const thumbnailsRef = React.useRef<HTMLDivElement>(null);
// Reset index when lightbox opens with a new initialIndex
React.useEffect(() => {
if (open) {
setCurrentIndex(initialIndex);
setIsImageLoaded(false);
}
}, [open, initialIndex]);
// Lock body scroll
React.useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [open]);
// Focus trap
useFocusTrap(containerRef, open);
// Navigation handlers
const goToPrevious = React.useCallback(() => {
setCurrentIndex((i) => (i > 0 ? i - 1 : images.length - 1));
setIsImageLoaded(false);
}, [images.length]);
const goToNext = React.useCallback(() => {
setCurrentIndex((i) => (i < images.length - 1 ? i + 1 : 0));
setIsImageLoaded(false);
}, [images.length]);
// Keyboard navigation
React.useEffect(() => {
if (!open) return;
function handleKeyDown(e: KeyboardEvent) {
switch (e.key) {
case 'Escape':
onClose();
break;
case 'ArrowLeft':
goToPrevious();
break;
case 'ArrowRight':
goToNext();
break;
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, onClose, goToPrevious, goToNext]);
// Swipe support
const swipeHandlers = useSwipe(goToNext, goToPrevious);
// Scroll active thumbnail into view
React.useEffect(() => {
if (!open || !thumbnailsRef.current) return;
const activeThumb = thumbnailsRef.current.children[currentIndex] as HTMLElement | undefined;
activeThumb?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
}, [currentIndex, open]);
// Preload adjacent images
React.useEffect(() => {
if (!open || images.length <= 1) return;
const preloadIndices = [
(currentIndex + 1) % images.length,
(currentIndex - 1 + images.length) % images.length,
];
preloadIndices.forEach((idx) => {
const imageToPreload = images[idx];
if (!imageToPreload) return;
const img = new window.Image();
img.src = imageToPreload.url;
});
}, [currentIndex, open, images]);
if (!open || images.length === 0) return null;
const currentImage = images[currentIndex];
if (!currentImage) return null;
return (
<div
ref={containerRef}
className="fixed inset-0 z-50 flex flex-col bg-black/95"
role="dialog"
aria-modal="true"
aria-label="Xem ảnh toàn màn hình"
>
{/* Top bar */}
<div className="flex items-center justify-between px-4 py-3">
<p className="text-sm text-white/80">
{currentIndex + 1} / {images.length}
</p>
{currentImage.caption && (
<p className="mx-4 flex-1 truncate text-center text-sm text-white/70">
{currentImage.caption}
</p>
)}
<button
onClick={onClose}
className="rounded-full p-2 text-white/80 transition-colors hover:bg-white/10 hover:text-white"
aria-label="Đóng (Escape)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
{/* Main image area */}
<div
className="relative flex flex-1 items-center justify-center px-12 md:px-20"
{...swipeHandlers}
>
{/* Loading spinner */}
{!isImageLoaded && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white/80" />
</div>
)}
{/* Image */}
<div className="relative h-full w-full">
<Image
key={currentImage.id}
src={currentImage.url}
alt={currentImage.caption || `Ảnh ${currentIndex + 1}`}
fill
sizes="100vw"
className={cn(
'object-contain transition-opacity duration-200',
isImageLoaded ? 'opacity-100' : 'opacity-0',
)}
priority
onLoad={() => setIsImageLoaded(true)}
/>
</div>
{/* Navigation arrows */}
{images.length > 1 && (
<>
<button
onClick={goToPrevious}
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/25 md:left-4"
aria-label="Ảnh trước (phím mũi tên trái)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<button
onClick={goToNext}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/25 md:right-4"
aria-label="Ảnh tiếp (phím mũi tên phải)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m9 18 6-6-6-6" />
</svg>
</button>
</>
)}
</div>
{/* Thumbnail strip */}
{images.length > 1 && (
<div className="border-t border-white/10 px-4 py-3">
<div
ref={thumbnailsRef}
className="flex justify-center gap-2 overflow-x-auto"
role="tablist"
aria-label="Danh sách ảnh thu nhỏ"
>
{images.map((img, index) => (
<button
key={img.id}
role="tab"
aria-selected={index === currentIndex}
aria-label={img.caption || `Ảnh ${index + 1}`}
onClick={() => {
setCurrentIndex(index);
setIsImageLoaded(false);
}}
className={cn(
'relative h-14 w-14 flex-shrink-0 overflow-hidden rounded-md border-2 transition-all md:h-16 md:w-16',
index === currentIndex
? 'border-white ring-1 ring-white/50'
: 'border-transparent opacity-50 hover:opacity-80',
)}
>
<Image
src={img.url}
alt={img.caption || `Thumbnail ${index + 1}`}
fill
sizes="64px"
className="object-cover"
/>
</button>
))}
</div>
</div>
)}
</div>
);
}