PageSourceSearch

https://badmickey.netlify.app/assets/crew/ui.js

js badmickey.netlify.app collected 2026-10-03 10:29:19 UTC 7,907 bytes, 206 lines download raw bytes

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, '&lt;')
16    .replace(/>/g, '&gt;')
17    .replace(/"/g, '&quot;')
18    .replace(/'/g, '&#39;');
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.