Forum thread generator for RW traders. Vault storage, 10 designs, live preview, per-item notes, filters, bulk actions, export/import. FREE
// ==UserScript==
// @name Torn RW ThreadSmith
// @namespace http://tampermonkey.net/
// @version 4.0.0
// @description Forum thread generator for RW traders. Vault storage, 10 designs, live preview, per-item notes, filters, bulk actions, export/import. FREE
// @author Rowage [3926289]
// @copyright 2026, Rowage [3926289]
// @match https://www.torn.com/forums.php*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_setClipboard
// @license GPL-3.0-or-later
// ==/UserScript==
(function () {
'use strict';
const VERSION = '4.0.0';
const API_THROTTLE_MS = 700;
const PANEL_MAX_H = 580;
const RARITY_COLORS = {
Yellow: '#ffff00',
Orange: '#ff8000',
Red: '#ff0000',
White: '#ffffff'
};
const TYPE_ORDER = ['Primary', 'Secondary', 'Melee', 'Armor', 'Other'];
const RARITY_ORDER = ['Yellow', 'Orange', 'Red', 'White'];
const STATUS_ORDER = ['active', 'sold', 'hidden'];
const STATUS_LABELS = { active: 'Active', sold: 'Sold', hidden: 'Hidden' };
const STATUS_COLORS = { active: '#4caf50', sold: '#ffcc4d', hidden: '#888' };
const UI = {
panelBg: '#181818',
headerBg: '#202020',
tabBarBg: '#1a1a1a',
cardBg: '#0f0f0f',
inputBg: '#0f0f0f',
border: '#2e2e2e',
borderSoft: '#252525',
textMain: '#e0e0e0',
textDim: '#b0b0b0',
textFaint: '#9a9a9a',
accent: '#c9a84c',
accentBg: '#291e00',
accentBgHi: '#372800',
success: '#4caf50',
successBg: '#0e1f0e',
danger: '#ff6b6b',
dangerBg: '#250e0e',
bonusUI: '#9778ff',
soldUI: '#ffcc4d',
soldBg: '#2a1f00'
};
const SHARED = {
bonus: '#00ff66',
stats: '#cccccc'
};
const $ = id => document.getElementById(id);
const sleep = ms => new Promise(r => setTimeout(r, ms));
const normType = t => t === 'Defensive' ? 'Armor' : (t || 'Other');
const normRarity = r => RARITY_ORDER.includes(r) ? r : 'White';
const hasBonuses = d => d?.bonuses && Object.keys(d.bonuses).length > 0;
const normStatus = s => STATUS_ORDER.includes(s) ? s : 'active';
const fmt = val => {
if (!val) return 'Offer';
const mul = { k: 1e3, m: 1e6, b: 1e9 };
const s = val.toString().toLowerCase().replace(/[\s,]/g, '');
const sfx = s.match(/[kmb]$/);
let n = parseFloat(s.replace(/[^0-9.]/g, ''));
if (isNaN(n)) return 'Offer';
if (sfx) n *= mul[sfx[0]];
return '$' + n.toLocaleString('en-US');
};
const api = url => new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
responseType: 'json',
onload: r => {
const d = r.response;
if (!d) reject(new Error('Empty response'));
else if (d.error) reject(new Error(d.error.error));
else resolve(d);
},
onerror: reject
});
});
const fmtPrice = item => {
if (item.status === 'sold') return 'SOLD';
return fmt(item.manualPrice || item.bazaar_price || '');
};
const fmtStats = item => item.damage
? `Q: ${item.quality ?? 'N/A'}% | Dmg: ${item.damage} | Acc: ${item.accuracy ?? 'N/A'}`
: `Q: ${item.quality ?? 'N/A'}% | Armor: ${item.armor ?? 'N/A'}`;
const fmtBonuses = item => Object.values(item.bonuses || {})
.map(b => `${b.bonus} ${b.value}%`).join(' | ');
const fmtNote = (item, include) => {
if (!include) return '';
const note = GM_getValue('rwts_notes', {})[item.UID];
if (!note) return '';
return `<div style="font-size:11px;color:#888;font-style:italic;margin-top:6px;padding-top:6px;border-top:1px solid rgba(255,255,255,0.06);">${note}</div>`;
};
const THEMES = {
glow: {
label: 'Neon Box',
palette: {
innerBg: '#1a1a1a',
border: '#333',
priceText: '#000',
statsText: '#efefef'
},
item(item, rarity, includeNote) {
const c = this.palette;
return `
<div style="max-width:550px;margin:0 auto 15px auto;padding:2px;background:${rarity};border-radius:10px;">
<div style="padding:15px;background:${c.innerBg};border-radius:8px;text-align:left;">
<div style="display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid ${c.border};padding-bottom:8px;margin-bottom:8px;">
<span style="font-size:16px;font-weight:bold;text-transform:uppercase;letter-spacing:1px;"><font color="${rarity}">${item.name}</font></span>
<span style="background:${rarity};padding:2px 8px;border-radius:4px;font-weight:bold;font-size:14px;"><font color="${c.priceText}">${fmtPrice(item)}</font></span>
</div>
<div style="font-size:12px;margin-bottom:4px;"><font color="${c.statsText}">${fmtStats(item)}</font></div>
<div style="font-family:monospace;font-weight:bold;font-size:13px;"><font color="${SHARED.bonus}">>> ${fmtBonuses(item)}</font></div>
${fmtNote(item, includeNote)}
</div>
</div>`;
},
header(title, desc) {
const c = this.palette;
return `<div style="max-width:600px;margin:0 auto 30px auto;padding:30px;background:${c.innerBg};border:2px solid ${SHARED.bonus};border-radius:12px;"><h1 style="color:#fff;text-transform:uppercase;margin:0 0 15px 0;font-size:28px;">${title}</h1><p style="color:#888;font-size:15px;margin:0;">${desc.replace(/\n/g, '<br>')}</p></div>`;
}
},
banner: {
label: 'Gradient Banner',
palette: {
bgDark: '#222',
priceText: '#4dd0e1'
},
item(item, rarity, includeNote) {
const c = this.palette;
return `
<div style="max-width:650px;margin:0 auto 12px auto;background:linear-gradient(90deg,${rarity}15 0%,${c.bgDark} 100%);border-right:4px solid ${rarity};padding:15px;text-align:left;border-radius:4px;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-weight:bold;font-size:16px;"><font color="${rarity}">${item.name}</font></span>
<span style="font-weight:bold;font-size:15px;"><font color="${c.priceText}">${fmtPrice(item)}</font></span>
</div>
<div style="margin-top:5px;font-size:11px;"><font color="${SHARED.stats}">${fmtStats(item)}</font> | <font color="${SHARED.bonus}"><b>${fmtBonuses(item)}</b></font></div>
${fmtNote(item, includeNote)}
</div>`;
},
header(title, desc) {
const c = this.palette;
return `<div style="max-width:700px;margin:0 auto 30px auto;background:linear-gradient(90deg,${c.priceText}33 0%,${c.bgDark} 100%);border-left:6px solid ${c.priceText};padding:30px;text-align:left;"><h1 style="color:#fff;margin:0 0 15px 0;font-size:28px;">${title}</h1><p style="color:${SHARED.stats};font-size:16px;margin:0;">${desc.replace(/\n/g, '<br>')}</p></div>`;
}
},
split: {
label: 'Split Bar',
palette: {
bgDark: '#222',
bgLight: '#1a1a1a',
border: '#333',
priceText: '#ffffff'
},
item(item, rarity, includeNote) {
const c = this.palette;
const note = GM_getValue('rwts_notes', {})[item.UID];
return `
<div style="max-width:500px;margin:0 auto 10px auto;background:${c.bgDark};border-radius:6px;overflow:hidden;border:1px solid ${c.border};">
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px;background:${c.bgLight};border-bottom:2px solid ${rarity};">
<span style="font-size:14px;font-weight:bold;text-transform:uppercase;"><font color="${rarity}">${item.name}</font></span>
<span style="font-size:15px;font-weight:bold;"><font color="${c.priceText}">${fmtPrice(item)}</font></span>
</div>
<div style="padding:12px;background:${c.bgDark};display:flex;justify-content:space-between;align-items:center;">
<span><font color="${SHARED.stats}" style="font-size:11px;">${fmtStats(item)}</font></span>
<span style="text-align:right;"><font color="${SHARED.bonus}" style="font-size:12px;font-weight:bold;">${fmtBonuses(item)}</font></span>
</div>
${includeNote && note ? `<div style="padding:0 12px 10px;">${fmtNote(item, includeNote)}</div>` : ''}
</div>`;
},
header(title, desc) {
const c = this.palette;
return `<div style="max-width:550px;margin:0 auto 30px auto;background:${c.bgDark};border:1px solid ${c.border};border-radius:8px;overflow:hidden;"><div style="background:${c.bgLight};padding:20px;border-bottom:3px solid ${c.priceText};"><h1 style="color:#fff;margin:0;text-transform:uppercase;font-size:26px;">${title}</h1></div><div style="padding:20px;"><p style="color:${SHARED.stats};font-size:15px;margin:0;">${desc.replace(/\n/g, '<br>')}</p></div></div>`;
}
},
stripe: {
label: 'Thin Stripe',
palette: {
bgDark: '#222',
priceText: '#81c784'
},
item(item, rarity, includeNote) {
const c = this.palette;
return `
<div style="max-width:600px;margin:0 auto 4px auto;padding:8px 12px;background:${c.bgDark};border-left:33px solid ${rarity};display:flex;justify-content:space-between;align-items:center;">
<div style="text-align:left;">
<span style="font-weight:bold;font-size:13px;"><font color="${rarity}">${item.name}</font></span>
<div style="font-weight:bold;margin-top:2px;"><font color="${SHARED.bonus}" style="font-size:10px;">${fmtBonuses(item)}</font></div>
${fmtNote(item, includeNote)}
</div>
<div style="text-align:right;">
<div style="font-weight:bold;font-size:14px;"><font color="${c.priceText}">${fmtPrice(item)}</font></div>
<div style="margin-top:2px;"><font color="${SHARED.stats}" style="font-size:10px;">${fmtStats(item)}</font></div>
</div>
</div>`;
},
header(title, desc) {
const c = this.palette;
return `<div style="max-width:650px;margin:0 auto 30px auto;padding:20px 30px;background:${c.bgDark};border-left:6px solid ${c.priceText};text-align:left;"><h1 style="color:#fff;margin:0 0 15px 0;font-size:28px;">${title}</h1><p style="color:${SHARED.stats};font-size:16px;margin:0;">${desc.replace(/\n/g, '<br>')}</p></div>`;
}
},
ledger: {
label: 'Classic Ledger',
palette: {
bg: '#2c241e',
border: '#4a3b2f',
priceText: '#e0ca82',
statsText: '#a6927d'
},
item(item, rarity, includeNote) {
const c = this.palette;
const t = normType(item.type);
let em = '❖︎';
if (t === 'Primary' || t === 'Secondary') em = '⌖︎';
else if (t === 'Melee') em = '⚔︎';
else if (t === 'Armor') em = '⛨︎';
return `<div style="max-width:580px;margin:0 auto 15px auto;background:${c.bg};border:2px solid ${c.border};border-radius:2px;box-shadow:inset 0 0 40px rgba(0,0,0,.5);">
<div style="border-left:10px solid ${rarity};padding:15px;text-align:left;">
<div style="display:flex;justify-content:space-between;align-items:baseline;">
<span style="font-family:'Courier New',monospace;font-size:18px;font-weight:bold;text-transform:uppercase;"><font color="${rarity}">${item.name}</font></span>
<span style="font-family:'Courier New',monospace;font-size:16px;border-bottom:1px double ${rarity};"><font color="${c.priceText}">${fmtPrice(item)}</font></span>
</div>
<div style="margin-top:8px;font-family:Georgia,serif;font-style:italic;font-size:13px;color:${c.statsText};">${fmtStats(item)}</div>
<div style="margin-top:5px;padding-top:5px;border-top:1px dashed ${c.border};"><font color="${SHARED.bonus}" style="font-size:12px;font-weight:bold;letter-spacing:1px;">${em} ${fmtBonuses(item)}</font></div>
${fmtNote(item, includeNote)}
</div>
</div>`;
},
header(title, desc) {
const c = this.palette;
return `<div style="max-width:620px;margin:0 auto 30px auto;background:${c.bg};border:2px solid ${c.border};padding:30px;box-shadow:inset 0 0 50px rgba(0,0,0,.6);"><h1 style="font-family:'Courier New',monospace;color:${c.priceText};text-transform:uppercase;margin:0 0 20px 0;border-bottom:2px dashed ${c.border};padding-bottom:15px;font-size:26px;">${title}</h1><p style="font-family:Georgia,serif;font-style:italic;color:${c.statsText};font-size:16px;margin:0;">${desc.replace(/\n/g, '<br>')}</p></div>`;
}
},
minimal: {
label: 'Modern Minimalist',
palette: {
lightTxt: '#1a1a1a',
darkTxt: '#ffffff',
lightSubtle: 'rgba(0,0,0,0.6)',
darkSubtle: 'rgba(255,255,255,0.8)',
lightPill: 'rgba(0,0,0,0.1)',
darkPill: 'rgba(0,0,0,0.2)',
border: 'rgba(0,0,0,0.05)'
},
isLightRarity: c => c === '#ffff00' || c === '#ffffff',
item(item, rarity, includeNote) {
const c = this.palette;
const light = this.isLightRarity(rarity);
const tc = light ? c.lightTxt : c.darkTxt;
const sc = light ? c.lightSubtle : c.darkSubtle;
const pc = light ? c.lightPill : c.darkPill;
const note = GM_getValue('rwts_notes', {})[item.UID];
return `<div style="max-width:520px;margin:0 auto 8px auto;background:${rarity};border-radius:12px;overflow:hidden;box-shadow:0 4px 6px rgba(0,0,0,.2);border:1px solid ${c.border};">
<div style="padding:12px 20px;display:flex;justify-content:space-between;align-items:center;">
<div style="text-align:left;">
<div style="font-size:14px;font-weight:800;color:${tc};letter-spacing:-.5px;">${item.name.toUpperCase()}</div>
<div style="font-size:10px;color:${sc};font-weight:600;">${fmtStats(item)}</div>
${includeNote && note ? `<div style="font-size:10px;color:${sc};font-style:italic;margin-top:2px;">${note}</div>` : ''}
</div>
<div style="text-align:right;">
<div style="font-size:16px;font-weight:900;color:${tc};">${fmtPrice(item)}</div>
<div style="font-size:10px;color:${tc};font-weight:bold;background:${pc};padding:2px 10px;border-radius:20px;display:inline-block;margin-top:4px;">${fmtBonuses(item)}</div>
</div>
</div>
</div>`;
},
header(title, desc) {
return `<div style="max-width:580px;margin:0 auto 30px auto;background:#fff;border-radius:16px;padding:35px;box-shadow:0 6px 12px rgba(0,0,0,.15);"><h1 style="font-family:sans-serif;font-weight:900;color:#1a1a1a;letter-spacing:-1px;margin:0 0 15px 0;font-size:28px;">${title}</h1><p style="font-family:sans-serif;color:rgba(0,0,0,.6);font-size:16px;margin:0;">${desc.replace(/\n/g, '<br>')}</p></div>`;
}
},
terminal: {
label: 'Retro Terminal',
palette: {
bg: '#050505',
hdr: '#111',
hdrBorder: '#333',
statText: '#888',
priceText: '#00ff00'
},
item(item, rarity, includeNote) {
const c = this.palette;
return `
<div style="max-width:600px;margin:0 auto 10px auto;background:${c.bg};border:1px solid ${c.hdrBorder};font-family:'Courier New',monospace;text-align:left;">
<div style="background:${c.hdr};padding:4px 8px;border-bottom:1px solid ${c.hdrBorder};display:flex;justify-content:space-between;font-size:12px;">
<span style="color:${c.statText};">torn$ ./inspect ${item.UID || '1337'}</span>
<span style="color:${rarity};font-weight:bold;">[${normType(item.type).toUpperCase()}]</span>
</div>
<div style="padding:10px;">
<div style="display:flex;justify-content:space-between;margin-bottom:6px;">
<span style="color:${rarity};font-size:15px;font-weight:bold;">${item.name}</span>
<span style="color:${c.priceText};font-weight:bold;">${fmtPrice(item)}</span>
</div>
<div style="color:${SHARED.stats};font-size:11px;margin-bottom:4px;"><span style="color:${c.statText};">>_ STATS:</span> ${fmtStats(item)}</div>
<div style="color:${SHARED.bonus};font-size:12px;font-weight:bold;"><span style="color:${c.statText};">>_ BUFFS:</span> ${fmtBonuses(item)}</div>
${fmtNote(item, includeNote)}
</div>
</div>`;
},
header(title, desc) {
const c = this.palette;
return `<div style="max-width:650px;margin:0 auto 30px auto;background:${c.bg};border:1px solid ${c.hdrBorder};font-family:'Courier New',monospace;text-align:left;"><div style="background:${c.hdr};padding:10px 15px;border-bottom:1px solid ${c.hdrBorder};color:${c.statText};font-size:14px;">root@torn:~/shop# cat motd.txt</div><div style="padding:20px;"><h1 style="color:${c.priceText};font-size:22px;margin:0 0 15px 0;">> ${title}</h1><p style="color:${SHARED.stats};font-size:15px;margin:0;">${desc.replace(/\n/g, '<br>')}</p></div></div>`;
}
},
tactical: {
label: 'Military Crate',
palette: {
bg: '#252525',
hdr: '#1a1a1a',
hdrText: '#d7dadc',
priceText: '#aed581'
},
item(item, rarity, includeNote) {
const c = this.palette;
return `
<div style="max-width:500px;margin:0 auto 10px auto;background:${c.bg};border:1px solid ${c.hdr};border-left:6px solid ${rarity};font-family:Impact,Arial Black,sans-serif;text-transform:uppercase;">
<div style="display:flex;justify-content:space-between;align-items:center;background:${c.hdr};padding:8px 12px;">
<span style="color:${c.hdrText};font-size:14px;letter-spacing:1px;">${item.name}</span>
<span style="color:${c.priceText};font-size:14px;">${fmtPrice(item)}</span>
</div>
<div style="padding:10px 12px;font-family:Arial,sans-serif;text-transform:none;">
<div style="color:${SHARED.stats};font-size:11px;margin-bottom:4px;"><strong>SPEC:</strong> ${fmtStats(item)}</div>
<div style="color:${SHARED.bonus};font-size:11px;"><strong>BONUS:</strong> ${fmtBonuses(item)}</div>
${fmtNote(item, includeNote)}
</div>
</div>`;
},
header(title, desc) {
const c = this.palette;
return `<div style="max-width:550px;margin:0 auto 30px auto;background:${c.bg};border:3px solid ${c.hdr};"><div style="background:${c.hdr};padding:15px;"><h1 style="font-family:Impact,Arial Black,sans-serif;text-transform:uppercase;color:${c.hdrText};letter-spacing:3px;margin:0;font-size:26px;">${title}</h1></div><div style="padding:20px;"><p style="font-family:Arial,sans-serif;color:${SHARED.stats};font-size:15px;margin:0;">${desc.replace(/\n/g, '<br>')}</p></div></div>`;
}
},
boutique: {
label: 'Luxury Boutique',
palette: {
bg: '#0a0a0c',
border: '#2c2c35',
priceText: '#e5c158'
},
item(item, rarity, includeNote) {
const c = this.palette;
return `
<div style="max-width:550px;margin:0 auto 12px auto;background:${c.bg};border:1px solid ${c.border};border-radius:3px;padding:12px 16px;">
<div style="display:flex;justify-content:space-between;align-items:flex-end;border-bottom:1px solid ${c.border};padding-bottom:6px;margin-bottom:6px;">
<span style="font-family:Georgia,serif;font-size:16px;color:${rarity};font-style:italic;">${item.name}</span>
<span style="font-family:Tahoma,sans-serif;font-size:15px;color:${c.priceText};font-weight:bold;">${fmtPrice(item)}</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;font-family:Tahoma,sans-serif;">
<span style="font-size:10px;color:${SHARED.stats};text-transform:uppercase;letter-spacing:.5px;">${fmtStats(item)}</span>
<span style="font-size:11px;color:${SHARED.bonus};font-weight:bold;">${fmtBonuses(item)}</span>
</div>
${fmtNote(item, includeNote)}
</div>`;
},
header(title, desc) {
const c = this.palette;
return `<div style="max-width:600px;margin:0 auto 30px auto;background:${c.bg};border:1px solid ${c.border};border-radius:4px;padding:35px;"><h1 style="font-family:Georgia,serif;font-style:italic;color:${c.priceText};font-size:32px;margin:0 0 20px 0;">${title}</h1><div style="width:70px;height:2px;background:${c.priceText};margin:0 auto 20px auto;"></div><p style="font-family:Tahoma,sans-serif;color:${SHARED.stats};font-size:15px;line-height:1.8;margin:0;">${desc.replace(/\n/g, '<br>')}</p></div>`;
}
},
glass: {
label: 'Frosted Glass',
palette: {
bg: 'rgba(30,30,40,0.6)',
border: 'rgba(255,255,255,0.1)',
priceText: '#81d4fa'
},
item(item, rarity, includeNote) {
const c = this.palette;
return `
<div style="max-width:500px;margin:0 auto 10px auto;background:${c.bg};border:1px solid ${c.border};border-radius:16px;padding:12px 16px;backdrop-filter:blur(10px);box-shadow:0 4px 15px rgba(0,0,0,.3);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<span style="font-family:sans-serif;font-size:15px;font-weight:600;color:${rarity};">${item.name}</span>
<span style="background:rgba(0,0,0,.4);padding:4px 8px;border-radius:12px;font-size:13px;font-weight:bold;color:${c.priceText};">${fmtPrice(item)}</span>
</div>
<div style="display:flex;flex-direction:column;gap:4px;font-family:sans-serif;">
<span style="font-size:11px;color:${SHARED.stats};">${fmtStats(item)}</span>
<span style="font-size:11px;color:${SHARED.bonus};font-weight:bold;">${fmtBonuses(item)}</span>
</div>
${fmtNote(item, includeNote)}
</div>`;
},
header(title, desc) {
const c = this.palette;
return `<div style="max-width:550px;margin:0 auto 30px auto;background:${c.bg};border:1px solid ${c.border};border-radius:20px;padding:35px;backdrop-filter:blur(12px);box-shadow:0 8px 25px rgba(0,0,0,.4);"><h1 style="font-family:sans-serif;font-weight:600;color:#fff;margin:0 0 15px 0;font-size:28px;">${title}</h1><p style="font-family:sans-serif;color:#efefef;font-size:16px;margin:0;">${desc.replace(/\n/g, '<br>')}</p></div>`;
}
}
};
let items = [];
let abortLoad = false;
let _filteredUIDs = [];
const getVault = () => GM_getValue('rwts_vault', {});
const saveVault = v => GM_setValue('rwts_vault', v);
const setItemStatus = (uid, status) => {
const v = getVault();
if (v[uid]) {
v[uid].status = normStatus(status);
saveVault(v);
}
};
const removeFromVault = uid => {
const v = getVault();
delete v[uid];
saveVault(v);
};
const loadItemsFromVault = () => {
const v = getVault();
items = Object.values(v).map(i => ({
...i,
status: normStatus(i.status),
inBazaar: !!i.inBazaar
}));
};
(function migrateOldCacheToVault() {
if (GM_getValue('rwts_migrated_v41', false)) return;
const oldCache = GM_getValue('rwts_cache', {});
const vault = getVault();
let migrated = 0;
Object.entries(oldCache).forEach(([uid, entry]) => {
if (vault[uid]) return;
const d = entry?.d;
if (!d || !hasBonuses(d)) return;
vault[uid] = {
...d,
UID: uid,
bazaar_price: null,
inBazaar: false,
status: 'active',
addedAt: entry.ts || Date.now(),
lastSeen: entry.ts || Date.now()
};
migrated++;
});
if (migrated > 0) saveVault(vault);
GM_setValue('rwts_migrated_v41', true);
})();
const styleEl = document.createElement('style');
styleEl.textContent = `
#rwts{position:fixed;width:450px;background:${UI.panelBg};color:${UI.textMain};border:1px solid #333;z-index:9999;font-family:Arial,sans-serif;font-size:12px;border-radius:6px;box-shadow:0 6px 40px rgba(0,0,0,.9)}
#rwts-hdr{background:${UI.headerBg};padding:10px 14px;cursor:move;border-bottom:1px solid ${UI.border};display:flex;justify-content:space-between;align-items:center;border-radius:6px 6px 0 0;user-select:none}
#rwts-hdr-title{font-weight:bold;font-size:13px;color:${UI.accent};letter-spacing:.5px}
#rwts-tabs{display:flex;background:${UI.tabBarBg};border-bottom:1px solid ${UI.border}}
.rt{flex:1;padding:7px 4px;text-align:center;cursor:pointer;font-size:11px;color:${UI.textFaint};border-bottom:2px solid transparent;transition:all .18s}
.rt:hover{color:${UI.textMain};background:#222}
.rt.on{color:${UI.accent};border-bottom-color:${UI.accent};background:${UI.panelBg}}
#rwts-body{padding:12px;max-height:${PANEL_MAX_H}px;overflow-y:auto;scrollbar-width:thin;scrollbar-color:${UI.border} #111}
.rp{display:none}.rp.on{display:block}
.rrow{display:flex;gap:6px;margin-bottom:8px;align-items:center}
.ri{background:${UI.inputBg};color:${UI.textMain};border:1px solid ${UI.border};padding:5px 8px;border-radius:3px;font-size:12px;box-sizing:border-box}
.ri:focus{outline:none;border-color:${UI.accent}}
.ri::placeholder{color:#666}
.rb{background:#252525;color:${UI.textMain};border:1px solid ${UI.border};padding:6px 10px;cursor:pointer;border-radius:3px;font-size:12px;font-weight:bold;white-space:nowrap;transition:background .15s}
.rb:hover{background:#303030}
.rb:disabled{opacity:.35;cursor:default}
.rb.gold{background:${UI.accentBg};border-color:${UI.accent};color:${UI.accent}}
.rb.gold:hover{background:${UI.accentBgHi}}
.rb.grn{background:${UI.successBg};border-color:${UI.success};color:${UI.success}}
.rb.grn:hover{background:#122512}
.rb.red{background:${UI.dangerBg};border-color:${UI.danger};color:${UI.danger}}
.rb.red:hover{background:#301212}
.rb.sold{background:${UI.soldBg};border-color:${UI.soldUI};color:${UI.soldUI}}
.rb.sold:hover{background:#3a2a00}
.rb.full{width:100%}
.rb-x{background:#2a1010;color:${UI.danger};border:1px solid #4a1818;padding:5px 9px;cursor:pointer;border-radius:3px;font-size:11px;font-weight:bold;line-height:1;flex-shrink:0}
.rb-x:hover{background:#3a1818}
.ic{background:${UI.cardBg};border:1px solid ${UI.borderSoft};border-radius:4px;padding:10px;margin-bottom:7px;transition:border-color .15s;border-left:3px solid ${UI.borderSoft}}
.ic:hover{border-color:#444}
.ic.st-active{border-left-color:${STATUS_COLORS.active}}
.ic.st-sold{border-left-color:${STATUS_COLORS.sold}}
.ic.st-hidden{border-left-color:${STATUS_COLORS.hidden};opacity:.6}
.ic-name{font-weight:bold;font-size:13px}
.ic-stats{color:${UI.textDim};font-size:11px;margin-top:2px}
.ic-bon{color:${UI.bonusUI};font-size:11px;margin-top:2px;font-weight:600}
.ic-tag{color:${UI.textFaint};font-size:10px;text-transform:uppercase;flex-shrink:0;font-weight:600}
.ic-baz{color:${UI.textFaint};font-size:10px;flex-shrink:0}
.baz-yes{color:${UI.success};font-size:9px;font-weight:600;white-space:nowrap}
.baz-no{color:#777;font-size:9px;font-weight:600;white-space:nowrap}
.item-count{color:${UI.textFaint};font-size:10px;text-align:right;margin-bottom:6px}
.pbw{background:#080808;border:1px solid ${UI.borderSoft};border-radius:3px;height:6px;margin:6px 0;overflow:hidden}
.pb{height:100%;background:linear-gradient(90deg,#7a5c00,${UI.accent});border-radius:3px;transition:width .3s}
.stxt{color:${UI.textDim};font-size:11px;text-align:center;padding:3px 0}
.lbl{font-size:10px;color:${UI.textFaint};text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px;font-weight:600}
.div{border:none;border-top:1px solid #222;margin:12px 0}
.tw{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.tog{position:relative;width:30px;height:16px;cursor:pointer;flex-shrink:0}
.tog input{opacity:0;width:0;height:0}
.tog-sl{position:absolute;inset:0;background:#222;border-radius:16px;transition:.2s}
.tog-sl:before{content:'';position:absolute;width:12px;height:12px;left:2px;top:2px;background:#777;border-radius:50%;transition:.2s}
input:checked+.tog-sl{background:${UI.accentBg}}
input:checked+.tog-sl:before{transform:translateX(14px);background:${UI.accent}}
.note-i{background:#0a0a0a;color:${UI.textDim};border:1px dashed ${UI.borderSoft};padding:4px 6px;width:100%;box-sizing:border-box;border-radius:2px;font-style:italic;font-size:11px;margin-top:5px}
.note-i:focus{outline:none;border-color:#3a3a3a;color:${UI.textMain}}
.about{color:${UI.textDim};font-size:11px;line-height:1.9}
#btn-rpos{font-size:10px;color:${UI.textFaint};cursor:pointer}
#btn-rpos:hover{color:${UI.textMain}}
#btn-min{cursor:pointer;font-size:13px;color:${UI.textDim}}
#prev-ov{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.92);z-index:99999}
#prev-box{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:90%;max-width:840px;height:84vh;background:#111;border:1px solid #444;border-radius:8px;overflow:hidden;display:flex;flex-direction:column}
#prev-bar{background:${UI.headerBg};padding:8px 14px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid ${UI.border};flex-shrink:0}
#prev-frame{flex:1;border:none;background:#fff}
textarea.ri{resize:vertical;font-family:inherit}
select.ri{cursor:pointer}
.bulk-bar{background:#0a0a0a;border:1px solid #2a2a2a;border-radius:3px;padding:5px 6px;display:flex;gap:5px;align-items:center;margin-bottom:8px}
.bulk-bar .lbl{margin:0;flex-shrink:0}
.bulk-bar .rb{padding:4px 7px;font-size:10.5px;flex:1}
.vault-stats{background:#0d0d0d;border:1px solid ${UI.borderSoft};border-radius:3px;padding:8px 10px;margin-top:6px;font-size:11px;line-height:1.7}
.status-pill{display:inline-block;padding:1px 6px;border-radius:8px;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.5px}
.sf{flex-shrink:0}
`;
document.head.appendChild(styleEl);
const designOptions = Object.entries(THEMES)
.map(([key, t]) => `<option value="${key}">${t.label}</option>`)
.join('');
const wrap = document.createElement('div');
wrap.id = 'rwts';
const pos = GM_getValue('rwts_pos', { top: '50px', right: '20px', left: 'auto' });
wrap.style.top = pos.top;
if (pos.left === 'auto') {
wrap.style.right = pos.right;
} else {
wrap.style.left = pos.left;
wrap.style.right = 'auto';
}
const minimized = GM_getValue('rwts_min', false);
wrap.innerHTML = `
<div id="rwts-hdr">
<span id="rwts-hdr-title">⚔ RW ThreadSmith v${VERSION}</span>
<div style="display:flex;gap:12px;align-items:center">
<span id="btn-rpos">Reset</span>
<span id="btn-min">${minimized ? '▲' : '▼'}</span>
</div>
</div>
<div id="rwts-col" style="display:${minimized ? 'none' : 'block'}">
<div id="rwts-tabs">
<div class="rt on" data-tab="load">📦 Sync</div>
<div class="rt" data-tab="items">🗄 Vault</div>
<div class="rt" data-tab="gen">🖨 Output</div>
<div class="rt" data-tab="cfg">⚙ Config</div>
</div>
<div id="rwts-body">
<div id="tab-load" class="rp on">
<div class="lbl">Torn API Key</div>
<div class="rrow">
<input type="password" id="api-key" class="ri" placeholder="Enter API key..." style="flex:1">
<button class="rb gold" id="btn-load">Sync</button>
</div>
<div id="prog-area" style="display:none">
<div class="pbw"><div class="pb" id="pb" style="width:0%"></div></div>
<div class="stxt" id="stxt">Starting...</div>
<button class="rb red full" id="btn-cancel" style="margin-top:4px">✕ Cancel</button>
</div>
<div id="load-msg" class="stxt" style="margin-top:6px"></div>
<div class="lbl" style="margin-top:10px">Vault Status</div>
<div id="vault-stats" class="vault-stats"></div>
<hr class="div">
<div class="lbl">Backup & Restore</div>
<div class="rrow">
<button class="rb" id="btn-export" style="flex:1">⬇ Export JSON</button>
<button class="rb" id="btn-import" style="flex:1">⬆ Import JSON</button>
<input type="file" id="import-file" accept=".json" style="display:none">
</div>
<div class="rrow">
<button class="rb red" id="btn-clr-prices" style="flex:1">Clear Prices/Notes</button>
<button class="rb red" id="btn-clr-vault" style="flex:1">Clear Vault</button>
</div>
<div class="stxt" style="text-align:left;color:#777;margin-top:6px;line-height:1.6">
<strong style="color:#aaa">Vault</strong>: Persistent storage of every item you've ever synced.
Items remain after you remove them from your bazaar - toggle their status (Active/Sold/Hidden)
in the Vault tab to control which appear in generated threads.
</div>
</div>
<div id="tab-items" class="rp">
<div class="rrow">
<input type="text" id="item-search" class="ri" placeholder="🔍 Search items..." style="flex:1">
<select id="item-sort" class="ri" style="width:120px">
<option value="name-asc">Name A→Z</option>
<option value="name-desc">Name Z→A</option>
<option value="rarity">Rarity</option>
<option value="type">Type</option>
<option value="status">Status</option>
</select>
</div>
<div class="rrow">
<select id="f-bonus" class="ri" style="flex:1"><option value="">All Bonuses</option></select>
<select id="f-rarity" class="ri" style="flex:1">
<option value="">All Rarities</option>
<option value="Yellow">Yellow</option>
<option value="Orange">Orange</option>
<option value="Red">Red</option>
<option value="White">White</option>
</select>
<select id="f-status" class="ri" style="flex:1">
<option value="">All Status</option>
<option value="active">Active</option>
<option value="sold">Sold</option>
<option value="hidden">Hidden</option>
</select>
</div>
<div class="bulk-bar" title="Bulk operations apply to currently filtered items">
<span class="lbl">Bulk:</span>
<button class="rb grn" id="btn-bulk-active" title="Set filtered → Active">→ Active</button>
<button class="rb sold" id="btn-bulk-sold" title="Set filtered → Sold">→ Sold</button>
<button class="rb" id="btn-bulk-hidden" title="Set filtered → Hidden">→ Hidden</button>
<button class="rb-x" id="btn-bulk-remove" title="Remove filtered items from vault">✕</button>
</div>
<div id="item-list"><div class="stxt">Vault is empty. Sync your bazaar to populate it.</div></div>
</div>
<div id="tab-gen" class="rp">
<div class="lbl">Thread Header</div>
<input type="text" id="shop-title" class="ri" placeholder="Thread title..." style="width:100%;margin-bottom:6px">
<textarea id="shop-desc" class="ri" placeholder="Intro / description text..." style="width:100%;height:55px"></textarea>
<hr class="div">
<div class="lbl">Layout</div>
<div class="rrow">
<select id="sort-mode" class="ri" style="flex:1">
<option value="type">Group: Category</option>
<option value="rarity">Group: Rarity</option>
</select>
<select id="f-cat" class="ri" style="flex:1">
<option value="all">All Categories</option>
<option value="Primary">Primary</option>
<option value="Secondary">Secondary</option>
<option value="Melee">Melee</option>
<option value="Armor">Armor</option>
<option value="Other">Other</option>
</select>
</div>
<div class="tw" style="margin:8px 0 4px"><label class="tog"><input type="checkbox" id="tog-include-sold" checked><span class="tog-sl"></span></label><span>Include SOLD items in output</span></div>
<div class="lbl" style="margin-top:6px">Design Theme</div>
<select id="design-mode" class="ri" style="width:100%;margin-bottom:8px">${designOptions}</select>
<div class="rrow">
<button class="rb grn" id="btn-preview" style="flex:1">👁 Preview</button>
<button class="rb gold" id="btn-gen" style="flex:2">⚡ Generate & Copy</button>
</div>
<div id="gen-summary" class="stxt" style="text-align:left;color:#777;margin-top:6px"></div>
<textarea id="out-box" class="ri" style="width:100%;height:90px;margin-top:8px;font-family:monospace;font-size:10px;display:none" readonly></textarea>
</div>
<div id="tab-cfg" class="rp">
<div class="lbl">Item Card Display</div>
<div class="tw"><label class="tog"><input type="checkbox" id="tog-notes"><span class="tog-sl"></span></label><span>Show per-item notes field</span></div>
<div class="tw"><label class="tog"><input type="checkbox" id="tog-compact"><span class="tog-sl"></span></label><span>Compact item cards</span></div>
<hr class="div">
<div class="lbl">Generated HTML Options</div>
<div class="tw"><label class="tog"><input type="checkbox" id="tog-notes-html"><span class="tog-sl"></span></label><span>Include item notes in output</span></div>
<div class="tw"><label class="tog"><input type="checkbox" id="tog-counts" checked><span class="tog-sl"></span></label><span>Show item count in section headers</span></div>
<hr class="div">
<div class="lbl">About</div>
<div class="about">
RW ThreadSmith v${VERSION} · GPL-3.0 License<br>
by Rowage [3926289]<br>
API cadence ≈ 85 req/min<br>
${Object.keys(THEMES).length} designs · Vault storage · Bulk actions · Live preview · Export/Import
</div>
</div>
</div>
</div>
`;
document.body.appendChild(wrap);
const prevOverlay = document.createElement('div');
prevOverlay.id = 'prev-ov';
prevOverlay.innerHTML = `
<div id="prev-box">
<div id="prev-bar">
<span style="color:${UI.accent};font-weight:bold;font-size:13px">Thread Preview</span>
<div style="display:flex;gap:8px">
<button class="rb" id="btn-prev-copy" style="font-size:11px">Copy HTML</button>
<button class="rb" id="btn-prev-close">✕ Close</button>
</div>
</div>
<iframe id="prev-frame"></iframe>
</div>
`;
document.body.appendChild(prevOverlay);
$('btn-prev-close').onclick = () => { prevOverlay.style.display = 'none'; };
$('btn-prev-copy').onclick = () => {
GM_setClipboard($('out-box').value);
alert('Copied!');
};
$('api-key').value = GM_getValue('rwts_api', '');
$('shop-title').value = GM_getValue('rwts_title', '');
$('shop-desc').value = GM_getValue('rwts_desc', '');
$('design-mode').value = GM_getValue('rwts_design', 'glow');
$('sort-mode').value = GM_getValue('rwts_sort', 'type');
$('f-cat').value = GM_getValue('rwts_fcat', 'all');
$('tog-notes').checked = GM_getValue('rwts_tog_notes', false);
$('tog-compact').checked = GM_getValue('rwts_tog_compact', false);
$('tog-notes-html').checked = GM_getValue('rwts_tog_notes_html', false);
$('tog-counts').checked = GM_getValue('rwts_tog_counts', true);
$('tog-include-sold').checked = GM_getValue('rwts_tog_include_sold', true);
$('shop-title').oninput = e => GM_setValue('rwts_title', e.target.value);
$('shop-desc').oninput = e => GM_setValue('rwts_desc', e.target.value);
$('design-mode').onchange = e => GM_setValue('rwts_design', e.target.value);
$('sort-mode').onchange = e => GM_setValue('rwts_sort', e.target.value);
$('f-cat').onchange = e => GM_setValue('rwts_fcat', e.target.value);
$('tog-notes').onchange = e => { GM_setValue('rwts_tog_notes', e.target.checked); renderItems(); };
$('tog-compact').onchange = e => { GM_setValue('rwts_tog_compact', e.target.checked); renderItems(); };
$('tog-notes-html').onchange = e => GM_setValue('rwts_tog_notes_html', e.target.checked);
$('tog-counts').onchange = e => GM_setValue('rwts_tog_counts', e.target.checked);
$('tog-include-sold').onchange = e => GM_setValue('rwts_tog_include_sold', e.target.checked);
document.querySelectorAll('.rt').forEach(tab => {
tab.onclick = () => {
document.querySelectorAll('.rt').forEach(t => t.classList.remove('on'));
document.querySelectorAll('.rp').forEach(p => p.classList.remove('on'));
tab.classList.add('on');
$(`tab-${tab.dataset.tab}`).classList.add('on');
};
});
$('btn-min').onclick = () => {
const col = $('rwts-col');
const willMinimize = col.style.display !== 'none';
col.style.display = willMinimize ? 'none' : 'block';
$('btn-min').textContent = willMinimize ? '▲' : '▼';
GM_setValue('rwts_min', willMinimize);
};
$('btn-rpos').onclick = () => {
wrap.style.left = 'auto';
wrap.style.right = '20px';
wrap.style.top = '50px';
GM_setValue('rwts_pos', { top: '50px', right: '20px', left: 'auto' });
};
{
let dragging = false, ox = 0, oy = 0;
$('rwts-hdr').onmousedown = e => {
if (['btn-rpos', 'btn-min'].includes(e.target.id)) return;
dragging = true;
const r = wrap.getBoundingClientRect();
if (wrap.style.right !== 'auto') {
wrap.style.left = r.left + 'px';
wrap.style.right = 'auto';
}
ox = e.clientX - r.left;
oy = e.clientY - r.top;
};
document.addEventListener('mousemove', e => {
if (!dragging) return;
wrap.style.left = (e.clientX - ox) + 'px';
wrap.style.top = (e.clientY - oy) + 'px';
});
document.addEventListener('mouseup', () => {
if (dragging) {
GM_setValue('rwts_pos', { top: wrap.style.top, left: wrap.style.left, right: 'auto' });
}
dragging = false;
});
}
$('btn-load').onclick = async () => {
const key = $('api-key').value.trim();
if (!key) return alert('API Key required');
GM_setValue('rwts_api', key);
abortLoad = false;
$('prog-area').style.display = 'block';
$('load-msg').textContent = '';
$('btn-load').disabled = true;
try {
const baz = await api(`https://api.torn.com/user/?selections=bazaar&key=${key}`);
const rawArr = baz.bazaar || [];
const bazItems = Array.isArray(rawArr) ? rawArr.filter(i => i.UID) : [];
const vault = getVault();
const now = Date.now();
Object.values(vault).forEach(v => v.inBazaar = false);
const total = bazItems.length;
let newCount = 0, refreshedCount = 0, skippedCount = 0;
for (let i = 0; i < total; i++) {
if (abortLoad) break;
const it = bazItems[i];
$('pb').style.width = `${Math.round((i / total) * 100)}%`;
$('stxt').textContent = `Scanning ${i + 1} / ${total} - ${it.name || 'item'}...`;
if (vault[it.UID]) {
vault[it.UID].bazaar_price = it.price;
vault[it.UID].inBazaar = true;
vault[it.UID].lastSeen = now;
if (it.name) vault[it.UID].name = it.name;
refreshedCount++;
continue;
}
try {
const r = await api(`https://api.torn.com/torn/${it.UID}?selections=itemdetails&key=${key}`);
if (hasBonuses(r?.itemdetails)) {
vault[it.UID] = {
...r.itemdetails,
UID: it.UID,
bazaar_price: it.price,
inBazaar: true,
status: 'active',
addedAt: now,
lastSeen: now
};
newCount++;
} else {
skippedCount++;
}
} catch {
skippedCount++;
}
await sleep(API_THROTTLE_MS);
}
saveVault(vault);
loadItemsFromVault();
$('pb').style.width = '100%';
const totalVault = Object.keys(vault).length;
$('stxt').textContent = abortLoad
? `Cancelled. Vault: ${totalVault}.`
: `${newCount} new · ${refreshedCount} refreshed${skippedCount ? ` · ${skippedCount} skipped` : ''} · vault total: ${totalVault}`;
buildBonusFilter();
renderItems();
updateVaultStats();
if (newCount > 0 || refreshedCount > 0) {
document.querySelector('[data-tab="items"]').click();
}
} catch (e) {
$('stxt').textContent = `Error: ${e.message}`;
}
$('btn-load').disabled = false;
setTimeout(() => {
$('prog-area').style.display = 'none';
const totalVault = Object.keys(getVault()).length;
$('load-msg').textContent = totalVault > 0
? `✓ Vault: ${totalVault} item${totalVault !== 1 ? 's' : ''}`
: 'Vault is empty.';
}, 1800);
};
$('btn-cancel').onclick = () => { abortLoad = true; };
function buildBonusFilter() {
const sel = $('f-bonus');
const prevValue = sel.value;
const bonusSet = new Set();
items.forEach(item => {
Object.values(item.bonuses || {}).forEach(b => bonusSet.add(b.bonus));
});
sel.innerHTML = '<option value="">All Bonuses</option>';
[...bonusSet].sort().forEach(b => {
const o = document.createElement('option');
o.value = b;
o.textContent = b;
sel.appendChild(o);
});
if ([...bonusSet].includes(prevValue)) sel.value = prevValue;
}
function renderItems() {
const list = $('item-list');
loadItemsFromVault();
const savedPrices = GM_getValue('rwts_prices', {});
const savedNotes = GM_getValue('rwts_notes', {});
const showNotes = $('tog-notes').checked;
const compact = $('tog-compact').checked;
const search = $('item-search').value.toLowerCase();
const bonusFilter = $('f-bonus').value;
const rarityFilter = $('f-rarity').value;
const statusFilter = $('f-status').value;
const sortMode = $('item-sort').value;
list.innerHTML = '';
if (!items.length) {
list.innerHTML = '<div class="stxt">Vault is empty. Sync your bazaar to populate it.</div>';
_filteredUIDs = [];
updateVaultStats();
return;
}
const filtered = items.filter(i => {
if (search && !i.name.toLowerCase().includes(search)) return false;
if (bonusFilter && !Object.values(i.bonuses || {}).some(b => b.bonus === bonusFilter)) return false;
if (rarityFilter && normRarity(i.rarity) !== rarityFilter) return false;
if (statusFilter && (i.status || 'active') !== statusFilter) return false;
return true;
});
filtered.sort((a, b) => {
if (sortMode === 'name-asc') return a.name.localeCompare(b.name);
if (sortMode === 'name-desc') return b.name.localeCompare(a.name);
if (sortMode === 'rarity') return RARITY_ORDER.indexOf(normRarity(a.rarity)) - RARITY_ORDER.indexOf(normRarity(b.rarity));
if (sortMode === 'status') return STATUS_ORDER.indexOf(a.status || 'active') - STATUS_ORDER.indexOf(b.status || 'active');
return TYPE_ORDER.indexOf(normType(a.type)) - TYPE_ORDER.indexOf(normType(b.type));
});
_filteredUIDs = filtered.map(f => f.UID);
if (!filtered.length) {
list.innerHTML = '<div class="stxt">No items match current filters.</div>';
updateVaultStats();
return;
}
const countEl = document.createElement('div');
countEl.className = 'item-count';
countEl.textContent = filtered.length === items.length
? `${filtered.length} item${filtered.length !== 1 ? 's' : ''}`
: `${filtered.length} of ${items.length} items (filtered)`;
list.appendChild(countEl);
filtered.forEach(item => {
const color = RARITY_COLORS[normRarity(item.rarity)] || '#fff';
const bonuses = Object.values(item.bonuses || {})
.map(b => `${b.bonus} ${b.value}%`).join(' | ');
const stats = item.damage
? `Q:${item.quality ?? 'N/A'}% · Dmg:${item.damage} · Acc:${item.accuracy ?? 'N/A'}`
: `Q:${item.quality ?? 'N/A'}% · Armor:${item.armor ?? 'N/A'}`;
const status = item.status || 'active';
const inBaz = !!item.inBazaar;
const card = document.createElement('div');
card.className = `ic st-${status}`;
card.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:6px">
<div style="flex:1;min-width:0">
<div class="ic-name" style="color:${color}">${item.name}</div>
${!compact ? `<div class="ic-stats">${stats}</div><div class="ic-bon">${bonuses}</div>` : ''}
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:3px;flex-shrink:0">
<span class="ic-tag">${normType(item.type)}</span>
<span class="${inBaz ? 'baz-yes' : 'baz-no'}">${inBaz ? '● Listed' : '○ Vault'}</span>
</div>
</div>
<div style="display:flex;gap:6px;margin-top:7px;align-items:center">
<select class="ri sf" data-uid="${item.UID}" style="width:88px" title="Item status">
<option value="active" ${status === 'active' ? 'selected' : ''}>Active</option>
<option value="sold" ${status === 'sold' ? 'selected' : ''}>Sold</option>
<option value="hidden" ${status === 'hidden' ? 'selected' : ''}>Hidden</option>
</select>
<input type="text" class="ri pf" data-uid="${item.UID}" placeholder="Price (e.g. 5m)" value="${savedPrices[item.UID] || item.bazaar_price || ''}" style="flex:1">
<button class="rb-x" data-uid="${item.UID}" title="Remove from vault">✕</button>
</div>
${showNotes ? `<input type="text" class="note-i nf" data-uid="${item.UID}" placeholder="Item note (optional)..." value="${savedNotes[item.UID] || ''}">` : ''}
`;
card.querySelector('.sf').onchange = e => {
setItemStatus(e.target.dataset.uid, e.target.value);
renderItems();
updateVaultStats();
};
card.querySelector('.pf').oninput = e => {
const p = GM_getValue('rwts_prices', {});
p[e.target.dataset.uid] = e.target.value;
GM_setValue('rwts_prices', p);
};
card.querySelector('.rb-x').onclick = () => {
if (confirm(`Remove "${item.name}" from vault?\n\nThis only removes the script's stored copy. The item itself in Torn is unaffected.`)) {
removeFromVault(item.UID);
renderItems();
updateVaultStats();
}
};
if (showNotes) {
card.querySelector('.nf').oninput = e => {
const n = GM_getValue('rwts_notes', {});
n[e.target.dataset.uid] = e.target.value;
GM_setValue('rwts_notes', n);
};
}
list.appendChild(card);
});
updateVaultStats();
}
function bulkSetStatus(status) {
if (!_filteredUIDs.length) return alert('No filtered items to update.');
const label = STATUS_LABELS[status];
if (!confirm(`Set ${_filteredUIDs.length} filtered item(s) to "${label}"?`)) return;
const v = getVault();
_filteredUIDs.forEach(uid => { if (v[uid]) v[uid].status = status; });
saveVault(v);
renderItems();
}
function bulkRemove() {
if (!_filteredUIDs.length) return alert('No filtered items to remove.');
if (!confirm(`Permanently remove ${_filteredUIDs.length} filtered item(s) from vault?\n\nThis cannot be undone (export first if you want a backup).`)) return;
const v = getVault();
_filteredUIDs.forEach(uid => delete v[uid]);
saveVault(v);
renderItems();
}
$('btn-bulk-active').onclick = () => bulkSetStatus('active');
$('btn-bulk-sold').onclick = () => bulkSetStatus('sold');
$('btn-bulk-hidden').onclick = () => bulkSetStatus('hidden');
$('btn-bulk-remove').onclick = bulkRemove;
$('item-search').oninput = renderItems;
$('item-sort').onchange = renderItems;
$('f-bonus').onchange = renderItems;
$('f-rarity').onchange = renderItems;
$('f-status').onchange = renderItems;
function updateVaultStats() {
const el = $('vault-stats');
if (!el) return;
const v = getVault();
const arr = Object.values(v);
if (!arr.length) {
el.innerHTML = '<div style="color:#777;font-style:italic">Vault is empty - sync your bazaar to begin.</div>';
return;
}
const counts = { active: 0, sold: 0, hidden: 0, listed: 0, vaulted: 0 };
arr.forEach(i => {
counts[normStatus(i.status)]++;
if (i.inBazaar) counts.listed++; else counts.vaulted++;
});
el.innerHTML = `
<div><strong style="color:${UI.accent}">${arr.length}</strong> item${arr.length !== 1 ? 's' : ''} in vault</div>
<div style="margin-top:3px">
<span style="color:${STATUS_COLORS.active}">● ${counts.active} active</span> ·
<span style="color:${STATUS_COLORS.sold}">● ${counts.sold} sold</span> ·
<span style="color:${STATUS_COLORS.hidden}">● ${counts.hidden} hidden</span>
</div>
<div style="margin-top:3px;color:#888">
${counts.listed} currently listed · ${counts.vaulted} vault-only
</div>
`;
}
$('btn-export').onclick = () => {
const data = {
version: VERSION,
exportedAt: new Date().toISOString(),
prices: GM_getValue('rwts_prices', {}),
notes: GM_getValue('rwts_notes', {}),
vault: getVault()
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `rwts-vault-${Date.now()}.json`;
a.click();
};
$('btn-import').onclick = () => $('import-file').click();
$('import-file').onchange = e => {
const f = e.target.files[0];
if (!f) return;
const reader = new FileReader();
reader.onload = ev => {
try {
const d = JSON.parse(ev.target.result);
let imported = [];
if (d.prices) { GM_setValue('rwts_prices', d.prices); imported.push('prices'); }
if (d.notes) { GM_setValue('rwts_notes', d.notes); imported.push('notes'); }
if (d.vault) {
const existing = getVault();
const merged = { ...existing, ...d.vault };
saveVault(merged);
imported.push(`vault (${Object.keys(d.vault).length} items)`);
}
loadItemsFromVault();
buildBonusFilter();
renderItems();
updateVaultStats();
alert(imported.length ? `Imported: ${imported.join(', ')}` : 'No recognised fields in file.');
} catch (err) {
alert('Invalid JSON file: ' + err.message);
}
};
reader.readAsText(f);
e.target.value = '';
};
$('btn-clr-vault').onclick = () => {
const total = Object.keys(getVault()).length;
if (!total) return alert('Vault is already empty.');
if (confirm(`Clear ENTIRE vault (${total} items)?\n\nThis removes all stored items, statuses, and bazaar history. Cannot be undone - export first if you want a backup.`)) {
saveVault({});
GM_setValue('rwts_cache', {});
loadItemsFromVault();
renderItems();
updateVaultStats();
$('load-msg').textContent = 'Vault cleared.';
}
};
$('btn-clr-prices').onclick = () => {
if (confirm('Clear all saved prices and notes?\n\n(Vault items themselves are kept.)')) {
GM_setValue('rwts_prices', {});
GM_setValue('rwts_notes', {});
renderItems();
}
};
function buildHTML() {
const sortMode = $('sort-mode').value;
const designMode = $('design-mode').value;
const filterCat = $('f-cat').value;
const showCounts = $('tog-counts').checked;
const notesInHtml = $('tog-notes-html').checked;
const includeSold = $('tog-include-sold').checked;
const savedPrices = GM_getValue('rwts_prices', {});
const groups = {};
let included = 0, skippedHidden = 0, skippedSold = 0, skippedCat = 0;
items.forEach(item => {
const status = normStatus(item.status);
if (status === 'hidden') { skippedHidden++; return; }
if (status === 'sold' && !includeSold) { skippedSold++; return; }
if (filterCat !== 'all' && normType(item.type) !== filterCat) { skippedCat++; return; }
item.manualPrice = savedPrices[item.UID] || item.bazaar_price || '';
const key = sortMode === 'type' ? normType(item.type) : normRarity(item.rarity);
(groups[key] ||= []).push(item);
included++;
});
Object.values(groups).forEach(arr => arr.sort((a, b) => a.name.localeCompare(b.name)));
const userTitle = $('shop-title').value.trim();
const userDesc = $('shop-desc').value.trim();
const theme = THEMES[designMode] || THEMES.glow;
let html = `<div style="background:#111;padding:20px;color:#fff;font-family:Arial;border-radius:8px;text-align:center;">`;
if (userTitle || userDesc) {
html += theme.header(userTitle || ' ', userDesc || '');
}
const order = sortMode === 'type' ? TYPE_ORDER : RARITY_ORDER;
for (const group of order) {
if (!groups[group]?.length) continue;
const cnt = showCounts ? ` <span style="color:#888;font-size:13px;">(${groups[group].length})</span>` : '';
html += `<h2 style="color:#aaa;border-bottom:1px solid #333;padding-bottom:5px;margin:30px 0 15px 0;">${group} Items${cnt}</h2>`;
for (const item of groups[group]) {
const color = RARITY_COLORS[normRarity(item.rarity)] || RARITY_COLORS.White;
html += theme.item(item, color, notesInHtml);
}
}
html += `</div>`;
return { html, included, skippedHidden, skippedSold, skippedCat };
}
function showGenSummary({ included, skippedHidden, skippedSold, skippedCat }) {
const parts = [`<strong style="color:${UI.accent}">${included}</strong> included`];
if (skippedHidden) parts.push(`${skippedHidden} hidden`);
if (skippedSold) parts.push(`${skippedSold} sold (excluded)`);
if (skippedCat) parts.push(`${skippedCat} filtered out`);
$('gen-summary').innerHTML = parts.join(' · ');
}
$('btn-gen').onclick = () => {
if (!items.length) return alert('Vault is empty. Sync your bazaar first.');
const result = buildHTML();
if (!result.included) return alert('Nothing to generate. Check filters and item statuses.');
$('out-box').value = result.html;
$('out-box').style.display = 'block';
showGenSummary(result);
GM_setClipboard(result.html);
alert(`HTML generated and copied to clipboard!\n\n${result.included} item(s) included.`);
};
$('btn-preview').onclick = () => {
if (!items.length) return alert('Vault is empty. Sync your bazaar first.');
const result = buildHTML();
if (!result.included) return alert('Nothing to preview. Check filters and item statuses.');
$('out-box').value = result.html;
showGenSummary(result);
$('prev-frame').srcdoc = `<!DOCTYPE html><html><body style="margin:0;background:#111;">${result.html}</body></html>`;
prevOverlay.style.display = 'block';
};
loadItemsFromVault();
buildBonusFilter();
renderItems();
updateVaultStats();
})();