NOS Filter

Filter for NOS.nl

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==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();
  }

})();