[Wallhaven] Purity Groups

Organizes thumbnails into collapsible SFW, Sketchy, and NSFW sections with dynamic grid resizing and a floating button to toggle seen wallpapers.

// ==UserScript==
// @name        [Wallhaven] Purity Groups
// @namespace   NooScripts
// @match       https://wallhaven.cc/*
// @exclude     https://wallhaven.cc/w/*
// @grant       GM_addStyle
// @version     1.4
// @author      NooScripts
// @description Organizes thumbnails into collapsible SFW, Sketchy, and NSFW sections with dynamic grid resizing and a floating button to toggle seen wallpapers.
// @license     MIT
// @icon        https://wallhaven.cc/favicon.ico
// ==/UserScript==

(function() {
    'use strict';

    // Constants
    const MIN_THUMB_SIZE = 240; // Minimum thumbnail size
    const MAX_THUMB_SIZE = 340; // Maximum thumbnail size
    const GRID_GAP = 2; // Compact gap between thumbnails

    // Utility function to safely select elements
    const $ = (selector, context = document) => context.querySelector(selector);
    const $$ = (selector, context = document) => context.querySelectorAll(selector);

    // Debounce utility to limit resize event frequency
    const debounce = (func, wait) => {
        let timeout;
        return (...args) => {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), wait);
        };
    };

    // Grid Grouper Class
    class GridGrouper {
        constructor(container) {
            this.container = container;
            this.originalOrder = Array.from($$('figure', this.container));
            // Bind resize handler
            this.handleResize = debounce(() => this.groupByPurity(), 100);
            window.addEventListener('resize', this.handleResize);
        }

        calculateColumnsAndThumbSize() {
            const availableWidth = this.container.clientWidth;
            // Estimate columns based on minimum thumb size
            const maxColumns = Math.floor(availableWidth / (MIN_THUMB_SIZE + GRID_GAP));
            // Calculate actual thumb size to fill container
            const totalGapWidth = GRID_GAP * (maxColumns - 1);
            const thumbSize = Math.min(
                MAX_THUMB_SIZE,
                Math.max(MIN_THUMB_SIZE, (availableWidth - totalGapWidth) / maxColumns)
            );
            const columns = Math.max(1, Math.floor(availableWidth / (thumbSize + GRID_GAP)));
            return { columns, thumbSize };
        }

        groupByPurity() {
            if (!this.container) return;

            // Clear existing content
            this.container.innerHTML = '';

            // Define purities in desired order with styling
            const purities = [
                { id: 'sfw', title: 'SFW', className: 'purity-sfw' },
                { id: 'sketchy', title: 'Sketchy', className: 'purity-sketchy' },
                { id: 'nsfw', title: 'NSFW', className: 'purity-nsfw' }
            ];
            const sections = {};
            const labels = {};

            // Create separate labels and containers for each purity
            purities.forEach(purity => {
                // Create label as a button
                const label = document.createElement('button');
                label.className = `section-label ${purity.className}-label`;
                label.textContent = purity.title;
                label.setAttribute('aria-expanded', 'true');
                label.setAttribute('aria-controls', `section-${purity.id}`);
                this.container.appendChild(label);
                labels[purity.id] = label;

                // Create section container
                sections[purity.id] = document.createElement('div');
                sections[purity.id].className = `purity-section ${purity.className}`;
                sections[purity.id].id = `section-${purity.id}`;
                this.container.appendChild(sections[purity.id]);

                // Add click event to toggle collapse/expand
                label.addEventListener('click', () => {
                    const isExpanded = label.getAttribute('aria-expanded') === 'true';
                    sections[purity.id].style.display = isExpanded ? 'none' : 'grid';
                    label.setAttribute('aria-expanded', !isExpanded);
                    label.textContent = `${purity.title} ${isExpanded ? '▶' : '▼'}`;
                });
            });

            // Sort wallpapers into sections
            this.originalOrder.forEach(thumb => {
                const purity = purities.find(p => thumb.classList.contains(`thumb-${p.id}`))?.id || 'sfw';
                sections[purity].appendChild(thumb);
            });

            // Calculate columns and thumb size
            const { columns, thumbSize } = this.calculateColumnsAndThumbSize();

            // Apply grid styling to non-empty sections and hide empty ones
            purities.forEach(purity => {
                if (sections[purity.id].children.length === 0) {
                    sections[purity.id].style.display = 'none';
                    labels[purity.id].style.display = 'none';
                } else {
                    Object.assign(sections[purity.id].style, {
                        display: 'grid',
                        gap: `${GRID_GAP}px`,
                        gridTemplateColumns: `repeat(${columns}, minmax(${MIN_THUMB_SIZE}px, 1fr))`
                    });
                }
            });

            // Apply thumbnail styling
            $$('[data-wallpaper-id]', this.container).forEach(element => {
                Object.assign(element.style, {
                    width: `${thumbSize}px`,
                    height: `${thumbSize}px`
                });
                const image = $('[data-src]', element);
                if (image) {
                    Object.assign(image.style, {
                        maxWidth: '100%',
                        maxHeight: '100%',
                        width: '100%',
                        height: '100%',
                        objectFit: 'contain'
                    });
                }
            });

            // Ensure container supports vertical stacking
            Object.assign(this.container.style, {
                display: 'flex',
                flexDirection: 'column',
                gap: '0',
                width: '100%',
                boxSizing: 'border-box'
            });
        }

        // Cleanup event listeners
        destroy() {
            window.removeEventListener('resize', this.handleResize);
        }
    }

    // Control Panel Class
    class ControlPanel {
        constructor(grouper) {
            this.grouper = grouper;
            this.panelId = 'wallhaven-control-panel';
            this.seenButtonId = 'toggle-seen-button';
            this.hideSeen = false;
        }

        createPanel() {
            const panel = document.createElement('div');
            panel.id = this.panelId;
            panel.innerHTML = `
                <div class="control-group">
                    <button id="${this.seenButtonId}">Hide Seen</button>
                </div>
            `;
            document.body.appendChild(panel);

            // Event Listener
            const seenButton = $(`#${this.seenButtonId}`);
            seenButton.addEventListener('click', () => this.toggleSeenWallpapers(seenButton));
        }

        toggleSeenWallpapers(button) {
            this.hideSeen = !this.hideSeen;
            button.textContent = this.hideSeen ? 'Show Seen' : 'Hide Seen';

            // Remove existing style if present
            const existingStyle = $(`style[data-id="hide-seen-style"]`);
            if (existingStyle) existingStyle.remove();

            // Apply or remove visibility for seen wallpapers
            const seenThumbs = $$('figure.thumb.thumb-seen');
            if (this.hideSeen) {
                // Inject CSS rule
                GM_addStyle(`
                    figure.thumb.thumb-seen {
                        display: none !important;
                    }
                `).setAttribute('data-id', 'hide-seen-style');
                // Fallback: directly set style
                seenThumbs.forEach(thumb => {
                    thumb.style.display = 'none';
                });
            } else {
                // Restore default visibility
                seenThumbs.forEach(thumb => {
                    thumb.style.display = '';
                });
            }

            // Re-run grouping to update section visibility
            this.grouper.groupByPurity();
        }

        init() {
            this.createPanel();
            const panel = $(`#${this.panelId}`);
            if (panel) {
                Object.assign(panel.style, {
                    position: 'fixed',
                    bottom: '10px',
                    right: '10px',
                    backgroundColor: 'rgba(0, 0, 0, 0.9)',
                    padding: '12px',
                    border: '1px solid #666',
                    borderRadius: '10px',
                    zIndex: '9999'
                });
            }
            // Automatically group by purity
            this.grouper.groupByPurity();
        }
    }

    // Styles
    GM_addStyle(`
        #wallhaven-control-panel {
            display: flex;
            flex-direction: column;
            min-width: 50px;
        }
        .control-group {
            align-items: center;
        }
        #toggle-seen-button {
            padding: 4px 10px;
            background-color: #444;
            color: #fff;
            border: 1px solid #666;
            border-radius: 4px;
            cursor: pointer;
            font-size: 12px;
            transition: background-color 0.2s;
        }
        #toggle-seen-button:hover {
            background-color: #555;
        }
        .thumb-listing .thumb,
        .thumb-listing-page .thumb {
            margin: 1px;
        }
        .thumb-listing-page {
            flex-direction: column !important;
            gap: ${GRID_GAP}px;
            width: 100%;
            padding: 0 10px;
            box-sizing: border-box;
        }
        .purity-section {
            width: 100%;
            box-sizing: border-box;
            padding: 15px;
            margin-bottom: 20px;
            border-radius: 8px;
            transition: transform 0.2s;
        }
        .purity-sfw {
            border: 2px solid #008000;
        }
        .purity-sketchy {
            border: 2px solid #ffa500;
        }
        .purity-nsfw {
            border: 2px solid #ff0000;
        }
        .section-label {
            background-color: #333;
            color: #fff;
            font-size: 18px;
            padding: 8px 12px;
            margin: 10px 0 5px 0;
            border: none;
            border-radius: 4px;
            text-transform: uppercase;
            letter-spacing: 1px;
            cursor: pointer;
            text-align: left;
            width: 100%;
            box-sizing: border-box;
            transition: background-color 0.2s;
        }
        .section-label:hover {
            background-color: #444;
        }
        .purity-sfw-label {
            color: #00cc00;
        }
        .purity-sketchy-label {
            color: #ffcc00;
        }
        .purity-nsfw-label {
            color: #ff3333;
        }
        @media (max-width: 600px) {
            .section-label {
                font-size: 16px;
                padding: 6px 10px;
            }
            .purity-section {
                padding: 10px;
            }
        }
    `);

    // Initialize
    try {
        const container = $('.thumb-listing-page');
        if (container) {
            const grouper = new GridGrouper(container);
            new ControlPanel(grouper).init();
        }
    } catch (error) {
        console.error('[Wallhaven Lite Script] Initialization failed:', error);
    }
})();