Fullchan X

8chan features script

安装此脚本?
作者推荐脚本

您可能也喜欢8chan Style Script

安装此脚本
// ==UserScript==
// @name        Fullchan X
// @namespace   Violentmonkey Scripts
// @match       https://8chan.moe/*/res/*
// @match       https://8chan.se/*/res/*
// @match       https://8chan.moe/*/catalog*
// @match       https://8chan.se/*/catalog*
// @run-at      document-end
// @grant       none
// @version     1.12.5
// @author      vfyxe
// @description 8chan features script
// ==/UserScript==


class fullChanX extends HTMLElement {
  constructor() {
    super();
    this.settingsEl = document.querySelector('fullchan-x-settings');
    this.settingsAll = this.settingsEl.settings;
    this.settings = this.settingsAll.main;
    this.settingsThreadBanisher = this.settingsAll.threadBanisher;
    this.settingsMascot = this.settingsAll.mascot;
    this.isThread = !!document.querySelector('.opCell');
    this.isDisclaimer = window.location.href.includes('disclaimer');
    Object.keys(this.settings).forEach(key => {
      this[key] = this.settings[key]?.value;
    });
  }

  init() {
    this.settingsButton = this.querySelector('#fcx-settings-btn');
    this.settingsButton.addEventListener('click', () => this.settingsEl.toggle());
    this.handleBoardLinks();
    if (!this.isThread) {
      if (this.settingsThreadBanisher.enableThreadBanisher.value) this.banishThreads(this.settingsThreadBanisher);
      return;
    }
    this.quickReply = document.querySelector('#quick-reply');
    this.qrbody = document.querySelector('#qrbody');
    this.threadParent = document.querySelector('#divThreads');
    this.threadId = this.threadParent.querySelector('.opCell').id;
    this.thread = this.threadParent.querySelector('.divPosts');
    this.posts = [...this.thread.querySelectorAll('.postCell')];
    this.postOrder = 'default';
    this.postOrderSelect = this.querySelector('#thread-sort');
    this.myYousLabel = this.querySelector('.my-yous__label');
    this.yousContainer = this.querySelector('#my-yous');

    this.gallery = document.querySelector('fullchan-x-gallery');
    this.galleryButton = this.querySelector('#fcx-gallery-btn');

    this.updateYous();
    this.observers();

    if (this.enableFileExtensions) this.handleTruncatedFilenames();
    if (this.settingsMascot.enableMascot.value) this.showMascot(this.settingsMascot);
  }

  styleUI () {
    this.style.setProperty('--top', this.uiTopPosition);
    this.style.setProperty('--right', this.uiRightPosition);
    this.classList.toggle('fcx-in-nav', this.moveToNave)
    this.classList.toggle('fcx--dim', this.uiDimWhenInactive && !this.moveToNave);
    this.classList.toggle('page-thread', this.isThread);
    const style = document.createElement('style');

    if (this.hideDefaultBoards !== '') {
      style.textContent += '#navTopBoardsSpan{display:block!important;}'
    }
    document.body.appendChild(style);
  }

  checkRegexList(string, regexList) {
    const regexObjects = regexList.map(r => {
      const match = r.match(/^\/(.*)\/([gimsuy]*)$/);
      return match ? new RegExp(match[1], match[2]) : null;
    }).filter(Boolean);

    return regexObjects.some(regex => regex.test(string));
  }

  banishThreads(banisher) {
    this.threadsContainer = document.querySelector('#divThreads');
    if (!this.threadsContainer) return;
    this.threadsContainer.classList.add('fcx-threads');

    const currentBoard = document.querySelector('#labelBoard')?.textContent.replace(/\//g,'');
    const boards = banisher.boards.value?.split(',') || [''];
    if (!boards.includes(currentBoard)) return;

    const minCharacters = banisher.minimumCharacters.value || 0;
    const banishTerms = banisher.banishTerms.value?.split('\n') || [];
    const banishAnchored = banisher.banishAnchored.value;
    const wlCyclical = banisher.whitelistCyclical.value;
    const wlReplyCount = parseInt(banisher.whitelistReplyCount.value);

    const banishSorter = (thread) => {
      if (thread.querySelector('.pinIndicator') || thread.classList.contains('fcx-sorted')) return;
      let shouldBanish = false;

      const isAnchored = thread.querySelector('.bumpLockIndicator');
      const isCyclical = thread.querySelector('.cyclicIndicator');
      const replyCount = parseInt(thread.querySelector('.labelReplies')?.textContent?.trim()) || 0;
      const threadSubject = thread.querySelector('.labelSubject')?.textContent?.trim() || '';
      const threadMessage = thread.querySelector('.divMessage')?.textContent?.trim() || '';
      const threadContent = threadSubject + ' ' + threadMessage;

      const hasMinChars = threadMessage.length > minCharacters;
      const hasWlReplyCount = replyCount > wlReplyCount;

      if (!hasMinChars) shouldBanish = true;
      if (isAnchored && banishAnchored) shouldBanish = true;
      if (isCyclical && wlCyclical) shouldBanish = false;
      if (hasWlReplyCount) shouldBanish = false;

      // run heavy regex process only if needed
      if (!shouldBanish && this.checkRegexList(threadContent, banishTerms)) shouldBanish = true;
      if (shouldBanish) thread.classList.add('shit-thread');
      thread.classList.add('fcx-sorted');
    };

    const banishThreads = () => {
      this.threads = this.threadsContainer.querySelectorAll('.catalogCell');
      this.threads.forEach(thread => banishSorter(thread));
    };
    banishThreads();

    const observer = new MutationObserver((mutationsList) => {
      for (const mutation of mutationsList) {
        if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
          banishThreads();
          break;
        }
      }
    });

    observer.observe(this.threadsContainer, { childList: true });
  }

  handleBoardLinks () {
    const navBoards = document.querySelector('#navTopBoardsSpan');
    const customBoardLinks = this.customBoardLinks?.toLowerCase().replace(/\s/g,'').split(',');
    let hideDefaultBoards = this.hideDefaultBoards?.toLowerCase().replace(/\s/g,'') || '';
    const urlCatalog = this.catalogBoardLinks ? '/catalog.html' : '';


    if (hideDefaultBoards === 'all') {
      navBoards.classList.add('hidden');
    } else {
      const waitForNavBoards = setInterval(() => {
        const navBoards = document.querySelector('#navTopBoardsSpan');
        if (!navBoards || !navBoards.querySelector('a')) return;

        clearInterval(waitForNavBoards);

        hideDefaultBoards = hideDefaultBoards.split(',');
        const defaultLinks = [...navBoards.querySelectorAll('a')];
        defaultLinks.forEach(link => {
          link.href += urlCatalog;
          const linkText = link.textContent;
          const shouldHide = hideDefaultBoards.includes(linkText) || customBoardLinks.includes(linkText);
          link.classList.toggle('hidden', shouldHide);
        });
      }, 50);
    }

    if (this.customBoardLinks.length > 0) {
      const customNav = document.createElement('span');
      customNav.classList = 'nav-boards nav-boards--custom';
      customNav.innerHTML = '<span>[</span>';

      customBoardLinks.forEach((board, index) => {
        const link = document.createElement('a');
        link.href = '/' + board + urlCatalog;
        link.textContent = board;
        customNav.appendChild(link);
        if (index < customBoardLinks.length - 1) customNav.innerHTML += '<span>/</span>';
      });

      customNav.innerHTML += '<span>]</span>';
      navBoards.parentNode.insertBefore(customNav, navBoards);
    }
  }

  observers () {
    this.postOrderSelect.addEventListener('change', (event) => {
      this.postOrder = event.target.value;
      this.assignPostOrder();
    });

    const observerCallback = (mutationsList, observer) => {
      for (const mutation of mutationsList) {
        if (mutation.type === 'childList') {
          this.posts = [...this.thread.querySelectorAll('.postCell')];
          if (this.postOrder !== 'default') this.assignPostOrder();
          this.updateYous();
          this.gallery.updateGalleryImages();
          if (this.settings.enableFileExtensions) this.handleTruncatedFilenames();
        }
      }
    };

    const threadObserver = new MutationObserver(observerCallback);
    threadObserver.observe(this.thread, { childList: true, subtree: false });

    if (this.enableNestedQuotes) {
      this.thread.addEventListener('click', event => {
        this.handleClick(event);
      });
    }

    this.galleryButton.addEventListener('click', () => this.gallery.open());
    this.myYousLabel.addEventListener('click', (event) => {
      if (this.myYousLabel.classList.contains('unseen')) {
        this.yousContainer.querySelector('.unseen').click();
      }
    });
  }

  handleClick (event) {
    const clicked = event.target;

    const post = clicked.closest('.innerPost') || clicked.closest('.innerOP');
    if (!post) return;

    const isNested = !!post.closest('.innerNested');
    const nestQuote = clicked.closest('.quoteLink') || clicked.closest('.panelBacklinks a');
    const postMedia = clicked.closest('a[data-filemime]');
    const postId = clicked.closest('.linkQuote');
    const anonId = clicked.closest('.labelId');

    if (nestQuote) {
      if (event.target.closest('.fcx-prevent-nesting')) return;
      event.preventDefault();
      this.nestQuote(nestQuote, post);
    } else if (postMedia && isNested) {
      this.handleMediaClick(event, postMedia);
    } else if (postId && isNested) {
      this.handleIdClick(postId);
    } else if (anonId) {
      this.handleAnonIdClick(anonId, event);
    }
  }

  handleAnonIdClick (anonId, event) {
    this.anonIdPosts?.remove();
    if (anonId === this.anonId) {
      this.anonId = null;
      return;
    }

    this.anonId = anonId;
    const anonIdText = anonId.textContent.split(' ')[0];
    this.anonIdPosts = document.createElement('div');
    this.anonIdPosts.classList = 'fcx-id-posts fcx-prevent-nesting';

    const match = window.location.pathname.match(/^\/[^/]+\/res\/\d+\.html/);
    const prepend = match ? `${match[0]}#` : '';

    const selector = `.postInfo:has(.labelId[style="background-color: #${anonIdText}"]) .linkQuote`;

    const postIds = [...this.threadParent.querySelectorAll(selector)].map(link => {
      const postId = link.getAttribute('href').split('#q').pop();
      const newLink = document.createElement('a');
      newLink.className = 'quoteLink';
      newLink.href = prepend + postId;
      newLink.textContent = `>>${postId}`;
                  console.log('newLink',newLink)
      return newLink;
    });

    postIds.forEach(postId => this.anonIdPosts.appendChild(postId));
    anonId.insertAdjacentElement('afterend', this.anonIdPosts);

    this.setPostListeners(this.anonIdPosts);
  }


  handleMediaClick (event, postMedia) {
    if (postMedia.dataset.filemime === "video/webm") return;
    event.preventDefault();
    const imageSrc = `${postMedia.href}`;
    const imageEl = postMedia.querySelector('img');
    if (!postMedia.dataset.thumbSrc) postMedia.dataset.thumbSrc = `${imageEl.src}`;

    const isExpanding = imageEl.src !== imageSrc;

    if (isExpanding) {
      imageEl.src = imageSrc;
      imageEl.classList
    }
    imageEl.src = isExpanding ? imageSrc : postMedia.dataset.thumbSrc;
    imageEl.classList.toggle('imgExpanded', isExpanding);
  }

  handleIdClick (postId) {
    const idNumber = '>>' + postId.textContent;
    this.quickReply.style.display = 'block';
    this.qrbody.value += idNumber + '\n';
  }

  handleTruncatedFilenames () {
    this.postFileNames = [...this.threadParent.querySelectorAll('.originalNameLink[download]:not([data-file-ext])')];
    this.postFileNames.forEach(fileName => {
      if (!fileName.textContent.includes('.')) return;
      const strings = fileName.textContent.split('.');
      const typeStr =  `.${strings.pop()}`;
      const typeEl = document.createElement('a');
      typeEl.classList = ('file-ext originalNameLink');
      typeEl.textContent = typeStr;
      fileName.dataset.fileExt = typeStr;
      fileName.textContent = strings.join('.');
      fileName.parentNode.insertBefore(typeEl, fileName.nextSibling);
    });
  }

  assignPostOrder () {
    const postOrderReplies = (post) => {
      const replyCount = post.querySelectorAll('.panelBacklinks a').length;
      post.style.order = 100 - replyCount;
    }

    const postOrderCatbox = (post) => {
      const postContent = post.querySelector('.divMessage').textContent;
      const matches = postContent.match(/catbox\.moe/g);
      const catboxCount = matches ? matches.length : 0;
      post.style.order = 100 - catboxCount;
    }

    if (this.postOrder === 'default') {
      this.thread.style.display = 'block';
      return;
    }

    this.thread.style.display = 'flex';

    if (this.postOrder === 'replies') {
      this.posts.forEach(post => postOrderReplies(post));
    } else if (this.postOrder === 'catbox') {
      this.posts.forEach(post => postOrderCatbox(post));
    }
  }

  updateYous () {
    this.yous = this.posts.filter(post => post.querySelector('.quoteLink.you'));
    this.yousLinks = this.yous.map(you => {
      const youLink = document.createElement('a');
      youLink.textContent = '>>' + you.id;
      youLink.href = '#' + you.id;
      return youLink;
    })

    let hasUnseenYous = false;
    this.setUnseenYous();

    this.yousContainer.innerHTML = '';
    this.yousLinks.forEach(you => {
      const youId = you.textContent.replace('>>', '');
      if (!this.seenYous.includes(youId)) {
        you.classList.add('unseen');
        hasUnseenYous = true
      }
      this.yousContainer.appendChild(you)
    });

    this.myYousLabel.classList.toggle('unseen', hasUnseenYous);

    if (this.replyTabIcon === '') return;
    const icon = this.replyTabIcon;
    document.title = hasUnseenYous
      ? document.title.startsWith(`${icon} `)
        ? document.title
        : `${icon} ${document.title}`
      : document.title.replace(new RegExp(`^${icon} `), '');
  }

  observeUnseenYou(you) {
    you.classList.add('observe-you');

    const observer = new IntersectionObserver((entries, observer) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const id = you.id;
          you.classList.remove('observe-you');

          if (!this.seenYous.includes(id)) {
            this.seenYous.push(id);
            localStorage.setItem(this.seenKey, JSON.stringify(this.seenYous));
          }

          observer.unobserve(you);
          this.updateYous();

        }
      });
    }, { rootMargin: '0px', threshold: 0.1 });

    observer.observe(you);
  }

  setUnseenYous() {
    this.seenKey = `${this.threadId}-seen-yous`;
    this.seenYous = JSON.parse(localStorage.getItem(this.seenKey));

    if (!this.seenYous) {
      this.seenYous = [];
      localStorage.setItem(this.seenKey, JSON.stringify(this.seenYous));
    }

    this.unseenYous = this.yous.filter(you => !this.seenYous.includes(you.id));

    this.unseenYous.forEach(you => {
      if (!you.classList.contains('observe-you')) {
        this.observeUnseenYou(you);
      }
    });
  }

  nestQuote(quoteLink, parentPost) {
    const parentPostMessage = parentPost.querySelector('.divMessage');
    const quoteId = quoteLink.href.split('#').pop();
    const quotePost = document.getElementById(quoteId);
    if (!quotePost) return;

    const quotePostContent = quotePost.querySelector('.innerOP') || quotePost.querySelector('.innerPost');
    if (!quotePostContent) return;

    const existing = parentPost.querySelector(`.nestedPost[data-quote-id="${quoteId}"]`);
    if (existing) {
      existing.remove();
      return;
    }

    const isReply = !quoteLink.classList.contains('quoteLink');

    const wrapper = document.createElement('div');
    wrapper.classList.add('nestedPost');
    wrapper.setAttribute('data-quote-id', quoteId);

    const clone = quotePostContent.cloneNode(true);
    clone.style.whiteSpace = 'unset';
    clone.classList.add('innerNested');
    wrapper.appendChild(clone);

    if (isReply) {
      parentPostMessage.insertBefore(wrapper, parentPostMessage.firstChild);
    } else {
      quoteLink.insertAdjacentElement('afterend', wrapper);
    }

    this.setPostListeners(wrapper);
  }

  setPostListeners(parentPost) {
    const postLinks = [
      ...parentPost.querySelectorAll('.quoteLink'),
      ...parentPost.querySelectorAll('.panelBacklinks a')
    ];

    const hoverPost = (event, link) => {
      const quoteId = link.href.split('#')[1];

      let existingPost = document.querySelector(`.nestedPost[data-quote-id="${quoteId}"]`)
        || link.closest(`.postCell[id="${quoteId}"]`);

      if (existingPost) {
        this.markedPost = existingPost.querySelector('.innerPost') || existingPost.querySelector('.innerOP');
        this.markedPost?.classList.add('markedPost');
        return;
      }

      const quotePost = document.getElementById(quoteId);

      tooltips.removeIfExists();

      const tooltip = document.createElement('div');
      tooltip.className = 'quoteTooltip';
      document.body.appendChild(tooltip);

      const rect = link.getBoundingClientRect();
      if (!api.mobile) {
        if (rect.left > window.innerWidth / 2) {
          const right = window.innerWidth - rect.left - window.scrollX;
          tooltip.style.right = `${right}px`;
        } else {
          const left = rect.right + 10 + window.scrollX;
          tooltip.style.left = `${left}px`;
        }
      }

      tooltip.style.top = `${rect.top + window.scrollY}px`;
      tooltip.style.display = 'inline';

      tooltips.loadTooltip(tooltip, link.href, quoteId);
      tooltips.currentTooltip = tooltip;
    }

    const unHoverPost = (event, link) => {
      if (!tooltips.currentTooltip) {
        this.markedPost?.classList.remove('markedPost');
        return false;
      }

      if (tooltips.unmarkReply) {
        tooltips.currentTooltip.classList.remove('markedPost');
        Array.from(tooltips.currentTooltip.getElementsByClassName('replyUnderline'))
          .forEach((a) => a.classList.remove('replyUnderline'))
        tooltips.unmarkReply = false;
      } else {
        tooltips.currentTooltip.remove();
      }

      tooltips.currentTooltip = null;
    }

    const addHoverPost = (link => {
      link.addEventListener('mouseenter', (event) => hoverPost(event, link));
      link.addEventListener('mouseleave', (event) => unHoverPost(event, link));
    });

    postLinks.forEach(link => addHoverPost(link));
  }

  showMascot(settings) {
    const mascot = document.createElement('img');
    mascot.classList.add('fcx-mascot');
    mascot.src = settings.image.value;
    mascot.style.opacity = settings.opacity.value * 0.01;
    mascot.style.top = settings.top.value;
    mascot.style.left = settings.left.value;
    mascot.style.right = settings.right.value;
    mascot.style.bottom = settings.bottom.value;
    mascot.style.height = settings.height.value;
    mascot.style.width = settings.width.value;
    document.body.appendChild(mascot);
  }
};

window.customElements.define('fullchan-x', fullChanX);


class fullChanXGallery extends HTMLElement {
  constructor() {
    super();
  }

  init() {
    this.fullchanX = document.querySelector('fullchan-x');
    this.imageContainer = this.querySelector('.gallery__images');
    this.mainImageContainer = this.querySelector('.gallery__main-image');
    this.mainImage = this.mainImageContainer.querySelector('img');
    this.sizeButtons = [...this.querySelectorAll('.gallery__scale-options .scale-option')];
    this.closeButton = this.querySelector('.gallery__close');
    this.listeners();
    this.addGalleryImages();
    this.initalized = true;
  }

  addGalleryImages () {
    this.thumbs = [...this.fullchanX.threadParent.querySelectorAll('.imgLink')].map(thumb => {
      return thumb.cloneNode(true);
    });

    this.thumbs.forEach(thumb => {
      this.imageContainer.appendChild(thumb);
    });
  }

  updateGalleryImages () {
    if (!this.initalized) return;

    const newThumbs = [...this.fullchanX.threadParent.querySelectorAll('.imgLink')].filter(thumb => {
      return !this.thumbs.find(thisThumb.href === thumb.href);
    }).map(thumb => {
      return thumb.cloneNode(true);
    });

    newThumbs.forEach(thumb => {
      this.thumbs.push(thumb);
      this.imageContainer.appendChild(thumb);
    });
  }

  listeners () {
    this.addEventListener('click', event => {
      const clicked = event.target;

      let imgLink = clicked.closest('.imgLink');
      if (imgLink?.dataset.filemime === 'video/webm') return;

      if (imgLink) {
        event.preventDefault();
        this.mainImage.src = imgLink.href;
      }

      this.mainImageContainer.classList.toggle('active', !!imgLink);

      const scaleButton = clicked.closest('.scale-option');
      if (scaleButton) {
        const scale = parseFloat(getComputedStyle(this.imageContainer).getPropertyValue('--scale')) || 1;
        const delta = scaleButton.id === 'fcxg-smaller' ? -0.1 : 0.1;
        const newScale = Math.max(0.1, scale + delta);
        this.imageContainer.style.setProperty('--scale', newScale.toFixed(2));
      }

      if (clicked.closest('.gallery__close')) this.close();
    });
  }

  open () {
    if (!this.initalized) this.init();
    this.classList.add('open');
    document.body.classList.add('fct-gallery-open');
  }

  close () {
    this.classList.remove('open');
    document.body.classList.remove('fct-gallery-open');
  }
}

window.customElements.define('fullchan-x-gallery', fullChanXGallery);



class fullChanXSettings extends HTMLElement {
  constructor() {
    super();
    this.settingsKey = 'fullchan-x-settings';
    this.inputs = [];
    this.settings = {};
    this.settingsTemplate = {
      main: {
        moveToNav: {
          info: 'Move Fullchan-X controls into the navbar.',
          type: 'checkbox',
          value: true
        },
        enableNestedQuotes: {
          info: 'Nest posts when clicking backlinks.',
          type: 'checkbox',
          value: true
        },
        enableFileExtensions: {
          info: 'Always show filetype on shortened file names.',
          type: 'checkbox',
          value: true
        },
        customBoardLinks: {
          info: 'List of custom boards in nav (seperate by comma)',
          type: 'input',
          value: 'v,a,b'
        },
        hideDefaultBoards: {
          info: 'List of boards to remove from nav (seperate by comma). Set as "all" to remove all.',
          type: 'input',
          value: 'interracial,mlp'
        },
        catalogBoardLinks: {
          info: 'Redirect nav board links to catalog pages.',
          type: 'checkbox',
          value: true
        },
        uiTopPosition: {
          info: 'Position from top of screen e.g. 100px',
          type: 'input',
          value: '50px'
        },
        uiRightPosition: {
          info: 'Position from right of screen e.g. 100px',
          type: 'input',
          value: '25px'
        },
        uiDimWhenInactive: {
          info: 'Dim UI when not hovering with mouse.',
          type: 'checkbox',
          value: true
        },
        replyTabIcon: {
          info: 'Set the icon/text added to tab title when you get a new (You).',
          type: 'input',
          value: '❗'
        }
      },
      mascot: {
        enableMascot: {
          info: 'Enable mascot image.',
          type: 'checkbox',
          value: false
        },
        image: {
          info: 'Image URL (8chan image recommended).',
          type: 'input',
          value: '/.static/logo.png'
        },
        opacity: {
          info: 'Opacity (1 to 100)',
          type: 'input',
          inputType: 'number',
          value: '75'
        },
        width: {
          info: 'Width of image.',
          type: 'input',
          value: '300px'
        },
        height: {
          info: 'Height of image.',
          type: 'input',
          value: 'auto'
        },
        bottom: {
          info: 'Bottom position.',
          type: 'input',
          value: '0px'
        },
        right: {
          info: 'Right position.',
          type: 'input',
          value: '0px'
        },
        top: {
          info: 'Top position.',
          type: 'input',
          value: ''
        },
        left: {
          info: 'Left position.',
          type: 'input',
          value: ''
        }
      },
      threadBanisher: {
        enableThreadBanisher: {
          info: 'Banish shit threads to the bottom of the calalog.',
          type: 'checkbox',
          value: true
        },
        boards: {
          info: 'Banish theads on these boards (seperated by comma).',
          type: 'input',
          value: 'v,a'
        },
        minimumCharacters: {
          info: 'Minimum character requirements',
          type: 'input',
          inputType: 'number',
          value: 100
        },
        banishTerms: {
          info: `<p>Banish threads with these terms to the bottom of the catalog (new line per term).</p>
                <p>How to use regex: <a href="https://www.datacamp.com/cheat-sheet/regular-expresso" target="__blank">Regex Cheatsheet</a>.</p>
                <p>NOTE: word breaks (\\b) MUST be entered as double escapes (\\\\b), they will appear as (\\b) when saved.</p>
          `,
          type: 'textarea',
          value: '/\\bcuck\\b/i\n/\\bchud\\b/i\n/\\bblacked\\b/i\n/\\bnormie\\b/i\n/\\bincel\\b/i\n/\\btranny\\b/i\n/\\bslop\\b/i\n'
        },
        whitelistCyclical: {
          info: 'Whitelist cyclical threads.',
          type: 'checkbox',
          value: true
        },
        banishAnchored: {
          info: 'Banish anchored threads that are under minimum reply count.',
          type: 'checkbox',
          value: true
        },
        whitelistReplyCount: {
          info: 'Threads above this reply count (excluding those with banish terms) will be whitelisted.',
          type: 'input',
          inputType: 'number',
          value: 100
        },
      }
    };
  }

  init() {
    this.settingsMain = this.querySelector('.fcxs-main');
    this.settingsThreadBanisher = this.querySelector('.fcxs-thread-banisher');
    this.settingsMascot = this.querySelector('.fcxs-mascot');
    this.getSavedSettings();
    this.buildSettingsOptions('main', this.settingsMain);
    this.buildSettingsOptions('threadBanisher', this.settingsThreadBanisher);
    this.buildSettingsOptions('mascot', this.settingsMascot);
    this.listeners();
    this.querySelector('.fcx-settings__close').addEventListener('click', () => this.close());
  }

  setSavedSettings(updated) {
    localStorage.setItem(this.settingsKey, JSON.stringify(this.settings));
    if (updated) this.classList.add('fcxs-updated');
  }

  getSavedSettings() {
    let saved = JSON.parse(localStorage.getItem(this.settingsKey));
    if (!saved) return;

    // Ensure all top-level keys exist
    for (const key in this.settingsTemplate) {
      if (!saved[key]) saved[key] = {};
    }

    this.settings = saved;
  }

  listeners() {
    this.inputs.forEach(input => {
      input.addEventListener('change', () => {
        const section = input.dataset.section;
        const key = input.name;
        const value = input.type === 'checkbox' ? input.checked : input.value;
        this.settings[section][key].value = value;
        this.setSavedSettings(true);
      });
    });
  }

  buildSettingsOptions(subSettings, parent) {
    if (!this.settings[subSettings]) this.settings[subSettings] = {}

    Object.entries(this.settingsTemplate[subSettings]).forEach(([key, config]) => {
      const wrapper = document.createElement('div');
      const infoWrapper = document.createElement('div');
      wrapper.classList.add('fcx-setting');
      infoWrapper.classList.add('fcx-setting__info');
      wrapper.appendChild(infoWrapper);

      const label = document.createElement('label');
      label.textContent = key
        .replace(/([A-Z])/g, ' $1')
        .replace(/^./, str => str.toUpperCase());
      label.setAttribute('for', key);
      infoWrapper.appendChild(label);

      if (config.info) {
        const info = document.createElement('p');
        info.innerHTML = config.info;
        infoWrapper.appendChild(info);
      }

      const savedValue = this.settings[subSettings][key]?.value ?? config.value;
      let input;

      if (config.type === 'checkbox') {
        input = document.createElement('input');
        input.type = 'checkbox';
        input.checked = savedValue;
      } else if (config.type === 'textarea') {
        input = document.createElement('textarea');
        input.value = savedValue;
      } else if (config.type === 'input') {
        input = document.createElement('input');
        input.type = config.inputType || 'text';
        input.value = savedValue;
      } else if (config.type === 'select' && config.options) {
        input = document.createElement('select');
        const options = config.options.split(',');
        options.forEach(opt => {
          const option = document.createElement('option');
          option.value = opt;
          option.textContent = opt;
          if (opt === savedValue) option.selected = true;
          input.appendChild(option);
        });
      }

      if (input) {
        input.id = key;
        input.name = key;
        input.dataset.section = subSettings;
        wrapper.appendChild(input);
        this.inputs.push(input);
        this.settings[subSettings][key] = {
          value: input.type === 'checkbox' ? input.checked : input.value
        };
      }

      parent.appendChild(wrapper);
    });
  }

  open() {
    this.classList.add('open');
  }

  close() {
    this.classList.remove('open');
  }

  toggle() {
    this.classList.toggle('open');
  }
}

window.customElements.define('fullchan-x-settings', fullChanXSettings);



class ToggleButton extends HTMLElement {
  constructor() {
    super();
    const data = this.dataset;
    this.onclick = () => {
      const target = data.target ? document.querySelector(data.target) : this;
      const value = data.value || 'active';
      !!data.set ? target.dataset[data.set] = value : target.classList.toggle(value);
    }
  }
}

window.customElements.define('toggle-button', ToggleButton);



// Create fullchan-x settings
const fcxs = document.createElement('fullchan-x-settings');
fcxs.innerHTML = `
  <div class="fcx-settings fcxs" data-tab="main">
    <header>
      <div class="fcxs__heading">
        <span class="fcx-settings__title">
          Fullchan-X Settings
        </span>
        <button class="fcx-settings__close fullchan-x__option">Close</button>
      </div>

      <div class="fcx-settings__tab-buttons">
        <toggle-button data-target=".fcxs" data-set="tab" data-value="main">
          Main
        </toggle-button>
        <toggle-button data-target=".fcxs" data-set="tab" data-value="catalog">
          catalog
        </toggle-button>
        <toggle-button data-target=".fcxs" data-set="tab" data-value="mascot">
          Mascot
        </toggle-button>
      </div>
    </header>

    <main>
      <div class="fcxs__updated-message">
        <p>Settings updated, refresh page to apply</p>
        <button class="fullchan-x__option" onClick="location.reload()">Reload page</button>
      </div>

      <div class="fcx-settings__settings">
        <div class="fcxs-main fcxs-tab"></div>
        <div class="fcxs-mascot fcxs-tab"></div>
        <div class="fcxs-catalog fcxs-tab">
          <div class="fcxs-thread-banisher"></div>
        </div>
      </div>
    </main>

    <footer>
    </footer>
  </div>
`;
document.body.appendChild(fcxs);
fcxs.init();



// Create fullchan-x gallery
const fcxg = document.createElement('fullchan-x-gallery');
fcxg.innerHTML = `
  <div class="fcxg gallery">
    <button id="fcxg-close" class="gallery__close fullchan-x__option">Close</button>
    <div class="gallery__scale-options">
      <button id="fcxg-smaller" class="scale-option fullchan-x__option">-</button>
      <button id="fcxg-larger" class="scale-option fullchan-x__option">+</button>
    </div>
    <div id="fcxg-images" class="gallery__images" style="--scale:1.0"></div>
    <div id="fcxg-main-image" class="gallery__main-image">
      <img src="" />
    </div>
  </div>
`;
document.body.appendChild(fcxg);



// Create fullchan-x element
const fcx = document.createElement('fullchan-x');
fcx.innerHTML = `
  <div class="fcx__controls">
    <button id="fcx-settings-btn" class="fullchan-x__option fcx-settings-toggle">
     <a>⚙️</a><span>Settings</span>
    </button>

    <div class="fullchan-x__option fullchan-x__sort thread-only">
      <a>☰</a>
      <select id="thread-sort">
        <option value="default">Default</option>
        <option value="replies">Replies</option>
        <option value="catbox">Catbox</option>
      </select>
    </div>

    <button id="fcx-gallery-btn" class="gallery__toggle fullchan-x__option thread-only">
      <a>🖼️</a><span>Gallery</span>
    </button>

    <div class="fcx__my-yous thread-only">
      <p class="my-yous__label fullchan-x__option"><a>💬</a><span>My (You)s</span></p>
      <div class="my-yous__yous fcx-prevent-nesting" id="my-yous"></div>
    </div>
  </div>
`;
(document.querySelector('.navHeader') || document.body).appendChild(fcx);
fcx.styleUI()
onload = (event) => fcx.init();

// Styles
const style = document.createElement('style');
style.innerHTML = `
  fullchan-x {
    --top: 50px;
    --right: 25px;
    background: var(--background-color);
    border: 1px solid var(--navbar-text-color);
    color: var(--link-color);
    font-size: 14px;
    z-index: 3;
  }

  toggle-button {
    cursor: pointer;
  }

  /* Fullchan-X in nav styles */
  .fcx-in-nav {
    padding: 0;
    border-width: 0;
    line-height: 20px;
    margin-right: 2px;
    background: none;
  }

  .fcx-in-nav .fcx__controls:before,
  .fcx-in-nav .fcx__controls:after {
    color: var(--navbar-text-color);
    font-size: 85%;
  }

  .fcx-in-nav .fcx__controls:before {
    content: "]";
  }

  .fcx-in-nav .fcx__controls:after {
    content: "[";
  }

  .fcx-in-nav .fcx__controls,
  .fcx-in-nav:hover .fcx__controls:hover {
    flex-direction: row-reverse;
  }

  .fcx-in-nav .fcx__controls .fullchan-x__option {
    padding: 0!important;
    justify-content: center;
    background: none;
    line-height: 0;
    max-width: 20px;
    min-width: 20px;
    translate: 0 1px;
    border: solid var(--navbar-text-color) 1px !important;
  }

  .fcx-in-nav .fcx__controls .fullchan-x__option:hover {
    border: solid var(--subject-color) 1px !important;
  }

  .fcx-in-nav .fullchan-x__sort > a {
    margin-bottom: 1px;
  }

  .fcx-in-nav .fcx__controls > * {
    position: relative;
  }

  .fcx-in-nav .fcx__controls .fullchan-x__option > span,
  .fcx-in-nav .fcx__controls .fullchan-x__option:not(:hover) > select {
    display: none;
  }

  .fcx-in-nav .fcx__controls .fullchan-x__option > select {
    appearance: none;
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    font-size: 0;
  }

  .fcx-in-nav .fcx__controls .fullchan-x__option > select option {
    font-size: 12px;
  }

  .fcx-in-nav .my-yous__yous {
    position: absolute;
    left: 50%;
    translate: -50%;
    background: var(--background-color);
    border: 1px solid var(--navbar-text-color);
    padding: 14px;
  }

  /* Fullchan-X main styles */
  fullchan-x:not(.fcx-in-nav) {
    top: var(--top);
    right: var(--right);
    display: block;
    padding: 10px;
    position: fixed;
    display: block;
  }

  fullchan-x:not(.page-thread) .thread-only,
  fullchan-x:not(.page-catalog) .catalog-only {
    display: none!important;
  }

  fullchan-x:hover {
    z-index: 1000!important;
  }

  .navHeader:has(fullchan-x:hover) {
    z-index: 1000!important;
  }

  fullchan-x.fcx--dim:not(:hover) {
    opacity: 0.6;
  }

  .divPosts {
    flex-direction: column;
  }

  .fcx__controls {
    display: flex;
    flex-direction: column;
    gap: 6px;
  }

  fullchan-x:not(:hover):not(:has(select:focus)) span,
  fullchan-x:not(:hover):not(:has(select:focus)) select {
    display: none;
    margin-left: 5px;
    z-index:3;
  }

  .fcx__controls span,
  .fcx__controls select {
    margin-left: 5px;
  }

  .fcx__controls select {
    cursor: pointer;
  }

  #thread-sort {
    border: none;
    background: none;
  }

  .my-yous__yous {
    display: none;
    flex-direction: column;
    padding-top: 10px;
    max-height: calc(100vh - 220px - var(--top));
    overflow: auto;
  }

  .fcx__my-yous:hover .my-yous__yous {
    display: flex;
  }

  .fullchan-x__option {
    display: flex;
    padding: 6px 8px;
    background: white;
    border: none !important;
    border-radius: 0.2rem;
    transition: all ease 150ms;
    cursor: pointer;
    margin: 0;
    text-align: left;
    min-width: 18px;
    min-height: 18px;
    align-items: center;
  }

  .fullchan-x__option,
  .fullchan-x__option select {
    font-size: 12px;
    font-weight: 400;
    color: #374369;
  }

  fullchan-x:not(:hover):not(:has(select:focus)) .fullchan-x__option {
    display: flex;
    justify-content: center;
  }

  #thread-sort {
    padding-right: 0;
  }

  #thread-sort:hover {
    display: block;
  }

  .innerPost:has(.quoteLink.you) {
    border-left: solid #dd003e 6px;
  }

  .innerPost:has(.youName) {
    border-left: solid #68b723 6px;
  }

  /* --- Nested quotes --- */
  .divMessage .nestedPost {
    display: inline-block;
    width: 100%;
    margin-bottom: 14px;
    white-space: normal!important;
    overflow-wrap: anywhere;
    margin-top: 0.5em;
    border: 1px solid var(--navbar-text-color);
  }

  .nestedPost .innerPost,
  .nestedPost .innerOP {
    width: 100%;
  }

  .nestedPost .imgLink .imgExpanded {
    width: auto!important;
    height: auto!important;
  }

  .my-yous__label.unseen {
    background: var(--link-hover-color)!important;
    color: white;
  }

  .my-yous__yous .unseen {
    font-weight: 900;
    color: var(--link-hover-color);
  }

  /*--- Settings --- */
  .fcx-settings {
    display: block;
    position: fixed;
    top: 50vh;
    left: 50vw;
    translate: -50% -50%;
    padding: 20px 0;
    background: var(--background-color);
    border: 1px solid var(--navbar-text-color);
    color: var(--link-color);
    font-size: 14px;
    max-width: 480px;
    max-height: 80vh;
    overflow: scroll;
    min-width: 500px;
    z-index: 1000;
  }

  fullchan-x-settings:not(.open) {
    display: none;
  }

  .fcxs__heading,
  .fcxs-tab,
  .fcxs footer {
    padding: 0 20px;
  }

  .fcx-settings header {
    margin: 0 0 15px;
    border-bottom: 1px solid var(--navbar-text-color);
  }

  .fcxs__heading {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding-bottom: 20px;
  }

  .fcx-settings__title {
    font-size: 24px;
    font-size: 24px;
    letter-spacing: 0.04em;
  }

  .fcx-settings__tab-buttons {
    border-top: 1px solid var(--navbar-text-color);
    display: flex;
    align-items: center;
  }

  .fcx-settings__tab-buttons toggle-button {
    flex: 1;
    padding: 15px;
    font-size: 14px;
  }

  .fcx-settings__tab-buttons toggle-button + toggle-button {
    border-left: 1px solid var(--navbar-text-color);
  }

  .fcx-settings__tab-buttons toggle-button:hover {
    color: var(--role-color);
  }

  fullchan-x-settings:not(.fcxs-updated) .fcxs__updated-message {
    display: none;
  }

  .fcxs:not([data-tab="main"]) .fcxs-main,
  .fcxs:not([data-tab="catalog"]) .fcxs-catalog,
  .fcxs:not([data-tab="mascot"]) .fcxs-mascot {
    display: none;
  }

  .fcxs[data-tab="main"] [data-value="main"],
  .fcxs[data-tab="catalog"] [data-value="catalog"],
  .fcxs[data-tab="mascot"] [data-value="mascot"] {
    font-weight: 700;
  }

  .fcx-setting {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 12px 0;
  }

  .fcx-setting__info {
    max-width: 60%;
  }

  .fcx-setting input[type="text"],
  .fcx-setting input[type="number"],
  .fcx-setting select,
  .fcx-setting textarea {
    padding: 4px 6px;
    min-width: 35%;
  }

  .fcx-setting textarea {
    min-height: 100px;
  }

  .fcx-setting label {
    font-weight: 600;
  }

  .fcx-setting p {
    margin: 6px 0 0;
    font-size: 12px;
  }

  .fcx-setting + .fcx-setting {
    border-top: 1px solid var(--navbar-text-color);
  }

  .fcxs__updated-message {
    margin: 10px 0;
    text-align: center;
  }

  .fcxs__updated-message p {
    font-size: 14px;
    color: var(--error);
  }

  .fcxs__updated-message button {
    margin: 14px auto 0;
  }

  /* --- Gallery --- */
  .fct-gallery-open,
  body.fct-gallery-open,
  body.fct-gallery-open #mainPanel {
    overflow: hidden!important;
  }

  body.fct-gallery-open fullchan-x:not(.fcx-in-nav),
  body.fct-gallery-open #quick-reply {
    display: none!important;
  }

  fullchan-x-gallery {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    background: rgba(0,0,0,0.9);
    display: none;
    height: 100%;
    overflow: auto;
  }

  fullchan-x-gallery.open {
    display: block;
  }

  fullchan-x-gallery .gallery {
    padding: 50px 10px 0
  }

  fullchan-x-gallery .gallery__images {
    --scale: 1.0;
    display: flex;
    width: 100%;
    height: 100%;
    justify-content: center;
    align-content: flex-start;
    gap: 4px 8px;
    flex-wrap: wrap;
  }

  fullchan-x-gallery .imgLink {
    float: unset;
    display: block;
    zoom: var(--scale);
  }

  fullchan-x-gallery .imgLink img {
    border: solid white 1px;
  }

  fullchan-x-gallery .imgLink[data-filemime="video/webm"] img {
    border: solid #68b723 4px;
  }

  fullchan-x-gallery .gallery__close {
    border: solid 1px var(--background-color)!important;
    position: fixed;
    top: 60px;
    right: 35px;
    padding: 6px 14px;
    min-height: 30px;
    z-index: 10;
  }

  .fcxg .gallery__scale-options {
    position: fixed;
    bottom: 30px;
    right: 35px;
    display: flex;
    gap: 14px;
    z-index: 10;
  }

  .fcxg .gallery__scale-options .fullchan-x__option {
    border: solid 1px var(--background-color)!important;
    width: 35px;
    height: 35px;
    font-size: 18px;
    display: flex;
    justify-content: center;
  }

  .gallery__main-image {
    display: none;
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    justify-content: center;
    align-content: center;
    background: rgba(0,0,0,0.5);
  }

  .gallery__main-image img {
    padding: 40px 10px 15px;
    height: auto;
    max-width: calc(100% - 20px);
    object-fit: contain;
  }

  .gallery__main-image.active {
    display: flex;
  }

  /*-- Truncated file extentions --*/
  .originalNameLink[data-file-ext] {
    display: inline-block;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
    max-width: 65px;
  }

  .originalNameLink[data-file-ext]:hover {
    max-width: unset;
    white-space: normal;
    display: inline;
  }

  a[data-file-ext]:hover:after {
    content: attr(data-file-ext);
  }

  a[data-file-ext] + .file-ext {
    pointer-events: none;
  }

  a[data-file-ext]:hover + .file-ext {
    display: none;
  }

  /*-- Nav Board Links --*/
  .nav-boards--custom {
    display: flex;
    gap: 3px;
  }

  #navTopBoardsSpan.hidden ~ #navBoardsSpan,
  #navTopBoardsSpan.hidden ~ .nav-fade,
  #navTopBoardsSpan a.hidden + span {
    display: none;
  }

  /*-- Anon Unique ID posts --*/
  .postInfo .spanId {
    position: relative;
  }

  .fcx-id-posts {
    position: absolute;
    top: 0;
    left: 20px;
    translate: 0 calc(-100% - 5px);
    display: flex;
    flex-direction: column;
    padding: 10px;
    background: var(--background-color);
    border: 1px solid var(--navbar-text-color);
    width: max-content;
    max-width: 500px;
    max-height: 500px;
    overflow: auto;
    z-index: 1000;
  }

  .fcx-id-posts .nestedPost {
    pointer-events: none;
    width: auto;
  }

  /* mascot */
  .fcx-mascot {
    position: fixed;
    z-index: 0;
  }

  .fct-gallery-open .fcx-mascot {
    display: none;
  }

  /*-- Thread sorting --*/
  #divThreads.fcx-threads {
    display: flex!important;
    flex-wrap: wrap;
    justify-content: center;
  }

  .catalogCell.shit-thread {
    order: 10;
  }

  .catalogCell.shit-thread .labelPage:after {
    content: " 💩"
  }
`;

document.head.appendChild(style);


// Asuka and Eris (fantasy Asuka) are best girls