// ==UserScript== // @name Sharty Advance 'log Filter // @namespace https://soyjak.st/ // @version 1.2.1 // @description Reintroduces hiding threads and posts that your filters match. Includes a "Show Me Anyways" buttons for threads and 'logs, shift+click a catalog thread to hide it, and settings under Options -> Hiding. // @match *://soyjak.st/* // @match *://*.soyjak.st/* // @run-at document-end // @grant none // ==/UserScript== (function () { 'use strict'; const STORAGE_KEY = 'postFilter'; const SETTINGS_KEY = 'sjcf_settings'; const PURGE_INTERVAL = 86400; const TYPE_LABELS = { name: 'Name', trip: 'Tripcode', sub: 'Subject', com: 'Comment', flag: 'Flag', filename: 'Filename', filehash: 'File hash', }; const DEFAULTS = { catalogButton: true, threadButton: true, blurOp: false, unblurOnHover: false, shiftClick: true, }; const page = typeof window.active_page === 'string' ? window.active_page : ''; const pageBoard = (typeof window.board_name === 'string' && window.board_name) || location.pathname.split('/')[1]; const jq = typeof window.jQuery === 'function' ? window.jQuery : null; /* ---------- settings ---------- */ let settings = loadSettings(); let revealed = false; let refreshPage = () => {}; function loadSettings() { try { return Object.assign({}, DEFAULTS, JSON.parse(localStorage.getItem(SETTINGS_KEY)) || {}); } catch (err) { return Object.assign({}, DEFAULTS); } } function saveSettings() { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } function setRevealed(value) { revealed = value; document.body.classList.toggle('sjcf-reveal', value); refreshPage(); } function applySettings() { const body = document.body.classList; body.toggle('sjcf-blur', settings.blurOp); body.toggle('sjcf-hover-unblur', settings.blurOp && settings.unblurOnHover); const buttonOn = page === 'catalog' ? settings.catalogButton : settings.threadButton; if (revealed && !buttonOn) setRevealed(false); else refreshPage(); } let queued = false; function scheduleRefresh() { if (queued) return; queued = true; setTimeout(() => { queued = false; refreshPage(); }, 0); } /* ---------- filter storage ---------- */ function now() { return Math.floor(Date.now() / 1000); } function readList() { const raw = localStorage.getItem(STORAGE_KEY); if (raw == null) { return { generalFilter: [], postFilter: {}, nextPurge: {}, lastPurge: now() }; } const list = JSON.parse(raw); list.generalFilter = list.generalFilter || []; list.postFilter = list.postFilter || {}; list.nextPurge = list.nextPurge || {}; return list; } function writeThreadHide(board, id, hide) { let list; try { list = readList(); } catch (err) { alert('Catalog Filter: localStorage.postFilter could not be read, so nothing was changed.'); return false; } const threads = list.postFilter[board] || (list.postFilter[board] = {}); const purge = list.nextPurge[board] || (list.nextPurge[board] = {}); const entries = threads[id] || (threads[id] = []); const i = entries.findIndex(e => e && e.post != null && String(e.post) === id); if (hide && i < 0) { entries.push({ post: id, hideReplies: false }); purge[id] = { timestamp: now(), interval: PURGE_INTERVAL }; } else if (!hide && i >= 0) { entries.splice(i, 1); } if (!entries.length) { delete threads[id]; delete purge[id]; } if (!Object.keys(threads).length) delete list.postFilter[board]; if (!Object.keys(purge).length) delete list.nextPurge[board]; localStorage.setItem(STORAGE_KEY, JSON.stringify(list)); return true; } /* ---------- matching ---------- */ function safeRegExp(source) { try { return new RegExp(source); } catch (err) { return null; } } function compileRules(list) { return list.generalFilter.filter(Boolean).map(rule => { const value = String(rule.value == null ? '' : rule.value); let test; if (rule.regex) { const re = safeRegExp(value); test = re ? s => re.test(s) : () => false; } else if (rule.type === 'sub' || rule.type === 'com' || rule.type === 'flag') { // post-filter.js wraps plain sub/com/flag rules in word boundaries const re = safeRegExp('\\b' + value + '\\b') || new RegExp('\\b' + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b'); test = s => re.test(s); } else { test = s => s === value; } return { rule, test }; }); } function matches({ rule, test }, d) { switch (rule.type) { case 'name': return d.name != null && test(d.name); case 'trip': return d.trip !== '' && test(d.trip); case 'sub': return d.sub !== '' && test(d.sub); case 'com': return test(d.com); case 'flag': return d.flag !== '' && test(d.flag); case 'filename': return d.files.some(f => f.name !== '' && test(f.name)); case 'filehash': return d.files.some(f => f.hashes.some(test)); } return false; } function shorten(text, max) { return text.length > max ? text.slice(0, max - 1) + '…' : text; } function describe(rule) { const value = String(rule.value); const shown = rule.type === 'filehash' && !rule.regex ? shorten(value, 13) : shorten(value, 40); return (TYPE_LABELS[rule.type] || rule.type) + ': ' + (rule.regex ? '/' + shown + '/' : shown); } /* ---------- post data helpers ---------- */ function bodyText(nodes) { return Array.from(nodes) .map(n => n.textContent) .filter(t => t !== '') .map(t => t.trim()) .join(' '); } function htmlText(html) { return bodyText(new DOMParser().parseFromString(html, 'text/html').body.childNodes); } function toHex(value) { if (!value) return ''; if (/^[0-9a-f]+$/i.test(value) && value.length % 2 === 0) return value.toLowerCase(); try { return Array.from(atob(value), c => c.charCodeAt(0).toString(16).padStart(2, '0')).join(''); } catch (err) { return ''; } } function apiFiles(post) { return [post].concat(post.extra_files || []) .filter(f => f.filename != null || f.md5 || f.pdq) .map(f => ({ name: f.filename != null ? f.filename + (f.ext || '') : '', hashes: [toHex(f.pdq), toHex(f.md5)].filter(Boolean), })); } /* ---------- DOM helpers ---------- */ function el(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; if (text != null) node.textContent = text; return node; } function action(name, text) { const a = el('a', null, text); a.href = '#'; a.dataset.sjcf = name; return a; } /* ---------- catalog ---------- */ function initCatalog(grid) { const apiData = new Map(); const domCache = new WeakMap(); let apiFailed = false; function tileKey(mix) { let id = mix.getAttribute('data-id'); if (!id) { const link = mix.querySelector('a[href*="/res/"], a[href*="/thread/"]'); const m = link && link.getAttribute('href').match(/\/(?:res|thread)\/(\d+)/); id = m && m[1]; } if (!id) return null; const img = mix.querySelector('img.thread-image'); const first = img && img.classList[0]; const board = first && first !== 'thread-image' ? first : pageBoard; return { board, id: String(id) }; } function domData(mix) { let d = domCache.get(mix); if (d) return d; const img = mix.querySelector('img.thread-image'); const replies = mix.querySelector('.replies'); let com = ''; let sub = img ? img.getAttribute('data-subject') || '' : ''; if (replies) { const otherNodes = Array.from(replies.childNodes); let start = 0; for (let seen = 0; start < otherNodes.length && seen < 2; start++) { if (otherNodes[start].nodeType === 1) seen++; } com = bodyText(otherNodes.slice(start)); if (!sub) { const s = replies.querySelector('.subject'); sub = s ? s.textContent.trim() : ''; } } d = { name: img ? (img.getAttribute('data-name') || '').trim() : null, trip: '', sub, com, flag: '', uid: '', files: [], }; domCache.set(mix, d); return d; } function apiPostData(t) { return { name: t.name != null ? String(t.name).trim() : null, trip: t.trip || '', sub: t.sub || '', com: htmlText(t.com || ''), flag: t.country_name || '', uid: t.id != null ? String(t.id) : '', files: apiFiles(t), }; } async function loadApi() { const url = location.pathname.replace(/catalog\.html$/, 'catalog.json'); try { const res = await fetch(url, { credentials: 'same-origin', cache: 'no-cache' }); if (!res.ok) throw new Error('HTTP ' + res.status); for (const pg of await res.json()) { for (const t of pg.threads || []) { apiData.set((t.board || pageBoard) + '/' + t.no, apiPostData(t)); } } } catch (err) { apiFailed = true; console.warn('[Catalog Filter] could not load ' + url + ':', err); } refresh(); } function statusOf(mix, list, compiled) { const key = tileKey(mix); if (!key) return null; const d = apiData.get(key.board + '/' + key.id) || domData(mix); const entries = (list.postFilter[key.board] || {})[key.id] || []; const manual = entries.some(e => e && e.post != null && String(e.post) === key.id); const reasons = []; if (manual) reasons.push('Hidden by you'); if (d.uid && entries.some(e => e && e.uid != null && String(e.uid) === d.uid)) { reasons.push('Poster ID: ' + d.uid); } for (const c of compiled) { if (matches(c, d)) reasons.push(describe(c.rule)); } return { manual, reasons }; } function render(mix, st) { const hidden = !!st && st.reasons.length > 0; const host = mix.querySelector('.thread') || mix; let label = host.querySelector(':scope > .sjcf-label'); mix.classList.toggle('sjcf-hidden', hidden); if (!hidden) { if (label) label.remove(); return; } if (!label) { label = el('div', 'sjcf-label'); host.prepend(label); } label.title = st.manual ? 'Shift+click to unhide' : 'Matches your filters (Options → Filters on a board or thread page)'; label.replaceChildren( el('span', 'sjcf-tag', 'Hidden'), el('span', 'sjcf-why', st.reasons.join(' · ')) ); if (st.manual) label.append(action('unhide', 'unhide')); } function flash(mix) { mix.classList.remove('sjcf-flash'); void mix.offsetWidth; mix.classList.add('sjcf-flash'); setTimeout(() => mix.classList.remove('sjcf-flash'), 700); } function setManual(mix, hide) { const key = tileKey(mix); if (key && writeThreadHide(key.board, key.id, hide)) refresh(); } const showButton = el('button'); showButton.type = 'button'; showButton.id = 'sjcf-show'; showButton.addEventListener('click', () => setRevealed(!revealed)); const sizeSelect = document.getElementById('image_size'); if (sizeSelect) sizeSelect.after(showButton); else grid.before(showButton); function refresh() { let list; try { list = readList(); } catch (err) { console.warn('[Catalog Filter] localStorage.postFilter is not valid JSON:', err); return; } const compiled = compileRules(list); let hidden = 0; for (const mix of grid.querySelectorAll('.mix')) { const st = statusOf(mix, list, compiled); render(mix, st); if (st && st.reasons.length) hidden++; } showButton.textContent = (revealed ? 'Hide again' : 'Show anyways') + ' (' + hidden + ')'; showButton.disabled = !revealed && hidden === 0; showButton.hidden = !settings.catalogButton; showButton.title = apiFailed ? "catalog.json didn't load, so tripcode, flag, poster ID, filename and file hash filters can't be checked." : ''; } refreshPage = refresh; window.addEventListener('click', e => { const target = e.target instanceof Element ? e.target : null; if (!target) return; const unhide = target.closest('.sjcf-label [data-sjcf="unhide"]'); if (unhide && !e.shiftKey) { e.preventDefault(); e.stopPropagation(); setManual(unhide.closest('.mix'), false); return; } if (!settings.shiftClick) return; if (!e.shiftKey || e.button !== 0 || e.ctrlKey || e.metaKey || e.altKey) return; const mix = target.closest('.mix'); if (!mix || !grid.contains(mix)) return; e.preventDefault(); e.stopImmediatePropagation(); let st; try { const list = readList(); st = statusOf(mix, list, compileRules(list)); } catch (err) { alert('Catalog Filter: localStorage.postFilter could not be read, so nothing was changed.'); return; } if (!st) return; if (st.manual) setManual(mix, false); else if (st.reasons.length) flash(mix); else setManual(mix, true); }, true); window.addEventListener('mousedown', e => { if (settings.shiftClick && e.shiftKey && e.button === 0 && e.target instanceof Element && e.target.closest('#Grid .mix')) { e.preventDefault(); } }, true); new MutationObserver(records => { let dirty = false; for (const r of records) { for (const node of r.removedNodes) { if (node.nodeType !== 1 || node.isConnected || !node.classList.contains('sjcf-hidden')) continue; const next = r.nextSibling && r.nextSibling.parentNode === r.target ? r.nextSibling : null; r.target.insertBefore(node, next); dirty = true; } for (const node of r.addedNodes) { if (node.nodeType === 1 && node.classList.contains('mix')) dirty = true; } } if (dirty) scheduleRefresh(); }).observe(grid, { childList: true }); refresh(); loadApi(); } /* ---------- threads ---------- */ function findThread() { const m = location.pathname.match(/\/(\d+)[^/]*\.html$/); return (m && document.getElementById('thread_' + m[1])) || document.querySelector('form[name="postcontrols"] .thread'); } function nameText(node) { let s = ''; for (const n of node.childNodes) { if (n.nodeName === 'IMG') s += n.getAttribute('alt') || ''; else if (n.nodeType === 3) s += n.nodeValue; } return s.trim(); } function initThread(thread) { const tid = thread.id.replace(/^thread_/, ''); const board = thread.getAttribute('data-board') || pageBoard; const cache = new WeakMap(); const jsonFiles = new Map(); const bar = el('div', 'sjcf-thread-bar'); const button = el('button'); button.type = 'button'; button.addEventListener('click', () => setRevealed(!revealed)); bar.append(button); function posts() { return Array.from(thread.children).filter(c => c.classList.contains('post')); } function opParts() { return thread.querySelectorAll(':scope > .files, :scope > .video-container'); } function postData(post) { let d = cache.get(post); if (!d) { const isOp = post.classList.contains('op'); const intro = post.querySelector(':scope > .intro') || post; const text = sel => { const node = intro.querySelector(sel); return node ? node.textContent : ''; }; const nameEl = intro.querySelector('.name'); const flagEl = intro.querySelector('.flag'); const body = post.querySelector(':scope > .body'); const infos = (isOp ? thread : post).querySelectorAll(':scope > .files .fileinfo'); d = { id: post.id.replace(/^(op|reply)_/, ''), name: nameEl ? nameText(nameEl) : '', trip: text('.trip'), sub: text('.subject'), com: body ? bodyText(body.childNodes) : '', flag: flagEl ? flagEl.getAttribute('title') || '' : '', uid: text('.poster_id'), files: Array.from(infos, info => { const link = info.querySelector('.filename-download-link'); const hash = info.querySelector('.filehash'); return { name: link ? link.getAttribute('download') || '' : '', hashes: hash && hash.textContent.trim() ? [hash.textContent.trim()] : [], }; }), quotes: body ? Array.from(body.querySelectorAll('a:not([rel="nofollow"])'), a => (a.textContent.match(/^>>(\d+)$/) || [])[1]).filter(Boolean) : [], }; cache.set(post, d); } const extra = jsonFiles.get(d.id); return extra ? Object.assign({}, d, { files: d.files.concat(extra) }) : d; } async function loadApi() { const url = location.pathname.replace(/[^/]+$/, tid + '.json'); try { const res = await fetch(url, { credentials: 'same-origin', cache: 'no-cache' }); if (!res.ok) throw new Error('HTTP ' + res.status); for (const p of (await res.json()).posts || []) { const files = apiFiles(p); if (files.length) jsonFiles.set(String(p.no), files); } } catch (err) { console.warn('[Catalog Filter] could not load ' + url + ':', err); } refresh(); } function statusFor(all, list, compiled) { const entries = (list.postFilter[board] || {})[tid] || []; const byHand = new Set(); const noReplies = new Set(); for (const e of entries) { if (!e || e.post == null) continue; byHand.add(String(e.post)); if (e.hideReplies) noReplies.add(String(e.post)); } const uidEntries = entries.filter(e => e && e.uid != null); const status = new Map(); for (const post of all) { const d = postData(post); const reasons = []; if (byHand.has(d.id)) reasons.push('Hidden by you'); const uid = d.uid && uidEntries.find(e => String(e.uid) === d.uid); if (uid) { reasons.push('Poster ID: ' + d.uid); if (uid.hideReplies) noReplies.add(d.id); } for (const c of compiled) { if (matches(c, d)) reasons.push(describe(c.rule)); } status.set(post, reasons); } for (const post of all) { const reasons = status.get(post); const quoted = postData(post).quotes.find(n => noReplies.has(n)); if (quoted) reasons.push('Reply to hidden post >>' + quoted); if (!reasons.length && nativeHidden(post)) reasons.push('Hidden by the site'); } return status; } function nativeHidden(node) { try { return !!(jq && jq(node).data('hidden')); } catch (err) { return false; } } function showInline(node) { if (node && node.style.display === 'none') node.style.display = ''; } function undoNativeHide(post) { showInline(post); for (const child of post.children) { if (child.matches('.body, .files, .video-container')) showInline(child); } const next = post.nextElementSibling; if (next && next.tagName === 'BR') showInline(next); } function renderTag(post, reasons) { const intro = post.querySelector(':scope > .intro'); let tag = intro && intro.querySelector(':scope > .sjcf-post-tag'); if (!reasons.length || !intro) { if (tag) tag.remove(); return; } const why = reasons.join(' · '); if (!tag) { tag = el('span', 'sjcf-post-tag'); tag.append(el('b', null, 'Hidden'), el('span')); intro.append(tag); } if (tag.lastChild.textContent !== why) tag.lastChild.textContent = why; } function placeBar(all) { const last = all[all.length - 1]; if (!last) return; let next = last.nextElementSibling; if (next === bar) next = bar.nextElementSibling; const anchor = next && next.tagName === 'BR' ? next : last; if (anchor.nextElementSibling !== bar) anchor.after(bar); } function refresh() { let list; try { list = readList(); } catch (err) { console.warn('[Catalog Filter] localStorage.postFilter is not valid JSON:', err); return; } const all = posts(); const status = statusFor(all, list, compileRules(list)); const op = all.find(p => p.classList.contains('op')); const opHiddenBySite = !!op && nativeHidden(op); if (opHiddenBySite) { showInline(thread); opParts().forEach(showInline); } placeBar(all); let hidden = 0; for (const post of all) { const reasons = status.get(post); const isHidden = reasons.length > 0; const isOp = post === op; if (opHiddenBySite || nativeHidden(post) || (!jq && isHidden)) undoNativeHide(post); post.classList.toggle('sjcf-post-hidden', isHidden && !isOp); post.classList.toggle('sjcf-op-hidden', isHidden && isOp); const next = post.nextElementSibling; if (next && next.tagName === 'BR') next.classList.toggle('sjcf-br-hidden', isHidden && !isOp); if (isOp) opParts().forEach(part => part.classList.toggle('sjcf-op-files', isHidden)); renderTag(post, reasons); if (isHidden) hidden++; } button.textContent = (revealed ? 'Hide again' : 'Show Me Anyways') + ' (' + hidden + ')'; bar.hidden = !settings.threadButton || (hidden === 0 && !revealed); } refreshPage = refresh; const managed = node => node === thread || node.parentElement === thread || (!!node.parentElement && node.parentElement.parentElement === thread); new MutationObserver(records => { for (const r of records) { const changed = r.type === 'childList' ? r.target === thread && [...r.addedNodes, ...r.removedNodes].some(n => n.nodeType === 1 && n.classList.contains('post')) : r.target.style.display === 'none' && managed(r.target); if (changed) { scheduleRefresh(); return; } } }).observe(thread, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] }); if (jq) { jq(document).on('new_post', (e, post) => { const node = post && post.jquery ? post[0] : post; if (node) cache.delete(node); scheduleRefresh(); }); } refresh(); loadApi(); } /* ---------- Options -> Hiding ---------- */ const OPTION_GROUPS = [ ['Buttons', [ ['catalogButton', 'Show the "Show anyways" button in the catalog'], ['threadButton', 'Show the "Show Me Anyways" button at the bottom of threads'], ]], ['Hidden OPs', [ ['blurOp', 'Show hidden OPs blurred instead of hiding them (catalog and threads)'], ['unblurOnHover', 'Unblur them while the mouse is over them', true], ]], ['Catalog', [ ['shiftClick', 'Shift+click a thread to hide or unhide it'], ]], ]; let optionsRoot = null; function syncOptions() { if (!optionsRoot) return; for (const box of optionsRoot.querySelectorAll('input[data-key]')) { box.checked = !!settings[box.dataset.key]; } optionsRoot.querySelector('[data-key="unblurOnHover"]').disabled = !settings.blurOp; } function initOptions() { const Options = window.Options; if (!Options || typeof Options.add_tab !== 'function' || typeof Options.get_tab !== 'function') return; if (Options.get_tab('sjcf')) return; const tab = Options.add_tab('sjcf', 'eye-slash', 'Hiding', '
'); optionsRoot = tab.content[0].querySelector('#sjcf-options'); for (const [legend, rows] of OPTION_GROUPS) { const set = el('fieldset'); set.append(el('legend', null, legend)); for (const [key, text, sub] of rows) { const label = el('label', sub ? 'sjcf-sub' : null); const box = el('input'); box.type = 'checkbox'; box.dataset.key = key; label.append(box, ' ' + text); set.append(label); } optionsRoot.append(set); } optionsRoot.append(el('p', 'sjcf-note', 'Filter rules are added and removed in the Filters tab on board and thread pages.')); optionsRoot.addEventListener('change', e => { const key = e.target.dataset && e.target.dataset.key; if (!key) return; settings[key] = e.target.checked; saveSettings(); syncOptions(); applySettings(); }); syncOptions(); } /* ---------- styles ---------- */ const style = el('style', null, ` /* catalog */ body:not(.sjcf-reveal):not(.sjcf-blur) .mix.sjcf-hidden { display: none !important; } .sjcf-label { display: none; } body.sjcf-reveal .mix.sjcf-hidden .sjcf-label, body.sjcf-blur .mix.sjcf-hidden .sjcf-label { display: flex; flex-wrap: wrap; justify-content: center; gap: 0 6px; margin-bottom: 4px; padding: 2px 4px; border: 1px dashed currentColor; border-radius: 3px; font-size: 11px; line-height: 1.35; } body.sjcf-reveal .mix.sjcf-hidden .thread > :not(.sjcf-label) { opacity: 0.6; } body.sjcf-blur:not(.sjcf-reveal) .mix.sjcf-hidden .thread > a img, body.sjcf-blur:not(.sjcf-reveal) .mix.sjcf-hidden .thread > .replies { filter: blur(8px); } body.sjcf-hover-unblur:not(.sjcf-reveal) .mix.sjcf-hidden .thread:hover > a img, body.sjcf-hover-unblur:not(.sjcf-reveal) .mix.sjcf-hidden .thread:hover > .replies { filter: none; } .sjcf-label .sjcf-tag { font-weight: bold; letter-spacing: 0.08em; text-transform: uppercase; } .sjcf-label .sjcf-why { opacity: 0.85; overflow-wrap: anywhere; } .mix.sjcf-flash .sjcf-label { outline: 2px solid currentColor; } #sjcf-show { margin-left: 8px; } #sjcf-show[hidden] { display: none !important; } /* threads */ body:not(.sjcf-reveal) .post.sjcf-post-hidden, body:not(.sjcf-reveal) br.sjcf-br-hidden, body:not(.sjcf-reveal):not(.sjcf-blur) .post.sjcf-op-hidden, body:not(.sjcf-reveal):not(.sjcf-blur) .sjcf-op-files { display: none !important; } body.sjcf-blur:not(.sjcf-reveal) .sjcf-op-files, body.sjcf-blur:not(.sjcf-reveal) .post.sjcf-op-hidden > .body { filter: blur(10px); } body.sjcf-hover-unblur:not(.sjcf-reveal) .sjcf-op-files:hover, body.sjcf-hover-unblur:not(.sjcf-reveal) .post.sjcf-op-hidden > .body:hover { filter: none; } body.sjcf-reveal .post.sjcf-post-hidden, body.sjcf-reveal .post.sjcf-op-hidden, body.sjcf-reveal .sjcf-op-files { opacity: 0.6; } .sjcf-post-tag { display: none; margin-left: 6px; padding: 0 4px; border: 1px dashed currentColor; border-radius: 3px; font-size: 11px; } body.sjcf-reveal .sjcf-post-tag, body.sjcf-blur .post.sjcf-op-hidden .sjcf-post-tag { display: inline-block; } .sjcf-post-tag b { letter-spacing: 0.08em; text-transform: uppercase; } .sjcf-post-tag span { margin-left: 4px; opacity: 0.85; } .sjcf-thread-bar { clear: both; text-align: left; margin: 8px 0 4px; } .sjcf-thread-bar[hidden] { display: none !important; } /* options */ #sjcf-options fieldset { margin: 0 0 10px; } #sjcf-options label { display: block; margin: 3px 0; cursor: pointer; } #sjcf-options label.sjcf-sub { margin-left: 22px; } #sjcf-options label.sjcf-sub:has(input:disabled) { opacity: 0.5; cursor: default; } #sjcf-options .sjcf-note { font-size: 11px; opacity: 0.8; } `); document.head.append(style); /* ---------- start ---------- */ window.addEventListener('storage', e => { if (e.key === SETTINGS_KEY || e.key === null) { settings = loadSettings(); syncOptions(); applySettings(); } if (e.key === STORAGE_KEY || e.key === null) scheduleRefresh(); }); applySettings(); if (page === 'catalog') { const grid = document.getElementById('Grid'); if (grid) initCatalog(grid); } else if (page === 'thread') { const thread = findThread(); if (thread) initThread(thread); } initOptions(); })();