VoidVerified

Display a verified sign next to user's name in AniList.

当前为 2023-11-23 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name          VoidVerified
// @version       1.4.1
// @namespace     http://tampermonkey.net/
// @author        voidnyan
// @description   Display a verified sign next to user's name in AniList.
// @homepageURL   https://github.com/voidnyan/void-verified#voidverified
// @supportURL    https://github.com/voidnyan/void-verified/issues
// @grant         none
// @match         https://anilist.co/*
// @license MIT
// ==/UserScript==

(function () {
	'use strict';

	const categories = {
		users: "users",
		paste: "paste",
		misc: "misc",
	};

	const defaultSettings = {
		copyColorFromProfile: {
			defaultValue: true,
			description: "Copy user color from their profile.",
			category: categories.users,
		},
		moveSubscribeButtons: {
			defaultValue: false,
			description:
				"Move activity subscribe button next to comments and likes.",
			category: categories.misc,
		},
		hideLikeCount: {
			defaultValue: false,
			description: "Hide activity and reply like counts.",
			category: categories.misc,
		},
		enabledForUsername: {
			defaultValue: true,
			description: "Display a verified sign next to usernames.",
			category: categories.users,
		},
		enabledForProfileName: {
			defaultValue: false,
			description: "Display a verified sign next to a profile name.",
			category: categories.users,
		},
		defaultSign: {
			defaultValue: "✔",
			description: "The default sign displayed next to a username.",
			category: categories.users,
		},
		highlightEnabled: {
			defaultValue: true,
			description: "Highlight user activity with a border.",
			category: categories.users,
		},
		highlightEnabledForReplies: {
			defaultValue: true,
			description: "Highlight replies with a border.",
			category: categories.users,
		},
		highlightSize: {
			defaultValue: "5px",
			description: "Width of the highlight border.",
			category: categories.users,
		},
		colorUserActivity: {
			defaultValue: false,
			description: "Color user activity links with user color.",
			category: categories.users,
		},
		colorUserReplies: {
			defaultValue: false,
			description: "Color user reply links with user color.",
			category: categories.users,
		},
		useDefaultHighlightColor: {
			defaultValue: false,
			description:
				"Use fallback highlight color when user color is not specified.",
			category: categories.users,
		},
		defaultHighlightColor: {
			defaultValue: "#FFFFFF",
			description: "Fallback highlight color.",
			category: categories.users,
		},
		globalCssEnabled: {
			defaultValue: false,
			description: "Enable custom global CSS.",
			category: categories.misc,
		},
		globalCssAutoDisable: {
			defaultValue: true,
			description: "Disable global CSS when a profile has custom CSS.",
			category: categories.misc,
		},
		quickAccessEnabled: {
			defaultValue: false,
			description: "Display quick access of users in home page.",
			category: categories.users,
		},
		pasteEnabled: {
			defaultValue: false,
			description:
				"Automatically wrap pasted links and images with link and image tags.",
			category: categories.paste,
		},
		pasteWrapImagesWithLink: {
			defaultValue: false,
			description: "Wrap images with a link tag.",
			category: categories.paste,
		},
		// pasteRequireKeyPress: {
		// 	defaultValue: true,
		// 	description: "Require an additional key to be pressed while pasting.",
		// 	category: categories.paste,
		// },
		// pasteKeybind: {
		// 	defaultValue: "Shift",
		// 	description: "The key to be pressed while pasting.",
		// 	category: categories.paste,
		// },
		pasteImageWidth: {
			defaultValue: "420",
			description: "Width used when pasting images.",
			category: categories.paste,
		},
		pasteImagesToHostService: {
			defaultValue: false,
			description:
				"Upload image from the clipboard to image host (configure below).",
			category: categories.paste,
		},
	};

	class ColorFunctions {
		static hexToRgb(hex) {
			const r = parseInt(hex.slice(1, 3), 16);
			const g = parseInt(hex.slice(3, 5), 16);
			const b = parseInt(hex.slice(5, 7), 16);

			return `${r}, ${g}, ${b}`;
		}

		static rgbToHex(rgb) {
			const [r, g, b] = rgb.split(",");
			const hex = this.generateHex(r, g, b);
			return hex;
		}

		static generateHex(r, g, b) {
			return (
				"#" +
				[r, g, b]
					.map((x) => {
						const hex = Number(x).toString(16);
						return hex.length === 1 ? "0" + hex : hex;
					})
					.join("")
			);
		}
	}

	class AnilistAPI {
		apiQueryTimeoutInMinutes = 30;
		apiQueryTimeout = this.apiQueryTimeoutInMinutes * 60 * 1000;

		settings;
		constructor(settings) {
			this.settings = settings;
		}

		queryUserData() {
			this.#createUserQuery();
		}

		async #createUserQuery() {
			let stopQueries = false;

			for (const user of this.#getUsersToQuery()) {
				if (stopQueries) {
					break;
				}

				stopQueries = this.#queryUser(user);
			}
		}

		#userQuery = `
        query ($username: String) {
            User(name: $username) {
                name
                avatar {
                    large
                }
                options {
                    profileColor
                }
            }
        }
    `;

		#queryUser(user) {
			const variables = {
				username: user.username,
			};

			const url = "https://graphql.anilist.co";
			const options = {
				method: "POST",
				headers: {
					"Content-Type": "application/json",
					Accept: "application/json",
				},
				body: JSON.stringify({
					query: this.#userQuery,
					variables,
				}),
			};

			let stopQueries = false;

			fetch(url, options)
				.then(this.#handleResponse)
				.then((data) => {
					const resultUser = data.User;
					this.settings.updateUserFromApi(user, resultUser);
				})
				.catch((err) => {
					console.error(err);
					stopQueries = true;
				});

			return stopQueries;
		}

		#getUsersToQuery() {
			if (
				this.settings.options.copyColorFromProfile.getValue() ||
				this.settings.options.quickAccessEnabled.getValue()
			) {
				return this.#filterUsersByLastFetch();
			}

			const users = this.settings.verifiedUsers.filter(
				(user) => user.copyColorFromProfile || user.quickAccessEnabled
			);

			return this.#filterUsersByLastFetch(users);
		}

		#handleResponse(response) {
			return response.json().then((json) => {
				return response.ok ? json.data : Promise.reject(json);
			});
		}

		#filterUsersByLastFetch(users = null) {
			const currentDate = new Date();
			if (users) {
				return users.filter(
					(user) =>
						!user.lastFetch ||
						currentDate - new Date(user.lastFetch) >
							this.apiQueryTimeout
				);
			}
			return this.settings.verifiedUsers.filter(
				(user) =>
					!user.lastFetch ||
					currentDate - new Date(user.lastFetch) > this.apiQueryTimeout
			);
		}
	}

	class Option {
		value;
		defaultValue;
		description;
		category;
		constructor(option) {
			this.defaultValue = option.defaultValue;
			this.description = option.description;
			this.category = option.category;
		}

		getValue() {
			if (this.value === "") {
				return this.defaultValue;
			}
			return this.value ?? this.defaultValue;
		}
	}

	class Settings {
		localStorageUsers = "void-verified-users";
		localStorageSettings = "void-verified-settings";
		version = GM_info.script.version;

		verifiedUsers = [];

		options = {};

		constructor() {
			this.verifiedUsers =
				JSON.parse(localStorage.getItem(this.localStorageUsers)) ?? [];

			const settingsInLocalStorage =
				JSON.parse(localStorage.getItem(this.localStorageSettings)) ?? {};

			for (const [key, value] of Object.entries(defaultSettings)) {
				this.options[key] = new Option(value);
			}

			for (const [key, value] of Object.entries(settingsInLocalStorage)) {
				if (!this.options[key]) {
					continue;
				}
				this.options[key].value = value.value;
			}
		}

		verifyUser(username) {
			if (
				this.verifiedUsers.find(
					(user) => user.username.toLowerCase() === username.toLowerCase()
				)
			) {
				return;
			}

			this.verifiedUsers.push({ username });
			localStorage.setItem(
				this.localStorageUsers,
				JSON.stringify(this.verifiedUsers)
			);

			const anilistAPI = new AnilistAPI(this);
			anilistAPI.queryUserData();
		}

		updateUserOption(username, key, value) {
			this.verifiedUsers = this.verifiedUsers.map((u) =>
				u.username === username
					? {
							...u,
							[key]: value,
					  }
					: u
			);
			localStorage.setItem(
				this.localStorageUsers,
				JSON.stringify(this.verifiedUsers)
			);
		}

		updateUserFromApi(user, apiUser) {
			const newUser = this.#mapApiUser(user, apiUser);
			this.verifiedUsers = this.verifiedUsers.map((u) =>
				u.username.toLowerCase() === user.username.toLowerCase()
					? newUser
					: u
			);

			localStorage.setItem(
				this.localStorageUsers,
				JSON.stringify(this.verifiedUsers)
			);
		}

		#mapApiUser(user, apiUser) {
			let userObject = { ...user };

			userObject.color = this.#handleAnilistColor(
				apiUser.options.profileColor
			);

			userObject.username = apiUser.name;
			userObject.avatar = apiUser.avatar.large;
			userObject.lastFetch = new Date();

			return userObject;
		}

		removeUser(username) {
			this.verifiedUsers = this.verifiedUsers.filter(
				(user) => user.username !== username
			);
			localStorage.setItem(
				this.localStorageUsers,
				JSON.stringify(this.verifiedUsers)
			);
		}

		saveSettingToLocalStorage(key, value) {
			let localSettings = JSON.parse(
				localStorage.getItem(this.localStorageSettings)
			);

			this.options[key].value = value;

			if (localSettings === null) {
				const settings = {
					[key]: value,
				};
				localStorage.setItem(
					this.localStorageSettings,
					JSON.stringify(settings)
				);
				return;
			}

			localSettings[key] = { value };
			localStorage.setItem(
				this.localStorageSettings,
				JSON.stringify(localSettings)
			);
		}

		#defaultColors = [
			"gray",
			"blue",
			"purple",
			"green",
			"orange",
			"red",
			"pink",
		];

		#defaultColorRgb = {
			gray: "103, 123, 148",
			blue: "61, 180, 242",
			purple: "192, 99, 255",
			green: "76, 202, 81",
			orange: "239, 136, 26",
			red: "225, 51, 51",
			pink: "252, 157, 214",
		};

		#handleAnilistColor(color) {
			if (this.#defaultColors.includes(color)) {
				return this.#defaultColorRgb[color];
			}

			return ColorFunctions.hexToRgb(color);
		}
	}

	class StyleHandler {
		settings;
		usernameStyles = "";
		highlightStyles = "";
		otherStyles = "";

		profileLink = this.createStyleLink("", "profile");

		constructor(settings) {
			this.settings = settings;
		}

		refreshStyles() {
			this.createStyles();
			this.createStyleLink(this.usernameStyles, "username");
			this.createStyleLink(this.highlightStyles, "highlight");
			this.createStyleLink(this.otherStyles, "other");
		}

		createStyles() {
			this.usernameStyles = "";
			this.otherStyles = "";

			for (const user of this.settings.verifiedUsers) {
				if (
					this.settings.options.enabledForUsername.getValue() ||
					user.enabledForUsername
				) {
					this.createUsernameCSS(user);
				}
			}

			if (this.settings.options.moveSubscribeButtons.getValue()) {
				this.otherStyles += `
                .has-label::before {
                top: -30px !important;
                left: unset !important;
                right: -10px;
                }
    
                .has-label[label="Unsubscribe"],
                .has-label[label="Subscribe"] {
                font-size: 0.875em !important;
                }
    
                .has-label[label="Unsubscribe"] {
                color: rgba(var(--color-green),.8);
                }
                `;
			}

			this.createHighlightStyles();

			if (this.settings.options.hideLikeCount.getValue()) {
				this.otherStyles += `
                    .like-wrap .count {
                        display: none;
                    }
                `;
			}
		}

		createHighlightStyles() {
			this.highlightStyles = "";
			for (const user of this.settings.verifiedUsers) {
				if (
					this.settings.options.highlightEnabled.getValue() ||
					user.highlightEnabled
				) {
					this.createHighlightCSS(
						user,
						`div.wrap:has( div.header > a.name[href*="/${user.username}/" i] )`
					);
					this.createHighlightCSS(
						user,
						`div.wrap:has( div.details > a.name[href*="/${user.username}/" i] )`
					);
				}

				if (
					this.settings.options.highlightEnabledForReplies.getValue() ||
					user.highlightEnabledForReplies
				) {
					this.createHighlightCSS(
						user,
						`div.reply:has( a.name[href*="/${user.username}/" i] )`
					);
				}

				this.#createActivityCss(user);
			}

			this.disableHighlightOnSmallCards();
		}

		#createActivityCss(user) {
			const colorUserActivity =
				this.settings.options.colorUserActivity.getValue() ??
				user.colorUserActivity;
			const colorUserReplies =
				this.settings.options.colorUserReplies.getValue() ??
				user.colorUserReplies;

			if (colorUserActivity) {
				this.highlightStyles += `
                div.wrap:has( div.header > a.name[href*="/${
					user.username
				}/"]) a,
                div.wrap:has( div.details > a.name[href*="/${
					user.username
				}/"]) a
                {
                    color: ${this.getUserColor(user)};
                }
            `;
			}
			if (colorUserReplies) {
				this.highlightStyles += `
                .reply:has(a.name[href*="/${user.username}/"]) a,
                .reply:has(a.name[href*="/${
					user.username
				}/"]) .markdown-spoiler::before
                {
                    color: ${this.getUserColor(user)};
                }
            `;
			}
		}

		createUsernameCSS(user) {
			this.usernameStyles += `
            a.name[href*="/${user.username}/" i]::after {
                content: "${
					this.stringIsEmpty(user.sign) ??
					this.settings.options.defaultSign.getValue()
				}";
                color: ${this.getUserColor(user) ?? "rgb(var(--color-blue))"}
            }
        `;
		}

		createHighlightCSS(user, selector) {
			this.highlightStyles += `
                ${selector} {
                    margin-right: -${this.settings.options.highlightSize.getValue()};
                    border-right: ${this.settings.options.highlightSize.getValue()} solid ${
			this.getUserColor(user) ?? this.getDefaultHighlightColor()
		};
                    border-radius: 5px;
                }
                `;
		}

		disableHighlightOnSmallCards() {
			this.highlightStyles += `
                div.wrap:has(div.small) {
                margin-right: 0px !important;
                border-right: 0px solid black !important;
                }
                `;
		}

		refreshHomePage() {
			if (!this.settings.options.highlightEnabled.getValue()) {
				return;
			}
			this.createHighlightStyles();
			this.createStyleLink(this.highlightStyles, "highlight");
		}

		clearProfileVerify() {
			this.profileLink.href =
				"data:text/css;charset=UTF-8," + encodeURIComponent("");
		}

		clearStyles(id) {
			const styles = document.getElementById(`void-verified-${id}-styles`);
			styles?.remove();
		}

		verifyProfile() {
			if (!this.settings.options.enabledForProfileName.getValue()) {
				return;
			}

			const usernameHeader = document.querySelector("h1.name");
			const username = usernameHeader.innerHTML.trim();

			const user = this.settings.verifiedUsers.find(
				(u) => u.username.toLowerCase() === username.toLowerCase()
			);

			if (!user) {
				this.clearProfileVerify();
				return;
			}

			const profileStyle = `
                    .name-wrapper h1.name::after {
                    content: "${
						this.stringIsEmpty(user.sign) ??
						this.settings.options.defaultSign.getValue()
					}"
                    }
                `;
			this.profileLink = this.createStyleLink(profileStyle, "profile");
		}

		copyUserColor() {
			const usernameHeader = document.querySelector("h1.name");
			const username = usernameHeader.innerHTML.trim();
			const user = this.settings.verifiedUsers.find(
				(u) => u.username === username
			);

			if (!user) {
				return;
			}

			if (
				!(
					user.copyColorFromProfile ||
					this.settings.options.copyColorFromProfile.getValue()
				)
			) {
				return;
			}

			const color =
				getComputedStyle(usernameHeader).getPropertyValue("--color-blue");

			this.settings.updateUserOption(user.username, "color", color);
		}

		getUserColor(user) {
			return (
				user.colorOverride ??
				(user.color &&
				(user.copyColorFromProfile ||
					this.settings.options.copyColorFromProfile.getValue())
					? `rgb(${user.color})`
					: undefined)
			);
		}

		getDefaultHighlightColor() {
			if (this.settings.options.useDefaultHighlightColor.getValue()) {
				return this.settings.options.defaultHighlightColor.getValue();
			}
			return "rgb(var(--color-blue))";
		}

		createStyleLink(styles, id) {
			const oldLink = document.getElementById(`void-verified-${id}-styles`);
			const link = document.createElement("link");
			link.setAttribute("id", `void-verified-${id}-styles`);
			link.setAttribute("rel", "stylesheet");
			link.setAttribute("type", "text/css");
			link.setAttribute(
				"href",
				"data:text/css;charset=UTF-8," + encodeURIComponent(styles)
			);
			document.head?.append(link);
			oldLink?.remove();
			return link;
		}

		stringIsEmpty(string) {
			if (!string || string.length === 0) {
				return undefined;
			}
			return string;
		}
	}

	class GlobalCSS {
		settings;
		styleHandler;

		styleId = "global-css";
		isCleared = false;

		cssInLocalStorage = "void-verified-global-css";
		constructor(settings) {
			this.settings = settings;
			this.styleHandler = new StyleHandler(settings);

			this.css = localStorage.getItem(this.cssInLocalStorage) ?? "";
		}

		createCss() {
			if (!this.settings.options.globalCssEnabled.getValue()) {
				this.styleHandler.clearStyles(this.styleId);
				return;
			}

			if (!this.shouldRender()) {
				return;
			}

			this.isCleared = false;
			this.styleHandler.createStyleLink(this.css, this.styleId);
		}

		updateCss(css) {
			this.css = css;
			this.createCss();
			localStorage.setItem(this.cssInLocalStorage, css);
		}

		clearCssForProfile() {
			if (this.isCleared) {
				return;
			}
			if (!this.shouldRender()) {
				this.styleHandler.clearStyles(this.styleId);
				this.isCleared = true;
			}
		}

		shouldRender() {
			if (window.location.pathname.startsWith("/settings")) {
				return false;
			}

			if (!this.settings.options.globalCssAutoDisable.getValue()) {
				return true;
			}

			if (!window.location.pathname.startsWith("/user/")) {
				return true;
			}

			const profileCustomCss = document.getElementById(
				"customCSS-automail-styles"
			);

			if (!profileCustomCss) {
				return true;
			}

			const shouldRender = profileCustomCss.innerHTML.trim().length === 0;
			return shouldRender;
		}
	}

	class ActivityHandler {
		settings;
		constructor(settings) {
			this.settings = settings;
		}

		moveAndDisplaySubscribeButton() {
			if (!this.settings.options.moveSubscribeButtons.getValue()) {
				return;
			}

			const subscribeButtons = document.querySelectorAll(
				"span[label='Unsubscribe'], span[label='Subscribe']"
			);
			for (const subscribeButton of subscribeButtons) {
				if (subscribeButton.parentNode.classList.contains("actions")) {
					continue;
				}

				const container = subscribeButton.parentNode.parentNode;
				const actions = container.querySelector(".actions");
				actions.append(subscribeButton);
			}
		}
	}

	const imageHosts = {
		imgbb: "imgbb",
	};

	const imageHostConfiguration = {
		selectedHost: imageHosts.imgbb,
		configurations: {
			imgbb: {
				name: "imgbb",
				apiKey: "",
			},
		},
	};

	class ImageHostService {
		#configuration;
		#localStorage = "void-verified-image-host-config";
		constructor() {
			const config = JSON.parse(localStorage.getItem(this.#localStorage));
			if (!config) {
				localStorage.setItem(
					this.#localStorage,
					JSON.stringify(imageHostConfiguration)
				);
			}
			this.#configuration = config ?? imageHostConfiguration;

		}

		getImageHostConfiguration(host) {
			return this.#configuration.configurations[host];
		}

		getSelectedHost() {
			return this.#configuration.selectedHost;
		}

		setSelectedHost(host) {
			this.#configuration.selectedHost = host;
			localStorage.setItem(
				this.#localStorage,
				JSON.stringify(this.#configuration)
			);
		}

		setImageHostConfiguration(host, config) {
			this.#configuration.configurations[host] = config;
			localStorage.setItem(
				this.#localStorage,
				JSON.stringify(this.#configuration)
			);
		}
	}

	class ImageHostBase {
		conventToBase64(image) {
			return new Promise(function (resolve, reject) {
				var reader = new FileReader();
				reader.onloadend = function (e) {
					resolve({
						fileName: this.name,
						result: e.target.result,
						error: e.target.error,
					});
				};
				reader.readAsDataURL(image);
			});
		}
	}

	class ImgbbAPI extends ImageHostBase {
		#url = "https://api.imgbb.com/1/upload";
		#configuration;
		constructor(configuration) {
			super();
			this.#configuration = configuration;
		}

		async uploadImage(image) {
			const file = await this.conventToBase64(image);
			if (!file.result) {
				return;
			}

			if (!this.#configuration.apiKey) {
				return;
			}

			const base64 = file.result.split("base64,")[1];

			const settings = {
				method: "POST",
				headers: {
					Accept: "application/json",
					"Content-Type": "application/x-www-form-urlencoded",
				},
				body: "image=" + encodeURIComponent(base64),
			};

			try {
				const response = await fetch(
					`${this.#url}?key=${this.#configuration.apiKey}`,
					settings
				);
				const data = await response.json();
				return data;
			} catch (error) {
				console.error(error);
				return error;
			}
		}

		renderSettings() {
			const container = document.createElement("div");
			const apiKeyInput = document.createElement("input");
			apiKeyInput.setAttribute("type", "text");

			const label = document.createElement("label");
			label.append("API key");
			label.setAttribute("class", "void-api-label");
			container.append(label);

			apiKeyInput.setAttribute("value", this.#configuration.apiKey);
			apiKeyInput.addEventListener("change", (event) =>
				this.#updateApiKey(event, this.#configuration)
			);
			apiKeyInput.setAttribute("class", "void-api-key");
			container.append(apiKeyInput);

			const note = document.createElement("div");
			note.append("You need to get the API key from the following link: ");

			note.setAttribute("class", "void-notice");

			const apiKeyLink = document.createElement("a");
			apiKeyLink.setAttribute("href", "https://api.imgbb.com/");
			apiKeyLink.append("api.imgbb.com");
			apiKeyLink.setAttribute("target", "_blank");
			note.append(apiKeyLink);

			container.append(note);

			return container;
		}

		#updateApiKey(event, configuration) {
			const apiKey = event.target.value;
			const config = {
				...configuration,
				apiKey,
			};
			new ImageHostService().setImageHostConfiguration(config.name, config);
		}
	}

	class ImageApiFactory {
		getImageHostInstance() {
			const imageHostService = new ImageHostService();
			switch (imageHostService.getSelectedHost()) {
				case imageHosts.imgbb:
					return new ImgbbAPI(
						imageHostService.getImageHostConfiguration(imageHosts.imgbb)
					);
			}
		}
	}

	class SettingsUserInterface {
		settings;
		styleHandler;
		globalCSS;
		AnilistBlue = "120, 180, 255";
		#activeCategory = "all";

		constructor(settings, styleHandler, globalCSS) {
			this.settings = settings;
			this.styleHandler = styleHandler;
			this.globalCSS = globalCSS;
		}

		renderSettingsUi() {
			const container = document.querySelector(
				".settings.container > .content"
			);
			const settingsContainer = document.createElement("div");
			settingsContainer.setAttribute("id", "voidverified-settings");
			settingsContainer.setAttribute("class", "void-settings");
			this.#renderSettingsHeader(settingsContainer);

			this.#renderCategories(settingsContainer);
			this.#renderOptions(settingsContainer);
			this.#renderUserTable(settingsContainer);
			this.#renderCustomCssEditor(settingsContainer);

			const imageHostContainer = document.createElement("div");
			imageHostContainer.setAttribute("id", "void-verified-image-host");
			settingsContainer.append(imageHostContainer);

			this.#renderImageHostSettings(imageHostContainer);

			container.append(settingsContainer);
		}

		#renderOptions(settingsContainer) {
			const oldSettingsListContainer =
				document.getElementById("void-settings-list");
			const settingsListContainer =
				oldSettingsListContainer ?? document.createElement("div");
			settingsListContainer.innerHTML = "";
			settingsListContainer.setAttribute("id", "void-settings-list");
			settingsListContainer.setAttribute("class", "void-settings-list");

			for (const [key, setting] of Object.entries(this.settings.options)) {
				if (
					setting.category !== this.#activeCategory &&
					this.#activeCategory !== "all"
				) {
					continue;
				}
				this.#renderSetting(setting, settingsListContainer, key);
			}

			oldSettingsListContainer ??
				settingsContainer.append(settingsListContainer);
		}

		removeSettingsUi() {
			const settings = document.querySelector("#voidverified-settings");
			settings?.remove();
		}

		#renderSettingsHeader(settingsContainer) {
			const headerContainer = document.createElement("div");
			headerContainer.setAttribute("class", "void-settings-header");
			const header = document.createElement("h1");
			header.innerText = "VoidVerified settings";

			const versionInfo = document.createElement("p");
			versionInfo.append("Version: ");
			const versionNumber = document.createElement("span");
			versionNumber.append(this.settings.version);

			versionInfo.append(versionNumber);

			headerContainer.append(header);
			headerContainer.append(versionInfo);
			settingsContainer.append(headerContainer);
		}

		#renderCategories(settingsContainer) {
			const oldNav = document.querySelector(".void-nav");
			const nav = oldNav ?? document.createElement("nav");

			nav.innerHTML = "";

			nav.setAttribute("class", "void-nav");
			const list = document.createElement("ol");

			list.append(this.#createNavBtn("all"));

			for (const category of Object.values(categories)) {
				list.append(this.#createNavBtn(category));
			}

			nav.append(list);
			oldNav ?? settingsContainer.append(nav);
		}

		#createNavBtn(category) {
			const li = document.createElement("li");
			li.append(category);
			if (category === this.#activeCategory) {
				li.setAttribute("class", "void-active");
			}

			li.addEventListener("click", () => {
				this.#activeCategory = category;
				this.#renderCategories();
				this.#renderOptions();
			});

			return li;
		}

		#renderUserTable(settingsContainer) {
			const oldTableContainer = document.querySelector(
				"#void-verified-user-table"
			);
			const tableContainer =
				oldTableContainer ?? document.createElement("div");
			tableContainer.innerHTML = "";
			tableContainer.setAttribute("class", "void-table");

			tableContainer.setAttribute("id", "void-verified-user-table");

			tableContainer.style = `
            margin-top: 25px;
        `;

			const table = document.createElement("table");
			const head = document.createElement("thead");
			const headrow = document.createElement("tr");
			headrow.append(this.#createCell("Username", "th"));
			headrow.append(this.#createCell("Sign", "th"));
			headrow.append(this.#createCell("Color", "th"));
			headrow.append(this.#createCell("Other", "th"));

			head.append(headrow);

			const body = document.createElement("tbody");

			for (const user of this.settings.verifiedUsers) {
				body.append(this.#createUserRow(user));
			}

			table.append(head);
			table.append(body);
			tableContainer.append(table);

			const inputForm = document.createElement("form");
			inputForm.addEventListener("submit", (event) =>
				this.#handleVerifyUserForm(event, this.settings)
			);
			const label = document.createElement("label");
			label.innerText = "Add user";
			inputForm.append(label);
			const textInput = document.createElement("input");
			textInput.setAttribute("id", "voidverified-add-user");

			inputForm.append(textInput);
			tableContainer.append(inputForm);

			oldTableContainer || settingsContainer.append(tableContainer);
		}

		#createUserRow(user) {
			const row = document.createElement("tr");
			const userLink = document.createElement("a");
			userLink.innerText = user.username;
			userLink.setAttribute(
				"href",
				`https://anilist.co/user/${user.username}/`
			);
			userLink.setAttribute("target", "_blank");
			row.append(this.#createCell(userLink));

			const signInput = document.createElement("input");
			signInput.setAttribute("type", "text");
			signInput.value = user.sign ?? "";
			signInput.addEventListener("input", (event) =>
				this.#updateUserOption(user.username, "sign", event.target.value)
			);
			const signCell = this.#createCell(signInput);
			signCell.append(
				this.#createUserCheckbox(
					user.enabledForUsername,
					user.username,
					"enabledForUsername",
					this.settings.options.enabledForUsername.getValue()
				)
			);

			row.append(this.#createCell(signCell));

			const colorInputContainer = document.createElement("div");

			const colorInput = document.createElement("input");
			colorInput.setAttribute("type", "color");
			colorInput.value = this.#getUserColorPickerColor(user);
			colorInput.addEventListener(
				"change",
				(event) => this.#handleUserColorChange(event, user.username),
				false
			);

			colorInputContainer.append(colorInput);

			const resetColorBtn = document.createElement("button");
			resetColorBtn.innerText = "🔄";
			resetColorBtn.addEventListener("click", () =>
				this.#handleUserColorReset(user.username)
			);

			colorInputContainer.append(resetColorBtn);

			colorInputContainer.append(
				this.#createUserCheckbox(
					user.copyColorFromProfile,
					user.username,
					"copyColorFromProfile",
					this.settings.options.copyColorFromProfile.getValue()
				)
			);

			colorInputContainer.append(
				this.#createUserCheckbox(
					user.highlightEnabled,
					user.username,
					"highlightEnabled",
					this.settings.options.highlightEnabled.getValue()
				)
			);

			colorInputContainer.append(
				this.#createUserCheckbox(
					user.highlightEnabledForReplies,
					user.username,
					"highlightEnabledForReplies",
					this.settings.options.highlightEnabledForReplies.getValue()
				)
			);

			colorInputContainer.append(
				this.#createUserCheckbox(
					user.colorUserActivity,
					user.username,
					"colorUserActivity",
					this.settings.options.colorUserActivity.getValue()
				)
			);

			colorInputContainer.append(
				this.#createUserCheckbox(
					user.colorUserReplies,
					user.username,
					"colorUserReplies",
					this.settings.options.colorUserReplies.getValue()
				)
			);

			const colorCell = this.#createCell(colorInputContainer);
			row.append(colorCell);

			const quickAccessCheckbox = this.#createUserCheckbox(
				user.quickAccessEnabled,
				user.username,
				"quickAccessEnabled",
				this.settings.options.quickAccessEnabled.getValue()
			);
			row.append(this.#createCell(quickAccessCheckbox));

			const deleteButton = document.createElement("button");
			deleteButton.innerText = "❌";
			deleteButton.addEventListener("click", () =>
				this.#removeUser(user.username)
			);
			row.append(this.#createCell(deleteButton));
			return row;
		}

		#getUserColorPickerColor(user) {
			if (user.colorOverride) {
				return user.colorOverride;
			}

			if (
				user.color &&
				(user.copyColorFromProfile ||
					this.settings.options.copyColorFromProfile.getValue())
			) {
				return ColorFunctions.rgbToHex(user.color);
			}

			if (this.settings.options.useDefaultHighlightColor.getValue()) {
				return this.settings.options.defaultHighlightColor.getValue();
			}

			return ColorFunctions.rgbToHex(this.AnilistBlue);
		}

		#createUserCheckbox(isChecked, username, settingKey, disabled) {
			const checkbox = document.createElement("input");
			if (disabled) {
				checkbox.setAttribute("disabled", "");
			}

			checkbox.setAttribute("type", "checkbox");
			checkbox.checked = isChecked;
			checkbox.addEventListener("change", (event) => {
				this.#updateUserOption(username, settingKey, event.target.checked);
				this.#refreshUserTable();
			});

			checkbox.title = this.settings.options[settingKey].description;
			return checkbox;
		}

		#handleUserColorReset(username) {
			this.#updateUserOption(username, "colorOverride", undefined);
			this.#refreshUserTable();
		}

		#handleUserColorChange(event, username) {
			const color = event.target.value;
			this.#updateUserOption(username, "colorOverride", color);
		}

		#handleVerifyUserForm(event, settings) {
			event.preventDefault();

			const usernameInput = document.getElementById("voidverified-add-user");
			const username = usernameInput.value;
			settings.verifyUser(username);
			usernameInput.value = "";
			this.#refreshUserTable();
		}

		#refreshUserTable() {
			const container = document.querySelector(
				".settings.container > .content"
			);
			this.#renderUserTable(container);
		}

		#updateUserOption(username, key, value) {
			this.settings.updateUserOption(username, key, value);
			this.styleHandler.refreshStyles();
		}

		#removeUser(username) {
			this.settings.removeUser(username);
			this.#refreshUserTable();
			this.styleHandler.refreshStyles();
		}

		#createCell(content, elementType = "td") {
			const cell = document.createElement(elementType);
			cell.append(content);
			return cell;
		}

		#renderSetting(setting, settingsContainer, settingKey, disabled = false) {
			const value = setting.getValue();
			const type = typeof value;

			const container = document.createElement("div");
			const input = document.createElement("input");

			if (type === "boolean") {
				input.setAttribute("type", "checkbox");
			} else if (settingKey == "defaultHighlightColor") {
				input.setAttribute("type", "color");
			} else if (type === "string") {
				input.setAttribute("type", "text");
			}

			if (disabled) {
				input.setAttribute("disabled", "");
			}

			input.setAttribute("id", settingKey);

			if (settingKey === "pasteKeybind") {
				input.style.width = "80px";
				input.addEventListener("keydown", (event) =>
					this.#handleKeybind(event, settingKey, input)
				);
			} else {
				input.addEventListener("change", (event) =>
					this.#handleOption(event, settingKey, type)
				);
			}

			if (type === "boolean" && value) {
				input.setAttribute("checked", true);
			} else if (type === "string") {
				input.value = value;
			}

			container.append(input);

			const label = document.createElement("label");
			label.setAttribute("for", settingKey);
			label.innerText = setting.description;
			container.append(label);
			settingsContainer.append(container);
		}

		#handleKeybind(event, settingKey, input) {
			event.preventDefault();
			const keybind = event.key;
			this.settings.saveSettingToLocalStorage(settingKey, keybind);
			input.value = keybind;
		}

		#handleOption(event, settingKey, type) {
			const value =
				type === "boolean" ? event.target.checked : event.target.value;
			this.settings.saveSettingToLocalStorage(settingKey, value);
			this.styleHandler.refreshStyles();
			this.#refreshUserTable();
		}

		#renderCustomCssEditor(settingsContainer) {
			const container = document.createElement("div");
			container.setAttribute("class", "void-css-editor");
			const label = document.createElement("label");
			label.innerText = "Custom Global CSS";
			label.setAttribute("for", "void-verified-global-css-editor");
			container.append(label);

			const textarea = document.createElement("textarea");
			textarea.setAttribute("id", "void-verified-global-css-editor");
			textarea.value = this.globalCSS.css;

			textarea.addEventListener("change", (event) => {
				this.#handleCustomCssEditor(event, this);
			});

			container.append(textarea);

			const notice = document.createElement("div");
			notice.innerText =
				"Please note that Custom CSS is disabled in the settings. \nIn the event that you accidentally disable rendering of critical parts of AniList, navigate to the settings by URL";
			notice.style.fontSize = "11px";
			container.append(notice);

			settingsContainer.append(container);
		}

		#handleCustomCssEditor(event, settingsUi) {
			const value = event.target.value;
			settingsUi.globalCSS.updateCss(value);
		}

		#renderImageHostSettings(cont) {
			const container =
				cont ?? document.getElementById("void-verified-image-host");
			const title = document.createElement("label");
			title.append("Image Host");
			container.append(title);

			const imageHostService = new ImageHostService();
			const imageApiFactory = new ImageApiFactory();

			const select = document.createElement("select");
			select.append(undefined);
			for (const imageHost of Object.values(imageHosts)) {
				select.append(
					this.#createOption(
						imageHost,
						imageHost === imageHostService.getSelectedHost()
					)
				);
			}

			container.append(select);

			const hostSpecificSettings = document.createElement("div");

			const imageHostApi = imageApiFactory.getImageHostInstance();

			hostSpecificSettings.append(imageHostApi.renderSettings());

			container.append(hostSpecificSettings);
		}

		#createOption(value, selected = false) {
			const option = document.createElement("option");
			if (selected) {
				option.setAttribute("selected", true);
			}
			option.setAttribute("value", value);
			option.append(value);
			return option;
		}
	}

	class QuickAccess {
		settings;
		#quickAccessId = "void-verified-quick-access";
		constructor(settings) {
			this.settings = settings;
		}

		renderQuickAccess() {
			if (this.#quickAccessRendered()) {
				return;
			}

			if (
				!this.settings.options.quickAccessEnabled.getValue() &&
				!this.settings.verifiedUsers.some((user) => user.quickAccessEnabled)
			) {
				return;
			}

			const quickAccessContainer = document.createElement("div");
			quickAccessContainer.setAttribute("class", "void-quick-access");
			quickAccessContainer.setAttribute("id", this.#quickAccessId);

			const sectionHeader = document.createElement("div");
			sectionHeader.setAttribute("class", "section-header");
			const title = document.createElement("h2");
			title.append("Quick Access");
			sectionHeader.append(title);

			quickAccessContainer.append(sectionHeader);

			const quickAccessBody = document.createElement("div");
			quickAccessBody.setAttribute("class", "void-quick-access-wrap");

			for (const user of this.#getQuickAccessUsers()) {
				quickAccessBody.append(this.#createQuickAccessLink(user));
			}

			quickAccessContainer.append(quickAccessBody);

			const section = document.querySelector(
				".container > .home > div:nth-child(2)"
			);
			section.insertBefore(quickAccessContainer, section.firstChild);
		}

		#createQuickAccessLink(user) {
			const container = document.createElement("a");
			container.setAttribute("class", "void-quick-access-item");
			const link = document.createElement("a");
			container.setAttribute(
				"href",
				`https://anilist.co/user/${user.username}/`
			);

			const image = document.createElement("div");
			image.style.backgroundImage = `url(${user.avatar})`;
			image.setAttribute("class", "void-quick-access-pfp");
			container.append(image);

			const username = document.createElement("div");
			username.append(user.username);
			username.setAttribute("class", "void-quick-access-username");

			container.append(username);
			container.append(link);
			return container;
		}

		#quickAccessRendered() {
			const quickAccess = document.getElementById(this.#quickAccessId);
			return quickAccess !== null;
		}

		#getQuickAccessUsers() {
			if (this.settings.options.quickAccessEnabled.getValue()) {
				return this.settings.verifiedUsers;
			}

			return this.settings.verifiedUsers.filter(
				(user) => user.quickAccessEnabled
			);
		}
	}

	class IntervalScriptHandler {
		styleHandler;
		settingsUi;
		activityHandler;
		settings;
		globalCSS;
		quickAccess;
		constructor(settings) {
			this.settings = settings;

			this.styleHandler = new StyleHandler(settings);
			this.globalCSS = new GlobalCSS(settings);
			this.settingsUi = new SettingsUserInterface(
				settings,
				this.styleHandler,
				this.globalCSS
			);
			this.activityHandler = new ActivityHandler(settings);
			this.quickAccess = new QuickAccess(settings);
		}

		currentPath = "";
		evaluationIntervalInSeconds = 1;
		hasPathChanged(path) {
			if (path === this.currentPath) {
				return false;
			}
			this.currentPath = path;
			return true;
		}

		handleIntervalScripts(intervalScriptHandler) {
			const path = window.location.pathname;

			intervalScriptHandler.activityHandler.moveAndDisplaySubscribeButton();
			intervalScriptHandler.globalCSS.clearCssForProfile();

			if (path === "/home") {
				intervalScriptHandler.styleHandler.refreshHomePage();
				intervalScriptHandler.quickAccess.renderQuickAccess();
			}

			if (!path.startsWith("/settings/developer")) {
				intervalScriptHandler.settingsUi.removeSettingsUi();
			}

			if (!intervalScriptHandler.hasPathChanged(path)) {
				return;
			}

			if (path.startsWith("/user/")) {
				intervalScriptHandler.styleHandler.verifyProfile();
				intervalScriptHandler.styleHandler.copyUserColor();
			} else {
				intervalScriptHandler.styleHandler.clearProfileVerify();
			}

			if (path.startsWith("/settings/developer")) {
				intervalScriptHandler.settingsUi.renderSettingsUi();
			}

			intervalScriptHandler.globalCSS.createCss();
		}

		enableScriptIntervalHandling() {
			setInterval(() => {
				this.handleIntervalScripts(this);
			}, this.evaluationIntervalInSeconds * 1000);
		}
	}

	class PasteHandler {
		settings;

		#imageFormats = [
			"jpg",
			"png",
			"gif",
			"webp",
			"apng",
			"avif",
			"jpeg",
			"svg",
		];

		// #isKeyPressed = false;
		#uploadInProgress = false;
		constructor(settings) {
			this.settings = settings;
		}

		setup() {
			// window.addEventListener("keydown", (event) => {
			// 	this.#handleKeybind(event);
			// });
			// window.addEventListener("keyup", (event) => {
			// 	this.#handleKeybind(event, false);
			// });
			window.addEventListener("paste", (event) => {
				this.#handlePaste(event);
			});
		}

		// #handleKeybind(event, isKeyDown = true) {
		// 	if (this.settings.options.pasteKeybind.getValue() !== event.key) {
		// 		return;
		// 	}
		// 	this.#isKeyPressed = isKeyDown;
		// }

		async #handlePaste(event) {
			if (event.target.tagName !== "TEXTAREA") {
				return;
			}

			const clipboard = event.clipboardData.getData("text/plain").trim();
			const file = event.clipboardData.items[0]?.getAsFile();
			let result = [];
			if (
				file &&
				file.type.startsWith("image/") &&
				this.settings.options.pasteImagesToHostService.getValue()
			) {
				event.preventDefault();
				if (this.#uploadInProgress) {
					return;
				}
				this.#uploadInProgress = true;
				document.body.setAttribute("id", "void-upload-in-progess");
				try {
					const imageApi = new ImageApiFactory().getImageHostInstance();
					const response = await imageApi.uploadImage(file);
					result.push(this.#handleRow(response.data.url));
				} catch (error) {
					console.error(error);
				} finally {
					this.#uploadInProgress = false;
					document.body.removeAttribute("id");
				}
			} else if (this.settings.options.pasteEnabled.getValue()) {
				event.preventDefault();
				const rows = clipboard.split("\n");

				for (let row of rows) {
					result.push(this.#handleRow(row));
				}
			} else {
				return;
			}

			const transformedClipboard = result.join("\n\n");
			window.document.execCommand("insertText", false, transformedClipboard);
		}

		#handleRow(row) {
			row = row.trim();
			if (
				this.#imageFormats.some((format) =>
					row.toLowerCase().endsWith(format)
				)
			) {
				return this.#handleImg(row);
			} else if (row.toLowerCase().startsWith("http")) {
				return `[](${row})`;
			} else {
				return row;
			}
		}

		#handleImg(row) {
			const img = `img${this.settings.options.pasteImageWidth.getValue()}(${row})`;
			let result = img;
			if (this.settings.options.pasteWrapImagesWithLink.getValue()) {
				result = `[ ${img} ](${row})`;
			}
			return result;
		}
	}

	const styles = /* css */ `
    a[href="/settings/developer" i]::after{content: " & Void"}
    .void-settings .void-nav ol {
        display: flex;
        margin: 8px 0px;
        padding: 0;
    }

    .void-settings .void-nav li {
        list-style: none;
        display: block;
        color: white;
        padding: 3px 8px;
        text-transform: capitalize;
        background: black;
        cursor: pointer;
        min-width: 50px;
        text-align: center;
        font-size: 1.3rem;
    }

    .void-settings .void-nav li.void-active {
        background: rgb(var(--color-blue));
    }

    .void-settings .void-nav li:first-child {
        border-radius: 4px 0px 0px 4px;
    }

    .void-settings .void-nav li:last-child {
        border-radius: 0px 4px 4px 0px;
    }
    
    .void-settings .void-nav li:hover {
        background: rgb(var(--color-blue));
    }

    .void-settings .void-settings-header {
        margin-top: 30px;
    }

    .void-settings .void-table input[type="text"] {
        width: 100px;
    }
    .void-settings .void-table input[type="color"] {
        border: 0;
        height: 24px;
        width: 40px;
        padding: 0;
        background-color: unset;
        cursor: pointer;
    }

    .void-settings .void-table input[type="checkbox"] {
        margin-left: 3px;
        margin-right: 3px;
    }

    .void-settings .void-table button {
        background: unset;
        border: none;
        cursor: pointer;
        padding: 0;
    }

    .void-settings .void-table form {
        padding: 8px;
        display: flex;
        align-items: center;
        gap: 8px;
    }

    .void-settings .void-settings-header span {
        color: rgb(var(--color-blue));
    }

    .void-settings .void-settings-list {
        display: flex;
        flex-direction: column;
        gap: 5px;
    }

    .void-settings .void-settings-list input[type="color"] {
        border: 0;
        height: 20px;
        width: 25px;
        padding: 0;
        background-color: unset;
        cursor: pointer;
    }

    .void-settings .void-settings-list input[type="text"] {
        width: 50px;
    }

    .void-settings .void-settings-list label {
        margin-left: 5px;
    }

    .void-settings .void-css-editor label {
        margin-top: 20px;
        fontSize: 2rem;
        display: inline-block;
    }

    .void-settings .void-css-editor textarea {
        width: 100%;
        height: 200px;
        resize: vertical;
        background: rgb(var(--color-foreground));
        color: rgb(var(--color-text));
    }
    
    .void-quick-access .void-quick-access-wrap {
        background: rgb(var(--color-foreground));
        display: grid;
        grid-template-columns: repeat(auto-fill, 60px);
        grid-template-rows: repeat(auto-fill, 80px);
        gap: 15px;
        padding: 15px;
        margin-bottom: 25px;
    }

    .void-quick-access-item {
        display: inline-block;
    }

    .void-quick-access-pfp {
        background-size: contain;
        background-repeat: no-repeat;
        height: 60px;
        width: 60px;
        border-radius: 4px;
    }

    .void-quick-access-username {
        display: inline-block;
        text-align: center;
        bottom: -20px;
        width: 100%;
        word-break: break-all;
        font-size: 1.2rem;
    }

    .void-api-label {
        margin-right: 5px;
    }

    .void-api-key {
        width: 300px;
    }

    .void-notice {
        font-size: 11px;
        margin-top: 5px;
    }

    #void-upload-in-progess {
        cursor: wait;
    }
`;

	const settings = new Settings();
	const styleHandler = new StyleHandler(settings);
	const intervalScriptHandler = new IntervalScriptHandler(settings);
	const anilistAPI = new AnilistAPI(settings);
	const pasteHandler = new PasteHandler(settings);

	styleHandler.refreshStyles();
	intervalScriptHandler.enableScriptIntervalHandling();

	anilistAPI.queryUserData();
	pasteHandler.setup();

	styleHandler.createStyleLink(styles, "script");

	console.log(`VoidVerified ${settings.version} loaded.`);

})();