Greasy Fork

Enhanced 8chan UI

Creates a media gallery with blur toggle and live thread info (Posts, Users, Files) plus additional enhancements

目前为 2025-04-20 提交的版本。查看 最新版本

// ==UserScript==
// @name         Enhanced 8chan UI
// @version      1.6.7
// @description  Creates a media gallery with blur toggle and live thread info (Posts, Users, Files) plus additional enhancements
// @match        https://8chan.moe/*/res/*
// @match        https://8chan.se/*/res/*
// @grant        GM_addStyle
// @grant        GM.addStyle
// @license MIT
// @namespace https://greasyfork.org/users/1459581
// ==/UserScript==


(function () {
  'use strict';

  // Check if we're on a thread page
  const isThreadPage = window.location.href.match(/https:\/\/8chan\.moe\/.*\/res\/.*/);

  // Default configuration for additional features
  var defaultConfig = {}; // TODO add menu and default configs to toggle options

  // Main gallery functionality
  let currentIndex = 0;
  const mediaElements = [];

  GM_addStyle(`
    .postCell {
      margin: 0 !important;
    }
    #navBoardsSpan {
      font-size: large;
    }
    .gallery-button {
      position: fixed;
      right: 20px;
      z-index: 9999;
      background: #333;
      color: white;
      padding: 15px;
      border-radius: 50%;
      cursor: pointer;
      box-shadow: 0 2px 5px rgba(0,0,0,0.3);
      text-align: center;
      line-height: 1;
      font-size: 20px;
    }
    .gallery-button.blur-toggle {
      bottom: 80px;
    }
    .gallery-button.gallery-open {
      bottom: 20px;
    }
    #media-count-display {
      position: fixed;
      bottom: 150px;
      right: 20px;
      background: #444;
      color: white;
      padding: 8px 12px;
      border-radius: 10px;
      font-size: 14px;
      z-index: 9999;
      box-shadow: 0 2px 5px rgba(0,0,0,0.3);
      white-space: nowrap;
    }
    .gallery-modal {
      display: none;
      position: fixed;
      bottom: 80px;
      right: 20px;
      width: 80%;
      max-width: 600px;
      max-height: 80vh;
      background: oklch(21% 0.006 285.885);
      border-radius: 10px;
      padding: 20px;
      overflow-y: auto;
      z-index: 9998;
    }
    .gallery-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
      gap: 10px;
    }
    .media-item {
      position: relative;
      cursor: pointer;
      aspect-ratio: 1;
      overflow: hidden;
      border-radius: 5px;
    }
    .media-thumbnail {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }
    .media-type-icon {
      position: absolute;
      bottom: 5px;
      right: 5px;
      color: white;
      background: rgba(0,0,0,0.5);
      padding: 2px 5px;
      border-radius: 3px;
      font-size: 0.8em;
    }
    .lightbox {
      display: none;
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: rgba(0,0,0,0.9);
      z-index: 10000;
    }
    .lightbox-content {
      position: absolute;
      top: 45%;
      left: 50%;
      transform: translate(-50%, -50%);
      max-width: 90%;
      max-height: 90%;
    }
    .lightbox-video {
      max-width: 90vw;
      max-height: 90vh;
    }
    .close-btn {
      position: absolute;
      top: 20px;
      right: 20px;
      width: 50px;
      height: 50px;
      cursor: pointer;
    }
    .lightbox-nav {
      position: absolute;
      top: 50%;
      transform: translateY(-50%);
      background: rgba(255,255,255,0.2);
      color: white;
      border: none;
      padding: 15px;
      cursor: pointer;
      font-size: 24px;
      border-radius: 50%;
    }
    .lightbox-prev {
      left: 20px;
    }
    .lightbox-next {
      right: 20px;
    }
    .go-to-post-btn {
      position: absolute;
      bottom: 10px;
      left: 50%;
      transform: translateX(-50%);
      background: rgba(255,255,255,0.1);
      color: white;
      border: none;
      padding: 8px 15px;
      border-radius: 20px;
      cursor: pointer;
      font-size: 14px;
    }
    .blurred-media img,
    .blurred-media video,
    .blurred-media audio {
      filter: blur(10px) brightness(0.8);
      transition: filter 0.3s ease;
    }

    /* New styles for centered quick-reply */
    #quick-reply.centered {
      position: fixed;
      top: 50% !important;
      left: 50% !important;
      transform: translate(-50%, -50%);
      width: 80%;
      max-width: 800px;
      min-height: 550px;
      background: oklch(21% 0.006 285.885);
      padding: 10px !important;
      border-radius: 10px;
      z-index: 9999;
      box-shadow: 0 0 20px rgba(0,0,0,0.5);
    }
    #quick-reply table,
    #quick-reply.centered #qrname,
    #quick-reply.centered #qrsubject,
    #quick-reply.centered #qrbody {
      width: 100% !important;
      max-width: 100% !important;
      box-sizing: border-box;
    }
    #quick-reply.centered #qrbody {
      min-height: 200px;
    }
    #quick-reply-overlay {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: rgba(0,0,0,0.7);
      z-index: 99;
      display: none;
    }


    /* Cleanup */
    #footer,
    #postingForm,
    #actionsForm,
    #navTopBoardsSpan,
    .coloredIcon.linkOverboard,
    .coloredIcon.linkSfwOver,
    .coloredIcon.multiboardButton,
    #navLinkSpan>span:nth-child(9),
    #navLinkSpan>span:nth-child(11),
    #navLinkSpan>span:nth-child(13) {
    display: none;
    }
    /* Header */
    #dynamicHeaderThread,
    .navHeader {
    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
    }
    /* Thread Watcher */
    #watchedMenu .floatingContainer {
    min-width: 330px;
    }
    #watchedMenu .watchedCellLabel > a:after {
        content: " - "attr(href);
        filter: saturate(50%);
        font-style: italic;
        font-weight: bold;
    }
    #watchedMenu {
    box-shadow: -3px 3px 2px 0px rgba(0,0,0,0.19);
    }
    /* Posts */
    .quoteTooltip .innerPost {
    overflow: hidden;
    box-shadow: -3px 3px 2px 0px rgba(0,0,0,0.19);
    }

    /* Catalog page CSS */
    #dynamicAnnouncement {
    display: none;
    }
    #postingForm {
    margin: 2em auto;
    }
  `);

  // Only create thread-specific UI elements if we're on a thread page
  if (isThreadPage) {
    // Create gallery UI elements
    const galleryButton = document.createElement('div');
    galleryButton.className = 'gallery-button gallery-open';
    galleryButton.textContent = '🎴';
    galleryButton.title = 'Gallery';
    document.body.appendChild(galleryButton);

    const blurToggle = document.createElement('div');
    blurToggle.className = 'gallery-button blur-toggle';
    blurToggle.textContent = '💼';
    blurToggle.title = 'Goon Mode';
    document.body.appendChild(blurToggle);

    const replyButton = document.createElement('div');
    replyButton.id = 'replyButton';
    replyButton.className = 'gallery-button';
    replyButton.style.bottom = '190px';
    replyButton.textContent = '✏️';
    replyButton.title = 'Reply';
    document.body.appendChild(replyButton);

    const mediaInfoDisplay = document.createElement('div');
    mediaInfoDisplay.id = 'media-count-display';
    document.body.appendChild(mediaInfoDisplay);

    // Create overlay for quick-reply
    const overlay = document.createElement('div');
    overlay.id = 'quick-reply-overlay';
    document.body.appendChild(overlay);

    let isBlurred = false;

    blurToggle.addEventListener('click', () => {
      isBlurred = !isBlurred;
      blurToggle.textContent = isBlurred ? '🍆' : '💼';
      blurToggle.title = isBlurred ? 'SafeMode' : 'Goon Mode';
      document.querySelectorAll('div.innerPost').forEach(post => {
        post.classList.toggle('blurred-media', isBlurred);
      });
    });

    function setupQuickReply() {
      const quickReply = document.getElementById('quick-reply');
      if (!quickReply) return;

      // Create close button if it doesn't exist
      if (!quickReply.querySelector('.qr-close-btn')) {
        const closeBtn = document.createElement('div');
        closeBtn.className = 'close-btn qr-close-btn';
        closeBtn.textContent = ' ';
        closeBtn.style.position = 'absolute';
        closeBtn.style.top = '10px';
        closeBtn.style.right = '10px';
        closeBtn.style.cursor = 'pointer';
        closeBtn.addEventListener('click', () => {
          quickReply.classList.remove('centered');
          overlay.style.display = 'none';
        });
        quickReply.appendChild(closeBtn);
      }

      quickReply.classList.add('centered');
      overlay.style.display = 'block';

      // Focus on reply body
      setTimeout(() => {
        document.querySelector('#qrbody')?.focus();
      }, 100);
    }

    replyButton.addEventListener('click', () => {
      const nativeReplyBtn = document.querySelector('a#replyButton[href="#postingForm"]');
      if (nativeReplyBtn) {
        nativeReplyBtn.click();
      } else {
        location.hash = '#postingForm';
      }

      // Clear form fields and setup centered quick-reply
      setTimeout(() => {
        document.querySelectorAll('#qrname, #qrsubject, #qrbody').forEach(field => {
          field.value = '';
        });
        setupQuickReply();
      }, 100);
    });

    const galleryModal = document.createElement('div');
    galleryModal.className = 'gallery-modal';
    const galleryGrid = document.createElement('div');
    galleryGrid.className = 'gallery-grid';
    galleryModal.appendChild(galleryGrid);
    document.body.appendChild(galleryModal);

    const lightbox = document.createElement('div');
    lightbox.className = 'lightbox';
    lightbox.innerHTML = `
      <div class="close-btn">×</div>
      <button class="lightbox-nav lightbox-prev">←</button>
      <button class="lightbox-nav lightbox-next">→</button>
    `;
    document.body.appendChild(lightbox);

    function collectMedia() {
      mediaElements.length = 0;
      const seenUrls = new Set();
      document.querySelectorAll('div.innerPost').forEach(post => {
        post.querySelectorAll('img[loading="lazy"]').forEach(img => {
          const src = img.src;
          if (!src || seenUrls.has(src)) return;
          const parentLink = img.closest('a');
          const href = parentLink?.href;
          if (href && !seenUrls.has(href)) {
            seenUrls.add(href);
            mediaElements.push({
              element: parentLink,
              thumbnail: img,
              url: href,
              type: /\.(mp4|webm|mov)$/i.test(href) ? 'VIDEO' :
                    /\.(mp3|wav|ogg)$/i.test(href) ? 'AUDIO' : 'IMAGE',
              postElement: post
            });
          } else {
            seenUrls.add(src);
            mediaElements.push({
              element: img,
              thumbnail: img,
              url: src,
              type: 'IMAGE',
              postElement: post
            });
          }
        });

        post.querySelectorAll('a[href*=".media"]:not(:has(img)), a.imgLink:not(:has(img))').forEach(link => {
          const href = link.href;
          if (!href || seenUrls.has(href)) return;
          const ext = href.split('.').pop().toLowerCase();
          if (/\.(jpg|jpeg|png|gif|webp|mp4|webm|mov|mp3|wav|ogg)$/i.test(ext)) {
            seenUrls.add(href);
            mediaElements.push({
              element: link,
              thumbnail: null,
              url: href,
              type: /\.(mp4|webm|mov)$/i.test(ext) ? 'VIDEO' :
                    /\.(mp3|wav|ogg)$/i.test(ext) ? 'AUDIO' : 'IMAGE',
              postElement: post
            });
          }
        });
      });
    }

    function createGalleryItems() {
      galleryGrid.innerHTML = '';
      mediaElements.forEach((media, index) => {
        const item = document.createElement('div');
        item.className = 'media-item';
        const thumbnail = document.createElement('img');
        thumbnail.className = 'media-thumbnail';
        thumbnail.loading = 'lazy';
        thumbnail.src = media.thumbnail?.src || (
          media.type === 'VIDEO' ? 'https://via.placeholder.com/100/333/fff?text=VID' :
          media.type === 'AUDIO' ? 'https://via.placeholder.com/100/333/fff?text=AUD' :
          media.url
        );
        const typeIcon = document.createElement('div');
        typeIcon.className = 'media-type-icon';
        typeIcon.textContent = media.type === 'VIDEO' ? 'VID' :
                               media.type === 'AUDIO' ? 'AUD' : 'IMG';
        item.appendChild(thumbnail);
        item.appendChild(typeIcon);
        item.addEventListener('click', () => showLightbox(media, index));
        galleryGrid.appendChild(item);
      });
    }

    function showLightbox(media, index) {
      currentIndex = typeof index === 'number' ? index : mediaElements.indexOf(media);
      updateLightboxContent();
      lightbox.style.display = 'block';
    }

    function updateLightboxContent() {
      const media = mediaElements[currentIndex];
      let content;
      if (media.type === 'AUDIO') {
        content = document.createElement('audio');
        content.controls = true;
        content.className = 'lightbox-content';
        content.src = media.url;
      } else if (media.type === 'VIDEO') {
        content = document.createElement('video');
        content.controls = true;
        content.className = 'lightbox-content lightbox-video';
        content.src = media.url;
        content.autoplay = true;
        content.loop = true;
      } else {
        content = document.createElement('img');
        content.className = 'lightbox-content';
        content.src = media.url;
        content.loading = 'eager';
      }

      lightbox.querySelector('.lightbox-content')?.remove();
      lightbox.querySelector('.go-to-post-btn')?.remove();

      const goToPostBtn = document.createElement('button');
      goToPostBtn.className = 'go-to-post-btn';
      goToPostBtn.textContent = 'Go to post';
      goToPostBtn.addEventListener('click', () => {
        lightbox.style.display = 'none';
        media.postElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
        media.postElement.style.transition = 'box-shadow 0.5s ease';
        media.postElement.style.boxShadow = '0 0 0 3px rgba(255, 255, 0, 0.5)';
        setTimeout(() => {
          media.postElement.style.boxShadow = 'none';
        }, 2000);
      });

      lightbox.appendChild(content);
      lightbox.appendChild(goToPostBtn);
    }

    function navigate(direction) {
      currentIndex = (currentIndex + direction + mediaElements.length) % mediaElements.length;
      updateLightboxContent();
    }

    function updateThreadInfoDisplay() {
      const postCount = document.getElementById('postCount')?.textContent || '0';
      const userCount = document.getElementById('userCountLabel')?.textContent || '0';
      const fileCount = document.getElementById('fileCount')?.textContent || '0';
      mediaInfoDisplay.textContent = `Posts: ${postCount} | Users: ${userCount} | Files: ${fileCount}`;
    }

    lightbox.querySelector('.lightbox-prev').addEventListener('click', () => navigate(-1));
    lightbox.querySelector('.lightbox-next').addEventListener('click', () => navigate(1));
    lightbox.querySelector('.close-btn').addEventListener('click', () => {
      lightbox.style.display = 'none';
    });

    galleryButton.addEventListener('click', () => {
      collectMedia();
      createGalleryItems();
      galleryModal.style.display = galleryModal.style.display === 'block' ? 'none' : 'block';
    });

    document.addEventListener('click', (e) => {
      if (!galleryModal.contains(e.target) && !galleryButton.contains(e.target)) {
        galleryModal.style.display = 'none';
      }
    });

    document.addEventListener('keydown', (e) => {
      if (lightbox.style.display === 'block') {
        if (e.key === 'ArrowLeft') navigate(-1);
        if (e.key === 'ArrowRight') navigate(1);
      }

      if (e.key === 'Escape') {
        galleryModal.style.display = 'none';
        lightbox.style.display = 'none';

        const qrCloseBtn = document.querySelector('.quick-reply .close-btn, th .close-btn');
        if (qrCloseBtn && typeof qrCloseBtn.click === 'function') {
          qrCloseBtn.click();
        }

        const qrFields = document.querySelectorAll('#qrname, #qrsubject, #qrbody');
        qrFields.forEach(field => {
          field.value = '';
        });

        // Also hide overlay and centered quick-reply
        document.getElementById('quick-reply-overlay').style.display = 'none';
        document.getElementById('quick-reply')?.classList.remove('centered');
      }

      if (e.altKey && e.key.toLowerCase() === 'z') {
        replyButton.click();
      }
    });

    // Initialize main gallery functionality
    collectMedia();
    createGalleryItems();
    updateThreadInfoDisplay();
    setInterval(updateThreadInfoDisplay, 5000);
  }

  // The following features are available on all pages
  // Header Catalog Links
  // Function to append /catalog.html to links
  function appendCatalogToLinks() {
      const navboardsSpan = document.getElementById('navBoardsSpan');
      if (navboardsSpan) {
          const links = navboardsSpan.getElementsByTagName('a');
          for (let link of links) {
              if (link.href && !link.href.endsWith('/catalog.html')) {
                  link.href += '/catalog.html';
              }
          }
      }
  }
  // Initial call to append links on page load
  appendCatalogToLinks();

  // Set up a MutationObserver to watch for changes in the #navboardsSpan div
  const observer = new MutationObserver(appendCatalogToLinks);
  const config = { childList: true, subtree: true };

  const navboardsSpan = document.getElementById('navBoardsSpan');
  if (navboardsSpan) {
      observer.observe(navboardsSpan, config);
  }

  // Scroll to last read post
  // Function to save the scroll position
  const MAX_PAGES = 50; // Maximum number of pages to store scroll positions
  const currentPage = window.location.href;

  // Specify pages to exclude from scroll position saving (supports wildcards)
  const excludedPagePatterns = [
      /\/catalog\.html$/i, // Exclude any page ending with /catalog.html (case-insensitive)
      // Add more patterns as needed
  ];

  // Function to check if current page matches any exclusion pattern
  function isExcludedPage(url) {
      return excludedPagePatterns.some(pattern => pattern.test(url));
  }

  // Function to save the scroll position for the current page
  function saveScrollPosition() {
      // Check if the current page matches any excluded pattern
      if (isExcludedPage(currentPage)) {
          return; // Skip saving scroll position for excluded pages
      }

      const scrollPosition = window.scrollY; // Get the current vertical scroll position
      localStorage.setItem(`scrollPosition_${currentPage}`, scrollPosition); // Store it in localStorage with a unique key

      // Manage the number of stored scroll positions
      manageScrollStorage();
  }

  // Function to restore the scroll position for the current page
  function restoreScrollPosition() {
      const savedPosition = localStorage.getItem(`scrollPosition_${currentPage}`); // Retrieve the saved position for the current page
      if (savedPosition) {
          window.scrollTo(0, parseInt(savedPosition, 10)); // Scroll to the saved position
      }
  }

  // Function to manage the number of stored scroll positions
  function manageScrollStorage() {
      const keys = Object.keys(localStorage).filter(key => key.startsWith('scrollPosition_'));

      // If the number of stored positions exceeds the limit, remove the oldest
      if (keys.length > MAX_PAGES) {
          // Sort keys by their creation time (assuming the order of keys reflects the order of storage)
          keys.sort((a, b) => {
              return localStorage.getItem(a) - localStorage.getItem(b);
          });
          // Remove the oldest entries until we are within the limit
          while (keys.length > MAX_PAGES) {
              localStorage.removeItem(keys.shift());
          }
      }
  }

  // Event listener to save scroll position before the page unloads
  window.addEventListener('beforeunload', saveScrollPosition);

  // Restore scroll position when the page loads
  window.addEventListener('load', restoreScrollPosition);

  // Fix for Image Hover
  (function () {
      'use strict';

      // Function to handle mouse movement
      function onMouseMove(event) {
          const img = document.querySelector('img[style*="position: fixed"]');
          if (img) {
              // Get the viewport dimensions
              const viewportWidth = window.innerWidth;
              const viewportHeight = window.innerHeight;

              // Calculate the new position
              let newX = event.clientX + 10; // Offset to avoid cursor overlap
              let newY = event.clientY + 10; // Offset to avoid cursor overlap

              // Ensure the image stays within the viewport
              if (newX + img.width > viewportWidth) {
                  newX = viewportWidth - img.width - 10; // Adjust for right edge
              }
              if (newY + img.height > viewportHeight) {
                  newY = viewportHeight - img.height - 10; // Adjust for bottom edge
              }

              // Update the image position
              img.style.left = `${newX}px`;
              img.style.top = `${newY}px`;
          }
      }

      // Function to handle mouse enter and leave
      function onMouseEnter() {
          document.addEventListener('mousemove', onMouseMove);
      }

      function onMouseLeave() {
          document.removeEventListener('mousemove', onMouseMove);
      }

      // Observe for the image to appear and disappear
      const observer = new MutationObserver((mutations) => {
          mutations.forEach((mutation) => {
              mutation.addedNodes.forEach((node) => {
                  if (node.nodeType === Node.ELEMENT_NODE && node.matches('img[style*="position: fixed"]')) {
                      onMouseEnter();
                  }
              });
              mutation.removedNodes.forEach((node) => {
                  if (node.nodeType === Node.ELEMENT_NODE && node.matches('img[style*="position: fixed"]')) {
                      onMouseLeave();
                  }
              });
          });
      });

      // Start observing the body for changes
      observer.observe(document.body, { childList: true, subtree: true });
  })();
})();