Enhanced 8chan UI

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

目前為 2025-04-19 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Enhanced 8chan UI
// @version      1.6.6
// @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';

  // 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(`
    .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;
    }

    /* Additional CSS from second script */
    /* Cleanup */
    #footer,
    #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;
    }
  `);

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

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

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