F95 Improvements

Allows you to set custom tags to games and also highlight certain tags automatically

// ==UserScript==
// @name         F95 Improvements
// @namespace    http://tampermonkey.net/
// @version      0.15
// @description  Allows you to set custom tags to games and also highlight certain tags automatically
// @author       Anon
// @license      MIT
// @match        https://f95zone.to/sam/latest_alpha/*
// @icon         https://icons.duckduckgo.com/ip2/f95zone.to.ico
// @grant        GM_info
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM.setValue
// @grant        GM.getValue
// @grant        GM_deleteValue
// @grant        GM_registerMenuCommand
// @noframes
// @run-at       document-start
// ==/UserScript==

let currentPage = location.href;

const tagId = "add_tag";
const tagDataKey = "tag_data";
const watchedBorderColor = "orange";

const getGeneralTagData = () => {
    let data = GM_getValue(tagDataKey, null);
    if (!data) return {
        good: [].map(tag => tag.toLowerCase().trim()),
        bad: [].map(tag => tag.toLowerCase().trim()),
        signalGoodTag: (el) => {
            el.style.backgroundColor = "green";
            el.style.color = "white";
        },
        signalBadTag: (el) => {
            el.style.backgroundColor = "yellow";
            el.style.color = "black";
        }
    };
    return JSON.parse(data);
};
const setGeneralTagData = (highlightedTagNamesArray, blacklistedTagNamesArray) => {
    const newData = {
        good: [...new Set(highlightedTagNamesArray)].map(tag => tag.toLowerCase().trim()),
        bad: [...new Set(blacklistedTagNamesArray)].map(tag => tag.toLowerCase().trim()),
        signalGoodTag: (el) => {
            el.style.backgroundColor = "green";
            el.style.color = "white";
        },
        signalBadTag: (el) => {
            el.style.backgroundColor = "yellow";
            el.style.color = "black";
        }
    };
    GM_setValue(tagDataKey, JSON.stringify(newData));
};
const tagData = getGeneralTagData();

const getCustomTagDataForTitle = (title) => { 
    let data = GM_getValue(title, null);
    return !data ? null : JSON.parse(data).map(base64Tag => atob(base64Tag)); 
};
const setCustomTagDataForTitle = (title, tagArray) => {
    GM_setValue(title, JSON.stringify(tagArray));
};
const removeCustomTagDataForTitle = (title) => { GM_deleteValue(title); }
const pageMatchesScriptMatchList = () => {
    const isMatch = GM_info.script.matches.some(match => {
        let _isMatch = match.indexOf('*') === -1 ? currentPage === match : currentPage.startsWith(match.split('*')[0]);
        return _isMatch;
    });
    return isMatch;
};
function waitForElement(selector, timeout = 0, observeDocumentOverride = document.documentElement) {
    return new Promise((resolve) => {
        //const el = document.querySelector(selector);
        const el = observeDocumentOverride.querySelector(selector);
        if (el) {resolve(el);}

        const observer = new MutationObserver((_, observer) => {
            // Query for elements matching the specified selector
            //const items = document.querySelectorAll(selector);
            const items = observeDocumentOverride.querySelectorAll(selector);
            if (items.length > 0) {
                observer.disconnect();
                resolve(items[0]);
            }
        });

        observer.observe(observeDocumentOverride, {
            childList: true,
            subtree: true
        });

        if (timeout > 0) {
            setTimeout(() => {
                observer.disconnect();
                resolve(null);
            }, timeout);
        }
    });
}
const sleep = async (ms) => new Promise(resolve => setTimeout(resolve, ms));

const main = async () => {
    if (!pageMatchesScriptMatchList()) { return; }

    const entryIdentifier = ".resource-tile";
    await sleep(500);
    await waitForElement(entryIdentifier);

    const entries = document.querySelectorAll(entryIdentifier);
    if (entries.length === 0) return;

    for (let i = 0; i < entries.length; i++) {
        const entry = entries[i];
        const title = entry.hasAttribute("data-thread-id") ? entry.getAttribute("data-thread-id") : entry?.querySelector(".header_title-wrap")?.innerText?.trim();
        const isWatched = entry.querySelector(".watch-icon");
        const customTags = getCustomTagDataForTitle(title);
        if (isWatched) entry.style.border = `2px solid ${watchedBorderColor}`;

        const addTagButton = (recievingElement, postTriggerRecievingElementModifier = null) => {
            let addTagbutton = postTriggerRecievingElementModifier ? postTriggerRecievingElementModifier(recievingElement).querySelector(`#${tagId}`) : recievingElement.querySelector(`#${tagId}`);
            if (addTagbutton) addTagbutton.remove();
            addTagbutton = document.createElement("span");

            addTagbutton.style.cssText = `margin-left:5px;width:auto;height:auto;cursor:pointer;position:absolute;text-align:center;font-size:16px;z-index:9998;`;
            addTagbutton.title = `Add/Remove custom tags for ${title}`;
            addTagbutton.textContent = '🆔';
            addTagbutton.addEventListener("click", async (e) => {
                const userInput = await customPrompt(`Modify the custom tags for this title (${title})`, customTags?.join(',') ?? null);
                if (userInput.action === "cancel") return;

                if (!userInput.value) removeCustomTagDataForTitle(title);
                else setCustomTagDataForTitle(title, userInput.value.split(',').map(plaintextTag => btoa(plaintextTag)));
            });
            const resolvedRecievingElement = (postTriggerRecievingElementModifier ? postTriggerRecievingElementModifier(recievingElement) : recievingElement);
            const addedNode = resolvedRecievingElement.appendChild(addTagbutton);
            if (postTriggerRecievingElementModifier) addedNode.style.zIndex = `${((parseInt(window.getComputedStyle(resolvedRecievingElement)?.zIndex ?? 0) || 0) + 2).toString()} !important`;
            else {
                addedNode.style.marginLeft = "";
                addedNode.style.top = "25px";
                addedNode.style.left = "5px";
            }
            return addedNode !== null;
        };
        if (customTags) addTagButton(entry);
        
        const onMouseEnter = async (event) => {
            const hoverPanel = await waitForElement(".resource-tile_hover-wrap", 2000, entry);
            if (!hoverPanel /* if waitForElement was called with a timeout value and null is returned, it has timed out */) return;

            const parent = hoverPanel.closest(entryIdentifier) || entry;
            
            // TODO: This adds it to the parent element for 'hoverPanel' only when hovered
            addTagButton(parent);
            
            const customTags = getCustomTagDataForTitle(title);
            let tags = (hoverPanel?.querySelector(".resource-tile_tags") ?? null)?.querySelectorAll("span") ?? [];
            if (customTags) {
                const spanParent = tags !== null ? tags[0].parentElement : hoverPanel.querySelector(".resource-tile_tags");
                customTags.forEach(customTag => {
                    const exists = tags.length <  1 ? false : Array.from(tags).some(span => (span?.innerText?.trim() ?? "") === customTag);
                    if (exists) return;
                    spanParent.insertAdjacentHTML("afterbegin", `<span style="background-color:purple;">${customTag.trim()}</span>`);
                });
            }

            if (tags === null) return;
            tags.forEach(span => {
                const tagText = span.innerText?.trim() ?? "";
                if (!tagText) return;

                if (tagData.good.includes(tagText)) tagData.signalGoodTag(span);
                if (tagData.bad.includes(tagText)) tagData.signalBadTag(span);
            });
        };
        entry.addEventListener("mouseenter", onMouseEnter, { once: false, passive: true, });
    }
};

await (async () => {
    'use strict'
    main();
    GM_registerMenuCommand("General Tag Highlighting Settings", async function(event) {
        const responseGood = await customPrompt("Enter the the tags (comma separated) you want to highlight", tagData.good.join(','));
        const responseBad = await customPrompt("Enter the the tags (comma separated) you want to blacklist/mark as 'bad'", tagData.bad.join(','));
        if (responseGood.action === "cancel" && responseBad.action === "cancel") return;
        const newGood = responseGood.action === "apply" ? responseGood.value.split(',') : tagData.good;
        const newBad = responseBad.action === "apply" ? responseBad.value.split(',') : tagData.bad;
        setGeneralTagData(newGood, newBad);   
      }, { autoClose: true});

    setInterval(() => { if (currentPage != location.href && pageMatchesScriptMatchList()) {
        currentPage = location.href;
        console.log(`F95 Tagger triggered for url: ${location.href}`);
        main();
    } }, 250);
})();

function customPrompt(message, defaultValue = null) {
    return new Promise((resolve) => {
        const modal = document.createElement('div');
        modal.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;justify-content:center;align-items:center;z-index:10000;';
        
        const box = document.createElement('div');
        box.style.cssText = 'background:white;padding:20px;border-radius:5px;min-width:300px;';
        
        const msg = document.createElement('p');
        msg.textContent = message;
        msg.style.color = "black";
        
        const input = document.createElement('input');
        input.style.cssText = 'width:100%;margin:10px 0;padding:5px;';
        if (typeof defaultValue === 'string') input.value = defaultValue;
        
        const buttonContainer = document.createElement('div');
        buttonContainer.style.cssText = 'display:flex;justify-content:flex-end;gap:10px;';
        
        const applyBtn = document.createElement('button');
        applyBtn.textContent = 'Apply';
        applyBtn.onclick = () => {
            modal.remove();
            resolve({ value: input.value, action: 'apply' });
        };
        
        const cancelBtn = document.createElement('button');
        cancelBtn.textContent = 'Cancel';
        cancelBtn.onclick = () => {
            modal.remove();
            resolve({ value: null, action: 'cancel' });
        };
        
        buttonContainer.append(cancelBtn, applyBtn);
        box.append(msg, input, buttonContainer);
        modal.appendChild(box);
        document.body.appendChild(modal);
        
        input.focus();
    });
}