Auto Dark Mode for ESJ Zone

Automatically switch the theme between light and dark, based on the browser’s color scheme preference.

// ==UserScript==
// @name               Auto Dark Mode for ESJ Zone
// @name:zh-TW         ESJ Zone 自動黑暗模式
// @name:zh-CN         ESJ Zone 自动黑暗模式
// @description        Automatically switch the theme between light and dark, based on the browser’s color scheme preference.
// @description:zh-TW  根據瀏覽器的佈景主題設定,自動從明亮和黑暗模式間切換。
// @description:zh-CN  根据浏览器的布景主题设定,自动从明亮和黑暗模式间切换。
// @icon               https://icons.duckduckgo.com/ip3/www.esjzone.cc.ico
// @author             Jason Kwok
// @namespace          https://jasonhk.dev/
// @version            1.0.9
// @license            MIT
// @match              https://www.esjzone.cc/*
// @match              https://www.esjzone.me/*
// @run-at             document-end
// @grant              GM.getValue
// @grant              GM.setValue
// @grant              GM.registerMenuCommand
// @require            https://unpkg.com/typesafe-i18n@5.26.2/dist/i18n.object.min.js
// @require            https://unpkg.com/uuid-random@1.3.2/uuid-random.min.js
// @require            https://update.greasyfork.org/scripts/494512/1373878/gm-inject.js
// @supportURL         https://greasyfork.org/scripts/488026/feedback
// ==/UserScript==

const LL = (function()
{
    const translations =
    {
        "en": {
            COMMAND: {
                SETTINGS: "Change Theme Settings",
            },
            SETTINGS: {
                TITLE: "Theme Settings",
                LIGHT_THEME: "Light Theme",
                WHITE: "White",
                BLUE: "Blue",
                GREEN: "Green",
                GRAY: "Pink",
                LIGHT_GRAY: "Light Gray",
                DARK_THEME: "Dark Theme",
                BLACK: "Black",
                DARK_GRAY: "Dark Gray",
                CANCEL: "Cancel",
                SAVE: "Save",
            },
        },
        "zh-TW": {
            COMMAND: {
                SETTINGS: "更改主題設定",
            },
            SETTINGS: {
                TITLE: "主題設定",
                LIGHT_THEME: "明亮主題",
                WHITE: "白色",
                BLUE: "藍色",
                GREEN: "綠色",
                PINK: "粉紅色",
                LIGHT_GRAY: "淺灰色",
                DARK_THEME: "黑暗主題",
                BLACK: "黑色",
                DARK_GRAY: "深灰色",
                CANCEL: "取消",
                SAVE: "儲存",
            },
        },
        "zh-CN": {
            COMMAND: {
                SETTINGS: "更改主题设定",
            },
            SETTINGS: {
                TITLE: "主题设定",
                LIGHT_THEME: "明亮主题",
                WHITE: "白色",
                BLUE: "蓝色",
                GREEN: "绿色",
                PINK: "粉红色",
                LIGHT_GRAY: "浅灰色",
                DARK_THEME: "黑暗主题",
                BLACK: "黑色",
                DARK_GRAY: "深灰色",
                CANCEL: "取消",
                SAVE: "储存",
            },
        },
    };

    let locale = "en";
    for (let _locale of navigator.languages.map((language) => new Intl.Locale(language)))
    {
        if (_locale.language === "zh")
        {
            _locale = new Intl.Locale("zh", { region: _locale.maximize().region });
        }
;
        if (_locale.baseName in translations)
        {
            locale = _locale.baseName;
            break;
        }
    }

    return i18nObject(locale, translations[locale]);
})();

const EVENT_KEY = uuid();

const query = matchMedia("(prefers-color-scheme: dark)");

GM.registerMenuCommand(LL.COMMAND.SETTINGS(), async () =>
{
    await showThemeSettings();
    updateTheme(query);
});

query.addEventListener("change", updateTheme);
updateTheme(query);

GM.injectPageScript(
    ({ EVENT_KEY }) =>
    {
        window.addEventListener(`${EVENT_KEY}:showModal`, ({ detail: selector }) =>
        {
            $(selector)
                .on("hide.bs.modal", (event) =>
                {
                    event.target.dispatchEvent(new CustomEvent("hide.bs.modal", { ...event }));
                })
                .on("hidden.bs.modal", (event) =>
                {
                    event.target.dispatchEvent(new CustomEvent("hidden.bs.modal", { ...event }));
                })
                .modal("show");
        });

        window.addEventListener(`${EVENT_KEY}:hideModal`, ({ detail: selector }) =>
        {
            $(selector)
                .modal("hide");
        });
    },
    { EVENT_KEY });

function getLightTheme()
{
    return GM.getValue("light_theme", "mycolor-0");
}

function getDarkTheme()
{
    return GM.getValue("dark_theme", "mycolor-1");
}

function setThemeSettings(lightTheme, darkTheme)
{
    return Promise.all([GM.setValue("light_theme", lightTheme), GM.setValue("dark_theme", darkTheme)]);
}

function getExpectedTheme(isDarkMode)
{
    return isDarkMode ? getDarkTheme() : getLightTheme();
}

function getCurrentTheme()
{
    return document.querySelector(".customizer-color-switch [id^=mycolor-].active")?.id ?? "mycolor-0";
}

function setTheme(name)
{
    document.querySelector(`.customizer-color-switch #${name}`).click();
}

async function updateTheme({ matches: isDarkMode })
{
    const expectedTheme = await getExpectedTheme(isDarkMode);
    if (getCurrentTheme() !== expectedTheme)
    {
        setTheme(expectedTheme);
    }
}

let settingsOpened = false;

function showThemeSettings()
{
    if (settingsOpened) { return Promise.reject(new Error("Settings was already opened.")); }

    return new Promise(async (resolve) =>
    {
        const [lightTheme, darkTheme] = await Promise.all([getLightTheme(), getDarkTheme()]);

        const form = document.createElement("form");
        form.id = uuid();
        form.classList.add("modal", "fade");
        form.addEventListener("submit", async (event) =>
        {
            event.preventDefault();

            const settings = new FormData(form);
            await setThemeSettings(settings.get("light_theme"), settings.get("dark_theme"));

            window.dispatchEvent(new CustomEvent(`${EVENT_KEY}:hideModal`, { detail: `#${form.id}` }));
        });
        form.addEventListener("hide.bs.modal", () => resolve());
        form.addEventListener("hidden.bs.modal", () =>
        {
            form.remove();
            settingsOpened = false;
        });

        const modalDialog = document.createElement("div");
        modalDialog.classList.add("modal-dialog");

        const modalContent = document.createElement("div");
        modalContent.classList.add("modal-content");

        const modalHeader = document.createElement("div");
        modalHeader.classList.add("modal-header");

        const modalTitle = document.createElement("h4");
        modalTitle.classList.add("modal-title");
        modalTitle.innerText = LL.SETTINGS.TITLE();

        const closeButton = document.createElement("button");
        closeButton.classList.add("close");
        closeButton.type = "button";
        closeButton.dataset.dismiss = "modal";
        closeButton.innerHTML = `<span aria-hidden="true">×</span>`;

        const modalBody = document.createElement("div");
        modalBody.classList.add("modal-body");

        const lightThemeFormGroup = document.createElement("div");
        lightThemeFormGroup.classList.add("form-group");

        const lightThemeLabel = document.createElement("label");
        lightThemeLabel.htmlFor = "light-theme-select";
        lightThemeLabel.innerText = LL.SETTINGS.LIGHT_THEME();

        const lightThemeSelect = document.createElement("select");
        lightThemeSelect.id = "light-theme-select";
        lightThemeSelect.classList.add("form-control");
        lightThemeSelect.name = "light_theme";

        const whiteThemeOption = document.createElement("option");
        whiteThemeOption.value = "mycolor-0";
        whiteThemeOption.selected = (lightTheme === "mycolor-0");
        whiteThemeOption.innerText = LL.SETTINGS.WHITE();

        const blueThemeOption = document.createElement("option");
        blueThemeOption.value = "mycolor-2";
        blueThemeOption.selected = (lightTheme === "mycolor-2");
        blueThemeOption.innerText = LL.SETTINGS.BLUE();

        const greenThemeOption = document.createElement("option");
        greenThemeOption.value = "mycolor-3";
        greenThemeOption.selected = (lightTheme === "mycolor-3");
        greenThemeOption.innerText = LL.SETTINGS.GREEN();

        const pinkThemeOption = document.createElement("option");
        pinkThemeOption.value = "mycolor-4";
        pinkThemeOption.selected = (lightTheme === "mycolor-4");
        pinkThemeOption.innerText = LL.SETTINGS.PINK();

        const lightGrayThemeOption = document.createElement("option");
        lightGrayThemeOption.value = "mycolor-5";
        lightGrayThemeOption.selected = (lightTheme === "mycolor-5");
        lightGrayThemeOption.innerText = LL.SETTINGS.LIGHT_GRAY();

        const darkThemeFormGroup = document.createElement("div");
        darkThemeFormGroup.classList.add("form-group");

        const darkThemeLabel = document.createElement("label");
        darkThemeLabel.htmlFor = "dark-theme-select";
        darkThemeLabel.innerText = LL.SETTINGS.DARK_THEME();

        const darkThemeSelect = document.createElement("select");
        darkThemeSelect.id = "dark-theme-select";
        darkThemeSelect.classList.add("form-control");
        darkThemeSelect.name = "dark_theme";

        const blackThemeOption = document.createElement("option");
        blackThemeOption.value = "mycolor-1";
        blackThemeOption.selected = (darkTheme === "mycolor-1");
        blackThemeOption.innerText = LL.SETTINGS.BLACK();

        const darkGrayThemeOption = document.createElement("option");
        darkGrayThemeOption.value = "mycolor-6";
        darkGrayThemeOption.selected = (darkTheme === "mycolor-6");
        darkGrayThemeOption.innerText = LL.SETTINGS.DARK_GRAY();

        const modalFooter = document.createElement("div");
        modalFooter.classList.add("modal-footer");

        const cancelButton = document.createElement("button");
        cancelButton.classList.add("btn", "btn-default");
        cancelButton.type = "button";
        cancelButton.dataset.dismiss = "modal";
        cancelButton.innerText = LL.SETTINGS.CANCEL();

        const saveButton = document.createElement("button");
        saveButton.classList.add("btn", "btn-primary");
        cancelButton.type = "submit";
        saveButton.innerText = LL.SETTINGS.SAVE();

        modalHeader.append(modalTitle, closeButton);
        lightThemeSelect.append(whiteThemeOption, blueThemeOption, greenThemeOption, pinkThemeOption, lightGrayThemeOption);
        lightThemeFormGroup.append(lightThemeLabel, lightThemeSelect);
        darkThemeSelect.append(blackThemeOption, darkGrayThemeOption);
        darkThemeFormGroup.append(darkThemeLabel, darkThemeSelect);
        modalBody.append(lightThemeFormGroup, darkThemeFormGroup);
        modalFooter.append(cancelButton, saveButton);
        modalContent.append(modalHeader, modalBody, modalFooter);
        modalDialog.append(modalContent);
        form.append(modalDialog);
        document.body.append(form);

        window.dispatchEvent(new CustomEvent(`${EVENT_KEY}:showModal`, { detail: `#${form.id}` }));
        settingsOpened = true;
    });
}