- VideoPlayer: auto-detects YouTube/Vimeo embed or direct video file - PhotoCarousel: accepts videoUrl prop, shows video as first slide with play icon thumbnail - PropertyDetailPage: renders video in carousel (position=carousel) or as standalone section (position=section) - PriceBox: fix sticky offset for navbar height - PropertyForm: video URL input with live preview + position selector - AdminPropertiesPage: include video fields in edit initial data - types/property: add video_url and video_position to PropertyDetail - utils/getEmbedUrl: helper to resolve YouTube/Vimeo/direct URLs
179 lines
7.9 KiB
TypeScript
179 lines
7.9 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import type { PropertyPhoto } from '../../types/property'
|
|
import VideoPlayer from './VideoPlayer'
|
|
|
|
interface PhotoCarouselProps {
|
|
photos: PropertyPhoto[]
|
|
videoUrl?: string | null
|
|
}
|
|
|
|
function ChevronLeft() {
|
|
return (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<polyline points="15 18 9 12 15 6" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function ChevronRight() {
|
|
return (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<polyline points="9 18 15 12 9 6" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function NoPhotoPlaceholder() {
|
|
return (
|
|
<div className="w-full aspect-[16/9] bg-panel border border-white/5 rounded-xl flex flex-col items-center justify-center gap-3 text-textQuaternary">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<rect x="3" y="3" width="18" height="18" rx="3" />
|
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
<polyline points="21 15 16 10 5 21" />
|
|
</svg>
|
|
<span className="text-sm">Sem fotos disponíveis</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function PhotoCarousel({ photos, videoUrl }: PhotoCarouselProps) {
|
|
const [activeIndex, setActiveIndex] = useState(0)
|
|
const touchStartX = useRef<number | null>(null)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const hasVideo = Boolean(videoUrl)
|
|
const totalSlides = photos.length + (hasVideo ? 1 : 0)
|
|
|
|
const prev = useCallback(() => {
|
|
setActiveIndex((i) => (i === 0 ? totalSlides - 1 : i - 1))
|
|
}, [totalSlides])
|
|
|
|
const next = useCallback(() => {
|
|
setActiveIndex((i) => (i === totalSlides - 1 ? 0 : i + 1))
|
|
}, [totalSlides])
|
|
|
|
useEffect(() => {
|
|
const el = containerRef.current
|
|
if (!el) return
|
|
const handleKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'ArrowLeft') prev()
|
|
else if (e.key === 'ArrowRight') next()
|
|
}
|
|
el.addEventListener('keydown', handleKey)
|
|
return () => el.removeEventListener('keydown', handleKey)
|
|
}, [prev, next])
|
|
|
|
if (totalSlides === 0) return <NoPhotoPlaceholder />
|
|
|
|
const isVideoSlide = hasVideo && activeIndex === 0
|
|
const photoIndex = hasVideo ? activeIndex - 1 : activeIndex
|
|
const active = !isVideoSlide ? photos[photoIndex] : null
|
|
const single = totalSlides === 1
|
|
|
|
return (
|
|
<div ref={containerRef} className="w-full outline-none" tabIndex={0}>
|
|
{/* Main slide */}
|
|
<div className="relative w-full rounded-xl overflow-hidden bg-panel">
|
|
{isVideoSlide ? (
|
|
<VideoPlayer url={videoUrl!} />
|
|
) : (
|
|
<div className="aspect-[16/9]">
|
|
<img
|
|
src={active!.url}
|
|
alt={active!.alt_text || `Foto ${activeIndex + 1}`}
|
|
className="w-full h-full object-cover transition-opacity duration-300"
|
|
loading="lazy"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Nav buttons */}
|
|
{!single && (
|
|
<>
|
|
<button
|
|
onClick={prev}
|
|
aria-label="Foto anterior"
|
|
className="absolute left-3 top-1/2 -translate-y-1/2 w-9 h-9 rounded-full bg-black/50 backdrop-blur-sm border border-white/10 flex items-center justify-center text-white hover:bg-black/70 transition-colors"
|
|
>
|
|
<ChevronLeft />
|
|
</button>
|
|
<button
|
|
onClick={next}
|
|
aria-label="Próxima foto"
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 w-9 h-9 rounded-full bg-black/50 backdrop-blur-sm border border-white/10 flex items-center justify-center text-white hover:bg-black/70 transition-colors"
|
|
>
|
|
<ChevronRight />
|
|
</button>
|
|
|
|
{/* Counter */}
|
|
<div className="absolute bottom-3 right-3 bg-black/60 backdrop-blur-sm rounded-full px-2.5 py-1 text-xs text-white/80">
|
|
{activeIndex + 1} / {totalSlides}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Thumbnail strip */}
|
|
{!single && (
|
|
<div
|
|
className="flex gap-2 mt-3 overflow-x-auto pb-1 scrollbar-thin"
|
|
onTouchStart={(e) => { touchStartX.current = e.touches[0].clientX }}
|
|
onTouchEnd={(e) => {
|
|
if (touchStartX.current === null) return
|
|
const delta = e.changedTouches[0].clientX - touchStartX.current
|
|
if (delta < -50) next()
|
|
else if (delta > 50) prev()
|
|
touchStartX.current = null
|
|
}}
|
|
>
|
|
{hasVideo && (
|
|
<button
|
|
key="video-thumb"
|
|
onClick={() => setActiveIndex(0)}
|
|
aria-label="Ver vídeo"
|
|
aria-current={activeIndex === 0}
|
|
className={`flex-shrink-0 w-20 h-14 rounded-lg overflow-hidden border-2 transition-all duration-150 flex items-center justify-center bg-panel ${
|
|
activeIndex === 0
|
|
? 'border-accent-violet opacity-100'
|
|
: 'border-transparent opacity-50 hover:opacity-75'
|
|
}`}
|
|
>
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 24 24"
|
|
fill="currentColor"
|
|
className="text-white/70"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
{photos.map((photo, idx) => {
|
|
const slideIdx = hasVideo ? idx + 1 : idx
|
|
return (
|
|
<button
|
|
key={idx}
|
|
onClick={() => setActiveIndex(slideIdx)}
|
|
aria-label={`Ver foto ${idx + 1}`}
|
|
aria-current={slideIdx === activeIndex}
|
|
className={`flex-shrink-0 w-20 h-14 rounded-lg overflow-hidden border-2 transition-all duration-150 ${
|
|
slideIdx === activeIndex
|
|
? 'border-accent-violet opacity-100'
|
|
: 'border-transparent opacity-50 hover:opacity-75'
|
|
}`}
|
|
>
|
|
<img
|
|
src={photo.url}
|
|
alt={photo.alt_text || `Miniatura ${idx + 1}`}
|
|
className="w-full h-full object-cover"
|
|
loading="lazy"
|
|
/>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|