PageSourceSearch

https://astralbabes.ai/js/site.js

js astralbabes.ai collected 2026-10-05 11:48:11 UTC 18,926 bytes, 519 lines download raw bytes

1/* ================= Astral Babes ================= */
2
3// Set while a nav-link smooth scroll is in flight so the gallery's
4// infinite scroll doesn't grow the page and move the target.
5let anchorScrolling = false;
6
7/* ---------- starfield ---------- */
8(function starfield() {
9  const canvas = document.getElementById('stars');
10  const ctx = canvas.getContext('2d');
11  let stars = [];
12
13  function resize() {
14    canvas.width = window.innerWidth;
15    canvas.height = window.innerHeight;
16    const count = Math.min(260, Math.floor(canvas.width * canvas.height / 6000));
17    stars = Array.from({ length: count }, () => ({
18      x: Math.random() * canvas.width,
19      y: Math.random() * canvas.height,
20      r: Math.random() * 1.3 + .2,
21      phase: Math.random() * Math.PI * 2,
22      speed: Math.random() * .9 + .3,
23      hue: Math.random() < .12 ? 285 : (Math.random() < .5 ? 195 : 0),
24    }));
25  }
26
27  function draw(t) {
28    ctx.clearRect(0, 0, canvas.width, canvas.height);
29    for (const s of stars) {
30      const tw = .35 + .65 * (0.5 + 0.5 * Math.sin(s.phase + t * .001 * s.speed));
31      ctx.beginPath();
32      ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
33      ctx.fillStyle = s.hue
34        ? `hsla(${s.hue}, 90%, 78%, ${tw})`
35        : `rgba(236, 230, 247, ${tw})`;
36      ctx.fill();
37    }
38    requestAnimationFrame(draw);
39  }
40
41  window.addEventListener('resize', resize);
42  resize();
43  requestAnimationFrame(draw);
44})();
45
46/* ---------- mobile nav ---------- */
47(function mobileNav() {
48  const nav = document.querySelector('.nav');
49  const toggle = document.getElementById('nav-toggle');
50  function setOpen(open) {
51    nav.classList.toggle('open', open);
52    toggle.setAttribute('aria-expanded', open);
53  }
54  toggle.addEventListener('click', () => setOpen(!nav.classList.contains('open')));
55  document.querySelectorAll('.nav-links a').forEach((a) =>
56    a.addEventListener('click', () => setOpen(false)));
57})();
58
59/* ---------- anchor scrolling ---------- */
60(function anchorNav() {
61  let timer;
62  document.querySelectorAll('a[href^="#"]').forEach((a) => {
63    a.addEventListener('click', (e) => {
64      const target = document.querySelector(a.getAttribute('href'));
65      if (!target) return;
66      e.preventDefault();
67      history.pushState(null, '', a.getAttribute('href'));
68      anchorScrolling = true;
69      target.scrollIntoView({ behavior: 'smooth' });
70      const done = () => {
71        clearTimeout(timer);
72        document.removeEventListener('scrollend', done);
73        // if the page grew mid-scroll and we landed short, snap to place
74        if (Math.abs(target.getBoundingClientRect().top - 84) > 60) {
75          target.scrollIntoView({ behavior: 'instant' });
76        }
77        anchorScrolling = false;
78      };
79      document.addEventListener('scrollend', done);
80      clearTimeout(timer);
81      timer = setTimeout(done, 2000);
82    });
83  });
84})();
85
86/* ---------- name marquees ---------- */
87(function marquees() {
88  const shuffled = BABE_NAMES.slice().sort(() => Math.random() - .5);
89  const half = 70;
90  [['marquee-a', shuffled.slice(0, half)], ['marquee-b', shuffled.slice(half, half * 2)]]
91    .forEach(([id, names]) => {
92      const track = document.querySelector('#' + id + ' .marquee-track');
93      const seq = names.map(n => `${n}<span class="star">✦</span>`).join('');
94      track.innerHTML = seq + seq; // duplicate for seamless loop
95    });
96})();
97
98/* ---------- hero fan rotation ---------- */
99(function fanRotation() {
100  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
101  const POOL = [421, 500, 600, 700, 777, 850, 950, 1000, 1100, 1200, 1300, 1400,
102    1500, 1600, 1700, 1800, 1950, 2000, 2100, 2300, 2400, 2500, 2600, 2700, 2800, 2900];
103  const cards = [...document.querySelectorAll('.hero-fan .fan-card')];
104  if (!cards.length) return;
105  const current = cards.map((c) => +c.querySelector('.layer-base').src.match(/(\d+)\.webp$/)[1]);
106  let queue = [];
107  let slot = 0;
108
109  function nextBabe() {
110    if (!queue.length) {
111      queue = POOL.filter((n) => !current.includes(n));
112      for (let i = queue.length - 1; i > 0; i--) {
113        const j = Math.floor(Math.random() * (i + 1));
114        [queue[i], queue[j]] = [queue[j], queue[i]];
115      }
116    }
117    return queue.pop();
118  }
119
120  setInterval(() => {
121    const card = cards[slot];
122    const idx = slot;
123    slot = (slot + 1) % cards.length;
124    const base = card.querySelector('.layer-base');
125    const top = card.querySelector('.layer-top');
126    const n = nextBabe();
127    const preload = new Image();
128    preload.onload = () => {
129      // put the new babe on the (invisible) top layer, then dissolve it in
130      top.classList.add('snap');
131      top.src = preload.src;
132      void top.offsetWidth;
133      top.classList.remove('snap');
134      top.classList.add('showing');
135      setTimeout(() => {
136        // dissolve done: bake into base layer and silently reset the top
137        base.src = preload.src;
138        current[idx] = n;
139        top.classList.add('snap');
140        top.classList.remove('showing');
141        void top.offsetWidth;
142        top.classList.remove('snap');
143      }, 1050);
144    };
145    preload.src = `images/babes/featured/${n}.webp`;
146  }, 3200);
147})();
148
149/* ---------- gallery ---------- */
150(function gallery() {
151  const grid = document.getElementById('gallery-grid');
152  const sentinel = document.getElementById('gallery-sentinel');
153  const countEl = document.getElementById('shown-count');
154  const BATCH = window.matchMedia('(max-width: 640px)').matches ? 30 : 70;
155  const AUTO_BATCHES = 10; // pause infinite scroll every N batches so the footer stays reachable
156  const moreBtn = document.getElementById('more-btn');
157  let order = [];
158  let cursor = 0;
159  let batchesSincePause = 0;
160
161  function shuffle() {
162    order = BABE_NUMS.slice();
163    for (let i = order.length - 1; i > 0; i--) {
164      const j = Math.floor(Math.random() * (i + 1));
165      [order[i], order[j]] = [order[j], order[i]];
166    }
167  }
168
169  function loadBatch() {
170    const frag = document.createDocumentFragment();
171    const end = Math.min(cursor + BATCH, order.length);
172    for (; cursor < end; cursor++) {
173      const n = order[cursor];
174      const tile = document.createElement('div');
175      tile.className = 'tile';
176      tile.dataset.index = cursor;
177      const img = document.createElement('img');
178      img.loading = 'lazy';
179      img.decoding = 'async';
180      img.src = `images/babes/thumbs/${n}.webp`;
181      const meta = BABE_MAP[n];
182      img.alt = meta ? meta[0] : 'Astral Babe';
183      tile.appendChild(img);
184      const label = document.createElement('span');
185      label.className = 'tile-name';
186      label.textContent = meta ? meta[0] : '✦';
187      tile.appendChild(label);
188      frag.appendChild(tile);
189    }
190    grid.appendChild(frag);
191    countEl.textContent = cursor.toLocaleString();
192  }
193
194  const io = new IntersectionObserver((entries) => {
195    if (!entries[0].isIntersecting || cursor >= order.length) return;
196    if (anchorScrolling) return;
197    // only load when approaching from above; if the sentinel is already
198    // above the viewport (user is reading a lower section), don't push
199    // new rows in above them
200    if (entries[0].boundingClientRect.top < 0) return;
201    if (batchesSincePause >= AUTO_BATCHES) {
202      moreBtn.hidden = false;
203      return;
204    }
205    loadBatch();
206    batchesSincePause++;
207  }, { rootMargin: '1200px' });
208
209  moreBtn.addEventListener('click', () => {
210    moreBtn.hidden = true;
211    batchesSincePause = 0;
212    loadBatch();
213  });
214
215  /* ---------- search ---------- */
216  const searchInput = document.getElementById('babe-search');
217  const searchClear = document.getElementById('search-clear');
218  const noResults = document.getElementById('no-results');
219  const totalEl = document.getElementById('total-count');
220  const norm = (s) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
221  const index = BABE_NUMS.map((n) => {
222    const [name, id, num] = BABE_MAP[n];
223    return { n, name: norm(name), id, num: String(num) };
224  });
225  let baseOrder = [];
226
227  function applySearch(raw) {
228    const q = norm(raw.trim());
229    grid.innerHTML = '';
230    cursor = 0;
231    batchesSincePause = 0;
232    moreBtn.hidden = true;
233    if (!q) {
234      order = baseOrder;
235      noResults.hidden = true;
236      totalEl.textContent = '2,912';
237      loadBatch();
238      return;
239    }
240    const digits = q.replace(/[#,\s]/g, '');
241    let matches;
242    if (/^\d+$/.test(digits)) {
243      matches = index.filter((e) => e.num.includes(digits));
244    } else {
245      const hexish = /^[0-9a-f]{10,}(i\d+)?$/.test(q);
246      matches = index.filter((e) => e.name.includes(q) || (hexish && e.id.includes(q)));
247    }
248    order = matches.map((e) => e.n);
249    totalEl.textContent = order.length.toLocaleString();
250    noResults.hidden = order.length > 0;
251    loadBatch();
252  }
253
254  let debounce;
255  searchInput.addEventListener('input', () => {
256    searchClear.hidden = !searchInput.value;
257    clearTimeout(debounce);
258    debounce = setTimeout(() => applySearch(searchInput.value), 180);
259  });
260  searchClear.addEventListener('click', () => {
261    searchInput.value = '';
262    searchClear.hidden = true;
263    applySearch('');
264    searchInput.focus();
265  });
266
267  shuffle();
268  baseOrder = order;
269  loadBatch();
270  io.observe(sentinel);
271
272  /* ---------- lightbox ---------- */
273  const lb = document.getElementById('lightbox');
274  const lbImg = document.getElementById('lb-img');
275  let current = -1;
276
277  const lbName = document.getElementById('lb-name');
278  const lbNum = document.getElementById('lb-num');
279  const lbInfo = document.getElementById('lb-info');
280  const lbSatflow = document.getElementById('lb-satflow');
281  const lbOrd = document.getElementById('lb-ord');
282  const lbSpinner = document.getElementById('lb-spinner');
283  let loadToken = 0;
284
285  function show(i) {
286    if (i < 0 || i >= cursor) return;
287    lbMode = 'gallery';
288    current = i;
289    const n = order[i];
290    const meta = BABE_MAP[n];
291    const token = ++loadToken;
292    lbInfo.textContent = '';
293    lbImg.src = `images/babes/thumbs/${n}.webp`;
294    lbImg.alt = meta ? meta[0] : 'Astral Babe';
295    lbSpinner.classList.toggle('spinning', !!meta);
296    if (meta) {
297      const full = new Image();
298      full.onload = () => {
299        if (token !== loadToken) return;
300        lbImg.src = full.src;
301        lbSpinner.classList.remove('spinning');
302      };
303      full.onerror = () => {
304        if (token === loadToken) lbSpinner.classList.remove('spinning');
305      };
306      full.src = 'https://ordinals-api.deezy.io/content/' + meta[1];
307    }
308    lbName.textContent = meta ? meta[0] : '';
309    lbNum.textContent = meta ? 'Inscription #' + meta[2].toLocaleString() : '';
310    lbSatflow.href = meta
311      ? 'https://www.satflow.com/inscription/' + meta[1]
312      : 'https://www.satflow.com/ordinals/astral-babes';
313    lbOrd.href = meta
314      ? 'https://ordinals.com/inscription/' + meta[1]
315      : 'https://ordinals.com/inscription/ad5438c1bbf593dee182eb1703388753c6c04287c266428861cdad3284189702i0';
316    lb.hidden = false;
317    document.body.style.overflow = 'hidden';
318  }
319  // market-mode lightbox: same viewer fed by For Sale / Sales cards
320  let lbMode = 'gallery';
321  let mList = [];
322  let mIdx = -1;
323  const ID2NUM = {};
324  for (const n of BABE_NUMS) ID2NUM[BABE_MAP[n][1]] = n;
325
326  function showMarket(list, i) {
327    if (i < 0 || i >= list.length) return;
328    lbMode = 'market';
329    mList = list;
330    mIdx = i;
331    const item = list[i];
332    const token = ++loadToken;
333    const fileN = ID2NUM[item.id];
334    lbImg.src = fileN != null
335      ? `images/babes/thumbs/${fileN}.webp`
336      : 'https://ord.satflow.com/content/' + item.id;
337    lbImg.alt = item.name || 'Astral Babe';
338    lbName.textContent = item.name || 'Astral Babe';
339    lbNum.textContent = item.num != null ? 'Inscription #' + item.num.toLocaleString() : '';
340    lbInfo.textContent = item.__info || '';
341    lbSatflow.href = 'https://www.satflow.com/inscription/' + item.id;
342    lbOrd.href = 'https://ordinals.com/inscription/' + item.id;
343    lbSpinner.classList.add('spinning');
344    const full = new Image();
345    full.onload = () => {
346      if (token !== loadToken) return;
347      lbImg.src = full.src;
348      lbSpinner.classList.remove('spinning');
349    };
350    full.onerror = () => {
351      if (token === loadToken) lbSpinner.classList.remove('spinning');
352    };
353    full.src = 'https://ordinals-api.deezy.io/content/' + item.id;
354    lb.hidden = false;
355    document.body.style.overflow = 'hidden';
356  }
357  window.__openMarketItem = showMarket;
358
359  const lbNav = (d) => lbMode === 'market' ? showMarket(mList, mIdx + d) : show(current + d);
360
361  function hide() {
362    lb.hidden = true;
363    document.body.style.overflow = '';
364  }
365
366  grid.addEventListener('click', (e) => {
367    const tile = e.target.closest('.tile');
368    if (tile) show(+tile.dataset.index);
369  });
370  document.getElementById('lb-close').addEventListener('click', hide);
371  document.getElementById('lb-prev').addEventListener('click', () => lbNav(-1));
372  document.getElementById('lb-next').addEventListener('click', () => lbNav(1));
373  lb.addEventListener('click', (e) => { if (e.target === lb) hide(); });
374  document.addEventListener('keydown', (e) => {
375    if (lb.hidden) return;
376    if (e.key === 'Escape') hide();
377    if (e.key === 'ArrowLeft') lbNav(-1);
378    if (e.key === 'ArrowRight') lbNav(1);
379  });
380})();
381
382/* ---------- market (live via /api/market, snapshot fallback) ---------- */
383(async function market() {
384  let data;
385  for (const src of ['api/market', 'data/market.json']) {
386    try {
387      const res = await fetch(src);
388      if (!res.ok) continue;
389      data = await res.json();
390      break;
391    } catch { /* try next source */ }
392  }
393  if (!data) return;
394
395  const fmtBtc = (s) => '\u20bf\u00a0' + (s / 100000000).toLocaleString(undefined, { maximumFractionDigits: 5 });
396  const fmtUsdVal = (v) => '$' + v.toLocaleString(undefined, { maximumFractionDigits: v >= 100 ? 0 : 2 });
397  const usdOf = (item) => item.priceUsd != null
398    ? item.priceUsd
399    : (data.btcUsd ? item.price / 100000000 * data.btcUsd : null);
400  const timeAgo = (iso) => {
401    const days = Math.floor((Date.now() - new Date(iso)) / 86400000);
402    if (days < 1) return 'today';
403    if (days === 1) return 'yesterday';
404    if (days < 30) return days + ' days ago';
405    const months = Math.floor(days / 30);
406    return months === 1 ? '1 month ago' : months + ' months ago';
407  };
408  const fullDate = (iso) => new Date(iso).toLocaleDateString(undefined,
409    { month: 'short', day: 'numeric', year: 'numeric' });
410  const priceLine = (item) => {
411    const v = usdOf(item);
412    return fmtBtc(item.price) + (v != null ? ' (' + fmtUsdVal(v) + ')' : '');
413  };
414  function openMarket(list, i, kind) {
415    if (!window.__openMarketItem) return;
416    window.__openMarketItem(list.map((it) => ({
417      ...it,
418      __info: kind === 'listing'
419        ? 'Listed for ' + priceLine(it) + ' \u00b7 ' + timeAgo(it.listedAt)
420        : 'Sold for ' + priceLine(it) + (it.soldAt ? ' \u00b7 ' + fullDate(it.soldAt) : ''),
421    })), i);
422  }
423
424  // primary 'usd' renders "$1,000 (\u20bf 0.01)"; default is BTC-primary
425  function priceInto(el, item, primary) {
426    const btcText = fmtBtc(item.price);
427    const v = usdOf(item);
428    const usdText = v != null ? fmtUsdVal(v) : '';
429    const usdPrimary = primary === 'usd' && usdText;
430    el.textContent = usdPrimary ? usdText : btcText;
431    const paren = usdPrimary ? btcText : usdText;
432    if (paren) {
433      el.appendChild(document.createTextNode(' '));
434      const u = document.createElement('span');
435      u.className = 'price-usd';
436      u.textContent = '(' + paren + ')';
437      el.appendChild(u);
438    }
439  }
440
441  function card(item, sub, rank, primary, onOpen) {
442    const a = document.createElement('div');
443    a.className = 'market-card';
444    if (onOpen) a.addEventListener('click', onOpen);
445    const img = document.createElement('img');
446    img.loading = 'lazy';
447    img.decoding = 'async';
448    img.src = 'https://ord.satflow.com/content/' + item.id;
449    img.alt = item.name || 'Astral Babe';
450    a.appendChild(img);
451    if (rank) {
452      const badge = document.createElement('span');
453      badge.className = 'mc-rank';
454      badge.textContent = '#' + rank;
455      a.appendChild(badge);
456    }
457    const info = document.createElement('div');
458    info.className = 'mc-info';
459    info.innerHTML =
460      '<span class="mc-name"></span><span class="mc-num"></span><span class="mc-price"></span><span class="mc-sub"></span>';
461    info.querySelector('.mc-name').textContent = item.name || 'Astral Babe';
462    if (item.num != null) {
463      const numEl = info.querySelector('.mc-num');
464      const lbl = document.createElement('span');
465      lbl.className = 'mc-num-label';
466      lbl.textContent = 'Insc ';
467      numEl.append(lbl, document.createTextNode('#' + item.num.toLocaleStr
467ing()));
468    }
469    priceInto(info.querySelector('.mc-price'), item, primary);
470    info.querySelector('.mc-sub').textContent = sub;
471    a.appendChild(info);
472    return a;
473  }
474
475  if (data.listings?.length) {
476    const grid = document.getElementById('listings-grid');
477    data.listings.forEach((l, i) => grid.appendChild(card(l, 'listed ' + timeAgo(l.listedAt), null, undefined,
478      () => openMarket(data.listings, i, 'listing'))));
479    priceInto(document.getElementById('mkt-floor'), { price: data.stats.floor });
480    document.getElementById('mkt-count').textContent = data.stats.listedCount;
481    const age = Date.now() - new Date(data.updatedAt).getTime();
482    document.getElementById('mkt-updated').textContent = age < 600000
483      ? 'just now'
484      : new Date(data.updatedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
485    document.getElementById('market').hidden = false;
486  }
487
488  if (data.sales?.length) {
489    const grid = document.getElementById('sales-grid');
490    data.sales.forEach((s, i) => grid.appendChild(card(s, 'sold ' + timeAgo(s.soldAt), null, undefined,
491      () => openMarket(data.sales, i, 'sale'))));
492    document.getElementById('sales').hidden = false;
493  }
494
495  if (data.topSales?.length) {
496    const grid = document.getElementById('top-sales-grid');
497    const soldSub = (s) => s.soldAt
498      ? 'sold ' + new Date(s.soldAt).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })
499      : '';
500    function renderTop(mode) {
501      grid.innerHTML = '';
502      const sorted = data.topSales
503        .slice()
504        .sort((a, b) => mode === 'usd' ? (usdOf(b) ?? 0) - (usdOf(a) ?? 0) : b.price - a.price)
505        .slice(0, 30);
506      sorted.forEach((s, i) => grid.appendChild(card(s, soldSub(s), i + 1, mode,
507        () => openMarket(sorted, i, 'sale'))));
508    }
509    document.querySelectorAll('#top-sort .sort-opt').forEach((btn) => {
510      btn.addEventListener('click', () => {
511        document.querySelectorAll('#top-sort .sort-opt').forEach((b) =>
512          b.classList.toggle('active', b === btn));
513        renderTop(btn.dataset.sort);
514      });
515    });
516    renderTop('usd');
517    document.getElementById('top-sales').hidden = false;
518  }
519})();

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.