mirror of
https://github.com/severian-dev/sucker.severian.dev.git
synced 2026-10-17 17:10:28 +00:00
d7c8a9a008
- Bump Avalon member count to 885 - Add looping, muted video banner linking to the new Janitor profile CSS editor - Desktop: show the Avalon and CSS banners side by side - Mobile: show them in a carousel (random start, 8s auto-advance, arrows/dots) - Paint video frames to a canvas so browsers like Opera GX don't overlay pop-out controls on the banner Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
86 lines
2.5 KiB
TypeScript
86 lines
2.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, ReactNode } from "react";
|
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
|
|
interface BannerCarouselProps {
|
|
slides: ReactNode[];
|
|
intervalMs?: number;
|
|
className?: string;
|
|
}
|
|
|
|
export function BannerCarousel({
|
|
slides,
|
|
intervalMs = 8000,
|
|
className = "",
|
|
}: BannerCarouselProps) {
|
|
const [index, setIndex] = useState(0);
|
|
|
|
// Pick a random starting slide on the client to avoid hydration mismatch
|
|
useEffect(() => {
|
|
setIndex(Math.floor(Math.random() * slides.length));
|
|
}, [slides.length]);
|
|
|
|
// Re-keyed on index so a manual change restarts the countdown
|
|
useEffect(() => {
|
|
const id = setTimeout(
|
|
() => setIndex((i) => (i + 1) % slides.length),
|
|
intervalMs
|
|
);
|
|
return () => clearTimeout(id);
|
|
}, [index, intervalMs, slides.length]);
|
|
|
|
const go = (delta: number) =>
|
|
setIndex((i) => (i + delta + slides.length) % slides.length);
|
|
|
|
return (
|
|
<div className={`relative ${className}`}>
|
|
{/* Slides stacked in one grid cell so the container fits the tallest */}
|
|
<div className="grid">
|
|
{slides.map((slide, i) => (
|
|
<div
|
|
key={i}
|
|
className={`[grid-area:1/1] transition-opacity duration-500 ${
|
|
i === index ? "opacity-100" : "opacity-0 pointer-events-none"
|
|
}`}
|
|
aria-hidden={i !== index}
|
|
>
|
|
{slide}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => go(-1)}
|
|
aria-label="Previous banner"
|
|
className="absolute left-1.5 top-1/2 -translate-y-1/2 rounded-full bg-black/50 hover:bg-black/70 text-white p-1 transition-colors"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => go(1)}
|
|
aria-label="Next banner"
|
|
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-full bg-black/50 hover:bg-black/70 text-white p-1 transition-colors"
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</button>
|
|
|
|
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1.5">
|
|
{slides.map((_, i) => (
|
|
<button
|
|
key={i}
|
|
type="button"
|
|
onClick={() => setIndex(i)}
|
|
aria-label={`Show banner ${i + 1}`}
|
|
className={`h-1.5 rounded-full transition-all ${
|
|
i === index ? "w-4 bg-white" : "w-1.5 bg-white/50"
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|