Dark/lila background + left decor column + DTI background picker (search, favorites, hide). Includes button-theme + accent-color pickers. Settings persist locally.
// ==UserScript==
// @name ③ Neopets — Background & Decor
// @namespace neopets-qol
// @version 4.8.1
// @author marius@clraik
// @license MIT
// @description Dark/lila background + left decor column + DTI background picker (search, favorites, hide). Includes button-theme + accent-color pickers. Settings persist locally.
// @match *://*.neopets.com/*
// @exclude https://www.neopets.com/userlookup.phtml?user=*
// @exclude https://www.neopets.com/petlookup.phtml*
// @exclude https://www.neopets.com/~*
// @exclude https://www.neopets.com/games/game.phtml?*
// @run-at document-start
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @connect impress.openneo.net
// @connect impress-2020.openneo.net
// ==/UserScript==
(() => {
'use strict';
if (window.top !== window) return;
if (document.documentElement.dataset.npBgBoot === '1') return;
document.documentElement.dataset.npBgBoot = '1';
// Cross-script access: the HUD script exposes globals from page context.
// Use unsafeWindow to read them when we're in a sandboxed Tampermonkey scope.
const W = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
// ---------- Layout ----------
const LEFT_BAR_PX = 330;
const TOP_BAR_PX = 0;
// ---------- Storage keys ----------
const LS = {
mainBgId: 'npqol_bg_main_v3', // DTI URL or '' (transparent)
leftBarBg: 'npqol_bg_left_v3', // DTI URL or '' (transparent)
favorites: 'npqol_bg_favs_v1', // JSON array of DTI ids
hidden: 'npqol_bg_hidden_v1', // JSON array of DTI ids
};
// ---------- Background base color ----------
const BG_COLOR = '#000';
// ---------- Right-side picker UI ----------
const UI_Z = 2147483000;
const HANDLE_W = 20;
const HANDLE_H = 90;
const SIDEBAR_W = 320;
// ---------- Utils ----------
const safeUrl = (u) => String(u || '').replace(/["\\\n\r]/g, '');
const clamp01 = (x) => Math.max(0, Math.min(1, x));
const LSget = (k, fallback = '') => { try { return localStorage.getItem(k) ?? fallback; } catch { return fallback; } };
const LSset = (k, v) => { try { localStorage.setItem(k, String(v)); } catch {} };
// ---------- DTI background list (GraphQL on impress-2020.openneo.net) ----------
const DTI_CACHE_KEY = 'npqol_dti_bgs_v6';
const DTI_CACHE_TTL = 7 * 24 * 60 * 60 * 1000;
const DTI_PAGE_SIZE = 30;
// Hard safety cap — the loop in fetchDTIBackgrounds breaks naturally when a
// page returns fewer items than DTI_PAGE_SIZE (last page reached), so this
// value is only here to prevent an infinite loop if the DTI API ever
// misbehaves. 30 × 5000 = 150 000 backgrounds, way above what DTI hosts.
const DTI_MAX_PAGES = 5000;
const DTI_ENDPOINT = 'https://impress-2020.openneo.net/api/graphql';
const gqlQuery = (query, variables = {}) => new Promise((resolve, reject) => {
const body = JSON.stringify({ query, variables });
if (typeof GM_xmlhttpRequest === 'function') {
GM_xmlhttpRequest({
method: 'POST',
url: DTI_ENDPOINT,
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
data: body,
timeout: 20000,
onload: (r) => {
try { resolve(JSON.parse(r.responseText)); }
catch (e) { reject(new Error('JSON parse: ' + e.message)); }
},
onerror: () => reject(new Error('network')),
ontimeout: () => reject(new Error('timeout')),
});
} else {
fetch(DTI_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body })
.then(r => r.json()).then(resolve).catch(reject);
}
});
const DTI_QUERY = `
query SearchBgs($query: String!, $offset: Int!, $limit: Int!) {
itemSearch(query: $query, offset: $offset, limit: $limit) {
items {
id
name
thumbnailUrl
appearanceOn(speciesId: 1, colorId: 8) {
layers {
id
zone { id label }
imageUrl(size: SIZE_600)
}
}
}
}
}
`;
async function fetchDTIBackgrounds(progressCb){
const all = [];
const seen = new Set();
let skippedNoImage = 0;
for (let p = 0; p < DTI_MAX_PAGES; p++){
progressCb?.(p + 1, all.length);
const offset = p * DTI_PAGE_SIZE;
let resp;
try {
resp = await gqlQuery(DTI_QUERY, { query: 'background', offset, limit: DTI_PAGE_SIZE });
} catch (e) {
if (p === 0) throw e;
break;
}
if (resp?.errors?.length){
throw new Error('GraphQL: ' + (resp.errors[0]?.message || JSON.stringify(resp.errors[0])));
}
const items = resp?.data?.itemSearch?.items || [];
if (!items.length) break;
for (const it of items){
if (seen.has(it.id)) continue;
seen.add(it.id);
const layers = it.appearanceOn?.layers || [];
const pickBg = layers.find(l => (l?.zone?.label || '').toLowerCase().includes('background')) || layers[0];
const url = pickBg?.imageUrl || '';
if (!url){ skippedNoImage++; continue; }
all.push({
id: String(it.id),
name: it.name,
thumbnailUrl: it.thumbnailUrl || '',
url,
});
}
if (items.length < DTI_PAGE_SIZE) break;
}
if (skippedNoImage) console.info(`[BG] DTI fetch: ${all.length} usable backgrounds, ${skippedNoImage} skipped (no PNG render).`);
return all;
}
function readDTICached(){
try {
const raw = localStorage.getItem(DTI_CACHE_KEY);
if (!raw) return null;
const obj = JSON.parse(raw);
if (!obj || !Array.isArray(obj.items) || (Date.now() - obj.ts) > DTI_CACHE_TTL) return null;
return obj.items;
} catch { return null; }
}
function writeDTICached(items){
try { localStorage.setItem(DTI_CACHE_KEY, JSON.stringify({ ts: Date.now(), items })); } catch {}
}
// ---------- Favorites + hidden (DTI id sets) ----------
function readIdSet(key){
try {
const raw = LSget(key, '');
if (!raw) return new Set();
const arr = JSON.parse(raw);
return new Set(Array.isArray(arr) ? arr.map(String) : []);
} catch { return new Set(); }
}
function writeIdSet(key, set){
try { LSset(key, JSON.stringify([...set])); } catch {}
}
const readFavs = () => readIdSet(LS.favorites);
const writeFavs = (s) => writeIdSet(LS.favorites, s);
const readHidden = () => readIdSet(LS.hidden);
const writeHidden = (s) => writeIdSet(LS.hidden, s);
// ---------- Apply (bare URLs — no opacity/tint) ----------
function applyMainBg(url){
if (!url){
document.documentElement.style.setProperty('--npWallImage', 'none');
document.documentElement.style.setProperty('--npMainSolid', 'transparent');
return;
}
document.documentElement.style.setProperty('--npMainSolid', BG_COLOR);
document.documentElement.style.setProperty('--npWallImage', `url("${safeUrl(url)}")`);
}
function applyLeftBg(url){
if (!url){
document.documentElement.style.setProperty('--npLeftBarImg', 'none');
document.documentElement.style.setProperty('--npLeftBarSolid', 'transparent');
document.documentElement.style.setProperty('--npLeftBarShadeA', '0');
return;
}
document.documentElement.style.setProperty('--npLeftBarImg', `url("${safeUrl(url)}")`);
document.documentElement.style.setProperty('--npLeftBarSolid', '#000');
document.documentElement.style.setProperty('--npLeftBarShadeA', '1');
}
// Apply saved values right away, before any rendering.
applyMainBg(LSget(LS.mainBgId, ''));
applyLeftBg(LSget(LS.leftBarBg, ''));
// ---------- CSS (early) ----------
const style = document.createElement('style');
style.textContent = `
:root{
--npWallImage:none;
--npMainSolid:${BG_COLOR};
--npLeftBarImg:none;
--npLeftBarSolid:#000;
--npLeftBarShadeA:1;
}
html,body{
background:transparent!important;
background-image:none!important;
}
:root::before{
content:"";
position:fixed;
/* Anchored to the (user-resizable) decor column right edge. */
inset:${TOP_BAR_PX}px 0 0 var(--np-col-width, ${LEFT_BAR_PX}px);
z-index:-2147483647;
pointer-events:none;
background-color:var(--npMainSolid)!important;
background-image:var(--npWallImage) !important;
background-repeat:no-repeat!important;
background-position:right top!important;
background-size:cover!important;
}
.nav-top-pattern__2020,.nav-bottom-pattern__2020,.footer-pattern__2020{
background:transparent!important;
background-image:none!important;
}
/* Left decor column. visibility:visible escapes a body{visibility:hidden}
anti-FOUC injected by some other extensions (e.g. Stylus). */
#npLeftBar{
position:fixed!important; top:0; left:0;
/* Tracks --np-col-width set by the HUD resize handle. */
width: var(--np-col-width, ${LEFT_BAR_PX}px); height:100vh;
z-index:18;
pointer-events:none;
overflow:hidden;
box-sizing:border-box;
border:2px solid #000;
background:transparent;
contain:paint;
visibility:visible!important;
opacity:1!important;
}
#npLeftBarBg{
position:absolute; inset:0;
background-color:var(--npLeftBarSolid);
background-image:var(--npLeftBarImg);
background-size:cover;
background-position:center;
opacity:1!important;
visibility:visible!important;
}
#npLeftBarShade{
position:absolute; inset:0;
background:linear-gradient(180deg, rgba(0,0,0,.10), rgba(0,0,0,.28));
opacity:var(--npLeftBarShadeA);
visibility:visible!important;
}
/* The decor column tracks --np-col-width (user-controlled via the HUD
resize handle), so no automatic shrink rules — only hide on tiny
screens where the HUD itself is hidden. */
@media (max-width: 900px){
#npLeftBar{ display:none; }
:root::before{ inset:${TOP_BAR_PX}px 0 0 0; }
}
/* Right-side BG picker — slide-in panel */
#npBgHandle{
position:fixed;
right:0;
top:calc(${TOP_BAR_PX}px + (100vh - ${TOP_BAR_PX}px - ${HANDLE_H}px)/2);
width:${HANDLE_W}px;
height:${HANDLE_H}px;
border-radius:12px 0 0 12px;
background: var(--np-glass, rgba(0,0,0,.45));
backdrop-filter: blur(var(--np-blur, 8px));
-webkit-backdrop-filter: blur(var(--np-blur, 8px));
border:1px solid var(--np-line, rgba(255,255,255,.18));
border-right:0;
color:#fff;
display:flex; align-items:center; justify-content:center;
font-weight:700; font-size:14px; line-height:1;
z-index:${UI_Z};
cursor:pointer;
pointer-events:auto;
box-shadow:none;
user-select:none;
transition: background .12s ease;
}
#npBgHandle:hover{ background: rgba(20,14,32,.7); }
#npBgHandle span{ transform:translateX(-1px); }
#npBgSidebar{
position:fixed;
right:0;
top:${TOP_BAR_PX}px;
width:${SIDEBAR_W}px;
max-width: min(${SIDEBAR_W}px, 90vw);
height:calc(100vh - ${TOP_BAR_PX}px);
background:var(--np-glass, rgba(0,0,0,.22));
backdrop-filter:blur(var(--np-blur, 8px));
-webkit-backdrop-filter:blur(var(--np-blur, 8px));
box-shadow:var(--np-shadow, -2px 0 10px rgba(0,0,0,.28));
z-index:${UI_Z-2};
transition:transform .20s ease;
transform:translateX(100%);
display:flex;
flex-direction:column;
overflow:hidden;
box-sizing:border-box;
}
#npBgBody{
flex:1 1 auto;
min-height:0;
overflow:auto;
padding:12px;
box-sizing:border-box;
scrollbar-width:thin;
scrollbar-color: rgba(255,255,255,.35) rgba(0,0,0,.12);
}
#npBgBody::-webkit-scrollbar{ width:10px; }
#npBgBody::-webkit-scrollbar-thumb{
background:rgba(255,255,255,.28);
border-radius:10px;
border:2px solid rgba(0,0,0,.12);
}
#npBgBody::-webkit-scrollbar-track{ background:rgba(0,0,0,.10); }
#npBgFooter{
flex:0 0 auto;
padding:10px 12px;
display:flex; gap:8px;
border-top:1px solid rgba(255,255,255,.18);
background:rgba(0,0,0,.22);
}
.npFooterBtn{
flex:1;
border-radius:10px;
border:1px solid rgba(255,255,255,.32);
background:rgba(0,0,0,.22);
color:#fff; font-weight:900;
padding:10px 8px; cursor:pointer; font-size:12px;
}
.npFooterBtn:hover{ filter:brightness(1.08); }
.npFooterBtn.primary{
background:var(--np-accent, #dcb8ff);
color:#1a1330;
border-color:transparent;
}
.npThemeBlock{ margin:0 0 10px 0; }
.npThemeSelect{
width:100%;
height:32px;
border-radius:8px;
border:1px solid rgba(255,255,255,.32);
background:rgba(0,0,0,.22);
color:#fff; font-weight:700; font-size:12px;
padding:0 10px;
cursor:pointer;
appearance:none;
-webkit-appearance:none;
background-image:linear-gradient(45deg, transparent 50%, rgba(255,255,255,.6) 50%),
linear-gradient(135deg, rgba(255,255,255,.6) 50%, transparent 50%);
background-position:calc(100% - 14px) center, calc(100% - 9px) center;
background-size:5px 5px;
background-repeat:no-repeat;
}
.npThemeSelect:focus{ outline:2px solid var(--np-accent, #dcb8ff); }
.npThemeSelect option{ background:#1a1330; color:#fff; }
.npAccentBlock{ margin:0 0 12px 0; }
.npAccentPastilles{
display:flex; flex-wrap:wrap; gap:8px;
padding:6px 2px 2px;
}
.npAccentDot{
width:24px; height:24px; flex:0 0 24px;
border-radius:50%; cursor:pointer;
border:2px solid rgba(255,255,255,.18);
box-shadow: 0 1px 3px rgba(0,0,0,.5), inset 0 0 0 1px rgba(0,0,0,.2);
transition: transform .12s ease, border-color .12s ease, box-shadow .12s ease;
padding:0;
}
.npAccentDot:hover{
transform: scale(1.12);
border-color: rgba(255,255,255,.55);
}
.npAccentDot.isActive{
border-color: #fff;
box-shadow: 0 0 0 2px rgba(0,0,0,.55), 0 1px 6px rgba(0,0,0,.55), inset 0 0 0 1px rgba(0,0,0,.2);
transform: scale(1.1);
}
.npBgSearch{
display:flex; gap:6px; padding:2px;
}
.npBgSearch input{
flex:1; min-width:0; height:28px; padding:0 8px;
border:1px solid rgba(255,255,255,.32);
background:rgba(0,0,0,.18); color:#fff;
border-radius:8px; outline:none; font-size:12px;
}
.npBgSearch input::placeholder{ color:rgba(255,255,255,.45); }
.npBgSearch input:focus{ border-color:rgba(255,255,255,.55); }
.npBgSearch button{
width:32px; height:28px; padding:0;
border:1px solid rgba(255,255,255,.32);
background:rgba(0,0,0,.18); color:#fff;
border-radius:8px; cursor:pointer; font-size:14px;
}
.npBgSearch button.isOn{
background:var(--np-accent, #dcb8ff); color:#1a1330;
border-color:transparent;
}
#npBgList{
display:flex; flex-direction:column; gap:6px; margin-top:8px;
}
.npBgItem{
display:grid; grid-template-columns:44px 1fr auto auto auto auto;
gap:6px; align-items:center;
padding:4px 6px; border-radius:10px;
background:rgba(0,0,0,.12);
border:1px solid rgba(255,255,255,.12);
}
.npBgItem.isHidden{
opacity:.55;
background:rgba(255,80,80,.06);
border-color:rgba(255,80,80,.18);
}
.npBgThumb{
width:44px; height:44px; border-radius:6px;
background:#fff no-repeat center/cover;
border:1px solid rgba(255,255,255,.16);
}
.npBgName{
color:rgba(255,255,255,.92); font-size:11px; line-height:1.2;
overflow:hidden; text-overflow:ellipsis; display:-webkit-box;
-webkit-line-clamp:2; -webkit-box-orient:vertical; word-break:break-word;
}
.npBgBtn{
width:30px; height:28px; padding:0;
border:1px solid rgba(255,255,255,.32);
background:rgba(0,0,0,.20); color:#fff; font-weight:900;
border-radius:8px; cursor:pointer; font-size:12px;
}
.npBgBtn:hover{ filter:brightness(1.08); }
.npBgBtn.isActive{ outline:2px solid var(--np-accent, #dcb8ff); }
.npFavBtn, .npHideBtn{
width:24px; height:28px; padding:0;
border:none; background:transparent;
color:rgba(255,255,255,.45); cursor:pointer; font-size:14px;
line-height:1;
}
.npFavBtn:hover, .npHideBtn:hover{ color:rgba(255,255,255,.85); }
.npFavBtn.isOn{ color:#ffd166; }
.npHideBtn.isOn{ color:#ff6b6b; }
.npFavBtn{ font-size:16px; }
#npBgStatus{
padding:6px 4px; font-size:11px; color:rgba(255,255,255,.7);
}
.npSmall{
font:800 10px/1.2 system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
color:rgba(255,255,255,.82);
letter-spacing:.04em;
margin-bottom:6px;
}
`;
(document.head || document.documentElement).appendChild(style);
// ---------- Left decor column mount ----------
function mountLeftBar(){
if (document.getElementById('npLeftBar')) return;
const bar = document.createElement('div');
bar.id = 'npLeftBar';
bar.innerHTML = `<div id="npLeftBarBg"></div><div id="npLeftBarShade"></div>`;
(document.body || document.documentElement).appendChild(bar);
}
if (document.body) mountLeftBar();
else new MutationObserver((_, o) => { if (document.body){ mountLeftBar(); o.disconnect(); } })
.observe(document.documentElement, { childList:true, subtree:true });
// ---------- Right-side picker (slide-in) ----------
function onReady(fn){
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once:true });
else fn();
}
onReady(() => {
if (document.getElementById('npBgHandle')) return;
const handle = document.createElement('div');
handle.id = 'npBgHandle';
handle.title = 'Click to open';
handle.innerHTML = '<span>❮</span>';
const sidebar = document.createElement('div');
sidebar.id = 'npBgSidebar';
sidebar.innerHTML = `
<div id="npBgBody">
<div class="npThemeBlock">
<div class="npSmall" style="margin-bottom:4px">Button style</div>
<select id="npBtnTheme" class="npThemeSelect"></select>
</div>
<div class="npAccentBlock">
<div class="npSmall" style="margin-bottom:4px">Accent color</div>
<div class="npAccentPastilles" id="npAccentPastilles"></div>
</div>
<div class="npSmall" id="npBgStatus">Empty list — please wait while it loads…</div>
<div class="npBgSearch">
<input id="npBgQuery" type="text" placeholder="Filter by name…" />
<button id="npBgFavOnly" type="button" title="Show favorites only">★</button>
<button id="npBgHiddenOnly" type="button" title="Show hidden backgrounds (to restore them)">🗑</button>
<button id="npBgReload" type="button" title="Reload from DTI">↻</button>
</div>
<div id="npBgList"></div>
</div>
<div id="npBgFooter">
<button class="npFooterBtn" id="npBgCancel" type="button">Cancel</button>
<button class="npFooterBtn primary" id="npBgSave" type="button">Save</button>
</div>
`;
(document.body || document.documentElement).append(sidebar, handle);
const OPEN_X = 'translateX(0)';
const CLOSED_X = 'translateX(100%)';
let built = false;
let isOpen = false;
let snapshot = null; // LS state captured on open
let session = null; // live preview state during editing
function openBar(){
if (isOpen) return;
isOpen = true;
snapshot = {
mainBg: LSget(LS.mainBgId, ''),
leftBg: LSget(LS.leftBarBg, ''),
btnTheme: (typeof W.__npGetBtnTheme === 'function') ? W.__npGetBtnTheme() : '',
accent: (typeof W.__npGetAccent === 'function') ? W.__npGetAccent() : '#dcb8ff',
};
session = { ...snapshot };
sidebar.style.transform = OPEN_X;
if (!built) buildUI();
refreshActives();
refreshThemeSelect();
refreshAccentPastilles();
}
function closeBar(){
isOpen = false;
sidebar.style.transform = CLOSED_X;
snapshot = null;
session = null;
}
function saveAndClose(){
if (!session) { closeBar(); return; }
LSset(LS.mainBgId, session.mainBg);
LSset(LS.leftBarBg, session.leftBg);
if (session.btnTheme && typeof W.__npSaveBtnTheme === 'function'){
W.__npSaveBtnTheme(session.btnTheme);
}
if (session.accent && typeof W.__npSaveAccent === 'function'){
W.__npSaveAccent(session.accent);
}
closeBar();
}
function cancelAndClose(){
if (snapshot){
applyMainBg(snapshot.mainBg);
applyLeftBg(snapshot.leftBg);
if (snapshot.btnTheme && typeof W.__npApplyBtnTheme === 'function'){
W.__npApplyBtnTheme(snapshot.btnTheme);
}
if (snapshot.accent && typeof W.__npApplyAccent === 'function'){
W.__npApplyAccent(snapshot.accent);
}
}
closeBar();
}
handle.addEventListener('click', (ev) => {
ev.preventDefault(); ev.stopPropagation();
openBar();
});
// Click anywhere outside the panel (and not on the handle) reverts + closes.
document.addEventListener('pointerdown', (ev) => {
if (!isOpen) return;
const t = ev.target;
if (!t) return;
if (t.closest && (t.closest('#npBgSidebar') || t.closest('#npBgHandle'))) return;
cancelAndClose();
}, true);
function refreshActives(){
if (!session) return;
sidebar.querySelectorAll('.npBgR').forEach(b => {
b.classList.toggle('isActive', b.getAttribute('data-url') === session.mainBg);
});
sidebar.querySelectorAll('.npBgL').forEach(b => {
b.classList.toggle('isActive', b.getAttribute('data-url') === session.leftBg);
});
}
// (Re)populate the button-theme <select> — runs on each open so we pick up
// the API in case the HUD script wasn't loaded yet on first build.
function refreshThemeSelect(){
const sel = sidebar.querySelector('#npBtnTheme');
if (!sel) return;
const themes = Array.isArray(W.__npBtnThemes) ? W.__npBtnThemes : [];
if (!themes.length){
sel.innerHTML = `<option value="">(HUD script not loaded)</option>`;
sel.disabled = true;
return;
}
sel.disabled = false;
if (sel.options.length !== themes.length || sel.options[0]?.value === ''){
sel.innerHTML = themes.map(t => `<option value="${t.id}">${t.label}</option>`).join('');
}
const cur = (session && session.btnTheme)
|| (typeof W.__npGetBtnTheme === 'function' ? W.__npGetBtnTheme() : '');
if (cur) sel.value = cur;
}
function refreshAccentPastilles(){
const box = sidebar.querySelector('#npAccentPastilles');
if (!box) return;
const palette = Array.isArray(W.__npAccentPalette) ? W.__npAccentPalette : [];
if (!palette.length){
box.innerHTML = `<div class="npSmall" style="opacity:.6">(Core script not loaded)</div>`;
return;
}
if (box.children.length !== palette.length){
box.innerHTML = palette.map(p =>
`<button type="button" class="npAccentDot" data-hex="${p.hex}" title="${p.label}" style="background:${p.hex}"></button>`
).join('');
}
const cur = (session && session.accent)
|| (typeof W.__npGetAccent === 'function' ? W.__npGetAccent() : '#dcb8ff');
box.querySelectorAll('.npAccentDot').forEach(el => {
el.classList.toggle('isActive', el.getAttribute('data-hex').toLowerCase() === String(cur).toLowerCase());
});
}
function buildUI(){
built = true;
const bgList = sidebar.querySelector('#npBgList');
const bgStatus = sidebar.querySelector('#npBgStatus');
const bgQuery = sidebar.querySelector('#npBgQuery');
const bgReload = sidebar.querySelector('#npBgReload');
const bgFavOnly = sidebar.querySelector('#npBgFavOnly');
const bgHiddenOnly = sidebar.querySelector('#npBgHiddenOnly');
const saveBtn = sidebar.querySelector('#npBgSave');
const cancelBtn = sidebar.querySelector('#npBgCancel');
const bodyEl = sidebar.querySelector('#npBgBody');
const themeSel = sidebar.querySelector('#npBtnTheme');
themeSel?.addEventListener('change', () => {
if (!session) return;
session.btnTheme = themeSel.value;
if (typeof W.__npApplyBtnTheme === 'function') W.__npApplyBtnTheme(themeSel.value);
});
refreshThemeSelect();
const accentBox = sidebar.querySelector('#npAccentPastilles');
accentBox?.addEventListener('click', (e) => {
const btn = e.target.closest('.npAccentDot');
if (!btn || !session) return;
const hex = btn.getAttribute('data-hex');
if (!hex) return;
session.accent = hex;
if (typeof W.__npApplyAccent === 'function') W.__npApplyAccent(hex);
accentBox.querySelectorAll('.npAccentDot').forEach(el => {
el.classList.toggle('isActive', el === btn);
});
});
refreshAccentPastilles();
saveBtn .addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); saveAndClose(); });
cancelBtn.addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); cancelAndClose(); });
let dtiAll = [];
let dtiFiltered = [];
let renderCount = 0;
const RENDER_STEP = 30;
let favs = readFavs();
let hidden = readHidden();
let favOnly = false;
let hiddenOnly = false;
function syncFilterBtns(){
bgFavOnly.classList.toggle('isOn', favOnly);
bgHiddenOnly.classList.toggle('isOn', hiddenOnly);
}
function renderMore(){
const slice = dtiFiltered.slice(renderCount, renderCount + RENDER_STEP);
for (const it of slice){
const row = document.createElement('div');
row.className = 'npBgItem';
const isFav = favs.has(it.id);
const isHid = hidden.has(it.id);
if (isHid) row.classList.add('isHidden');
row.innerHTML = `
<div class="npBgThumb" style="background-image:url('${safeUrl(it.thumbnailUrl)}')"></div>
<div class="npBgName" title="${it.name.replace(/"/g,'"')}">${it.name}</div>
<button type="button" class="npFavBtn ${isFav?'isOn':''}" title="Favorite">★</button>
<button type="button" class="npBgBtn npBgL" data-url="${safeUrl(it.url)}" title="Use on LEFT decor column">L</button>
<button type="button" class="npBgBtn npBgR" data-url="${safeUrl(it.url)}" title="Use as MAIN background">M</button>
<button type="button" class="npHideBtn ${isHid?'isOn':''}" title="${isHid?'Restore':'Hide this background'}">🗑</button>
`;
const favBtn = row.querySelector('.npFavBtn');
const btnL = row.querySelector('.npBgL');
const btnR = row.querySelector('.npBgR');
const hideBtn = row.querySelector('.npHideBtn');
favBtn.addEventListener('click', (ev) => {
ev.preventDefault(); ev.stopPropagation();
if (favs.has(it.id)) favs.delete(it.id); else favs.add(it.id);
writeFavs(favs);
favBtn.classList.toggle('isOn', favs.has(it.id));
if (favOnly) applyFilter();
});
hideBtn.addEventListener('click', (ev) => {
ev.preventDefault(); ev.stopPropagation();
if (hidden.has(it.id)) hidden.delete(it.id); else hidden.add(it.id);
writeHidden(hidden);
applyFilter();
});
btnL.addEventListener('click', (ev) => {
ev.preventDefault(); ev.stopPropagation();
if (!session) return;
session.leftBg = it.url;
applyLeftBg(it.url);
refreshActives();
});
btnR.addEventListener('click', (ev) => {
ev.preventDefault(); ev.stopPropagation();
if (!session) return;
session.mainBg = it.url;
applyMainBg(it.url);
refreshActives();
});
bgList.appendChild(row);
}
renderCount += slice.length;
refreshActives();
}
function applyFilter(){
const q = (bgQuery.value || '').trim().toLowerCase();
dtiFiltered = dtiAll.filter(it => {
const isHid = hidden.has(it.id);
if (hiddenOnly){
if (!isHid) return false;
} else {
if (isHid) return false;
if (favOnly && !favs.has(it.id)) return false;
}
if (q && !it.name.toLowerCase().includes(q)) return false;
return true;
});
bgList.innerHTML = '';
renderCount = 0;
renderMore();
updateStatus();
}
function updateStatus(){
if (!dtiAll.length){
bgStatus.textContent = 'No backgrounds loaded. Click ↻ to fetch from DTI.';
} else if (!dtiFiltered.length){
if (hiddenOnly) bgStatus.textContent = '0 hidden backgrounds (trash is empty).';
else if (favOnly) bgStatus.textContent = `0 favorite${favs.size ? ' matching' : ''} (out of ${dtiAll.length} backgrounds).`;
else bgStatus.textContent = `No results (out of ${dtiAll.length} cached backgrounds).`;
} else {
const note = hiddenOnly ? ' (trash)' : (favOnly ? ' (favorites)' : '');
bgStatus.textContent = `${dtiFiltered.length} background${dtiFiltered.length > 1 ? 's' : ''}${note} shown (${Math.min(renderCount, dtiFiltered.length)} rendered).`;
}
}
bodyEl.addEventListener('scroll', () => {
if (renderCount >= dtiFiltered.length) return;
if (bodyEl.scrollTop + bodyEl.clientHeight >= bodyEl.scrollHeight - 120) renderMore();
});
bgQuery.addEventListener('input', applyFilter);
bgFavOnly.addEventListener('click', (ev) => {
ev.preventDefault(); ev.stopPropagation();
favOnly = !favOnly;
if (favOnly) hiddenOnly = false;
syncFilterBtns();
applyFilter();
});
bgHiddenOnly.addEventListener('click', (ev) => {
ev.preventDefault(); ev.stopPropagation();
hiddenOnly = !hiddenOnly;
if (hiddenOnly) favOnly = false;
syncFilterBtns();
applyFilter();
});
async function loadDTI(force = false){
if (!force){
const cached = readDTICached();
if (cached && cached.length){
dtiAll = cached;
applyFilter();
return;
}
}
bgStatus.textContent = 'Loading from DTI…';
try {
const items = await fetchDTIBackgrounds((page, count) => {
bgStatus.textContent = `Loading from DTI… (page ${page}, ${count} backgrounds)`;
});
if (items.length){
dtiAll = items;
writeDTICached(items);
applyFilter();
bgStatus.textContent = `${items.length} backgrounds loaded from DTI.`;
} else {
bgStatus.textContent = 'No backgrounds returned by DTI.';
}
} catch (err) {
bgStatus.textContent = 'DTI fetch failed: ' + (err.message || err);
}
}
bgReload.addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); loadDTI(true); });
const cached = readDTICached();
if (cached && cached.length){
dtiAll = cached;
applyFilter();
} else {
updateStatus();
setTimeout(() => loadDTI(false), 60);
}
}
});
})();