Filter for NOS.nl
// ==UserScript==
// @name NOS Filter
// @name:nl NOS Filter
// @namespace https://github.com/CasperAtUU/nos-filter
// @version 5.0.0
// @description Filter for NOS.nl
// @description:nl Filter NOS.nl op categorie en trefwoord, met blur-modus
// @author Claude
// @match https://nos.nl/*
// @match https://www.nos.nl/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ─────────────────────────────────────────────
// STANDAARD CATEGORIEËN
// ─────────────────────────────────────────────
const BASE_CATEGORIES = [
{ slug: 'sport', label: 'Sport (alles)', icon: '🏅', group: 'sport' },
{ slug: 'wielrennen', label: 'Wielrennen', icon: '🚴', group: 'sport' },
{ slug: 'voetbal', label: 'Voetbal', icon: '⚽', group: 'sport' },
{ slug: 'tennis', label: 'Tennis', icon: '🎾', group: 'sport' },
{ slug: 'schaatsen', label: 'Schaatsen', icon: '⛸️', group: 'sport' },
{ slug: 'formule-1', label: 'Formule 1', icon: '🏎️', group: 'sport' },
{ slug: 'atletiek', label: 'Atletiek', icon: '🏃', group: 'sport' },
{ slug: 'hockey', label: 'Hockey', icon: '🏑', group: 'sport' },
{ slug: 'golf', label: 'Golf', icon: '⛳', group: 'sport' },
{ slug: 'zwemmen', label: 'Zwemmen', icon: '🏊', group: 'sport' },
{ slug: 'baanwielrennen', label: 'Baanwielrennen', icon: '🚴', group: 'sport' },
{ slug: 'honkbal', label: 'Honkbal', icon: '⚾', group: 'sport' },
{ slug: 'binnenland', label: 'Binnenland', icon: '🏠', group: 'nieuws' },
{ slug: 'buitenland', label: 'Buitenland', icon: '🌍', group: 'nieuws' },
{ slug: 'politiek', label: 'Politiek', icon: '🏛️', group: 'nieuws' },
{ slug: 'economie', label: 'Economie', icon: '📈', group: 'nieuws' },
{ slug: 'koningshuis', label: 'Koningshuis', icon: '👑', group: 'nieuws' },
{ slug: 'tech', label: 'Tech', icon: '💻', group: 'nieuws' },
{ slug: 'cultuur-en-media', label: 'Cultuur & Media', icon: '🎭', group: 'nieuws' },
{ slug: 'opmerkelijk', label: 'Opmerkelijk', icon: '😲', group: 'nieuws' },
{ slug: 'gezondheid', label: 'Gezondheid', icon: '🏥', group: 'nieuws' },
{ slug: 'wetenschap', label: 'Wetenschap', icon: '🔬', group: 'nieuws' },
{ slug: 'regio', label: 'Regionaal nieuws', icon: '📍', group: 'nieuws' },
];
const SPORT_SLUGS = new Set([
'sport','wielrennen','voetbal','tennis','schaatsen','formule-1',
'atletiek','hockey','golf','zwemmen','baanwielrennen','honkbal',
]);
// ─────────────────────────────────────────────
// STATE — persistent via GM_*
// ─────────────────────────────────────────────
let blockedSlugs = new Set(JSON.parse(GM_getValue('blockedSlugs', '[]')));
let blockedWords = JSON.parse(GM_getValue('blockedWords', '[]')); // array of strings
let blurMode = GM_getValue('blurMode', false);
let extraCats = JSON.parse(GM_getValue('extraCats', '[]')); // [{slug,label,icon,group}]
let activeTab = 'cats'; // 'cats' | 'words'
let panelOpen = false;
let searchQuery = '';
function save() {
GM_setValue('blockedSlugs', JSON.stringify([...blockedSlugs]));
GM_setValue('blockedWords', JSON.stringify(blockedWords));
GM_setValue('blurMode', blurMode);
GM_setValue('extraCats', JSON.stringify(extraCats));
}
function allCategories() {
const knownSlugs = new Set(BASE_CATEGORIES.map(c => c.slug));
const extras = extraCats.filter(c => !knownSlugs.has(c.slug));
return [...BASE_CATEGORIES, ...extras];
}
// ─────────────────────────────────────────────
// PARSE __NEXT_DATA__
// ─────────────────────────────────────────────
const articleCategoryMap = new Map();
const labelToSlug = {};
BASE_CATEGORIES.forEach(c => {
labelToSlug[c.label.toLowerCase()] = c.slug;
labelToSlug[c.slug.toLowerCase()] = c.slug;
});
function buildCategoryMap() {
try {
const el = document.getElementById('__NEXT_DATA__');
if (!el) return;
const data = JSON.parse(el.textContent);
const lists = data?.props?.pageProps?.data?.lists || [];
const newExtras = [];
const knownSlugs = new Set(BASE_CATEGORIES.map(c => c.slug));
const existingExtras = new Set(extraCats.map(c => c.slug));
const processItem = (item) => {
const id = item.id;
const cats = (item.categories || []).map(c => c.id);
if (item.owner === 'SPORT' || item.owner === 'NOS_SPORT') cats.push('sport');
if (id && cats.length) articleCategoryMap.set(String(id), new Set(cats));
// Auto-detecteer onbekende categorieën
(item.categories || []).forEach(c => {
if (!knownSlugs.has(c.id) && !existingExtras.has(c.id)) {
const isSport = item.owner === 'SPORT' || item.owner === 'NOS_SPORT';
newExtras.push({ slug: c.id, label: c.label || c.id, icon: isSport ? '🏅' : '📰', group: isSport ? 'sport' : 'nieuws' });
existingExtras.add(c.id);
}
// Update labelToSlug for sidebar matching
if (c.label) labelToSlug[c.label.toLowerCase()] = c.id;
});
};
lists.forEach(list => (list.items || []).forEach(processItem));
(data?.props?.pageProps?.latestNews || []).forEach(processItem);
if (newExtras.length) {
extraCats = [...extraCats, ...newExtras];
save();
}
} catch (e) { /* silent */ }
}
// ─────────────────────────────────────────────
// CATEGORIE BEPALEN
// ─────────────────────────────────────────────
function extractIdFromHref(href) {
const m = href && href.match(/\/(?:artikel|video|liveblog|livestream|audio)\/(\d+)/);
return m ? m[1] : null;
}
function getCatsForElement(el) {
const cats = new Set();
const links = el.tagName === 'A' ? [el] : [...el.querySelectorAll('a[href]')];
for (const a of links) {
const id = extractIdFromHref(a.getAttribute('href'));
if (id && articleCategoryMap.has(id)) {
articleCategoryMap.get(id).forEach(c => cats.add(c));
break;
}
}
// Zijbalk: tekst na "•"
if (!cats.size) {
const metaEl = el.querySelector('[class*="LatestNewsItem_metadata"]');
if (metaEl) {
const txt = metaEl.textContent.split('•').pop().trim().toLowerCase();
if (labelToSlug[txt]) cats.add(labelToSlug[txt]);
}
}
// Live-agenda subtitle
if (!cats.size) {
const subEl = el.querySelector('[class*="CalenderTile_subTitle"]');
if (subEl) {
const txt = subEl.textContent.trim().toLowerCase();
if (labelToSlug[txt]) cats.add(labelToSlug[txt]);
}
}
return cats;
}
function getTitleForElement(el) {
const titleEl = el.querySelector('h1,h2,h3,[class*="title" i],[class*="Title" i]');
return (titleEl?.textContent || el.textContent || '').toLowerCase();
}
// ─────────────────────────────────────────────
// FILTER LOGICA
// ─────────────────────────────────────────────
function shouldBlockByCat(cats) {
for (const c of cats) {
if (blockedSlugs.has(c)) return true;
if (blockedSlugs.has('sport') && SPORT_SLUGS.has(c)) return true;
}
return false;
}
function shouldBlockByWord(el) {
if (!blockedWords.length) return false;
const title = getTitleForElement(el);
return blockedWords.some(w => w && title.includes(w.toLowerCase()));
}
const ITEM_SEL = [
'li[class*="Default_listItem"]',
'li[class*="List_listItem"]',
'li[class*="Spotlight_listItem"]',
'ul[class*="LiveSection_list"] > li',
'ul[class*="InDepth_list"] > li',
'ul[class*="LatestNewsTimeline_itemList"] > li',
'a[class*="CalenderTile_anchor"]',
].join(',');
let hiddenCount = 0;
function applyFilters() {
hiddenCount = 0;
// Hele Sport-sectie
document.querySelectorAll('section').forEach(section => {
const cls = section.className || '';
if (cls.includes('Sport_section') || cls.includes('themeSport')) {
const blockSport = blockedSlugs.has('sport') || [...SPORT_SLUGS].some(s => blockedSlugs.has(s));
applyEffect(section, blockSport);
if (blockSport) hiddenCount++;
}
});
// Individuele items
document.querySelectorAll(ITEM_SEL).forEach(el => {
const cats = getCatsForElement(el);
const block = (cats.size && shouldBlockByCat(cats)) || shouldBlockByWord(el);
applyEffect(el, block);
if (block) hiddenCount++;
});
updateBadge(hiddenCount);
if (panelOpen) updateStats();
}
function applyEffect(el, block) {
if (!block) {
el.style.removeProperty('display');
el.style.removeProperty('filter');
el.style.removeProperty('opacity');
el.style.removeProperty('cursor');
el.onclick = null;
el.removeAttribute('data-nf-blurred');
return;
}
if (blurMode) {
el.style.removeProperty('display');
if (!el.dataset.nfBlurred) {
el.style.setProperty('filter', 'blur(6px)', 'important');
el.style.setProperty('opacity', '0.4', 'important');
el.style.setProperty('cursor', 'pointer', 'important');
el.dataset.nfBlurred = '1';
el.onclick = (e) => {
e.preventDefault(); e.stopPropagation();
el.style.removeProperty('filter');
el.style.removeProperty('opacity');
el.style.removeProperty('cursor');
el.onclick = null;
el.removeAttribute('data-nf-blurred');
};
}
} else {
el.style.setProperty('display', 'none', 'important');
el.style.removeProperty('filter');
el.style.removeProperty('opacity');
el.removeAttribute('data-nf-blurred');
}
}
function updateBadge(n) {
const badge = document.querySelector('#nos-filter-trigger .nf-badge');
if (!badge) return;
badge.textContent = n;
badge.dataset.zero = n === 0 ? 'true' : 'false';
}
function updateStats() {
const el = document.getElementById('nos-filter-stats');
if (el) el.innerHTML = `<strong>${blockedSlugs.size}</strong> categorie${blockedSlugs.size !== 1 ? 'ën' : ''} · <strong>${blockedWords.length}</strong> trefwoord${blockedWords.length !== 1 ? 'en' : ''} · <strong>${hiddenCount}</strong> verborgen`;
}
// ─────────────────────────────────────────────
// STYLES
// ─────────────────────────────────────────────
GM_addStyle(`
#nos-filter-root * { box-sizing: border-box; margin: 0; padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }
#nos-filter-trigger {
all: initial !important; position: fixed !important; bottom: 28px !important;
right: 28px !important; z-index: 2147483647 !important; display: flex !important;
align-items: center !important; gap: 8px !important; padding: 11px 20px !important;
background: #111 !important; color: #fff !important;
border: 1.5px solid rgba(255,255,255,0.18) !important; border-radius: 999px !important;
cursor: pointer !important;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif !important;
font-size: 14px !important; font-weight: 600 !important;
box-shadow: 0 4px 20px rgba(0,0,0,0.5) !important;
transition: transform 0.15s ease, box-shadow 0.15s ease !important;
user-select: none !important;
}
#nos-filter-trigger:hover { background: #222 !important; transform: translateY(-2px) !important; box-shadow: 0 8px 28px rgba(0,0,0,0.6) !important; }
#nos-filter-trigger .nf-badge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 20px; height: 20px; padding: 0 6px; background: #e63232;
border-radius: 999px; font-size: 11px; font-weight: 700; color: #fff;
transition: transform 0.2s cubic-bezier(0.34,1.56,0.64,1), opacity 0.15s;
}
#nos-filter-trigger .nf-badge[data-zero="true"] { transform: scale(0); opacity: 0; }
#nos-filter-backdrop { position: fixed; inset: 0; z-index: 2147483644;
background: rgba(0,0,0,0); transition: background 0.25s; pointer-events: none; }
#nos-filter-backdrop.nf-open { background: rgba(0,0,0,0.45); pointer-events: all; }
#nos-filter-panel { position: fixed; top: 0; right: 0; bottom: 0; z-index: 2147483645;
width: 380px; max-width: 95vw; background: #0f0f0f;
border-left: 1px solid rgba(255,255,255,0.09); display: flex; flex-direction: column;
transform: translateX(100%); transition: transform 0.35s cubic-bezier(0.77,0,0.18,1);
overflow: hidden; box-shadow: -8px 0 48px rgba(0,0,0,0.5); }
#nos-filter-panel.nf-open { transform: translateX(0); }
/* Header */
.nf-header { padding: 18px 22px 14px; border-bottom: 1px solid rgba(255,255,255,0.07);
background: #161616; flex-shrink: 0; }
.nf-header-top { display: flex; align-items: flex-start;
justify-content: space-between; margin-bottom: 14px; }
.nf-title { font-size: 18px; font-weight: 800; color: #fff; letter-spacing: -0.02em; }
.nf-subtitle { font-size: 11px; color: rgba(255,255,255,0.35); margin-top: 2px; }
.nf-close { width: 30px; height: 30px; border-radius: 8px;
border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.05);
color: rgba(255,255,255,0.5); cursor: pointer; display: flex;
align-items: center; justify-content: center; font-size: 15px; flex-shrink: 0;
transition: background 0.15s, color 0.15s; }
.nf-close:hover { background: rgba(255,255,255,0.12); color: #fff; }
/* Blur toggle */
.nf-blur-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
padding: 9px 12px; background: rgba(255,255,255,0.04); border-radius: 10px;
border: 1px solid rgba(255,255,255,0.07); cursor: pointer; }
.nf-blur-row:hover { background: rgba(255,255,255,0.07); }
.nf-blur-label { flex: 1; font-size: 13px; font-weight: 500; color: rgba(255,255,255,0.7); }
.nf-blur-desc { font-size: 11px; color: rgba(255,255,255,0.3); margin-top: 1px; }
/* Tabs */
.nf-tabs { display: flex; gap: 0; }
.nf-tab { flex: 1; padding: 8px; border: none; background: rgba(255,255,255,0.05);
color: rgba(255,255,255,0.4); font-size: 13px; font-weight: 600; cursor: pointer;
border-bottom: 2px solid transparent; transition: all 0.15s; }
.nf-tab:first-child { border-radius: 8px 0 0 8px; }
.nf-tab:last-child { border-radius: 0 8px 8px 0; }
.nf-tab.active { background: rgba(230,50,50,0.12); color: #ff6b6b;
border-bottom-color: #e63232; }
.nf-tab:hover:not(.active) { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.7); }
/* Quick actions */
.nf-actions { display: flex; gap: 6px; padding: 10px 22px;
border-bottom: 1px solid rgba(255,255,255,0.06); flex-shrink: 0; flex-wrap: wrap; }
.nf-action-btn { flex: 1; min-width: 70px; padding: 6px 8px; border-radius: 8px;
border: 1px solid rgba(255,255,255,0.1); background: transparent;
color: rgba(255,255,255,0.5); font-size: 11.5px; font-weight: 500; cursor: pointer;
transition: all 0.15s; white-space: nowrap; }
.nf-action-btn:hover { background: rgba(255,255,255,0.08); color: #fff; }
.nf-action-btn.danger:hover { background: rgba(230,50,50,0.12); color: #ff6b6b;
border-color: rgba(230,50,50,0.3); }
/* Search */
.nf-search-wrap { position: relative; padding: 10px 22px;
border-bottom: 1px solid rgba(255,255,255,0.06); flex-shrink: 0; }
.nf-search-icon { position: absolute; left: 33px; top: 50%; transform: translateY(-50%);
font-size: 13px; color: rgba(255,255,255,0.3); pointer-events: none; }
.nf-search { width: 100%; padding: 8px 12px 8px 32px;
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.09);
border-radius: 8px; color: #fff; font-size: 13px; outline: none; }
.nf-search:focus { border-color: rgba(255,255,255,0.22); }
.nf-search::placeholder { color: rgba(255,255,255,0.22); }
/* Category list */
.nf-list { flex: 1; overflow-y: auto; padding: 4px 0;
scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.1) transparent; }
.nf-list::-webkit-scrollbar { width: 4px; }
.nf-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
.nf-group-label { padding: 10px 22px 3px; font-size: 10px; font-weight: 700;
letter-spacing: 0.1em; text-transform: uppercase; color: rgba(255,255,255,0.18); }
.nf-cat-row { display: flex; align-items: center; gap: 12px; padding: 9px 22px;
cursor: pointer; transition: background 0.1s; }
.nf-cat-row:hover { background: rgba(255,255,255,0.04); }
.nf-cat-row[data-blocked="true"] { background: rgba(230,50,50,0.04); }
.nf-cat-row[data-blocked="true"]:hover { background: rgba(230,50,50,0.08); }
.nf-cat-icon { font-size: 14px; width: 20px; text-align: center; flex-shrink: 0; }
.nf-cat-name { flex: 1; font-size: 13px; font-weight: 500; color: rgba(255,255,255,0.85); }
.nf-cat-row[data-blocked="true"] .nf-cat-name { color: rgba(255,255,255,0.3); }
.nf-cat-badge { font-size: 10px; padding: 1px 6px; border-radius: 4px;
background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.3); margin-right: 4px; }
/* Toggle */
.nf-toggle { position: relative; width: 38px; height: 21px; flex-shrink: 0; cursor: pointer; }
.nf-toggle input { opacity: 0; width: 0; height: 0; position: absolute; }
.nf-toggle-track { position: absolute; inset: 0; border-radius: 999px;
background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.08);
transition: background 0.22s; }
.nf-toggle input:checked + .nf-toggle-track { background: #e63232; border-color: #e63232; }
.nf-toggle-thumb { position: absolute; top: 3px; left: 3px; width: 13px; height: 13px;
border-radius: 50%; background: rgba(255,255,255,0.4);
transition: transform 0.22s cubic-bezier(0.34,1.56,0.64,1), background 0.22s;
pointer-events: none; }
.nf-toggle input:checked ~ .nf-toggle-thumb { transform: translateX(17px); background: #fff; }
/* Word filter tab */
.nf-words-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.nf-word-input-row { display: flex; gap: 8px; padding: 12px 22px;
border-bottom: 1px solid rgba(255,255,255,0.06); flex-shrink: 0; }
.nf-word-input { flex: 1; padding: 8px 12px; background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.09); border-radius: 8px;
color: #fff; font-size: 13px; outline: none; }
.nf-word-input:focus { border-color: rgba(255,255,255,0.22); }
.nf-word-input::placeholder { color: rgba(255,255,255,0.22); }
.nf-word-add { padding: 8px 14px; border-radius: 8px; border: none;
background: #e63232; color: #fff; font-size: 13px; font-weight: 600;
cursor: pointer; flex-shrink: 0; transition: background 0.15s; }
.nf-word-add:hover { background: #c02020; }
.nf-word-list { flex: 1; overflow-y: auto; padding: 8px 22px;
scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.1) transparent; }
.nf-word-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px;
background: rgba(255,255,255,0.05); border-radius: 8px; margin-bottom: 6px;
border: 1px solid rgba(255,255,255,0.08); }
.nf-word-text { flex: 1; font-size: 13px; color: rgba(255,255,255,0.8);
font-family: monospace; }
.nf-word-del { width: 22px; height: 22px; border-radius: 50%; border: none;
background: rgba(230,50,50,0.2); color: #ff6b6b; cursor: pointer; font-size: 12px;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s; flex-shrink: 0; }
.nf-word-del:hover { background: rgba(230,50,50,0.4); }
.nf-word-empty { padding: 32px 0; text-align: center; color: rgba(255,255,255,0.2);
font-size: 13px; }
.nf-word-hint { padding: 10px 22px; font-size: 11px; color: rgba(255,255,255,0.2);
border-top: 1px solid rgba(255,255,255,0.06); flex-shrink: 0; }
/* Footer */
.nf-footer { padding: 10px 22px; border-top: 1px solid rgba(255,255,255,0.07);
background: #161616; flex-shrink: 0; }
.nf-stats { font-size: 11px; color: rgba(255,255,255,0.25); text-align: center; }
.nf-stats strong { color: rgba(255,255,255,0.55); }
`);
// ─────────────────────────────────────────────
// UI BOUWEN
// ─────────────────────────────────────────────
function buildUI() {
document.getElementById('nos-filter-root')?.remove();
const root = document.createElement('div');
root.id = 'nos-filter-root';
// Backdrop
const backdrop = document.createElement('div');
backdrop.id = 'nos-filter-backdrop';
backdrop.addEventListener('click', closePanel);
// Trigger
const trigger = document.createElement('button');
trigger.id = 'nos-filter-trigger';
trigger.innerHTML = '<span>🚫 NOS Filter</span><span class="nf-badge" data-zero="true">0</span>';
trigger.addEventListener('click', togglePanel);
// Panel
const panel = document.createElement('div');
panel.id = 'nos-filter-panel';
panel.innerHTML = `
<div class="nf-header">
<div class="nf-header-top">
<div>
<div class="nf-title">🚫 NOS Filter</div>
<div class="nf-subtitle">Verberg categorieën en trefwoorden · Alt+F</div>
</div>
<button class="nf-close" id="nf-close">✕</button>
</div>
<div class="nf-blur-row" id="nf-blur-toggle">
<span style="font-size:16px">👁️</span>
<div>
<div class="nf-blur-label">Blur-modus</div>
<div class="nf-blur-desc">Artikelen wazig tonen · klik om te onthullen</div>
</div>
<label class="nf-toggle" onclick="event.stopPropagation()">
<input type="checkbox" id="nf-blur-check" ${blurMode ? 'checked' : ''} />
<div class="nf-toggle-track"></div>
<div class="nf-toggle-thumb"></div>
</label>
</div>
<div class="nf-tabs">
<button class="nf-tab ${activeTab==='cats'?'active':''}" id="nf-tab-cats">📂 Categorieën</button>
<button class="nf-tab ${activeTab==='words'?'active':''}" id="nf-tab-words">🔤 Trefwoorden</button>
</div>
</div>
<div id="nf-cats-panel">
<div class="nf-actions">
<button class="nf-action-btn" id="nf-enable-all">✅ Alles tonen</button>
<button class="nf-action-btn danger" id="nf-block-sport">⚽ Sport weg</button>
<button class="nf-action-btn danger" id="nf-block-all">🚫 Alles weg</button>
</div>
<div class="nf-search-wrap">
<span class="nf-search-icon">🔍</span>
<input class="nf-search" id="nf-search" type="text" placeholder="Zoek categorie…" autocomplete="off" />
</div>
<div class="nf-list" id="nf-cat-list"></div>
</div>
<div class="nf-words-panel" id="nf-words-panel" style="display:none">
<div class="nf-word-input-row">
<input class="nf-word-input" id="nf-word-input" type="text" placeholder="Bijv. Trump, bitcoin, koningshuis…" autocomplete="off" />
<button class="nf-word-add" id="nf-word-add">+ Voeg toe</button>
</div>
<div class="nf-word-list" id="nf-word-list"></div>
<div class="nf-word-hint">Artikelen waarvan de titel dit woord bevat worden gefilterd (hoofdletterongevoelig)</div>
</div>
<div class="nf-footer"><div class="nf-stats" id="nos-filter-stats">…</div></div>
`;
root.appendChild(backdrop);
root.appendChild(trigger);
root.appendChild(panel);
document.body.appendChild(root);
// Events
document.getElementById('nf-close').addEventListener('click', closePanel);
document.getElementById('nf-blur-toggle').addEventListener('click', () => {
document.getElementById('nf-blur-check').click();
});
document.getElementById('nf-blur-check').addEventListener('change', e => {
blurMode = e.target.checked;
save();
// Reset all blur states first
document.querySelectorAll('[data-nf-blurred]').forEach(el => {
el.style.removeProperty('filter');
el.style.removeProperty('opacity');
el.style.removeProperty('cursor');
el.onclick = null;
el.removeAttribute('data-nf-blurred');
});
applyFilters();
});
document.getElementById('nf-tab-cats').addEventListener('click', () => switchTab('cats'));
document.getElementById('nf-tab-words').addEventListener('click', () => switchTab('words'));
document.getElementById('nf-enable-all').addEventListener('click', () => {
blockedSlugs.clear(); save(); applyFilters(); renderCats();
});
document.getElementById('nf-block-sport').addEventListener('click', () => {
blockedSlugs.add('sport'); save(); applyFilters(); renderCats();
});
document.getElementById('nf-block-all').addEventListener('click', () => {
allCategories().forEach(c => blockedSlugs.add(c.slug)); save(); applyFilters(); renderCats();
});
document.getElementById('nf-search').addEventListener('input', e => {
searchQuery = e.target.value.toLowerCase().trim(); renderCats();
});
const wordInput = document.getElementById('nf-word-input');
document.getElementById('nf-word-add').addEventListener('click', addWord);
wordInput.addEventListener('keydown', e => { if (e.key === 'Enter') addWord(); });
}
function switchTab(tab) {
activeTab = tab;
document.getElementById('nf-tab-cats').classList.toggle('active', tab === 'cats');
document.getElementById('nf-tab-words').classList.toggle('active', tab === 'words');
document.getElementById('nf-cats-panel').style.display = tab === 'cats' ? '' : 'none';
document.getElementById('nf-words-panel').style.display = tab === 'words' ? '' : 'none';
if (tab === 'cats') renderCats();
else renderWords();
}
function openPanel() { panelOpen = true; document.getElementById('nos-filter-panel')?.classList.add('nf-open'); document.getElementById('nos-filter-backdrop')?.classList.add('nf-open'); renderCats(); updateStats(); }
function closePanel() { panelOpen = false; document.getElementById('nos-filter-panel')?.classList.remove('nf-open'); document.getElementById('nos-filter-backdrop')?.classList.remove('nf-open'); }
function togglePanel() { panelOpen ? closePanel() : openPanel(); }
// ─────────────────────────────────────────────
// CATEGORIE LIJST
// ─────────────────────────────────────────────
function renderCats() {
const list = document.getElementById('nf-cat-list');
if (!list) return;
const cats = allCategories();
const filtered = searchQuery ? cats.filter(c =>
c.label.toLowerCase().includes(searchQuery) || c.slug.includes(searchQuery)
) : cats;
const sport = filtered.filter(c => c.group === 'sport');
const nieuws = filtered.filter(c => c.group === 'nieuws');
const extra = filtered.filter(c => c.group !== 'sport' && c.group !== 'nieuws');
let html = '';
if (sport.length) {
if (!searchQuery) html += '<div class="nf-group-label">Sport</div>';
sport.forEach(c => { html += catRow(c); });
}
if (nieuws.length) {
if (!searchQuery) html += '<div class="nf-group-label">Nieuws</div>';
nieuws.forEach(c => { html += catRow(c); });
}
if (extra.length) {
html += '<div class="nf-group-label">Automatisch ontdekt</div>';
extra.forEach(c => { html += catRow(c, true); });
}
if (!html) html = '<div style="padding:24px;text-align:center;color:rgba(255,255,255,0.2)">Geen resultaten</div>';
list.innerHTML = html;
list.querySelectorAll('.nf-toggle input').forEach(input => {
input.addEventListener('change', e => {
const slug = e.target.dataset.slug;
blockedSlugs[e.target.checked ? 'add' : 'delete'](slug);
save(); applyFilters();
const row = list.querySelector(`.nf-cat-row[data-slug="${slug}"]`);
if (row) row.dataset.blocked = blockedSlugs.has(slug) ? 'true' : 'false';
updateStats();
});
});
updateStats();
}
function catRow(c, isExtra = false) {
const blocked = blockedSlugs.has(c.slug);
return `<div class="nf-cat-row" data-slug="${c.slug}" data-blocked="${blocked}">
<span class="nf-cat-icon">${c.icon}</span>
<div class="nf-cat-name">${c.label}${isExtra ? ' <span class="nf-cat-badge">nieuw</span>' : ''}</div>
<label class="nf-toggle" onclick="event.stopPropagation()">
<input type="checkbox" data-slug="${c.slug}" ${blocked ? 'checked' : ''} />
<div class="nf-toggle-track"></div>
<div class="nf-toggle-thumb"></div>
</label>
</div>`;
}
// ─────────────────────────────────────────────
// WOORDFILTER
// ─────────────────────────────────────────────
function addWord() {
const input = document.getElementById('nf-word-input');
const word = input.value.trim().toLowerCase();
if (!word || blockedWords.includes(word)) { input.value = ''; return; }
blockedWords.push(word);
input.value = '';
save(); applyFilters(); renderWords(); updateStats();
}
function removeWord(word) {
blockedWords = blockedWords.filter(w => w !== word);
save(); applyFilters(); renderWords(); updateStats();
}
function renderWords() {
const list = document.getElementById('nf-word-list');
if (!list) return;
if (!blockedWords.length) {
list.innerHTML = '<div class="nf-word-empty">Geen trefwoorden ingesteld.<br>Voeg hierboven een woord toe.</div>';
return;
}
list.innerHTML = blockedWords.map(w => `
<div class="nf-word-item">
<span class="nf-word-text">${w}</span>
<button class="nf-word-del" data-word="${w}" title="Verwijder">✕</button>
</div>`).join('');
list.querySelectorAll('.nf-word-del').forEach(btn => {
btn.addEventListener('click', () => removeWord(btn.dataset.word));
});
}
// ─────────────────────────────────────────────
// KEYBOARD & MUTATION
// ─────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.altKey && e.key === 'f') { e.preventDefault(); togglePanel(); }
if (e.key === 'Escape' && panelOpen) closePanel();
});
let filterTimer = null;
new MutationObserver(() => {
clearTimeout(filterTimer);
filterTimer = setTimeout(applyFilters, 300);
}).observe(document.documentElement, { childList: true, subtree: true });
// ─────────────────────────────────────────────
// INIT
// ─────────────────────────────────────────────
function init() {
if (!document.body) { setTimeout(init, 50); return; }
buildCategoryMap();
buildUI();
applyFilters();
updateStats();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();