diff --git a/public/avalon/css-banner.mp4 b/public/avalon/css-banner.mp4 new file mode 100644 index 0000000..a1dd559 Binary files /dev/null and b/public/avalon/css-banner.mp4 differ diff --git a/src/app/page.tsx b/src/app/page.tsx index 447e6d6..94f5040 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -32,6 +32,8 @@ import { DropdownMenuItem, } from "@/components/ui/dropdown-menu"; import { DiscordBannerPermanent } from "@/components/ui/discord-banner-permanent"; +import { CssEditorBanner } from "@/components/ui/css-editor-banner"; +import { BannerCarousel } from "@/components/ui/banner-carousel"; import Script from "next/script"; interface CardDataV2 { @@ -447,12 +449,35 @@ export default function Home() { {/* Discord Banner - Disabled if NEXT_PUBLIC_DISABLE_DISCORD_BANNER is set */} {process.env.NEXT_PUBLIC_DISABLE_DISCORD_BANNER !== "true" && ( - + <> + {/* Desktop: banners side by side */} +
+ + +
+ + {/* Mobile: carousel */} + , + , + ]} + /> + )}
diff --git a/src/components/ui/banner-carousel.tsx b/src/components/ui/banner-carousel.tsx new file mode 100644 index 0000000..37a0a03 --- /dev/null +++ b/src/components/ui/banner-carousel.tsx @@ -0,0 +1,85 @@ +"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 ( +
+ {/* Slides stacked in one grid cell so the container fits the tallest */} +
+ {slides.map((slide, i) => ( +
+ {slide} +
+ ))} +
+ + + + +
+ {slides.map((_, i) => ( +
+
+ ); +} diff --git a/src/components/ui/css-editor-banner.tsx b/src/components/ui/css-editor-banner.tsx new file mode 100644 index 0000000..cfa5c3f --- /dev/null +++ b/src/components/ui/css-editor-banner.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +interface CssEditorBannerProps { + href?: string; + className?: string; + /** "contain" letterboxes the video so its caption is never cropped */ + fit?: "cover" | "contain"; +} + +export function CssEditorBanner({ + href = "https://tools.from-avalon.com/janitor-profile-maker/", + className = "", + fit = "cover", +}: CssEditorBannerProps) { + const videoRef = useRef(null); + const canvasRef = useRef(null); + + // Browsers like Opera GX overlay pop-out controls on large