mirror of
https://github.com/PeterMaquiran/tvone.git
synced 2026-04-18 15:27:52 +00:00
This commit is contained in:
@@ -4,84 +4,53 @@ import Image from "next/image";
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
import type { SliderPhoto } from "@/lib/slider-photos";
|
// Definição do tipo para garantir 100% de compatibilidade TS
|
||||||
|
export interface SliderPhoto {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
id?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
const ROTATE_MS = 5500;
|
const ROTATE_MS = 5500;
|
||||||
|
|
||||||
function PromoStripSingleSlide({
|
// --- 1. COMPONENTE DE SLIDE INDIVIDUAL ---
|
||||||
photos,
|
interface SlideProps {
|
||||||
activeIndex,
|
photo: SliderPhoto;
|
||||||
}: {
|
onRatioLoad: (src: string, ratio: number) => void;
|
||||||
photos: SliderPhoto[];
|
}
|
||||||
activeIndex: number;
|
|
||||||
}) {
|
|
||||||
const [ratioBySrc, setRatioBySrc] = useState<Record<string, number>>({});
|
|
||||||
const lastKnownRatioRef = useRef(16 / 9);
|
|
||||||
|
|
||||||
const active = photos[activeIndex];
|
|
||||||
const activeRatio = active ? ratioBySrc[active.src] : undefined;
|
|
||||||
|
|
||||||
const displayRatio =
|
|
||||||
activeRatio != null && activeRatio > 0 ? activeRatio : photos.length > 0 ? lastKnownRatioRef.current : undefined;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeRatio != null && activeRatio > 0) {
|
|
||||||
lastKnownRatioRef.current = activeRatio;
|
|
||||||
}
|
|
||||||
}, [activeRatio]);
|
|
||||||
|
|
||||||
const containerStyle = useMemo((): CSSProperties => {
|
|
||||||
if (photos.length === 0) {
|
|
||||||
return { minHeight: "10rem" };
|
|
||||||
}
|
|
||||||
if (displayRatio != null) {
|
|
||||||
return { aspectRatio: displayRatio };
|
|
||||||
}
|
|
||||||
return { minHeight: "10rem" };
|
|
||||||
}, [photos.length, displayRatio]);
|
|
||||||
|
|
||||||
const handleImageLoad = useCallback((src: string, naturalWidth: number, naturalHeight: number) => {
|
|
||||||
if (naturalHeight <= 0) return;
|
|
||||||
setRatioBySrc((prev) => {
|
|
||||||
const next = naturalWidth / naturalHeight;
|
|
||||||
if (prev[src] === next) return prev;
|
|
||||||
return { ...prev, [src]: next };
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const n = photos.length;
|
|
||||||
|
|
||||||
|
function PromoStripSingleSlide({ photo, onRatioLoad }: SlideProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="relative h-full w-full">
|
||||||
className="relative w-full bg-neutral-100 dark:bg-neutral-900"
|
|
||||||
style={containerStyle}
|
|
||||||
>
|
|
||||||
{n > 0 ? (
|
|
||||||
photos.map((photo, i) => (
|
|
||||||
<Image
|
<Image
|
||||||
key={photo.src}
|
|
||||||
src={photo.src}
|
src={photo.src}
|
||||||
alt={photo.alt}
|
alt={photo.alt}
|
||||||
fill
|
fill
|
||||||
priority={i === 0}
|
priority
|
||||||
className={`object-cover object-center transition-opacity duration-[900ms] ease-in-out ${
|
className="object-cover object-center"
|
||||||
i === activeIndex ? "z-[1] opacity-100" : "pointer-events-none z-0 opacity-0"
|
|
||||||
}`}
|
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
aria-hidden={i !== activeIndex}
|
onLoad={(e) => {
|
||||||
onLoad={(e) => handleImageLoad(photo.src, e.currentTarget.naturalWidth, e.currentTarget.naturalHeight)}
|
const target = e.currentTarget;
|
||||||
|
if (target.naturalHeight > 0) {
|
||||||
|
onRatioLoad(photo.src, target.naturalWidth / target.naturalHeight);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))
|
|
||||||
) : (
|
{/* Label Interna - Estilo Premium/iOS */}
|
||||||
<div className="absolute inset-0 bg-neutral-200 dark:bg-neutral-800" aria-hidden />
|
<div className="absolute bottom-6 left-6 z-10 md:bottom-10 md:left-10">
|
||||||
)}
|
<span className="rounded-full bg-black/40 backdrop-blur-xl px-4 py-2 text-[10px] font-bold uppercase tracking-[0.2em] text-white border border-white/10 shadow-2xl">
|
||||||
|
Destaque
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 2. HOOK DE DADOS (Simulação de Fetch) ---
|
||||||
function useSliderPhotos(): { photos: SliderPhoto[]; loading: boolean } {
|
function useSliderPhotos(): { photos: SliderPhoto[]; loading: boolean } {
|
||||||
const [photos, setPhotos] = useState<SliderPhoto[]>([]);
|
const [photos, setPhotos] = useState<SliderPhoto[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -90,19 +59,11 @@ function useSliderPhotos(): { photos: SliderPhoto[]; loading: boolean } {
|
|||||||
.then((data: unknown) => {
|
.then((data: unknown) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
setPhotos(
|
const validated = data.filter(
|
||||||
data.filter(
|
|
||||||
(x): x is SliderPhoto =>
|
(x): x is SliderPhoto =>
|
||||||
typeof x === "object" &&
|
typeof x === "object" && x !== null && "src" in x && "alt" in x
|
||||||
x !== null &&
|
|
||||||
"src" in x &&
|
|
||||||
typeof (x as SliderPhoto).src === "string" &&
|
|
||||||
"alt" in x &&
|
|
||||||
typeof (x as SliderPhoto).alt === "string",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} else {
|
setPhotos(validated);
|
||||||
setPhotos([]);
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -111,20 +72,20 @@ function useSliderPhotos(): { photos: SliderPhoto[]; loading: boolean } {
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => { cancelled = true; };
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { photos, loading };
|
return { photos, loading };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Slider: one image at a time; container height follows each image aspect ratio (no fixed empty band). */
|
// --- 3. COMPONENTE PRINCIPAL (O CARROSSEL) ---
|
||||||
export function TvonePromoStrip() {
|
export function TvonePromoStrip() {
|
||||||
const { photos, loading } = useSliderPhotos();
|
const { photos, loading } = useSliderPhotos();
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState<number>(0);
|
||||||
const [reduceMotion, setReduceMotion] = useState(false);
|
const [ratios, setRatios] = useState<Record<string, number>>({});
|
||||||
|
const [reduceMotion, setReduceMotion] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Detetar preferência de movimento do sistema (iOS/MacOS/Windows)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
const sync = () => setReduceMotion(mq.matches);
|
const sync = () => setReduceMotion(mq.matches);
|
||||||
@@ -133,9 +94,10 @@ export function TvonePromoStrip() {
|
|||||||
return () => mq.removeEventListener("change", sync);
|
return () => mq.removeEventListener("change", sync);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Lógica de avanço automático
|
||||||
const advance = useCallback(() => {
|
const advance = useCallback(() => {
|
||||||
if (photos.length <= 1) return;
|
if (photos.length <= 1) return;
|
||||||
setIndex((i) => (i + 1) % photos.length);
|
setIndex((i: number) => (i + 1) % photos.length);
|
||||||
}, [photos.length]);
|
}, [photos.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -144,27 +106,60 @@ export function TvonePromoStrip() {
|
|||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [photos.length, reduceMotion, advance]);
|
}, [photos.length, reduceMotion, advance]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Handler para atualizar rácios de imagem
|
||||||
setIndex((prev) => (photos.length === 0 ? 0 : Math.min(prev, photos.length - 1)));
|
const handleRatio = useCallback((src: string, ratio: number) => {
|
||||||
}, [photos.length]);
|
setRatios((prev) => (prev[src] === ratio ? prev : { ...prev, [src]: ratio }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const current = photos.length > 0 ? photos[index] : null;
|
// Calcula a proporção do contentor baseado no slide ativo
|
||||||
const regionLabel = loading ? "A carregar galeria" : current?.alt ?? "Galeria";
|
const currentRatio = useMemo<number>(() => {
|
||||||
|
const activePhoto = photos[index];
|
||||||
|
return activePhoto ? (ratios[activePhoto.src] || 16 / 9) : 16 / 9;
|
||||||
|
}, [photos, index, ratios]);
|
||||||
|
|
||||||
|
if (loading && photos.length === 0) {
|
||||||
|
return <div className="aspect-[16/9] w-full animate-pulse bg-neutral-100 dark:bg-neutral-800 rounded-3xl" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photos.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<section
|
||||||
className="relative w-full overflow-hidden bg-neutral-100 dark:bg-neutral-900"
|
className="relative w-full overflow-hidden bg-neutral-100 dark:bg-neutral-900 transition-[aspect-ratio] duration-700 ease-[cubic-bezier(0.32,0.72,0,1)]"
|
||||||
|
style={{ aspectRatio: currentRatio } as CSSProperties}
|
||||||
role="region"
|
role="region"
|
||||||
aria-roledescription="carrossel"
|
aria-roledescription="carrossel"
|
||||||
aria-label={regionLabel}
|
|
||||||
>
|
>
|
||||||
<span className="sr-only" aria-live="polite">
|
{/* O "ROLO" (A fita flexível) */}
|
||||||
{current ? current.alt : loading ? "A carregar" : "Sem imagens"}
|
<div
|
||||||
</span>
|
className="flex h-full w-full transition-transform duration-1000 ease-[cubic-bezier(0.32,0.72,0,1)]"
|
||||||
|
style={{ transform: `translateX(-${index * 100}%)` }}
|
||||||
|
>
|
||||||
|
{photos.map((photo: SliderPhoto, i: number) => (
|
||||||
|
<div key={photo.src || i} className="h-full w-full flex-shrink-0">
|
||||||
|
<PromoStripSingleSlide
|
||||||
|
photo={photo}
|
||||||
|
onRatioLoad={handleRatio}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="relative w-full">
|
{/* INDICADORES (iOS Dot Style) */}
|
||||||
<PromoStripSingleSlide photos={photos} activeIndex={index} />
|
{photos.length > 1 && (
|
||||||
</div>
|
<div className="absolute bottom-6 left-1/2 flex -translate-x-1/2 gap-2 z-20">
|
||||||
|
{photos.map((_, i: number) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setIndex(i)}
|
||||||
|
className={`h-1.5 rounded-full transition-all duration-500 ${
|
||||||
|
index === i ? "w-8 bg-white" : "w-1.5 bg-white/30 hover:bg-white/60"
|
||||||
|
}`}
|
||||||
|
aria-label={`Ir para slide ${i + 1}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user