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>
350 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|