LiveChart.me Minimum Rating Filter with Themed UI (Persistent)

Adds a minimum rating filter to anime list on LiveChart.me with styled UI and persistent value

// ==UserScript==
// @name         LiveChart.me Minimum Rating Filter with Themed UI (Persistent)
// @namespace    http://tampermonkey.net/
// @version      1.7
// @author       pedro-mass
// @copyright    2025, Pedro Mass (https://github.com/pedro-mass)
// @icon         https://www.google.com/s2/favicons?sz=64&domain=livechart.me
// @license      GNU GPLv3
// @description  Adds a minimum rating filter to anime list on LiveChart.me with styled UI and persistent value
// @match        https://www.livechart.me/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const STORAGE_KEY = 'pm_min_rating';

    init();

    function init() {
        addFilterUI();
    }

    function addFilterUI() {
        const container = document.querySelector('.options-bar-v2');
        if (!container) return;

        const existingUI = document.querySelector('.pm-rating-filter');
        if (existingUI) existingUI.remove();

        const label = document.createElement("label");
        label.textContent = "Minimum Rating:";
        label.classList.add('pm-rating-filter', 'option-v2');
        Object.assign(label.style, {
            display: "flex",
            gap: "0.5em",
            alignItems: "center",
            fontFamily: "sans-serif",
            fontSize: "0.9em"
        });

        const input = Object.assign(document.createElement("input"), {
            type: "number",
            placeholder: "7.6",
            min: "0",
            max: "10",
            step: "0.1",
            id: "pm-rating-input",
            value: localStorage.getItem(STORAGE_KEY) || ""
        });
        Object.assign(input.style, {
            width: "4em",
            padding: "0.25em 0.35em",
            border: "1px solid #ccc",
            borderRadius: "4px",
            fontSize: "0.9em",
            transition: "border-color 0.2s",
            backgroundColor: "#fff"
        });
        label.htmlFor = input.id;
        label.appendChild(input);

        const filters = container.querySelectorAll('.option-v2.hide-for-small-only');
        const lastFilter = filters[filters.length - 1];
        if (!lastFilter) return console.error("Could NOT find the filters to add onto");
        lastFilter.after(label);

        // Input listener
        input.addEventListener("input", debounce((event) => {
            const value = toNumber(event.target.value, 0);
            filterAnimes(value);
            // Save to localStorage
            localStorage.setItem(STORAGE_KEY, value);
        }, 300));

        // Apply filter on page load if a value is saved
        const savedValue = toNumber(localStorage.getItem(STORAGE_KEY), 0);
        if (savedValue > 0) {
            filterAnimes(savedValue);
        }
    }

    function filterAnimes(minRating = 0) {
        const animes = document.querySelectorAll('.anime');
        animes.forEach(anime => {
            const rating = parseFloat(anime.querySelector('.anime-avg-user-rating')?.innerText) || 0;
            if (rating < minRating) {
                hide(anime);
            } else {
                show(anime);
            }
        });
    }

    function debounce(func, delay) {
        let timeoutId;
        return function(...args) {
            clearTimeout(timeoutId);
            timeoutId = setTimeout(() => func.apply(this, args), delay);
        };
    }

    function toNumber(input, defaultValue = 0) {
        const number = parseFloat(input);
        return Number.isNaN(number) ? defaultValue : number;
    }

    function hide(element) { element.style.display = "none"; }
    function show(element) { element.style.display = ""; }

})();