Feat: Mejora de galeria de imagenes
This commit is contained in:
198
Frontend/src/components/PremiumGallery.tsx
Normal file
198
Frontend/src/components/PremiumGallery.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { FaChevronLeft, FaChevronRight, FaExpand, FaTimes, FaArrowLeft } from 'react-icons/fa';
|
||||||
|
|
||||||
|
interface Photo {
|
||||||
|
filePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PremiumGalleryProps {
|
||||||
|
photos: Photo[];
|
||||||
|
isAdActive: boolean;
|
||||||
|
onFavoriteToggle: () => void;
|
||||||
|
isFavorite: boolean;
|
||||||
|
statusBadge: React.ReactNode;
|
||||||
|
featuredBadge: React.ReactNode;
|
||||||
|
locationBadge: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PremiumGallery({
|
||||||
|
photos,
|
||||||
|
isAdActive,
|
||||||
|
onFavoriteToggle,
|
||||||
|
isFavorite,
|
||||||
|
statusBadge,
|
||||||
|
featuredBadge,
|
||||||
|
locationBadge
|
||||||
|
}: PremiumGalleryProps) {
|
||||||
|
const [activePhoto, setActivePhoto] = useState(0);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const touchStartX = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const getImageUrl = (path: string) => {
|
||||||
|
if (!path) return "/placeholder-car.png";
|
||||||
|
return path.startsWith('http') ? path : `${import.meta.env.VITE_STATIC_BASE_URL}${path}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
touchStartX.current = e.touches[0].clientX;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||||
|
if (touchStartX.current === null) return;
|
||||||
|
const touchEndX = e.changedTouches[0].clientX;
|
||||||
|
const diff = touchStartX.current - touchEndX;
|
||||||
|
|
||||||
|
if (Math.abs(diff) > 50) {
|
||||||
|
if (diff > 0) nextPhoto();
|
||||||
|
else prevPhoto();
|
||||||
|
}
|
||||||
|
touchStartX.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextPhoto = () => setActivePhoto((prev) => (prev + 1) % photos.length);
|
||||||
|
const prevPhoto = () => setActivePhoto((prev) => (prev - 1 + photos.length) % photos.length);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (isFullscreen) {
|
||||||
|
if (e.key === 'ArrowRight') nextPhoto();
|
||||||
|
if (e.key === 'ArrowLeft') prevPhoto();
|
||||||
|
if (e.key === 'Escape') setIsFullscreen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isFullscreen]);
|
||||||
|
|
||||||
|
if (!photos || photos.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 select-none">
|
||||||
|
{/* CONTENEDOR PRINCIPAL */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="glass rounded-2xl md:rounded-[3rem] overflow-hidden border border-white/5 relative h-[350px] md:h-[550px] shadow-2xl group bg-black/40 cursor-zoom-in"
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
onClick={() => setIsFullscreen(true)}
|
||||||
|
>
|
||||||
|
<div className="relative w-full h-full overflow-hidden flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
src={getImageUrl(photos[activePhoto].filePath)}
|
||||||
|
className={`max-h-full max-w-full object-contain transition-transform duration-500 ${!isAdActive ? 'grayscale' : ''}`}
|
||||||
|
alt="Vehículo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HUD DE INFO */}
|
||||||
|
<div className="absolute top-4 md:top-8 left-4 md:left-8 flex flex-col items-start gap-2 z-10">
|
||||||
|
{statusBadge}
|
||||||
|
{featuredBadge}
|
||||||
|
{locationBadge}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onFavoriteToggle(); }}
|
||||||
|
className={`absolute top-4 md:top-8 right-4 md:right-8 w-12 h-12 md:w-14 md:h-14 rounded-xl md:rounded-2xl flex items-center justify-center text-xl md:text-2xl transition-all shadow-xl backdrop-blur-xl border z-10 ${isFavorite ? 'bg-red-600 border-red-500/50 text-white' : 'bg-black/40 border-white/10 text-white hover:bg-white hover:text-red-500'}`}
|
||||||
|
>
|
||||||
|
{isFavorite ? '❤️' : '🤍'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{photos.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); prevPhoto(); }}
|
||||||
|
className="absolute left-4 top-1/2 -translate-y-1/2 w-10 md:w-12 h-10 md:h-12 bg-black/40 hover:bg-white hover:text-black backdrop-blur-md rounded-full border border-white/10 flex items-center justify-center transition-all opacity-0 group-hover:opacity-100 translate-x-[-10px] group-hover:translate-x-0 z-10"
|
||||||
|
>
|
||||||
|
<FaChevronLeft />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); nextPhoto(); }}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 w-10 md:w-12 h-10 md:h-12 bg-black/40 hover:bg-white hover:text-black backdrop-blur-md rounded-full border border-white/10 flex items-center justify-center transition-all opacity-0 group-hover:opacity-100 translate-x-[10px] group-hover:translate-x-0 z-10"
|
||||||
|
>
|
||||||
|
<FaChevronRight />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute bottom-6 right-6 opacity-0 group-hover:opacity-100 transition-opacity z-10 hidden md:block">
|
||||||
|
<div className="bg-black/60 backdrop-blur-md p-3 rounded-xl border border-white/10 text-white/70">
|
||||||
|
<FaExpand size={18} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 bg-black/40 backdrop-blur-md px-4 py-1.5 rounded-full border border-white/10 text-[10px] font-black tracking-[0.2em] text-white/80 z-10">
|
||||||
|
{activePhoto + 1} / {photos.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MINIATURAS (THUMBNAILS) */}
|
||||||
|
{photos.length > 1 && (
|
||||||
|
<div className="flex gap-3 md:gap-4 overflow-x-auto pt-4 pb-4 px-1 scrollbar-hide no-scrollbar items-center justify-center">
|
||||||
|
{photos.map((p, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => setActivePhoto(idx)}
|
||||||
|
className={`relative w-24 md:w-32 h-16 md:h-20 rounded-xl md:rounded-2xl overflow-hidden flex-shrink-0 border-2 transition-all duration-300 ${activePhoto === idx ? 'border-blue-500 scale-105 shadow-lg ring-4 ring-blue-500/20' : 'border-white/5 opacity-40 hover:opacity-100 hover:border-white/20'}`}
|
||||||
|
>
|
||||||
|
<img src={getImageUrl(p.filePath)} className="w-full h-full object-cover" alt="" />
|
||||||
|
{activePhoto === idx && <div className="absolute inset-0 bg-blue-500/10 pointer-events-none" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* LIGHTBOX CON PORTAL */}
|
||||||
|
{isFullscreen && createPortal(
|
||||||
|
<div className="fixed inset-0 z-[99999] bg-black/98 backdrop-blur-3xl flex flex-col items-center justify-center animate-fade-in" onClick={() => setIsFullscreen(false)}>
|
||||||
|
|
||||||
|
{/* Botón Cerrar Desktop (X Minimalista) */}
|
||||||
|
<button
|
||||||
|
className="absolute top-8 right-8 text-white/40 hover:text-white transition-all hidden md:flex items-center gap-2 group z-[100000]"
|
||||||
|
onClick={() => setIsFullscreen(false)}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-black tracking-widest opacity-0 group-hover:opacity-100 transition-opacity">CERRAR</span>
|
||||||
|
<FaTimes size={24} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative w-full h-full flex flex-col items-center justify-center p-4 md:p-12"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={getImageUrl(photos[activePhoto].filePath)}
|
||||||
|
className="max-h-[80vh] max-w-full object-contain animate-scale-up shadow-2xl"
|
||||||
|
alt="Vista amplia"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{photos.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); prevPhoto(); }} className="absolute left-4 md:left-12 top-1/2 -translate-y-1/2 w-16 h-16 bg-white/5 hover:bg-white hover:text-black rounded-full border border-white/5 flex items-center justify-center transition-all text-2xl hidden md:flex"><FaChevronLeft /></button>
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); nextPhoto(); }} className="absolute right-4 md:right-12 top-1/2 -translate-y-1/2 w-16 h-16 bg-white/5 hover:bg-white hover:text-black rounded-full border border-white/5 flex items-center justify-center transition-all text-2xl hidden md:flex"><FaChevronRight /></button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info inferior (Contador) */}
|
||||||
|
<div className="mt-8 text-white/30 font-black tracking-[0.4em] text-[10px] uppercase">
|
||||||
|
Foto {activePhoto + 1} de {photos.length}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BOTÓN CERRAR MÓVIL (Píldora ergonómica) */}
|
||||||
|
<button
|
||||||
|
className="mt-12 md:hidden bg-white/10 border border-white/20 text-white px-8 py-4 rounded-full font-black text-xs tracking-widest flex items-center gap-2 hover:bg-white/20 active:scale-95 transition-all shadow-xl"
|
||||||
|
onClick={() => setIsFullscreen(false)}
|
||||||
|
>
|
||||||
|
<FaArrowLeft size={10} /> VOLVER AL AVISO
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,13 +6,13 @@ import ChatModal from '../components/ChatModal';
|
|||||||
import { FaWhatsapp, FaMapMarkerAlt, FaInfoCircle, FaShareAlt } from 'react-icons/fa';
|
import { FaWhatsapp, FaMapMarkerAlt, FaInfoCircle, FaShareAlt } from 'react-icons/fa';
|
||||||
import { AD_STATUSES } from '../constants/adStatuses';
|
import { AD_STATUSES } from '../constants/adStatuses';
|
||||||
import AdStatusBadge from '../components/AdStatusBadge';
|
import AdStatusBadge from '../components/AdStatusBadge';
|
||||||
|
import PremiumGallery from '../components/PremiumGallery';
|
||||||
|
|
||||||
export default function VehiculoDetailPage() {
|
export default function VehiculoDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const [vehicle, setVehicle] = useState<any>(null);
|
const [vehicle, setVehicle] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [activePhoto, setActivePhoto] = useState(0);
|
|
||||||
const [isFavorite, setIsFavorite] = useState(false);
|
const [isFavorite, setIsFavorite] = useState(false);
|
||||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||||
const user = AuthService.getCurrentUser();
|
const user = AuthService.getCurrentUser();
|
||||||
@@ -88,11 +88,6 @@ export default function VehiculoDetailPage() {
|
|||||||
const isAdActive = vehicle.statusID === AD_STATUSES.ACTIVE;
|
const isAdActive = vehicle.statusID === AD_STATUSES.ACTIVE;
|
||||||
const isContactable = isAdActive && !isOwnerAdmin;
|
const isContactable = isAdActive && !isOwnerAdmin;
|
||||||
|
|
||||||
const getImageUrl = (path: string) => {
|
|
||||||
if (!path) return "/placeholder-car.png";
|
|
||||||
return path.startsWith('http') ? path : `${import.meta.env.VITE_STATIC_BASE_URL}${path}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 md:px-6 py-6 md:py-12 animate-fade-in-up">
|
<div className="container mx-auto px-4 md:px-6 py-6 md:py-12 animate-fade-in-up">
|
||||||
<nav className="flex gap-2 text-xs font-bold uppercase tracking-widest text-gray-500 mb-6 md:mb-8 items-center overflow-x-auto whitespace-nowrap">
|
<nav className="flex gap-2 text-xs font-bold uppercase tracking-widest text-gray-500 mb-6 md:mb-8 items-center overflow-x-auto whitespace-nowrap">
|
||||||
@@ -105,37 +100,15 @@ export default function VehiculoDetailPage() {
|
|||||||
|
|
||||||
{/* COLUMNA IZQUIERDA: Galería + Descripción (Desktop) */}
|
{/* COLUMNA IZQUIERDA: Galería + Descripción (Desktop) */}
|
||||||
<div className="lg:col-span-2 space-y-8 md:space-y-12 order-1 lg:order-1">
|
<div className="lg:col-span-2 space-y-8 md:space-y-12 order-1 lg:order-1">
|
||||||
{/* BLOQUE 1: Galería y Fotos */}
|
<PremiumGallery
|
||||||
<div className="space-y-3 md:space-y-4">
|
photos={vehicle.photos}
|
||||||
<div className="glass rounded-2xl md:rounded-[3rem] overflow-hidden border border-white/5 relative h-[300px] md:h-[500px] shadow-2xl group bg-black/40 flex items-center justify-center">
|
isAdActive={isAdActive}
|
||||||
<img
|
isFavorite={isFavorite}
|
||||||
src={getImageUrl(vehicle.photos?.[activePhoto]?.filePath)}
|
onFavoriteToggle={handleFavoriteToggle}
|
||||||
className={`max-h-full max-w-full object-contain transition-all duration-1000 group-hover:scale-105 ${!isAdActive ? 'grayscale' : ''}`}
|
statusBadge={<AdStatusBadge statusId={vehicle.statusID} />}
|
||||||
alt={vehicle.versionName}
|
featuredBadge={vehicle.isFeatured && <span className="bg-gradient-to-r from-blue-600 to-cyan-500 text-white px-3 md:px-4 py-1 md:py-1.5 rounded-full text-[9px] md:text-[10px] font-black uppercase tracking-widest shadow-lg animate-glow flex items-center gap-1">⭐ DESTACADO</span>}
|
||||||
/>
|
locationBadge={vehicle.location && <span className="bg-black/60 backdrop-blur-md text-white px-3 md:px-4 py-1 md:py-1.5 rounded-full text-[9px] md:text-[10px] font-black uppercase tracking-widest border border-white/10 flex items-center gap-1.5"><FaMapMarkerAlt className="text-blue-500" /> {vehicle.location}</span>}
|
||||||
<div className="absolute top-3 md:top-6 left-3 md:left-6 flex flex-col items-start gap-2">
|
/>
|
||||||
<AdStatusBadge statusId={vehicle.statusID} />
|
|
||||||
{vehicle.isFeatured && <span className="bg-gradient-to-r from-blue-600 to-cyan-500 text-white px-3 md:px-4 py-1 md:py-1.5 rounded-full text-[9px] md:text-[10px] font-black uppercase tracking-widest shadow-lg animate-glow flex items-center gap-1">⭐ DESTACADO</span>}
|
|
||||||
{vehicle.location && <span className="bg-black/60 backdrop-blur-md text-white px-3 md:px-4 py-1 md:py-1.5 rounded-full text-[9px] md:text-[10px] font-black uppercase tracking-widest border border-white/10 flex items-center gap-1.5"><FaMapMarkerAlt className="text-blue-500" /> {vehicle.location}</span>}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleFavoriteToggle}
|
|
||||||
className={`absolute top-3 md:top-6 right-3 md:right-6 w-12 h-12 md:w-14 md:h-14 rounded-xl md:rounded-2xl flex items-center justify-center text-xl md:text-2xl transition-all shadow-xl backdrop-blur-xl border ${isFavorite ? 'bg-red-600 border-red-500/50 text-white' : 'bg-black/40 border-white/10 text-white hover:bg-white hover:text-red-500'}`}
|
|
||||||
>
|
|
||||||
{isFavorite ? '❤️' : '🤍'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{vehicle.photos?.length > 1 && (
|
|
||||||
<div className="flex gap-2 md:gap-4 overflow-x-auto pb-2 scrollbar-hide no-scrollbar">
|
|
||||||
{vehicle.photos.map((p: any, idx: number) => (
|
|
||||||
<button key={idx} onClick={() => setActivePhoto(idx)} className={`relative w-24 md:w-28 h-16 md:h-18 rounded-lg md:rounded-2xl overflow-hidden flex-shrink-0 border-2 transition-all ${activePhoto === idx ? 'border-blue-500 scale-105 shadow-lg' : 'border-white/5 opacity-50 hover:opacity-100'}`}>
|
|
||||||
<img src={getImageUrl(p.filePath)} className="w-full h-full object-cover" alt="" />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* BLOQUE 3: Información General y Técnica (Acomodado debajo de la galería) */}
|
{/* BLOQUE 3: Información General y Técnica (Acomodado debajo de la galería) */}
|
||||||
<div className="glass p-6 md:p-12 rounded-2xl md:rounded-[3rem] border border-white/5 relative overflow-hidden group shadow-2xl">
|
<div className="glass p-6 md:p-12 rounded-2xl md:rounded-[3rem] border border-white/5 relative overflow-hidden group shadow-2xl">
|
||||||
|
|||||||
Reference in New Issue
Block a user