Torn Raceway — VIPs to Top (PDA-safe)

Hoist chosen races to the top of the Custom Races list. Match by host name, host ID, or keywords anywhere in the row. PDA/WebView safe. Draggable panel with saved position & tap-friendly header.

// ==UserScript==
// @name         Torn Raceway — VIPs to Top (PDA-safe)
// @author       Fu11y [2774724]
// @namespace    torn.racepin.pda
// @version      1.4
// @description  Hoist chosen races to the top of the Custom Races list. Match by host name, host ID, or keywords anywhere in the row. PDA/WebView safe. Draggable panel with saved position & tap-friendly header.
// @match        https://www.torn.com/*
// @match        https://www.torn.com/loader.php?sid=racing*
// @match        https://www.torn.com/racing*
// @run-at       document-idle
// @grant        none
// @license      GNU GPLv3
//
// ==/UserScript==
//
// Credits: Fizzy_ [3253722]- Testing and PDA feedback

(function () {
  'use strict';

  const LS_KEY = 'racepin.settings.v2';
  const POS_KEY = 'racepin.panel.pos.v1';

  function loadSettings() {
    try {
      const raw = localStorage.getItem(LS_KEY);
      if (!raw) return { hosts: ['changme'], hostIds: [], keywords: ['change','me'] };
      const s = JSON.parse(raw);
      s.hosts = (s.hosts || []).map(x => String(x).toLowerCase().trim()).filter(Boolean);
      s.hostIds = (s.hostIds || []).map(x => parseInt(x, 10)).filter(Number.isFinite);
      s.keywords = (s.keywords || []).map(x => String(x).toLowerCase().trim()).filter(Boolean);
      return s;
    } catch { return { hosts: [], hostIds: [], keywords: [] }; }
  }
  function saveSettings(s){ localStorage.setItem(LS_KEY, JSON.stringify(s)); }
  let SETTINGS = loadSettings();

  const isRacingScreen = () => {
    const u = location.href.toLowerCase();
    if (/sid=racing|\/racing/.test(u)) return true;
    const t = (document.body.textContent || '').toLowerCase();
    return t.includes('torn city raceway') || t.includes('custom races');
  };

  const byText = (root, pred) => {
    const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
    const out = [];
    while (w.nextNode()) {
      const t = w.currentNode.nodeValue.trim();
      if (t && pred(t)) out.push(w.currentNode);
    }
    return out;
  };

  function parseRaceRow(row) {
    const text = (row.textContent || '').replace(/\s+/g, ' ').trim();
    const lower = text.toLowerCase();
    const m = text.match(/([\w.\-\s]{1,32})'s race\b/i);
    const creator = m ? m[1].trim() : null;
    let xid = null;
    const a = row.querySelector('a[href*="profiles.php?XID="]');
    if (a) { const mx = a.href.match(/XID=(\d+)/i); if (mx) xid = parseInt(mx[1], 10); }
    return { creator, xid, text, lower };
  }

  function isVIPRow(row) {
    const { creator, xid, lower } = parseRaceRow(row);
    const byName = creator && SETTINGS.hosts.includes(creator.toLowerCase());
    const byId   = xid != null && SETTINGS.hostIds.includes(xid);
    const byKw   = SETTINGS.keywords.length > 0 && SETTINGS.keywords.some(k => lower.includes(k));
    return byName || byId || byKw;
  }

  function findListContainer() {
    const candidates = Array.from(document.querySelectorAll(
      '.table-body, .content, .table, .body, .m-row, .table-list, ul, .racing-wrap, .racing-table, .tableCont, .cont-rounded'
    ));
    for (const c of candidates) {
      const kids = Array.from(c.children || []);
      if (kids.length < 5) continue;
      const score = kids.reduce((acc, k) => {
        const t = (k.textContent || '').toLowerCase();
        return acc + (t.includes('join this race') || t.includes('drivers') ? 1 : 0);
      }, 0);
      if (score >= 3) return c;
    }
    return null;
  }

  function resolveRowWithinContainer(node, container) {
    let el = node.nodeType === 3 ? node.parentElement : node;
    while (el && el.parentElement && el.parentElement !== container) el = el.parentElement;
    if (el && el.parentElement === container) {
      const txt = (el.textContent || '').toLowerCase();
      if (txt.includes('join this race') || txt.includes('drivers') || txt.includes('waiting')) return el;
    }
    return null;
  }

  // -------- Panel (draggable; tap-friendly) --------
  let panel, headerEl, listEl, countEl;
  function ensurePanel() {
    if (panel) return;
    const pos = (() => { try { return JSON.parse(localStorage.getItem(POS_KEY) || '{}'); } catch { return {}; } })();
    panel = document.createElement('div');
    panel.style.cssText = `
      position: fixed; z-index: 99999;
      left: ${Number.isFinite(pos.x)?pos.x:8}px; bottom: ${Number.isFinite(pos.y)?pos.y:8}px;
      background: rgba(0,0,0,.85); color: #fff; font: 14px/1.3 system-ui, sans-serif;
      padding: 8px 10px; border-radius: 8px; max-width: 92vw; max-height: 46vh; overflow:auto;
      box-shadow: 0 2px 10px rgba(0,0,0,.4);
    `;
    panel.innerHTML = `
      <div id="rp-header" style="display:flex;gap:8px;align-items:center;margin-bottom:6px;cursor:move;user-select:none;">
        <strong>Pinned races</strong>
        <span id="vipCount" style="opacity:.8"></span>
        <div style="margin-left:auto; display:flex; gap:6px;">
          <button id="vipSettings" style="background:#333;border:0;color:#fff;border-radius:6px;padding:2px 6px;cursor:pointer">CFG</button>
          <button id="vipCollapse" style="background:#333;border:0;color:#fff;border-radius:6px;padding:2px 6px;cursor:pointer">–</button>
          <button id="vipHide" style="background:#333;border:0;color:#fff;border-radius:6px;padding:2px 6px;cursor:pointer">X</button>
        </div>
      </div>
      <div id="vipList" style="display:grid;gap:6px;"></div>
    `;
    document.documentElement.appendChild(panel);
    headerEl = panel.querySelector('#rp-header');
    listEl   = panel.querySelector('#vipList');
    countEl  = panel.querySelector('#vipCount');

    // Buttons
    panel.querySelector('#vipCollapse').addEventListener('click', () => {
      listEl.style.display = listEl.style.display === 'none' ? 'grid' : 'none';
    });
    panel.querySelector('#vipHide').addEventListener('click', () => { panel.style.display = 'none'; });
    panel.querySelector('#vipSettings').addEventListener('click', openSettings);

    // Drag with click-vs-drag threshold and button guard
    let dragging = false, start = null, offset = null;
    const THRESH = 6;

    headerEl.addEventListener('mousedown', startDrag);
    headerEl.addEventListener('touchstart', startDrag, {passive:false});

    function startDrag(ev) {

      if (ev.target.closest('button')) return;

      const p = getPoint(ev);
      start = p;
      const rect = panel.getBoundingClientRect();
      offset = { dx: p.x - rect.left, dy: p.y - rect.top };
      dragging = false; // not yet; wait until threshold
      window.addEventListener('mousemove', onMove);
      window.addEventListener('touchmove', onMove, {passive:false});
      window.addEventListener('mouseup', endDrag, {once:true});
      window.addEventListener('touchend', endDrag, {once:true});
    }
    function onMove(ev) {
      if (!start) return;
      const p = getPoint(ev);
      const moved = Math.hypot(p.x - start.x, p.y - start.y);
      if (!dragging && moved < THRESH) return; // still a click
      dragging = true;
      ev.preventDefault(); // only prevent once we are truly dragging
      const x = clamp(p.x - offset.dx, 4, window.innerWidth  - panel.offsetWidth  - 4);
      const yTop = clamp(p.y - offset.dy, 4, window.innerHeight - panel.offsetHeight - 4);
      const yBottom = window.innerHeight - (yTop + panel.offsetHeight);
      panel.style.left = `${x}px`;
      panel.style.bottom = `${yBottom}px`;
    }
    function endDrag() {
      if (!start) return;
      if (dragging) {
        const rect = panel.getBoundingClientRect();
        const x = rect.left;
        const yBottom = window.innerHeight - (rect.top + rect.height);
        localStorage.setItem(POS_KEY, JSON.stringify({x, y:yBottom}));
      }
      start = null; dragging = false; offset = null;
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('touchmove', onMove);
    }
    function getPoint(ev){
      if (ev.touches && ev.touches[0]) return {x: ev.touches[0].clientX, y: ev.touches[0].clientY};
      return {x: ev.clientX, y: ev.clientY};
    }
    function clamp(v,min,max){ return Math.max(min, Math.min(max, v)); }
  }

  function openSettings() {
    const wrap = document.createElement('div');
    wrap.style.cssText = `position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 100000;
                          display:flex; align-items:center; justify-content:center;`;
    const box = document.createElement('div');
    box.style.cssText = `background:#121212;color:#fff;border:1px solid #444;border-radius:10px;
                          padding:14px;width:min(640px,94vw);`;
    box.innerHTML = `
      <h3 style="margin:0 0 10px 0;">Racepin Settings</h3>
      <div style="display:grid; gap:10px;">
        <label>Hosts (names, comma-separated)
          <input id="rp-hosts" style="width:100%; padding:6px; margin-top:4px; background:#1f1f1f; color:#fff; border:1px solid #444; border-radius:6px;">
        </label>
        <label>Host IDs (XIDs, comma-separated)
          <input id="rp-ids" style="width:100%; padding:6px; margin-top:4px; background:#1f1f1f; color:#fff; border:1px solid #444; border-radius:6px;">
        </label>
        <label>Keywords (comma-separated — matches anywhere in a row)
          <input id="rp-kws" style="width:100%; padding:6px; margin-top:4px; background:#1f1f1f; color:#fff; border:1px solid #444; border-radius:6px;">
        </label>
        <div style="display:flex; gap:8px; justify-content:flex-end;">
          <button id="rp-save" style="background:#2e7d32; border:0; color:#fff; padding:6px 10px; border-radius:6px; cursor:pointer;">Save</button>
          <button id="rp-cancel" style="background:#333; border:0; color:#fff; padding:6px 10px; border-radius:6px; cursor:pointer;">Cancel</button>
        </div>
      </div>
    `;
    wrap.appendChild(box);
    document.body.appendChild(wrap);

    const S = SETTINGS;
    box.querySelector('#rp-hosts').value = S.hosts.join(', ');
    box.querySelector('#rp-ids').value   = S.hostIds.join(', ');
    box.querySelector('#rp-kws').value   = S.keywords.join(', ');

    box.querySelector('#rp-cancel').onclick = () => wrap.remove();
    box.querySelector('#rp-save').onclick = () => {
      const hosts = box.querySelector('#rp-hosts').value.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
      const ids   = box.querySelector('#rp-ids').value.split(',').map(s => parseInt(s.trim(),10)).filter(Number.isFinite);
      const kws   = box.querySelector('#rp-kws').value.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
      SETTINGS = { hosts, hostIds: ids, keywords: kws };
      saveSettings(SETTINGS);
      wrap.remove();
      bubbleVIPs();
    };
  }

  function renderPinnedPanel(rows) {
    ensurePanel();
    const vipRows = rows.filter(isVIPRow);
    listEl.innerHTML = '';
    if (!vipRows.length) {
      listEl.innerHTML = `<div style="opacity:.7;">No VIP races detected.</div>`;
      countEl.textContent = '';
      return;
    }
    countEl.textContent = `(${vipRows.length})`;
    vipRows.forEach(row => {
      const { creator, text } = parseRaceRow(row);
      const btn = document.createElement('button');
      btn.style.cssText = `text-align:left;background:#1f1f1f;border:1px solid #444;border-radius:6px;color:#fff;padding:6px 8px;cursor:pointer;`;
      btn.textContent = (creator ? creator + ' — ' : '') + text.slice(0, 110);
      btn.addEventListener('click', e => {
        e.preventDefault();
        const join = row.querySelector('a, button');
        if (join) join.dispatchEvent(new MouseEvent('click', { bubbles: true }));
        row.scrollIntoView({ behavior: 'smooth', block: 'center' });
        row.style.outline = '2px solid #ffcc00';
        setTimeout(() => (row.style.outline = ''), 1200);
      });
      listEl.appendChild(btn);
    });
  }

  function bubbleVIPs() {
    if (!isRacingScreen()) return;
    const container = findListContainer();
    if (!container) { renderPinnedPanel([]); return; }

    const textNodes = byText(container, t => /'s race\b/i.test(t));
    const rows = [];
    const seen = new WeakSet();
    for (const tn of textNodes) {
      const row = resolveRowWithinContainer(tn, container);
      if (!row || seen.has(row)) continue;
      seen.add(row);
      rows.push(row);
    }
    if (rows.length === 0) {
      Array.from(container.children).forEach(r => {
        const t = (r.textContent || '').toLowerCase();
        if (t.includes('join this race') || t.includes('drivers')) rows.push(r);
      });
    }

    renderPinnedPanel(rows);

    const vipRows = rows.filter(isVIPRow);
    if (!vipRows.length) return;

    const anchor = container.firstElementChild;
    vipRows.forEach(r => {
      if (!r.__vip_moved) {
        container.insertBefore(r, anchor);
        r.__vip_moved = true;
        r.style.outline = '2px solid #ffcc00';
        setTimeout(() => (r.style.outline = ''), 1200);
      }
    });
  }

  const mo = new MutationObserver(() => {
    clearTimeout(bubbleVIPs._t);
    bubbleVIPs._t = setTimeout(bubbleVIPs, 180);
  });
  mo.observe(document.documentElement, { subtree: true, childList: true, characterData: true });

  bubbleVIPs();
})();