F95Zone Toolbox

A collection of js-modules for F95Zone that can be individually toggled by the user.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         F95Zone Toolbox
// @description  A collection of js-modules for F95Zone that can be individually toggled by the user.
// @author       equmaq
// @icon         https://www.google.com/s2/favicons?domain=f95zone.to
// @namespace    https://github.com/equmaq/F95Zone-Toolbox
// @supportURL   https://github.com/equmaq/F95Zone-Toolbox/issues
// @version      1.4.2
// @license      GPL-3.0
// @match        https://f95zone.to/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.registerMenuCommand
// ==/UserScript==

(function init() {
  'use strict';

  const STORAGE_KEYS = {
    lastSeenVersion: 'f95zone-toolbox:last-seen-version',
    plusOneEnabled: 'f95zone-toolbox:plus-one-enabled',
    ignoreEnabled: 'f95zone-toolbox:ignore-enabled',
    styleUnignoreButton: 'f95zone-toolbox:style-unignore-button',
    styleWatchIcons: 'f95zone-toolbox:style-watch-icons',
  };
  const CURRENT_VERSION = GM_info.script.version;

  const isThreadPage = /\/threads\//.test(location.pathname);
  const modules = [
    {
      key: 'plusOneEnabled',
      type: 'function',
      title: '+1 Blocker',
      description: 'Blocks those annoying +1 replies.',
      enabledByDefault: false,
      run({ document, isThreadPage }) {
        if (!isThreadPage) {
          return;
        }

        const regex = /^\s*\+?\s*\d+\s*$/;

        function clean() {
          document.querySelectorAll('.message').forEach(post => {
            const bb = post.querySelector('.bbWrapper');
            if (!bb) return;

            const text = bb.textContent.trim();

            if (regex.test(text)) {
              post.remove();
            }
          });
        }

        clean();

        const observer = new MutationObserver(() => clean());
        observer.observe(document.body, { childList: true, subtree: true });
      },
    },
    {
      key: 'ignoreEnabled',
      type: 'function',
      title: 'Instant Ignore',
      description: 'Makes the "Ignore thread" button instantly ignore threads. Also redirects back to the ignored thread instead of the Adult Games forum.',
      enabledByDefault: false,
      run({ document }) {
        document.addEventListener('click', event => {
          const button = event.target?.closest?.('a.tic--button');
          if (!button || button.textContent.trim() !== 'Ignore thread') return;

          event.preventDefault();

          const url = new URL(button.href, location.href);
          const contentId = url.searchParams.get('content_id');
          const contentType = url.searchParams.get('content_type');
          const token = document.documentElement.getAttribute('data-csrf');

          if (!contentId || !contentType || !token) return;

          const form = document.createElement('form');
          form.method = 'POST';
          form.action = '/misc/tic-ignore';
          form.innerHTML = [
            '<input type="hidden" name="is_confirmed" value="1">',
            `<input type="hidden" name="content_type" value="${contentType}">`,
            `<input type="hidden" name="redirect" value="/threads/${contentId}">`,
            `<input type="hidden" name="content_id" value="${contentId}">`,
            `<input type="hidden" name="_xfToken" value="${token}">`,
          ].join('');

          document.body.appendChild(form);
          form.submit();
        });
      },
    },
    {
      key: 'styleUnignoreButton',
      type: 'style',
      title: 'Highlight Unignore And Unwatch Buttons',
      description: 'Does what it says on the tin. Makes the "Unignore thread" and "Unwatch" buttons more visible by adding a red background and border.',
      enabledByDefault: false,
      run({ document }) {
        const buttonStyles = {
          backgroundColor: 'rgba(220, 53, 69, 0.3)',
          borderColor: '#dc3545',
          borderLeftWidth: '1px',
          borderLeftStyle: 'solid',
        };

        const clearStyles = element => {
          element.style.backgroundColor = '';
          element.style.borderColor = '';
          element.style.borderLeftWidth = '';
          element.style.borderLeftStyle = '';
        };

        const applyStyles = () => {
          document.querySelectorAll('a.tic--button, a.button--link').forEach(element => {
            const text = element.textContent.trim();

            clearStyles(element);

            if (text === 'Unignore thread' || text === 'Unwatch') {
              Object.assign(element.style, buttonStyles);
            }
          });
        };

        let debounceTimer = null;
        const scheduleApply = () => {
          window.clearTimeout(debounceTimer);
          debounceTimer = window.setTimeout(applyStyles, 0);
        };

        applyStyles();

        const observer = new MutationObserver(scheduleApply);
        observer.observe(document.body, {
          childList: true,
          subtree: true,
          characterData: true,
        });
      },
    },
    {
      key: 'styleWatchIcons',
      type: 'style',
      title: 'Highlight Watched Threads On Latest Updates Page',
      description: 'Makes watched threads more obvious on the latest updates page by applying a red overlay to the thumbnail and making the "watched eye" red.',
      enabledByDefault: false,
      run({ document }) {
        const containerSelector = '#latest-page_items-wrap';
        let styleObserver = null;
        let retryObserver = null;

        const applyStyles = () => {
          document.querySelectorAll('.fa-eye').forEach(icon => {
            Object.assign(icon.style, {
              color: 'rgb(186, 69, 69)',
              opacity: '1',
              top: '2px',
              right: '5px',
              fontSize: '20px',
            });
          });

          document.querySelectorAll('.resource-tile_thumb').forEach(tile => {
            if (tile.querySelector('i.far.fa-eye.watch-icon')) {
              tile.style.boxShadow = 'inset 0 0 0 9999px rgba(149, 4, 4, 0.6)';
            }
          });
        };

        const attachObserver = () => {
          const scope = document.querySelector(containerSelector);
          if (!scope) {
            return false;
          }

          styleObserver?.disconnect();
          styleObserver = new MutationObserver(() => applyStyles());
          styleObserver.observe(scope, {
            childList: true,
            subtree: true,
            attributes: true,
            characterData: true,
          });

          return true;
        };

        applyStyles();

        if (!attachObserver()) {
          retryObserver = new MutationObserver(() => {
            if (attachObserver()) {
              retryObserver?.disconnect();
              retryObserver = null;
              applyStyles();
            }
          });

          retryObserver.observe(document.body, {
            childList: true,
            subtree: true,
          });
        }
      },
    },
  ];

  const moduleState = Object.fromEntries(
    modules.map(module => [module.key, Boolean(module.enabledByDefault)])
  );

  const introState = {
    mode: 'normal',
    fresh: false,
  };

  function getStorage() {
    const gmGetValue = typeof GM_getValue === 'function'
      ? (key, fallback) => Promise.resolve(GM_getValue(key, fallback))
      : typeof GM?.getValue === 'function'
        ? (key, fallback) => GM.getValue(key, fallback)
        : null;

    const gmSetValue = typeof GM_setValue === 'function'
      ? (key, value) => Promise.resolve(GM_setValue(key, value))
      : typeof GM?.setValue === 'function'
        ? (key, value) => GM.setValue(key, value)
        : null;

    return {
      async get(key, fallback) {
        if (gmGetValue) return gmGetValue(key, fallback);
        const stored = localStorage.getItem(key);
        if (stored === null) return fallback;
        try {
          return JSON.parse(stored);
        } catch {
          return stored;
        }
      },
      async set(key, value) {
        if (gmSetValue) return gmSetValue(key, value);
        localStorage.setItem(key, JSON.stringify(value));
      },
    };
  }

  const storage = getStorage();

  let menuDialog = null;
  let moduleCheckboxes = new Map();

  function getModuleContext() {
    return {
      document,
      window,
      location,
      isThreadPage,
    };
  }

  async function loadModuleState() {
    const values = await Promise.all(modules.map(async module => {
      const stored = await storage.get(STORAGE_KEYS[module.key], Boolean(module.enabledByDefault));
      return [module.key, Boolean(stored)];
    }));

    for (const [key, value] of values) {
      moduleState[key] = value;
    }
  }

  function createDialog() {
    const existing = document.getElementById('f95zone-toolbox-dialog');
    if (existing) return existing;

    const dialog = document.createElement('dialog');
    dialog.id = 'f95zone-toolbox-dialog';
    dialog.className = 'f95zone-toolbox-dialog';
    dialog.style.cssText = [
      'max-width:min(92vw,800px)',
      'width:fit-content',
      'border:0',
      'border-radius:7px',
      'padding:0',
      'overflow:hidden',
      'color:#e5eefc',
      'background:transparent',
      'box-shadow:0 24px 80px rgba(0,0,0,.5)',
    ].join(';');

    if (!document.getElementById('f95zone-toolbox-style')) {
      const style = document.createElement('style');
      style.id = 'f95zone-toolbox-style';
      style.textContent = `
        dialog.f95zone-toolbox-dialog {
          background: transparent;
          color: #e8edf5;
          border-radius: 7px;
        }

        dialog.f95zone-toolbox-dialog::backdrop {
          background: rgba(8, 10, 16, 0.72);
          backdrop-filter: blur(6px);
        }

        .f95zone-toolbox-form {
          display: flex;
          flex-direction: column;
          font: 14px/1.45 Arial, sans-serif;
        }

        .f95zone-toolbox-header {
          display: flex;
          align-items: flex-start;
          justify-content: space-between;
          gap: 16px;
        }

        .f95zone-toolbox-title {
          margin: 0 0 4px;
          font-size: 20px;
          line-height: 1.2;
          color: rgb(236, 85, 85);
        }

        .f95zone-toolbox-description {
          margin: 0;
          color: rgb(238, 238, 238);
        }

        .f95zone-toolbox-intro {
          margin: 0 0 6px;
          color: #f0c76b;
          font-size: 12px;
          font-weight: 700;
          text-transform: uppercase;
          letter-spacing: 0.08em;
        }

        .f95zone-toolbox-welcome {
          margin: 0 0 8px;
          color: #e6e7ea;
          font-size: 13px;
        }

        .f95zone-toolbox-body {
          display: grid;
          grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
          gap: 14px;
          padding: 18px 18px 16px;
          background: #242629;
        }

        .f95zone-toolbox-column {
          display: grid;
          gap: 10px;
          align-content: start;
          width: fit-content;
        }

        .f95zone-toolbox-column-title {
          margin: 0 2px 2px;
          color: #b8c1d1;
          font-size: 12px;
          font-weight: 700;
          text-transform: uppercase;
          letter-spacing: 0.08em;
        }

        .f95zone-toolbox-column-list {
          display: grid;
          gap: 10px;
          width: fit-content;
        }

        .f95zone-toolbox-module {
          display: flex;
          align-items: center;
          padding: 12px 14px;
          border: 1px solid rgba(255, 255, 255, 0.08);
          border-radius: 12px;
          background: rgba(255, 255, 255, 0.03);
          width: 370px;
        }

        .f95zone-toolbox-module.iconic {
          gap: 10px;
        }

        .f95zone-toolbox-module .iconic-label {
          display: grid;
          gap: 2px;
        }

        .f95zone-toolbox-module .iconic-label strong {
          font-size: 14px;
        }

        .f95zone-toolbox-module .iconic-label span {
          color: #aab2c1;
        }

        .f95zone-toolbox-module i {
          display: inline-block;
          min-width: 1em;
          height: 0.9em;
          text-align: left;
          position: relative;
          flex: 0 0 auto;
          font-style: normal;
          line-height: 1;
          color: #9398a0;
        }

        .f95zone-toolbox-module i::before,
        .f95zone-toolbox-module i::after {
          position: absolute;
          left: 0;
          top: 0.12em;
          display: inline-block;
          width: 0.88em;
          line-height: 1;
          vertical-align: -1px;
          font-size: 1.2em;
        }

        .f95zone-toolbox-module-copy {
          display: grid;
          gap: 2px;
        }

        .f95zone-toolbox-module-copy strong {
          font-size: 14px;
        }

        .f95zone-toolbox-module-copy span {
          color: #aab2c1;
        }

        .f95zone-toolbox-column--styles .f95zone-toolbox-module {
          border-color: rgba(220, 53, 69, 0.18);
        }

        @media (max-width: 860px) {
          .f95zone-toolbox-body {
            grid-template-columns: 1fr;
          }
        }

        .f95zone-toolbox-footer {
          display: flex;
          align-items: center;
          justify-content: space-between;
          gap: 16px;
          height: 52px;
          padding: 0 16px;
          background: #37383a;
        }

        .f95zone-toolbox-tip {
          color: #9fa6b3;
          font-size: 12px;
          line-height: 1.2;
        }

        .f95zone-toolbox-tip--fresh {
          color: #ff5151;
          text-shadow: 0 0 6px rgba(255, 81, 81, 0.85), 0 0 16px rgba(255, 81, 81, 0.45);
        }
      `;
      document.head.appendChild(style);
    }

    dialog.innerHTML = `
      <form class="f95zone-toolbox-form" method="dialog">
        <div class="f95zone-toolbox-body">
          <div class="f95zone-toolbox-header" style="grid-column: 1 / -1;">
            <div>
              <p class="f95zone-toolbox-intro" data-role="intro-line"></p>
              <p class="f95zone-toolbox-welcome" data-role="welcome-line"></p>
              <h2 id="f95zone-toolbox-title" class="f95zone-toolbox-title">F95Zone Toolbox</h2>
              <p class="f95zone-toolbox-description">Choose which modules should be active. Changes are saved immediately and applied upon reload.</p>
            </div>
          </div>
          <section class="f95zone-toolbox-column f95zone-toolbox-column--functions">
            <h3 class="f95zone-toolbox-column-title">Function modules</h3>
            <div class="f95zone-toolbox-column-list">
              ${renderModuleCards('function')}
            </div>
          </section>
          <section class="f95zone-toolbox-column f95zone-toolbox-column--styles">
            <h3 class="f95zone-toolbox-column-title">Style modules</h3>
            <div class="f95zone-toolbox-column-list">
              ${renderModuleCards('style')}
            </div>
          </section>
        </div>
        <div class="f95zone-toolbox-footer">
          <div class="f95zone-toolbox-tip" data-role="tip-line">Open this menu again from the userscript manager menu.</div>
          <button type="submit" value="apply" class="button--primary button">Apply</button>
        </div>
      </form>
    `;

    dialog.addEventListener('close', async () => {
      if (!menuDialog) {
        return;
      }

      if (dialog.returnValue === 'apply') {
        await saveMenuState();
        await storage.set(STORAGE_KEYS.lastSeenVersion, CURRENT_VERSION);
        location.reload();
      }

      menuDialog = null;
    });

    document.body.appendChild(dialog);
    moduleCheckboxes = new Map(modules.map(module => [module.key, dialog.querySelector(`input[name="${module.key}"]`)]));
    syncDialogIntro(dialog);
    return dialog;
  }

  function renderModuleCards(type) {
    return modules
      .filter(module => module.type === type)
      .map(module => `
        <label class="f95zone-toolbox-module iconic iconic--checkbox">
          <input type="checkbox" name="${module.key}" ${moduleState[module.key] ? 'checked' : ''}>
          <i aria-hidden="true"></i>
          <span class="iconic-label f95zone-toolbox-module-copy">
            <strong>${module.title}</strong>
            <span>${module.description}</span>
          </span>
        </label>
      `)
      .join('');
  }

  function syncDialogIntro(dialog) {
    const introLine = dialog.querySelector('[data-role="intro-line"]');
    const welcomeLine = dialog.querySelector('[data-role="welcome-line"]');
    const tipLine = dialog.querySelector('[data-role="tip-line"]');

    if (introLine) {
      introLine.textContent = introState.mode === 'update'
        ? `New Version: ${CURRENT_VERSION}!`
        : introState.mode === 'install'
          ? 'Welcome to F95Zone Toolbox!'
          : '';
      introLine.style.display = introLine.textContent ? '' : 'none';
    }

    if (welcomeLine) {
      welcomeLine.textContent = introState.mode === 'install'
        ? 'Use this setup to turn modules on or off before the page reloads.'
        : introState.mode === 'update'
          ? 'This config window opens once per version so you can review new releases.'
          : '';
      welcomeLine.style.display = welcomeLine.textContent ? '' : 'none';
    }

    if (tipLine) {
      tipLine.classList.toggle('f95zone-toolbox-tip--fresh', introState.fresh);
    }
  }

  async function saveMenuState() {
    for (const module of modules) {
      const checkbox = moduleCheckboxes.get(module.key);
      const enabled = Boolean(checkbox?.checked);
      moduleState[module.key] = enabled;
      await storage.set(STORAGE_KEYS[module.key], enabled);
    }
  }

  function applyModules() {
    const context = getModuleContext();

    for (const module of modules) {
      if (!moduleState[module.key]) {
        continue;
      }

      if (module.type === 'function' && typeof module.run === 'function') {
        module.run(context);
        continue;
      }

      if (module.type === 'style' && typeof module.run === 'function') {
        module.run(context);
      }
    }
  }

  async function showDialog(force = false) {
    const lastSeenVersion = await storage.get(STORAGE_KEYS.lastSeenVersion, '');
    const shouldShowFreshIntro = lastSeenVersion !== CURRENT_VERSION;
    if (!force && !shouldShowFreshIntro) return;

    introState.mode = shouldShowFreshIntro
      ? (lastSeenVersion ? 'update' : 'install')
      : 'normal';
    introState.fresh = shouldShowFreshIntro;

    const dialog = createDialog();
    menuDialog = dialog;
    if (dialog.open) {
      dialog.close();
    }

    if (shouldShowFreshIntro) {
      await storage.set(STORAGE_KEYS.lastSeenVersion, CURRENT_VERSION);
    }

    dialog.showModal();
  }

  function registerMenuCommand(label, handler) {
    const candidates = [
      typeof GM_registerMenuCommand === 'function' && GM_registerMenuCommand,
      typeof GM?.registerMenuCommand === 'function' && GM.registerMenuCommand,
    ].filter(Boolean);

    for (const register of candidates) {
      try {
        register(label, handler);
        return true;
      } catch {
        // Try the next supported menu API.
      }
    }

    return false;
  }

  async function bootstrap() {
    console.log('[F95Zone Toolbox] loaded');

    await loadModuleState();

    applyModules();

    registerMenuCommand('F95Zone Toolbox: Open config', () => {
      void showDialog(true);
    });

    await showDialog(false);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => {
      void bootstrap();
    }, { once: true });
  } else {
    void bootstrap();
  }
}());