Fullchan X

8chan features script

Ekde 2025/04/24. Vidu La ĝisdata versio.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name        Fullchan X
// @namespace   Violentmonkey Scripts
// @match        *://8chan.moe/*
// @match        *://8chan.se/*
// @match        *://8chan.cc/*
// @match        *://8chan.cc/*
// @run-at      document-end
// @grant       none
// @version     1.12.9
// @author      vfyxe
// @description 8chan features script
// ==/UserScript==


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

  init() {
    this.settingsEl = document.querySelector('fullchan-x-settings');
    this.settingsAll = this.settingsEl.settings;
    this.settings = this.settingsAll.main;

    console.log('all', this.settingsAll)

    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;
    });

    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);

    this.styleUI();
  }

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

    if (this.hideDefaultBoards !== '' && this.hideDefaultBoards.toLowerCase() !== 'all') {
      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') {
      document.body.classList.add('hide-navboard');
    } 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) {
      quoteLink.classList.add('active');
      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) {
      quoteLink.insertAdjacentElement('afterend', wrapper);
    } else {
      parentPostMessage.insertBefore(wrapper, parentPostMessage.firstChild);
      quoteLink.classList.add('active');
    }

    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.fcx = document.querySelector('fullchan-x');
    this.settingsMain = this.querySelector('.fcxs-main');
    this.settingsThreadBanisher = this.querySelector('.fcxs-thread-banisher');
    this.settingsMascot = this.querySelector('.fcxs-mascot');
    this.getSavedSettings();
    if (this.settings.main) {
      this.fcx.init()
      this.loaded = true;
    };
    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());

    if (!this.loaded) this.fcx.init();
  }

  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 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);



// 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">
          <img class="fcxs_logo" src="/.static/logo/logo_blue.png" height="25px" width="auto">
          <span>
            Fullchan-X Settings
          </span>
        </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();



// Styles
const style = document.createElement('style');
style.innerHTML = `
  .hide-navboard #navTopBoardsSpan {
    display: none!important;
  }

  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;
  }

  .bottom-header .fcx-in-nav .my-yous__yous {
    top: 0;
    translate: -50% -100%;
  }

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

  .panelBacklinks a.active {
    color: #dd003e;
  }

  /*--- 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 {
    display: flex;
    align-items: center;
    gap: 10px;
    font-size: 24px;
    font-size: 24px;
    letter-spacing: 0.04em;
  }

  .fcxs_logo {
    .margin-top: -2px;
  }

  .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: -1;
  }

  .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