VoidVerified

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

目前為 2023-11-23 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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.`);

})();