Quality-of-life utilities: shop leaks, keyboard shortcuts & more.
// ==UserScript==
// @name Nitro Type Toolkit
// @namespace https://greasyfork.org/users/1443935
// @version 1.0.1
// @description Quality-of-life utilities: shop leaks, keyboard shortcuts & more.
// @author Captain.Loveridge
// @match https://www.nitrotype.com/*
// @match *://*.nitrotype.com/settings/mods*
// @grant none
// @run-at document-start
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ─── Singleton Guard ─────────────────────────────────────────────────────────
const pageWindow = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window;
const SCRIPT_SINGLETON_KEY = '__ntToolkitSingleton';
if (pageWindow[SCRIPT_SINGLETON_KEY]) {
try { console.info('[TOOLKIT] Duplicate instance detected; skipping.'); } catch (e) { }
return;
}
pageWindow[SCRIPT_SINGLETON_KEY] = true;
// ─── Constants ───────────────────────────────────────────────────────────────
const TOOLKIT_LOG_PREFIX = '[TOOLKIT]';
const NTCFG_MANIFEST_ID = 'toolkit';
const NTCFG_MANIFEST_KEY = `ntcfg:manifest:${NTCFG_MANIFEST_ID}`;
const NTCFG_VALUE_PREFIX = `ntcfg:${NTCFG_MANIFEST_ID}:`;
const NTCFG_BRIDGE_VERSION = '1.0.0-bridge.1';
// ─── Shared Settings (Mod Menu Manifest) ─────────────────────────────────────
// /profile is a special path that resolves to the current user's racer page
// /team auto-redirects to the user's team on NT's side
// Bump SHORTCUTS_VERSION when adding new defaults — triggers auto-merge for existing users.
const SHORTCUTS_VERSION = 2;
const SHORTCUTS_VERSION_KEY = `${NTCFG_VALUE_PREFIX}__shortcuts_version`;
const DEFAULT_SHORTCUTS = [
{ key: 'R', path: '/race', mod1: 'alt', mod2: 'none' },
{ key: 'G', path: '/garage', mod1: 'alt', mod2: 'none' },
{ key: 'F', path: '/friends', mod1: 'alt', mod2: 'none' },
{ key: 'S', path: '/shop', mod1: 'alt', mod2: 'none' },
{ key: 'P', path: '/profile', mod1: 'alt', mod2: 'none' },
{ key: 'T', path: '/team', mod1: 'alt', mod2: 'none' },
{ key: 'A', path: '/achievements', mod1: 'alt', mod2: 'none' },
{ key: 'M', path: '/settings/mods', mod1: 'alt', mod2: 'none' },
{ key: 'E', path: '/season', mod1: 'alt', mod2: 'none' },
{ key: 'L', path: '/leagues', mod1: 'alt', mod2: 'none' },
{ key: 'B', path: '/leaderboards', mod1: 'alt', mod2: 'none' },
{ key: 'I', path: '/stats', mod1: 'alt', mod2: 'none' }
];
const TOOLKIT_SETTINGS = {
// ── Garage ─────────────────────────────────────────────────────────────
ENABLE_GARAGE_ORGANIZER: {
type: 'boolean',
label: 'Garage Section Organizer',
default: true,
group: 'Garage',
description: 'Add a button on the garage page to set the number of visible garage sections (pages of 30 cars).'
},
GARAGE_ORGANIZER_ACTION: {
type: 'action',
label: 'Open Garage Organizer',
style: 'primary',
group: 'Garage',
help: 'Set the number of garage sections directly from the mod menu.',
visibleWhen: { key: 'ENABLE_GARAGE_ORGANIZER', eq: true }
},
HIDE_BUY_CASH: {
type: 'boolean',
label: 'Hide Buy Cash Button',
default: false,
group: 'Garage',
description: 'Hide the buy cash button in the Garage.'
},
// ── Profile ──────────────────────────────────────────────────────────────
ENABLE_CAR_ICON: {
type: 'boolean',
label: 'Car Profile Icon',
default: false,
group: 'Profile',
description: 'Replace the green profile icon in the top-right with your current car.'
},
ENABLE_SEND_CASH_FORMAT: {
type: 'boolean',
label: 'Readable Cash Amounts',
default: true,
group: 'Profile',
description: 'Format large numbers with commas in the Send Cash modal.'
},
DONATION_LINK_STYLE: {
type: 'select',
label: 'Donation Profile Link',
default: 'username',
group: 'Profile',
description: 'Show a clickable link to the donor\'s profile in cash gift modals.',
options: [
{ value: 'off', label: 'Off' },
{ value: 'username', label: 'Username Only' },
{ value: 'full_url', label: 'Full URL (nitrotype.com/racer/username)' }
]
},
DONATION_LINK_NEW_TAB: {
type: 'boolean',
label: 'Open Donation Link in New Tab',
default: false,
group: 'Profile',
description: 'Open the donor profile link in a new browser tab instead of redirecting.',
visibleWhen: { key: 'DONATION_LINK_STYLE', neq: 'off' }
},
// ── Team ─────────────────────────────────────────────────────────────────
ENABLE_BANNED_LABELS: {
type: 'boolean',
label: 'Banned/Warned Labels',
default: true,
group: 'Team',
description: 'Show ban/warn status badges on team member lists and the team header.'
},
ENABLE_MOTD_FILTER: {
type: 'boolean',
label: 'MOTD Offensive Filter',
default: true,
group: 'Team',
description: 'Pre-check MOTD text for possibly offensive words before saving.'
},
CUSTOM_MOTD_WORDS: {
type: 'text',
label: 'Custom Filter Words',
default: '',
group: 'Team',
description: 'Additional words to flag, separated by commas (e.g. "word1, word2, word3").',
placeholder: 'word1, word2, word3',
visibleWhen: { key: 'ENABLE_MOTD_FILTER', eq: true }
},
// ── Shop ──────────────────────────────────────────────────────────────────
ENABLE_SHOP_LEAKS: {
type: 'boolean',
label: 'Shop Leaks',
default: true,
group: 'Shop',
description: 'Show a preview of tomorrow\'s featured and daily shop items on the shop page.'
},
SHOP_NOTE: {
type: 'note',
group: 'Shop',
message: 'Shop notification options (Hide Shop Notifications, Smart Shop Notifications) are available in the Notifications tab.',
tone: 'info'
},
// ── Appearance ───────────────────────────────────────────────────────────
HIDE_SEASON_BANNER: {
type: 'boolean',
label: 'Hide Season Banner',
default: false,
group: 'Appearance',
description: 'Hide the seasonal event banner at the top of the page.'
},
HIDE_ADS: {
type: 'boolean',
label: 'Hide Ads',
default: false,
group: 'Appearance',
description: 'Hide ad containers on the page.'
},
HIDE_FOOTER: {
type: 'boolean',
label: 'Hide Footer',
default: false,
group: 'Appearance',
description: 'Hide the page footer.'
},
HIDE_ALT_LOGINS: {
type: 'boolean',
label: 'Hide Alternative Login Options',
default: false,
group: 'Appearance',
description: 'Hide third-party login options on the login and race pages.'
},
HIDE_CASH_DISPLAY: {
type: 'boolean',
label: 'Hide Cash Display',
default: false,
group: 'Appearance',
description: 'Hide your Nitro Cash balance across all pages.'
},
HIDE_CASH_MODE: {
type: 'select',
label: 'Cash Display Mode',
default: 'hidden',
group: 'Appearance',
description: 'How to hide the cash amount.',
options: [
{ value: 'hidden', label: 'Hidden (blank space)' },
{ value: 'redacted', label: 'REDACTED' },
{ value: 'stars', label: '*******' },
{ value: 'custom', label: 'Custom Message' }
],
visibleWhen: { key: 'HIDE_CASH_DISPLAY', eq: true }
},
HIDE_CASH_CUSTOM_TEXT: {
type: 'text',
label: 'Custom Cash Text',
default: '',
group: 'Appearance',
description: 'Custom text to display in place of your cash balance.',
placeholder: 'Enter custom text...',
visibleWhen: { key: 'HIDE_CASH_MODE', eq: 'custom' }
},
HIDE_CASH_TOTAL_SPENT: {
type: 'boolean',
label: 'Hide Total Spent',
default: false,
group: 'Appearance',
description: 'Also hide the Total Spent value on the stats page.',
visibleWhen: { key: 'HIDE_CASH_DISPLAY', eq: true }
},
// ── Notifications ────────────────────────────────────────────────────────
HIDE_NOTIFY_ALL: {
type: 'boolean',
label: 'Disable All Notifications',
default: false,
group: 'Notifications',
description: 'Hide all notification badges on the navigation bar.'
},
HIDE_NOTIFY_SHOP: {
type: 'boolean',
label: 'Hide Shop Notifications',
default: false,
group: 'Notifications',
description: 'Hide the notification badge on the Shop nav item.',
disabledWhen: { key: 'HIDE_NOTIFY_ALL', eq: true }
},
SMART_SHOP_NOTIFY: {
type: 'boolean',
label: 'Smart Shop Notifications',
default: false,
group: 'Notifications',
description: 'Only show the Shop notification when there are unowned items in the current daily rotation.',
visibleWhen: { key: 'HIDE_NOTIFY_SHOP', eq: false },
disabledWhen: { key: 'HIDE_NOTIFY_ALL', eq: true }
},
HIDE_NOTIFY_FRIENDS: {
type: 'boolean',
label: 'Hide Friends Notifications',
default: false,
group: 'Notifications',
description: 'Hide the notification badge on the Friends nav item.',
disabledWhen: { key: 'HIDE_NOTIFY_ALL', eq: true }
},
HIDE_NOTIFY_TEAM: {
type: 'boolean',
label: 'Hide Team Notifications',
default: false,
group: 'Notifications',
description: 'Hide the notification badge on the Team nav item.',
disabledWhen: { key: 'HIDE_NOTIFY_ALL', eq: true }
},
HIDE_NOTIFY_NEWS: {
type: 'boolean',
label: 'Hide News Notifications',
default: false,
group: 'Notifications',
description: 'Hide the notification badge on the News nav item.',
disabledWhen: { key: 'HIDE_NOTIFY_ALL', eq: true }
},
HIDE_NOTIFY_ACHIEVEMENTS: {
type: 'boolean',
label: 'Hide Achievements Notifications',
default: false,
group: 'Notifications',
description: 'Hide the notification badge on the Achievements nav item.',
disabledWhen: { key: 'HIDE_NOTIFY_ALL', eq: true }
},
// ── Keyboard Shortcuts ───────────────────────────────────────────────────
ENABLE_KEYBOARD_SHORTCUTS: {
type: 'boolean',
label: 'Keyboard Shortcuts',
default: true,
group: 'Shortcuts',
description: 'Quick page navigation with modifier+key shortcuts.'
},
KEYBOARD_SHORTCUTS_NOTE: {
type: 'note',
group: 'Shortcuts',
message: 'Keyboard shortcuts do not work on the race page due to Nitro Type\'s keyboard lockdown. They work on all other pages.',
tone: 'info'
},
KEYBOARD_SHORTCUT_MAP: {
type: 'keymap',
label: 'Shortcut Mappings',
default: DEFAULT_SHORTCUTS,
group: 'Shortcuts',
description: 'Customize which shortcuts navigate to which pages. Use /profile for your own racer page.',
visibleWhen: { key: 'ENABLE_KEYBOARD_SHORTCUTS', eq: true }
}
};
// ─── Settings Read/Write Utilities ────────────────────────────────────────────
const getStorageKey = (settingKey) => `${NTCFG_VALUE_PREFIX}${settingKey}`;
const readSetting = (settingKey) => {
const meta = TOOLKIT_SETTINGS[settingKey];
if (!meta) return undefined;
try {
const raw = localStorage.getItem(getStorageKey(settingKey));
if (raw == null) return meta.default;
const parsed = JSON.parse(raw);
if (meta.type === 'boolean') return !!parsed;
return parsed;
} catch {
return meta.default;
}
};
const writeSetting = (settingKey, value) => {
try {
const serialized = JSON.stringify(value);
if (localStorage.getItem(getStorageKey(settingKey)) !== serialized) {
localStorage.setItem(getStorageKey(settingKey), serialized);
}
} catch { /* ignore storage failures */ }
};
const applySetting = (settingKey, value) => {
const meta = TOOLKIT_SETTINGS[settingKey];
if (!meta) return;
writeSetting(settingKey, meta.type === 'boolean' ? !!value : value);
};
const isFeatureEnabled = (settingKey) => readSetting(settingKey) !== false;
// ─── Donation Link Migration ────────────────────────────────────────────────
// Migrate old boolean ENABLE_DONATION_LINK + DONATION_LINK_FULL_URL to new select DONATION_LINK_STYLE
(() => {
try {
const oldKey = `${NTCFG_VALUE_PREFIX}ENABLE_DONATION_LINK`;
const oldVal = localStorage.getItem(oldKey);
if (oldVal != null) {
const wasEnabled = JSON.parse(oldVal);
if (!wasEnabled) {
writeSetting('DONATION_LINK_STYLE', 'off');
} else {
const oldFullUrl = localStorage.getItem(`${NTCFG_VALUE_PREFIX}DONATION_LINK_FULL_URL`);
writeSetting('DONATION_LINK_STYLE', oldFullUrl && JSON.parse(oldFullUrl) ? 'full_url' : 'username');
}
localStorage.removeItem(oldKey);
localStorage.removeItem(`${NTCFG_VALUE_PREFIX}DONATION_LINK_FULL_URL`);
}
} catch { /* ignore */ }
})();
// ─── Shortcut Defaults Migration ─────────────────────────────────────────────
// When SHORTCUTS_VERSION bumps, merge any new default shortcuts into the user's
// saved list without overwriting their customizations.
(() => {
try {
const storedVersion = parseInt(localStorage.getItem(SHORTCUTS_VERSION_KEY), 10) || 0;
if (storedVersion >= SHORTCUTS_VERSION) return;
const saved = readSetting('KEYBOARD_SHORTCUT_MAP');
if (Array.isArray(saved) && saved.length > 0) {
// Find keys the user already has mapped
const existingKeys = new Set(saved.map(s => s.key?.toUpperCase()));
// Add any new defaults that don't conflict with user's keys
const additions = DEFAULT_SHORTCUTS.filter(d => !existingKeys.has(d.key.toUpperCase()));
if (additions.length > 0) {
writeSetting('KEYBOARD_SHORTCUT_MAP', saved.concat(additions));
}
}
localStorage.setItem(SHORTCUTS_VERSION_KEY, String(SHORTCUTS_VERSION));
} catch { /* ignore */ }
})();
// ─── Manifest Registration ────────────────────────────────────────────────────
const registerManifest = () => {
try {
const manifest = {
id: NTCFG_MANIFEST_ID,
name: 'Toolkit',
version: NTCFG_BRIDGE_VERSION,
description: 'Quality-of-life utilities: banned labels, cash formatting, donation links, MOTD filter, appearance toggles, keyboard shortcuts.',
sections: [
{ id: 'garage', title: 'Garage', subtitle: 'Garage section management and display options.', resetButton: 'Reset Garage to Defaults' },
{ id: 'profile', title: 'Profile', subtitle: 'Profile icon, cash formatting, and donation links.', resetButton: 'Reset Profile to Defaults' },
{ id: 'team', title: 'Team', subtitle: 'Team member labels and MOTD tools.', resetButton: 'Reset Team to Defaults' },
{ id: 'shop', title: 'Shop', subtitle: 'Shop page previews and features.', resetButton: 'Reset Shop to Defaults' },
{ id: 'appearance', title: 'Appearance', subtitle: 'Hide or show page elements.', resetButton: 'Reset Appearance to Defaults' },
{ id: 'notifications', title: 'Notifications', subtitle: 'Control which navigation bar notification badges are visible.', resetButton: 'Reset Notifications to Defaults' },
{ id: 'shortcuts', title: 'Keyboard Shortcuts', subtitle: 'Quick navigation with customizable shortcuts.', resetButton: 'Reset Shortcuts to Defaults' }
],
settings: TOOLKIT_SETTINGS
};
const serialized = JSON.stringify(manifest);
if (localStorage.getItem(NTCFG_MANIFEST_KEY) !== serialized) {
localStorage.setItem(NTCFG_MANIFEST_KEY, serialized);
}
} catch { /* ignore */ }
};
const syncAllSettings = () => {
Object.keys(TOOLKIT_SETTINGS).forEach((key) => {
writeSetting(key, readSetting(key));
});
};
// Listen for mod menu changes (same tab)
document.addEventListener('ntcfg:change', (event) => {
if (event?.detail?.script !== NTCFG_MANIFEST_ID) return;
applySetting(event.detail.key, event.detail.value);
});
// Listen for mod menu action buttons
document.addEventListener('ntcfg:action', (event) => {
if (event?.detail?.script !== NTCFG_MANIFEST_ID) return;
if (event.detail.key === 'GARAGE_ORGANIZER_ACTION') {
try {
const persist = JSON.parse(localStorage.getItem('persist:nt'));
const user = JSON.parse(persist.user);
const garage = user.garage;
if (!Array.isArray(garage)) return;
const totalCars = user.totalCars || user.carsOwned || 0;
const currentSections = Math.ceil(garage.length / 30);
showGarageModal(currentSections, totalCars);
} catch (e) {
console.error(TOOLKIT_LOG_PREFIX, 'Garage organizer action error:', e);
}
}
});
// Listen for cross-tab changes
window.addEventListener('storage', (event) => {
const key = String(event?.key || '');
if (!key.startsWith(NTCFG_VALUE_PREFIX) || event.newValue == null) return;
const settingKey = key.slice(NTCFG_VALUE_PREFIX.length);
if (!TOOLKIT_SETTINGS[settingKey]) return;
try { applySetting(settingKey, JSON.parse(event.newValue)); } catch { /* ignore */ }
});
registerManifest();
syncAllSettings();
try {
document.dispatchEvent(new CustomEvent('ntcfg:manifest-updated', {
detail: { script: NTCFG_MANIFEST_ID }
}));
} catch { /* ignore */ }
// ─── Shared Utilities ─────────────────────────────────────────────────────────
/** Traverse React fiber tree to find component instance. */
const findReact = (dom, traverseUp = 0) => {
if (!dom) return null;
const key = Object.keys(dom).find((k) => k.startsWith('__reactFiber$'));
const domFiber = dom[key];
if (domFiber == null) return null;
const getCompFiber = (fiber) => {
let parentFiber = fiber?.return;
while (typeof parentFiber?.type === 'string') {
parentFiber = parentFiber?.return;
}
return parentFiber;
};
let compFiber = getCompFiber(domFiber);
for (let i = 0; i < traverseUp && compFiber; i++) {
compFiber = getCompFiber(compFiber);
}
return compFiber?.stateNode;
};
/** Get React fiber from a DOM node (lower-level than findReact). */
const getReactFiber = (dom) => {
if (!dom) return null;
const key = Object.keys(dom).find((k) =>
k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$')
);
return key ? dom[key] : null;
};
/** Walk up fiber tree looking for props containing a given key. */
const findReactProp = (dom, propName, maxSteps = 30) => {
let fiber = getReactFiber(dom);
let steps = 0;
while (fiber && steps++ < maxSteps) {
const props = fiber.memoizedProps || fiber.pendingProps || null;
if (props && propName in props) return props[propName];
const stateNode = fiber.stateNode;
if (stateNode?.props && propName in stateNode.props) return stateNode.props[propName];
fiber = fiber.return;
}
return undefined;
};
/** Escape HTML entities for safe injection. */
const escapeHtml = (value) => {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
/** Normalize a pathname by stripping trailing slashes. */
const normalizePath = (pathname) => {
if (!pathname || pathname === '/') return '/';
return pathname.replace(/\/+$/, '') || '/';
};
/** SPA-friendly navigation using history API. */
const spaNavigate = (path) => {
try {
history.pushState({}, '', path);
window.dispatchEvent(new PopStateEvent('popstate'));
} catch {
window.location.href = path;
}
};
/** Check if user is currently in an active race (actively typing, not lobby/results). */
const isInActiveRace = () => {
const container = document.getElementById('raceContainer');
if (!container) return false;
// Only block during actual typing — check for the race text AND no results yet
const hasRaceText = !!container.querySelector('.dash-copy');
const hasResults = !!container.querySelector('.race-results');
// Active typing = race text exists but results haven't appeared
return hasRaceText && !hasResults;
};
/** Get current user data from localStorage. */
const getCurrentUser = () => {
try {
const persist = JSON.parse(localStorage.getItem('persist:nt'));
return JSON.parse(persist.user);
} catch {
return null;
}
};
// ─── SPA Navigation Hooks ─────────────────────────────────────────────────────
const originalPushState = history.pushState;
history.pushState = function () {
const result = originalPushState.apply(this, arguments);
onRouteChange();
return result;
};
const originalReplaceState = history.replaceState;
history.replaceState = function () {
const result = originalReplaceState.apply(this, arguments);
onRouteChange();
return result;
};
window.addEventListener('popstate', onRouteChange);
function onRouteChange() {
// Features that need cleanup on route change can hook here.
// The NTObserverManager handles re-injection on DOM mutations.
}
// ─── Observer Manager Integration ─────────────────────────────────────────────
function initObserverManager() {
window.NTObserverManager = window.NTObserverManager || {
callbacks: {},
observer: null,
debounceTimer: null,
register(scriptName, callback) {
this.callbacks[scriptName] = callback;
if (!this.observer) {
this.observer = new MutationObserver(() => {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
Object.values(this.callbacks).forEach(cb => {
try { cb(); } catch (e) { console.error('[Observer Error]', e); }
});
}, 250);
});
this.observer.observe(document.body, {
childList: true,
subtree: true
});
}
}
};
}
// ─────────────────────────────────────────────────────────────────────────────
// FEATURE 1: Banned/Warned Labels (/team/*)
// ─────────────────────────────────────────────────────────────────────────────
const BANNED_ATTR = 'data-toolkit-banned';
/** Create a styled banned/warned badge element. */
function createStatusBadge(status) {
const badge = document.createElement('span');
badge.className = 'toolkit-status-badge';
badge.style.cssText = 'display:inline-block;margin-left:6px;padding:1px 5px;border-radius:3px;font-size:10px;font-weight:700;letter-spacing:0.5px;text-transform:uppercase;vertical-align:middle;line-height:1.4;';
if (status === 'banned') {
badge.textContent = 'BANNED';
badge.style.color = '#fff';
badge.style.backgroundColor = 'rgba(214, 47, 58, 0.8)';
} else {
badge.textContent = 'WARNED';
badge.style.color = '#fff';
badge.style.backgroundColor = 'rgba(255, 159, 28, 0.8)';
}
return badge;
}
function handleBannedLabels() {
if (!isFeatureEnabled('ENABLE_BANNED_LABELS')) return;
const teamTable = document.querySelector('.table.table--striped.table--selectable.table--team.table--teamOverview');
if (!teamTable) return;
// Try to get members data from React internals
const members = findTeamMembers(teamTable);
if (!members || members.length === 0) return;
// ── Team header: captain badge ──────────────────────────────────────────
const headerContainer = document.querySelector('.tsm.tbs');
if (headerContainer && !headerContainer.hasAttribute(BANNED_ATTR)) {
const captainLink = headerContainer.querySelector('a.link');
if (captainLink) {
// Extract username from href like "/racer/ii_zvx"
const hrefParts = (captainLink.getAttribute('href') || '').split('/').filter(Boolean);
const captainUsername = hrefParts[hrefParts.length - 1] || '';
if (captainUsername) {
const captainMember = members.find(m =>
(m.username || '').toLowerCase() === captainUsername.toLowerCase()
);
if (captainMember) {
const captainStatus = (captainMember.status || '').toLowerCase();
if (captainStatus === 'banned' || captainStatus === 'warned') {
headerContainer.setAttribute(BANNED_ATTR, captainStatus);
const badge = createStatusBadge(captainStatus);
// Insert badge after the "Team Captain" label
const captainLabel = headerContainer.querySelector('.tsxs.ttu.tsi.mls');
if (captainLabel) {
captainLabel.after(badge);
} else {
captainLink.after(badge);
}
} else {
headerContainer.setAttribute(BANNED_ATTR, 'ok');
}
}
}
}
}
// ── Roster rows ─────────────────────────────────────────────────────────
const rows = teamTable.querySelectorAll('.table-row');
rows.forEach((row) => {
if (row.hasAttribute(BANNED_ATTR)) return;
const nameContainer = row.querySelector('.player-name--container[title]');
if (!nameContainer) return;
const nameSpan = nameContainer.querySelector('.type-ellip');
if (!nameSpan) return;
const displayName = nameSpan.textContent.trim();
// Match this row to a member object
const member = members.find(m => {
const mName = m.displayName || m.username || '';
return mName.trim() === displayName;
});
if (!member) return;
const status = (member.status || '').toLowerCase();
if (status !== 'banned' && status !== 'warned') {
row.setAttribute(BANNED_ATTR, 'ok');
return;
}
row.setAttribute(BANNED_ATTR, status);
const badge = createStatusBadge(status);
// Place the badge in the status area (where flag icons also go),
// or after the name text and any racer badge icons
const statusArea = nameContainer.querySelector('.tsi.tc-lemon.tsxs');
if (statusArea) {
if (statusArea.firstChild) {
statusArea.insertBefore(badge, statusArea.firstChild);
const sep = document.createTextNode(' ');
statusArea.insertBefore(sep, badge.nextSibling);
} else {
statusArea.appendChild(badge);
}
} else {
const nameEl = nameContainer.querySelector('.type-ellip');
if (nameEl) {
let insertAfter = nameEl;
while (insertAfter.nextElementSibling &&
insertAfter.nextElementSibling.hasAttribute &&
(insertAfter.nextElementSibling.hasAttribute('data-badge-scope') ||
insertAfter.nextElementSibling.classList?.contains('profile-badge'))) {
insertAfter = insertAfter.nextElementSibling;
}
insertAfter.parentNode.insertBefore(badge, insertAfter.nextSibling);
} else {
nameContainer.appendChild(badge);
}
}
});
}
/** Extract team members array from React fiber tree. */
function findTeamMembers(teamTable) {
try {
// Walk up from the table or a card container to find the members prop
const card = teamTable.closest('.card') || teamTable.closest('section') || teamTable.parentElement;
if (!card) return null;
let fiber = getReactFiber(card);
let steps = 0;
while (fiber && steps++ < 40) {
const props = fiber.memoizedProps || fiber.pendingProps || {};
if (Array.isArray(props.members)) return props.members;
if (props.children?.props?.members) return props.children.props.members;
const state = fiber.memoizedState;
if (state?.memoizedState?.members) return state.memoizedState.members;
fiber = fiber.return;
}
// Fallback: try from the root section
const root = document.querySelector('#root section.card');
if (root) {
const reactObj = findReact(root);
if (reactObj?.props?.members) return reactObj.props.members;
}
return null;
} catch (e) {
console.error(TOOLKIT_LOG_PREFIX, 'Error finding team members:', e);
return null;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// FEATURE 2: Send Cash Readable Amount (/racer/*)
// ─────────────────────────────────────────────────────────────────────────────
const CASH_FORMAT_ATTR = 'data-toolkit-cashformat';
const NUMERIC_REGEXP = /^[0-9]+$/;
function handleSendCashFormat() {
if (!isFeatureEnabled('ENABLE_SEND_CASH_FORMAT')) return;
// Look for the Send Cash modal's input
const modals = document.querySelectorAll('.modal');
modals.forEach((modal) => {
const cashInput = modal.querySelector('.input.as-nitro-cash input.input-field');
if (!cashInput) return;
if (cashInput.hasAttribute(CASH_FORMAT_ATTR)) return;
cashInput.setAttribute(CASH_FORMAT_ATTR, '1');
// Create preview node using NT's native styling
const preview = document.createElement('div');
preview.className = 'tar tc-i tss mtxs';
preview.style.minHeight = '1.2em';
const prefixSpan = document.createElement('span');
prefixSpan.className = 'as-nitro-cash--prefix';
prefixSpan.setAttribute('data-ntk-cash-preview', '1');
preview.appendChild(prefixSpan);
const updatePreview = (value) => {
if (NUMERIC_REGEXP.test(value) && value.length > 0) {
prefixSpan.textContent = '$' + parseInt(value, 10).toLocaleString();
} else {
prefixSpan.textContent = '';
}
};
cashInput.addEventListener('input', (e) => updatePreview(e.target.value));
cashInput.addEventListener('change', (e) => updatePreview(e.target.value));
// Insert preview below the input
const inputContainer = cashInput.closest('.input.as-nitro-cash');
if (inputContainer) {
inputContainer.appendChild(preview);
// Fix layout if needed
const splitFlag = cashInput.closest('.split.split--flag');
if (splitFlag) splitFlag.classList.remove('split--flag');
}
// Initialize with current value
updatePreview(cashInput.value);
});
}
// ─────────────────────────────────────────────────────────────────────────────
// FEATURE 3: Donation Profile Link (/racer/*, /garage)
// ─────────────────────────────────────────────────────────────────────────────
const DONATION_LINK_ATTR = 'data-toolkit-donation-link';
function handleDonationLink() {
const linkStyle = readSetting('DONATION_LINK_STYLE');
if (!linkStyle || linkStyle === 'off') return;
// Look for the "You've Got Cash" modal
const modals = document.querySelectorAll('.modal');
modals.forEach((modal) => {
if (modal.hasAttribute(DONATION_LINK_ATTR)) return;
const header = modal.querySelector('.modal-header h2');
if (!header || header.textContent.trim() !== "You've Got Cash") return;
modal.setAttribute(DONATION_LINK_ATTR, '1');
const body = modal.querySelector('.modal-body');
if (!body) return;
// Get donor username from React internals
let donorUsername = null;
try {
const reactObj = findReact(body);
if (reactObj?.props?.gift?.username) {
donorUsername = reactObj.props.gift.username;
}
} catch { /* fallback below */ }
// Fallback: try to find username from fiber props
if (!donorUsername) {
try {
donorUsername = findReactProp(body, 'gift', 40)?.username;
} catch { /* give up */ }
}
if (!donorUsername) return;
const profileUrl = `/racer/${encodeURIComponent(donorUsername)}`;
// Find the well container to append the link row
const rowContainer = body.querySelector('.well.well--b.well--s');
if (!rowContainer) return;
// Create link row using NT's native split layout
const linkText = linkStyle === 'full_url'
? 'nitrotype.com/racer/' + donorUsername
: donorUsername;
const linkRow = document.createElement('div');
linkRow.className = 'split split--flag';
linkRow.innerHTML =
'<div class="split-cell tal">' +
'<span class="tc-ts tsxs ttu">Profile Link:</span>' +
'</div>' +
'<div class="split-cell">' +
'<a class="link link--i" href="' + escapeHtml(profileUrl) + '">' +
escapeHtml(linkText) +
'</a>' +
'</div>';
// Set link behavior — new tab or same-tab redirect
const link = linkRow.querySelector('a');
if (link) {
const openNewTab = readSetting('DONATION_LINK_NEW_TAB');
if (openNewTab) {
link.target = '_blank';
link.rel = 'noopener noreferrer';
}
// Same-tab: just let the native <a href> handle it (full page load)
}
rowContainer.appendChild(linkRow);
});
}
// ─────────────────────────────────────────────────────────────────────────────
// FEATURE 4: MOTD Offensive Filter (/team/*)
// ─────────────────────────────────────────────────────────────────────────────
const MOTD_FILTER_ATTR = 'data-toolkit-motd-filter';
// Embedded offensive word dictionary — sourced from Toonidy's Google Sheet,
// maintained locally to avoid external dependency.
// To add/remove words: edit this array directly.
const OFFENSIVE_WORDS = [
'!0li', '!o!i', '!oli', '!u57', '!u5t', '!us7', '!ust', '(um', '(un7', '(unt',
'***', '1d10t', '33x', '34tmy', '37d', '3ex', '3kr3m', '3krem', '3td',
'4!de', '4166a', '416ga', '41d3', '420', '43s', '4fr1c4', '4fr1ca', '4fric4',
'4frica', '4frika', '4id3', '4ide', '4igga', '4ld3', '4r53', '4r5e', '4rs3',
'4rse', '4y!r', '4yir', '4ylr',
'5!xtyn!n3', '5!xtyn!ne', '5!xtynin3', '5!xtynine', '57d', '5h!7', '5h074',
'5h07a', '5h0t4', '5hi7', '5hit', '5hot4', '5hota', '5ixtyn!n3', '5p!c',
'5p1c', '5pic', '5td',
'666', '69',
'7!75', '7!t5', '71t5', '71ts', '7it5', '7w47', '7w4t', '7wat',
'8!7h', '8!th', '817h', '81th', '84n6', '84n9', '84ng', '8ang', '8i7h', '8ith',
'9/11',
'@ss',
'a!d3', 'a!de', 'a$$', 'a1d3', 'a1d5', 'a1de', 'a1ds', 'a33', 'a35', 'a3s',
'a53', 'a55', 'a5s', 'afr1c4', 'afr1ca', 'afr1ka', 'afric4', 'africa', 'afrik4',
'afrika', 'aid3', 'aid5', 'aide', 'aids', 'aigga', 'ald3', 'alde', 'anal', 'anus',
'ar53', 'ar5e', 'ars3', 'arse', 'as3', 'as5', 'ass', 'ay!r', 'ayir', 'aylr',
'b!7h', 'b!th', 'b00b', 'b17h', 'b1th', 'b4n6', 'b4n9', 'b4ng', 'ba!!!',
'ba!ll', 'bal!l', 'ball!', 'balll', 'balls', 'ban6', 'ban9', 'bang', 'bi7h',
'bigd', 'bith', 'bm7ch', 'bmtch', 'boob', 'br34s7', 'br34st', 'br3as7',
'br3ast', 'bre4s7', 'bre4st', 'breas7', 'breast', 'bu77h', 'bu7th', 'but7h',
'butho', 'butma', 'butth',
'c0ck', 'cnts', 'cock', 'cr4p', 'crap', 'cum', 'cun7', 'cunt',
'd!k3', 'd!ke', 'd0n6', 'd0n9', 'd0ng', 'd1k3', 'd1ke', 'd360', 'd390', 'd39o',
'd3g0', 'd3go', 'd4dy', 'dady', 'damn', 'day90', 'daygo', 'de60', 'de90',
'de9o', 'deg0', 'dego', 'dik3', 'dike', 'don6', 'don9', 'dong', 'douche',
'dup4', 'dupa',
'e4tmy', 'eatmy', 'ekr3m', 'ekrem',
'f177', 'f46', 'f49', 'f4g', 'f4r7', 'f4rt', 'f94', 'fa6', 'fa9', 'fag', 'far7',
'fart', 'fitt', 'fu!k', 'fuc', 'fuck', 'fuik', 'fuk', 'fulk', 'fvck',
'g00k', 'g9y', 'gay', 'gey', 'gook',
'h0or', 'h0r', 'h0r3', 'h0re', 'h0rn!', 'h0rni', 'h0rny', 'h3rp3', 'h3rpe',
'h4t3r', 'h4ter', 'h4x0r', 'hat3r', 'hater', 'herp3', 'herpe', 'ho0r', 'hoe',
'hoor', 'hor3', 'hore', 'horn!', 'horni', 'horny', 'hump',
'idiot',
'k!11', 'k!ll', 'k111', 'ki11', 'kkk', 'kl11', 'kun7', 'kunt',
'l0!i', 'l0!l', 'l0l1', 'l0ll', 'ldlot', 'lo!i', 'lol1', 'loli', 'lu57',
'lu5t', 'lus7', 'lust',
'm!!f', 'm!lf', 'm0r0n', 'm0ron', 'm3h4rd', 'm3hard', 'meh4rd', 'mehard',
'mexican', 'mi!f', 'milf', 'mor0n', 'moron', 'mother',
'n!g4', 'n!ga', 'n!p5', 'n!ps', 'n119r', 'n19r', 'n1g4', 'n1ga', 'n1gr',
'n1p5', 'n1ps', 'n4z!', 'n4z1', 'n4zi', 'n5fW', 'n@z1', 'n@zi', 'naz!',
'naz1', 'nazi', 'nig4', 'niga', 'nigger', 'nip5', 'nips', 'nlg4', 'nlga',
'nlp5', 'nlps', 'nsfw', 'nw0rd', 'nword',
'p!33', 'p!35', 'p!53', 'p!5s', 'p!s5', 'p!ss', 'p00p', 'p133', 'p135', 'p153',
'p3n', 'p3n15', 'p3n1s', 'p3ni5', 'p3nis', 'p3rs3', 'p3rse', 'p4ck!', 'p4cki',
'p4ckl', 'pack!', 'packi', 'packl', 'pecker', 'pedo', 'pen15', 'peni5',
'penis', 'pers3', 'perse', 'phuck', 'pi33', 'pi35', 'pi3s', 'pi53', 'pi55',
'pi5s', 'pis3', 'pis5', 'piss', 'pl33', 'pl3s', 'pl5s', 'pls3', 'pls5', 'plss',
'poop', 'porn', 'pu70', 'pu7o', 'pusse', 'put0', 'puto',
'r4pe', 'rape', 're74r', 'retar',
's!xtyn!n3', 's!xtyn!ne', 's!xtynin3', 's!xtynine', 's33x', 's3ex', 's3x',
's7d', 's7up1d', 's7updi', 'se3x', 'seex', 'sex', 'sh!7', 'sh!t', 'sh007',
'sh00t', 'sh074', 'sh07a', 'sh0ta', 'shi7', 'shit', 'sho74', 'sho7a', 'shoo7',
'shoot', 'shot4', 'shota', 'sixtyn!n3', 'sixtyn!ne', 'sixtynin3',
'sixtynine', 'sp!c', 'sp1c', 'sp3rm', 'sperm', 'spic', 'std', 'stfu',
'stup1d', 'stupid',
't!75', 't!t5', 't!ts', 't175', 't177', 't17s', 't1t5', 't1tt', 'ti75',
'tits', 'titt', 'tw4t', 'twa7', 'twat',
'urmom', 'urmother', 'urmum',
'w00se', 'w0n6', 'w0n9', 'w0ng', 'w33d', 'w33nu', 'w3ed', 'w3enu', 'we3d',
'we3nu', 'weed', 'weenu', 'whore', 'won6', 'won9', 'wong',
'xxx',
'\u042F', '\uD83C\uDD70', '\uD83C\uDD71\uFE0F'
];
/** Build full word list including any user-added custom words. */
function getDictionaryWords() {
const customRaw = readSetting('CUSTOM_MOTD_WORDS') || '';
if (!customRaw.trim()) return OFFENSIVE_WORDS;
const custom = customRaw.split(',').map(w => w.trim().toLowerCase()).filter(Boolean);
return OFFENSIVE_WORDS.concat(custom);
}
// ─── Unicode-aware string splitting (runes) ──────────────────────────────────
const HIGH_SURROGATE_START = 0xd800;
const HIGH_SURROGATE_END = 0xdbff;
const LOW_SURROGATE_START = 0xdc00;
const REGIONAL_INDICATOR_START = 0x1f1e6;
const REGIONAL_INDICATOR_END = 0x1f1ff;
const FITZPATRICK_MODIFIER_START = 0x1f3fb;
const FITZPATRICK_MODIFIER_END = 0x1f3ff;
const VARIATION_MODIFIER_START = 0xfe00;
const VARIATION_MODIFIER_END = 0xfe0f;
const DIACRITICAL_MARKS_START = 0x20d0;
const DIACRITICAL_MARKS_END = 0x20ff;
const ZWJ = 0x200d;
const GRAPHEMS = [
0x0308, 0x0937, 0x0937, 0x093f, 0x093f, 0x0ba8,
0x0bbf, 0x0bcd, 0x0e31, 0x0e33, 0x0e40, 0x0e49,
0x1100, 0x1161, 0x11a8
];
function runes(string) {
if (typeof string !== 'string') return [];
const result = [];
let i = 0;
let increment = 0;
while (i < string.length) {
increment += runesNextUnits(i + increment, string);
if (isGraphem(string[i + increment])) increment++;
if (isVariationSelector(string[i + increment])) increment++;
if (isDiacriticalMark(string[i + increment])) increment++;
if (isZeroWidthJoiner(string[i + increment])) { increment++; continue; }
result.push(string.substring(i, i + increment));
i += increment;
increment = 0;
}
return result;
}
function runesNextUnits(i, string) {
const current = string[i];
if (!isFirstOfSurrogatePair(current) || i === string.length - 1) return 1;
const currentPair = current + string[i + 1];
const nextPair = string.substring(i + 2, i + 5);
if (isRegionalIndicator(currentPair) && isRegionalIndicator(nextPair)) return 4;
if (isFitzpatrickModifier(nextPair)) return 4;
return 2;
}
function betweenInclusive(value, lower, upper) { return value >= lower && value <= upper; }
function isFirstOfSurrogatePair(s) { return s && betweenInclusive(s.charCodeAt(0), HIGH_SURROGATE_START, HIGH_SURROGATE_END); }
function codePointFromSurrogatePair(pair) {
return ((pair.charCodeAt(0) - HIGH_SURROGATE_START) << 10) + (pair.charCodeAt(1) - LOW_SURROGATE_START) + 0x10000;
}
function isRegionalIndicator(s) { return s && s.length >= 2 && betweenInclusive(codePointFromSurrogatePair(s), REGIONAL_INDICATOR_START, REGIONAL_INDICATOR_END); }
function isFitzpatrickModifier(s) { return s && s.length >= 2 && betweenInclusive(codePointFromSurrogatePair(s), FITZPATRICK_MODIFIER_START, FITZPATRICK_MODIFIER_END); }
function isVariationSelector(s) { return typeof s === 'string' && betweenInclusive(s.charCodeAt(0), VARIATION_MODIFIER_START, VARIATION_MODIFIER_END); }
function isDiacriticalMark(s) { return typeof s === 'string' && betweenInclusive(s.charCodeAt(0), DIACRITICAL_MARKS_START, DIACRITICAL_MARKS_END); }
function isGraphem(s) { return typeof s === 'string' && GRAPHEMS.indexOf(s.charCodeAt(0)) !== -1; }
function isZeroWidthJoiner(s) { return typeof s === 'string' && s.charCodeAt(0) === ZWJ; }
/** Find offensive words in text, skipping punctuation/spacing between letters. */
function findBadWords(textRunes) {
const foundWords = [];
const foundLetters = [];
getDictionaryWords().forEach((word) => {
const wordChunks = runes(word);
for (let i = 0; i < textRunes.length; i++) {
if (textRunes[i].toLowerCase() !== wordChunks[0].toLowerCase()) continue;
const matched = [i];
let wordIndex = 1;
let j = i + 1;
for (; j < textRunes.length && wordIndex < wordChunks.length; j++) {
const checkLetter = textRunes[j].toLowerCase();
const offensiveLetter = wordChunks[wordIndex].toLowerCase();
if (checkLetter === offensiveLetter) {
matched.push(j);
wordIndex++;
continue;
}
// Skip punctuation, spacing, or unmatched numbers
if (
checkLetter === null ||
(/[0-9]/.test(checkLetter) && !/^[0-9]+$/.test(word)) ||
(!/[a-z0-9]/i.test(checkLetter) &&
!/(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])/.test(checkLetter))
) {
continue;
}
break; // Character didn't match and wasn't skippable
}
if (matched.length === wordChunks.length) {
if (!foundWords.includes(word)) foundWords.push(word);
matched.forEach((idx) => {
if (!foundLetters.includes(idx)) foundLetters.push(idx);
});
}
}
});
return [foundWords, foundLetters];
}
// ─── MOTD Filter UI ──────────────────────────────────────────────────────────
/** Inject MOTD filter styles once. */
let motdStylesInjected = false;
function injectMOTDStyles() {
if (motdStylesInjected) return;
motdStylesInjected = true;
const style = document.createElement('style');
style.textContent = `
.toolkit-motd-review { margin-top: 12px; border-radius: 8px; padding: 12px; background-color: #2a2d3e; }
.toolkit-motd-review.hidden { display: none; }
.toolkit-motd-body { display: flex; flex-flow: row wrap; padding: 10px; background-color: #1a1d2e; border-radius: 6px; font-family: "Roboto Mono", "Courier New", monospace; font-size: 12px; }
.toolkit-motd-word { display: flex; margin-right: 1ch; }
.toolkit-motd-letter { color: rgba(255, 255, 255, 0.3); }
.toolkit-motd-letter.offensive { color: #d62f3a; font-weight: 700; }
.toolkit-motd-letter.invalid { color: #ff9f1c; font-weight: 700; text-decoration: underline wavy; }
.toolkit-motd-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; }
.toolkit-motd-helper { color: #888; font-size: 12px; }
.toolkit-motd-warnings { margin-top: 8px; border: 1px solid #d62f3a; border-radius: 6px; padding: 10px; background-color: rgba(214, 47, 58, 0.15); color: rgba(255, 255, 255, 0.85); font-size: 13px; }
.toolkit-motd-warnings ul { margin: 4px 0 0 16px; padding: 0; }
.toolkit-motd-warnings li { margin-bottom: 2px; }
.toolkit-motd-check-btn { padding: 6px 14px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.08); color: #fff; font-size: 12px; cursor: pointer; transition: background 0.15s; }
.toolkit-motd-check-btn:hover { background: rgba(255,255,255,0.15); }
`;
document.head.appendChild(style);
}
function handleMOTDFilter() {
if (!isFeatureEnabled('ENABLE_MOTD_FILTER')) return;
const path = window.location.pathname;
if (!path.startsWith('/team/')) return;
// Check if user is captain/officer
const currentUser = getCurrentUser();
if (!currentUser?.tag || !['captain', 'officer'].includes(currentUser.teamRole)) return;
// Check we're on our own team page
const teamTag = path.split('/').filter(Boolean).pop();
if (!teamTag || teamTag.toLowerCase() !== currentUser.tag.toLowerCase()) return;
// Watch for the MOTD modal
const modals = document.querySelectorAll('.modal');
modals.forEach((modal) => {
if (modal.hasAttribute(MOTD_FILTER_ATTR)) return;
const textarea = modal.querySelector('textarea.input-field');
if (!textarea) return;
modal.setAttribute(MOTD_FILTER_ATTR, '1');
injectMOTDStyles();
// Create the review UI
const reviewContainer = document.createElement('div');
reviewContainer.className = 'toolkit-motd-review hidden';
const reviewBody = document.createElement('div');
reviewBody.className = 'toolkit-motd-body';
const warnings = document.createElement('div');
warnings.className = 'toolkit-motd-warnings';
warnings.style.display = 'none';
const actions = document.createElement('div');
actions.className = 'toolkit-motd-actions';
const helper = document.createElement('span');
helper.className = 'toolkit-motd-helper';
helper.textContent = 'Precautionary check — some flagged words may be allowed by Nitro Type, and some may not be detected.';
const checkBtn = document.createElement('button');
checkBtn.type = 'button';
checkBtn.className = 'toolkit-motd-check-btn';
checkBtn.textContent = 'Check for Issues';
actions.appendChild(helper);
actions.appendChild(checkBtn);
reviewContainer.appendChild(reviewBody);
reviewContainer.appendChild(warnings);
reviewContainer.appendChild(actions);
// Insert after the textarea's parent form or input container
const form = textarea.closest('form') || textarea.parentElement;
if (form) {
form.appendChild(reviewContainer);
}
/** Run the check. */
const runCheck = () => {
const text = textarea.value;
if (!text.trim()) {
reviewContainer.classList.add('hidden');
return;
}
const letters = runes(text);
const [badWords, badLetters] = findBadWords(letters);
// Render the letter-by-letter review
reviewBody.innerHTML = '';
let wordFragment = document.createDocumentFragment();
let hasLetters = false;
for (let i = 0; i < letters.length; i++) {
const char = letters[i];
if (char === ' ') {
if (hasLetters) {
const wordDiv = document.createElement('div');
wordDiv.className = 'toolkit-motd-word';
wordDiv.appendChild(wordFragment);
reviewBody.appendChild(wordDiv);
wordFragment = document.createDocumentFragment();
hasLetters = false;
}
continue;
}
const letterDiv = document.createElement('div');
letterDiv.className = 'toolkit-motd-letter';
letterDiv.textContent = char;
if (badLetters.includes(i)) {
letterDiv.classList.add('offensive');
}
wordFragment.appendChild(letterDiv);
hasLetters = true;
}
if (hasLetters) {
const wordDiv = document.createElement('div');
wordDiv.className = 'toolkit-motd-word';
wordDiv.appendChild(wordFragment);
reviewBody.appendChild(wordDiv);
}
// Check for non-Latin characters (NT only allows Latin letters, numbers, standard punctuation)
const invalidChars = [];
const invalidPositions = new Set();
for (let i = 0; i < letters.length; i++) {
const ch = letters[i];
// Allow Latin letters, digits, standard punctuation, whitespace
if (!/^[\x20-\x7E\n\r\t]+$/.test(ch)) {
invalidChars.push(ch);
invalidPositions.add(i);
}
}
// Mark invalid characters in the review
invalidPositions.forEach(i => {
const els = reviewBody.querySelectorAll('.toolkit-motd-letter');
// Map index to element accounting for spaces
let elIdx = 0;
let letterCount = 0;
for (let j = 0; j <= i && elIdx < els.length; j++) {
if (letters[j] !== ' ') {
if (j === i) { els[elIdx].classList.add('invalid'); break; }
elIdx++;
}
}
});
// Show warnings
const warningParts = [];
if (invalidChars.length > 0) {
const unique = [...new Set(invalidChars)];
warningParts.push('<p>Contains characters not allowed by Nitro Type (only Latin letters, numbers, and standard punctuation):</p><ul>' +
unique.map(c => '<li>' + escapeHtml(c) + ' (U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0') + ')</li>').join('') +
'</ul>');
}
if (badWords.length > 0) {
warningParts.push('<p>Possibly offensive words found:</p><ul>' +
badWords.map(w => '<li>' + escapeHtml(w) + '</li>').join('') +
'</ul>');
}
if (warningParts.length > 0) {
warnings.style.display = '';
warnings.innerHTML = warningParts.join('');
} else {
warnings.style.display = 'none';
}
reviewContainer.classList.remove('hidden');
};
checkBtn.addEventListener('click', runCheck);
// Live check as the user types (debounced)
let debounceTimer = null;
textarea.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(runCheck, 300);
});
// Also intercept NT's own error message about offensive content
const inputAlert = modal.querySelector('.input-alert .bucket-content');
if (inputAlert) {
const errorObserver = new MutationObserver(([mutation]) => {
if (mutation.addedNodes.length === 0) return;
const errorNode = mutation.addedNodes[0];
if (errorNode.textContent && errorNode.textContent.startsWith('This contains words that are possibly offensive')) {
errorNode.textContent = 'Message contains possibly offensive content. Use the checker below to review.';
runCheck();
}
});
errorObserver.observe(inputAlert, { childList: true });
}
});
}
// ─────────────────────────────────────────────────────────────────────────────
// FEATURE 6: Appearance Toggles (CSS-based)
// ─────────────────────────────────────────────────────────────────────────────
const TOOLKIT_STYLE_ID = 'toolkit-appearance-styles';
const SMART_NOTIFY_ATTR = 'data-toolkit-smart-notify';
/**
* Smart Shop Notify: update the shop badge to show only unowned item count.
* Hides the badge entirely if everything is owned.
* Must run after NTGLOBALS is loaded and the nav badge is in the DOM.
*/
function applySmartShopNotify() {
const pw = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window;
const SHOP = pw.NTGLOBALS?.SHOP;
if (!SHOP) return; // NTGLOBALS not loaded yet — will retry via poll/observer
const shopNotify = document.querySelector('a[href="/shop"] .notify');
if (!shopNotify) return; // Badge not in DOM yet — will retry
// Don't re-process if we already handled this badge
if (shopNotify.hasAttribute(SMART_NOTIFY_ATTR)) return;
try {
const now = Math.floor(Date.now() / 1000);
const currentDaily = SHOP.find(r => r.category === 'daily1' && r.startStamp <= now && r.expiration > now);
const currentFeatured = SHOP.find(r => r.category === 'featured' && r.startStamp <= now && r.expiration > now);
let ownedCarIDs = new Set();
let ownedLootIDs = new Set();
try {
const persist = JSON.parse(localStorage.getItem('persist:nt'));
const user = JSON.parse(persist.user);
if (user.cars) user.cars.forEach(c => ownedCarIDs.add(c[0]));
if (user.loot) user.loot.forEach(l => ownedLootIDs.add(l.lootID));
} catch { /* ignore */ }
const allItems = [
...(currentDaily?.items || []),
...(currentFeatured?.items || [])
];
const unownedCount = allItems.filter(item => {
if (item.type === 'car') return !ownedCarIDs.has(item.id);
if (item.type === 'loot') return !ownedLootIDs.has(item.id);
return false;
}).length;
shopNotify.setAttribute(SMART_NOTIFY_ATTR, '1');
if (unownedCount === 0) {
shopNotify.style.display = 'none';
} else {
shopNotify.setAttribute('data-count', unownedCount);
}
} catch (e) {
console.warn(TOOLKIT_LOG_PREFIX, 'Smart shop notify error:', e.message);
}
}
function applyAppearanceStyles() {
// Remove existing toolkit style block to rebuild
const existing = document.getElementById(TOOLKIT_STYLE_ID);
if (existing) existing.remove();
const rules = [];
if (readSetting('HIDE_SEASON_BANNER')) rules.push('.seasonTeaser { display: none !important; }');
if (readSetting('HIDE_ADS')) rules.push('.ad, .profile-ad { display: none !important; }');
if (readSetting('HIDE_FOOTER')) rules.push('footer { display: none !important; }');
if (readSetting('HIDE_BUY_CASH')) rules.push('.profile--content--current-cash .bucket-media { display: none !important; }');
if (readSetting('HIDE_NOTIFY_ALL')) {
rules.push('.notify { display: none !important; }');
} else {
const notifyPages = [
['HIDE_NOTIFY_SHOP', 'shop'],
['HIDE_NOTIFY_FRIENDS', 'friends'],
['HIDE_NOTIFY_TEAM', 'team', true],
['HIDE_NOTIFY_NEWS', 'news'],
['HIDE_NOTIFY_ACHIEVEMENTS', 'achievements']
];
notifyPages.forEach(([key, page, startsWith]) => {
if (readSetting(key)) {
const sel = startsWith
? 'a[href^="/' + page + '"] .notify'
: 'a[href="/' + page + '"] .notify';
rules.push(sel + ' { display: none !important; }');
}
});
// Smart Shop Notify: CSS fallback to hide badge if JS hasn't run yet
if (!readSetting('HIDE_NOTIFY_SHOP') && readSetting('SMART_SHOP_NOTIFY')) {
applySmartShopNotify();
}
}
if (readSetting('HIDE_CASH_DISPLAY')) {
const mode = readSetting('HIDE_CASH_MODE') || 'hidden';
// Exclude: shop item prices, shop modal prices, toolkit's own cash preview, racelog winnings
const excludeParts = [
'.page-shop--product--price .as-nitro-cash--prefix',
'.page-shop--modal--price .as-nitro-cash--prefix',
'[data-ntk-cash-preview]',
'.table--striped .as-nitro-cash--prefix',
'.season-reward-mini-preview--label .as-nitro-cash--prefix'
];
// Only exclude Total Spent if the user hasn't opted to hide it
if (!readSetting('HIDE_CASH_TOTAL_SPENT')) {
excludeParts.push('.stat-box--extra .as-nitro-cash--prefix');
}
const excludeReset = excludeParts.join(', ');
if (mode === 'hidden') {
rules.push('.as-nitro-cash--prefix { visibility: hidden !important; }');
rules.push(`${excludeReset} { visibility: visible !important; }`);
// Hide the entire cash section on garage/profile when showing nothing
rules.push('.profile--content--current-cash { display: none !important; }');
} else {
rules.push('.as-nitro-cash--prefix { font-size: 0 !important; }');
let replacementText = '';
if (mode === 'redacted') replacementText = 'REDACTED';
else if (mode === 'stars') replacementText = '*******';
else if (mode === 'custom') replacementText = (readSetting('HIDE_CASH_CUSTOM_TEXT') || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
rules.push(`.as-nitro-cash--prefix::after { content: '${replacementText}'; font-size: 1rem; }`);
// Restore excluded elements to normal
rules.push(`${excludeReset} { font-size: inherit !important; }`);
const afterParts = excludeParts.map(s => s.replace('.as-nitro-cash--prefix', '.as-nitro-cash--prefix::after'));
rules.push(`${afterParts.join(', ')} { content: none !important; }`);
// Fix double replacement — nested .as-nitro-cash--prefix elements should only show one replacement
rules.push(`.as-nitro-cash--prefix .as-nitro-cash--prefix::after { content: none !important; }`);
}
}
if (readSetting('HIDE_ALT_LOGINS')) {
rules.push('.split.split--divided > :not(:last-child) { display: none !important; }');
rules.push('.race-results--qualifying--signup > :not(.race-results--qualifying--form) { display: none !important; }');
}
if (rules.length === 0) return;
const style = document.createElement('style');
style.id = TOOLKIT_STYLE_ID;
style.textContent = rules.join('\n');
(document.head || document.documentElement).appendChild(style);
}
// ─────────────────────────────────────────────────────────────────────────────
// FEATURE 7: Car Profile Icon (Global)
// ─────────────────────────────────────────────────────────────────────────────
const CAR_ICON_ATTR = 'data-toolkit-car-icon';
function handleCarIcon() {
if (!readSetting('ENABLE_CAR_ICON')) return;
// Already replaced
if (document.querySelector('[' + CAR_ICON_ATTR + ']')) return;
const icon = document.querySelector('svg.icon-user-s');
if (!icon) return;
try {
const persistRaw = localStorage.getItem('persist:nt');
if (!persistRaw) return;
const persist = JSON.parse(persistRaw);
const user = JSON.parse(persist.user);
const { carID, carHueAngle } = user;
const cars = window.NTGLOBALS?.CARS;
if (!cars || !carID) return;
const car = cars.find(c => c.carID === carID);
if (!car?.options?.smallSrc) return;
const img = document.createElement('img');
img.src = 'https://www.nitrotype.com/cars/' + car.options.smallSrc;
img.style.height = '18px';
img.style.width = '18px';
img.style.filter = 'hue-rotate(' + (carHueAngle || 0) + 'deg)';
img.style.objectFit = 'contain';
img.setAttribute(CAR_ICON_ATTR, '1');
icon.replaceWith(img);
} catch (e) {
console.error(TOOLKIT_LOG_PREFIX, 'CarIcon error:', e);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// FEATURE 9: Shop Tomorrow Preview (/shop)
// ─────────────────────────────────────────────────────────────────────────────
const SHOP_PREVIEW_ATTR = 'data-toolkit-shop-preview';
let shopPreviewActive = false;
/**
* Extract React internals from Nitro Type's bundled code for animated previews.
* Returns { React, ReactDOM, CarComponent, TrailComponent, getCarUrl, getCarMetaData, getLootMetaData, playerCar }
* or null if extraction fails.
*/
function extractReactInternals(pw) {
// 1. React & ReactDOM from webpack
let React = null, ReactDOM = null;
try {
const moduleId = '__ntk_react_' + Date.now();
pw.webpackJsonp.push([[moduleId], { [moduleId]: function(m, e, req) {
for (const id of Object.keys(req.m)) {
try {
const mod = req(id);
if (mod?.createElement && mod?.Component && !React) React = mod;
if (mod?.render && mod?.createPortal && !ReactDOM) ReactDOM = mod;
} catch (ex) { /* skip */ }
}
}}, [[moduleId]]]);
} catch (e) {
console.warn(TOOLKIT_LOG_PREFIX, 'webpackJsonp extraction failed:', e.message);
return null;
}
if (!React || !ReactDOM) return null;
// 2. Walk React fiber to find components and helper functions
let CarComponent = null, TrailComponent = null;
let getCarUrl = null, getCarMetaData = null, getLootMetaData = null;
function getComponentFromSelector(selector) {
const canvas = document.querySelector(selector);
if (!canvas) return null;
const container = canvas.closest('.animated-car-preview') || canvas.closest('.animated-asset-preview') || canvas.parentElement;
if (!container) return null;
const fk = Object.keys(container).find(k => k.startsWith('__reactFiber'));
if (!fk) return null;
let node = container[fk];
while (node && !(node.type && typeof node.type === 'function')) node = node.return;
return node;
}
// Extract CarComponent
const carNode = getComponentFromSelector('.animated-car-preview canvas');
if (carNode) {
CarComponent = carNode.type;
// Walk up to find shop-level helper props
let sn = carNode;
while (sn) {
if (sn.memoizedProps?.getCarMetaData) {
getCarMetaData = sn.memoizedProps.getCarMetaData;
getLootMetaData = sn.memoizedProps.getLootMetaData;
if (sn.memoizedProps.getCarUrl) getCarUrl = sn.memoizedProps.getCarUrl;
break;
}
if (sn.memoizedProps?.getCarUrl && !getCarUrl) getCarUrl = sn.memoizedProps.getCarUrl;
sn = sn.return;
}
}
// Extract TrailComponent
const trailNode = getComponentFromSelector('.animated-asset-preview canvas');
if (trailNode) {
TrailComponent = trailNode.type;
if (!getCarUrl && trailNode.memoizedProps?.getCarUrl) getCarUrl = trailNode.memoizedProps.getCarUrl;
}
if (!getCarUrl || !getCarMetaData) return null;
// 3. Build playerCar data for trail previews (user's currently equipped car)
let playerCar = null;
try {
const persist = JSON.parse(localStorage.getItem('persist:nt'));
const user = JSON.parse(persist.user);
const carID = user.carID;
const hue = user.carHueAngle || 0;
const meta = getCarMetaData(carID);
if (meta) {
playerCar = {
id: carID, hue,
rarity: meta.rarity,
largeSrc: meta.largeSrc,
smallSrc: meta.smallSrc,
assetKey: meta.assetKey,
goldOnly: meta.goldOnly || false
};
}
} catch { /* ignore */ }
// 4. Inject CSS for animated preview containers
if (!document.getElementById('ntk-anim-preview-css')) {
const style = document.createElement('style');
style.id = 'ntk-anim-preview-css';
style.textContent = `.ntk-anim-mount .animated-car-preview, .ntk-anim-mount .animated-asset-preview { width: 100%; height: 100%; }`;
document.head.appendChild(style);
}
console.info(TOOLKIT_LOG_PREFIX, 'Animated previews ready:', { car: !!CarComponent, trail: !!TrailComponent });
return { React, ReactDOM, CarComponent, TrailComponent, getCarUrl, getCarMetaData, getLootMetaData, playerCar };
}
function handleShopPreview() {
if (!isFeatureEnabled('ENABLE_SHOP_LEAKS')) return;
if (window.location.pathname !== '/shop') return;
if (document.querySelector('[' + SHOP_PREVIEW_ATTR + ']')) return;
const featuredSection = document.querySelector('.page-shop--featured-products');
if (!featuredSection) return;
const pw = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window;
const SHOP = pw.NTGLOBALS?.SHOP;
const CARS = pw.NTGLOBALS?.CARS;
const LOOT = pw.NTGLOBALS?.LOOT;
const CAR_URL = pw.NTGLOBALS?.CAR_URL || '/cars/';
if (!SHOP || !CARS || !LOOT) return;
const now = Math.floor(Date.now() / 1000);
// Find next rotations (startStamp in the future)
const nextFeatured = SHOP.find(r => r.category === 'featured' && r.startStamp > now);
const nextDaily = SHOP.find(r => r.category === 'daily1' && r.startStamp > now);
if (!nextFeatured && !nextDaily) return;
// ── Extract React internals for animated previews ──
let reactCtx = null;
try {
reactCtx = extractReactInternals(pw);
} catch (e) {
console.warn(TOOLKIT_LOG_PREFIX, 'Animated previews unavailable:', e.message);
}
// Track mounted React roots for cleanup
const mountedRoots = [];
// User inventory for ownership badges
let ownedCarIDs = new Set();
let ownedLootIDs = new Set();
try {
const persist = JSON.parse(localStorage.getItem('persist:nt'));
const user = JSON.parse(persist.user);
if (user.cars) user.cars.forEach(c => ownedCarIDs.add(c[0]));
if (user.loot) user.loot.forEach(l => ownedLootIDs.add(l.lootID));
} catch { /* ignore */ }
// Resolve item metadata from NTGLOBALS
function resolveItem(shopItem) {
const { type, id, price } = shopItem;
if (type === 'car') {
const car = CARS.find(c => c.carID === id);
if (!car) return null;
return {
type: 'car',
typeName: car.name,
name: car.name,
price: price ?? car.price ?? null,
rarity: car.options?.rarity || 'common',
imgSrc: CAR_URL + car.options?.largeSrc,
isAnimated: !!car.options?.isAnimated,
assetKey: car.options?.assetKey,
carID: id,
owned: ownedCarIDs.has(id)
};
}
const loot = LOOT.find(l => l.lootID === id);
if (!loot) return null;
const lootType = loot.type || 'loot';
// Animated loot types: trail, nametag, nitro — assetKey is top-level on the loot object
const isAnimatedLoot = (lootType === 'trail' || lootType === 'nametag' || lootType === 'nitro') && !!loot.assetKey;
return {
type: lootType,
typeName: lootType.toUpperCase(),
name: loot.name,
price: price ?? loot.price ?? null,
rarity: loot.options?.rarity || 'common',
imgSrc: loot.options?.src || null,
animationPath: isAnimatedLoot ? loot.assetKey : null,
animMode: lootType === 'trail' ? 'trail-preview' : lootType === 'nametag' ? 'nametag-preview' : lootType === 'nitro' ? 'nitro-preview' : null,
lootID: id,
owned: ownedLootIDs.has(id)
};
}
// Format price with commas
function formatPrice(price) {
if (price == null) return null;
return '$' + Number(price).toLocaleString();
}
// Build a single item card matching NT's DOM structure
function buildItemCard(item, isFeatured) {
const card = document.createElement('div');
card.className = `page-shop--product type--${item.type}${isFeatured ? ' is-featured' : ''}${item.owned ? ' is-owned' : ''}`;
let previewHtml = '';
const useAnimCar = item.type === 'car' && item.isAnimated && reactCtx;
const useAnimLoot = item.animationPath && item.animMode && reactCtx;
if (item.type === 'title') {
previewHtml = `
<div class="page-shop--product--preview">
<div class="title-wrapper">
<div class="title-label">
<div style="height: 38px;">
<div class="page-shop--product--name" style="white-space: nowrap; position: absolute; transform: translate(-50%, 0px) scale(1, 1); left: 50%;">
<span class="quote">"</span>${item.name}<span class="quote">"</span>
</div>
</div>
</div>
</div>
</div>`;
} else if (useAnimCar) {
// Animated car — mount point for React component
previewHtml = `
<div class="page-shop--product--preview">
<div class="ntk-anim-mount" data-ntk-anim="car" data-ntk-car-id="${item.carID}" style="width:100%; height:180px;"></div>
</div>`;
} else if (useAnimLoot) {
// Animated loot (trail, nametag, nitro) — mount point for React component
previewHtml = `
<div class="page-shop--product--preview">
<div class="ntk-anim-mount" data-ntk-anim="loot" data-ntk-path="${item.animationPath}" data-ntk-mode="${item.animMode}" style="width:100%; height:180px;"></div>
</div>`;
} else if (item.type === 'car' && item.imgSrc) {
previewHtml = `
<div class="page-shop--product--preview">
<div class="vehicle-wrapper">
<img src="${item.imgSrc}" style="max-width:100%; max-height:200px; object-fit:contain;" alt="${item.name}" loading="lazy">
</div>
</div>`;
} else if (item.imgSrc) {
previewHtml = `
<div class="page-shop--product--preview">
<div style="display:flex; align-items:center; justify-content:center; width:100%; min-height:140px; padding:12px;">
<img src="${item.imgSrc}" style="max-width:100%; max-height:200px; object-fit:contain;" alt="${item.name}" loading="lazy">
</div>
</div>`;
}
const priceHtml = formatPrice(item.price)
? `<div class="page-shop--product--price"><span class="as-nitro-cash--prefix">${formatPrice(item.price)}</span></div>`
: `<div class="page-shop--product--price" style="opacity:0.5; font-style:italic; font-size:12px;">Price TBD</div>`;
card.innerHTML = `
<div class="page-shop--product--content">
<div class="rarity-frame rarity-frame--${item.rarity}">
<div class="rarity-frame--extra"></div>
<div class="rarity-frame--content">
<div class="page-shop--product--container">
${item.owned ? '<div class="page-shop--owned">Owned</div>' : ''}
${previewHtml}
<div class="page-shop--product--details">
<div class="page-shop--product--type">${item.name}</div>
${priceHtml}
</div>
</div>
</div>
</div>
</div>`;
return card;
}
// Mount animated previews into DOM-attached mount points
function mountAnimations(listEl) {
if (!reactCtx) return;
const { React, ReactDOM, CarComponent, TrailComponent, getCarUrl, getCarMetaData, playerCar } = reactCtx;
listEl.querySelectorAll('.ntk-anim-mount').forEach(mount => {
const animType = mount.dataset.ntkAnim;
try {
if (animType === 'car' && CarComponent) {
const carID = parseInt(mount.dataset.ntkCarId);
const meta = getCarMetaData(carID);
if (!meta) return;
ReactDOM.render(
React.createElement(CarComponent, {
carID, hue: 0,
getCarUrl, getCarMetaData, metaData: meta,
animate: true, scale: 0.85,
transparent: true, backgroundColor: 0,
useWebGL: true, offsetX: 0, offsetY: 0
}),
mount
);
mountedRoots.push(mount);
} else if (animType === 'loot' && TrailComponent) {
const path = mount.dataset.ntkPath;
const mode = mount.dataset.ntkMode;
if (!path || !mode) return;
ReactDOM.render(
React.createElement(TrailComponent, {
mode, path, scale: 1.33,
getCarUrl, playerCar,
transparent: true, useWebGL: false,
options: { hideBackground: true },
backgroundColor: 0, animate: true
}),
mount
);
mountedRoots.push(mount);
}
} catch (e) {
console.warn(TOOLKIT_LOG_PREFIX, 'Anim mount failed:', animType, e.message);
}
});
}
// Unmount all animated React roots
function unmountAnimations() {
if (!reactCtx) return;
mountedRoots.forEach(mount => {
try { reactCtx.ReactDOM.unmountComponentAtNode(mount); } catch (e) { /* ignore */ }
});
mountedRoots.length = 0;
}
// Build a product list container with item cards
function buildProductList(rotation, isFeatured) {
const list = document.createElement('div');
list.className = 'page-shop--product-list';
list.setAttribute(SHOP_PREVIEW_ATTR, 'items');
if (!rotation || !rotation.items.length) {
list.innerHTML = '<div style="color:#8a8ea0; text-align:center; padding:24px; font-size:14px;">No items available for preview.</div>';
return list;
}
rotation.items.forEach(shopItem => {
const item = resolveItem(shopItem);
if (item) list.appendChild(buildItemCard(item, isFeatured));
});
return list;
}
// Format a countdown string from seconds
function formatCountdown(seconds) {
if (seconds <= 0) return 'now';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const parts = [];
if (h > 0) parts.push(h + (h === 1 ? ' hour' : ' hours'));
if (m > 0) parts.push(m + (m === 1 ? ' minute' : ' minutes'));
if (s > 0 || parts.length === 0) parts.push(s + (s === 1 ? ' second' : ' seconds'));
return parts.join(', ');
}
// Find the product sections by their header text (search all shop sections, not just one container)
const productSections = document.querySelectorAll('.page-shop--products');
const sections = [];
productSections.forEach(sec => {
const nameEl = sec.querySelector('.page-shop--category--name');
const productList = sec.querySelector('.page-shop--product-list');
const timeEl = sec.querySelector('.page-shop--time-remaining');
if (!nameEl || !productList) return;
const name = nameEl.textContent.trim();
const isFeatured = name.includes('Featured');
const nextRotation = isFeatured ? nextFeatured : nextDaily;
sections.push({
container: sec,
nameEl,
productList,
timeEl,
isFeatured,
nextRotation,
originalName: name
});
});
if (sections.length === 0) return;
// Create toggle button
const btn = document.createElement('button');
btn.setAttribute(SHOP_PREVIEW_ATTR, 'toggle');
btn.textContent = 'Show Tomorrow';
btn.style.cssText = `
background: #2D8050;
color: #fff;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 14px;
font-weight: 600;
font-family: "Montserrat", sans-serif;
cursor: pointer;
margin-left: auto;
transition: all 0.25s ease-in-out;
white-space: nowrap;
line-height: 1;
box-shadow: 0 2px 0 #1e5c38, 0 3px 6px rgba(0,0,0,0.3);
`;
btn.addEventListener('mouseenter', () => { btn.style.opacity = '0.85'; });
btn.addEventListener('mouseleave', () => { btn.style.opacity = '1'; });
btn.addEventListener('mousedown', () => { btn.style.transform = 'scale(0.97)'; });
btn.addEventListener('mouseup', () => { btn.style.transform = 'scale(1)'; });
// Insert button into the first section's category header
const firstCategory = sections[0].container.querySelector('.page-shop--category');
if (firstCategory) {
firstCategory.style.display = 'flex';
firstCategory.style.alignItems = 'center';
firstCategory.style.flexWrap = 'wrap';
firstCategory.appendChild(btn);
}
// Toggle handler
btn.addEventListener('click', () => {
shopPreviewActive = !shopPreviewActive;
sections.forEach(sec => {
if (shopPreviewActive) {
// Hide original, show tomorrow's items
sec.productList.style.display = 'none';
const existing = sec.container.querySelector('[' + SHOP_PREVIEW_ATTR + '="items"]');
if (existing) existing.remove();
const tomorrowList = buildProductList(sec.nextRotation, sec.isFeatured);
sec.productList.parentNode.insertBefore(tomorrowList, sec.productList.nextSibling);
// Mount animated previews now that list is in the DOM
mountAnimations(tomorrowList);
// Update countdown to show when tomorrow's items go live
if (sec.timeEl && sec.nextRotation) {
sec._originalTimeHtml = sec.timeEl.innerHTML;
const secsUntil = sec.nextRotation.startStamp - Math.floor(Date.now() / 1000);
sec.timeEl.innerHTML = `<svg class="icon icon-time"><use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="/dist/site/images/icons/icons.css.svg#icon-time"></use></svg>Goes live in ${formatCountdown(Math.max(0, secsUntil))}`;
}
} else {
// Unmount animated previews before removing DOM
unmountAnimations();
// Restore original
sec.productList.style.display = '';
const preview = sec.container.querySelector('[' + SHOP_PREVIEW_ATTR + '="items"]');
if (preview) preview.remove();
if (sec.timeEl && sec._originalTimeHtml) {
sec.timeEl.innerHTML = sec._originalTimeHtml;
}
}
});
btn.textContent = shopPreviewActive ? 'Show Today' : 'Show Tomorrow';
btn.style.background = shopPreviewActive ? '#e67e22' : '#2D8050';
btn.style.boxShadow = shopPreviewActive
? '0 2px 0 #b35a10, 0 3px 6px rgba(0,0,0,0.3)'
: '0 2px 0 #1e5c38, 0 3px 6px rgba(0,0,0,0.3)';
});
}
// ─────────────────────────────────────────────────────────────────────────────
// FEATURE 11: Garage Section Organizer (/garage)
// ─────────────────────────────────────────────────────────────────────────────
const GARAGE_ORGANIZER_ATTR = 'data-ntk-garage-organizer';
/** Show a custom NT-styled modal with an input field. Returns a Promise that resolves with the value or null. */
function showGarageModal(currentSections, totalCars) {
return new Promise((resolve) => {
// Overlay
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);';
// Modal container
const modal = document.createElement('div');
modal.style.cssText = 'width:580px;max-width:92vw;border-radius:8px;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,0.5);font-family:Montserrat,sans-serif;';
// Header bar (blue, like mod menu)
const header = document.createElement('div');
header.style.cssText = 'background:url(/dist/site/images/backgrounds/bg-noise.png) top left repeat, linear-gradient(90deg,#1c99f4 0%,#167ac3 42%,#0f4f86 100%);padding:18px 28px;display:flex;align-items:center;justify-content:space-between;';
const title = document.createElement('span');
title.style.cssText = 'color:#fff;font-size:28px;font-weight:600;line-height:1.2;text-shadow:0 2px 2px rgba(2,2,2,0.25);font-family:Montserrat,sans-serif;';
title.textContent = 'Garage Organizer';
header.appendChild(title);
const closeX = document.createElement('button');
closeX.style.cssText = 'background:none;border:none;cursor:pointer;padding:0;line-height:1;display:inline-flex;';
closeX.innerHTML = '<svg viewBox="0 0 24 24" style="width:28px;height:28px;display:block;"><rect x="1" y="1" width="22" height="22" rx="4" fill="#d62f3a"/><path d="M8 8l8 8M16 8l-8 8" stroke="rgba(255,255,255,0.85)" stroke-width="1.5" stroke-linecap="round"/></svg>';
closeX.addEventListener('click', () => { overlay.remove(); resolve(null); });
header.appendChild(closeX);
// Body
const body = document.createElement('div');
body.style.cssText = 'background:url(/dist/site/images/backgrounds/bg-noise.png) top left repeat, linear-gradient(180deg,#2a2d3d 0%,#232636 100%);padding:28px;';
const desc = document.createElement('div');
desc.style.cssText = 'color:#d0d3e0;font-size:15px;line-height:1.7;margin-bottom:18px;';
desc.innerHTML = 'Set the number of visible garage sections. Each section holds 30 car slots.<br><span style="color:#d0d3e0;font-size:13px;">Note: You will be logged out after applying so changes can take effect.</span>';
const emptySlots = Math.max(0, currentSections * 30 - totalCars);
const currentInfo = document.createElement('div');
currentInfo.style.cssText = 'color:#8a8ea0;font-size:14px;line-height:1.8;margin-bottom:22px;padding:14px 16px;background:rgba(0,0,0,0.25);border-radius:4px;border-left:3px solid #1c99f4;';
currentInfo.innerHTML =
`<span style="color:#d0d3e0;">Cars:</span> <span style="color:#fff;font-weight:600;">${totalCars}</span>` +
`<span style="margin:0 10px;color:#3a4553;">|</span>` +
`<span style="color:#d0d3e0;">Sections:</span> <span style="color:#fff;font-weight:600;">${currentSections}</span>` +
`<span style="margin:0 10px;color:#3a4553;">|</span>` +
`<span style="color:#d0d3e0;">Slots:</span> <span style="color:#fff;font-weight:600;">${currentSections * 30}</span>` +
`<span style="margin:0 10px;color:#3a4553;">|</span>` +
`<span style="color:#d0d3e0;">Empty:</span> <span style="color:#fff;font-weight:600;">${emptySlots}</span>`;
const inputRow = document.createElement('div');
inputRow.style.cssText = 'display:flex;align-items:center;gap:12px;';
const inputLabel = document.createElement('span');
inputLabel.style.cssText = 'color:#d0d3e0;font-size:16px;white-space:nowrap;font-weight:600;';
inputLabel.textContent = 'Sections:';
const input = document.createElement('input');
input.type = 'text';
input.inputMode = 'numeric';
input.pattern = '[0-9]*';
input.value = String(currentSections);
input.placeholder = '1-30';
input.style.cssText = 'width:85px;padding:12px 14px;border-radius:4px;border:2px solid #2a5a8a;background:#1e2a3a;color:#fff;font-size:16px;font-family:Montserrat,sans-serif;text-align:center;outline:none;cursor:text;-moz-appearance:textfield;';
input.addEventListener('focus', () => { input.style.borderColor = '#2196f3'; });
input.addEventListener('blur', () => { input.style.borderColor = '#2a5a8a'; });
input.addEventListener('input', () => {
const v = parseInt(input.value, 10);
if (v > 30) input.value = '30';
if (v < 1 && input.value !== '') input.value = '1';
});
const maxLabel = document.createElement('span');
maxLabel.style.cssText = 'color:#d0d3e0;font-size:13px;';
maxLabel.textContent = 'Max: 30';
inputRow.appendChild(inputLabel);
inputRow.appendChild(input);
inputRow.appendChild(maxLabel);
const errorMsg = document.createElement('div');
errorMsg.style.cssText = 'color:#d62f3a;font-size:13px;margin-top:10px;min-height:18px;';
// Status message area (replaces browser alert)
const statusMsg = document.createElement('div');
statusMsg.style.cssText = 'margin-top:10px;min-height:20px;font-size:14px;';
body.appendChild(desc);
body.appendChild(currentInfo);
body.appendChild(inputRow);
body.appendChild(errorMsg);
body.appendChild(statusMsg);
// Footer
const footer = document.createElement('div');
footer.style.cssText = 'background:url(/dist/site/images/backgrounds/bg-noise.png) top left repeat, linear-gradient(180deg,#2a2d3d 0%,#232636 100%);padding:20px 28px;display:flex;justify-content:center;gap:14px;';
const cancelBtn = document.createElement('button');
cancelBtn.style.cssText = 'padding:12px 32px;border-radius:4px;border:1px solid #4a4e63;background:transparent;color:#d0d3e0;font-size:15px;font-weight:600;cursor:pointer;font-family:Montserrat,sans-serif;';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', () => { overlay.remove(); resolve(null); });
const applyBtn = document.createElement('button');
applyBtn.style.cssText = 'padding:12px 32px;border-radius:4px;border:none;background:#d62f3a;color:#fff;font-size:15px;font-weight:600;cursor:pointer;font-family:Montserrat,sans-serif;';
applyBtn.textContent = 'Apply';
applyBtn.addEventListener('click', async () => {
const val = parseInt(input.value, 10);
if (isNaN(val) || val < 1 || val > 30) {
errorMsg.textContent = 'Please enter a number between 1 and 30.';
input.style.borderColor = '#d62f3a';
return;
}
errorMsg.textContent = '';
// Disable controls during request
applyBtn.disabled = true;
applyBtn.style.opacity = '0.5';
cancelBtn.disabled = true;
cancelBtn.style.opacity = '0.5';
input.disabled = true;
statusMsg.style.color = '#a6aac1';
statusMsg.textContent = 'Updating garage...';
try {
const persist = JSON.parse(localStorage.getItem('persist:nt'));
const user = JSON.parse(persist.user);
const garage = user.garage;
if (!Array.isArray(garage)) {
statusMsg.style.color = '#d62f3a';
statusMsg.textContent = 'Could not read garage data.';
applyBtn.disabled = false; applyBtn.style.opacity = '1';
cancelBtn.disabled = false; cancelBtn.style.opacity = '1';
input.disabled = false;
return;
}
const totalSlots = val * 30;
let reqBody = '';
for (let i = 0; i < totalSlots; i++) {
reqBody += `garage%5B${i}%5D=${garage[i] || ''}&`;
}
const token = localStorage.getItem('player_token');
if (!token) {
statusMsg.style.color = '#d62f3a';
statusMsg.textContent = 'Not logged in.';
applyBtn.disabled = false; applyBtn.style.opacity = '1';
cancelBtn.disabled = false; cancelBtn.style.opacity = '1';
input.disabled = false;
return;
}
await fetch('/api/v2/loot/arrange-cars', {
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: reqBody,
method: 'POST',
mode: 'cors'
});
statusMsg.style.color = '#4caf50';
statusMsg.innerHTML = 'Garage updated! Logging you out so changes take effect...';
// Brief delay so user can read the message
setTimeout(() => {
overlay.remove();
resolve(val);
const logoutLink = document.querySelector('a.dropdown-link[href="/"]');
if (logoutLink) logoutLink.click();
else window.location.href = '/';
}, 1500);
} catch (e) {
console.error(TOOLKIT_LOG_PREFIX, 'Garage organizer error:', e);
statusMsg.style.color = '#d62f3a';
statusMsg.textContent = 'Something went wrong. Check console for details.';
applyBtn.disabled = false; applyBtn.style.opacity = '1';
cancelBtn.disabled = false; cancelBtn.style.opacity = '1';
input.disabled = false;
}
});
// Enter key submits
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') applyBtn.click();
if (e.key === 'Escape') { overlay.remove(); resolve(null); }
});
footer.appendChild(cancelBtn);
footer.appendChild(applyBtn);
modal.appendChild(header);
modal.appendChild(body);
modal.appendChild(footer);
overlay.appendChild(modal);
// Close on overlay click (outside modal)
overlay.addEventListener('click', (e) => {
if (e.target === overlay) { overlay.remove(); resolve(null); }
});
document.body.appendChild(overlay);
input.focus();
input.select();
});
}
function handleGarageOrganizer() {
if (!isFeatureEnabled('ENABLE_GARAGE_ORGANIZER')) return;
const path = window.location.pathname.replace(/\/+$/, '') || '/';
if (path !== '/garage') return;
// Already injected?
if (document.querySelector(`[${GARAGE_ORGANIZER_ATTR}]`)) return;
// Find the "Rearrange Cars" button
const rearrangeBtn = Array.from(document.querySelectorAll('a.btn')).find(
el => el.textContent.trim().toLowerCase().includes('rearrange')
);
if (!rearrangeBtn) return;
const btn = document.createElement('button');
btn.setAttribute(GARAGE_ORGANIZER_ATTR, '');
btn.style.cssText = 'margin-right:8px;cursor:pointer;background:none;border:none;padding:0;display:inline-flex;align-items:center;vertical-align:middle;';
btn.title = 'Add garage sections (pages of 30 car slots)';
btn.innerHTML = '<svg viewBox="0 0 24 24" style="width:28px;height:28px;display:block;"><rect x="1" y="1" width="22" height="22" rx="4" fill="#d62f3a"/><path d="M12 6v12M6 12h12" stroke="rgba(255,255,255,0.85)" stroke-width="1.5" stroke-linecap="round"/></svg>';
btn.addEventListener('click', async () => {
try {
const persist = JSON.parse(localStorage.getItem('persist:nt'));
const user = JSON.parse(persist.user);
const garage = user.garage;
if (!Array.isArray(garage)) return;
const totalCars = user.totalCars || user.carsOwned || 0;
const currentSections = Math.ceil(garage.length / 30);
await showGarageModal(currentSections, totalCars);
} catch (e) {
console.error(TOOLKIT_LOG_PREFIX, 'Garage organizer error:', e);
}
});
rearrangeBtn.parentElement.insertBefore(btn, rearrangeBtn);
}
// ─────────────────────────────────────────────────────────────────────────────
// FEATURE 10: Keyboard Shortcuts (Global)
// ─────────────────────────────────────────────────────────────────────────────
/** Map e.code (e.g. "KeyR") to a single uppercase letter. */
const codeToLetter = (code) => {
if (code.startsWith('Key')) return code.slice(3).toUpperCase();
if (code.startsWith('Digit')) return code.slice(5);
return code.toUpperCase();
};
/** Check if exactly the chosen modifier(s) are active (and no others). */
const isModifierMatch = (e, modifier, modifier2) => {
const required = new Set([modifier]);
if (modifier2 && modifier2 !== 'none') required.add(modifier2);
const active = new Set();
if (e.altKey) active.add('alt');
if (e.ctrlKey) active.add('ctrl');
if (e.shiftKey) active.add('shift');
if (e.metaKey) active.add('meta');
if (required.size !== active.size) return false;
for (const m of required) { if (!active.has(m)) return false; }
return true;
};
function initKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
if (!isFeatureEnabled('ENABLE_KEYBOARD_SHORTCUTS')) return;
// Skip on the race page — NT locks down keyboard input
if (document.getElementById('raceContainer')) return;
// Don't fire if a modal is open
if (document.querySelector('.modal')) return;
// Don't fire during input/textarea focus
const active = document.activeElement;
if (active) {
const tag = active.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || active.contentEditable === 'true') return;
}
// Read shortcut map from settings (fall back to defaults)
const shortcuts = (() => {
const raw = readSetting('KEYBOARD_SHORTCUT_MAP');
return Array.isArray(raw) && raw.length > 0 ? raw : DEFAULT_SHORTCUTS;
})();
// Use e.code for reliable key detection (Alt/Option on Mac changes e.key)
const pressed = codeToLetter(e.code);
const match = shortcuts.find(s => {
if (!s.key || s.key.toUpperCase() !== pressed) return false;
const mod1 = s.mod1 || 'alt';
const mod2 = s.mod2 || 'none';
return isModifierMatch(e, mod1, mod2);
});
if (!match || !match.path) return;
e.preventDefault();
e.stopPropagation();
// Handle toggle actions
if ((match.action === 'toggle') && match.path.startsWith('toggle:')) {
const parts = match.path.split(':');
// format: toggle:scriptId:settingKey
if (parts.length >= 3) {
const scriptId = parts[1];
const settingKey = parts.slice(2).join(':');
const storageKey = `ntcfg:${scriptId}:${settingKey}`;
try {
const current = JSON.parse(localStorage.getItem(storageKey) ?? 'null');
const newVal = !current;
localStorage.setItem(storageKey, JSON.stringify(newVal));
// Dispatch change event so scripts react immediately
document.dispatchEvent(new CustomEvent('ntcfg:change', {
detail: { script: scriptId, key: settingKey, value: newVal }
}));
} catch { /* ignore */ }
}
return;
}
// Navigate action
let path = match.path;
if (path === '/profile') {
const user = getCurrentUser();
path = user?.username ? '/racer/' + user.username : null;
}
if (path) {
window.open(window.location.origin + path, '_self');
}
}, true);
}
// ─── Register keyboard shortcuts immediately at document-start ─────────────
// Must happen before NT's bundle loads and registers its own capture listeners.
initKeyboardShortcuts();
// ─── Main Initialization ──────────────────────────────────────────────────────
function startToolkit() {
initObserverManager();
// Apply CSS-based appearance toggles immediately
applyAppearanceStyles();
// Listen for setting changes to re-apply appearance styles
document.addEventListener('ntcfg:change', (e) => {
const k = e.detail?.key || '';
if (e.detail?.script === NTCFG_MANIFEST_ID && k.startsWith('HIDE_')) {
applyAppearanceStyles();
}
});
// Poll for global UI elements that may not exist yet when the observer first fires
// (NT renders the nav shell asynchronously)
let globalRetries = 0;
const globalPoll = setInterval(() => {
globalRetries++;
const carDone = !readSetting('ENABLE_CAR_ICON') || document.querySelector('[' + CAR_ICON_ATTR + ']');
const smartNotifyDone = !readSetting('SMART_SHOP_NOTIFY') || document.querySelector('[' + SMART_NOTIFY_ATTR + ']');
try { handleCarIcon(); } catch { /* ignore */ }
if (!smartNotifyDone && !readSetting('HIDE_NOTIFY_ALL') && !readSetting('HIDE_NOTIFY_SHOP')) {
try { applySmartShopNotify(); } catch { /* ignore */ }
}
if ((carDone && smartNotifyDone) || globalRetries >= 50) {
clearInterval(globalPoll);
}
}, 200);
// Register observer callback that dispatches to feature handlers
window.NTObserverManager.register('toolkit', () => {
const path = window.location.pathname;
// Global features (run on every page)
try { handleCarIcon(); } catch (e) { console.error(TOOLKIT_LOG_PREFIX, 'CarIcon error:', e); }
// Team page features
if (path.startsWith('/team/') && document.querySelector('.table-row')) {
try { handleBannedLabels(); } catch (e) { console.error(TOOLKIT_LOG_PREFIX, 'BannedLabels error:', e); }
try { handleMOTDFilter(); } catch (e) { console.error(TOOLKIT_LOG_PREFIX, 'MOTDFilter error:', e); }
}
// Garage page features
if (path === '/garage') {
try { handleGarageOrganizer(); } catch (e) { console.error(TOOLKIT_LOG_PREFIX, 'GarageOrganizer error:', e); }
}
// Shop page features
if (path === '/shop') {
try { handleShopPreview(); } catch (e) { console.error(TOOLKIT_LOG_PREFIX, 'ShopPreview error:', e); }
}
// Modal-based features (can appear on any page with modals)
if (document.querySelector('.modal')) {
try { handleSendCashFormat(); } catch (e) { console.error(TOOLKIT_LOG_PREFIX, 'SendCash error:', e); }
try { handleDonationLink(); } catch (e) { console.error(TOOLKIT_LOG_PREFIX, 'DonationLink error:', e); }
// MOTD modal can also appear on team pages
if (path.startsWith('/team/')) {
try { handleMOTDFilter(); } catch (e) { console.error(TOOLKIT_LOG_PREFIX, 'MOTDFilter modal error:', e); }
}
}
});
}
// Wait for document.body to exist before starting
if (document.body) {
startToolkit();
} else {
const bodyWaiter = new MutationObserver((_, obs) => {
if (document.body) {
obs.disconnect();
startToolkit();
}
});
bodyWaiter.observe(document.documentElement, { childList: true });
}
})();