19 Commits

Author SHA1 Message Date
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
Severian d35d704ce1 chore: 2.2 2026-01-30 18:22:16 +08:00
Severian 549d94fe85 Merge pull request #7 from chill-protocol/webp-link
Option for webp file name or character image URL inputs
2025-12-21 12:41:20 +08:00
tui bdde78475e Added the option to directly input webp file name or character image link when creating PNG for character cards 2025-12-21 09:44:53 +13:00
Ema 06d8b2e36c Merge pull request #6 from severian-dev/docker-next-standalone
Docker next standalone
2025-12-10 22:37:39 -05:00
Ema 59acc534fa Cleaning readme. 2025-12-10 22:34:29 -05:00
Ema fdd13085c3 Removing runtime env 2025-12-10 22:31:54 -05:00
Severian 8923bf3f63 chore: prod env, no sourcemaps 2025-12-11 08:42:20 +08:00
Ema P. a02087915b Standalone Readme Editing 2025-12-10 12:38:10 -05:00
Ema P. e6e230ab84 Building image from .next standalone server. 2025-12-10 10:55:35 -05:00
Ema P. b3aece1e41 Adding next.config.js for standalone. 2025-12-10 10:52:36 -05:00
Ema 24441720d6 Merge pull request #5 from leri-a/master
Updating base image
2025-12-10 09:42:29 -05:00
Ema P. 2fc4e419b2 Updating base image 2025-12-10 09:39:17 -05:00
22 changed files with 1264 additions and 441 deletions
+5
View File
@@ -0,0 +1,5 @@
# Environment Variables for Sucker
# Disable Discord community banner (optional)
# Uncomment the line below to hide the Discord banner
# NEXT_PUBLIC_DISABLE_DISCORD_BANNER=true
+2
View File
@@ -1,2 +1,4 @@
node_modules
.next
.env.local
.env*.local
+17 -1
View File
@@ -1,9 +1,25 @@
# Sucker
Check package.json for commands, I can't be bothered.
### Environment Variables
Add these to your `.env.local` file (optional):
```bash
# Disable Discord community banner (defaults to showing banner)
NEXT_PUBLIC_DISABLE_DISCORD_BANNER=true
```
### Usage
Pull this repostory and build with `npm run build`. You can start the server with `node ./.next/standalone/server.js`
You can also build and run Sucker as a Docker container with `docker compose build` and `docker compose up`.
### 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.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.
- also 2.0: V2 charcard format and alternate greetings.
+1 -2
View File
@@ -2,7 +2,6 @@ services:
web:
build: .
image: sucker
restart: unless-stopped
ports:
- "3000:3000"
environment:
NODE_ENV: production
+10 -13
View File
@@ -1,7 +1,5 @@
FROM node:18-alpine AS base
FROM node:22-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./
RUN \
@@ -12,23 +10,22 @@ RUN \
fi
FROM base AS builder
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS runner
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
FROM node:22-alpine AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
USER nextjs
EXPOSE 3000
CMD ["npm", "start"]
CMD ["node", "server.js"]
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
productionBrowserSourceMaps: false,
};
module.exports = nextConfig;
+254
View File
@@ -11,6 +11,7 @@
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@types/react": "^19.2.7",
@@ -59,6 +60,44 @@
"tslib": "^2.4.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz",
"integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz",
"integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.4",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz",
"integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.5"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@img/colour": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
@@ -766,6 +805,29 @@
}
}
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collapsible": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
@@ -966,6 +1028,35 @@
}
}
},
"node_modules/@radix-ui/react-dropdown-menu": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
"integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-menu": "2.1.16",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
@@ -1024,6 +1115,96 @@
}
}
},
"node_modules/@radix-ui/react-menu": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
"integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
@@ -1113,6 +1294,37 @@
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",
@@ -1262,6 +1474,48 @@
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+1
View File
@@ -12,6 +12,7 @@
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@types/react": "^19.2.7",
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+22 -1
View File
@@ -93,6 +93,15 @@ function parseConversationToken(content: string | undefined | null): string | nu
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 {
let result = content;
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}`;
}
// 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();
// Return SSE response if requested, otherwise JSON
@@ -528,7 +549,7 @@ export async function POST(request: NextRequest) {
console.error("Error processing request:", error);
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
const acceptHeader = request.headers.get("accept");
+455 -220
View File
@@ -20,18 +20,20 @@ import {
import { Input } from "@/components/ui/input";
import { Png } from "@/lib/png";
import {
ChevronUp,
ChevronDown,
Copy,
ChevronLeft,
ChevronRight,
Menu,
} from "lucide-react";
import {
CollapsibleContent,
Collapsible,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { CollapsibleInfobox } from "@/components/ui/collapsible-infobox";
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
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 {
@@ -66,7 +68,6 @@ interface Card {
}
export default function Home() {
const [isInstructionsOpen, setIsInstructionsOpen] = useState(false);
const [cards, setCards] = useState<Card[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedCardIndex, setSelectedCardIndex] = useState<number | null>(
@@ -82,6 +83,9 @@ export default function Home() {
const [altGreetingIndexById, setAltGreetingIndexById] = useState<
Record<string, number>
>({});
const [mirrorsDialogOpen, setMirrorsDialogOpen] = useState(false);
const [changelogDialogOpen, setChangelogDialogOpen] = useState(false);
const [howToUseDialogOpen, setHowToUseDialogOpen] = useState(false);
const [proxyUrl, setProxyUrl] = useState(
"https://sucker.severian.dev/api/proxy"
@@ -258,8 +262,7 @@ export default function Home() {
const cardData = JSON.stringify(pngData);
const newImageData = Png.Generate(arrayBuffer, cardData);
const newFileName = `${
(card.initialVersion?.name || card.data.name).replace(
const newFileName = `${(card.initialVersion?.name || card.data.name).replace(
/[^a-zA-Z0-9\-_]/g,
"_"
) || "character"
@@ -290,6 +293,11 @@ export default function Home() {
};
const handleOpenMetadata = () => {
// Check if the input is a character metadata URL (janitorai.com/characters/...)
const isCharacterUrl = /janitorai\.com\/characters\//.test(characterUrl);
if (isCharacterUrl) {
// Extract character ID and open metadata page, then show second input
const match = characterUrl.match(/characters\/([\w-]+)/);
if (match && match[1]) {
const characterId = match[1].split("_")[0];
@@ -299,6 +307,31 @@ export default function Home() {
);
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;
}
};
const handleFetchAvatar = async () => {
@@ -335,13 +368,73 @@ export default function Home() {
gtag('config', 'G-YVD6QFSR71');`}
</Script>
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-4">
{/* Mobile Layout */}
<div className="sm:hidden mb-4">
{/* Row 1: Title on left, Hamburger on right */}
<div className="flex justify-between items-start mb-3">
<div>
<h1 className="text-3xl font-bold">Sucker v2.1</h1>
<p className="text-sm text-muted-foreground">
Just some notes this time
<h1 className="text-2xl font-bold">Sucker v2.4</h1>
<p className="text-xs text-muted-foreground">
ECHO: Consider joining Avalon!
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Menu className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setHowToUseDialogOpen(true)}>
How to Use
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setMirrorsDialogOpen(true)}>
Mirrors
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setChangelogDialogOpen(true)}>
Changelog
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Row 2: Full-width Refresh button */}
<Button
onClick={fetchCards}
variant="outline"
disabled={isRefreshing}
className="w-full"
>
{isRefreshing ? "Refreshing..." : "Refresh"}
</Button>
</div>
{/* Desktop Layout */}
<div className="hidden sm:flex justify-between items-center mb-4">
<div>
<h1 className="text-3xl font-bold">Sucker v2.4</h1>
<p className="text-sm text-muted-foreground">
ECHO: Consider joining Avalon!
</p>
</div>
<div className="flex gap-2">
<Button
onClick={() => setHowToUseDialogOpen(true)}
variant="outline"
>
How to Use
</Button>
<Button
onClick={() => setMirrorsDialogOpen(true)}
variant="outline"
>
Mirrors
</Button>
<Button
onClick={() => setChangelogDialogOpen(true)}
variant="outline"
>
Changelog
</Button>
<Button
onClick={fetchCards}
variant="outline"
@@ -350,207 +443,42 @@ export default function Home() {
{isRefreshing ? "Refreshing..." : "Refresh"}
</Button>
</div>
</div>
<Separator className="my-4" />
{/* Collapsible infoboxes */}
{[
{
title: "(Dec 2025) A note about fetching avatars",
content: (
<>
<p className="text-sm text-muted-foreground">
The platform you suck from has implemented limited visibility
of metadata for certain content with a particular 'obscenity
rating'. This means that in some cases, the Fetch Avatar flow
here will show a 404 - character not found error at the end.
</p>
<p className="text-sm text-muted-foreground">
Sometimes (but not always), the avatar URL can still be
fetched after a day or two since the bot was published.
</p>
<p className="text-sm text-muted-foreground">
As of this moment, can't really find a fix for it, so you'll
have to download the image yourself and just add the image to
the card someplace else.
</p>
</>
),
},
{
title:
"(Oct 2025) V2 charcard format, multi-turn support for scripts/lorebooks, alternate greetings.",
content: (
<p className="text-sm text-muted-foreground">
Sucker now tracks changes to character descriptions and
scenarios across multiple messages. Cards with multiple versions
show a version badge and offer a "Download Changes" button to
get the change history.
<br />
Alternate greetings are also supported. Sucker will provide you
with a conversation ID that you can use to start off a new chat
when capturing alternate greetings, send it as first message
instead of the character name.
<br />
Directions are updated below. Make sure you read 'em.
</p>
),
},
{
title: "List of mirrors",
content: (
<>
<p className="text-sm text-muted-foreground">
Sucker goes down sometimes on severian.dev because I use the
server for other stuff. Here's a full list of existing sucker
instances (thanks to those who signed up for it!):
</p>
<ul className="text-sm flex flex-col sm:flex-row list-none">
<li className="after:content-none sm:after:mx-2 sm:after:content-['•'] sm:last:after:content-none">
<a
className="text-yellow-600"
href="https://sucker.severian.dev"
>
severian.dev
</a>
</li>
<li className="after:content-none sm:after:mx-2 sm:after:content-['•'] sm:last:after:content-none">
<a
className="text-yellow-600"
href="https://sucker.trashpanda.land"
>
trashpanda.land
</a>
</li>
<li className="after:content-none sm:after:mx-2 sm:after:content-['•'] sm:last:after:content-none">
<a
className="text-yellow-600"
href="https://sucker.hitani.me"
>
hitani.me
</a>
</li>
<li className="after:content-none sm:after:mx-2 sm:after:content-['•'] sm:last:after:content-none">
<a
className="text-yellow-600"
href="https://succ.portalnexus.link"
>
portalnexus.link
</a>
</li>
<li className="after:content-none sm:after:mx-2 sm:after:content-['•'] sm:last:after:content-none">
<a
className="text-yellow-600"
href="https://sucker.lemuria.dev"
>
lemuria.dev
</a>
</li>
</ul>
<p className="text-sm text-muted-foreground">
<br />
If you're interested in hosting your own sucker instance, lmk
via Discord: @lyseverian, I've made the GH repo private for
now. Or send me a message if there's anything you think that
could be added here, open to suggestions.
</p>
</>
),
},
].map((infobox, idx) => (
<CollapsibleInfobox
key={infobox.title}
title={infobox.title}
defaultOpen={false}
>
{infobox.content}
</CollapsibleInfobox>
))}
<Collapsible
open={isInstructionsOpen}
onOpenChange={setIsInstructionsOpen}
className="w-full mb-8"
>
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold">How to Use</h2>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-9 p-0">
{isInstructionsOpen ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
{/* Discord Banner - Disabled if NEXT_PUBLIC_DISABLE_DISCORD_BANNER is set */}
{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
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="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" />,
]}
/>
</>
)}
<span className="sr-only">Toggle instructions</span>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="mt-4">
<div className="prose dark:prose-invert max-w-none">
<p className="mb-2">
Follow every instruction here to the letter because it's all you
need to know and I have no intent of helping you further.
</p>
<ol className="list-decimal list-inside">
<li className="mb-2">
Put <code style={{ color: "#fff0b9" }}>{proxyUrl}</code> in
your API settings, any value for model and key.
</li>
<li className="mb-2">
REQUIRED: Set your custom prompt to{" "}
<code style={{ color: "#fff0b9" }}>&lt;.&gt;</code>
</li>
<li className="mb-2">
REQUIRED: Set your persona (or create a new one) with the name{" "}
<code style={{ color: "#fff0b9" }}>&#123;user&#125;</code> and
the description should only have{" "}
<code style={{ color: "#fff0b9" }}>.</code> in it.
</li>
<li className="mb-2">
Save settings and refresh the page. Not this page. <i>That</i>{" "}
page.
</li>
<li className="mb-2">Start a new chat with a character.</li>
<li className="mb-2">
Char name inference is implemented: if you send just a dot:{" "}
<code style={{ color: "#fff0b9" }}>.</code>, sucker will use
the inferred name from the persona tag, or you can send the
character name yourself.
</li>
<li className="mb-2">
Hit the Refresh button here, and the cards should appear here.
</li>
<li className="mb-2">
If you're interested in capturing alternate greetings, start a
new chat and send the conversation ID as first message instead
of the character name. The format is{" "}
<code style={{ color: "#fff0b9" }}>
[sucker:conv=conversationId]
</code>{" "}
which you'll be given when creating a new card.
</li>
<li className="mb-2">
You can also send more messages with possible keywords to
trigger scripts/lorebooks. Sucker will track changes to the
description and scenario fields. Cards with multiple versions
will show a version badge and offer a "Download Changes"
button to get a detailed change history with timestamps.
Unfortunately, lorebook creation is out of scope at the
moment, but you can use the changes detected to modify the
character card yourself post-export.
</li>
<li className="mb-2">
Download the JSON files or go through a little more effort to
get PNGs instead.
</li>
</ol>
<p className="mb-2">
Extractions will only last for 10 minutes, after which they're
discarded. Reloading the page will remove any attached avatars.
I'm not storing shit.
</p>
</div>
</CollapsibleContent>
</Collapsible>
<div className="max-w-3xl mx-auto space-y-6">
{cards.length === 0 ? (
@@ -650,8 +578,7 @@ export default function Home() {
card.alternate_greetings.length > 0 && (
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium">{`Alternate Greetings (${
card.alternate_greetings?.length || 0
<h4 className="font-medium">{`Alternate Greetings (${card.alternate_greetings?.length || 0
})`}</h4>
<div className="flex items-center gap-2">
<Button
@@ -859,16 +786,325 @@ export default function Home() {
</div>
</div>
{/* How to Use Dialog */}
<Dialog open={howToUseDialogOpen} onOpenChange={setHowToUseDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>How to Use</DialogTitle>
<DialogDescription>
Follow every instruction here to the letter because it&apos;s all you
need to know and I have no intent of helping you further.
</DialogDescription>
</DialogHeader>
<div className="prose dark:prose-invert max-w-none">
<ol className="list-decimal list-inside space-y-3 text-sm">
<li>
Put <code className="bg-muted px-1 py-0.5 rounded text-yellow-600">{proxyUrl}</code> in
your API settings, any value for model and key.
</li>
<li>
REQUIRED: Set your custom prompt to{" "}
<code className="bg-muted px-1 py-0.5 rounded text-yellow-600">&lt;.&gt;</code>
</li>
<li>
REQUIRED: Set your persona (or create a new one) with the name{" "}
<code className="bg-muted px-1 py-0.5 rounded text-yellow-600">&#123;user&#125;</code> and
the description should only have{" "}
<code className="bg-muted px-1 py-0.5 rounded text-yellow-600">.</code> in it.
</li>
<li>
Save settings and refresh the page. Not this page. <i>That</i>{" "}
page.
</li>
<li>Start a new chat with a character.</li>
<li>
Char name inference is implemented: if you send just a dot:{" "}
<code className="bg-muted px-1 py-0.5 rounded text-yellow-600">.</code>, sucker will use
the inferred name from the persona tag, or you can send the
character name yourself.
</li>
<li>
Hit the Refresh button here, and the cards should appear here.
</li>
<li>
If you&apos;re interested in capturing alternate greetings, start a
new chat and send the conversation ID as first message instead
of the character name. The format is{" "}
<code className="bg-muted px-1 py-0.5 rounded text-yellow-600">
[sucker:conv=conversationId]
</code>{" "}
which you&apos;ll be given when creating a new card.
</li>
<li>
You can also send more messages with possible keywords to
trigger scripts/lorebooks. Sucker will track changes to the
description and scenario fields. Cards with multiple versions
will show a version badge and offer a &quot;Download Changes&quot;
button to get a detailed change history with timestamps.
Unfortunately, lorebook creation is out of scope at the
moment, but you can use the changes detected to modify the
character card yourself post-export.
</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>
Download the JSON files or go through a little more effort to
get PNGs instead.
</li>
</ol>
<p className="mt-4 text-sm text-muted-foreground">
Extractions will only last for 10 minutes, after which they&apos;re
discarded. Reloading the page will remove any attached avatars.
I&apos;m not storing shit.
</p>
</div>
</DialogContent>
</Dialog>
{/* Mirrors Dialog */}
<Dialog open={mirrorsDialogOpen} onOpenChange={setMirrorsDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Sucker Mirrors</DialogTitle>
<DialogDescription>
Sucker goes down sometimes on severian.dev because I use the
server for other stuff. Here&apos;s a full list of existing sucker
instances (thanks to those who signed up for it!):
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<ul className="text-sm flex flex-col gap-2">
<li>
<a
className="text-yellow-600 hover:underline"
href="https://sucker.severian.dev"
target="_blank"
rel="noopener noreferrer"
>
severian.dev
</a>
</li>
<li>
<a
className="text-yellow-600 hover:underline"
href="https://sucker.trashpanda.land"
target="_blank"
rel="noopener noreferrer"
>
trashpanda.land
</a>
</li>
<li>
<a
className="text-yellow-600 hover:underline"
href="https://sucker.hitani.me"
target="_blank"
rel="noopener noreferrer"
>
hitani.me
</a>
</li>
<li>
<a
className="text-yellow-600 hover:underline"
href="https://succ.portalnexus.link"
target="_blank"
rel="noopener noreferrer"
>
portalnexus.link
</a>
</li>
<li>
<a
className="text-yellow-600 hover:underline"
href="https://sucker.lemuria.dev"
target="_blank"
rel="noopener noreferrer"
>
lemuria.dev
</a>
</li>
<li>
<a
className="text-yellow-600 hover:underline"
href="https://milker.ladyofpa.in/"
target="_blank"
rel="noopener noreferrer"
>
ladyofpa.in
</a>
</li>
</ul>
<p className="text-sm text-muted-foreground pt-4">
If you&apos;re interested in hosting your own sucker instance, lmk
via Discord: @lyseverian, I&apos;ve made the GH repo private for
now. Or send me a message if there&apos;s anything you think that
could be added here, open to suggestions.
</p>
</div>
</DialogContent>
</Dialog>
{/* Changelog Dialog */}
<Dialog open={changelogDialogOpen} onOpenChange={setChangelogDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Changelog</DialogTitle>
<DialogDescription>
Recent updates and changes to Sucker
</DialogDescription>
</DialogHeader>
<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>
<h3 className="font-semibold text-lg mb-2">Jan 2026 - Direct image input</h3>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
You can now paste webp filenames (like id.webp) or full image URLs
directly into the avatar field without having to open the metadata tab first.
</p>
<p>
Makes grabbing avatars way faster when you already know the image path.
</p>
<p>
You should also consider joining our new thing, a Discord community server for botmakers:{" "}
<a
href="https://discord.gg/5jQKkCfHP3"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
discord.gg/5jQKkCfHP3
</a>
</p>
</div>
</div>
<Separator />
<div>
<h3 className="font-semibold text-lg mb-2">Dec 2025 - A note about fetching avatars</h3>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
The platform you suck from has implemented limited visibility
of metadata for certain content with a particular &apos;obscenity
rating&apos;. This means that in some cases, the Fetch Avatar flow
here will show a 404 - character not found error at the end.
</p>
<p>
Sometimes (but not always), the avatar URL can still be
fetched after a day or two since the bot was published.
</p>
<p>
As of this moment, can&apos;t really find a fix for it, so you&apos;ll
have to download the image yourself and just add the image to
the card someplace else.
</p>
</div>
</div>
<Separator />
<div>
<h3 className="font-semibold text-lg mb-2">
Oct 2025 - V2 charcard format, multi-turn support
</h3>
<div className="text-sm text-muted-foreground space-y-2">
<p>
Sucker now tracks changes to character descriptions and
scenarios across multiple messages. Cards with multiple versions
show a version badge and offer a &quot;Download Changes&quot; button to
get the change history.
</p>
<p>
Alternate greetings are also supported. Sucker will provide you
with a conversation ID that you can use to start off a new chat
when capturing alternate greetings, send it as first message
instead of the character name.
</p>
<p>
Directions are updated below. Make sure you read &apos;em.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isMetadataOpen ? "Enter Avatar Path" : "Enter Character URL"}
{isMetadataOpen ? "Enter Avatar Path" : "Fetch Avatar"}
</DialogTitle>
<DialogDescription>
{isMetadataOpen
? "Look for the avatar field in the opened tab and paste the value here."
: "Enter the Janitor character URL (https://janitorai.com/characters/...)."}
: "Enter a character URL (janitorai.com/characters/...) to open metadata, or paste an image filename (id.webp) or full image URL directly."}
</DialogDescription>
</DialogHeader>
@@ -888,19 +1124,18 @@ export default function Home() {
) : (
<div className="space-y-4">
<Input
placeholder="https://janitorai.com/characters/..."
placeholder="URL or id.webp"
value={characterUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setCharacterUrl(e.target.value)
}
/>
<p className="text-sm text-muted-foreground">
Upon clicking this button, a new tab will open with the
character's metadata. Look for the avatar field and copy the
value before returning to this page.
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">
Open Metadata
Fetch Avatar
</Button>
</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>
);
}
-52
View File
@@ -1,52 +0,0 @@
import { useState } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { Button } from "@/components/ui/button";
export interface CollapsibleInfoboxProps {
title: string;
children: React.ReactNode;
defaultOpen?: boolean;
className?: string;
}
export function CollapsibleInfobox({
title,
children,
defaultOpen = false,
className = "",
}: CollapsibleInfoboxProps) {
const [open, setOpen] = useState(defaultOpen);
return (
<div
className={`bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4 ${className}`}
>
<div
className="flex items-center justify-between cursor-pointer"
onClick={() => setOpen((v) => !v)}
>
<span className="text-lg font-semibold text-blue-800 dark:text-blue-200">
{title}
</span>
<Button
variant="ghost"
size="sm"
className="w-9 p-0"
tabIndex={-1}
type="button"
onClick={(e) => {
e.stopPropagation();
setOpen((v) => !v);
}}
>
{open ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
<span className="sr-only">Toggle {title}</span>
</Button>
</div>
{open && <div className="mt-2">{children}</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>
);
}
@@ -0,0 +1,112 @@
"use client";
import Image from "next/image";
interface DiscordBannerPermanentProps {
inviteLink: string;
serverName?: string;
description?: string;
ctaText?: string;
className?: string;
}
export function DiscordBannerPermanent({
inviteLink,
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 = "mb-4",
}: DiscordBannerPermanentProps) {
return (
<div className={`relative rounded-lg overflow-hidden shadow-lg bg-[#36393f] ${className}`}>
{/* Background Image */}
<div className="absolute inset-0">
<Image
src="/avalon/bg.webp"
alt="Background"
fill
className="object-cover opacity-20"
/>
</div>
{/* Content */}
<div className="relative p-3 sm:p-4">
{/* Chat Message Style */}
<div className="flex gap-3 mb-2">
{/* Morgana Avatar */}
<div className="flex-shrink-0 pt-0.5">
<Image
src="/avalon/morgana-thumb.webp"
alt="Morgana"
width={40}
height={40}
className="rounded-full w-10 h-10"
/>
</div>
{/* Message Content */}
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2 mb-0.5">
<span className="text-white font-medium text-[15px]">Morgana</span>
<span className="text-[#a3a6aa] text-[11px] font-medium">Today at {new Date().toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })}</span>
</div>
<div className="text-[#dcddde] text-[15px] leading-[1.375rem] mb-2">
{description}
</div>
{/* Server Invite Embed */}
<div className="bg-[#2f3136] rounded border-l-4 border-[#5865F2] overflow-hidden max-w-[432px]">
<div className="p-4 pb-3">
<div className="text-[#00b0f4] text-xs font-semibold uppercase mb-2">
You&apos;ve been invited to join a server
</div>
<div className="flex items-center gap-4">
<Image
src="/avalon/avalon-pfp.webp"
alt="Avalon"
width={50}
height={50}
className="rounded-2xl w-[50px] h-[50px] flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<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="font-medium">885 members</div>
<a
href="https://from-avalon.com"
target="_blank"
rel="noopener noreferrer"
className="text-[#00aff4] hover:underline text-xs inline-block"
>
from-avalon.com
</a>
</div>
</div>
<a
href={inviteLink}
target="_blank"
rel="noopener noreferrer"
className="bg-[#248046] hover:bg-[#1a6334] text-white px-4 py-2 text-sm rounded font-medium transition-colors flex-shrink-0"
>
{ctaText}
</a>
</div>
</div>
</div>
</div>
</div>
{/* Trash Panda Icon - Bottom Right */}
<div className="absolute bottom-1.5 right-1.5">
<Image
src="/avalon/trashpanda.webp"
alt=""
width={20}
height={20}
className="rounded-full w-5 h-5 ring-1 ring-black/20 opacity-60 hover:opacity-100 transition-opacity"
/>
</div>
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className = "", sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={`z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ${className}`}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className = "", inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={`relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 ${
inset ? "pl-8" : ""
} ${className}`}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem };