feat(frontend): video presentation on property detail page
- 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
This commit is contained in:
parent
d363a09f36
commit
2e9f903d06
8 changed files with 252 additions and 35 deletions
|
|
@ -1,8 +1,10 @@
|
|||
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() {
|
||||
|
|
@ -34,18 +36,20 @@ function NoPhotoPlaceholder() {
|
|||
)
|
||||
}
|
||||
|
||||
export default function PhotoCarousel({ photos }: PhotoCarouselProps) {
|
||||
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 ? photos.length - 1 : i - 1))
|
||||
}, [photos.length])
|
||||
setActiveIndex((i) => (i === 0 ? totalSlides - 1 : i - 1))
|
||||
}, [totalSlides])
|
||||
|
||||
const next = useCallback(() => {
|
||||
setActiveIndex((i) => (i === photos.length - 1 ? 0 : i + 1))
|
||||
}, [photos.length])
|
||||
setActiveIndex((i) => (i === totalSlides - 1 ? 0 : i + 1))
|
||||
}, [totalSlides])
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
|
|
@ -58,21 +62,29 @@ export default function PhotoCarousel({ photos }: PhotoCarouselProps) {
|
|||
return () => el.removeEventListener('keydown', handleKey)
|
||||
}, [prev, next])
|
||||
|
||||
if (photos.length === 0) return <NoPhotoPlaceholder />
|
||||
if (totalSlides === 0) return <NoPhotoPlaceholder />
|
||||
|
||||
const active = photos[activeIndex]
|
||||
const single = photos.length === 1
|
||||
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 photo */}
|
||||
<div className="relative w-full aspect-[16/9] rounded-xl overflow-hidden bg-panel">
|
||||
<img
|
||||
src={active.url}
|
||||
alt={active.alt_text || `Foto ${activeIndex + 1}`}
|
||||
className="w-full h-full object-cover transition-opacity duration-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/* 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 && (
|
||||
|
|
@ -94,7 +106,7 @@ export default function PhotoCarousel({ photos }: PhotoCarouselProps) {
|
|||
|
||||
{/* 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} / {photos.length}
|
||||
{activeIndex + 1} / {totalSlides}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -113,25 +125,53 @@ export default function PhotoCarousel({ photos }: PhotoCarouselProps) {
|
|||
touchStartX.current = null
|
||||
}}
|
||||
>
|
||||
{photos.map((photo, idx) => (
|
||||
{hasVideo && (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setActiveIndex(idx)}
|
||||
aria-label={`Ver foto ${idx + 1}`}
|
||||
aria-current={idx === activeIndex}
|
||||
className={`flex-shrink-0 w-20 h-14 rounded-lg overflow-hidden border-2 transition-all duration-150 ${idx === activeIndex
|
||||
? 'border-accent-violet opacity-100'
|
||||
: 'border-transparent opacity-50 hover:opacity-75'
|
||||
}`}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={photo.url}
|
||||
alt={photo.alt_text || `Miniatura ${idx + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue