Enhanced BLAEO

Adds some cool features to BLAEO.

// ==UserScript==
// @name Enhanced BLAEO
// @namespace https://rafaelgssa.gitlab.io/monkey-scripts
// @version 5.0.2
// @author rafaelgssa
// @description Adds some cool features to BLAEO.
// @match https://www.backlog-assassins.net/*
// @match https://www.steamgifts.com/discussion/9VTBD/*
// @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js
// @require https://greasyfork.org/scripts/405813-monkey-utils/code/Monkey%20Utils.js?version=821710
// @require https://greasyfork.org/scripts/405802-monkey-dom/code/Monkey%20DOM.js?version=823982
// @require https://greasyfork.org/scripts/405831-monkey-storage/code/Monkey%20Storage.js?version=821709
// @require https://greasyfork.org/scripts/405822-monkey-requests/code/Monkey%20Requests.js?version=821708
// @require https://greasyfork.org/scripts/406057-blaeo-api/code/BLAEO%20API.js?version=823678
// @connect steamgifts.com
// @connect steamcommunity.com
// @run-at document-idle
// @grant GM.info
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.deleteValue
// @grant GM.xmlHttpRequest
// @grant GM.openInTab
// @grant GM_info
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @grant GM_openInTab
// @noframes
// ==/UserScript==

/* global BlaeoApi, DOM, PersistentStorage, Requests, Utils */

/**
 * @typedef {'new' | GameProgress} GameCategory
 *
 * @typedef {'uncategorized' | 'never-played' | 'unfinished' | 'beaten' | 'completed' | 'wont-play'} GameProgress
 *
 * @typedef {Object} GameCategoryInfo
 * @property {string} name
 * @property {string} color
 * @property {string} bootstrapClass
 *
 * @typedef {Object} EblaeoGlobals Global variables for the entire script.
 * @property {UserData} user
 * @property {Partial<SmGlobals>} sm
 * @property {Partial<GlcGlobals>} glc
 * @property {Partial<PgGlobals>} pg
 * @property {HTMLElement | null} alertEl
 * @property {HTMLButtonElement | null} dialogModalButton
 * @property {HTMLElement | null} dialogModalHolderEl
 * @property {HTMLElement | null} dialogModalEl
 * @property {HTMLElement | null} dialogModalLabelEl
 * @property {HTMLElement | null} dialogModalFooterEl
 * @property {HTMLElement | null} dialogModalDenyButton
 * @property {HTMLElement | null} dialogModalConfirmButton
 *
 * @typedef {Object} UserData
 * @property {string} steamId
 * @property {string} username
 * @property {boolean} isAdmin
 * @property {Record<number, OwnedGame>} ownedGames
 * @property {number} lastSync
 * @property {Record<string, number[]>} glcLists
 * @property {Record<string, PgPreset<PgPresetType>>} pgPresets
 *
 * @typedef {Object} OwnedGame
 * @property {number} id
 * @property {GameProgress} [progress]
 *
 * @typedef {Object} SmGlobals Global variables for Settings Menu.
 * @property {HTMLElement | null} sidebarNavEl
 * @property {HTMLElement | null} button
 * @property {HTMLElement | null} mainContainerEl
 * @property {HTMLButtonElement | null} syncButton
 * @property {HTMLElement | null} lastSyncEl
 * @property {HTMLElement | null} usernameEl
 * @property {HTMLElement | null} ownedGamesEl
 *
 * @typedef {Object} GlcGlobals Global variables for Game List Checker.
 * @property {HTMLElement | null} postEl
 * @property {HTMLElement[]} listItemEls
 * @property {HTMLButtonElement | null} button
 * @property {HTMLElement | null} buttonIconEl
 * @property {HTMLButtonElement | null} modalButton
 * @property {HTMLElement | null} modalEl
 * @property {HTMLElement | null} modalBodyEl
 * @property {boolean} hasChecked
 * @property {boolean} isFirstCheck
 * @property {Partial<Record<GameCategory, GlcGame[]>>} games
 * @property {number} gamesCount
 * @property {string} postId
 *
 * @typedef {Object} GlcGame
 * @property {number} id
 * @property {string} name
 * @property {boolean} [isNew]
 *
 * @typedef {Object} PgGlobals Global variables for Post Generator.
 * @property {HTMLTextAreaElement | null} postField
 * @property {HTMLElement | null} previewButton
 * @property {HTMLElement | null} createButton
 * @property {Record<string, PgPreset<PgPresetType>>} defaultPresets
 * @property {PgGame} defaultGame
 * @property {PgPresetType[]} presetTypes
 * @property {Record<PgPresetType, string>} presetTypeNames
 * @property {Record<PgPresetType, PgPreset<PgPresetType>>} presets
 * @property {PgPresetType} currentPresetType
 * @property {PgGameInfo[]} gameInfos
 * @property {PgGame | null} selectedGame
 * @property {boolean} isEditing
 * @property {Partial<Record<PgPresetType, HTMLElement | null>>} presetTabNavEls
 * @property {Partial<Record<PgPresetType, HTMLElement | null>>} presetTabEls
 * @property {Partial<Record<PgPresetType, HTMLElement | null>>} presetDropdownEls
 * @property {Pick<PgFieldContainers, 'presets'> & Partial<PgFieldContainers>} fieldContainerEls
 * @property {Pick<PgFields, 'presets'> & Partial<PgFields>} fields
 * @property {boolean} isSearchingGames
 * @property {boolean} hasNewGameSearchQuery
 * @property {HTMLElement | null} generateButton
 * @property {HTMLElement | null} modalEl
 * @property {HTMLElement | null} modalBodyEl
 * @property {HTMLInputElement | null} searchGamesField
 * @property {HTMLElement | null} searchGamesResultsEl
 * @property {HTMLElement | null} generatorEl
 * @property {HTMLElement | null} generatorBodyEl
 * @property {HTMLElement | null} generatorNavEl
 * @property {HTMLElement | null} savePresetButton
 * @property {HTMLElement | null} gamePreviewContainerEl
 * @property {HTMLElement | null} gamePreviewEl
 * @property {HTMLElement | null} gamePreviewBodyEl
 * @property {HTMLElement | null} gamePreviewButton
 * @property {HTMLElement | null} fullPreviewEl
 * @property {HTMLElement | null} fullPreviewBodyEl
 * @property {HTMLElement | null} doneButton
 * @property {PgGame[]} gamesCache
 * @property {Record<number, number>} playtimeThisMonthCache
 * @property {Record<number, number>} screenshotsCache
 * @property {Record<number, PgReviewCache>} reviewsCache
 *
 * @typedef {'box' | 'bar' | 'panel' | 'custom'} PgPresetType
 *
 * @typedef {Object} PgPresets
 * @property {PgBoxPreset} box
 * @property {PgBarPreset} bar
 * @property {PgPanelPreset} panel
 * @property {PgCustomPreset} custom
 *
 * @typedef {PgPresetBase & PgBoxPresetBase} PgBoxPreset
 *
 * @typedef {PgPresetBase & PgBarPresetBase} PgBarPreset
 *
 * @typedef {PgPresetBase & PgPanelPresetBase} PgPanelPreset
 *
 * @typedef {PgCustomPresetBase} PgCustomPreset
 *
 * @typedef {Object} PgPresetBase
 * @property {boolean} showPlaytimeThisMonth
 * @property {string} playtimeTemplate
 * @property {boolean} linkAchievements
 * @property {string} achievementsTemplate
 * @property {string} noAchievementsTemplate
 * @property {boolean} checkScreenshots
 * @property {boolean} linkScreenshots
 * @property {string} screenshotsTemplate
 * @property {string} noScreenshotsTemplate
 * @property {'Solid' | 'Horizontal gradient' | 'Vertical gradient'} bgType
 * @property {string} bgColor1
 * @property {string} bgColor2
 * @property {string} titleColor
 * @property {string} textColor
 * @property {string} linkColor
 *
 * @typedef {Object} PgBoxPresetBase
 * @property {'Left' | 'Right'} reviewPosition
 *
 * @typedef {Object} PgBarPresetBase
 * @property {boolean} showInfoInOneLine
 * @property {'Left' | 'Right' | 'Hidden'} completionBarPosition
 * @property {'Left' | 'Right'} imagePosition
 * @property {boolean} useCollapsibleReview
 * @property {'Bar click' | 'Button click'} reviewTriggerMethod
 *
 * @typedef {Object} PgPanelPresetBase
 * @property {boolean} usePredefinedTheme
 * @property {'Blue' | 'Green' | 'Grey' | 'Red' | 'Yellow'} predefinedThemeColor
 * @property {boolean} useCustomTheme
 * @property {boolean} useCollapsibleReview
 *
 * @typedef {Object} PgCustomPresetBase
 * @property {string} htmlTemplate
 *
 * @typedef {Object} PgGame
 * @property {number} id
 * @property {string} name
 * @property {string} image
 * @property {GameProgress} progress
 * @property {PgGamePlaytime} playtime
 * @property {PgGameAchievements} achievements
 * @property {number} screenshotsCount
 * @property {string} customHtml
 * @property {string} rating
 * @property {string} review
 * @property {PgPreset<PgPresetType>} preset
 *
 * @typedef {Object} PgGamePlaytime
 * @property {number} thisMonth
 * @property {number} total
 *
 * @typedef {Object} PgGameAchievements
 * @property {number} unlocked
 * @property {number} total
 *
 * @typedef {Object} PgGameInfo
 * @property {PgGame} game
 * @property {ElementArray[]} elArrays
 *
 * @typedef {Record<'presets', PgPresetFieldContainers> & PgFieldContainersBase} PgFieldContainers
 *
 * @typedef {Object} PgPresetFieldContainers
 * @property {Partial<PgBoxFieldContainers>} box
 * @property {Partial<PgBarFieldContainers>} bar
 * @property {Partial<PgPanelFieldContainers>} panel
 * @property {Partial<PgCustomFieldContainers>} custom
 *
 * @typedef {PgPresetFieldContainersBase & PgBoxFieldContainersBase} PgBoxFieldContainers
 *
 * @typedef {PgPresetFieldContainersBase & PgBarFieldContainersBase} PgBarFieldContainers
 *
 * @typedef {PgPresetFieldContainersBase & PgPanelFieldContainersBase} PgPanelFieldContainers
 *
 * @typedef {PgCustomFieldContainersBase} PgCustomFieldContainers
 *
 * @typedef {Record<keyof PgPresetFieldsBase, HTMLElement | null>} PgPresetFieldContainersBase
 *
 * @typedef {Record<keyof PgBoxFieldsBase, HTMLElement | null>} PgBoxFieldContainersBase
 *
 * @typedef {Record<keyof PgBarFieldsBase, HTMLElement | null>} PgBarFieldContainersBase
 *
 * @typedef {Record<keyof PgPanelFieldsBase, HTMLElement | null>} PgPanelFieldContainersBase
 *
 * @typedef {Record<keyof PgCustomFieldsBase, HTMLElement | null>} PgCustomFieldContainersBase
 *
 * @typedef {Record<PgFieldKey, HTMLElement | null>} PgFieldContainersBase
 *
 * @typedef {Object} PgFields
 * @property {PgPresetFields} presets
 * @property {HTMLInputElement | null} customHtml
 * @property {HTMLInputElement | null} rating
 * @property {HTMLTextAreaElement | null} review
 * @property {HTMLInputElement | null} presetName
 *
 * @typedef {Object} PgPresetFields
 * @property {Partial<PgBoxFields>} box
 * @property {Partial<PgBarFields>} bar
 * @property {Partial<PgPanelFields>} panel
 * @property {Partial<PgCustomFields>} custom
 *
 * @typedef {PgPresetFieldsBase & PgBoxFieldsBase} PgBoxFields
 *
 * @typedef {PgPresetFieldsBase & PgBarFieldsBase} PgBarFields
 *
 * @typedef {PgPresetFieldsBase & PgPanelFieldsBase} PgPanelFields
 *
 * @typedef {PgCustomFieldsBase} PgCustomFields
 *
 * @typedef {Object} PgPresetFieldsBase
 * @property {HTMLInputElement | null} showPlaytimeThisMonth
 * @property {HTMLInputElement | null} playtimeTemplate
 * @property {HTMLInputElement | null} linkAchievements
 * @property {HTMLInputElement | null} achievementsTemplate
 * @property {HTMLInputElement | null} noAchievementsTemplate
 * @property {HTMLInputElement | null} checkScreenshots
 * @property {HTMLInputElement | null} linkScreenshots
 * @property {HTMLInputElement | null} screenshotsTemplate
 * @property {HTMLInputElement | null} noScreenshotsTemplate
 * @property {HTMLInputElement | null} bgType
 * @property {HTMLInputElement | null} bgColor1
 * @property {HTMLInputElement | null} bgColor2
 * @property {HTMLInputElement | null} titleColor
 * @property {HTMLInputElement | null} textColor
 * @property {HTMLInputElement | null} linkColor
 *
 * @typedef {Object} PgBoxFieldsBase
 * @property {HTMLInputElement | null} reviewPosition
 *
 * @typedef {Object} PgBarFieldsBase
 * @property {HTMLInputElement | null} showInfoInOneLine
 * @property {HTMLInputElement | null} customHtml
 * @property {HTMLInputElement | null} completionBarPosition
 * @property {HTMLInputElement | null} imagePosition
 * @property {HTMLInputElement | null} useCollapsibleReview
 * @property {HTMLInputElement | null} reviewTriggerMethod
 *
 * @typedef {Object} PgPanelFieldsBase
 * @property {HTMLInputElement | null} rating
 * @property {HTMLInputElement | null} usePredefinedTheme
 * @property {HTMLInputElement | null} predefinedThemeColor
 * @property {HTMLInputElement | null} useCustomTheme
 * @property {HTMLInputElement | null} useCollapsibleReview
 *
 * @typedef {Object} PgCustomFieldsBase
 * @property {HTMLInputElement | null} htmlTemplate
 *
 * @typedef {Object} PgFieldOptions
 * @property {'textarea' | 'text' | 'color' | 'checkbox' | 'radio' | 'select'} type
 * @property {PgPresetFieldKey | PgFieldKey} id
 * @property {string} htmlId
 * @property {string} label
 * @property {string} [description]
 * @property {boolean} [usePlaceholders]
 * @property {boolean} [useReviewPlaceholder]
 * @property {string[]} [selectOptions]
 *
 * @typedef {keyof PgPresetBase | keyof PgBoxPresetBase | keyof PgBarPresetBase | keyof PgPanelPresetBase | keyof PgCustomPresetBase} PgPresetFieldKey
 *
 * @typedef {keyof Omit<PgFields, 'presets'>} PgFieldKey
 *
 * @typedef {Object} PgReviewCache
 * @property {string} review
 * @property {string} reviewPreview
 */

/**
 * @template {PgPresetType} T
 * @typedef {Object} PgPreset
 * @property {T} type
 * @property {string} name
 * @property {PgPresets[T]} prefs
 */

class CustomError extends Error {
	/**
	 * @param {string} message
	 */
	constructor(message) {
		super(message);
	}
}

(async () => {
	'use strict';

	const scriptId = 'eblaeo';
	const scriptName = GM.info.script.name;

	/** @type {UserData} */
	const defaultValues = {
		steamId: '',
		username: '',
		isAdmin: false,
		ownedGames: {},
		lastSync: 0,
		glcLists: {},
		pgPresets: {},
	};

	const isInBlaeo = window.location.host === 'www.backlog-assassins.net';

	const gameCategories = /** @type {GameCategory[]} */ ([
		'new',
		'uncategorized',
		'never-played',
		'unfinished',
		'beaten',
		'completed',
		'wont-play',
	]);

	/** @type {Record<GameCategory, GameCategoryInfo>} */
	const gameCategoryInfos = {
		new: {
			name: 'New',
			color: '#555555',
			bootstrapClass: 'primary',
		},
		uncategorized: {
			name: 'Uncategorized',
			color: '#dddddd',
			bootstrapClass: 'default',
		},
		'never-played': {
			name: 'Never Played',
			color: '#eeeeee',
			bootstrapClass: 'default',
		},
		unfinished: {
			name: 'Unfinished',
			color: '#f0ad4e',
			bootstrapClass: 'warning',
		},
		beaten: {
			name: 'Beaten',
			color: '#5cb85c',
			bootstrapClass: 'success',
		},
		completed: {
			name: 'Completed',
			color: '#5bc0de',
			bootstrapClass: 'info',
		},
		'wont-play': {
			name: "Won't Play",
			color: '#d9534f',
			bootstrapClass: 'danger',
		},
	};

	/** @type {Record<BlaeoGameProgress, GameProgress>} */
	const gameProgresses = {
		uncategorized: 'uncategorized',
		'never played': 'never-played',
		unfinished: 'unfinished',
		beaten: 'beaten',
		completed: 'completed',
		'wont play': 'wont-play',
	};

	const bootstrapColorClasses = {
		Grey: 'default',
		Yellow: 'warning',
		Green: 'success',
		Blue: 'info',
		Red: 'danger',
	};

	const eblaeo = /** @type {EblaeoGlobals} */ ({
		user: {},
		sm: {},
		glc: {},
		pg: {},
	});

	/**
	 * Loads the script.
	 * @returns {Promise<void>}
	 */
	const load = async () => {
		await loadUserData();
		addStyles();
		await loadFeatures();
		if (isInBlaeo) {
			document.addEventListener('turbolinks:load', loadFeatures);
		}
	};

	/**
	 * Loads the user's data.
	 * @returns {Promise<void>}
	 */
	const loadUserData = async () => {
		const keys = /** @type {(keyof UserData)[]} */ (Object.keys(defaultValues));
		for (const key of keys) {
			eblaeo.user[key] = /** @type {never} */ (await PersistentStorage.getValue(key));
		}
	};

	/**
	 * Adds styles to the page.
	 */
	const addStyles = () => {
		// prettier-ignore
		DOM.insertElements(document.head, 'beforeend', [
			['style', null,
				isInBlaeo
					? `
						.clear-both {
							clear: both;
						}

						#eblaeo-alert, #eblaeo-dialog-modal-button, #eblaeo-glc-modal-button, #eblaeo-pg-generator, #eblaeo-pg-full-preview, .eblaeo-pg-collapse, .eblaeo-pg-expand {
							display: none;
						}

						#eblaeo-alert {
							margin: 10px 0;
						}

						#eblaeo-glc-button-container, .eblaeo-pg-full-preview-game {
							position: relative;
						}

						#eblaeo-glc-button {
							height: 38px;
							max-height: 38px;
							max-width: 38px;
							position: absolute;
							right: -19px;
							text-align: center;
							top: 15px;
							width: 38px;
						}

						#eblaeo-glc-button i {
							vertical-align: middle;
						}

						.eblaeo-glc-results-section .panel-heading {
							position: sticky;
							top: 0;
						}

						#eblaeo-pg-generate-button {
							margin-right: 5px;
						}

						#eblaeo-pg-modal {
							overflow: auto;
						}

						.eblaeo-pg-game-list-item-button {
							margin-bottom: 5px;
						}

						ul[id^='eblaeo-pg-preset-dropdown']:empty::after {
							content: 'No presets saved for this type.';
							padding: 5px;
						}

						ul[id^='eblaeo-pg-preset-dropdown'] li {
							align-items: center;
							display: flex;
						}

						ul[id^='eblaeo-pg-preset-dropdown'] li a {
							cursor: pointer;
							display: inline-block;
							flex: 1;
						}

						ul[id^='eblaeo-pg-preset-dropdown'] li i {
							cursor: pointer;
							padding: 3px 10px;
						}

						.eblaeo-pg-field-container {
							margin-bottom: 15px;
						}

						.eblaeo-pg-double-field-container, .eblaeo-pg-triple-field-container {
							display: flex;
						}

						:not(.eblaeo-pg-double-field-container) > .eblaeo-pg-field-container select {
							width: calc(50% - 15px);
						}

						.eblaeo-pg-double-field-container >* {
							width: calc(50% - 15px);
						}

						.eblaeo-pg-double-field-container >:first-child {
							margin-right: 15px;
						}

						.eblaeo-pg-double-field-container >:last-child {
							margin-left: 15px;
						}

						.eblaeo-pg-triple-field-container >* {
							width: calc(34% - 15px);
						}

						.eblaeo-pg-triple-field-container >:not(:last-child) {
							margin-right: 15px;
						}

						#eblaeo-pg-game-preview-container {
							background-color: #ffffff;
							bottom: 0;
							display: none;
							padding: 15px 0;
							position: sticky;
							z-index: 999;
						}

						#eblaeo-pg-game-preview {
							margin: 0;
							max-height: 300px;
							overflow: auto;
						}

						.eblaeo-pg-full-preview-game .btn-toolbar {
							background-color: rgba(0, 0, 0, 0.5);
							display: none;
							justify-content: center;
							left: 0;
							margin: 0;
							padding: 5px 5px 5px 0;
							position: absolute;
							right: 0;
							top: 0;
							z-index: 2;
						}

						.eblaeo-pg-full-preview-game:hover .btn-toolbar {
							display: flex;
						}

						#eblaeo-pg-game-preview .panel-heading.collapsed .eblaeo-pg-expand, #eblaeo-pg-game-preview .panel-heading:not(.collapsed) .eblaeo-pg-collapse, #eblaeo-pg-full-preview .panel-heading.collapsed .eblaeo-pg-expand, #eblaeo-pg-full-preview .panel-heading:not(.collapsed) .eblaeo-pg-collapse {
							display: block;
						}

						.eblaeo-pg-collapse, .eblaeo-pg-expand {
							cursor: pointer;
							float: right;
							font-weight: bold;
						}
					`
					: `
						.eblaeo-at-user-button {
							cursor: pointer;
						}
					`,
			],
		]);
	};

	/**
	 * Loads the features.
	 * @returns {Promise<void>}
	 */
	const loadFeatures = async () => {
		if (!isInBlaeo) {
			if (eblaeo.user.isAdmin && window.location.pathname.includes('/discussion/9VTBD/')) {
				at_addUserButtons(document.body);
				DOM.observeNode(document.body, null, /** @type {NodeCallback} */ (at_addUserButtons));
			}
			return;
		}
		if (window.location.pathname.includes('/settings')) {
			sm_addButton();
		} else if (window.location.href.includes('/admin/users/new?steam_id=')) {
			at_searchUser();
		} else if (window.location.pathname.includes('/posts/new')) {
			await pg_addButton();
		} else if (window.location.pathname.includes('/posts/')) {
			glc_addButton();
		}
	};

	/**
	 * Shows an alert in a context element.
	 * @param {HTMLElement} contextEl The context element where to show the alert.
	 * @param {ExtendedInsertPosition} position Where to insert the alert.
	 * @param {'loading' | 'success' | 'warning' | 'danger'} alertType The type of the alert.
	 * @param {string} message The message to show.
	 */
	const showAlert = (contextEl, position, alertType, message) => {
		if (eblaeo.alertEl) {
			// prettier-ignore
			DOM.insertElements(contextEl, position, [eblaeo.alertEl]);
		} else {
			// prettier-ignore
			DOM.insertElements(contextEl, position, [
				['div', {
					id: 'eblaeo-alert',
					ref: (/** @type {HTMLElement} */ ref) => (eblaeo.alertEl = ref),
				}, null],
			]);
		}
		if (!eblaeo.alertEl) {
			return;
		}
		eblaeo.alertEl.className = `alert alert-${alertType === 'loading' ? 'info' : alertType}`;
		/** @type {ElementArray[]} */
		let elArrays;
		switch (alertType) {
			case 'loading':
				// prettier-ignore
				elArrays = [
					['i', { className: 'fa fa-circle-o-notch fa-spin' }, null],
					' ',
					message,
				];
				break;
			case 'success':
				// prettier-ignore
				elArrays = [
					['i', { className: 'fa fa-check-circle' }, null],
					' ',
					message,
				];
				break;
			case 'warning':
				// prettier-ignore
				elArrays = [
					['i', { className: 'fa fa-question-circle' }, null],
					' ',
					message,
				];
				break;
			case 'danger':
				// prettier-ignore
				elArrays = [
					['i', { className: 'fa fa-times-circle' }, null],
					' An error happened: ',
					['span', null, message || null],
					'. Please try again later. If the error persists, please report it on ',
					['a', { href: 'https://gitlab.com/rafaelgssa/monkey-scripts/-/issues' }, 'GitLab'],
					'.',
				];
				break;
			// no default
		}
		// prettier-ignore
		DOM.insertElements(eblaeo.alertEl, 'atinner', elArrays);
		eblaeo.alertEl.style.display = 'block';
	};

	/**
	 * Shows a confirmation dialog.
	 * @param {string} message The message to show.
	 * @param {(event: MouseEvent) => unknown | null} [onYes] Callback to call when the 'yes' button is clicked.
	 * @param {(event: MouseEvent) => unknown | null} [onNo] Callback to call when the 'no' button is clicked.
	 * @returns {Promise<void>}
	 */
	const showDialog = async (message, onYes, onNo) => {
		if (!eblaeo.dialogModalEl) {
			// prettier-ignore
			DOM.insertElements(document.body, 'beforeend', [
				['button', {
					id: 'eblaeo-dialog-modal-button',
					dataset: { toggle: 'modal', target: '#eblaeo-dialog-modal' },
					ref: (/** @type {HTMLButtonElement} */ ref) => (eblaeo.dialogModalButton = ref),
				}, null],
				['div', {
					id: 'eblaeo-dialog-modal-holder',
					ref: (/** @type {HTMLElement} */ ref) => (eblaeo.dialogModalHolderEl = ref),
				}, [
					['div', {
						id: 'eblaeo-dialog-modal',
						className: 'modal',
						attrs: { role: 'dialog' },
						tabIndex: -1,
						ref: (/** @type {HTMLElement} */ ref) => (eblaeo.dialogModalEl = ref),
					}, [
						['div', { className: 'modal-dialog' }, [
							['div', { className: 'modal-content' }, [
								['div', { className: 'modal-header' }, [
									['button', { type: 'button', dataset: { dismiss: 'modal' } }, [
										['i', { className: 'fa fa-close' }, null],
									]],
									['h4', {
										id: 'eblaeo-dialog-modal-label', className: 'modal-title',
										ref: (/** @type {HTMLElement} */ ref) => (eblaeo.dialogModalLabelEl = ref),
									}, null],
								]],
								['div', {
									className: 'modal-footer',
									ref: (/** @type {HTMLElement} */ ref) => (eblaeo.dialogModalFooterEl = ref),
								}, [
									['button', {
										type: 'button',
										className: 'btn btn-default',
										dataset: { dismiss: 'modal' },
										ref: (/** @type {HTMLElement} */ ref) => (eblaeo.dialogModalDenyButton = ref),
									}, 'No'],
									['button', {
										id: 'eblaeo-pg-dialog-modal-confirm-button',
										type: 'button',
										className: 'btn btn-primary',
										dataset: { dismiss: 'modal' },
										ref: (/** @type {HTMLElement} */ ref) => (eblaeo.dialogModalConfirmButton = ref),
									}, 'Yes'],
								]],
							]],
						]],
					]],
				]],
			]);
		}
		if (
			!eblaeo.dialogModalButton ||
			!eblaeo.dialogModalEl ||
			!eblaeo.dialogModalLabelEl ||
			!eblaeo.dialogModalFooterEl ||
			!eblaeo.dialogModalDenyButton ||
			!eblaeo.dialogModalConfirmButton
		) {
			return;
		}
		eblaeo.dialogModalLabelEl.textContent = message;
		if (onYes || onNo) {
			eblaeo.dialogModalFooterEl.style.display = 'block';
			if (onYes) {
				eblaeo.dialogModalConfirmButton.onclick = onYes;
			}
			if (onNo) {
				eblaeo.dialogModalDenyButton.onclick = onNo;
			}
		} else {
			eblaeo.dialogModalFooterEl.style.display = 'none';
		}
		eblaeo.dialogModalButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
		const isModalOpen = !!(await DOM.dynamicQuerySelector(
			'#eblaeo-dialog-modal.modal.in',
			60,
			0.1
		));
		if (isModalOpen) {
			eblaeo.dialogModalEl.style.zIndex = '1070';
			const modalBackdropEl = /** @type {HTMLElement | null} */ (await DOM.dynamicQuerySelector(
				'#eblaeo-dialog-modal-holder + .modal-backdrop.in',
				60,
				0.1
			));
			if (modalBackdropEl) {
				modalBackdropEl.style.zIndex = '1060';
			}
		}
	};

	/**
	 * [SM] Settings Menu
	 * Allows the user to sync their data.
	 */

	/**
	 * Adds a button to the settings page, which allows loading the settings menu.
	 */
	const sm_addButton = () => {
		eblaeo.sm = {};
		eblaeo.sm.sidebarNavEl = /** @type {HTMLElement | null} */ (document.querySelector(
			'.nav.nav-pills.nav-stacked'
		));
		if (!eblaeo.sm.sidebarNavEl) {
			return;
		}
		const oldButton = eblaeo.sm.sidebarNavEl.querySelector('#eblaeo-sm-button');
		if (oldButton) {
			oldButton.remove();
		}
		// prettier-ignore
		[eblaeo.sm.button] = DOM.insertElements(eblaeo.sm.sidebarNavEl, 'beforeend', [
			['li', { id: 'eblaeo-sm-button', onclick: sm_loadMenu }, [
				['a', { href: '#eblaeo-sm' }, 'Enhanced BLAEO'],
			]],
		]);
		if (window.location.hash === '#eblaeo-sm') {
			sm_loadMenu();
		}
	};

	/**
	 * Loads the settings menu.
	 */
	const sm_loadMenu = () => {
		if (!eblaeo.sm.sidebarNavEl || !eblaeo.sm.button) {
			return;
		}
		eblaeo.sm.mainContainerEl = /** @type {HTMLElement | null} */ (document.querySelector(
			'.col-sm-9.col-md-10'
		));
		if (!eblaeo.sm.mainContainerEl) {
			return;
		}
		window.location.hash = '#eblaeo-sm';
		const activeButton = eblaeo.sm.sidebarNavEl.querySelector('.active');
		if (activeButton) {
			activeButton.classList.remove('active');
		}
		eblaeo.sm.button.classList.add('active');
		// prettier-ignore
		DOM.insertElements(eblaeo.sm.mainContainerEl, 'atinner', [
			['div', { className: 'panel panel-default' }, [
				['div', { className: 'panel-body' }, [
					['button', {
						type: 'button',
						className: 'btn btn-success pull-right',
						ref: (/** @type {HTMLButtonElement} */ ref) => (eblaeo.sm.syncButton = ref),
						onclick: sm_sync,
					}, [
						['i', { className: 'fa fa-refresh' }, null],
						' Sync now',
					]],
					['p', null, [
						'Your data was last synced ',
						['span', {
							title: eblaeo.user.lastSync ? Utils.getUtcString(new Date(eblaeo.user.lastSync)) : null,
							ref: (/** @type {HTMLElement} */ ref) => (eblaeo.sm.lastSyncEl = ref),
						},
							eblaeo.user.lastSync ? Utils.getRelativeTimeFromUnix(eblaeo.user.lastSync / 1e3) : 'never'
						],
						' ago.',
					]],
					['p', null, [
						'Your current username is ',
						['b', { ref: (/** @type {HTMLElement} */ ref) => (eblaeo.sm.usernameEl = ref) },
							eblaeo.user.username || '?'
						],
						' and you have ',
						['b', { ref: (/** @type {HTMLElement} */ ref) => (eblaeo.sm.ownedGamesEl = ref) },
							Object.keys(eblaeo.user.ownedGames).length.toString()
						],
						' games in your library, right?',
					]],
				]],
			]],
		]);
	};

	/**
	 * Syncs the user's data.
	 * @returns {Promise<void>}
	 */
	const sm_sync = async () => {
		if (!eblaeo.sm.mainContainerEl || !eblaeo.sm.syncButton) {
			return;
		}
		if (eblaeo.alertEl) {
			eblaeo.alertEl.style.display = 'none';
		}
		eblaeo.sm.syncButton.disabled = true;
		// prettier-ignore
		DOM.insertElements(eblaeo.sm.syncButton, 'atinner', [
			['i', { className: 'fa fa-refresh fa-spin' }, null],
			' Syncing',
		]);
		try {
			await sm_syncUsername(true);
			await sm_syncSteamId(true);
			await sm_syncAdminStatus(true);
			await sm_syncOwnedGames(true);
			await sm_finishSync();
		} catch (err) {
			if (err instanceof CustomError) {
				showAlert(eblaeo.sm.mainContainerEl, 'beforeend', 'danger', err.message);
			} else {
				showAlert(eblaeo.sm.mainContainerEl, 'beforeend', 'danger', 'failed to sync data');
			}
		}
		// prettier-ignore
		DOM.insertElements(eblaeo.sm.syncButton, 'atinner', [
			['i', { className: 'fa fa-refresh' }, null],
			' Sync now',
		]);
		eblaeo.sm.syncButton.disabled = false;
	};

	/**
	 * Syncs the user's username.
	 * @param {boolean} inSettingsMenu Whether the sync will run in the settings menu or not.
	 * @returns {Promise<void>}
	 */
	const sm_syncUsername = async (inSettingsMenu) => {
		if (!sm_shouldSync(inSettingsMenu)) {
			return;
		}
		try {
			const avatarLink = /** @type {HTMLAnchorElement | null} */ (document.querySelector(
				'.navbar-btn.btn'
			));
			if (!avatarLink) {
				throw new CustomError('could not retrieve username');
			}
			const username = avatarLink.href.split('/users/')[1] || '';
			if (eblaeo.user.username === username) {
				return;
			}
			eblaeo.user.username = username;
			await PersistentStorage.setValue('username', eblaeo.user.username);
			if (eblaeo.sm.usernameEl) {
				eblaeo.sm.usernameEl.textContent = eblaeo.user.username || '?';
			}
		} catch (err) {
			if (err instanceof CustomError) {
				throw err;
			}
			throw new CustomError('failed to sync username');
		}
	};

	/**
	 * Syncs the user's Steam ID.
	 * @param {boolean} inSettingsMenu Whether the sync will run in the settings menu or not.
	 * @returns {Promise<void>}
	 */
	const sm_syncSteamId = async (inSettingsMenu) => {
		if (eblaeo.user.steamId) {
			return;
		}
		try {
			await sm_syncUsername(inSettingsMenu);
			if (!eblaeo.user.username) {
				throw new CustomError('cannot retrieve Steam ID without username');
			}
			const user = await BlaeoApi.getUser({ username: eblaeo.user.username });
			if (!user) {
				throw new CustomError('could not retrieve Steam ID');
			}
			eblaeo.user.steamId = user.steam_id;
			await PersistentStorage.setValue('steamId', eblaeo.user.steamId);
		} catch (err) {
			if (err instanceof CustomError) {
				throw err;
			}
			throw new CustomError('failed to sync Steam ID');
		}
	};

	/**
	 * Syncs the user's admin status.
	 * @param {boolean} inSettingsMenu Whether the sync will run in the settings menu or not.
	 * @returns {Promise<void>}
	 */
	const sm_syncAdminStatus = async (inSettingsMenu) => {
		if (!sm_shouldSync(inSettingsMenu)) {
			return;
		}
		try {
			const isAdmin = !!document.querySelector('[href="/admin"]');
			if (eblaeo.user.isAdmin === isAdmin) {
				return;
			}
			eblaeo.user.isAdmin = isAdmin;
			await PersistentStorage.setValue('isAdmin', eblaeo.user.isAdmin);
		} catch (err) {
			if (err instanceof CustomError) {
				throw err;
			}
			throw new CustomError('failed to sync admin status');
		}
	};

	/**
	 * Syncs the user's owned games.
	 * @param {boolean} inSettingsMenu Whether the sync will run in the settings menu or not.
	 * @returns {Promise<void>}
	 */
	const sm_syncOwnedGames = async (inSettingsMenu) => {
		if (!sm_shouldSync(inSettingsMenu)) {
			return;
		}
		try {
			await sm_syncSteamId(inSettingsMenu);
			if (!eblaeo.user.steamId) {
				throw new CustomError('cannot retrieve owned games without Steam ID');
			}
			eblaeo.user.ownedGames = {};
			const games = (await BlaeoApi.getGames({ steamId: eblaeo.user.steamId })) || [];
			for (const game of games) {
				const { steam_id: id, progress } = game;
				eblaeo.user.ownedGames[id] = { id };
				if (progress) {
					eblaeo.user.ownedGames[id].progress = gameProgresses[progress];
				}
			}
			await PersistentStorage.setValue('ownedGames', eblaeo.user.ownedGames);
			if (eblaeo.sm.ownedGamesEl) {
				eblaeo.sm.ownedGamesEl.textContent = Object.keys(eblaeo.user.ownedGames).length.toString();
			}
			if (!inSettingsMenu) {
				await sm_finishSync();
			}
		} catch (err) {
			if (err instanceof CustomError) {
				throw err;
			}
			throw new CustomError('failed to sync owned games');
		}
	};

	/**
	 * Finishes the sync.
	 * @returns {Promise<void>}
	 */
	const sm_finishSync = async () => {
		try {
			eblaeo.user.lastSync = Date.now();
			await PersistentStorage.setValue('lastSync', eblaeo.user.lastSync);
			if (!eblaeo.sm.lastSyncEl) {
				return;
			}
			eblaeo.sm.lastSyncEl.title = Utils.getUtcString(new Date(eblaeo.user.lastSync));
			eblaeo.sm.lastSyncEl.textContent = Utils.getRelativeTimeFromUnix(eblaeo.user.lastSync / 1e3);
		} catch (err) {
			if (err instanceof CustomError) {
				throw err;
			}
			throw new CustomError('failed to finish sync');
		}
	};

	/**
	 * Checks if the sync should run.
	 * @param {boolean} inSettingsMenu Whether the sync will run in the settings menu or not.
	 * @returns {boolean} Whether the sync should run or not.
	 */
	const sm_shouldSync = (inSettingsMenu) => {
		return inSettingsMenu || Date.now() - eblaeo.user.lastSync > Utils.ONE_WEEK_IN_MILLI;
	};

	/**
	 * [AT] Admin Tools
	 * Allows administrators to easily add new users to the website.
	 */

	/**
	 * Adds buttons for users in a context element on SteamGifts, which allow them to be added to BLAEO.
	 * @param {Element} contextEl The context element where to add the buttons.
	 */
	const at_addUserButtons = (contextEl) => {
		if (!(contextEl instanceof Element)) {
			return;
		}
		const selectors = '.comment__username';
		if (contextEl.matches(selectors)) {
			at_addUserButton(/** @type {HTMLElement} */ (contextEl));
		} else {
			const elements = Array.from(
				/** @type {NodeListOf<HTMLElement>} */ (contextEl.querySelectorAll(selectors))
			);
			elements.forEach(at_addUserButton);
		}
	};

	/**
	 * Adds a button for a user on SteamGifts, which allows them to be added to BLAEO.
	 * @param {HTMLElement} usernameEl The element containing the user's username.
	 */
	const at_addUserButton = (usernameEl) => {
		const username = (usernameEl.textContent || '').trim();
		// prettier-ignore
		const [button] = DOM.insertElements(usernameEl, 'beforeend', [
			' ',
			['img', {
				className: 'eblaeo-at-user-button',
				src: `${BlaeoApi.BLAEO_URL}/logo-32x32.png`,
				alt: 'BLAEO logo',
				title: `Add ${username} to BLAEO`,
				height: 12,
				onclick: () => at_onUserButtonClick(button, username),
			}, null],
		]);
	};

	/**
	 * Triggered when a user button is clicked on SteamGifts.
	 * @param {HTMLElement | undefined} button The button.
	 * @param {string} username The user's username.
	 * @returns {Promise<void>}
	 */
	const at_onUserButtonClick = async (button, username) => {
		try {
			await at_openUserTab(username);
			if (button) {
				button.remove();
			}
		} catch (err) {
			console.log(`[${scriptName}] Failed to open BLAEO admin tab for ${username}:`, err);
			window.alert(`Failed to open BLAEO admin tab for ${username}!`);
		}
	};

	/**
	 * Opens a BLAEO admin tab to search for a user.
	 * @param {string} username The user's username.
	 * @returns {Promise<void>}
	 */
	const at_openUserTab = async (username) => {
		const response = await Requests.GET(`https://www.steamgifts.com/user/${username}`);
		if (!response.dom) {
			throw new Error('Bad request');
		}
		const steamLink = response.dom.querySelector('[href*="/profiles/"]');
		if (!steamLink) {
			throw new Error('Could not retrieve Steam ID');
		}
		const url = steamLink.getAttribute('href');
		if (!url) {
			throw new Error('Could not retrieve Steam ID');
		}
		const [, steamId] = url.split('/profiles/');
		GM.openInTab(`${BlaeoApi.BLAEO_URL}/admin/users/new?steam_id=${steamId}`, true);
	};

	/**
	 * Searches for a user on BLAEO using their Steam ID, so they can be easily added.
	 */
	const at_searchUser = () => {
		const parts = window.location.search.split('steam_id=');
		if (parts.length !== 2) {
			return;
		}
		const searchField = /** @type {HTMLInputElement | null} */ (document.querySelector(
			'[name="q"]'
		));
		const searchButton = document.querySelector('.input-group-btn .btn.btn-default');
		if (!searchField || !searchButton) {
			return;
		}
		const [, steamId] = parts;
		searchField.value = steamId;
		searchButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
	};

	/**
	 * [GLC] Game List Checker
	 * Allows the user to keep track of a game list in a post and check which games they own / what their progress is.
	 */

	/**
	 * Adds a button to a post if it has a list, which allows checking the list.
	 */
	const glc_addButton = () => {
		eblaeo.glc = {};
		eblaeo.glc.postEl = /** @type {HTMLElement | null} */ (document.querySelector(
			'.panel-default.post'
		));
		if (!eblaeo.glc.postEl) {
			return;
		}
		eblaeo.glc.listItemEls = Array.from(eblaeo.glc.postEl.querySelectorAll('li'));
		if (eblaeo.glc.listItemEls.length === 0) {
			return;
		}
		// prettier-ignore
		DOM.insertElements(eblaeo.glc.postEl, 'afterbegin', [
			['div', { id: 'eblaeo-glc-button-container' }, [
				['button', {
					id: 'eblaeo-glc-button',
					type: 'button',
					className: 'btn btn-info',
					title: 'Check list',
					ref: (/** @type {HTMLButtonElement} */ ref) => (eblaeo.glc.button = ref),
					onclick: glc_checkList,
				}, [
					['i', {
						className: 'fa fa-search',
						ref: (/** @type {HTMLElement} */ ref) => (eblaeo.glc.buttonIconEl = ref),
					}, null	],
				]],
			]],
			['button', {
				id: 'eblaeo-glc-modal-button',
				dataset: { toggle: 'modal', target: '#eblaeo-glc-modal' },
				ref: (/** @type {HTMLButtonElement} */ ref) => (eblaeo.glc.modalButton = ref),
			}, null],
			['div', { id: 'eblaeo-glc-modal-holder' }, [
				['div', {
					id: 'eblaeo-glc-modal',
					className: 'modal',
					attrs: { role: 'dialog' },
					tabIndex: -1,
					ref: (/** @type {HTMLElement} */ ref) => (eblaeo.glc.modalEl = ref),
				}, [
					['div', { className: 'modal-dialog' }, [
						['div', { className: 'modal-content' }, [
							['div', { className: 'modal-header' }, [
								['button', { type: 'button', dataset: { dismiss: 'modal' } }, [
									['i', { className: 'fa fa-close' }, null],
								]],
								['h4', { id: 'eblaeo-glc-modal-label', className: 'modal-title' }, 'List check results'],
							]],
							['div', {
								className: 'modal-body markdown',
								ref: (/** @type {HTMLElement} */ ref) => (eblaeo.glc.modalBodyEl = ref),
							}, null],
						]],
					]],
				]],
			]],
		]);
	};

	/**
	 * Checks a list.
	 * @returns {Promise<void>}
	 */
	const glc_checkList = async () => {
		if (
			!eblaeo.glc.postEl ||
			!eblaeo.glc.listItemEls ||
			!eblaeo.glc.button ||
			!eblaeo.glc.buttonIconEl
		) {
			return;
		}
		if (eblaeo.glc.hasChecked) {
			return glc_showResults();
		}
		if (eblaeo.alertEl) {
			eblaeo.alertEl.style.display = 'none';
		}
		eblaeo.glc.button.disabled = true;
		eblaeo.glc.button.title = 'Checking list...';
		eblaeo.glc.buttonIconEl.className = 'fa fa-circle-o-notch fa-spin';
		try {
			await sm_syncOwnedGames(false);
			eblaeo.glc.isFirstCheck = false;
			eblaeo.glc.games = {};
			eblaeo.glc.gamesCount = 0;
			[, eblaeo.glc.postId] = window.location.pathname.split('/posts/');
			if (!eblaeo.user.glcLists[eblaeo.glc.postId]) {
				eblaeo.user.glcLists[eblaeo.glc.postId] = [];
				eblaeo.glc.isFirstCheck = true;
			}
			const oldLength = eblaeo.user.glcLists[eblaeo.glc.postId].length;
			eblaeo.glc.listItemEls.forEach(glc_checkListItem);
			const newLength = eblaeo.user.glcLists[eblaeo.glc.postId].length;
			if (newLength > 0 && newLength !== oldLength) {
				await PersistentStorage.setValue('glcLists', eblaeo.user.glcLists);
			}
			eblaeo.glc.hasChecked = true;
			eblaeo.glc.button.className = 'btn btn-success';
			eblaeo.glc.button.title = 'List checked (click to see results)';
			eblaeo.glc.buttonIconEl.className = 'fa fa-check';
			eblaeo.glc.button.disabled = false;
			return glc_showResults();
		} catch (err) {
			if (err instanceof CustomError) {
				showAlert(eblaeo.glc.postEl, 'beforebegin', 'danger', err.message);
			} else {
				showAlert(eblaeo.glc.postEl, 'beforebegin', 'danger', 'failed to check list');
			}
			eblaeo.glc.button.title = 'Check list';
			eblaeo.glc.buttonIconEl.className = 'fa fa-search';
			eblaeo.glc.button.disabled = false;
		}
	};

	/**
	 * Checks a list item.
	 * @param {HTMLElement} listItemEl The element of the list item to check.
	 */
	const glc_checkListItem = (listItemEl) => {
		if (!eblaeo.glc.games || !Utils.isSet(eblaeo.glc.gamesCount) || !eblaeo.glc.postId) {
			return;
		}
		const link = /** @type {HTMLAnchorElement | null} */ (listItemEl.querySelector(
			'[href*="store.steampowered.com/app/"]'
		));
		if (!link) {
			return;
		}
		const matches = link.href.match(/\/app\/(\d+)/);
		if (!matches) {
			return;
		}
		const id = parseInt(matches[1]);
		const name = (listItemEl.textContent || '').trim().replace(/\n*/g, '');
		eblaeo.glc.gamesCount += 1;
		let isNew = false;
		if (!eblaeo.user.glcLists[eblaeo.glc.postId].includes(id)) {
			eblaeo.user.glcLists[eblaeo.glc.postId].push(id);
			isNew = true;
		}
		const ownedGame = eblaeo.user.ownedGames[id];
		if (ownedGame && ownedGame.progress) {
			if (!eblaeo.glc.games[ownedGame.progress]) {
				eblaeo.glc.games[ownedGame.progress] = [];
			}
			// @ts-ignore
			eblaeo.glc.games[ownedGame.progress].push({ id, name, isNew });
		}
		if (!isNew || (ownedGame && ownedGame.progress)) {
			return;
		}
		if (!eblaeo.glc.games.new) {
			eblaeo.glc.games.new = [];
		}
		eblaeo.glc.games.new.push({ id, name });
	};

	/**
	 * Shows the results.
	 */
	const glc_showResults = () => {
		if (
			!eblaeo.glc.modalButton ||
			!eblaeo.glc.modalEl ||
			!eblaeo.glc.modalBodyEl ||
			!eblaeo.glc.games ||
			!Utils.isSet(eblaeo.glc.gamesCount)
		) {
			return;
		}
		eblaeo.glc.modalBodyEl.innerHTML = '';
		try {
			const entries = /** @type {[GameCategoryInfo, GlcGame[]][]} */ (
				/** @type {GameCategory[]} */ (gameCategories)
					.map((key) =>
						(key !== 'new' || !eblaeo.glc.isFirstCheck) && eblaeo.glc.games && eblaeo.glc.games[key]
							? [gameCategoryInfos[key], eblaeo.glc.games[key]]
							: null
					)
					.filter(Utils.isSet)
			);
			for (const [categoryInfo, games] of entries) {
				// prettier-ignore
				DOM.insertElements(eblaeo.glc.modalBodyEl, 'beforeend', [
					['div', {
						className: `eblaeo-glc-results-section panel panel-${categoryInfo.bootstrapClass}`,
					}, [
						['div', { className: 'panel-heading' }, categoryInfo.name],
						['div', { className: 'panel-body' }, [
							['ul', null,
								games.map((game) => /** @type {ElementArray} */ (
									['li', null, [
										game.isNew && !eblaeo.glc.isFirstCheck ? ['b', null, '[NEW] '] : null,
										['a', { href: `https://store.steampowered.com/app/${game.id}` },
											game.name || game.id.toString()
										],
									]]
								))
							],
						]],
					]],
				]);
			}
		} catch (err) {
			if (err instanceof CustomError) {
				showAlert(eblaeo.glc.modalBodyEl, 'atinner', 'danger', err.message);
			} else {
				showAlert(eblaeo.glc.modalBodyEl, 'atinner', 'danger', 'failed to load list check results');
			}
		}
		eblaeo.glc.modalButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
		const counterEl = document.querySelector('[id*="counter"]');
		if (!counterEl) {
			return;
		}
		// prettier-ignore
		DOM.insertElements(counterEl, 'atinner', [
			['font', { size: '4' }, [
				['b', null, `${eblaeo.glc.gamesCount} Games`],
			]],
		]);
	};

	/**
	 * [PG] Post Generator
	 * Allows the user to easily generate posts.
	 */

	/**
	 * Adds a button to the new post page, which allows generating posts.
	 * @returns {Promise<void> | void}
	 */
	const pg_addButton = () => {
		eblaeo.pg = {};
		eblaeo.pg.postField = /** @type {HTMLTextAreaElement | null} */ (document.querySelector(
			'[name="post[text]"]'
		));
		eblaeo.pg.previewButton = /** @type {HTMLElement | null} */ (document.querySelector(
			'#get-preview'
		));
		eblaeo.pg.createButton = /** @type {HTMLElement | null} */ (document.querySelector(
			'.btn.btn-primary.pull-right'
		));
		if (!eblaeo.pg.postField || !eblaeo.pg.previewButton || !eblaeo.pg.createButton) {
			return;
		}
		pg_loadInitialValues();
		eblaeo.pg.createButton.addEventListener('click', pg_deleteCaches);
		// prettier-ignore
		DOM.insertElements(eblaeo.pg.createButton, 'afterend', [
			['button', {
				id: 'eblaeo-pg-generate-button',
				type: 'button',
				className: 'btn btn-default pull-right',
				dataset: { toggle: 'modal', target: '#eblaeo-pg-modal' },
				ref: (/** @type {HTMLElement} */ ref) => (eblaeo.pg.generateButton = ref),
				onclick: pg_focusSearchGamesField,
			}, 'Generate'],
		]);
		// prettier-ignore
		DOM.insertElements(document.body, 'beforeend', [
			['div', { id: 'eblaeo-pg-modal-holder' }, [
				['div', {
					id: 'eblaeo-pg-modal',
					className: 'modal',
					attrs: { role: 'dialog' },
					tabIndex: -1,
					ref: (/** @type {HTMLElement} */ ref) => (eblaeo.pg.modalEl = ref),
				}, [
					['div', { className: 'modal-dialog modal-lg' }, [
						['div', { className: 'modal-content' }, [
							['div', { className: 'modal-header' }, [
								['button', { type: 'button', dataset: { dismiss: 'modal' } }, [
									['i', { className: 'fa fa-close' }, null],
								]],
								['h4', { id: 'eblaeo-pg-modal-label', className: 'modal-title' }, 'Generate post'],
							]],
							['div', {
								className: 'modal-body',
								ref: (/** @type {HTMLElement} */ ref) => (eblaeo.pg.modalBodyEl = ref),
							}, [
								['input', {
									id: 'eblaeo-pg-search-games',
									type: 'text',
									className: 'form-control',
									placeholder: 'Start typing to search for games …',
									dataset: { target: '#eblaeo-pg-search-games-results' },
									ref: (/** @type {HTMLInputElement} */ ref) => (eblaeo.pg.searchGamesField = ref),
									oninput: pg_searchGames,
								}, null],
								['br', null, null],
								['div', {
									id: 'eblaeo-pg-search-games-results',
									ref: (/** @type {HTMLElement} */ ref) => (eblaeo.pg.searchGamesResultsEl = ref),
								}, null],
								['br', null, null],
								['div', {
									id: 'eblaeo-pg-generator',
									className: 'panel panel-default',
									ref: (/** @type {HTMLElement} */ ref) => (eblaeo.pg.generatorEl = ref),
								}, [
									['div', { className: 'panel-heading' }, 'Generator'],
									['div', {
										id: 'eblaeo-pg-generator-body',
										className: 'panel-body',
										ref: (/** @type {HTMLElement} */ ref) => (eblaeo.pg.generatorBodyEl = ref),
									}, pg_getGeneratorBody()],
								]],
								['div', {
									id: 'eblaeo-pg-game-preview-container',
									ref: (/** @type {HTMLElement} */ ref) => (eblaeo.pg.gamePreviewContainerEl = ref),
								}, [
									['div', {
										id: 'eblaeo-pg-game-preview',
										className: 'panel panel-default',
										ref: (/** @type {HTMLElement} */ ref) => (eblaeo.pg.gamePreviewEl = ref),
									}, [
										['div', {
											className: 'panel-heading',
											dataset: { toggle: 'collapse', target: '#eblaeo-pg-game-preview-body' },
										}, [
											'Game preview',
											['span', { className: 'eblaeo-pg-collapse' }, [
												'Collapse ',
												['i', { className: 'fa fa-level-up' }],
											]],
											['span', { className: 'eblaeo-pg-expand' }, [
												'Expand ',
												['i', { className: 'fa fa-level-down' }],
											]],
										]],
										['div', {
											id: 'eblaeo-pg-game-preview-body',
											className: 'panel-body collapse in',
											ref: (/** @type {HTMLElement} */ ref) => (eblaeo.pg.gamePreviewBodyEl = ref),
										}, null],
										['div', { className: 'panel-footer' }, [
											['button', {
												id: 'eblaeo-pg-game-preview-button',
												type: 'button',
												className: 'btn btn-primary pull-right',
												ref: (/** @type {HTMLElement} */ ref) => (eblaeo.pg.gamePreviewButton = ref),
											}, null],
											['div', { className: 'clear-both' }, null],
										]],
									]],
								]],
								['div', {
									id: 'eblaeo-pg-full-preview',
									className: 'panel panel-default',
									ref: (/** @type {HTMLElement} */ ref) => (eblaeo.pg.fullPreviewEl = ref),
								}, [
									['div', {
										className: 'panel-heading',
										dataset: { toggle: 'collapse', target: '#eblaeo-pg-full-preview-body' },
									}, [
										'Full preview',
										['span', { className: 'eblaeo-pg-collapse' }, [
											'Collapse ',
											['i', { className: 'fa fa-level-up' }],
										]],
										['span', { className: 'eblaeo-pg-expand' }, [
											'Expand ',
											['i', { className: 'fa fa-level-down' }],
										]],
									]],
									['div', {
										id: 'eblaeo-pg-full-preview-body',
										className: 'panel-body collapse in',
										ref: (/** @type {HTMLElement} */ ref) => (eblaeo.pg.fullPreviewBodyEl = ref),
									}, null],
								]],
							]],
							['div', { className: 'modal-footer' }, [
								['button', {
									type: 'button',
									className: 'btn btn-default',
									dataset: { dismiss: 'modal' },
								}, 'Cancel'],
								['button', {
									id: 'eblaeo-pg-done-button',
									type: 'button',
									className: 'btn btn-primary',
									dataset: { dismiss: 'modal' },
									ref: (/** @type {HTMLElement} */ ref) => (eblaeo.pg.doneButton = ref),
									onclick: pg_generatePost,
								}, 'Done'],
							]],
						]],
					]],
				]],
			]],
		]);
		return pg_loadCaches();
	};

	/**
	 * Loads the initial values.
	 */
	const pg_loadInitialValues = () => {
		const defaultPresetBase = /** @type {PgPresetBase} */ ({
			showPlaytimeThisMonth: false,
			playtimeTemplate: '%playtime% playtime',
			linkAchievements: true,
			achievementsTemplate: '%achievements_unlocked% of %achievements_total% achievements',
			noAchievementsTemplate: 'no achievements',
			checkScreenshots: false,
			linkScreenshots: true,
			screenshotsTemplate: '%screenshots_count% screenshots',
			noScreenshotsTemplate: 'no screenshots',
			bgType: 'Solid',
			bgColor1: '#ffffff',
			bgColor2: '#000000',
			titleColor: '#555555',
			textColor: '#333333',
			linkColor: '#337ab7',
		});
		eblaeo.pg.defaultPresets = {
			'Box default': {
				type: 'box',
				name: 'Box default',
				prefs: {
					...defaultPresetBase,
					reviewPosition: 'Left',
				},
			},
			'Bar default': {
				type: 'bar',
				name: 'Bar default',
				prefs: {
					showInfoInOneLine: false,
					...defaultPresetBase,
					achievementsTemplate:
						'%achievements_unlocked% of %achievements_total% achievements (%achievements_percentage%%)',
					titleColor: '#333333',
					completionBarPosition: 'Left',
					imagePosition: 'Left',
					useCollapsibleReview: false,
					reviewTriggerMethod: 'Bar click',
				},
			},
			'Panel default': {
				type: 'panel',
				name: 'Panel default',
				prefs: {
					...defaultPresetBase,
					achievementsTemplate:
						'%achievements_unlocked% of %achievements_total% achievements (%achievements_percentage%%)',
					usePredefinedTheme: true,
					predefinedThemeColor: 'Blue',
					useCustomTheme: false,
					useCollapsibleReview: false,
				},
			},
			'Custom default': {
				type: 'custom',
				name: 'Custom default',
				prefs: {
					htmlTemplate: '',
				},
			},
		};
		eblaeo.pg.defaultGame = {
			id: 0,
			name: '',
			image: '',
			progress: 'uncategorized',
			playtime: {
				thisMonth: 0,
				total: 0,
			},
			achievements: {
				unlocked: 0,
				total: 0,
			},
			screenshotsCount: 0,
			customHtml: '',
			rating: '',
			review: '',
			preset: {
				...eblaeo.pg.defaultPresets['Box default'],
				prefs: {
					...eblaeo.pg.defaultPresets['Box default'].prefs,
				},
			},
		};
		eblaeo.pg.presetTypes = ['box', 'bar', 'panel', 'custom'];
		eblaeo.pg.presetTypeNames = {
			box: 'Box',
			bar: 'Bar',
			panel: 'Panel',
			custom: 'Custom',
		};
		eblaeo.pg.presets = {
			box: {
				...eblaeo.pg.defaultPresets['Box default'],
				prefs: {
					...eblaeo.pg.defaultPresets['Box default'].prefs,
				},
			},
			bar: {
				...eblaeo.pg.defaultPresets['Bar default'],
				prefs: {
					...eblaeo.pg.defaultPresets['Bar default'].prefs,
				},
			},
			panel: {
				...eblaeo.pg.defaultPresets['Panel default'],
				prefs: {
					...eblaeo.pg.defaultPresets['Panel default'].prefs,
				},
			},
			custom: {
				...eblaeo.pg.defaultPresets['Custom default'],
				prefs: {
					...eblaeo.pg.defaultPresets['Custom default'].prefs,
				},
			},
		};
		eblaeo.pg.currentPresetType = 'box';
		eblaeo.pg.gameInfos = [];
		eblaeo.pg.selectedGame = null;
		eblaeo.pg.isEditing = false;
		eblaeo.pg.presetTabNavEls = {};
		eblaeo.pg.presetTabEls = {};
		eblaeo.pg.presetDropdownEls = {};
		eblaeo.pg.fieldContainerEls = {
			presets: {
				box: {},
				bar: {},
				panel: {},
				custom: {},
			},
		};
		eblaeo.pg.fields = {
			presets: {
				box: {},
				bar: {},
				panel: {},
				custom: {},
			},
		};
		eblaeo.pg.isSearchingGames = false;
		eblaeo.pg.hasNewGameSearchQuery = false;
	};

	/**
	 * Returns element arrays for the generator body.
	 * @returns {ElementArray[]} The element arrays for the body.
	 */
	const pg_getGeneratorBody = () => {
		if (!eblaeo.pg.presetTypeNames) {
			return [];
		}
		// prettier-ignore
		return /** @type {ElementArray[]} */ ([
			['div', null, [
				['p', null, 'These placeholders are replaced with info about you:'],
				['ul', null, [
					['li', null, [['b', null, '%steamid%'], ' - Your Steam ID.']],
					['li', null, [['b', null, '%username%'], ' - Your BLAEO username (this can be your SteamGifts or Steam username depending on your BLAEO settings).']],
				]],
				['p', null, 'These placeholders are replaced with info about the game:'],
				['ul', null, [
					['li', null, [['b', null, '%id%'], ' - The Steam ID of the game.']],
					['li', null, [['b', null, '%name%'], ' - The name of the game.']],
					['li', null, [['b', null, '%image%'], ' - The URL of the game image.']],
					['li', null, [['b', null, '%progress%'], " - The progress of the game ('uncategorized', 'never-played', 'unfinished', 'beaten', 'completed' or 'wont-play')"]],
					['li', null, [['b', null, '%progress_name%'], " - The name for the progress of the game ('Uncategorized', 'Never Played', 'Unfinished', 'Beaten', 'Completed' or \"Won't Play\")"]],
					['li', null, [['b', null, '%progress_color%'], ' - The HEX color for the progress of the game.']],
					['li', null, [['b', null, '%playtime%'], " - Your playtime in the '12 hours' format."]],
					['li', null, [['b', null, '%playtime_this_month%'], " - Your playtime this month in the '12 hours' format."]],
					['li', null, [['b', null, '%achievements%'], " - Your achievements in the 'X of Y achievements' or 'no achievements' format."]],
					['li', null, [['b', null, '%achievements_unlocked%'], ' - The number of achievements you have unlocked in the game.']],
					['li', null, [['b', null, '%achievements_total%'], ' - The total number of achievements in the game.']],
					['li', null, [['b', null, '%achievements_percentage%'], ' - The percentage of achievements you have unlocked in the game.']],
					['li', null, [['b', null, '%screenshots%'], " - Your screenshots in the 'X screenshots' or 'no screenshots' format (if the option to check screenshots is enabled)."]],
					['li', null, [['b', null, '%screenshots_count%'], ' - The number of screenshots you have taken for the game (if the option to check screenshots is enabled)']],
				]],
				['br', null, null],
				['ul', {
					id: 'eblaeo-pg-generator-nav',
					className: 'nav nav-tabs',
					attrs: { role: 'tablist' },
					ref: (/** @type {HTMLElement} */ ref) => (eblaeo.pg.generatorNavEl = ref),
				},
					/** @type [PgPresetType, string][] */ (Object.entries(eblaeo.pg.presetTypeNames)).map(
						pg_getPresetTabNav
					)
				],
				['div', { className: 'tab-content' }, [
					pg_getBoxTab(),
					pg_getBarTab(),
					pg_getPanelTab(),
					pg_getCustomTab(),
				]],
				['div', { className: 'form-group' }, [
					pg_getField(null, {
						type: 'textarea',
						id: 'review',
						htmlId: 'review',
						label: 'Review',
						usePlaceholders: true,
					}),
				]],
				['div', { className: 'form-group' }, [
					pg_getField(null, {
						type: 'text',
						id: 'presetName',
						htmlId: 'preset-name',
						label: 'Preset name',
						description: 'Save these preferences as a preset to quickly reuse later.',
					}),
				]],
				['button', {
					id: 'eblaeo-pg-save-preset-button',
					type: 'button',
					className: 'btn btn-default',
					ref: (/** @type {HTMLElement} */ ref) => (eblaeo.pg.savePresetButton = ref),
					onclick: pg_savePreset,
				}, 'Save preset'],
				['br', null, null],
				['br', null, null],
			]],
		]);
	};

	/**
	 * Returns an element array for a preset tab nav.
	 * @param {[PgPresetType, string]} presetTypeNameEntry The preset type and name for the tab nav.
	 * @returns {ElementArray} The element array for the tab nav.
	 */
	const pg_getPresetTabNav = ([presetType, presetName]) => {
		if (!eblaeo.pg.presetTabNavEls) {
			return null;
		}
		const tabId = `eblaeo-pg-tab-${presetType}`;
		// prettier-ignore
		return /** @type {ElementArray} */ (
			['li', {
				attrs: { role: 'presentation' },
				// @ts-expect-error
				ref: (/** @type {HTMLElement} */ ref) => eblaeo.pg.presetTabNavEls[presetType] = ref,
			}, [
				['a', {
					href: `#${tabId}`,
					attrs: { role: 'tab' },
					dataset: { toggle: 'tab' },
					onclick: () => pg_changeCurrentPreset(presetType),
				}, presetName],
			]]
		);
	};

	/**
	 * Returns an element array for a box tab.
	 * @returns {ElementArray} The element array for the tab.
	 */
	const pg_getBoxTab = () => {
		if (!eblaeo.pg.presetTabEls) {
			return null;
		}
		// prettier-ignore
		return /** @type {ElementArray} */ (
			['div', {
				id: 'eblaeo-pg-tab-box',
				className: 'tab-pane',
				attrs: { role: 'tabpanel' },
				// @ts-expect-error
				ref: (/** @type {HTMLElement} */ ref) => (eblaeo.pg.presetTabEls.box = ref),
			}, [
				['div', { className: 'form-group' }, [
					pg_getPresetDropdown('box'),
					['br', null, null],
					...pg_getPresetBaseFields('box'),
					pg_getField('box', {
						type: 'select',
						id: 'reviewPosition',
						htmlId: 'review-position',
						label: 'Review position',
						selectOptions: ['Left', 'Right'],
					}),
				]],
			]]
		);
	};

	/**
	 * Returns an element array for a bar tab.
	 * @returns {ElementArray} The element array for the tab.
	 */
	const pg_getBarTab = () => {
		if (!eblaeo.pg.presetTabEls) {
			return null;
		}
		const presetBaseFields = pg_getPresetBaseFields('bar');
		// prettier-ignore
		return /** @type {ElementArray} */ (
			['div', {
				id: 'eblaeo-pg-tab-bar',
				className: 'tab-pane',
				attrs: { role: 'tabpanel' },
				// @ts-expect-error
				ref: (/** @type {HTMLElement} */ ref) => (eblaeo.pg.presetTabEls.bar = ref),
			}, [
				['div', { className: 'form-group' }, [
					pg_getPresetDropdown('bar'),
					['br', null, null],
					pg_getField('bar', {
						type: 'checkbox',
						id: 'showInfoInOneLine',
						htmlId: 'show-info-in-one-line',
						label: 'Show playtime, achievements and screenshots in one line.',
					}),
					...presetBaseFields.slice(0, 9),
					pg_getField(null, {
						type: 'text',
						id: 'customHtml',
						htmlId: 'custom-html',
						label: 'Custom HTML',
						usePlaceholders: true,
						description: 'Forces playtime, achievements and screenshots to be shown in one line, and shows the custom HTML below the line.'
					}),
					['div', { className: 'eblaeo-pg-double-field-container' }, [
						pg_getField('bar', {
							type: 'select',
							id: 'completionBarPosition',
							htmlId: 'completion-bar-position',
							label: 'Completion bar position',
							selectOptions: ['Left', 'Right', 'Hidden'],
						}),
						pg_getField('bar', {
							type: 'select',
							id: 'imagePosition',
							htmlId: 'image-position',
							label: 'Image position',
							selectOptions: ['Left', 'Right'],
						}),
					]],
					...presetBaseFields.slice(9),
					pg_getField('bar', {
						type: 'checkbox',
						id: 'useCollapsibleReview',
						htmlId: 'use-collapsible-review',
						label: 'Use collapsible review.',
					}),
					pg_getField('bar', {
						type: 'select',
						id: 'reviewTriggerMethod',
						htmlId: 'review-trigger-method',
						label: 'Review trigger method',
						selectOptions: ['Bar click', 'Button click'],
					}),
				]],
			]]
		);
	};

	/**
	 * Returns an element array for a panel tab.
	 * @returns {ElementArray} The element array for the tab.
	 */
	const pg_getPanelTab = () => {
		if (!eblaeo.pg.presetTabEls) {
			return null;
		}
		const presetBaseFields = pg_getPresetBaseFields('panel');
		// prettier-ignore
		return /** @type {ElementArray} */ (
			['div', {
				id: 'eblaeo-pg-tab-panel',
				className: 'tab-pane',
				attrs: { role: 'tabpanel' },
				// @ts-expect-error
				ref: (/** @type {HTMLElement} */ ref) => (eblaeo.pg.presetTabEls.panel = ref),
			}, [
				['div', { className: 'form-group' }, [
					pg_getPresetDropdown('panel'),
					['br', null, null],
					pg_getField(null, {
						type: 'text',
						id: 'rating',
						htmlId: 'rating',
						label: 'Rating',
					}),
					...presetBaseFields.slice(0, 9),
					pg_getField('panel', {
						type: 'radio',
						id: 'usePredefinedTheme',
						htmlId: 'use-predefined-theme',
						label: 'Use predefined theme.',
					}),
					pg_getField('panel', {
						type: 'select',
						id: 'predefinedThemeColor',
						htmlId: 'predefined-theme-color',
						label: 'Predefined theme color',
						selectOptions: ['Blue', 'Green', 'Grey', 'Red', 'Yellow'],
					}),
					pg_getField('panel', {
						type: 'radio',
						id: 'useCustomTheme',
						htmlId: 'use-custom-theme',
						label: 'Use custom theme.',
					}),
					...presetBaseFields.slice(9),
					pg_getField('panel', {
						type: 'checkbox',
						id: 'useCollapsibleReview',
						htmlId: 'use-collapsible-review',
						label: 'Use collapsible review.',
					}),
				]],
			]]
		);
	};

	/**
	 * Returns an element array for a custom tab.
	 * @returns {ElementArray} The element array for the tab.
	 */
	const pg_getCustomTab = () => {
		if (!eblaeo.pg.presetTabEls) {
			return null;
		}
		// prettier-ignore
		return /** @type {ElementArray} */ (
			['div', {
				id: 'eblaeo-pg-tab-custom',
				className: 'tab-pane',
				attrs: { role: 'tabpanel' },
				// @ts-expect-error
				ref: (/** @type {HTMLElement} */ ref) => (eblaeo.pg.presetTabEls.custom = ref),
			}, [
				['div', { className: 'form-group' }, [
					pg_getPresetDropdown('custom'),
					['br', null, null],
					pg_getField('custom', {
						type: 'textarea',
						id: 'htmlTemplate',
						htmlId: 'html-template',
						label: 'Custom HTML template',
						usePlaceholders: true,
						useReviewPlaceholder: true,
					}),
				]],
			]]
		);
	};

	/**
	 * Returns an element array for a preset dropdown.
	 * @param {PgPresetType} presetType The preset type for the dropdown.
	 * @returns {ElementArray} The element array for the dropdown.
	 */
	const pg_getPresetDropdown = (presetType) => {
		if (!eblaeo.pg.presetDropdownEls) {
			return null;
		}
		const buttonId = `eblaeo-pg-apply-preset-button-${presetType}`;
		const presets = Object.values(eblaeo.user.pgPresets).filter(
			(preset) => preset.type === presetType
		);
		// prettier-ignore
		return /** @type {ElementArray} */ (
			['div', {
				className: 'dropdown',
				// @ts-expect-error
				ref: (/** @type {HTMLElement} */ ref) => (eblaeo.pg.presetDropdownEls[presetType] = ref),
			}, [
				['button', { id: buttonId, type: 'button', dataset: { toggle: 'dropdown' } }, [
					`Apply ${presetType} preset`,
					['span', { className: 'caret' }, null],
				]],
				['ul', { id: `eblaeo-pg-preset-dropdown-${presetType}`, className: 'dropdown-menu' },
					presets.length > 0 ? presets.map(pg_getPresetDropdownListItem) : null
				],
			]]
		);
	};

	/**
	 * Returns an element array for a preset dropdown list item.
	 * @param {PgPreset<PgPresetType>} preset The preset for the list item.
	 * @returns {ElementArray} The element array for the list item.
	 */
	const pg_getPresetDropdownListItem = (preset) => {
		/** @type {HTMLElement} */
		let listItemEl;
		// prettier-ignore
		return /** @type {ElementArray} */ (
			['li', { ref: (/** @type {HTMLElement} */ ref) => (listItemEl = ref) }, [
				['a', { onclick: () => pg_applyPreset(preset.type, preset.name) }, preset.name],
				['i', {
					className: 'fa fa-trash',
					title: 'Delete preset',
					onclick: () => pg_deletePreset(preset.type, preset.name, listItemEl),
				}, null],
			]]
		);
	};

	/**
	 * Returns element arrays for preset base fields.
	 * @param {PgPresetType} presetType The preset type for the fields.
	 * @returns {ElementArray[]} The element arrays for the fields.
	 */
	const pg_getPresetBaseFields = (presetType) => {
		// prettier-ignore
		return /** @type {ElementArray[]} */ ([
			pg_getField(presetType, {
				type: 'checkbox',
				id: 'showPlaytimeThisMonth',
				htmlId: 'show-playtime-this-month',
				label: 'Show your playtime for the game this month.',
			}),
			pg_getField(presetType, {
				type: 'text',
				id: 'playtimeTemplate',
				htmlId: 'playtime-template',
				label: 'Playtime template',
				usePlaceholders: true,
			}),
			pg_getField(presetType, {
				type: 'checkbox',
				id: 'linkAchievements',
				htmlId: 'link-achievements',
				label: 'Link achievements to your achievements page for the game.',
			}),
			pg_getField(presetType, {
				type: 'text',
				id: 'achievementsTemplate',
				htmlId: 'achievements-template',
				label: 'Achievements template',
				usePlaceholders: true,
			}),
			pg_getField(presetType, {
				type: 'text',
				id: 'noAchievementsTemplate',
				htmlId: 'no-achievements-template',
				label: 'No achievements template',
				usePlaceholders: true,
			}),
			pg_getField(presetType, {
				type: 'checkbox',
				id: 'checkScreenshots',
				htmlId: 'check-screenshots',
				label: 'Check if you have screenshots for the game.',
			}),
			pg_getField(presetType, {
				type: 'checkbox',
				id: 'linkScreenshots',
				htmlId: 'link-screenshots',
				label: 'Link screenshots to your screenshots page for the game.',
			}),
			pg_getField(presetType, {
				type: 'text',
				id: 'screenshotsTemplate',
				htmlId: 'screenshots-template',
				label: 'Screenshots template',
				usePlaceholders: true,
			}),
			pg_getField(presetType, {
				type: 'text',
				id: 'noScreenshotsTemplate',
				htmlId: 'no-screenshots-template',
				label: 'No screenshots template',
				usePlaceholders: true,
			}),
			pg_getField(presetType, {
				type: 'select',
				id: 'bgType',
				htmlId: 'bg-type',
				label: 'Background type',
				selectOptions: ['Solid', 'Horizontal gradient', 'Vertical gradient'],
			}),
			['div', { className: 'eblaeo-pg-double-field-container' }, [
				pg_getField(presetType, {
					type: 'color',
					id: 'bgColor1',
					htmlId: 'bg-color-1',
					label: 'Background color 1',
				}),
				pg_getField(presetType, {
					type: 'color',
					id: 'bgColor2',
					htmlId: 'bg-color-2',
					label: 'Background color 2',
				}),
			]],
			['div', { className: 'eblaeo-pg-triple-field-container' }, [
				pg_getField(presetType, {
					type: 'color',
					id: 'titleColor',
					htmlId: 'title-color',
					label: 'Title color',
				}),
				pg_getField(presetType, {
					type: 'color',
					id: 'textColor',
					htmlId: 'text-color',
					label: 'Text color',
				}),
				pg_getField(presetType, {
					type: 'color',
					id: 'linkColor',
					htmlId: 'link-color',
					label: 'Link color',
				}),
			]],
		]);
	};

	/**
	 * Returns element arrays for a field.
	 * @param {PgPresetType | null} presetType The preset type for the field, if any.
	 * @param {PgFieldOptions} options The options for the field.
	 * @returns {ElementArray[]} The element arrays for the field.
	 */
	const pg_getField = (presetType, options) => {
		let elArrays = /** @type {ElementArray[]} */ ([]);
		const fieldId = `eblaeo-pg-${presetType ? `${presetType}-` : ''}${options.htmlId}`;
		switch (options.type) {
			case 'textarea':
			case 'text':
			case 'color':
				// prettier-ignore
				elArrays.push(['label', { htmlFor: fieldId }, `${options.label}:`]);
				if (options.usePlaceholders) {
					// prettier-ignore
					elArrays.push(
						['p', null, `You can use placeholders here. ${
							options.useReviewPlaceholder
								? 'Additionally, place `<div id="review-%username%-%id%"></div>` (without the `) where you want the review to appear.'
								: ''
						} ${options.description || ''}`]
					);
				} else if (options.description) {
					// prettier-ignore
					elArrays.push(['p', null, options.description]);
				}
				if (options.type === 'textarea') {
					// prettier-ignore
					elArrays.push(
						['textarea', {
							id: fieldId,
							className: 'form-control',
							rows: 5,
							ref: (/** @type {HTMLElement} */ ref) => pg_assignField(ref, presetType, options),
							onchange: options.id === 'review' ? pg_gamePreview : null,
							oninput: options.id === 'review' ? null : pg_gamePreview,
						}, null]
					);
				} else {
					// prettier-ignore
					elArrays.push(
						['input', {
							id: fieldId,
							type: options.type,
							className: 'form-control',
							ref: (/** @type {HTMLElement} */ ref) => pg_assignField(ref, presetType, options),
							oninput: pg_gamePreview,
						}, null]
					);
				}
				break;
			case 'checkbox':
				// prettier-ignore
				elArrays.push(
					['div', { className: 'checkbox' }, [
						['label', { htmlFor: fieldId }, [
							['input', {
								id: fieldId,
								type: 'checkbox',
								ref: (/** @type {HTMLElement} */ ref) => pg_assignField(ref, presetType, options),
								onchange: pg_gamePreview,
							}, null],
							options.label,
						]],
					]]
				);
				break;
			case 'radio':
				// prettier-ignore
				elArrays.push(
					['div', { className: 'radio' }, [
						['label', { htmlFor: fieldId }, [
							['input', {
								id: fieldId,
								type: 'radio',
								name: 'optradio',
								ref: (/** @type {HTMLElement} */ ref) => pg_assignField(ref, presetType, options),
								onchange: pg_gamePreview,
							}, null],
							options.label,
						]],
					]]
				);
				break;
			case 'select':
				// prettier-ignore
				elArrays.push(
					['label', { htmlFor: fieldId }, `${options.label}:`],
					['select', {
						id: fieldId,
						className: 'form-control',
						ref: (/** @type {HTMLElement} */ ref) => pg_assignField(ref, presetType, options),
						onchange: pg_gamePreview,
					},
						options.selectOptions.map((option) => /** @type {ElementArray} */ (
							['option', null, option]
						))
					]
				);
				break;
		}
		// prettier-ignore
		elArrays = ['div', {
			className: 'eblaeo-pg-field-container',
			ref: (/** @type {HTMLElement} */ ref) => pg_assignFieldContainer(ref, presetType, options),
		}, elArrays];
		return elArrays;
	};

	/**
	 * Assigns a field container to a variable.
	 * @param {HTMLElement} field The field container to assign.
	 * @param {PgPresetType | null} presetType The preset type for the field container, if any.
	 * @param {PgFieldOptions} options The options for the field container.
	 */
	const pg_assignFieldContainer = (field, presetType, options) => {
		if (!eblaeo.pg.fieldContainerEls) {
			return;
		}
		if (presetType) {
			// @ts-expect-error
			eblaeo.pg.fieldContainerEls.presets[presetType][options.id] = field;
		} else {
			// @ts-expect-error
			eblaeo.pg.fieldContainerEls[options.id] = field;
		}
	};

	/**
	 * Assigns a field to a variable.
	 * @param {HTMLElement} field The field to assign.
	 * @param {PgPresetType | null} presetType The preset type for the field, if any.
	 * @param {PgFieldOptions} options The options for the field.
	 */
	const pg_assignField = (field, presetType, options) => {
		if (!eblaeo.pg.fields) {
			return;
		}
		if (presetType) {
			// @ts-expect-error
			eblaeo.pg.fields.presets[presetType][options.id] = field;
		} else {
			// @ts-expect-error
			eblaeo.pg.fields[options.id] = field;
		}
	};

	/**
	 * Changes the current preset.
	 * @param {PgPresetType} presetType The type of the new preset.
	 */
	const pg_changeCurrentPreset = (presetType) => {
		eblaeo.pg.currentPresetType = presetType;
		if (!eblaeo.pg.presets || !eblaeo.pg.selectedGame) {
			return;
		}
		eblaeo.pg.selectedGame.preset = eblaeo.pg.presets[presetType];
		pg_selectGame(eblaeo.pg.selectedGame, eblaeo.pg.isEditing || false);
	};

	/**
	 * Applies a preset.
	 * @param {PgPresetType} presetType The type of the preset to apply.
	 * @param {string} presetName The name of the preset to apply.
	 */
	const pg_applyPreset = (presetType, presetName) => {
		if (
			!eblaeo.pg.defaultPresets ||
			!eblaeo.pg.presetTypeNames ||
			!eblaeo.pg.presets ||
			!eblaeo.pg.selectedGame ||
			!eblaeo.pg.fields ||
			!eblaeo.pg.fields.presetName
		) {
			return;
		}
		const preset =
			eblaeo.user.pgPresets[presetName] ||
			eblaeo.pg.defaultPresets[presetName] ||
			eblaeo.pg.defaultPresets[`${eblaeo.pg.presetTypeNames[presetType]}} default`];
		eblaeo.pg.presets[presetType] = {
			...preset,
			prefs: {
				...preset.prefs,
			},
		};
		eblaeo.pg.currentPresetType = presetType;
		eblaeo.pg.selectedGame.preset = eblaeo.pg.presets[presetType];
		eblaeo.pg.fields.presetName.value = eblaeo.pg.presets[presetType].name;
		pg_selectGame(eblaeo.pg.selectedGame, eblaeo.pg.isEditing || false);
	};

	/**
	 * Deletes a preset.
	 * @param {PgPresetType} presetType The type of the preset to delete.
	 * @param {string} presetName The name of the preset to delete.
	 * @param {HTMLElement} [listItemEl] The element of the list item for the preset, if any.
	 */
	const pg_deletePreset = (presetType, presetName, listItemEl) => {
		showDialog('Are you sure you want to delete this preset?', async () => {
			const contextEl = eblaeo.pg.presetDropdownEls && eblaeo.pg.presetDropdownEls[presetType];
			if (!contextEl) {
				return;
			}
			try {
				delete eblaeo.user.pgPresets[presetName];
				await PersistentStorage.setValue('pgPresets', eblaeo.user.pgPresets);
				if (listItemEl) {
					listItemEl.remove();
				}
				showAlert(contextEl, 'afterend', 'success', 'Preset deleted!');
			} catch (err) {
				if (err instanceof CustomError) {
					showAlert(contextEl, 'afterend', 'danger', err.message);
				} else {
					showAlert(contextEl, 'afterend', 'danger', 'failed to delete preset');
				}
			}
		});
	};

	/**
	 * Saves the current preset.
	 * @returns Promise<void>
	 */
	const pg_savePreset = async () => {
		if (
			!eblaeo.pg.presets ||
			!eblaeo.pg.currentPresetType ||
			!eblaeo.pg.presetDropdownEls ||
			!eblaeo.pg.fields ||
			!eblaeo.pg.fields.presetName ||
			!eblaeo.pg.savePresetButton
		) {
			return;
		}
		try {
			eblaeo.pg.savePresetButton.textContent = 'Saving...';
			const presetName =
				eblaeo.pg.fields.presetName.value ||
				`Untitled preset ${Object.keys(eblaeo.user.pgPresets).length + 1}`;
			const currentPreset = eblaeo.pg.presets[eblaeo.pg.currentPresetType];
			eblaeo.user.pgPresets[presetName] = {
				...currentPreset,
				name: presetName,
				prefs: {
					...currentPreset.prefs,
				},
			};
			await PersistentStorage.setValue('pgPresets', eblaeo.user.pgPresets);
			const dropdownEl = eblaeo.pg.presetDropdownEls[eblaeo.pg.currentPresetType];
			if (dropdownEl) {
				const dropdownListEl = dropdownEl.lastElementChild;
				if (dropdownListEl) {
					// prettier-ignore
					DOM.insertElements(dropdownListEl, 'beforeend', [
						pg_getPresetDropdownListItem(eblaeo.user.pgPresets[presetName])
					]);
				}
			}
			showAlert(eblaeo.pg.savePresetButton, 'afterend', 'success', 'Preset saved!');
		} catch (err) {
			if (err instanceof CustomError) {
				showAlert(eblaeo.pg.savePresetButton, 'afterend', 'danger', err.message);
			} else {
				showAlert(eblaeo.pg.savePresetButton, 'afterend', 'danger', 'failed to save preset');
			}
		}
		eblaeo.pg.savePresetButton.textContent = 'Save preset';
	};

	/**
	 * Gives focus to the search games field when the modal is open.
	 * @returns {Promise<void>}
	 */
	const pg_focusSearchGamesField = async () => {
		if (!eblaeo.pg.searchGamesField) {
			return;
		}
		const isModalOpen = !!(await DOM.dynamicQuerySelector('#eblaeo-pg-modal.modal.in', 60, 0.1));
		if (isModalOpen) {
			eblaeo.pg.searchGamesField.focus();
		}
	};

	/**
	 * Searches for games.
	 * @returns {Promise<void>}
	 */
	const pg_searchGames = async () => {
		if (!eblaeo.pg.searchGamesField || !eblaeo.pg.searchGamesResultsEl) {
			return;
		}
		if (eblaeo.pg.isSearchingGames) {
			eblaeo.pg.hasNewGameSearchQuery = true;
			return;
		}
		try {
			eblaeo.pg.isSearchingGames = true;
			eblaeo.pg.searchGamesResultsEl.innerHTML = '';
			await sm_syncSteamId(false);
			const query = eblaeo.pg.searchGamesField.value;
			if (query) {
				const listEl = await BlaeoApi.searchGames({ steamId: eblaeo.user.steamId }, query);
				if (listEl) {
					eblaeo.pg.searchGamesResultsEl.appendChild(listEl);
					const elements = Array.from(
						/** @type {NodeListOf<HTMLElement>} */ (listEl.querySelectorAll('.game'))
					);
					elements.forEach(pg_addGameListItemButton);
				}
			}
			eblaeo.pg.isSearchingGames = false;
			if (!eblaeo.pg.hasNewGameSearchQuery) {
				return;
			}
			eblaeo.pg.hasNewGameSearchQuery = false;
			pg_searchGames();
		} catch (err) {
			eblaeo.pg.isSearchingGames = false;
			if (err instanceof CustomError) {
				showAlert(eblaeo.pg.searchGamesResultsEl, 'afterend', 'danger', err.message);
			} else {
				showAlert(
					eblaeo.pg.searchGamesResultsEl,
					'afterend',
					'danger',
					'failed to search for games'
				);
			}
		}
	};

	/**
	 * Adds a button to a game list item, which allows selecting it.
	 * @param {HTMLElement} listItemEl The element of the game list item where to add the button.
	 */
	const pg_addGameListItemButton = (listItemEl) => {
		// prettier-ignore
		DOM.insertElements(listItemEl, 'beforeend', [
			['button', {
				type: 'button',
				className: 'eblaeo-pg-game-list-item-button btn btn-default',
				onclick: () => pg_selectGameListItem(listItemEl),
			}, 'Select'],
		]);
	};

	/**
	 * Selects a game list item.
	 * @param {HTMLElement} listItemEl The element of the game list item to select.
	 * @returns {Promise<void>}
	 */
	const pg_selectGameListItem = async (listItemEl) => {
		if (!eblaeo.pg.presets || !eblaeo.pg.currentPresetType || !eblaeo.pg.searchGamesResultsEl) {
			return;
		}
		try {
			const link = /** @type {HTMLAnchorElement | null} */ (listItemEl.querySelector('a'));
			if (!link) {
				return;
			}
			const url = link.href;
			if (!url) {
				return;
			}
			const matches = url.match(/\/app\/(\d+)/);
			if (!matches) {
				return;
			}
			const gameId = parseInt(matches[1]);
			const game = await BlaeoApi.getGame({ steamId: eblaeo.user.steamId }, gameId);
			if (!game) {
				throw new CustomError('could not retrieve game');
			}
			const imageEl = listItemEl.querySelector('img');
			const currentPreset = eblaeo.pg.presets[eblaeo.pg.currentPresetType];
			eblaeo.pg.presets[eblaeo.pg.currentPresetType] = {
				...currentPreset,
				prefs: {
					...currentPreset.prefs,
				},
			};
			const pgGame = /** @type {PgGame} */ ({
				id: gameId,
				name: game.name,
				image: (imageEl && imageEl.src) || '',
				progress: game.progress ? gameProgresses[game.progress] : 'uncategorized',
				playtime: {
					thisMonth: 0,
					total: game.playtime,
				},
				achievements: game.achievements || {
					unlocked: 0,
					total: 0,
				},
				screenshotsCount: 0,
				customHtml: '',
				rating: '',
				review: '',
				preset: eblaeo.pg.presets[eblaeo.pg.currentPresetType],
			});
			pg_selectGame(pgGame, false);
		} catch (err) {
			if (err instanceof CustomError) {
				showAlert(eblaeo.pg.searchGamesResultsEl, 'afterend', 'danger', err.message);
			} else {
				showAlert(eblaeo.pg.searchGamesResultsEl, 'afterend', 'danger', 'failed to select game');
			}
		}
	};

	/**
	 * Generates the post.
	 */
	const pg_generatePost = () => {
		if (
			!eblaeo.pg.postField ||
			!eblaeo.pg.previewButton ||
			!eblaeo.pg.gameInfos ||
			!eblaeo.pg.modalEl
		) {
			return;
		}
		try {
			const divEl = document.createElement('div');
			const elArrays = [];
			let boxElArrays = [];
			for (const gameInfo of eblaeo.pg.gameInfos) {
				if (gameInfo.game.preset.type === 'box') {
					boxElArrays.push(...gameInfo.elArrays);
				} else {
					if (boxElArrays.length > 0) {
						// prettier-ignore
						elArrays.push(
							['ul', {
								className: 'games',
								style: {
									minHeight: '0',
								},
							}, boxElArrays]
						);
						boxElArrays = [];
					}
					elArrays.push(...gameInfo.elArrays);
				}
			}
			if (boxElArrays.length > 0) {
				// prettier-ignore
				elArrays.push(
					['ul', {
						className: 'games',
						style: {
							minHeight: '0',
						},
					}, boxElArrays]
				);
			}
			DOM.insertElements(divEl, 'atinner', elArrays);
			eblaeo.pg.postField.value = `${eblaeo.pg.postField.value}\n\n${divEl.innerHTML}\n\n`;
			eblaeo.pg.postField.dispatchEvent(new Event('input', { bubbles: true }));
			eblaeo.pg.previewButton.dispatchEvent(new Event('click', { bubbles: true }));
		} catch (err) {
			if (err instanceof CustomError) {
				showAlert(eblaeo.pg.modalEl, 'beforeend', 'danger', err.message);
			} else {
				showAlert(eblaeo.pg.modalEl, 'beforeend', 'danger', 'failed to generate post');
			}
		}
	};

	/**
	 * Selects a game.
	 * @param {PgGame} game The game to select.
	 * @param {boolean} isEdit Whether the game is being edited or not.
	 */
	const pg_selectGame = (game, isEdit) => {
		if (
			!eblaeo.pg.searchGamesField ||
			!eblaeo.pg.searchGamesResultsEl ||
			!eblaeo.pg.generatorEl ||
			!eblaeo.pg.presetTabNavEls ||
			!eblaeo.pg.presetTabEls ||
			!eblaeo.pg.gamePreviewContainerEl ||
			!eblaeo.pg.gamePreviewBodyEl ||
			!eblaeo.pg.gamePreviewButton ||
			!eblaeo.pg.fullPreviewEl
		) {
			return;
		}
		try {
			eblaeo.pg.currentPresetType = game.preset.type || 'box';
			const presetTabNavElEntries = /** @type {[PgPresetType, HTMLElement | null][]} */ (Object.entries(
				eblaeo.pg.presetTabNavEls
			));
			for (const [presetType, presetTabNavEl] of presetTabNavElEntries) {
				const presetTabEl = eblaeo.pg.presetTabEls[presetType];
				if (!presetTabNavEl || !presetTabEl) {
					continue;
				}
				if (presetType === eblaeo.pg.currentPresetType) {
					presetTabNavEl.classList.add('active');
					presetTabEl.classList.add('active');
				} else {
					presetTabNavEl.classList.remove('active');
					presetTabEl.classList.remove('active');
				}
			}
			eblaeo.pg.selectedGame = game;
			eblaeo.pg.isEditing = isEdit;
			eblaeo.pg.searchGamesField.value = '';
			eblaeo.pg.searchGamesResultsEl.innerHTML = '';
			eblaeo.pg.generatorEl.style.display = 'block';
			eblaeo.pg.gamePreviewContainerEl.style.display = 'block';
			eblaeo.pg.gamePreviewBodyEl.innerHTML = '';
			eblaeo.pg.gamePreviewButton.textContent = isEdit ? 'Edit' : 'Add';
			eblaeo.pg.fullPreviewEl.style.display = 'none';
			Object.entries(game.preset.prefs).forEach((entry) =>
				// @ts-expect-error
				pg_fillPresetField(game.preset.type, entry)
			);
			// @ts-expect-error
			Object.entries(game).forEach(pg_fillField);
			eblaeo.pg.gamePreviewButton.onclick = () => pg_generateGame(game, isEdit, false);
			pg_gamePreview();
		} catch (err) {
			if (err instanceof CustomError) {
				showAlert(eblaeo.pg.generatorEl, 'beforebegin', 'danger', err.message);
			} else {
				showAlert(eblaeo.pg.generatorEl, 'beforebegin', 'danger', 'failed to select game');
			}
		}
	};

	/**
	 * Generates a game.
	 * @param {PgGame} game The game to generate.
	 * @param {boolean} isEdit Whether the game is being edited or not.
	 * @param {boolean} isFromCache Whether the game is from the cache or not.
	 */
	const pg_generateGame = async (game, isEdit, isFromCache) => {
		if (
			!eblaeo.pg.gameInfos ||
			!eblaeo.pg.generatorEl ||
			!eblaeo.pg.gamePreviewContainerEl ||
			!eblaeo.pg.gamePreviewButton ||
			!eblaeo.pg.fullPreviewEl
		) {
			return;
		}
		try {
			if (!isFromCache) {
				eblaeo.pg.gamePreviewButton.textContent = isEdit ? 'Editing...' : 'Adding...';
			}
			const gameElArrays = await pg_getGame(game, isFromCache);
			if (!gameElArrays) {
				throw new CustomError('could not build elements for game generation');
			}
			if (isEdit) {
				const gameInfo = eblaeo.pg.gameInfos.find((gameInfo) => gameInfo.game === game);
				if (gameInfo) {
					gameInfo.elArrays = gameElArrays;
				}
			} else {
				eblaeo.pg.gameInfos.push({ game, elArrays: gameElArrays });
			}
			if (isFromCache) {
				return;
			}
			pg_saveCaches();
			eblaeo.pg.isEditing = false;
			eblaeo.pg.generatorEl.style.display = 'none';
			eblaeo.pg.gamePreviewContainerEl.style.display = 'none';
			eblaeo.pg.fullPreviewEl.style.display = 'block';
			pg_fullPreview();
		} catch (err) {
			if (err instanceof CustomError) {
				showAlert(eblaeo.pg.generatorEl, 'afterend', 'danger', err.message);
			} else {
				showAlert(eblaeo.pg.generatorEl, 'afterend', 'danger', 'failed to generate game');
			}
			throw err;
		}
	};

	/**
	 * Returns element arrays for a game.
	 * @param {PgGame} game The game.
	 * @param {boolean} isFromCache Whether the game is from the cache or not.
	 * @returns {Promise<ElementArray[] | undefined>} The element arrays for the game, if successful.
	 */
	const pg_getGame = async (game, isFromCache) => {
		if (!isFromCache) {
			pg_fillGameValues(game);
		}
		game.playtime.thisMonth = await pg_getPlaytimeThisMonth(game);
		game.screenshotsCount = await pg_getScreenshotsCount(game);
		const reviewPreviewEl = await pg_getReviewPreview(game);
		if (pg_isPresetType(game.preset, 'box')) {
			return pg_getBoxGame(game, game.preset, reviewPreviewEl);
		}
		if (pg_isPresetType(game.preset, 'bar')) {
			return pg_getBarGame(game, game.preset, reviewPreviewEl);
		}
		if (pg_isPresetType(game.preset, 'panel')) {
			return pg_getPanelGame(game, game.preset, reviewPreviewEl);
		}
		if (pg_isPresetType(game.preset, 'custom')) {
			return pg_getCustomGame(game, game.preset, reviewPreviewEl);
		}
	};

	/**
	 * Fills the values for a game from the field values.
	 * @param {PgGame} game The game.
	 */
	const pg_fillGameValues = (game) => {
		if (!eblaeo.pg.fields) {
			return;
		}
		const fieldKeys = /** @type {(keyof PgFields)[]} */ (Object.keys(eblaeo.pg.fields));
		for (const fieldKey of fieldKeys) {
			if (fieldKey === 'presets') {
				const presetKeys = /** @type {PgPresetFieldKey[]} */ (Object.keys(
					eblaeo.pg.fields.presets[game.preset.type]
				));
				for (const presetKey of presetKeys) {
					// @ts-expect-error
					game.preset.prefs[presetKey] = /** @type {never} */ (pg_getPresetFieldValue(
						game.preset.type,
						presetKey
					));
				}
			} else if (fieldKey !== 'presetName') {
				game[fieldKey] = /** @type {never} */ (pg_getFieldValue(fieldKey));
			}
		}
		pg_handleFieldDependencies(game);
		if (!pg_isNotPresetType(game.preset, 'custom')) {
			return;
		}
		const oldPlaytimeTemplate = game.preset.prefs.playtimeTemplate;
		if (game.preset.prefs.showPlaytimeThisMonth) {
			if (!game.preset.prefs.playtimeTemplate.includes('%playtime_this_month%')) {
				game.preset.prefs.playtimeTemplate = `${game.preset.prefs.playtimeTemplate} (%playtime_this_month% this month)`;
			}
		} else {
			game.preset.prefs.playtimeTemplate = game.preset.prefs.playtimeTemplate.replace(
				' (%playtime_this_month% this month)',
				''
			);
		}
		const newPlaytimeTemplate = game.preset.prefs.playtimeTemplate;
		if (newPlaytimeTemplate !== oldPlaytimeTemplate) {
			pg_fillPresetField(game.preset.type, ['playtimeTemplate', newPlaytimeTemplate]);
		}
	};

	/**
	 * Handles field dependencies for a game.
	 * @param {PgGame} game The game.
	 */
	const pg_handleFieldDependencies = (game) => {
		if (!eblaeo.pg.fields) {
			return;
		}
		const fieldKeys = /** @type {(keyof PgFields)[]} */ (Object.keys(eblaeo.pg.fields));
		for (const fieldKey of fieldKeys) {
			if (fieldKey === 'presets') {
				const presetKeys = /** @type {PgPresetFieldKey[]} */ (Object.keys(
					eblaeo.pg.fields.presets[game.preset.type]
				));
				for (const presetKey of presetKeys) {
					/** @type {boolean} */
					let shouldBeVisible;
					switch (presetKey) {
						case 'checkScreenshots':
							// @ts-expect-error
							shouldBeVisible = game.preset.prefs.checkScreenshots;
							pg_togglePresetField(game.preset.type, 'linkScreenshots', shouldBeVisible);
							pg_togglePresetField(game.preset.type, 'screenshotsTemplate', shouldBeVisible);
							pg_togglePresetField(game.preset.type, 'noScreenshotsTemplate', shouldBeVisible);
							break;
						case 'usePredefinedTheme':
							// @ts-expect-error
							shouldBeVisible = game.preset.prefs.usePredefinedTheme;
							pg_togglePresetField(game.preset.type, 'predefinedThemeColor', shouldBeVisible);
							pg_togglePresetField(game.preset.type, 'bgType', !shouldBeVisible);
							pg_togglePresetField(game.preset.type, 'bgColor1', !shouldBeVisible);
							pg_togglePresetField(game.preset.type, 'bgColor2', !shouldBeVisible);
							pg_togglePresetField(game.preset.type, 'titleColor', !shouldBeVisible);
							pg_togglePresetField(game.preset.type, 'textColor', !shouldBeVisible);
							pg_togglePresetField(game.preset.type, 'linkColor', !shouldBeVisible);
							break;
						case 'useCustomTheme':
							// @ts-expect-error
							shouldBeVisible = game.preset.prefs.useCustomTheme;
							pg_togglePresetField(game.preset.type, 'predefinedThemeColor', !shouldBeVisible);
							pg_togglePresetField(game.preset.type, 'bgType', shouldBeVisible);
							pg_togglePresetField(game.preset.type, 'bgColor1', shouldBeVisible);
							pg_togglePresetField(game.preset.type, 'bgColor2', shouldBeVisible);
							pg_togglePresetField(game.preset.type, 'titleColor', shouldBeVisible);
							pg_togglePresetField(game.preset.type, 'textColor', shouldBeVisible);
							pg_togglePresetField(game.preset.type, 'linkColor', shouldBeVisible);
							break;
						case 'bgType':
							shouldBeVisible =
								// @ts-expect-error
								!game.preset.prefs.usePredefinedTheme && game.preset.prefs.bgType !== 'Solid';
							pg_togglePresetField(game.preset.type, 'bgColor2', shouldBeVisible);
							break;
						case 'useCollapsibleReview':
							// @ts-expect-error
							shouldBeVisible = game.preset.prefs.useCollapsibleReview;
							pg_togglePresetField(game.preset.type, 'reviewTriggerMethod', shouldBeVisible);
							break;
						// no default
					}
				}
			} else if (fieldKey !== 'presetName') {
				// Do nothing for now.
			}
		}
	};

	/**
	 * Fills a preset field
	 * @param {PgPresetType} presetType The preset type.
	 * @param {[PgPresetFieldKey, unknown]} pair The key / value pair to fill.
	 */
	const pg_fillPresetField = (presetType, [key, value]) => {
		if (!eblaeo.pg.fields) {
			return;
		}
		// @ts-expect-error
		const field = /** @type {HTMLInputElement | null} */ (eblaeo.pg.fields.presets[presetType][
			key
		]);
		if (!field) {
			return;
		}
		if (field.type === 'checkbox' || field.type === 'radio') {
			// @ts-expect-error
			field.checked = value;
		} else {
			// @ts-expect-error
			field.value = value;
		}
	};

	/**
	 * Fills a field
	 * @param {[PgFieldKey, unknown]} pair The key / value pair to fill.
	 */
	const pg_fillField = ([key, value]) => {
		if (!eblaeo.pg.fields) {
			return;
		}
		const field = /** @type {HTMLInputElement | null} */ (eblaeo.pg.fields[key]);
		if (!field) {
			return;
		}
		if (field.type === 'checkbox' || field.type === 'radio') {
			// @ts-expect-error
			field.checked = value;
		} else {
			// @ts-expect-error
			field.value = value;
		}
	};

	/**
	 * Returns a preset field value.
	 * @param {PgPresetType} presetType The preset type.
	 * @param {PgPresetFieldKey} key The field key.
	 * @returns {unknown} The field value.
	 */
	const pg_getPresetFieldValue = (presetType, key) => {
		if (!eblaeo.pg.fields) {
			return;
		}
		// @ts-expect-error
		const field = /** @type {HTMLInputElement | null} */ (eblaeo.pg.fields.presets[presetType][
			key
		]);
		return field
			? field.type === 'checkbox' || field.type === 'radio'
				? field.checked
				: field.value
			: null;
	};

	/**
	 * Returns a field value.
	 * @param {PgFieldKey} key The field key.
	 * @returns {unknown} The field value.
	 */
	const pg_getFieldValue = (key) => {
		if (!eblaeo.pg.fields) {
			return '';
		}
		const field = /** @type {HTMLInputElement | null} */ (eblaeo.pg.fields[key]);
		return field
			? field.type === 'checkbox' || field.type === 'radio'
				? field.checked
				: field.value
			: null;
	};

	/**
	 * Toggles a preset field visibility.
	 * @param {PgPresetType} presetType The preset type.
	 * @param {PgPresetFieldKey} key The field key.
	 * @param {boolean} shouldBeVisible Whether the field should be visible or not.
	 */
	const pg_togglePresetField = (presetType, key, shouldBeVisible) => {
		if (!eblaeo.pg.fieldContainerEls) {
			return;
		}
		// @ts-expect-error
		const field = /** @type {HTMLInputElement | null} */ (eblaeo.pg.fieldContainerEls.presets[
			presetType
		][key]);
		if (field) {
			field.style.display = shouldBeVisible ? 'block' : 'none';
		}
	};

	/**
	 * Toggles a field visibility.
	 * @param {PgFieldKey} key The field key.
	 * @param {boolean} shouldBeVisible Whether the field should be visible or not.
	 */
	// eslint-disable-next-line
	const pg_toggleField = (key, shouldBeVisible) => {
		if (!eblaeo.pg.fieldContainerEls) {
			return '';
		}
		const field = /** @type {HTMLInputElement | null} */ (eblaeo.pg.fieldContainerEls[key]);
		if (field) {
			field.style.display = shouldBeVisible ? 'block' : 'none';
		}
	};

	/**
	 * Retrieves the playtime for a game this month.
	 * @param {PgGame} game The game.
	 * @returns {Promise<number>} The playtime for the game this month.
	 */
	const pg_getPlaytimeThisMonth = async (game) => {
		if (
			(pg_isPresetType(game.preset, 'custom') &&
				!game.preset.prefs.htmlTemplate.includes('%playtime_this_month%')) ||
			(pg_isNotPresetType(game.preset, 'custom') && !game.preset.prefs.showPlaytimeThisMonth)
		) {
			return 0;
		}
		if (!eblaeo.pg.playtimeThisMonthCache) {
			const recentlyPlayed =
				(await BlaeoApi.getRecentlyPlayed({ steamId: eblaeo.user.steamId })) || [];
			eblaeo.pg.playtimeThisMonthCache = Object.fromEntries(
				recentlyPlayed.map((recentlyPlayedGame) => [
					recentlyPlayedGame.steam_id,
					recentlyPlayedGame.minutes,
				])
			);
		}
		return eblaeo.pg.playtimeThisMonthCache[game.id] || 0;
	};

	/**
	 * Retrieves the screenshots count for a game.
	 * @param {PgGame} game The game.
	 * @returns {Promise<number>} The screenshots count for the game.
	 */
	const pg_getScreenshotsCount = async (game) => {
		if (
			(pg_isPresetType(game.preset, 'custom') &&
				!game.preset.prefs.htmlTemplate.includes('%screenshots%') &&
				!game.preset.prefs.htmlTemplate.includes('%screenshots_count%')) ||
			(pg_isNotPresetType(game.preset, 'custom') && !game.preset.prefs.checkScreenshots)
		) {
			return 0;
		}
		if (!eblaeo.pg.screenshotsCache) {
			eblaeo.pg.screenshotsCache = {};
		}
		if (!Utils.isSet(eblaeo.pg.screenshotsCache[game.id])) {
			const response = await Requests.GET(
				`https://steamcommunity.com/profiles/${eblaeo.user.steamId}/screenshots?appid=${game.id}`
			);
			if (response.dom) {
				const elements = Array.from(
					response.dom.querySelectorAll('[href*="steamcommunity.com/sharedfiles/filedetails"]')
				);
				eblaeo.pg.screenshotsCache[game.id] = elements.length;
			}
		}
		return eblaeo.pg.screenshotsCache[game.id] || 0;
	};

	/**
	 * Retrieves the review preview for a game.
	 * @param {PgGame} game The game.
	 * @returns {Promise<HTMLElement | null>} The element of the review preview for the game, if successful.
	 */
	const pg_getReviewPreview = async (game) => {
		if (!game.review) {
			return null;
		}
		if (!eblaeo.pg.reviewsCache) {
			eblaeo.pg.reviewsCache = {};
		}
		let reviewCache = eblaeo.pg.reviewsCache[game.id];
		if (!reviewCache || reviewCache.review !== game.review) {
			const reviewPreviewEl =
				(await BlaeoApi.previewPost(
					{ steamId: eblaeo.user.steamId },
					pg_replacePlaceholders(game.review, game)
				)) || null;
			if (reviewPreviewEl) {
				eblaeo.pg.reviewsCache[game.id] = {
					review: game.review,
					reviewPreview: reviewPreviewEl.outerHTML,
				};
				reviewCache = eblaeo.pg.reviewsCache[game.id];
			}
		}
		if (!reviewCache) {
			return null;
		}
		const divEl = document.createElement('div');
		divEl.innerHTML = reviewCache.reviewPreview;
		return /** @type {HTMLElement | null} */ (divEl.firstElementChild);
	};

	/**
	 * Returns element arrays for a game using a box preset.
	 * @param {PgGame} game The game.
	 * @param {PgPreset<'box'>} preset The box preset to use.
	 * @param {HTMLElement | null} reviewPreviewEl The element of the review preview for the game, if any.
	 * @returns {ElementArray[]} The element arrays for the game.
	 */
	const pg_getBoxGame = (game, preset, reviewPreviewEl) => {
		// prettier-ignore
		let elArrays = /** @type {ElementArray[]} */ ([
			['li', {
				className: `game game-thumbnail game-${game.progress}`,
				style: {
					background:
						preset.prefs.bgType === 'Solid'
							? null
							: `linear-gradient(to ${
									preset.prefs.bgType === 'Horizontal gradient' ? 'right' : 'bottom'
								}, ${preset.prefs.bgColor1}, ${preset.prefs.bgColor2})`,
					backgroundColor: preset.prefs.bgType === 'Solid' ? preset.prefs.bgColor1 : null,
					color: preset.prefs.textColor,
				},
			}, [
				['div', {
					className: 'title',
					style: {
						color: preset.prefs.titleColor,
					},
				}, game.name],
				['a', { href: `https://store.steampowered.com/app/${game.id}/`, target: '_blank' }, [
					['img', { src: game.image, alt: game.name }, null],
				]],
				['div', {
					className: 'caption',
					style: {
						backgroundColor: 'transparent',
						color: 'inherit',
						height: 'auto',
						padding: '9px',
					},
				}, [
					['p', null, pg_replacePlaceholders(preset.prefs.playtimeTemplate, game)],
					game.achievements.total === 0
						? ['p', {
								style: {
									color: 'inherit',
									opacity: '0.5',
								},
							}, pg_replacePlaceholders(preset.prefs.noAchievementsTemplate, game)]
						: preset.prefs.linkAchievements
						? ['p', null, [
								['a', {
									href: pg_replacePlaceholders(
										'https://steamcommunity.com/profiles/%steamid%/stats/%id%/?tab=achievements',
										game
									),
									target: '_blank',
									style: {
										color: preset.prefs.linkColor,
									},
								}, pg_replacePlaceholders(preset.prefs.achievementsTemplate, game)],
							]]
						: ['p', null, pg_replacePlaceholders(preset.prefs.achievementsTemplate, game)],
					preset.prefs.checkScreenshots
						? (
								game.screenshotsCount === 0
									? ['p', {
											style: {
												color: 'inherit',
												opacity: '0.5',
											},
										}, pg_replacePlaceholders(preset.prefs.noScreenshotsTemplate, game)]
									: preset.prefs.linkScreenshots
									? ['p', null, [
											['a', {
												href: pg_replacePlaceholders(
													'https://steamcommunity.com/profiles/%steamid%/screenshots?appid=%id%',
													game
												),
												target: '_blank',
												style: {
													color: preset.prefs.linkColor,
												},
											}, pg_replacePlaceholders(preset.prefs.screenshotsTemplate, game)],
										]]
									: ['p', null, pg_replacePlaceholders(preset.prefs.screenshotsTemplate, game)]
							)
						: null,
				]],
			]],
		]);
		if (!reviewPreviewEl) {
			return elArrays;
		}
		// prettier-ignore
		elArrays = [
			['div', {
				style: {
					overflow: 'auto',
				},
			}, [
				['div', {
					style: {
						...(
							preset.prefs.reviewPosition === 'Left'
								? {
										float: 'right',
										margin: '5px 5px 5px 10px',
									}
								: {
										float: 'left',
										margin: '5px 10px 5px 5px',
									}
						),
						position: 'relative',
						zIndex: '1',
					},
				}, elArrays],
				['div', {
					style: {
						fontSize: '14px',
						textAlign: 'justify',
					},
				}, reviewPreviewEl],
			]],
		];
		return elArrays;
	};

	/**
	 * Returns element arrays for a game using a bar preset.
	 * @param {PgGame} game The game.
	 * @param {PgPreset<'bar'>} preset The bar preset to use.
	 * @param {HTMLElement | null} reviewPreviewEl The element of the review preview for the game, if any.
	 * @returns {ElementArray[]} The element arrays for the game.
	 */
	const pg_getBarGame = (game, preset, reviewPreviewEl) => {
		const showInfoInOneLine = preset.prefs.showInfoInOneLine || !!game.customHtml;
		let customEls;
		if (game.customHtml) {
			const divEl = document.createElement('div');
			divEl.innerHTML = game.customHtml;
			customEls = Array.from(divEl.childNodes).map((child) =>
				child.nodeType === Node.TEXT_NODE ? child.textContent : child
			);
		}
		const reviewId = `review-${eblaeo.user.username}-${game.id}`;
		// prettier-ignore
		const imageArray = /** @type {ElementArray} */ (
			['div', {
				className: `media-${preset.prefs.imagePosition.toLowerCase()}`,
				style: {
					padding: '0',
				},
			}, [
				['a', { href: `https://store.steampowered.com/app/${game.id}/`, target: '_blank' }, [
					['img', {
						src: game.image,
						alt: game.name,
						style: {
							maxWidth: 'unset',
						},
					}, null],
				]],
			]]
		);
		// prettier-ignore
		const barArray = /** @type {ElementArray} */ (
			['div', {
				className: 'media-body',
				style: {
					fontSize: showInfoInOneLine ? '12px' : null,
					padding: '0 10px',
					position: 'relative',
				},
			}, [
				['h4', {
					className: 'media-heading',
					style: {
						color: preset.prefs.titleColor,
					},
				}, game.name],
				pg_replacePlaceholders(preset.prefs.playtimeTemplate, game),
				showInfoInOneLine ? ', ' : ['br', null, null],
				game.achievements.total === 0
					? ['span', {
							style: {
								color: 'inherit',
								opacity: '0.5',
							},
						}, pg_replacePlaceholders(preset.prefs.noAchievementsTemplate, game)]
					: preset.prefs.linkAchievements
					? ['a', {
							href: pg_replacePlaceholders(
								'https://steamcommunity.com/profiles/%steamid%/stats/%id%/?tab=achievements',
								game
							),
							target: '_blank',
							style: {
								color: preset.prefs.linkColor,
							},
						}, pg_replacePlaceholders(preset.prefs.achievementsTemplate, game)]
					: pg_replacePlaceholders(preset.prefs.achievementsTemplate, game),
				...(
					preset.prefs.checkScreenshots
						? [
								', ',
								game.screenshotsCount === 0
									? ['span', {
											style: {
												color: 'inherit',
												opacity: '0.5',
											},
										}, pg_replacePlaceholders(preset.prefs.noScreenshotsTemplate, game)]
									: preset.prefs.linkScreenshots
									? ['a', {
											href: pg_replacePlaceholders(
												'https://steamcommunity.com/profiles/%steamid%/screenshots?appid=%id%',
												game
											),
											target: '_blank',
											style: {
												color: preset.prefs.linkColor,
											},
										}, pg_replacePlaceholders(preset.prefs.screenshotsTemplate, game)]
									: pg_replacePlaceholders(preset.prefs.screenshotsTemplate, game),
							]
						: []
				),
				...(
					customEls
						? [
								['br', null, null],
								...customEls,
							]
						: []
				),
				preset.prefs.useCollapsibleReview && reviewPreviewEl
					? (
							preset.prefs.reviewTriggerMethod === 'Bar click'
								? ['span', {
										style: {
											bottom: '50%',
											fontSize: '14px',
											fontWeight: 'bold',
											position: 'absolute',
											right: '10px',
											transform: 'translateY(50%)',
										},
									}, [
										'More ',
										['i', { className: 'fa fa-level-down' }, null],
									]]
								:	['button', {
										className: 'btn btn-xs',
										dataset: { toggle: 'collapse', target: `#${reviewId}` },
										style: {
											backgroundColor: preset.prefs.titleColor,
											bottom: '50%',
											color: preset.prefs.bgColor1,
											fontSize: '14px',
											fontWeight: 'bold',
											position: 'absolute',
											right: '10px',
											transform: 'translateY(50%)',
										},
									}, [
										'More ',
										['i', { className: 'fa fa-level-down' }, null],
									]]
						)
					: null,
			]]
		);
		// prettier-ignore
		return /** @type {ElementArray[]} */ ([
			['div', {
				className: `game game-media game-${game.progress}`,
				dataset:
					preset.prefs.useCollapsibleReview &&
					preset.prefs.reviewTriggerMethod === 'Bar click' &&
					reviewPreviewEl
						? { toggle: 'collapse', target: `#${reviewId}` }
						: null,
				style: {
					background:
						preset.prefs.bgType === 'Solid'
							? null
							: `linear-gradient(to ${
									preset.prefs.bgType === 'Horizontal gradient' ? 'right' : 'bottom'
								}, ${preset.prefs.bgColor1}, ${preset.prefs.bgColor2})`,
					backgroundColor: preset.prefs.bgType === 'Solid' ? preset.prefs.bgColor1 : null,
					borderLeft:
						preset.prefs.completionBarPosition === 'Left'
							? `10px solid ${gameCategoryInfos[game.progress].color}`
							: '0',
					borderRight:
						preset.prefs.completionBarPosition === 'Right'
							? `10px solid ${gameCategoryInfos[game.progress].color}`
							: '0',
					color: preset.prefs.textColor,
				},
			},
				preset.prefs.imagePosition === 'Left' ? [imageArray, barArray] : [barArray, imageArray]
			],
			reviewPreviewEl
				? ['div', {
						...(preset.prefs.useCollapsibleReview ? { id: reviewId, className: 'collapse' } : {}),
						style: {
							border: '1px solid #dee2e6',
							borderRadius: '0 0 4px 4px',
							borderTop: '0',
							padding: '10px',
							textAlign: 'justify',
						},
					}, reviewPreviewEl]
				: null,
		]);
	};

	/**
	 * Returns element arrays for a game using a panel preset.
	 * @param {PgGame} game The game.
	 * @param {PgPreset<'panel'>} preset The panel preset to use.
	 * @param {HTMLElement | null} reviewPreviewEl The element of the review preview for the game, if any.
	 * @returns {ElementArray[]} The element arrays for the game.
	 */
	const pg_getPanelGame = (game, preset, reviewPreviewEl) => {
		const reviewId = `review-${eblaeo.user.username}-${game.id}`;
		// prettier-ignore
		return /** @type {ElementArray[]} */ ([
			['div', {
				className: `panel ${
					preset.prefs.usePredefinedTheme
						? `panel-${bootstrapColorClasses[preset.prefs.predefinedThemeColor]}`
						: ''
				}`,
				style: {
					borderColor: preset.prefs.useCustomTheme ? preset.prefs.bgColor1 : null,
				},
			}, [
				['div', {
					className: 'panel-heading',
					dataset:
						preset.prefs.useCollapsibleReview && reviewPreviewEl
							? { toggle: 'collapse', target: `#${reviewId}` }
							: null,
					style: {
						background:
							preset.prefs.usePredefinedTheme || preset.prefs.bgType === 'Solid'
								? null
								: `linear-gradient(to ${
										preset.prefs.bgType === 'Horizontal gradient' ? 'right' : 'bottom'
									}, ${preset.prefs.bgColor1}, ${preset.prefs.bgColor2})`,
						backgroundColor:
							preset.prefs.useCustomTheme && preset.prefs.bgType === 'Solid'
								? preset.prefs.bgColor1
								: null,
					},
				}, [
					['div', {
						className: `game game-media game-${game.progress}`,
						style: {
							color: preset.prefs.useCustomTheme ? preset.prefs.textColor : null,
						},
					}, [
						['div', { className: 'media-left' }, [
							['a', { href: `https://store.steampowered.com/app/${game.id}/`, target: '_blank' }, [
								['img', {
									src: `https://steamcdn-a.akamaihd.net/steam/apps/${game.id}/header.jpg`,
									alt: game.name,
									style: {
										height: '90px',
										maxWidth: 'unset',
										width: 'unset',
									},
								}, null],
							]],
						]],
						['div', {
							className: 'media-body',
							style: {
								position: 'relative',
							},
						}, [
							['h4', {
								className: 'media-heading',
								style: {
									color: preset.prefs.useCustomTheme ? preset.prefs.titleColor : null,
								},
							}, [
								game.name,
								' ',
								['a', {
									href: `https://store.steampowered.com/app/${game.id}`,
									target: '_blank',
									style: {
										color: preset.prefs.useCustomTheme ? preset.prefs.linkColor : null,
									},
								}, [
									['font', { size: '2px' }, [
										['i', { className: 'fa fa-external-link' }, null],
									]],
								]],
								preset.prefs.checkScreenshots && game.rating
									? ['div', {
											style: {
												float: 'right',
											},
										}, [
											`${game.rating} `,
											['i', { className: 'fa fa-star' }, null],
										]]
									: null,
							]],
							!preset.prefs.checkScreenshots && game.rating
								? ['div', null, [
										['i', { className: 'fa fa-star' }, null],
										` ${game.rating}`,
									]]
								: null,
							!preset.prefs.checkScreenshots && !game.rating
								? ['br', null, null]
								: null,
							['div', null, [
								['i', { className: 'fa fa-clock-o' }, null],
								` ${pg_replacePlaceholders(preset.prefs.playtimeTemplate, game)}`,
							]],
							['div', null, [
								['i', { className: 'fa fa-trophy' }, null],
								' ',
								game.achievements.total === 0
									? ['span', {
											style: {
												color: 'inherit',
												opacity: '0.5',
											},
										}, pg_replacePlaceholders(preset.prefs.noAchievementsTemplate, game)]
									: preset.prefs.linkAchievements
									? ['a', {
											href: pg_replacePlaceholders(
												'https://steamcommunity.com/profiles/%steamid%/stats/%id%/?tab=achievements',
												game
											),
											target: '_blank',
											style: {
												color: preset.prefs.useCustomTheme ? preset.prefs.linkColor : null,
											},
										}, pg_replacePlaceholders(preset.prefs.achievementsTemplate, game)]
									: pg_replacePlaceholders(preset.prefs.achievementsTemplate, game)
							]],
							preset.prefs.checkScreenshots
								? ['div', null, [
										['i', { className: 'fa fa-image' }, null],
										' ',
										game.screenshotsCount === 0
											? ['span', {
													style: {
														color: 'inherit',
														opacity: '0.5',
													},
												}, pg_replacePlaceholders(preset.prefs.noScreenshotsTemplate, game)]
											: preset.prefs.linkScreenshots
											? ['a', {
													href: pg_replacePlaceholders(
														'https://steamcommunity.com/profiles/%steamid%/screenshots?appid=%id%',
														game
													),
													target: '_blank',
													style: {
														color: preset.prefs.useCustomTheme ? preset.prefs.linkColor : null,
													},
												}, pg_replacePlaceholders(preset.prefs.screenshotsTemplate, game)]
											: pg_replacePlaceholders(preset.prefs.screenshotsTemplate, game)
									]]
								: null,
							preset.prefs.useCollapsibleReview && reviewPreviewEl
								? ['span', {
										style: {
											bottom: '1px',
											fontWeight: 'bold',
											position: 'absolute',
											right: '10px',
										},
									}, [
										'More ',
										['i', { className: 'fa fa-level-down' }, null],
									]]
								: null,
						]],
					]],
				]],
				reviewPreviewEl
					? ['div', {
							...(preset.prefs.useCollapsibleReview ? { id: reviewId, className: 'collapse' } : {}),
							style: {
								padding: '10px',
								textAlign: 'justify',
							},
						}, reviewPreviewEl]
					: null,
			]],
		]);
	};

	/**
	 * Returns element arrays for a game using a custom preset.
	 * @param {PgGame} game The game.
	 * @param {PgPreset<'custom'>} preset The custom preset to use.
	 * @param {HTMLElement | null} reviewPreviewEl The element of the review preview for the game, if any.
	 * @returns {ElementArray[]} The element arrays for the game.
	 */
	const pg_getCustomGame = (game, preset, reviewPreviewEl) => {
		const divEl = document.createElement('div');
		divEl.innerHTML = pg_replacePlaceholders(preset.prefs.htmlTemplate, game);
		if (reviewPreviewEl) {
			const reviewId = `review-${eblaeo.user.username}-${game.id}`;
			const reviewEl = divEl.querySelector(`#${reviewId}`);
			if (reviewEl) {
				reviewEl.appendChild(reviewPreviewEl);
			}
		}
		return Array.from(divEl.childNodes).map((child) =>
			child.nodeType === Node.TEXT_NODE ? child.textContent : child
		);
	};

	/**
	 * Replaces placeholders in a text.
	 * @param {string} text The text where to replace the placeholders.
	 * @param {PgGame} game The game with the values to replace the placeholders.
	 * @returns {string} The text with the placeholders replaced.
	 */
	const pg_replacePlaceholders = (text, game) => {
		const achievementsPercentage =
			game.achievements.total > 0
				? Math.round((game.achievements.unlocked / game.achievements.total) * 10000) / 100
				: 0;
		return (text || '')
			.replace(/%steamid%/g, eblaeo.user.steamId)
			.replace(/%username%/g, eblaeo.user.username)
			.replace(/%id%/g, game.id.toString())
			.replace(/%name%/g, game.name)
			.replace(/%image%/g, game.image)
			.replace(/%progress%/g, game.progress)
			.replace(/%progress_name%/g, gameCategoryInfos[game.progress].name)
			.replace(/%progress_color%/g, gameCategoryInfos[game.progress].color)
			.replace(
				/%playtime%/g,
				Utils.getRelativeTimeFromMinutes(game.playtime.total, 'h').replace('about ', '')
			)
			.replace(
				/%playtime_this_month%/g,
				Utils.getRelativeTimeFromMinutes(game.playtime.thisMonth, 'h').replace('about ', '')
			)
			.replace(
				/%achievements%/g,
				game.achievements.total > 0
					? `${game.achievements.unlocked} of ${game.achievements.total} achievements`
					: 'no achievements'
			)
			.replace(/%achievements_unlocked%/g, game.achievements.unlocked.toLocaleString())
			.replace(/%achievements_total%/g, game.achievements.total.toLocaleString())
			.replace(/%achievements_percentage%/g, achievementsPercentage.toLocaleString())
			.replace(
				/%screenshots%/g,
				game.screenshotsCount > 0 ? `${game.screenshotsCount} screenshots` : 'no screenshots'
			)
			.replace(/%screenshots_count%/g, game.screenshotsCount.toLocaleString());
	};

	/**
	 * @template {PgPresetType} T
	 * @param {PgPreset<PgPresetType>} preset
	 * @param {T} type
	 * @returns {preset is PgPreset<T>}
	 */
	const pg_isPresetType = (preset, type) => {
		return preset.type === type;
	};

	/**
	 * @template {PgPresetType} T
	 * @param {PgPreset<PgPresetType>} preset
	 * @param {T} type
	 * @returns {preset is PgPreset<Exclude<PgPresetType, T>>}
	 */
	const pg_isNotPresetType = (preset, type) => {
		return preset.type !== type;
	};

	/**
	 * Previews the selected game.
	 * @returns {Promise<void>}
	 */
	const pg_gamePreview = async () => {
		if (
			!eblaeo.pg.gameInfos ||
			!eblaeo.pg.selectedGame ||
			!eblaeo.pg.gamePreviewContainerEl ||
			!eblaeo.pg.gamePreviewBodyEl
		) {
			return;
		}
		try {
			eblaeo.pg.gamePreviewContainerEl.style.display = 'block';
			showAlert(eblaeo.pg.gamePreviewBodyEl, 'atinner', 'loading', 'Loading game preview...');
			const gameElArrays = await pg_getGame(eblaeo.pg.selectedGame, false);
			if (!gameElArrays) {
				throw new CustomError('could not build elements for game preview');
			}
			// prettier-ignore
			DOM.insertElements(eblaeo.pg.gamePreviewBodyEl, 'atinner',
				eblaeo.pg.selectedGame.preset.type === 'box'
					? [
							['ul', {
								className: 'games',
								style: {
									minHeight: '0',
								},
							}, gameElArrays]
						]
					: gameElArrays
			);
		} catch (err) {
			if (err instanceof CustomError) {
				showAlert(eblaeo.pg.gamePreviewBodyEl, 'atinner', 'danger', err.message);
			} else {
				showAlert(eblaeo.pg.gamePreviewBodyEl, 'atinner', 'danger', 'failed to preview game');
			}
		}
	};

	/**
	 * Previews all games.
	 */
	const pg_fullPreview = () => {
		if (!eblaeo.pg.gameInfos || !eblaeo.pg.fullPreviewEl || !eblaeo.pg.fullPreviewBodyEl) {
			return;
		}
		try {
			eblaeo.pg.fullPreviewEl.style.display = 'block';
			showAlert(eblaeo.pg.fullPreviewBodyEl, 'atinner', 'loading', 'Loading full preview...');
			const elArrays = [];
			let boxElArrays = [];
			for (const gameInfo of eblaeo.pg.gameInfos) {
				if (gameInfo.game.preset.type === 'box') {
					boxElArrays.push(pg_getFullPreviewGame(gameInfo));
				} else {
					if (boxElArrays.length > 0) {
						// prettier-ignore
						elArrays.push(
							['ul', {
								className: 'games',
								style: {
									minHeight: '0',
								},
							}, boxElArrays]
						);
						boxElArrays = [];
					}
					elArrays.push(pg_getFullPreviewGame(gameInfo));
				}
			}
			if (boxElArrays.length > 0) {
				// prettier-ignore
				elArrays.push(
					['ul', {
						className: 'games',
						style: {
							minHeight: '0',
						},
					}, boxElArrays]
				);
			}
			DOM.insertElements(eblaeo.pg.fullPreviewBodyEl, 'atinner', elArrays);
		} catch (err) {
			if (err instanceof CustomError) {
				showAlert(eblaeo.pg.fullPreviewBodyEl, 'atinner', 'danger', err.message);
			} else {
				showAlert(eblaeo.pg.fullPreviewBodyEl, 'atinner', 'danger', 'failed to preview all games');
			}
		}
	};

	/**
	 * Returns an element array for a full preview game.
	 * @param {PgGameInfo} gameInfo The info for the game.
	 * @returns {ElementArray} The element array for the game.
	 */
	const pg_getFullPreviewGame = (gameInfo) => {
		// prettier-ignore
		return /** @type {ElementArray} */ (
			['div', {
				className: 'eblaeo-pg-full-preview-game',
				style: {
					display: gameInfo.game.preset.type === 'box' ? 'inline-block' : null,
				},
			}, [
				...gameInfo.elArrays,
				['div', { className: 'btn-toolbar' }, [
					['button', {
						type: 'button',
						className: 'btn btn-default edit',
						title: 'Edit',
						onclick: () => pg_selectGame(gameInfo.game, true),
					}, [
						['i', { className: 'fa fa-edit' }, null],
					]],
					['button', {
						type: 'button',
						className: 'btn btn-default remove',
						title: 'Remove',
						onclick: () => pg_removeGame(gameInfo.game),
					}, [
						['i', { className: 'fa fa-trash' }, null],
					]],
					['button', {
						type: 'button',
						className: 'btn btn-default move-down',
						title: 'Move down',
						onclick: () => pg_moveGameDown(gameInfo.game),
					}, [
						['i', { className: 'fa fa-arrow-down' }, null],
					]],
					['button', {
						type: 'button',
						className: 'btn btn-default move-up',
						title: 'Move up',
						onclick: () => pg_moveGameUp(gameInfo.game),
					}, [
						['i', { className: 'fa fa-arrow-up' }, null],
					]],
				]],
			]]
		);
	};

	/**
	 * Removes a game.
	 * @param {PgGame} game The game to remove.
	 */
	const pg_removeGame = (game) => {
		showDialog('Are you sure you want to remove this game?', () => {
			if (!eblaeo.pg.gameInfos) {
				return;
			}
			eblaeo.pg.gameInfos = eblaeo.pg.gameInfos.filter((gameInfo) => gameInfo.game !== game);
			pg_saveCaches();
			pg_fullPreview();
		});
	};

	/**
	 * Moves a game down.
	 * @param {PgGame} game The game to move down.
	 */
	const pg_moveGameDown = (game) => {
		if (!eblaeo.pg.gameInfos) {
			return;
		}
		const currentIndex = eblaeo.pg.gameInfos.findIndex((gameInfo) => gameInfo.game === game);
		const nextIndex = currentIndex + 1;
		if (nextIndex >= eblaeo.pg.gameInfos.length) {
			showDialog('Cannot move down!');
			return;
		}
		const tmpGameInfo = eblaeo.pg.gameInfos[nextIndex];
		eblaeo.pg.gameInfos[nextIndex] = eblaeo.pg.gameInfos[currentIndex];
		eblaeo.pg.gameInfos[currentIndex] = tmpGameInfo;
		pg_saveCaches();
		pg_fullPreview();
	};

	/**
	 * Moves a game up.
	 * @param {PgGame} game The game to move up.
	 */
	const pg_moveGameUp = (game) => {
		if (!eblaeo.pg.gameInfos) {
			return;
		}
		const currentIndex = eblaeo.pg.gameInfos.findIndex((gameInfo) => gameInfo.game === game);
		const previousIndex = currentIndex - 1;
		if (previousIndex < 0) {
			showDialog('Cannot move up!');
			return;
		}
		const tmpGameInfo = eblaeo.pg.gameInfos[previousIndex];
		eblaeo.pg.gameInfos[previousIndex] = eblaeo.pg.gameInfos[currentIndex];
		eblaeo.pg.gameInfos[currentIndex] = tmpGameInfo;
		pg_saveCaches();
		pg_fullPreview();
	};

	/**
	 * Loads the caches.
	 * @returns {Promise<void>}
	 */
	const pg_loadCaches = async () => {
		if (!eblaeo.pg.generatorEl) {
			return;
		}
		eblaeo.pg.gamesCache = /** @type {PgGame[]} */ (PersistentStorage.getLocalValue('gamesCache'));
		eblaeo.pg.playtimeThisMonthCache = /** @type {Record<number, number>} */ (PersistentStorage.getLocalValue(
			'playtimeThisMonthCache'
		));
		eblaeo.pg.screenshotsCache = /** @type {Record<number, number>} */ (PersistentStorage.getLocalValue(
			'screenshotsCache'
		));
		eblaeo.pg.reviewsCache = /** @type {Record<number, PgReviewCache>} */ (PersistentStorage.getLocalValue(
			'reviewsCache'
		));
		if (!eblaeo.pg.gamesCache || eblaeo.pg.gamesCache.length === 0) {
			return;
		}
		try {
			for (const game of eblaeo.pg.gamesCache) {
				await pg_generateGame(game, false, true);
			}
			pg_fullPreview();
		} catch (err) {
			if (err instanceof CustomError) {
				showAlert(eblaeo.pg.generatorEl, 'beforebegin', 'danger', err.message);
			} else {
				showAlert(eblaeo.pg.generatorEl, 'beforebegin', 'danger', 'failed to load games cache');
			}
		}
	};

	/**
	 * Saves the caches.
	 */
	const pg_saveCaches = () => {
		if (!eblaeo.pg.gameInfos) {
			return;
		}
		PersistentStorage.setLocalValue(
			'gamesCache',
			eblaeo.pg.gameInfos.map((gameInfo) => gameInfo.game)
		);
		PersistentStorage.setLocalValue(
			'playtimeThisMonthCache',
			eblaeo.pg.playtimeThisMonthCache || {}
		);
		PersistentStorage.setLocalValue('screenshotsCache', eblaeo.pg.screenshotsCache || {});
		PersistentStorage.setLocalValue('reviewsCache', eblaeo.pg.reviewsCache || {});
	};

	/**
	 * Deletes the caches.
	 */
	const pg_deleteCaches = () => {
		PersistentStorage.deleteLocalValue('gamesCache');
		PersistentStorage.deleteLocalValue('playtimeThisMonthCache');
		PersistentStorage.deleteLocalValue('screenshotsCache');
		PersistentStorage.deleteLocalValue('reviewsCache');
	};

	try {
		BlaeoApi.init();
		await PersistentStorage.init(scriptId, defaultValues);
		await load();
	} catch (err) {
		console.log(`Failed to load ${scriptName}: `, err);
	}
})();