Add CSS editor banner and update Avalon member count

- 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>
This commit is contained in:
Eslezer
2026-09-10 22:51:12 +10:00
parent 225c2e3c4f
commit d7c8a9a008
5 changed files with 220 additions and 8 deletions
Binary file not shown.
+25
View File
@@ -32,6 +32,8 @@ import {
DropdownMenuItem, DropdownMenuItem,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { DiscordBannerPermanent } from "@/components/ui/discord-banner-permanent"; 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"; import Script from "next/script";
interface CardDataV2 { interface CardDataV2 {
@@ -447,12 +449,35 @@ export default function Home() {
{/* Discord Banner - Disabled if NEXT_PUBLIC_DISABLE_DISCORD_BANNER is set */} {/* Discord Banner - Disabled if NEXT_PUBLIC_DISABLE_DISCORD_BANNER is set */}
{process.env.NEXT_PUBLIC_DISABLE_DISCORD_BANNER !== "true" && ( {process.env.NEXT_PUBLIC_DISABLE_DISCORD_BANNER !== "true" && (
<>
{/* Desktop: banners side by side */}
<div className="hidden md:flex gap-4 mb-4 items-stretch">
<DiscordBannerPermanent <DiscordBannerPermanent
inviteLink="https://discord.gg/5jQKkCfHP3" inviteLink="https://discord.gg/5jQKkCfHP3"
serverName="Avalon" serverName="Avalon"
description="Avalon is an enchanted creative haven for a growing collective of botmakers and users—part workshop, part myth, part digital forest where strange and beautiful ideas come to life. Enter our little woodland here:" description="Avalon is an enchanted creative haven for a growing collective of botmakers and users—part workshop, part myth, part digital forest where strange and beautiful ideas come to life. Enter our little woodland here:"
ctaText="Join Server" ctaText="Join Server"
className="flex-1 min-w-0"
/> />
<CssEditorBanner className="flex-shrink-0 aspect-[16/10] w-[300px] lg:w-[360px] xl:w-[400px]" />
</div>
{/* Mobile: carousel */}
<BannerCarousel
className="md:hidden mb-4"
slides={[
<DiscordBannerPermanent
key="avalon"
inviteLink="https://discord.gg/5jQKkCfHP3"
serverName="Avalon"
description="Avalon is an enchanted creative haven for a growing collective of botmakers and users—part workshop, part myth, part digital forest where strange and beautiful ideas come to life. Enter our little woodland here:"
ctaText="Join Server"
className="h-full"
/>,
<CssEditorBanner key="css" className="h-full" fit="contain" />,
]}
/>
</>
)} )}
<div className="max-w-3xl mx-auto space-y-6"> <div className="max-w-3xl mx-auto space-y-6">
+85
View File
@@ -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 (
<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>
);
}
+100
View File
@@ -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<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
// Browsers like Opera GX overlay pop-out controls on large <video> elements.
// The real video stays 1px and invisible; its frames are painted onto a canvas.
useEffect(() => {
const video = videoRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!video || !canvas || !ctx) return;
const resize = () => {
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(canvas.clientWidth * dpr);
canvas.height = Math.round(canvas.clientHeight * dpr);
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(canvas);
let frame = 0;
const draw = () => {
const vw = video.videoWidth;
const vh = video.videoHeight;
const cw = canvas.width;
const ch = canvas.height;
if (vw && vh && cw && ch) {
const scale =
fit === "contain"
? Math.min(cw / vw, ch / vh)
: Math.max(cw / vw, ch / vh);
const w = vw * scale;
const h = vh * scale;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(video, (cw - w) / 2, (ch - h) / 2, w, h);
}
frame = requestAnimationFrame(draw);
};
frame = requestAnimationFrame(draw);
// Browsers may pause muted videos they consider hidden; keep it looping.
const resume = () => {
if (video.paused && document.visibilityState === "visible") {
video.play().catch(() => {});
}
};
resume();
video.addEventListener("pause", resume);
document.addEventListener("visibilitychange", resume);
const keepAlive = setInterval(resume, 1000);
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
clearInterval(keepAlive);
video.removeEventListener("pause", resume);
document.removeEventListener("visibilitychange", resume);
};
}, [fit]);
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label="Open the Janitor profile CSS editor"
className={`block relative rounded-lg overflow-hidden shadow-lg bg-black ${className}`}
>
<video
ref={videoRef}
src="/avalon/css-banner.mp4"
autoPlay
loop
muted
playsInline
disablePictureInPicture
preload="auto"
aria-hidden
className="absolute top-0 left-0 w-px h-px opacity-[0.01] pointer-events-none"
/>
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
</a>
);
}
@@ -7,6 +7,7 @@ interface DiscordBannerPermanentProps {
serverName?: string; serverName?: string;
description?: string; description?: string;
ctaText?: string; ctaText?: string;
className?: string;
} }
export function DiscordBannerPermanent({ export function DiscordBannerPermanent({
@@ -14,9 +15,10 @@ export function DiscordBannerPermanent({
serverName = "Avalon", serverName = "Avalon",
description = "Avalon is an enchanted creative haven for a growing collective of botmakers and users—part workshop, part myth, part digital forest where strange and beautiful ideas come to life. Enter our little woodland here:", description = "Avalon is an enchanted creative haven for a growing collective of botmakers and users—part workshop, part myth, part digital forest where strange and beautiful ideas come to life. Enter our little woodland here:",
ctaText = "Join Server", ctaText = "Join Server",
className = "mb-4",
}: DiscordBannerPermanentProps) { }: DiscordBannerPermanentProps) {
return ( return (
<div className="relative rounded-lg overflow-hidden shadow-lg mb-4 bg-[#36393f]"> <div className={`relative rounded-lg overflow-hidden shadow-lg bg-[#36393f] ${className}`}>
{/* Background Image */} {/* Background Image */}
<div className="absolute inset-0"> <div className="absolute inset-0">
<Image <Image
@@ -69,7 +71,7 @@ export function DiscordBannerPermanent({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-white font-semibold text-base mb-0.5">{serverName}</div> <div className="text-white font-semibold text-base mb-0.5">{serverName}</div>
<div className="text-[#b9bbbe] text-sm space-y-0.5"> <div className="text-[#b9bbbe] text-sm space-y-0.5">
<div className="font-medium">295 members</div> <div className="font-medium">885 members</div>
<a <a
href="https://from-avalon.com" href="https://from-avalon.com"
target="_blank" target="_blank"