8 Commits

Author SHA1 Message Date
Eslezer e7da1aab4d Merge pull request #10 from severian-dev/feat/css-editor-banner
profile/css editor banner
2026-09-11 00:17:49 +10:00
Eslezer 64013ea5ea Bump version to 2.4 and add CSS editor changelog entry
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 00:15:44 +10:00
Eslezer d7c8a9a008 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>
2026-09-10 22:51:12 +10:00
Severian 225c2e3c4f chore: 2.3
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvRuentLsG1UGpy9gmNWvR
2026-08-04 01:22:09 +08:00
Iseult a66a46fc4a fix: revise dingus error message to surface context size issue; update changelog 2026-03-29 06:00:21 +08:00
Iseult d42d13e7f0 Add ladyofpa.in mirror, update Avalon banner copy, bump member count to 295 2026-02-28 20:13:34 +08:00
Severian b252df5591 Merge pull request #9 from leri-a/master
Adding restart line to docker-compose.
2026-02-04 14:07:01 +08:00
Ema P. 75a16b6ece Adding restart line to docker-compose. 2026-02-03 15:48:24 -05:00
10 changed files with 1189 additions and 876 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
# Disable Discord community banner (optional) # Disable Discord community banner (optional)
# Uncomment the line below to hide the Discord banner # Uncomment the line below to hide the Discord banner
NEXT_PUBLIC_DISABLE_DISCORD_BANNER=true # NEXT_PUBLIC_DISABLE_DISCORD_BANNER=true
-5
View File
@@ -1,9 +1,4 @@
.idea/
bun.lock
.env
node_modules node_modules
.next .next
.env.local .env.local
.env*.local .env*.local
.env
+2
View File
@@ -17,6 +17,8 @@ You can also build and run Sucker as a Docker container with `docker compose bui
### Changelog ### Changelog
- 2.4: CSS editor for Janitor profiles. New banner links to the live-preview editor at tools.from-avalon.com/janitor-profile-maker.
- 2.3: ECHO. Put ECHO: anywhere in a message and sucker repeats whatever comes after it in the reply — for triggering char-side script and lorebook keywords.
- 2.2: Added support for directly inputting webp file names or character image links when creating PNG character cards - 2.2: Added support for directly inputting webp file names or character image links when creating PNG character cards
- 2.1: updated deps, note about image fetching, list of mirrors - 2.1: updated deps, note about image fetching, list of mirrors
- 2.0: from Tui: Multimessage support! Tracks changes to character descriptions and scenarios across multiple extractions. Shows version badges, message counts, and provides detailed change history viewer. - 2.0: from Tui: Multimessage support! Tracks changes to character descriptions and scenarios across multiple extractions. Shows version badges, message counts, and provides detailed change history viewer.
+1
View File
@@ -2,5 +2,6 @@ services:
web: web:
build: . build: .
image: sucker image: sucker
restart: unless-stopped
ports: ports:
- "3000:3000" - "3000:3000"
Binary file not shown.
+22 -1
View File
@@ -93,6 +93,15 @@ function parseConversationToken(content: string | undefined | null): string | nu
return null; return null;
} }
function parseEchoText(content: string | undefined | null): string | null {
if (!content) return null;
const match = content.match(/ECHO:\s*([\s\S]+)/i);
if (match && match[1].trim()) {
return match[1].trim();
}
return null;
}
function removePersonaTags(content: string): string { function removePersonaTags(content: string): string {
let result = content; let result = content;
const openingMatch = result.match(/<[^<>]+?\s*'s\s+Persona>/i); const openingMatch = result.match(/<[^<>]+?\s*'s\s+Persona>/i);
@@ -487,6 +496,18 @@ export async function POST(request: NextRequest) {
responseMessage = `New character "${extracted.data.name}" created (v1).${tokenNote}`; responseMessage = `New character "${extracted.data.name}" created (v1).${tokenNote}`;
} }
// Echo back anything the user sent after an "ECHO:" marker in their
// latest message, appended after the normal status message
const lastUserMessage =
[...body.messages]
.reverse()
.find((m: { role?: string }) => m?.role === "user") ??
body.messages[body.messages.length - 1];
const echoText = parseEchoText(lastUserMessage?.content);
if (echoText) {
responseMessage += `\n\n${echoText}`;
}
cleanupExpiredCards(); cleanupExpiredCards();
// Return SSE response if requested, otherwise JSON // Return SSE response if requested, otherwise JSON
@@ -528,7 +549,7 @@ export async function POST(request: NextRequest) {
console.error("Error processing request:", error); console.error("Error processing request:", error);
const errorMessage = const errorMessage =
"You dingus, read the directions on sucker before trying again."; "You dingus, read the directions on sucker before trying again. If you're trying to suck a bot with more tokens than your current context limit, try bumping your Context Size setting up on Generation Settings.";
// Check if this was a streaming request // Check if this was a streaming request
const acceptHeader = request.headers.get("accept"); const acceptHeader = request.headers.get("accept");
+174 -67
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 {
@@ -89,9 +91,6 @@ export default function Home() {
"https://sucker.severian.dev/api/proxy" "https://sucker.severian.dev/api/proxy"
); );
const [pageSource, setPageSource] = useState("");
const [metadataJson, setMetadataJson] = useState<any>(null);
const fetchCards = async () => { const fetchCards = async () => {
try { try {
setIsRefreshing(true); setIsRefreshing(true);
@@ -236,15 +235,6 @@ export default function Home() {
const arrayBuffer = await pngBlob.arrayBuffer(); const arrayBuffer = await pngBlob.arrayBuffer();
// Fallback if metadataJson hasn't been fetched for this card
const safeMetadata = metadataJson || {};
const creator = (safeMetadata.creator_name || (card.data as any).creator || "Unknown") + (safeMetadata.creator_verified ? " ✅" : "");
// Tag Parsing
const normalTags = (safeMetadata.tags || (card.data as any).tags || []).map((t: any) => t.name);
const customTags = safeMetadata.custom_tags || [];
const allTagsArray = [...normalTags, ...customTags];
// Use initial version for PNG embedding, or current version if no initial version available // Use initial version for PNG embedding, or current version if no initial version available
const chosen = card.initialVersion || card.data; const chosen = card.initialVersion || card.data;
const pngData = { const pngData = {
@@ -256,13 +246,13 @@ export default function Home() {
personality: chosen.personality, personality: chosen.personality,
mes_example: chosen.mes_example, mes_example: chosen.mes_example,
scenario: chosen.scenario, scenario: chosen.scenario,
creator: creator, creator: (chosen as any).creator || "",
creator_notes: safeMetadata.description || (chosen as any).creator_notes || "", creator_notes: (chosen as any).creator_notes || "",
system_prompt: (chosen as any).system_prompt || "", system_prompt: (chosen as any).system_prompt || "",
post_history_instructions: post_history_instructions:
(chosen as any).post_history_instructions || "", (chosen as any).post_history_instructions || "",
tags: allTagsArray, tags: (chosen as any).tags || [],
character_version: safeMetadata.name || (chosen as any).character_version || "1", character_version: (chosen as any).character_version || "1",
extensions: (chosen as any).extensions || {}, extensions: (chosen as any).extensions || {},
}, },
spec: card.spec || "chara_card_v2", spec: card.spec || "chara_card_v2",
@@ -307,9 +297,39 @@ export default function Home() {
const isCharacterUrl = /janitorai\.com\/characters\//.test(characterUrl); const isCharacterUrl = /janitorai\.com\/characters\//.test(characterUrl);
if (isCharacterUrl) { if (isCharacterUrl) {
// Open html source, then show second input // Extract character ID and open metadata page, then show second input
window.open(`view-source:${characterUrl}`, "_blank"); const match = characterUrl.match(/characters\/([\w-]+)/);
if (match && match[1]) {
const characterId = match[1].split("_")[0];
window.open(
`https://janitorai.com/hampter/characters/${characterId}`,
"_blank"
);
setIsMetadataOpen(true); setIsMetadataOpen(true);
}
return;
}
// Check if the input is a direct image link (webp filename or full image URL)
const isImagePath = /\.(webp|png|jpg|jpeg|gif)(\?.*)?$/i.test(characterUrl);
const isFullImageUrl = (characterUrl.startsWith("http://") || characterUrl.startsWith("https://")) && isImagePath;
const isWebpFilename = /^[\w-]+\.(webp|png|jpg|jpeg|gif)$/i.test(characterUrl);
if (isFullImageUrl || isWebpFilename) {
// Directly set the avatar URL without opening metadata
if (selectedCardIndex === null) return;
const avatarUrl = isFullImageUrl
? characterUrl
: `https://ella.janitorai.com/bot-avatars/${characterUrl}`;
const updatedCards = [...cards];
updatedCards[selectedCardIndex] = {
...updatedCards[selectedCardIndex],
avatarUrl,
};
setCards(updatedCards);
setDialogOpen(false);
return; return;
} }
}; };
@@ -318,42 +338,7 @@ export default function Home() {
if (selectedCardIndex === null) return; if (selectedCardIndex === null) return;
try { try {
const storeKey = "Sk--a:a-a--characterStore"; const avatarUrl = `https://ella.janitorai.com/bot-avatars/${avatarPath}`;
// Define the anchor points we are looking for
const prefix = 'window.mbxM.push(JSON.parse("';
const suffix = '"));';
// Locate the script content
const startIndex = pageSource.indexOf(prefix);
if (startIndex === -1) {
throw new Error("Could not find character data in the page source.");
}
// Move index to the start of the actual JSON string
const jsonStartIndex = startIndex + prefix.length;
// Find the end of the statement
const jsonEndIndex = pageSource.indexOf(suffix, jsonStartIndex);
if (jsonEndIndex === -1) {
throw new Error("Could not find closing tag for JSON content.");
}
// Extract the escaped string
const escapedJsonString = pageSource.substring(jsonStartIndex, jsonEndIndex);
// Wrap it in quotes so it gets treated as a string containing JSON
const rawJsonString = JSON.parse(`"${escapedJsonString}"`);
console.log(rawJsonString);
// Parse the actual JSON data into an object
const data = JSON.parse(rawJsonString);
// Return the specific character data
const char = data[storeKey].character;
setMetadataJson(char);
const avatarUrl = `https://ella.janitorai.com/bot-avatars/${char.avatar}`;
const updatedCards = [...cards]; const updatedCards = [...cards];
updatedCards[selectedCardIndex] = { updatedCards[selectedCardIndex] = {
...updatedCards[selectedCardIndex], ...updatedCards[selectedCardIndex],
@@ -388,9 +373,9 @@ export default function Home() {
{/* Row 1: Title on left, Hamburger on right */} {/* Row 1: Title on left, Hamburger on right */}
<div className="flex justify-between items-start mb-3"> <div className="flex justify-between items-start mb-3">
<div> <div>
<h1 className="text-2xl font-bold">Sucker v2.2</h1> <h1 className="text-2xl font-bold">Sucker v2.4</h1>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Consider joining Avalon! ECHO: Consider joining Avalon!
</p> </p>
</div> </div>
<DropdownMenu> <DropdownMenu>
@@ -426,9 +411,9 @@ export default function Home() {
{/* Desktop Layout */} {/* Desktop Layout */}
<div className="hidden sm:flex justify-between items-center mb-4"> <div className="hidden sm:flex justify-between items-center mb-4">
<div> <div>
<h1 className="text-3xl font-bold">Sucker v2.2</h1> <h1 className="text-3xl font-bold">Sucker v2.4</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Consider joining Avalon! ECHO: Consider joining Avalon!
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -464,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="Looking for somewhere more... interesting? Avalon's an enchanted collective of botmakers who refuse to be boring 🌿 Come play with us~ (18+ only, darling)" 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">
@@ -759,7 +767,7 @@ export default function Home() {
onClick={() => handleOpenDialog(index)} onClick={() => handleOpenDialog(index)}
variant="outline" variant="outline"
> >
Fetch Metadata Fetch Avatar (required for PNG)
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -767,7 +775,7 @@ export default function Home() {
variant="default" variant="default"
disabled={!card.avatarUrl} disabled={!card.avatarUrl}
> >
Download Complete Card Download PNG
</Button> </Button>
)} )}
</div> </div>
@@ -837,6 +845,14 @@ export default function Home() {
moment, but you can use the changes detected to modify the moment, but you can use the changes detected to modify the
character card yourself post-export. character card yourself post-export.
</li> </li>
<li>
Some advanced scripts and lorebooks only add their entries
when the <i>character</i> says the keyword, not you. Put{" "}
<code className="bg-muted px-1 py-0.5 rounded text-yellow-600">ECHO:</code>{" "}
in a message and everything after it gets repeated at the end
of the reply — instant character-side keywords, and sucker
tracks whatever gets added.
</li>
<li> <li>
Download the JSON files or go through a little more effort to Download the JSON files or go through a little more effort to
get PNGs instead. get PNGs instead.
@@ -914,6 +930,16 @@ export default function Home() {
lemuria.dev lemuria.dev
</a> </a>
</li> </li>
<li>
<a
className="text-yellow-600 hover:underline"
href="https://milker.ladyofpa.in/"
target="_blank"
rel="noopener noreferrer"
>
ladyofpa.in
</a>
</li>
</ul> </ul>
<p className="text-sm text-muted-foreground pt-4"> <p className="text-sm text-muted-foreground pt-4">
If you&apos;re interested in hosting your own sucker instance, lmk If you&apos;re interested in hosting your own sucker instance, lmk
@@ -935,6 +961,69 @@ export default function Home() {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-6"> <div className="space-y-6">
<div>
<h3 className="font-semibold text-lg mb-2">Sep 2026 - CSS Editor for Janitor profiles</h3>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
We&apos;d like to introduce a CSS editor we have been working
on for Janitor profiles. It comes loaded with some simple
presets, and we plan to include more in the future, be it
ours or provided by the community. You can see changes occur
in live time and it should accurately represent the
behaviour of the CSS if used in your Janitor profile.
</p>
<p>
Check it out at{" "}
<a
href="https://tools.from-avalon.com/janitor-profile-maker/"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
tools.from-avalon.com/janitor-profile-maker
</a>{" "}
and DM eslezer on Discord if you have any bug or suggestion
for the editor.
</p>
</div>
</div>
<Separator />
<div>
<h3 className="font-semibold text-lg mb-2">Aug 2026 - ECHO</h3>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
Sucker can parrot now. Send a message with ECHO: in it, and
everything after that gets repeated back at the end of the
reply, exactly as you typed it.
</p>
<p>
The usual responses don&apos;t change at all — the echoed part
just tags along after them. The point: some of the fancier
scripts and lorebooks on the platform you suck from only add
their entries when the character says the keyword, not you.
ECHO plants your words on the character&apos;s side of the
chat, those entries fire, and sucker catches whatever they
sneak in. Case doesn&apos;t matter, echo: works too.
</p>
</div>
</div>
<Separator />
<div>
<h3 className="font-semibold text-lg mb-2">Mar 2026 - Better error messaging for context size issues</h3>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
Identified an issue where attempting to pull bots with token counts
greater than your configured context size would result in a dingus error
with no useful explanation.
</p>
<p>
Revised the error message to surface this more accurately. It now
tells you to bump your Context Size in Generation Settings if you
suspect this is the cause.
</p>
</div>
</div>
<Separator />
<div> <div>
<h3 className="font-semibold text-lg mb-2">Jan 2026 - Direct image input</h3> <h3 className="font-semibold text-lg mb-2">Jan 2026 - Direct image input</h3>
<div className="space-y-2 text-sm text-muted-foreground"> <div className="space-y-2 text-sm text-muted-foreground">
@@ -1010,25 +1099,43 @@ export default function Home() {
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{isMetadataOpen ? "Enter Page Source Code" : "Fetch Metadata"} {isMetadataOpen ? "Enter Avatar Path" : "Fetch Avatar"}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{isMetadataOpen ? "Paste the entire source code here." : "Enter a character URL (janitorai.com/characters/...) to open page source."} {isMetadataOpen
? "Look for the avatar field in the opened tab and paste the value here."
: "Enter a character URL (janitorai.com/characters/...) to open metadata, or paste an image filename (id.webp) or full image URL directly."}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{isMetadataOpen ? ( {isMetadataOpen ? (
<div className="space-y-4"> <div className="space-y-4">
<Input placeholder="<!DOCTYPE html><html..." value={pageSource} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPageSource(e.target.value)} /> <Input
placeholder="id.webp"
value={avatarPath}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setAvatarPath(e.target.value)
}
/>
<Button onClick={handleFetchAvatar} className="w-full"> <Button onClick={handleFetchAvatar} className="w-full">
Fetch Metadata Fetch Avatar
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<Input placeholder="URL" value={characterUrl} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCharacterUrl(e.target.value)} /> <Input
placeholder="URL or id.webp"
value={characterUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setCharacterUrl(e.target.value)
}
/>
<p className="text-sm text-muted-foreground">
For character URLs, a new tab will open with metadata. For image
filenames or full image URLs, the avatar will be set directly.
</p>
<Button onClick={handleOpenMetadata} className="w-full"> <Button onClick={handleOpenMetadata} className="w-full">
Fetch Metadata Fetch Avatar
</Button> </Button>
</div> </div>
)} )}
+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,16 +7,18 @@ interface DiscordBannerPermanentProps {
serverName?: string; serverName?: string;
description?: string; description?: string;
ctaText?: string; ctaText?: string;
className?: string;
} }
export function DiscordBannerPermanent({ export function DiscordBannerPermanent({
inviteLink, inviteLink,
serverName = "Avalon", serverName = "Avalon",
description = "Looking for somewhere more... interesting? Avalon's an enchanted collective of botmakers who refuse to be boring 🌿 Come play with us~ (18+ only, darling)", 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">67 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"