🔎 Roblox Search Filters v1.0.0

Adds a sidebar where you can filter your searches on Roblox.

// ==UserScript==
// @name         🔎 Roblox Search Filters v1.0.0
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  Adds a sidebar where you can filter your searches on Roblox.
// @author       ru-bem
// @match        https://www.roblox.com/discover/*
// @match        https://www.roblox.com/charts/*
// @license      GPL-3.0-or-later
// @grant        none
// ==/UserScript==

(function() {
'use strict';

//// Personalization
const config={
  interfacePosition:{top:'80px',left:'0'},
    interfaceStyles:{
      position:'fixed',
      backgroundColor:'#191a1f',
      border:'0px solid #222',
      padding:'50px 15px 300px 15px',
      borderRadius:'16px',
      zIndex:'9999',
      fontFamily:'Arial, sans-serif',
      cursor:'grab',
      boxShadow:'0 10px 10px rgba(0,0,0,0)'
    },
    layout:{
        groupDirection:'vertical',
        inputAlignment:'rows',
        spacing:{betweenGroups:'30px',betweenInputs:'5px'}
    },
    elementsOrder:['approval','players','button'],
    inputStyles:{
        width:'74px',padding:'5px',margin:'0',borderRadius:'0px',
        backgroundColor:'#222',color:'white',border:'0px solid #333'
    },
    buttonStyles:{
        width:'100%',backgroundColor:'#335fff',color:'white',
        padding:'12px 0',border:'none',borderRadius:'6px',
        cursor:'pointer',marginTop:'15px',fontWeight:'bold'
    },
    defaultFilters:{minApproval:80,maxApproval:100,minPlayers:'',maxPlayers:''},
    selectors:{
        gameCard:['.game-card-container','.list-item.game-card.game-tile'],
        approvalPercentage:'.vote-percentage-label',
        activePlayers:'.playing-counts-label'
    },
    urlKeywords:['/discover/?Keyword=','/charts/']
};

//// Code
function createInterface(){
    if(document.getElementById('approval-filter-interface')) return;

    const interfaceDiv = document.createElement('div');
    interfaceDiv.id = 'approval-filter-interface';
    Object.assign(interfaceDiv.style, config.interfaceStyles);
    interfaceDiv.style.top = config.interfacePosition.top;
    interfaceDiv.style.left = config.interfacePosition.left;

    // Groups
    const groups = {
        approval: createFilterGroup('👍 %Approval', 'minApproval', 'maxApproval'),
        players: createFilterGroup('▶️ Active Players', 'minPlayers', 'maxPlayers'),
        button: createButton()
    };

    // Order elements
    config.elementsOrder.forEach(key => {
        if(groups[key]) {
            groups[key].style.marginBottom = config.layout.spacing.betweenGroups;
            interfaceDiv.appendChild(groups[key]);
        }
    });

    document.body.appendChild(interfaceDiv);
    makeDraggable(interfaceDiv);

    function createFilterGroup(title, minKey, maxKey) {
        const group = document.createElement('div');
        const titleElement = document.createElement('div');
        titleElement.textContent = title;
        titleElement.style.color = '#fff';
        titleElement.style.marginBottom = '10px';

        const inputsContainer = document.createElement('div');
        inputsContainer.style.display = 'flex';
        inputsContainer.style.flexDirection = 'column';
        inputsContainer.style.gap = config.layout.spacing.betweenInputs;

        const minInput = createInput('number', config.defaultFilters[minKey], 'MIN:');
        const maxInput = createInput('number', config.defaultFilters[maxKey], 'MAX:');

        inputsContainer.append(minInput, maxInput);
        group.append(titleElement, inputsContainer);

        group.minInput = minInput.querySelector('input');
        group.maxInput = maxInput.querySelector('input');

        return group;
    }

    function createInput(type, value, labelText) {
        const container = document.createElement('div');
        container.style.display = 'flex';
        container.style.alignItems = 'center';
        container.style.gap = '10px';

        const label = document.createElement('label');
        label.textContent = labelText;
        label.style.color = '#ccc';
        label.style.minWidth = '60px';

        const input = document.createElement('input');
        input.type = type;
        input.value = value;
        Object.assign(input.style, config.inputStyles);

        container.append(label, input);
        return container;
    }

    function createButton() {
        const button = document.createElement('button');
        button.textContent = 'Filter';
        Object.assign(button.style, config.buttonStyles);
        button.onclick = () => {
            filterGames(
                parseInt(groups.approval.minInput.value) || 0,
                parseInt(groups.approval.maxInput.value) || 100,
                parseInt(groups.players.minInput.value) || 0,
                parseInt(groups.players.maxInput.value) || Infinity
            );
        };
        return button;
    }
}

function filterGames(minApproval, maxApproval, minPlayers, maxPlayers) {
    document.querySelectorAll(config.selectors.gameCard).forEach(card => {
        const approvalMatch = checkApproval(card, minApproval, maxApproval);
        const playersMatch = checkPlayers(card, minPlayers, maxPlayers);
        card.style.display = (approvalMatch && playersMatch) ? '' : 'none';
    });

    function checkApproval(card, min, max) {
        const element = card.querySelector(config.selectors.approvalPercentage);
        if (!element) return true;
        const percent = parseInt(element.textContent.replace(/\D/g, ''));
        return percent >= min && percent <= max;
    }

    function checkPlayers(card, min, max) {
        const element = card.querySelector(config.selectors.activePlayers);
        if (!element) return true;
        const players = parseInt(element.textContent.replace(/\D/g, ''));
        return players >= min && players <= max;
    }
}

function makeDraggable(element) {
    let isDragging = false;
    let offset = [0, 0];

    element.addEventListener('mousedown', e => {
        if (e.target.tagName === 'INPUT') return;
        isDragging = true;
        offset = [e.clientX - element.offsetLeft, e.clientY - element.offsetTop];
        element.style.cursor = 'grabbing';
    });

    document.addEventListener('mousemove', e => {
        if (!isDragging) return;
        element.style.left = `${e.clientX - offset[0]}px`;
        element.style.top = `${e.clientY - offset[1]}px`;
    });

    document.addEventListener('mouseup', () => {
        isDragging = false;
        element.style.cursor = 'grab';
    });
}

// Init
new MutationObserver(() => {
    if (config.urlKeywords.some(kw => location.href.includes(kw))) {
        createInterface();
    }
}).observe(document.body, { subtree: true, childList: true });
})();