Files
tvone/app/components/tvone-promo-strip.tsx
T

171 lines
5.1 KiB
TypeScript
Raw Normal View History

2026-03-28 23:01:17 +01:00
"use client";
2026-03-27 22:56:54 +01:00
import Image from "next/image";
2026-03-28 23:10:16 +01:00
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
2026-03-27 22:56:54 +01:00
2026-03-28 23:01:17 +01:00
import type { SliderPhoto } from "@/lib/slider-photos";
2026-03-27 22:56:54 +01:00
2026-03-28 23:01:17 +01:00
const ROTATE_MS = 5500;
function PromoStripSingleSlide({
photos,
activeIndex,
}: {
photos: SliderPhoto[];
activeIndex: number;
}) {
2026-03-28 23:10:16 +01:00
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 };
});
}, []);
2026-03-28 23:01:17 +01:00
const n = photos.length;
2026-03-27 23:10:26 +01:00
return (
2026-03-28 23:10:16 +01:00
<div
className="relative w-full bg-neutral-100 dark:bg-neutral-900"
style={containerStyle}
>
2026-03-28 23:01:17 +01:00
{n > 0 ? (
photos.map((photo, i) => (
<Image
key={photo.src}
src={photo.src}
alt={photo.alt}
fill
2026-03-28 23:10:16 +01:00
priority={i === 0}
2026-03-28 23:01:17 +01:00
className={`object-cover object-center transition-opacity duration-[900ms] ease-in-out ${
i === activeIndex ? "z-[1] opacity-100" : "pointer-events-none z-0 opacity-0"
}`}
sizes="100vw"
aria-hidden={i !== activeIndex}
2026-03-28 23:10:16 +01:00
onLoad={(e) => handleImageLoad(photo.src, e.currentTarget.naturalWidth, e.currentTarget.naturalHeight)}
2026-03-28 23:01:17 +01:00
/>
))
) : (
<div className="absolute inset-0 bg-neutral-200 dark:bg-neutral-800" aria-hidden />
)}
</div>
2026-03-27 23:10:26 +01:00
);
}
2026-03-28 23:01:17 +01:00
function useSliderPhotos(): { photos: SliderPhoto[]; loading: boolean } {
const [photos, setPhotos] = useState<SliderPhoto[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
fetch("/api/slider-photos", { cache: "no-store" })
.then((r) => (r.ok ? r.json() : []))
.then((data: unknown) => {
if (cancelled) return;
if (Array.isArray(data)) {
setPhotos(
data.filter(
(x): x is SliderPhoto =>
typeof x === "object" &&
x !== null &&
"src" in x &&
typeof (x as SliderPhoto).src === "string" &&
"alt" in x &&
typeof (x as SliderPhoto).alt === "string",
),
);
} else {
setPhotos([]);
}
})
.catch(() => {
if (!cancelled) setPhotos([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return { photos, loading };
2026-03-27 23:10:26 +01:00
}
2026-03-28 23:10:16 +01:00
/** Slider: one image at a time; container height follows each image aspect ratio (no fixed empty band). */
2026-03-27 22:56:54 +01:00
export function TvonePromoStrip() {
2026-03-28 23:01:17 +01:00
const { photos, loading } = useSliderPhotos();
const [index, setIndex] = useState(0);
const [reduceMotion, setReduceMotion] = useState(false);
2026-03-27 22:56:54 +01:00
2026-03-28 23:01:17 +01:00
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const sync = () => setReduceMotion(mq.matches);
sync();
mq.addEventListener("change", sync);
return () => mq.removeEventListener("change", sync);
}, []);
const advance = useCallback(() => {
if (photos.length <= 1) return;
setIndex((i) => (i + 1) % photos.length);
}, [photos.length]);
useEffect(() => {
if (photos.length <= 1 || reduceMotion) return;
const id = window.setInterval(advance, ROTATE_MS);
return () => window.clearInterval(id);
}, [photos.length, reduceMotion, advance]);
useEffect(() => {
setIndex((prev) => (photos.length === 0 ? 0 : Math.min(prev, photos.length - 1)));
}, [photos.length]);
const current = photos.length > 0 ? photos[index] : null;
const regionLabel = loading ? "A carregar galeria" : current?.alt ?? "Galeria";
return (
<div
className="relative w-full overflow-hidden border-y border-neutral-200/80 bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-900"
role="region"
aria-roledescription="carrossel"
aria-label={regionLabel}
>
<span className="sr-only" aria-live="polite">
{current ? current.alt : loading ? "A carregar" : "Sem imagens"}
</span>
2026-03-27 22:56:54 +01:00
2026-03-28 23:10:16 +01:00
<div className="relative w-full">
2026-03-28 23:01:17 +01:00
<PromoStripSingleSlide photos={photos} activeIndex={index} />
2026-03-27 22:56:54 +01:00
</div>
</div>
);
}