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