555 lines
22 KiB
JavaScript
555 lines
22 KiB
JavaScript
/* ─── Dynamic Navigation with Dropdown Submenus ─── */
|
|
/* Fetches nav items from /api/navigation and renders dropdowns */
|
|
/* Includes sitewide wallet connect button via window.solWallet */
|
|
(function() {
|
|
'use strict';
|
|
|
|
async function loadNavigation() {
|
|
const navMenu = document.getElementById('navMenu');
|
|
if (!navMenu) return;
|
|
|
|
try {
|
|
const res = await fetch('/api/navigation');
|
|
if (!res.ok) throw new Error('Nav API ' + res.status);
|
|
const items = await res.json();
|
|
|
|
// Sort by order
|
|
items.sort((a, b) => (a.order || 0) - (b.order || 0));
|
|
|
|
// Determine current page for active state
|
|
const path = window.location.pathname;
|
|
const search = window.location.search;
|
|
const fullPath = path + search;
|
|
|
|
// Build nav HTML
|
|
let html = '';
|
|
items.forEach(item => {
|
|
const url = item.url || '/';
|
|
const label = (item.label || '').toUpperCase();
|
|
const children = item.children || [];
|
|
const hasChildren = children.length > 0;
|
|
|
|
// Determine if this link is active
|
|
let isActive = false;
|
|
if (url === '/' && (path === '/' || path === '/index.html')) {
|
|
isActive = true;
|
|
} else if (url !== '/' && path.startsWith(url.split('?')[0])) {
|
|
isActive = true;
|
|
}
|
|
|
|
const activeClass = isActive ? ' active' : '';
|
|
const hasDropdownClass = hasChildren ? ' has-dropdown' : '';
|
|
|
|
// Check if it's an anchor link (homepage sections)
|
|
const isAnchor = url.startsWith('#') || url.startsWith('/#');
|
|
const href = isAnchor && path !== '/' ? '/' + url.replace(/^\//, '') : url;
|
|
|
|
html += `<li class="nav-item${hasDropdownClass}">`;
|
|
html += `<a href="${href}" class="nav-link${activeClass}">${label}`;
|
|
if (hasChildren) {
|
|
html += `<span class="dropdown-arrow">▾</span>`;
|
|
}
|
|
html += `</a>`;
|
|
|
|
// Render dropdown submenu
|
|
if (hasChildren) {
|
|
html += `<ul class="dropdown">`;
|
|
children.forEach(child => {
|
|
const childUrl = child.url || '#';
|
|
const childLabel = (child.label || '').toUpperCase();
|
|
|
|
// Check child active state
|
|
let childActive = false;
|
|
if (childUrl === fullPath) {
|
|
childActive = true;
|
|
} else if (childUrl.startsWith('/#') && path === '/') {
|
|
childActive = false; // anchor links - don't mark active
|
|
}
|
|
|
|
const childActiveClass = childActive ? ' active' : '';
|
|
|
|
// Handle anchor links from non-homepage
|
|
const childIsAnchor = childUrl.startsWith('#') || childUrl.startsWith('/#');
|
|
const childHref = childIsAnchor && path !== '/' ? '/' + childUrl.replace(/^\//, '') : childUrl;
|
|
|
|
const childDesc = child.description || '';
|
|
html += `<li><a href="${childHref}" class="dropdown-link${childActiveClass}">${childLabel}${childDesc ? `<span class="dropdown-desc">${childDesc}</span>` : ''}</a></li>`;
|
|
});
|
|
html += `</ul>`;
|
|
}
|
|
|
|
html += `</li>\n`;
|
|
});
|
|
|
|
navMenu.innerHTML = html;
|
|
|
|
// Bind mobile dropdown toggles after rendering
|
|
initMobileDropdowns();
|
|
|
|
// Inject shared wrapper for SOL price + wallet, then inject both
|
|
// Start UNREDACTED block-reveal animation
|
|
initUnredactedAnimation();
|
|
injectSolWalletGroup();
|
|
} catch (err) {
|
|
console.warn('Nav load failed, keeping existing:', err);
|
|
}
|
|
}
|
|
|
|
// Mobile: tap parent to toggle dropdown
|
|
function initMobileDropdowns() {
|
|
const isMobile = () => window.innerWidth <= 768;
|
|
|
|
document.querySelectorAll('.nav-item.has-dropdown > .nav-link').forEach(link => {
|
|
link.addEventListener('click', function(e) {
|
|
if (!isMobile()) return; // desktop uses hover
|
|
|
|
const parent = this.parentElement;
|
|
const dropdown = parent.querySelector('.dropdown');
|
|
if (!dropdown) return;
|
|
|
|
// If dropdown is closed, prevent navigation and open it
|
|
if (!parent.classList.contains('dropdown-open')) {
|
|
e.preventDefault();
|
|
// Close all other dropdowns
|
|
document.querySelectorAll('.nav-item.dropdown-open').forEach(item => {
|
|
if (item !== parent) item.classList.remove('dropdown-open');
|
|
});
|
|
parent.classList.add('dropdown-open');
|
|
} else {
|
|
// Dropdown already open — allow navigation or close
|
|
e.preventDefault();
|
|
parent.classList.remove('dropdown-open');
|
|
}
|
|
});
|
|
});
|
|
|
|
// Close dropdowns when tapping outside
|
|
document.addEventListener('click', function(e) {
|
|
if (!isMobile()) return;
|
|
if (!e.target.closest('.nav-item.has-dropdown')) {
|
|
document.querySelectorAll('.nav-item.dropdown-open').forEach(item => {
|
|
item.classList.remove('dropdown-open');
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Nav toggle (hamburger menu)
|
|
function initNavToggle() {
|
|
const toggle = document.getElementById('navToggle');
|
|
const menu = document.getElementById('navMenu');
|
|
if (toggle && menu) {
|
|
toggle.addEventListener('click', () => {
|
|
menu.classList.toggle('open');
|
|
toggle.classList.toggle('active');
|
|
// Close any open dropdowns when closing menu
|
|
if (!menu.classList.contains('open')) {
|
|
document.querySelectorAll('.nav-item.dropdown-open').forEach(item => {
|
|
item.classList.remove('dropdown-open');
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ─── SOL + Wallet Group Wrapper ────────────────────────────
|
|
function injectSolWalletGroup() {
|
|
const navContainer = document.querySelector('.nav-container');
|
|
const navStatus = document.querySelector('.nav-status');
|
|
if (!navContainer) return;
|
|
|
|
// Don't inject twice
|
|
if (document.getElementById('navSolWalletGroup')) return;
|
|
|
|
// Create shared wrapper
|
|
const group = document.createElement('div');
|
|
group.id = 'navSolWalletGroup';
|
|
group.className = 'nav-sol-wallet-group';
|
|
|
|
// Insert before nav-status
|
|
if (navStatus) {
|
|
navContainer.insertBefore(group, navStatus);
|
|
} else {
|
|
navContainer.appendChild(group);
|
|
}
|
|
|
|
// Now inject SOL price and wallet into the group
|
|
injectSolPrice(group);
|
|
injectWalletButton(group);
|
|
}
|
|
|
|
// ─── SOL Price Ticker ──────────────────────────────────────
|
|
function injectSolPrice(parent) {
|
|
if (!parent || document.getElementById('navSolPrice')) return;
|
|
|
|
const wrap = document.createElement('div');
|
|
wrap.id = 'navSolPrice';
|
|
wrap.className = 'nav-sol-price';
|
|
wrap.innerHTML = `
|
|
<span class="sol-price-icon">◎</span>
|
|
<span class="sol-price-label">SOL</span>
|
|
<span class="sol-price-value" id="solPriceValue">--</span>
|
|
<span class="sol-price-change" id="solPriceChange"></span>
|
|
`;
|
|
|
|
parent.appendChild(wrap);
|
|
|
|
fetchSolPrice();
|
|
setInterval(fetchSolPrice, 30000);
|
|
}
|
|
|
|
async function fetchSolPrice() {
|
|
const priceEl = document.getElementById('solPriceValue');
|
|
const changeEl = document.getElementById('solPriceChange');
|
|
if (!priceEl || !changeEl) return;
|
|
|
|
try {
|
|
// Try Binance first (reliable CORS)
|
|
const res = await fetch('https://api.binance.com/api/v3/ticker/24hr?symbol=SOLUSDT');
|
|
if (!res.ok) throw new Error(res.status);
|
|
const data = await res.json();
|
|
const price = parseFloat(data.lastPrice);
|
|
const change = parseFloat(data.priceChangePercent);
|
|
|
|
priceEl.textContent = '$' + price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
|
|
const sign = change >= 0 ? '+' : '';
|
|
const arrow = change >= 0 ? '▲' : '▼';
|
|
changeEl.textContent = `${arrow} ${sign}${change.toFixed(1)}%`;
|
|
changeEl.className = 'sol-price-change ' + (change >= 0 ? 'up' : 'down');
|
|
} catch (e) {
|
|
// Fallback to CoinGecko
|
|
try {
|
|
const res2 = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=solana&vs_currencies=usd&include_24hr_change=true');
|
|
if (!res2.ok) throw new Error(res2.status);
|
|
const data2 = await res2.json();
|
|
const price = data2.solana.usd;
|
|
const change = data2.solana.usd_24h_change;
|
|
|
|
priceEl.textContent = '$' + price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
|
|
const sign = change >= 0 ? '+' : '';
|
|
const arrow = change >= 0 ? '▲' : '▼';
|
|
changeEl.textContent = `${arrow} ${sign}${change.toFixed(1)}%`;
|
|
changeEl.className = 'sol-price-change ' + (change >= 0 ? 'up' : 'down');
|
|
} catch (e2) {
|
|
console.warn('SOL price fetch failed:', e, e2);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// ─── Wallet Button Injection ────────────────────────────────
|
|
function injectWalletButton(parent) {
|
|
if (!parent) return;
|
|
|
|
// Don't inject twice
|
|
if (document.getElementById('navWalletWrap')) return;
|
|
|
|
// Create wallet wrapper
|
|
const wrap = document.createElement('div');
|
|
wrap.id = 'navWalletWrap';
|
|
wrap.className = 'nav-wallet-wrap';
|
|
wrap.innerHTML = `
|
|
<button class="nav-wallet-btn" id="navWalletBtn">
|
|
<span class="nav-wallet-icon">◆</span>
|
|
<span class="nav-wallet-label">CONNECT</span>
|
|
</button>
|
|
<div class="nav-wallet-dropdown hidden" id="navWalletDropdown"></div>
|
|
`;
|
|
|
|
parent.appendChild(wrap);
|
|
|
|
|
|
// Bind click
|
|
const btn = document.getElementById('navWalletBtn');
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
toggleWalletDropdown();
|
|
});
|
|
|
|
// Close dropdown on outside click
|
|
document.addEventListener('click', (e) => {
|
|
if (!e.target.closest('#navWalletWrap')) {
|
|
closeWalletDropdown();
|
|
}
|
|
});
|
|
|
|
// Listen for wallet events
|
|
window.addEventListener('wallet-connected', () => updateNavWalletUI());
|
|
window.addEventListener('wallet-disconnected', () => updateNavWalletUI());
|
|
|
|
// Set initial state
|
|
updateNavWalletUI();
|
|
}
|
|
|
|
function updateNavWalletUI() {
|
|
const btn = document.getElementById('navWalletBtn');
|
|
if (!btn) return;
|
|
|
|
const sw = window.solWallet;
|
|
if (sw && sw.connected && sw.address) {
|
|
btn.className = 'nav-wallet-btn connected';
|
|
btn.innerHTML = `
|
|
<span class="nav-wallet-dot">●</span>
|
|
<span class="nav-wallet-label">${sw.truncAddr(sw.address)}</span>
|
|
`;
|
|
} else {
|
|
btn.className = 'nav-wallet-btn';
|
|
btn.innerHTML = `
|
|
<span class="nav-wallet-icon">◆</span>
|
|
<span class="nav-wallet-label">CONNECT</span>
|
|
`;
|
|
}
|
|
|
|
// Close dropdown on state change
|
|
closeWalletDropdown();
|
|
}
|
|
|
|
function toggleWalletDropdown() {
|
|
const dd = document.getElementById('navWalletDropdown');
|
|
if (!dd) return;
|
|
|
|
if (dd.classList.contains('hidden')) {
|
|
openWalletDropdown();
|
|
} else {
|
|
closeWalletDropdown();
|
|
}
|
|
}
|
|
|
|
function openWalletDropdown() {
|
|
const dd = document.getElementById('navWalletDropdown');
|
|
if (!dd) return;
|
|
|
|
const sw = window.solWallet;
|
|
if (!sw) {
|
|
dd.innerHTML = '<div class="nw-dd-empty">WALLET MODULE NOT LOADED</div>';
|
|
dd.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
if (sw.connected && sw.address) {
|
|
// ── Connected dropdown: address, copy, solscan, disconnect ──
|
|
const addr = sw.address;
|
|
const solscan = 'https://solscan.io/account/' + addr;
|
|
dd.innerHTML = `
|
|
<div class="nw-dd-section">
|
|
<div class="nw-dd-label">CONNECTED WALLET</div>
|
|
<div class="nw-dd-addr" id="nwCopyAddr" title="Click to copy">
|
|
<span class="nw-dd-addr-text">${addr}</span>
|
|
<span class="nw-dd-copy-icon">⧉</span>
|
|
</div>
|
|
<div class="nw-dd-via">VIA ${sw.walletName || 'WALLET'}</div>
|
|
</div>
|
|
<div class="nw-dd-actions">
|
|
<a href="${solscan}" target="_blank" rel="noopener" class="nw-dd-action">
|
|
<span>SOLSCAN</span><span>↗</span>
|
|
</a>
|
|
<button class="nw-dd-action nw-dd-disconnect" id="nwDisconnect">
|
|
<span>DISCONNECT</span><span>✕</span>
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
// Copy address
|
|
dd.querySelector('#nwCopyAddr').addEventListener('click', () => {
|
|
navigator.clipboard.writeText(addr).then(() => {
|
|
const el = dd.querySelector('#nwCopyAddr .nw-dd-copy-icon');
|
|
if (el) { el.textContent = '✓'; setTimeout(() => el.textContent = '⧉', 1500); }
|
|
});
|
|
});
|
|
|
|
// Disconnect
|
|
dd.querySelector('#nwDisconnect').addEventListener('click', () => {
|
|
sw.disconnect();
|
|
});
|
|
} else {
|
|
// ── Disconnected dropdown: wallet picker ──
|
|
// Short delay for wallet detection
|
|
setTimeout(() => {
|
|
const available = sw.getAvailableWallets();
|
|
const detectedNames = new Set(available.map(w => w.name));
|
|
let html = '';
|
|
|
|
if (available.length > 0) {
|
|
html += '<div class="nw-dd-label">SELECT WALLET</div>';
|
|
html += available.map(w => `
|
|
<button class="nw-dd-wallet" data-wallet="${w.name}">
|
|
<span class="nw-dd-wallet-icon">${w.icon}</span>
|
|
<span class="nw-dd-wallet-name">${w.name}</span>
|
|
<span class="nw-dd-wallet-tag">DETECTED</span>
|
|
</button>
|
|
`).join('');
|
|
}
|
|
|
|
// Show install links for missing wallets
|
|
const missing = sw.KNOWN_WALLETS.filter(w => !detectedNames.has(w.name));
|
|
if (missing.length > 0 && available.length < 3) {
|
|
html += '<div class="nw-dd-divider"></div>';
|
|
html += '<div class="nw-dd-label nw-dd-label-dim">INSTALL</div>';
|
|
html += missing.slice(0, 3).map(w => `
|
|
<a href="${w.url}" target="_blank" rel="noopener" class="nw-dd-wallet nw-dd-wallet-install">
|
|
<span class="nw-dd-wallet-icon">${w.icon}</span>
|
|
<span class="nw-dd-wallet-name">${w.name}</span>
|
|
<span class="nw-dd-wallet-tag">INSTALL ↗</span>
|
|
</a>
|
|
`).join('');
|
|
}
|
|
|
|
if (available.length === 0) {
|
|
html = `
|
|
<div class="nw-dd-empty">
|
|
NO WALLETS DETECTED<br>
|
|
<span class="nw-dd-empty-hint">Install a Solana wallet extension</span>
|
|
</div>
|
|
`;
|
|
// Still show install links
|
|
html += sw.KNOWN_WALLETS.slice(0, 4).map(w => `
|
|
<a href="${w.url}" target="_blank" rel="noopener" class="nw-dd-wallet nw-dd-wallet-install">
|
|
<span class="nw-dd-wallet-icon">${w.icon}</span>
|
|
<span class="nw-dd-wallet-name">${w.name}</span>
|
|
<span class="nw-dd-wallet-tag">INSTALL ↗</span>
|
|
</a>
|
|
`).join('');
|
|
}
|
|
|
|
dd.innerHTML = html;
|
|
|
|
// Bind detected wallet clicks
|
|
dd.querySelectorAll('.nw-dd-wallet[data-wallet]').forEach(el => {
|
|
el.addEventListener('click', async (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const name = el.dataset.wallet;
|
|
const wallet = available.find(w => w.name === name);
|
|
if (!wallet) return;
|
|
|
|
// Show connecting state
|
|
el.querySelector('.nw-dd-wallet-tag').textContent = 'CONNECTING...';
|
|
try {
|
|
await sw.connect(wallet);
|
|
} catch (err) {
|
|
el.querySelector('.nw-dd-wallet-tag').textContent = 'REJECTED';
|
|
el.querySelector('.nw-dd-wallet-tag').classList.add('nw-dd-error');
|
|
setTimeout(() => {
|
|
if (el.querySelector('.nw-dd-wallet-tag')) {
|
|
el.querySelector('.nw-dd-wallet-tag').textContent = 'DETECTED';
|
|
el.querySelector('.nw-dd-wallet-tag').classList.remove('nw-dd-error');
|
|
}
|
|
}, 2000);
|
|
}
|
|
});
|
|
});
|
|
}, 150);
|
|
}
|
|
|
|
dd.classList.remove('hidden');
|
|
}
|
|
|
|
function closeWalletDropdown() {
|
|
const dd = document.getElementById('navWalletDropdown');
|
|
if (dd) dd.classList.add('hidden');
|
|
}
|
|
|
|
// ─── UNREDACTED Block-Reveal Animation ──────────────────────
|
|
function initUnredactedAnimation() {
|
|
// Find the UNREDACTED dropdown link
|
|
const allLinks = document.querySelectorAll('.dropdown-link');
|
|
let targetLink = null;
|
|
for (const link of allLinks) {
|
|
// Check the first text node or childNode text for UNREDACTED
|
|
const txt = link.childNodes[0];
|
|
if (txt && txt.nodeType === 3 && txt.textContent.trim() === 'UNREDACTED') {
|
|
targetLink = link;
|
|
break;
|
|
}
|
|
}
|
|
if (!targetLink) return;
|
|
|
|
const WORD = 'UNREDACTED';
|
|
const LEN = WORD.length;
|
|
const FRAME_MS = 90;
|
|
const HOLD_REVEALED = 2000;
|
|
const HOLD_REDACTED = 1000;
|
|
const BLOCK = '\u2588'; // █
|
|
|
|
// Wrap the text node in a span so we can animate just the label text
|
|
const originalText = targetLink.childNodes[0];
|
|
const animSpan = document.createElement('span');
|
|
animSpan.className = 'unredacted-anim';
|
|
animSpan.textContent = WORD;
|
|
targetLink.replaceChild(animSpan, originalText);
|
|
|
|
// Add minimal CSS for the animation
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
.unredacted-anim { font-family: inherit; }
|
|
.unredacted-anim .ur-block { color: #00cc33; opacity: 0.35; }
|
|
`;
|
|
document.head.appendChild(style);
|
|
|
|
function renderFrame(revealCount) {
|
|
// revealCount = number of letters revealed from the left
|
|
let html = '';
|
|
for (let i = 0; i < LEN; i++) {
|
|
if (i < revealCount) {
|
|
html += WORD[i];
|
|
} else {
|
|
html += '<span class="ur-block">' + BLOCK + '</span>';
|
|
}
|
|
}
|
|
animSpan.innerHTML = html;
|
|
}
|
|
|
|
let frame = 0;
|
|
let direction = 1; // 1 = revealing, -1 = redacting
|
|
let holdTimer = null;
|
|
|
|
function tick() {
|
|
if (direction === 1) {
|
|
// Forward reveal
|
|
renderFrame(frame);
|
|
frame++;
|
|
if (frame > LEN) {
|
|
// Fully revealed — hold
|
|
animSpan.textContent = WORD;
|
|
direction = 0;
|
|
holdTimer = setTimeout(() => {
|
|
direction = -1;
|
|
frame = LEN;
|
|
holdTimer = null;
|
|
}, HOLD_REVEALED);
|
|
}
|
|
} else if (direction === -1) {
|
|
// Reverse redact
|
|
frame--;
|
|
renderFrame(frame);
|
|
if (frame <= 0) {
|
|
// Fully redacted — hold
|
|
direction = 0;
|
|
holdTimer = setTimeout(() => {
|
|
direction = 1;
|
|
frame = 0;
|
|
holdTimer = null;
|
|
}, HOLD_REDACTED);
|
|
}
|
|
}
|
|
// direction === 0 means we're holding, do nothing
|
|
}
|
|
|
|
// Start fully redacted
|
|
renderFrame(0);
|
|
setInterval(tick, FRAME_MS);
|
|
}
|
|
|
|
// Run on DOM ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadNavigation();
|
|
initNavToggle();
|
|
});
|
|
} else {
|
|
loadNavigation();
|
|
initNavToggle();
|
|
}
|
|
})();
|