feat: add full project - backend, frontend, docker, specs and configs

This commit is contained in:
MatheusAlves96 2026-04-20 23:59:45 -03:00
parent b77c7d5a01
commit e6cb06255b
24489 changed files with 61341 additions and 36 deletions

View file

@ -0,0 +1,139 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { PropertyPhoto } from '../../types/property'
interface PhotoCarouselProps {
photos: PropertyPhoto[]
}
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 }: PhotoCarouselProps) {
const [activeIndex, setActiveIndex] = useState(0)
const touchStartX = useRef<number | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const prev = useCallback(() => {
setActiveIndex((i) => (i === 0 ? photos.length - 1 : i - 1))
}, [photos.length])
const next = useCallback(() => {
setActiveIndex((i) => (i === photos.length - 1 ? 0 : i + 1))
}, [photos.length])
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 (photos.length === 0) return <NoPhotoPlaceholder />
const active = photos[activeIndex]
const single = photos.length === 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"
/>
{/* 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} / {photos.length}
</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
}}
>
{photos.map((photo, idx) => (
<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'
}`}
>
<img
src={photo.url}
alt={photo.alt_text || `Miniatura ${idx + 1}`}
className="w-full h-full object-cover"
loading="lazy"
/>
</button>
))}
</div>
)}
</div>
)
}