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:
parent
0d6da1879f
commit
0f20a12b93
10 changed files with 192 additions and 120 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)[] = []
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>`
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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>`
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue