improve slider
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-03-28 23:01:17 +01:00
parent 48ff3359ab
commit d1f06cb9d5
10 changed files with 191 additions and 129 deletions
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { readSliderPhotos } from "@/lib/slider-photos";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const photos = readSliderPhotos();
return NextResponse.json(photos);
} catch {
return NextResponse.json([]);
}
}
-3
View File
@@ -294,9 +294,6 @@ export function TvoneFooter() {
<p className="text-[12px] font-semibold leading-[1.33337] tracking-[0.01em] text-[#1d1d1f]">Sobre a TV ONE</p>
<div className="mt-3 border-t border-[#d2d2d7] pt-3">
<p className="mb-1 text-[21px] font-semibold leading-[1.14286] tracking-tight text-[#1d1d1f]">tvone</p>
<p className="text-[12px] leading-[1.33337] text-[#6e6e73]">
Informação e entretenimento para Angola e para quem acompanha a atualidade.
</p>
</div>
<div className="mt-4 space-y-3 text-[12px] leading-[1.33337] text-[#6e6e73]">
<p className="text-pretty">
+111 -123
View File
@@ -1,141 +1,129 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
const PROMO_IMG_LEFT =
"https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=800&q=80&auto=format&fit=crop";
const PROMO_IMG_RIGHT =
"https://images.unsplash.com/photo-1544367567-0f2fcb009e0b?w=800&q=80&auto=format&fit=crop";
import type { SliderPhoto } from "@/lib/slider-photos";
const ROTATE_MS = 5500;
function PromoStripSingleSlide({
photos,
activeIndex,
}: {
photos: SliderPhoto[];
activeIndex: number;
}) {
const n = photos.length;
function IconFacebook({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
</svg>
<div className="relative min-h-[140px] w-full sm:min-h-[168px] md:min-h-[190px] lg:min-h-[210px]">
{n > 0 ? (
photos.map((photo, i) => (
<Image
key={photo.src}
src={photo.src}
alt={photo.alt}
fill
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}
/>
))
) : (
<div className="absolute inset-0 bg-neutral-200 dark:bg-neutral-800" aria-hidden />
)}
</div>
);
}
function IconLinkedIn({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
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 };
}
function IconInstagram({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z" />
</svg>
);
}
/** Insurance / partner strip — scrolls with the page. */
/** Slider: one image at a time from `public/slider` (see `lib/slider-photos.ts`). */
export function TvonePromoStrip() {
const { photos, loading } = useSliderPhotos();
const [index, setIndex] = useState(0);
const [reduceMotion, setReduceMotion] = useState(false);
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 flex min-h-[140px] w-full overflow-hidden border-y border-white/10 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] sm:min-h-[168px] md:min-h-[190px] lg:min-h-[210px]">
{/* Ambient glow */}
<div
className="pointer-events-none absolute -left-16 top-1/2 z-[2] h-[min(320px,80vw)] w-[min(320px,80vw)] -translate-y-1/2 rounded-full bg-fuchsia-500/35 blur-[64px]"
aria-hidden
/>
<div
className="pointer-events-none absolute -right-20 top-1/2 z-[2] h-[min(280px,70vw)] w-[min(280px,70vw)] -translate-y-1/2 rounded-full bg-rose-400/30 blur-[56px]"
aria-hidden
/>
<div
className="pointer-events-none absolute left-1/2 top-0 z-[2] h-24 w-[80%] max-w-[480px] -translate-x-1/2 bg-gradient-to-b from-white/20 to-transparent opacity-50 blur-2xl"
aria-hidden
/>
<div className="group relative w-[20%] min-w-[72px] max-w-[280px] shrink-0 sm:w-[22%] md:w-[24%]">
<Image
src={PROMO_IMG_LEFT}
alt="Mulher em atividade ao ar livre"
fill
className="object-cover object-[center_28%] transition duration-[1.2s] ease-out group-hover:scale-[1.04]"
sizes="(max-width: 640px) 28vw, 280px"
priority
/>
<div
className="absolute inset-0 bg-gradient-to-r from-black/35 from-[5%] via-[#b8326a]/75 via-[55%] to-transparent"
aria-hidden
/>
<div className="absolute inset-y-0 right-0 w-12 bg-gradient-to-l from-[#c2187a]/90 to-transparent sm:w-16" aria-hidden />
</div>
<div className="relative z-[3] flex min-w-0 flex-1 flex-col justify-center gap-3 bg-gradient-to-br from-[#a31459] via-[#d9468f] to-[#9d1f55] px-4 py-5 sm:flex-row sm:items-center sm:justify-between sm:gap-4 sm:px-6 sm:py-6 md:gap-8 md:px-10 md:py-7 lg:px-12">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_100%_at_50%_-20%,rgba(255,255,255,0.18),transparent_55%)]" aria-hidden />
<div className="flex min-w-0 flex-col gap-3 sm:max-w-[min(100%,42rem)]">
<span className="inline-flex w-fit items-center gap-2 rounded-full border border-white/25 bg-white/10 px-3 py-1.5 text-[10px] font-bold uppercase tracking-[0.22em] text-white shadow-sm backdrop-blur-md sm:text-[11px]">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400/80" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-400 shadow-[0_0_10px_rgba(52,211,153,0.9)]" />
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>
Nossa Seguros
</span>
<p className="relative text-pretty text-[15px] font-medium leading-snug text-white/95 sm:text-base md:text-lg md:leading-relaxed">
<span className="bg-gradient-to-r from-white via-white to-white/85 bg-clip-text font-semibold text-transparent">
Seguro Saúde Mulher
</span>
<span className="text-white/90"> cuidado que acompanha o seu ritmo.</span>
</p>
<Link
href="#"
className="group/cta relative mt-1 inline-flex w-fit items-center gap-2 rounded-full border border-white/30 bg-white/15 px-4 py-2 text-xs font-semibold text-white backdrop-blur-sm transition hover:border-white/50 hover:bg-white/25 sm:text-sm"
>
Saber mais
<span
aria-hidden
className="text-sm transition-transform group-hover/cta:translate-x-0.5"
>
</span>
</Link>
</div>
<div className="relative flex shrink-0 items-center gap-2 sm:flex-col sm:items-end sm:gap-3 sm:pl-2 md:flex-row md:items-center md:gap-4">
<span className="sr-only">Redes sociais</span>
<div className="flex items-center gap-2 rounded-full border border-white/15 bg-black/10 px-2 py-1.5 backdrop-blur-sm sm:px-3">
<Link
href="#"
className="rounded-lg p-1.5 text-white/90 transition hover:bg-white/15 hover:text-white"
aria-label="Facebook"
>
<IconFacebook className="h-4 w-4 sm:h-[18px] sm:w-[18px]" />
</Link>
<Link
href="#"
className="rounded-lg p-1.5 text-white/90 transition hover:bg-white/15 hover:text-white"
aria-label="LinkedIn"
>
<IconLinkedIn className="h-4 w-4 sm:h-[18px] sm:w-[18px]" />
</Link>
<Link
href="#"
className="rounded-lg p-1.5 text-white/90 transition hover:bg-white/15 hover:text-white"
aria-label="Instagram"
>
<IconInstagram className="h-4 w-4 sm:h-[18px] sm:w-[18px]" />
</Link>
</div>
</div>
</div>
<div className="group relative w-[20%] min-w-[72px] max-w-[280px] shrink-0 sm:w-[22%] md:w-[24%]">
<Image
src={PROMO_IMG_RIGHT}
alt="Bem-estar e cuidados de saúde"
fill
className="object-cover object-[center_38%] transition duration-[1.2s] ease-out group-hover:scale-[1.04]"
sizes="(max-width: 640px) 28vw, 280px"
priority
/>
<div
className="absolute inset-0 bg-gradient-to-l from-black/35 from-[5%] via-[#b8326a]/75 via-[55%] to-transparent"
aria-hidden
/>
<div className="absolute inset-y-0 left-0 w-12 bg-gradient-to-r from-[#c2187a]/90 to-transparent sm:w-16" aria-hidden />
<div className="relative flex min-h-[140px] w-full flex-col sm:min-h-[168px] md:min-h-[190px] lg:min-h-[210px]">
<PromoStripSingleSlide photos={photos} activeIndex={index} />
</div>
</div>
);
+63
View File
@@ -0,0 +1,63 @@
import fs from "fs";
import path from "path";
export type SliderPhoto = { src: string; alt: string };
function slugToAlt(filename: string): string {
const base = filename.replace(/\.[^.]+$/, "");
const words = base.replace(/[-_]+/g, " ").trim();
return words || "Slide";
}
export function parseManifest(data: unknown): SliderPhoto[] {
if (!Array.isArray(data)) return [];
const out: SliderPhoto[] = [];
for (const item of data) {
if (typeof item !== "object" || item === null || !("src" in item)) continue;
const src = (item as { src: unknown }).src;
if (typeof src !== "string" || !src.startsWith("/")) continue;
const altRaw = (item as { alt?: unknown }).alt;
const alt = typeof altRaw === "string" && altRaw.trim() ? altRaw.trim() : slugToAlt(src.split("/").pop() ?? "");
out.push({ src, alt });
}
return out;
}
const IMAGE_EXT = /\.(jpe?g|png|webp|gif|avif)$/i;
function scanSliderDirectory(dir: string): SliderPhoto[] {
let names: string[] = [];
try {
names = fs.readdirSync(dir);
} catch {
return [];
}
return names
.filter((f) => IMAGE_EXT.test(f))
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
.map((f) => ({
src: `/slider/${f}`,
alt: slugToAlt(f),
}));
}
/**
* Reads `public/slider/manifest.json` when present (full control: order + alt).
* Otherwise scans `public/slider` for image files (drop-in updates, no code edits).
*/
export function readSliderPhotos(): SliderPhoto[] {
const dir = path.join(process.cwd(), "public", "slider");
if (!fs.existsSync(dir)) return [];
const manifestPath = path.join(dir, "manifest.json");
if (fs.existsSync(manifestPath)) {
try {
const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as unknown;
return parseManifest(raw);
} catch {
return [];
}
}
return scanSliderDirectory(dir);
}
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB