8chan Catalog Filter

Filter catalog threads using regex patterns with per-filter board settings, bumplock hide option, and glow effect for prioritized threads

질문, 리뷰하거나, 이 스크립트를 신고하세요.
// ==UserScript==
// @name         8chan Catalog Filter
// @version      1.8
// @description  Filter catalog threads using regex patterns with per-filter board settings, bumplock hide option, and glow effect for prioritized threads
// @match        *://8chan.moe/*/catalog.*
// @match        *://8chan.se/*/catalog.*
// @grant        none
// @license MIT
// @namespace   https://greasyfork.org/users/1459581
// ==/UserScript==
(function() {
    'use strict';

    // Cache DOM elements to avoid repeated queries
    let catalogDiv, cachedCells;

    // Initial configuration - can be modified by the user through the dashboard
    let config = {
        filters: [
            {
                pattern: /Arknights|AKG/i, // Example regex pattern
                board: 'gacha',           // Board for this filter (optional)
                action: 'setTop'          // 'setTop' or 'remove'
            }
            // More filters can be added by the user
        ],
        hideBumplocked: true,  // Default to hide bumplocked threads
        glowColor: '#57c457',  // Default glow color for setTop threads
        glowIntensity: 5       // Default glow intensity (0-20)
    };

    // Load saved configuration from localStorage if available
    function loadConfig() {
        const savedConfig = localStorage.getItem('8chanCatalogFilterConfig');
        if (savedConfig) {
            try {
                const parsedConfig = JSON.parse(savedConfig);
                // Convert string patterns back to RegExp objects
                parsedConfig.filters = parsedConfig.filters.map(filter => ({
                    pattern: new RegExp(filter.patternText, filter.flags),
                    patternText: filter.patternText, // Store the raw text pattern
                    board: filter.board || '',       // Board setting for this filter
                    action: filter.action
                }));

                // Handle bumplocked setting if it exists
                if (parsedConfig.hideBumplocked !== undefined) {
                    config.hideBumplocked = parsedConfig.hideBumplocked;
                }

                // Handle glow settings if they exist
                if (parsedConfig.glowColor !== undefined) {
                    config.glowColor = parsedConfig.glowColor;
                }

                if (parsedConfig.glowIntensity !== undefined) {
                    config.glowIntensity = parsedConfig.glowIntensity;
                }

                config = parsedConfig;
            } catch (e) {
                console.error('Failed to load saved filters:', e);
            }
        }
    }

    // Save configuration to localStorage
    function saveConfig() {
        // Convert RegExp objects to a serializable format
        const serializedConfig = {
            filters: config.filters.map(filter => ({
                patternText: filter.patternText || filter.pattern.source,
                flags: filter.pattern.flags,
                board: filter.board || '',
                action: filter.action
            })),
            hideBumplocked: config.hideBumplocked,
            glowColor: config.glowColor,
            glowIntensity: config.glowIntensity
        };
        localStorage.setItem('8chanCatalogFilterConfig', JSON.stringify(serializedConfig));
    }

    // Get current board from URL
    function getCurrentBoard() {
        const match = window.location.pathname.match(/\/([^\/]+)\/catalog/);
        return match ? match[1] : '';
    }

    // Check if a filter should apply on the current board
    function shouldApplyFilter(filter) {
        const currentBoard = getCurrentBoard();
        // If filter has no board specified or matches current board, apply it
        return !filter.board || filter.board === '' || filter.board === currentBoard;
    }

    // Create and inject the filter dashboard
    function createDashboard() {
        const toolsDiv = document.getElementById('divTools');
        if (!toolsDiv) return;

        // Create container for the filter dashboard
        const dashboardContainer = document.createElement('div');
        dashboardContainer.id = 'filterDashboard';
        dashboardContainer.style.marginBottom = '10px';

        // Create the dashboard controls
        const dashboardControls = document.createElement('div');
        dashboardControls.className = 'catalogLabel';
        dashboardControls.innerHTML = `
            <span style="font-weight: bold;">Filters:</span>
            <button id="showFilterManager" class="catalogLabel" style="margin-left: 5px;">Manage Filters</button>
            <button id="applyFilters" class="catalogLabel" style="margin-left: 5px;">Apply Filters</button>
            <label style="margin-left: 5px; display: inline-flex; align-items: center;">
                <input type="checkbox" id="hideBumplockedCheck" ${config.hideBumplocked ? 'checked' : ''}>
                <span style="margin-left: 3px;">Hide Bumplocked</span>
            </label>
            <span id="activeFiltersCount" style="margin-left: 5px;">(${config.filters.length} active)</span>
            <span id="currentBoardInfo" style="margin-left: 5px;">Current board: ${getCurrentBoard() || 'unknown'}</span>
        `;

        // Create the filter manager panel (initially hidden)
        const filterManager = document.createElement('div');
        filterManager.id = 'filterManagerPanel';
        filterManager.style.display = 'none';
        filterManager.style.border = '1px solid #ccc';
        filterManager.style.padding = '10px';
        filterManager.style.marginTop = '5px';

        updateFilterManagerContent(filterManager);

        // Add everything to the dashboard container
        dashboardContainer.appendChild(dashboardControls);
        dashboardContainer.appendChild(filterManager);

        // Insert dashboard before the search box
        const searchDiv = toolsDiv.querySelector('div[style="float: right; margin-top: 6px;"]');
        if (searchDiv) {
            toolsDiv.insertBefore(dashboardContainer, searchDiv);
        } else {
            toolsDiv.appendChild(dashboardContainer);
        }

        // Add event listeners
        document.getElementById('showFilterManager').addEventListener('click', function() {
            const panel = document.getElementById('filterManagerPanel');
            panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
        });

        document.getElementById('applyFilters').addEventListener('click', function() {
            // Debounce the click to prevent multiple rapid clicks
            if (this.disabled) return;
            this.disabled = true;

            // Visual feedback
            const originalText = this.textContent;
            this.textContent = "Applying...";

            // Use requestAnimationFrame to avoid blocking the UI
            requestAnimationFrame(() => {
                processCatalog();
                this.textContent = originalText;
                this.disabled = false;
            });
        });

        document.getElementById('hideBumplockedCheck').addEventListener('change', function() {
            config.hideBumplocked = this.checked;
            saveConfig();
            processCatalog();
        });
    }

    function updateFilterManagerContent(filterManager) {
        let content = `
            <div style="margin-bottom: 10px;">
                <h4 style="margin: 0 0 5px 0;">Current Filters</h4>
                <table style="width: 100%; border-collapse: collapse;">
                    <thead>
                        <tr>
                            <th style="text-align: left; padding: 2px 5px;">Pattern</th>
                            <th style="text-align: left; padding: 2px 5px;">Board</th>
                            <th style="text-align: left; padding: 2px 5px;">Action</th>
                            <th style="text-align: center; padding: 2px 5px;">Remove</th>
                        </tr>
                    </thead>
                    <tbody id="filtersTableBody">
        `;

        config.filters.forEach((filter, index) => {
            content += `
                <tr>
                    <td style="padding: 2px 5px;">${filter.patternText || filter.pattern.source}</td>
                    <td style="padding: 2px 5px;">${filter.board || 'All boards'}</td>
                    <td style="padding: 2px 5px;">${filter.action}</td>
                    <td style="text-align: center; padding: 2px 5px;">
                        <button class="removeFilterBtn" data-index="${index}" style="cursor: pointer;">X</button>
                    </td>
                </tr>
            `;
        });

        content += `
                    </tbody>
                </table>
            </div>
            <div style="margin-bottom: 10px;">
                <h4 style="margin: 0 0 5px 0;">Add New Filter</h4>
                <div style="display: flex; gap: 5px; align-items: center; margin-bottom: 5px;">
                    <input type="text" id="newFilterPattern" placeholder="Regex pattern (e.g. anime|manga)" style="flex-grow: 1;">
                    <label style="white-space: nowrap;">
                        <input type="checkbox" id="caseInsensitive" checked> Case insensitive
                    </label>
                </div>
                <div style="display: flex; gap: 5px; align-items: center;">
                    <input type="text" id="newFilterBoard" placeholder="Board name (empty for all boards)" style="flex-grow: 1;">
                    <select id="newFilterAction">
                        <option value="setTop">Move to Top</option>
                        <option value="remove">Hide</option>
                    </select>
                    <button id="addNewFilter" style="cursor: pointer;">Add</button>
                </div>
            </div>
            <div style="margin-bottom: 10px;">
                <h4 style="margin: 0 0 5px 0;">Glow Effect Settings</h4>
                <div style="display: flex; gap: 5px; align-items: center; margin-bottom: 5px;">
                    <label style="white-space: nowrap;">
                        Glow Color:
                        <input type="color" id="glowColorPicker" value="${config.glowColor}" style="vertical-align: middle;">
                    </label>
                    <label style="white-space: nowrap; margin-left: 10px;">
                        Intensity:
                        <input type="range" id="glowIntensitySlider" min="0" max="20" value="${config.glowIntensity}" style="vertical-align: middle;">
                        <span id="glowIntensityValue">${config.glowIntensity}</span>
                    </label>
                    <button id="saveGlowSettings" style="margin-left: auto; cursor: pointer;">Save</button>
                </div>
                <div id="glowPreview" style="margin-top: 5px; padding: 10px; text-align: center; border: 1px solid #ccc; box-shadow: 0 0 ${config.glowIntensity}px ${config.glowIntensity/2}px ${config.glowColor};">
                    Preview: Thread with Glow Effect
                </div>
            </div>
        `;

        filterManager.innerHTML = content;

        // Add event listeners after updating content
        setTimeout(() => {
            // Use event delegation for remove buttons
            const filtersTable = document.getElementById('filtersTableBody');
            if (filtersTable) {
                filtersTable.addEventListener('click', (event) => {
                    if (event.target.classList.contains('removeFilterBtn')) {
                        const index = parseInt(event.target.dataset.index);
                        config.filters.splice(index, 1);
                        saveConfig();
                        updateFilterManagerContent(filterManager);
                        updateActiveFiltersCount();
                    }
                });
            }

            // Add new filter button
            document.getElementById('addNewFilter').addEventListener('click', function() {
                const patternInput = document.getElementById('newFilterPattern');
                const boardInput = document.getElementById('newFilterBoard');
                const caseInsensitive = document.getElementById('caseInsensitive').checked;
                const actionSelect = document.getElementById('newFilterAction');

                if (patternInput.value.trim()) {
                    try {
                        const patternText = patternInput.value.trim();
                        const flags = caseInsensitive ? 'i' : '';
                        const boardValue = boardInput.value.trim();

                        const newFilter = {
                            pattern: new RegExp(patternText, flags),
                            patternText: patternText, // Store the raw text
                            board: boardValue,       // Board specific to this filter
                            action: actionSelect.value
                        };

                        config.filters.push(newFilter);
                        saveConfig();
                        updateFilterManagerContent(filterManager);
                        updateActiveFiltersCount();
                        patternInput.value = '';
                        boardInput.value = '';
                    } catch (e) {
                        alert('Invalid regex pattern: ' + e.message);
                    }
                }
            });

            // Glow settings event listeners
            const glowColorPicker = document.getElementById('glowColorPicker');
            const glowIntensitySlider = document.getElementById('glowIntensitySlider');
            const glowIntensityValue = document.getElementById('glowIntensityValue');
            const glowPreview = document.getElementById('glowPreview');

            // Live preview for glow settings
            function updateGlowPreview() {
                const color = glowColorPicker.value;
                const intensity = parseInt(glowIntensitySlider.value);
                glowIntensityValue.textContent = intensity;

                glowPreview.style.boxShadow = `0 0 ${intensity}px ${intensity/2}px ${color}`;
            }

            glowColorPicker.addEventListener('input', updateGlowPreview);
            glowIntensitySlider.addEventListener('input', updateGlowPreview);

            // Save glow settings
            document.getElementById('saveGlowSettings').addEventListener('click', function() {
                config.glowColor = glowColorPicker.value;
                config.glowIntensity = parseInt(glowIntensitySlider.value);
                saveConfig();
                processCatalog(); // Re-apply filters with new glow settings
                alert('Glow settings saved!');
            });
        }, 0);
    }

    function updateActiveFiltersCount() {
        const countElement = document.getElementById('activeFiltersCount');
        if (countElement) {
            // Count only filters applicable to the current board
            const currentBoard = getCurrentBoard();
            const applicableFilters = config.filters.filter(filter =>
                !filter.board || filter.board === '' || filter.board === currentBoard
            );
            countElement.textContent = `(${applicableFilters.length} active on this board)`;
        }
    }

    // Optimized processCatalog function to reduce DOM operations
    function processCatalog() {
        catalogDiv = catalogDiv || document.getElementById('divThreads');
        if (!catalogDiv) return;

        // Cache cells if not already done to avoid repetitive querySelectorAll
        if (!cachedCells || cachedCells.length === 0) {
            cachedCells = Array.from(catalogDiv.querySelectorAll('.catalogCell'));
        }

        // Cache applicable filters for current board
        const currentBoard = getCurrentBoard();
        const applicableFilters = config.filters.filter(filter =>
            !filter.board || filter.board === '' || filter.board === currentBoard
        );

        // Prepare css updates in memory instead of directly manipulating the DOM
        const updates = new Map();

        // Process all cells in a single pass
        cachedCells.forEach(cell => {
            // Initialize update object if needed
            if (!updates.has(cell)) {
                updates.set(cell, {
                    display: '',
                    order: '0',
                    glow: false
                });
            }

            // Check for bumplocked threads first if option is enabled
            if (config.hideBumplocked) {
                const bumpLockIndicator = cell.querySelector('.bumpLockIndicator');
                if (bumpLockIndicator) {
                    updates.get(cell).display = 'none';
                    return; // Skip further processing for this cell
                }
            }

            // Cache the thread text to avoid repeated DOM access
            const subject = cell.querySelector('.labelSubject')?.textContent || '';
            const message = cell.querySelector('.divMessage')?.textContent || '';
            const text = `${subject} ${message}`;

            // Apply all applicable filters
            for (const filter of applicableFilters) {
                if (filter.pattern.test(text)) {
                    if (filter.action === 'remove') {
                        updates.get(cell).display = 'none';
                        break; // No need to check other filters
                    } else if (filter.action === 'setTop') {
                        updates.get(cell).order = '-1';
                        updates.get(cell).glow = true; // Mark for glow effect
                    }
                }
            }
        });

        // Apply all updates in a single batch
        updates.forEach((update, cell) => {
            cell.style.display = update.display;
            cell.style.order = update.order;

            // Apply or remove glow effect
            if (update.glow) {
                const intensity = config.glowIntensity;
                cell.style.boxShadow = `0 0 ${intensity}px ${intensity/2}px ${config.glowColor}`;
                cell.style.zIndex = '1'; // Make sure glowing cells appear above others
                cell.style.position = 'relative'; // Needed for z-index to work
            } else {
                cell.style.boxShadow = '';
                cell.style.zIndex = '';
                cell.style.position = '';
            }
        });

        // Make sure flex container is properly set for ordering to work
        if (!catalogDiv.style.display.includes('flex')) {
            catalogDiv.style.display = 'flex';
            catalogDiv.style.flexWrap = 'wrap';
        }
    }

    // Initialize the script
    function init() {
        loadConfig();

        // Add required CSS for flex ordering
        const styleEl = document.createElement('style');
        styleEl.textContent = `
            #divThreads {
                display: flex !important;
                flex-wrap: wrap !important;
            }
            .catalogCell {
                order: 0; /* Default order */
                transition: box-shadow 0.3s ease; /* Smooth transition for glow effect */
            }
        `;
        document.head.appendChild(styleEl);

        createDashboard();

        // Initial catalog processing with slight delay to ensure page is ready
        setTimeout(() => {
            catalogDiv = document.getElementById('divThreads');
            cachedCells = Array.from(catalogDiv.querySelectorAll('.catalogCell'));
            processCatalog();
            updateActiveFiltersCount();
        }, 100);

        // Use a more efficient mutation observer with throttling
        let pendingUpdate = false;
        const observer = new MutationObserver(() => {
            if (!pendingUpdate) {
                pendingUpdate = true;
                requestAnimationFrame(() => {
                    // Reset cache when DOM changes
                    cachedCells = catalogDiv ? Array.from(catalogDiv.querySelectorAll('.catalogCell')) : [];
                    processCatalog();
                    pendingUpdate = false;
                });
            }
        });

        const threadsDiv = document.getElementById('divThreads');
        if (threadsDiv) {
            observer.observe(threadsDiv, { childList: true, subtree: false });
        }
    }

    // Wait for page to load completely
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();