next-site: custom channels, admin data-round-trip fix, visual polish

Public index.json now exposes every admin-set channel name (not just a hardcoded
"stable"), matching Next's real repository protocol - the in-app UI lists whatever
channel names a repo defines. Admin panel now reads from a new /api/admin/state
endpoint (full override + channel + site data) instead of /index.json, which only
ever carried the public-facing fields - fixes fields appearing blank on reload.
Channel editing is now a free-form name+version input instead of a single
"promote to stable" button.

Also: color accents, drifting background blobs, a few floating decorative icons
(pride flag included), and a custom cursor follower on desktop, applied site-wide
via the shared theme shell.
This commit is contained in:
Ally Founder 2026-08-15 13:44:42 -04:00
parent 0d6da1879f
commit 0f20a12b93
10 changed files with 192 additions and 120 deletions

View file

@ -1,11 +1,3 @@
/**
* Runs `fn` and swallows any throw, returning `fallback` instead.
*
* `getModules`'s callback runs whenever Discord itself initializes a matching module, which can
* be deep inside Discord's own boot sequence - completely outside any try/catch wrapped around
* the initial registration call. An uncaught throw there crashes app startup instead of just
* skipping the ring patch.
*/
export function guard<T>(fn: () => T, fallback: T): T {
try {
return fn()

View file

@ -6,11 +6,6 @@ import type { JsonStorage } from '@revenge-mod/json-storage'
import type { ElementType } from 'react'
import type { RadialStatusStorage } from '../lib/types'
// Forked from AngelW0lf's Radial Status (BSD-3-Clause, see LICENSE), ported from the Classic
// (Vendetta-compat) version of this plugin. Real presence-carrying wrappers are square,
// borderRadius === width/2, with a user.id + status on children[1]/[3], and exactly 5 children -
// matched against this confirmed set of sizes rather than any circular shape. This heuristic is
// Discord-UI-generic (not Classic/Next specific) and is carried over unchanged.
const CONFIRMED_SIZES = new Set([24, 32, 40, 50, 60, 80])
function tryApplyRing(
@ -40,11 +35,9 @@ function tryApplyRing(
const colors = storage.cache?.colors ?? {}
const color = colors[presenceProps.status as string]
if (!color) return // no color configured for this status - leave the native dot alone
if (!color) return
const baseSize = wrapper.style[circleIdx].width
// Additive growth (fixed px), not a percentage multiplier - a flat multiplier made the ring
// way too thick on large avatars (YouBar) relative to small ones (member list).
const thickness = storage.cache?.ringThickness ?? 2
const newSize = baseSize + thickness * 2
@ -67,15 +60,6 @@ function tryApplyRing(
})
}
/**
* `General.View` in Classic (Vendetta-compat) resolves to `findByProps("Button", "Text", "View")`
* (confirmed against vendetta-mod/Vendetta's own source, since `@revenge-mod/discord/design`'s
* `Design` object does NOT expose raw View/Text/Button - it's the higher-level design-system kit,
* a different module). `getModules` (not `lookupModule`) is used deliberately - matches this
* repo's own house convention (see staff-tags/js/patches/name.tsx): forcing this to initialize
* during `start()`, before Discord is done booting, is what crashed app startup for staff-tags
* the first time it shipped.
*/
export default function patchRing(storage: JsonStorage<RadialStatusStorage>) {
const cleanups: (() => void)[] = []

View file

@ -21,11 +21,6 @@ interface ColorInputProps {
onChange: (value: string) => void
}
/**
* Hex color field with a live swatch preview and tap-to-pick presets. Built entirely from RN
* primitives (no Discord-internal component reuse), so it doesn't carry the same "might silently
* stop rendering after a Discord update" risk as the plugin's UI patches do.
*/
export default function ColorInput({
title,
value,

View file

@ -4,7 +4,6 @@ import ColorInput from '../ColorInput'
import type { PluginApi } from '@revenge-mod/plugins/types'
import type { RadialStatusStorage } from '../../lib/types'
// Offline is opt-in (blank by default) since Discord doesn't show a colored dot for it normally.
const STATUSES: { key: string; label: string; defaultColor?: string }[] = [
{ key: 'online', label: 'Online', defaultColor: '#23A55A' },
{ key: 'idle', label: 'Idle', defaultColor: '#F0B232' },
@ -12,14 +11,6 @@ const STATUSES: { key: string; label: string; defaultColor?: string }[] = [
{ key: 'offline', label: 'Offline', defaultColor: '#80848E' },
]
/**
* `Design` is read inside the component (not destructured at module top level): external plugin
* bundles run via a bare `new Function('revenge', 'plugin', script)` call at preInit with no
* Metro-style dependency ordering, so `revenge.discord.design.Design`'s fields aren't guaranteed
* populated yet at that point (see staff-tags/js/ui/pages/Settings.tsx for the same note). By the
* time this actually renders (the user opened the plugin's settings page), Discord's design
* system is long since ready.
*/
export default function Settings({
api,
}: {

View file

@ -9,28 +9,25 @@ CREATE TABLE plugin_overrides (
accent TEXT,
status TEXT,
how_it_works TEXT,
features TEXT, -- JSON array of strings
commands TEXT, -- JSON array of {cmd, desc}
features TEXT,
commands TEXT,
limitations TEXT,
hidden INTEGER NOT NULL DEFAULT 0,
is_draft INTEGER NOT NULL DEFAULT 0,
manifest TEXT, -- only used when is_draft = 1
main_js TEXT, -- only used when is_draft = 1
manifest TEXT,
main_js TEXT,
updated_at TEXT NOT NULL
);
DROP TABLE IF EXISTS plugin_channels;
CREATE TABLE plugin_channels (
plugin_id TEXT NOT NULL,
channel TEXT NOT NULL, -- 'alpha' | 'beta' | 'stable'
channel TEXT NOT NULL,
version TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (plugin_id, channel)
);
-- Single-row table (id always 'site') for site-wide admin-editable metadata: name/description
-- shown in the browse page hero and used as the default OG tags, overriding repo.config.json's
-- static values without needing a redeploy.
CREATE TABLE IF NOT EXISTS site_settings (
id TEXT PRIMARY KEY,
name TEXT,

View file

@ -1,9 +1,16 @@
import { setChannel, setSiteSettings, upsertOverride } from '../db'
import {
getAllChannels,
getAllOverrides,
getSiteSettings,
setChannel,
setSiteSettings,
upsertOverride,
} from '../db'
import { checkAuth } from './auth'
import type { Env } from '../env'
import type { BaseIndex } from '../publicIndex'
const PLUGIN_ID_REGEX = /^[a-zA-Z0-9.-]+$/ // matches generate-index.ts's own validation exactly
const PLUGIN_ID_REGEX = /^[a-zA-Z0-9.-]+$/
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
@ -14,7 +21,6 @@ function json(data: unknown, status = 200): Response {
const DEFAULT_MAIN_JS = `export default plugin({\n\tstart() {},\n})\n`
/** Returns null if `url.pathname` isn't an admin API route, so the caller can fall through. */
export async function handleAdminApi(
request: Request,
env: Env,
@ -24,6 +30,17 @@ export async function handleAdminApi(
if (!checkAuth(request, env)) return json({ error: 'unauthorized' }, 401)
if (url.pathname === '/api/admin/state' && request.method === 'GET') {
const baseRes = await env.ASSETS.fetch('https://internal/index.json')
const base = await baseRes.json<BaseIndex>()
const [overrides, channels, site] = await Promise.all([
getAllOverrides(env),
getAllChannels(env),
getSiteSettings(env),
])
return json({ base, overrides, channels, site })
}
if (url.pathname === '/api/admin/site' && request.method === 'PUT') {
const body = await request
.json<{ name?: string; description?: string }>()
@ -93,8 +110,6 @@ export async function handleAdminApi(
const body = await request.json<{ version?: string }>().catch(() => null)
if (!body?.version) return json({ error: 'version required' }, 400)
// Mirrors generate-index.ts's own "Channel override points at unpublished version" check -
// a channel can only ever point at a version that's actually in the built index.
const baseRes = await env.ASSETS.fetch('https://internal/index.json')
const base = await baseRes.json<BaseIndex>()
const plugin = base.plugins[id]

View file

@ -3,16 +3,6 @@ import { htmlShell, renderHeader } from '../theme'
const INPUT_STYLE =
'background:#15161c;border:1px solid rgba(255,255,255,0.12);color:#e8e9ed;padding:7px 9px;border-radius:6px;width:100%;font-family:inherit;font-size:12.5px;'
/**
* Single-page admin UI: a token field gates everything else client-side (the real gate is
* server-side, in handleAdminApi - this is just UX, not security). Once a token is entered, it
* fetches /index.json for the plugin list and lets the admin PUT overrides/channels directly.
*
* Exposes every field the backend already supports (db.ts's Override type) - not just tagline
* and hidden. `features`/`commands` are array-shaped, edited as raw JSON textareas rather than
* a bespoke list editor for each - functional and complete over polished, matching "edit
* anything" as the actual requirement here.
*/
export function renderAdminPage(): string {
const body = `
${renderHeader({ brandName: 'Admin' })}
@ -47,14 +37,19 @@ export function renderAdminPage(): string {
</div>\`
}
async function loadSiteSettings() {
const res = await fetch('/index.json')
const data = await res.json()
async function loadState() {
const res = await fetch('/api/admin/state', {
headers: { authorization: \`Bearer \${token}\` },
})
return res.json()
}
function renderSite(state) {
document.getElementById('site').innerHTML = \`
<div class="card" style="margin-bottom:20px;">
<div class="card-name">Site settings</div>
\${fieldRow('site', 'name', 'Site name', data.name)}
\${fieldRow('site', 'description', 'Site description', data.description, true)}
\${fieldRow('site', 'name', 'Site name', state.site?.name)}
\${fieldRow('site', 'description', 'Site description', state.site?.description, true)}
<button id="save-site" class="btn" style="border:none;cursor:pointer;">Save site settings</button>
</div>\`
@ -66,32 +61,45 @@ export function renderAdminPage(): string {
headers: { 'content-type': 'application/json', authorization: \`Bearer \${token}\` },
body: JSON.stringify({ name, description }),
})
alert('Saved site settings')
init()
})
}
async function loadPlugins() {
const res = await fetch('/index.json')
const data = await res.json()
function channelRows(id, existing) {
const entries = Object.entries(existing ?? {})
const rows = entries.length ? entries : [['', '']]
return rows.map(([channel, version]) => \`
<div style="display:flex;gap:8px;margin-bottom:6px;" class="channel-row">
<input placeholder="channel name" value="\${channel}" data-channel-name style="\${INPUT_STYLE}width:140px;flex:none;">
<input placeholder="version" value="\${version}" data-channel-version style="\${INPUT_STYLE}width:100px;flex:none;">
<button data-id="\${id}" class="btn-ghost set-channel" style="border-width:1px;cursor:pointer;">Set</button>
</div>\`).join('')
}
function renderPlugins(state) {
const pluginsEl = document.getElementById('plugins')
pluginsEl.innerHTML = Object.entries(data.plugins).map(([id, p]) => \`
<div class="card" style="margin-bottom:16px;">
<div class="card-name">\${p.name} <span class="card-meta">(\${id})</span></div>
<div style="margin-top:12px;">
\${TEXT_FIELDS.map(([key, label]) => fieldRow(id, key, label, p[key])).join('')}
\${fieldRow(id, 'features', 'Features (JSON array of strings)', p.features ? JSON.stringify(p.features) : '', true)}
\${fieldRow(id, 'commands', 'Commands (JSON array of {cmd, desc})', p.commands ? JSON.stringify(p.commands) : '', true)}
<label style="font-size:11px;display:flex;align-items:center;gap:4px;margin-bottom:10px;">
<input type="checkbox" data-id="\${id}" data-field="hidden"> hidden
</label>
<button data-id="\${id}" class="btn save" style="border:none;cursor:pointer;">Save</button>
</div>
<div style="margin-top:14px;padding-top:14px;border-top:1px solid rgba(255,255,255,0.08);display:flex;gap:8px;">
<input placeholder="version" data-id="\${id}" data-channel="stable" style="\${'${INPUT_STYLE}'}width:100px;flex:none;">
<button data-id="\${id}" data-channel="stable" class="btn promote" style="border:none;cursor:pointer;">Promote to stable</button>
</div>
</div>
\`).join('')
pluginsEl.innerHTML = Object.entries(state.base.plugins).map(([id, p]) => {
const o = state.overrides[id] ?? {}
const merged = { ...p, ...o }
return \`
<div class="card" style="margin-bottom:16px;">
<div class="card-name">\${p.name} <span class="card-meta">(\${id})</span></div>
<div style="margin-top:12px;">
\${TEXT_FIELDS.map(([key, label]) => fieldRow(id, key, label, merged[key])).join('')}
\${fieldRow(id, 'features', 'Features (JSON array of strings)', merged.features ? JSON.stringify(merged.features) : '', true)}
\${fieldRow(id, 'commands', 'Commands (JSON array of {cmd, desc})', merged.commands ? JSON.stringify(merged.commands) : '', true)}
<label style="font-size:11px;display:flex;align-items:center;gap:4px;margin-bottom:10px;">
<input type="checkbox" data-id="\${id}" data-field="hidden" \${o.hidden ? 'checked' : ''}> hidden
</label>
<button data-id="\${id}" class="btn save" style="border:none;cursor:pointer;">Save</button>
</div>
<div style="margin-top:14px;padding-top:14px;border-top:1px solid rgba(255,255,255,0.08);">
<div style="font-size:10.5px;color:rgba(255,255,255,0.4);margin-bottom:6px;">Channels (any name - Next's client lists these for the user to pick)</div>
<div class="channels" data-id="\${id}">\${channelRows(id, state.channels[id])}</div>
<button data-id="\${id}" class="btn-ghost add-channel-row" style="border-width:1px;cursor:pointer;margin-top:4px;">+ Add channel</button>
</div>
</div>\`
}).join('')
pluginsEl.querySelectorAll('.save').forEach(btn => btn.addEventListener('click', async () => {
const id = btn.dataset.id
@ -116,30 +124,56 @@ export function renderAdminPage(): string {
headers: { 'content-type': 'application/json', authorization: \`Bearer \${token}\` },
body: JSON.stringify(patch),
})
alert('Saved ' + id)
init()
}))
pluginsEl.querySelectorAll('.promote').forEach(btn => btn.addEventListener('click', async () => {
pluginsEl.querySelectorAll('.add-channel-row').forEach(btn => btn.addEventListener('click', () => {
const id = btn.dataset.id
const channel = btn.dataset.channel
const version = pluginsEl.querySelector(\`[data-id="\${id}"][data-channel="\${channel}"]\`).value
const res = await fetch(\`/api/admin/channels/\${id}/\${channel}\`, {
method: 'PUT',
headers: { 'content-type': 'application/json', authorization: \`Bearer \${token}\` },
body: JSON.stringify({ version }),
})
const data = await res.json()
alert(res.ok ? \`Promoted \${id}@\${version} to \${channel}\` : data.error)
const container = pluginsEl.querySelector(\`.channels[data-id="\${id}"]\`)
container.insertAdjacentHTML('beforeend', channelRows(id, { '': '' }))
wireChannelButtons()
}))
wireChannelButtons()
function wireChannelButtons() {
pluginsEl.querySelectorAll('.set-channel').forEach(btn => {
if (btn.dataset.wired) return
btn.dataset.wired = '1'
btn.addEventListener('click', async () => {
const id = btn.dataset.id
const row = btn.closest('.channel-row')
const channel = row.querySelector('[data-channel-name]').value.trim()
const version = row.querySelector('[data-channel-version]').value.trim()
if (!channel || !version) return
const res = await fetch(\`/api/admin/channels/\${id}/\${channel}\`, {
method: 'PUT',
headers: { 'content-type': 'application/json', authorization: \`Bearer \${token}\` },
body: JSON.stringify({ version }),
})
const data = await res.json()
if (!res.ok) {
alert(data.error)
return
}
init()
})
})
}
}
async function init() {
const state = await loadState()
panel.innerHTML = '<div id="site"></div><div id="plugins"></div>'
renderSite(state)
renderPlugins(state)
}
document.getElementById('unlock').addEventListener('click', () => {
token = document.getElementById('token').value
gate.style.display = 'none'
panel.style.display = 'block'
panel.innerHTML = '<div id="site"></div><div id="plugins"></div>'
loadSiteSettings()
loadPlugins()
init()
})
</script>`

View file

@ -20,7 +20,7 @@ export function renderDetailPage(
? `<ul>${override.features.map(f => `<li>${escapeHtml(f)}</li>`).join('')}</ul>`
: ''
const repoUrl = 'https://next.jarviscli.dev/index.json'
const repoUrl = 'https://next.jarviscli.dev'
const body = `
${renderHeader({ brandName: plugin.name })}

View file

@ -24,13 +24,6 @@ export interface BaseIndex {
plugins: Record<string, IndexPlugin>
}
/**
* Merges the git-tracked base index (real versions/hashes, never touched) with live D1
* overrides: hidden plugins drop out, edited display fields win, and whatever version is
* mapped to the 'stable' channel becomes 'latest' - the only field of `channels` a public
* consumer (Next's installer) ever reads. alpha/beta stay in `channels` for the admin UI's
* own use but are meaningless to a public client.
*/
export function buildPublicIndex(
base: BaseIndex,
overrides: Record<string, Override>,
@ -41,16 +34,18 @@ export function buildPublicIndex(
const override = overrides[id]
if (override?.hidden) continue
const pluginChannels = channels[id]
const stable = pluginChannels?.stable
const pluginChannels = channels[id] ?? {}
const validChannels = Object.fromEntries(
Object.entries(pluginChannels).filter(
([, version]) => version in plugin.versions,
),
)
plugins[id] = {
...plugin,
name: override?.name ?? plugin.name,
description: override?.description ?? plugin.description,
channels: {
...plugin.channels,
...(stable && stable in plugin.versions ? { latest: stable } : {}),
},
channels: { ...plugin.channels, ...validChannels },
}
}
return { ...base, plugins }

View file

@ -5,13 +5,47 @@ export const PAGE_CSS = `
background: linear-gradient(180deg, #0c0d12, #08090c);
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif;
overflow-x: hidden;
}
a { color: inherit; text-decoration: none; }
.wrap { max-width: 1040px; margin: 0 auto; padding: 0 32px; }
.wrap { max-width: 1040px; margin: 0 auto; padding: 0 32px; position: relative; z-index: 1; }
header {
display: flex; align-items: center; justify-content: space-between;
padding: 20px 32px; border-bottom: 1px solid rgba(255,255,255,0.07);
position: relative; z-index: 1;
}
.bg-blobs { position: fixed; inset: 0; overflow: hidden; z-index: 0; pointer-events: none; }
.bg-blob { position: absolute; border-radius: 50%; filter: blur(90px); opacity: 0.32; animation: blobDrift ease-in-out infinite alternate; }
.bg-blob.b1 { width: 440px; height: 440px; top: -100px; left: -80px; background: #9a8cff; animation-duration: 24s; }
.bg-blob.b2 { width: 380px; height: 380px; top: 38%; right: -120px; background: #ff8fc7; animation-duration: 30s; animation-delay: -6s; }
.bg-blob.b3 { width: 360px; height: 360px; bottom: -120px; left: 28%; background: #6fe3e8; animation-duration: 34s; animation-delay: -12s; }
@keyframes blobDrift {
0% { transform: translate(0,0) scale(1); }
50% { transform: translate(40px,-30px) scale(1.08); }
100% { transform: translate(-30px,20px) scale(0.95); }
}
.floaters { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
.float-icon { position: absolute; opacity: 0.16; animation: floatDrift linear infinite; will-change: transform; }
@keyframes floatDrift {
0% { transform: translate(0,0) rotate(0deg); }
25% { transform: translate(18px,-26px) rotate(6deg); }
50% { transform: translate(-14px,-46px) rotate(-4deg); }
75% { transform: translate(-22px,-16px) rotate(5deg); }
100% { transform: translate(0,0) rotate(0deg); }
}
html.custom-cursor, html.custom-cursor a, html.custom-cursor button, html.custom-cursor input,
html.custom-cursor textarea, html.custom-cursor .card, html.custom-cursor .chip { cursor: none; }
.cursor-ring {
position: fixed; top: 0; left: 0; width: 20px; height: 20px; border-radius: 50%;
border: 1.5px solid rgba(154,140,255,0.85); pointer-events: none; z-index: 9999;
transform: translate(-50%,-50%);
transition: width 0.2s ease, height 0.2s ease, background-color 0.2s ease, border-color 0.2s ease;
}
.cursor-ring.hover { width: 42px; height: 42px; background: rgba(154,140,255,0.14); border-color: rgba(154,140,255,1); }
@media (hover: none), (pointer: coarse) { .cursor-ring { display: none; } }
.brand { display: flex; align-items: center; gap: 10px; }
.brand-mark {
width: 24px; height: 24px; border-radius: 7px; background: #15161c;
@ -81,7 +115,6 @@ export const SOURCE_URL =
export const ISSUES_URL =
'https://github.com/everestmcarthur/revenge-next-plugs/issues'
/** Shared header markup - same nav (Plugins/Source/Issues) on every page for consistency. */
export function renderHeader(opts: {
brandName: string
brandHref?: string
@ -101,6 +134,42 @@ export function renderHeader(opts: {
</header>`
}
const FLOATERS = `
<div class="bg-blobs"><div class="bg-blob b1"></div><div class="bg-blob b2"></div><div class="bg-blob b3"></div></div>
<div class="floaters" aria-hidden="true">
<span class="float-icon" style="top:12%;left:8%;font-size:34px;animation-duration:22s;">🏳️‍🌈</span>
<span class="float-icon" style="top:65%;left:87%;font-size:26px;animation-duration:18s;animation-delay:-4s;">✨</span>
<span class="float-icon" style="top:80%;left:15%;font-size:30px;animation-duration:26s;animation-delay:-9s;">🚀</span>
<span class="float-icon" style="top:20%;left:92%;font-size:24px;animation-duration:20s;animation-delay:-3s;">⚡</span>
<span class="float-icon" style="top:46%;left:50%;font-size:22px;animation-duration:28s;animation-delay:-14s;">🎮</span>
</div>`
const CURSOR_SCRIPT = `
<script>
(function() {
if (!matchMedia('(hover: hover) and (pointer: fine)').matches) return
var ring = document.createElement('div')
ring.className = 'cursor-ring'
document.body.appendChild(ring)
document.documentElement.classList.add('custom-cursor')
var x = innerWidth / 2, y = innerHeight / 2, rx = x, ry = y
addEventListener('mousemove', function (e) { x = e.clientX; y = e.clientY })
function loop() {
rx += (x - rx) * 0.22
ry += (y - ry) * 0.22
ring.style.transform = 'translate(' + rx + 'px,' + ry + 'px) translate(-50%,-50%)'
requestAnimationFrame(loop)
}
loop()
document.addEventListener('mouseover', function (e) {
if (e.target.closest && e.target.closest('a, button, .card, .chip, #install-card')) ring.classList.add('hover')
})
document.addEventListener('mouseout', function (e) {
if (e.target.closest && e.target.closest('a, button, .card, .chip, #install-card')) ring.classList.remove('hover')
})
})()
</script>`
export function htmlShell(opts: {
title: string
description: string
@ -124,7 +193,7 @@ export function htmlShell(opts: {
<meta name="twitter:description" content="${escapeHtml(ogDescription)}">
<style>${PAGE_CSS}</style>
</head>
<body>${opts.body}</body>
<body>${FLOATERS}${opts.body}${CURSOR_SCRIPT}</body>
</html>`
}