1/** 2 * Shared UI primitives: escaping, badge artwork, chips, meters, toasts. 3 * Kept framework-free so the dashboard stays a zero-build static page. 4 */ 5import { 6 ART_STATE, 7 ART_STATE_HINT, 8 ART_STATE_LABEL, 9 badgeArt, 10} from '/shared/badge-art.mjs'; 11 12export const h = (value) => 13 String(value ?? '') 14 .replace(/&/g, '&') 15 .replace(/</g, '<') 16 .replace(/>/g, '>') 17 .replace(/"/g, '"') 18 .replace(/'/g, '''); 19 20export const qs = (selector, root = document) => root.querySelector(selector); 21 22export const shortWallet = (wallet) => 23 wallet ? `${wallet.slice(0, 4)}â¦${wallet.slice(-4)}` : 'â'; 24 25export const sol = (amount) => `${Number(amount || 0).toFixed(2)} SOL`; 26 27export function formatDate(value) { 28 if (!value) return 'â'; 29 const date = new Date(value); 30 if (Number.isNaN(date.getTime())) return 'â'; 31 return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' }); 32} 33 34/** 35 * Date AND time. A live balance check is only meaningful with the minute it ran 36 * â "12 Aug 2026" cannot tell a member whether their last verification was 37 * seconds or hours ago. 38 */ 39export function formatDateTime(value) { 40 if (!value) return 'â'; 41 const date = new Date(value); 42 if (Number.isNaN(date.getTime())) return 'â'; 43 return date.toLocaleString(undefined, { 44 day: '2-digit', 45 month: 'short', 46 year: 'numeric', 47 hour: '2-digit', 48 minute: '2-digit', 49 }); 50} 51 52/* ---------- Icons (inline SVG, never emoji) ---------- */ 53 54export const iconCheck = (color = 'var(--ok)') => 55 `<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M2.5 8.5l3.4 3.4L13.5 4.3" fill="none" stroke="${color}" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>`; 56 57export const iconLock = (color = 'var(--dim)') => 58 `<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><rect x="3.2" y="7" width="9.6" height="7" rx="1.6" fill="none" stroke="${color}" stroke-width="1.5"/><path d="M5.4 7V5.2a2.6 2.6 0 015.2 0V7" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round"/></svg>`; 59 60export const iconDot = (color = 'var(--red)') => 61 `<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="3.2" fill="${color}"/></svg>`; 62 63/** 64 * Badge artwork â the centrepiece of every badge card. 65 * 66 * The five supplied badge illustrations replaced the procedurally generated 67 * medallions that used to stand in for them. The artwork is always rendered, 68 * whatever the state: a locked badge is darkened and padlocked but never hidden, 69 * because a member needs to see what they are working toward. 70 * 71 * `state` must come from `badgeArtState()` in `/shared/badge-art.mjs`, which 72 * reads the badge engine's own `unlocked` / `awaitingTelegram` decision. This 73 * function decides nothing about qualification â it only paints it. 74 * 75 * The status word is always written out as text. Colour and brightness carry the 76 * same message, never the message on their own. 77 * 78 * @param {string} badgeKey Ladder key, e.g. `bad_badge`. 79 * @param {object} [options] 80 * @param {string} [options.state] One of ART_STATE. Defaults to locked. 81 * @param {number} [options.tier] Tier number, for the "Badge N" overlay. 82 * @param {'xs'|'sm'|'lg'} [options.size] 83 * @param {boolean} [options.current] Adds the "current badge" emphasis ring. 84 * @param {boolean} [options.showStatus] Set false only where adjacent text already states the status. 85 * @param {string} [options.tierLabel] Overrides the "Badge N" overlay text. 86 */ 87export function badgeArtwork(badgeKey, options = {}) { 88 const { 89 state = ART_STATE.LOCKED, 90 tier = null, 91 size = 'sm', 92 current = false, 93 showStatus = true, 94 tierLabel = null, 95 } = options; 96 97 const art = badgeArt(badgeKey); 98 const label = ART_STATE_LABEL[state] || ART_STATE_LABEL[ART_STATE.LOCKED]; 99 const hint = ART_STATE_HINT[state] || ''; 100 const overlay = tierLabel !== null ? tierLabel : tier ? `Badge ${tier}` : ''; 101 const classes = ['badge-art', size, state]; 102 if (art) classes.push(art.orientation); 103 if (current) classes.push('current'); 104 105 /** 106 * With no artwork for the key (only possible if the ladder gained a rung 107 * without artwork) the frame and the status text still render, so a badge card 108 * degrades to a readable placeholder instead of collapsing. 109 */ 110 const image = art 111 ? `<img class="badge-art-img" src="${h(art.src)}" alt="${h(art.alt)}" loading="lazy" decoding="async" 112 style="object-fit:${h(art.fit)};object-position:${h(art.focus)}" />` 113 : '<span class="badge-art-missing" aria-hidden="true"></span>'; 114 115 return `<span class="${classes.join(' ')}"> 116 <span class="badge-art-frame"> 117 ${image} 118 ${overlay ? `<span class="badge-art-tier">${h(overlay)}</span>` : ''} 119 ${ 120 state === ART_STATE.EARNED 121 ? '' 122 : `<span class="badge-art-lock">${iconLock(state === ART_STATE.AWAITING_TELEGRAM ? 'var(--warn)' : '#d8d8d8')}</span>` 123 } 124 </span> 125 ${ 126 showStatus 127 ? `<span class="badge-art-status">
128 <span class="badge-art-state">${h(label)}</span> 129 ${hint ? `<span class="badge-art-hint">${h(hint)}</span>` : ''} 130 </span>` 131 : '' 132 } 133 </span>`; 134} 135 136/** Progress meter. `value` is 0..1. */ 137export function meter(value, { done = false, large = false } = {}) { 138 const pct = Math.max(0, Math.min(1, Number(value) || 0)); 139 return `<div class="meter${done ? ' done' : ''}${large ? ' lg' : ''}" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(pct * 100)}"><span style="transform:scaleX(${pct})"></span></div>`; 140} 141 142export function chip(label, tone = 'neutral') { 143 const cls = tone === 'neutral' ? '' : ` ${tone}`; 144 return `<span class="chip${cls}"><span class="dot"></span>${h(label)}</span>`; 145} 146 147/** 148 * Telegram state is binary now: proven by the bot, or not proven. 149 * 150 * There is no "claimed a username" middle ground any more â a typed handle was 151 * never evidence of anything. Legacy `entered` rows read as not verified. 152 */ 153export function telegramChip(status) { 154 if (status === 'verified') return chip('Telegram Verified', 'ok'); 155 return chip('Telegram Not Verified', 'warn'); 156} 157 158/** 159 * Countdown for a verification code, e.g. "expires in 9:12". 160 * Returns null once the code is dead, so callers can swap in the expired copy. 161 */ 162export function countdownLabel(expiresAt, now = Date.now()) { 163 const remaining = new Date(expiresAt).getTime() - now; 164 if (!Number.isFinite(remaining) || remaining <= 0) return null; 165 const totalSeconds = Math.floor(remaining / 1000); 166 const minutes = Math.floor(totalSeconds / 60); 167 const seconds = String(totalSeconds % 60).padStart(2, '0'); 168 return `${minutes}:${seconds}`; 169} 170 171/* ---------- Toasts ---------- */ 172 173function toastHost() { 174 let host = document.querySelector('.toast-host'); 175 if (!host) { 176 host = document.createElement('div'); 177 host.className = 'toast-host'; 178 host.setAttribute('aria-live', 'polite'); 179 document.body.appendChild(host); 180 } 181 return host; 182} 183 184export function toast(message, tone = 'info', ms = 5200) { 185 const node = document.createElement('div'); 186 node.className = `toast${tone === 'ok' ? ' ok' : ''}`; 187 node.textContent = message; 188 toastHost().appendChild(node); 189 setTimeout(() => node.remove(), ms); 190} 191 192export async function copyText(value, successMessage = 'Copied.') { 193 try { 194 await navigator.clipboard.writeText(value); 195 toast(successMessage, 'ok'); 196 } catch { 197 toast('Copy failed â select the text and copy manually.'); 198 } 199} 200 201/** Skeleton placeholder that matches the shape of the panel it replaces. */ 202export function skeleton(lines = 3) { 203 return Array.from({ length: lines }, (_, index) => 204 `<div class="skeleton line" style="width:${[92, 76, 60, 84][index % 4]}%"></div>`, 205 ).join(''); 206}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.