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