From 0f20a12b93bc57cf59174de6c655f0d24127e707 Mon Sep 17 00:00:00 2001 From: Ally Founder Date: Sat, 15 Aug 2026 13:44:42 -0400 Subject: [PATCH] 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. --- plugins/radial-status/js/lib/safe.ts | 8 -- plugins/radial-status/js/patches/ring.tsx | 18 +-- plugins/radial-status/js/ui/ColorInput.tsx | 5 - .../radial-status/js/ui/pages/Settings.tsx | 9 -- worker/schema.sql | 13 +- worker/src/admin/routes.ts | 25 +++- worker/src/pages/admin.ts | 136 +++++++++++------- worker/src/pages/detail.ts | 2 +- worker/src/publicIndex.ts | 21 ++- worker/src/theme.ts | 75 +++++++++- 10 files changed, 192 insertions(+), 120 deletions(-) diff --git a/plugins/radial-status/js/lib/safe.ts b/plugins/radial-status/js/lib/safe.ts index b1fd7c3..92d11f4 100644 --- a/plugins/radial-status/js/lib/safe.ts +++ b/plugins/radial-status/js/lib/safe.ts @@ -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(fn: () => T, fallback: T): T { try { return fn() diff --git a/plugins/radial-status/js/patches/ring.tsx b/plugins/radial-status/js/patches/ring.tsx index 4ca58cf..82f8b74 100644 --- a/plugins/radial-status/js/patches/ring.tsx +++ b/plugins/radial-status/js/patches/ring.tsx @@ -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) { const cleanups: (() => void)[] = [] diff --git a/plugins/radial-status/js/ui/ColorInput.tsx b/plugins/radial-status/js/ui/ColorInput.tsx index bd03c16..d5460d8 100644 --- a/plugins/radial-status/js/ui/ColorInput.tsx +++ b/plugins/radial-status/js/ui/ColorInput.tsx @@ -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, diff --git a/plugins/radial-status/js/ui/pages/Settings.tsx b/plugins/radial-status/js/ui/pages/Settings.tsx index 8bf9e3e..1b17d77 100644 --- a/plugins/radial-status/js/ui/pages/Settings.tsx +++ b/plugins/radial-status/js/ui/pages/Settings.tsx @@ -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, }: { diff --git a/worker/schema.sql b/worker/schema.sql index f94970a..ffba242 100644 --- a/worker/schema.sql +++ b/worker/schema.sql @@ -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, diff --git a/worker/src/admin/routes.ts b/worker/src/admin/routes.ts index 570d3d2..692da3b 100644 --- a/worker/src/admin/routes.ts +++ b/worker/src/admin/routes.ts @@ -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() + 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() const plugin = base.plugins[id] diff --git a/worker/src/pages/admin.ts b/worker/src/pages/admin.ts index 6fcb796..01aa496 100644 --- a/worker/src/pages/admin.ts +++ b/worker/src/pages/admin.ts @@ -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 { \` } - 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 = \`
Site settings
- \${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)}
\` @@ -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]) => \` +
+ + + +
\`).join('') + } + + function renderPlugins(state) { const pluginsEl = document.getElementById('plugins') - pluginsEl.innerHTML = Object.entries(data.plugins).map(([id, p]) => \` -
-
\${p.name} (\${id})
-
- \${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)} - - -
-
- - -
-
- \`).join('') + pluginsEl.innerHTML = Object.entries(state.base.plugins).map(([id, p]) => { + const o = state.overrides[id] ?? {} + const merged = { ...p, ...o } + return \` +
+
\${p.name} (\${id})
+
+ \${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)} + + +
+
+
Channels (any name - Next's client lists these for the user to pick)
+
\${channelRows(id, state.channels[id])}
+ +
+
\` + }).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 = '
' + renderSite(state) + renderPlugins(state) } document.getElementById('unlock').addEventListener('click', () => { token = document.getElementById('token').value gate.style.display = 'none' panel.style.display = 'block' - panel.innerHTML = '
' - loadSiteSettings() - loadPlugins() + init() }) ` diff --git a/worker/src/pages/detail.ts b/worker/src/pages/detail.ts index 7b68041..dcdb764 100644 --- a/worker/src/pages/detail.ts +++ b/worker/src/pages/detail.ts @@ -20,7 +20,7 @@ export function renderDetailPage( ? `
    ${override.features.map(f => `
  • ${escapeHtml(f)}
  • `).join('')}
` : '' - const repoUrl = 'https://next.jarviscli.dev/index.json' + const repoUrl = 'https://next.jarviscli.dev' const body = ` ${renderHeader({ brandName: plugin.name })} diff --git a/worker/src/publicIndex.ts b/worker/src/publicIndex.ts index 91cb303..a73f966 100644 --- a/worker/src/publicIndex.ts +++ b/worker/src/publicIndex.ts @@ -24,13 +24,6 @@ export interface BaseIndex { plugins: Record } -/** - * 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, @@ -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 } diff --git a/worker/src/theme.ts b/worker/src/theme.ts index fd3285b..f2b4d7f 100644 --- a/worker/src/theme.ts +++ b/worker/src/theme.ts @@ -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: { ` } +const FLOATERS = ` +
+ ` + +const CURSOR_SCRIPT = ` + ` + export function htmlShell(opts: { title: string description: string @@ -124,7 +193,7 @@ export function htmlShell(opts: { -${opts.body} +${FLOATERS}${opts.body}${CURSOR_SCRIPT} ` }