From c7eedb9655017dc4eac73210050571b303a704f9 Mon Sep 17 00:00:00 2001 From: Maschga <88616799+Maschga@users.noreply.github.com> Date: Fri, 13 Jun 2025 15:19:24 +0200 Subject: [PATCH] chore: Typescript: Rewrite js (#21719) Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- assets/js/api.js | 68 ----------- assets/js/api.ts | 69 +++++++++++ assets/js/app.js | 84 ------------- assets/js/app.ts | 87 ++++++++++++++ assets/js/components/Auth/auth.ts | 2 +- .../GlobalSettings/UserInterfaceSettings.vue | 18 ++- assets/js/components/Savings/Savings.vue | 4 +- assets/js/featureflags.js | 15 --- assets/js/featureflags.ts | 16 +++ assets/js/i18n.js | 109 ----------------- assets/js/i18n.ts | 113 ++++++++++++++++++ assets/js/restart.js | 23 ---- assets/js/restart.ts | 23 ++++ assets/js/router.js | 97 --------------- assets/js/router.ts | 102 ++++++++++++++++ assets/js/theme.js | 73 ----------- assets/js/theme.ts | 71 +++++++++++ assets/js/types/evcc.ts | 7 ++ package-lock.json | 8 ++ package.json | 1 + 20 files changed, 512 insertions(+), 478 deletions(-) delete mode 100644 assets/js/api.js create mode 100644 assets/js/api.ts delete mode 100644 assets/js/app.js create mode 100644 assets/js/app.ts delete mode 100644 assets/js/featureflags.js create mode 100644 assets/js/featureflags.ts delete mode 100644 assets/js/i18n.js create mode 100644 assets/js/i18n.ts delete mode 100644 assets/js/restart.js create mode 100644 assets/js/restart.ts delete mode 100644 assets/js/router.js create mode 100644 assets/js/router.ts delete mode 100644 assets/js/theme.js create mode 100644 assets/js/theme.ts diff --git a/assets/js/api.js b/assets/js/api.js deleted file mode 100644 index 44559ab91..000000000 --- a/assets/js/api.js +++ /dev/null @@ -1,68 +0,0 @@ -import axios from "axios"; -import { openLoginModal } from "./components/Auth/auth"; - -const { protocol, hostname, port, pathname } = window.location; - -const base = protocol + "//" + hostname + (port ? ":" + port : "") + pathname; - -// override the way axios serializes arrays in query parameters (a=1&a=2&a=3 instead of a[]=1&a[]=2&a[]=3) -function customParamsSerializer(params) { - const queryString = Object.keys(params) - .filter((key) => params[key] !== null) - .map((key) => { - const value = params[key]; - if (Array.isArray(value)) { - return value.map((v) => `${encodeURIComponent(key)}=${encodeURIComponent(v)}`).join("&"); - } - return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; - }) - .join("&"); - return queryString; -} - -const api = axios.create({ - baseURL: base + "api/", - headers: { - Accept: "application/json", - }, - paramsSerializer: customParamsSerializer, -}); - -// global error handling -api.interceptors.response.use( - (response) => response, - (error) => { - // handle unauthorized errors - if (error.response?.status === 401) { - openLoginModal(); - return Promise.reject(error); - } - - const message = [`${error.message}.`]; - if (error.response?.data?.error) { - message.push(`${error.response.data.error}.`); - } - if (error.config) { - const method = error.config.method.toUpperCase(); - const url = error.request.responseURL; - message.push(`${method} ${url}`); - } - window.app.raise({ message }); - return Promise.reject(error); - } -); - -export default api; - -export const i18n = axios.create({ - baseURL: base + "i18n/", - headers: { - Accept: "application/json", - }, -}); - -export const allowClientError = { - validateStatus(status) { - return status >= 200 && status < 500; - }, -}; diff --git a/assets/js/api.ts b/assets/js/api.ts new file mode 100644 index 000000000..275447021 --- /dev/null +++ b/assets/js/api.ts @@ -0,0 +1,69 @@ +import axios from "axios"; +import { openLoginModal } from "./components/Auth/auth"; + +const { protocol, hostname, port, pathname } = window.location; + +const base = protocol + "//" + hostname + (port ? ":" + port : "") + pathname; + +// override the way axios serializes arrays in query parameters (a=1&a=2&a=3 instead of a[]=1&a[]=2&a[]=3) +function customParamsSerializer(params: { [key: string]: any }) { + return Object.keys(params) + .filter((key) => params[key] !== null) + .map((key) => { + const value = params[key]; + if (Array.isArray(value)) { + return value + .map((v) => `${encodeURIComponent(key)}=${encodeURIComponent(v)}`) + .join("&"); + } + return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; + }) + .join("&"); +} + +const api = axios.create({ + baseURL: base + "api/", + headers: { + Accept: "application/json", + }, + paramsSerializer: customParamsSerializer, +}); + +// global error handling +api.interceptors.response.use( + (response) => response, + (error) => { + // handle unauthorized errors + if (error.response?.status === 401) { + openLoginModal(); + return Promise.reject(error); + } + + const message = [`${error.message}.`]; + if (error.response?.data?.error) { + message.push(`${error.response.data.error}.`); + } + if (error.config) { + const method = error.config.method.toUpperCase(); + const url = error.request.responseURL; + message.push(`${method} ${url}`); + } + window.app.raise({ message }); + return Promise.reject(error); + } +); + +export default api; + +export const i18n = axios.create({ + baseURL: base + "i18n/", + headers: { + Accept: "application/json", + }, +}); + +export const allowClientError = { + validateStatus(status: number) { + return status >= 200 && status < 500; + }, +}; diff --git a/assets/js/app.js b/assets/js/app.js deleted file mode 100644 index 828fb8d98..000000000 --- a/assets/js/app.js +++ /dev/null @@ -1,84 +0,0 @@ -import "bootstrap/dist/css/bootstrap.min.css"; -import "../css/app.css"; -import { createApp, h } from "vue"; -import { VueHeadMixin, createHead } from "@unhead/vue/client"; -import App from "./views/App.vue"; -import setupRouter from "./router"; -import setupI18n from "./i18n"; -import featureflags from "./featureflags"; -import { watchThemeChanges } from "./theme"; -import { appDetection, sendToApp } from "./utils/native"; - -// lazy load smoothscroll polyfill. mainly for safari < 15.4 -if (!window.CSS.supports("scroll-behavior", "smooth")) { - console.log("no native smoothscroll support. polyfilling..."); - import("smoothscroll-polyfill").then((module) => { - module.polyfill(); - }); -} - -const app = createApp({ - data() { - return { notifications: [], offline: false }; - }, - watch: { - offline(value) { - console.log(`we are ${value ? "offline" : "online"}`); - }, - }, - methods: { - raise(msg) { - if (this.offline) return; - if (!msg.level) msg.level = "error"; - const now = new Date(); - const latestMsg = this.notifications[0]; - if (latestMsg && latestMsg.message === msg.message && latestMsg.lp === msg.lp) { - latestMsg.count++; - latestMsg.time = now; - } else { - this.notifications = [ - { - ...msg, - count: 1, - time: now, - }, - ...this.notifications, - ]; - } - }, - clear() { - this.notifications = []; - }, - setOnline() { - this.offline = false; - sendToApp({ type: "online" }); - }, - setOffline() { - this.offline = true; - sendToApp({ type: "offline" }); - }, - }, - render() { - return h(App, { notifications: this.notifications, offline: this.offline }); - }, -}); - -const i18n = setupI18n(); -const head = createHead(); - -app.use(i18n); -app.use(setupRouter(i18n)); -app.use(featureflags); -app.use(head); -app.mixin(VueHeadMixin); -window.app = app.mount("#app"); - -watchThemeChanges(); -appDetection(); - -if (window.evcc.customCss === "true") { - const link = document.createElement("link"); - link.href = `./custom.css`; - link.rel = "stylesheet"; - document.head.appendChild(link); -} diff --git a/assets/js/app.ts b/assets/js/app.ts new file mode 100644 index 000000000..325aa466e --- /dev/null +++ b/assets/js/app.ts @@ -0,0 +1,87 @@ +import "bootstrap/dist/css/bootstrap.min.css"; +import "../css/app.css"; +import { createApp, defineComponent, h } from "vue"; +import { VueHeadMixin, createHead } from "@unhead/vue/client"; +import App from "./views/App.vue"; +import setupRouter from "./router.ts"; +import setupI18n from "./i18n.ts"; +import featureflags from "./featureflags.ts"; +import { watchThemeChanges } from "./theme.ts"; +import { appDetection, sendToApp } from "./utils/native"; +import type { Notification } from "./types/evcc"; + +// lazy load smoothscroll polyfill. mainly for safari < 15.4 +if (!window.CSS.supports("scroll-behavior", "smooth")) { + console.log("no native smoothscroll support. polyfilling..."); + import("smoothscroll-polyfill").then((module) => { + module.polyfill(); + }); +} + +const app = createApp( + defineComponent({ + data() { + return { notifications: [] as Notification[], offline: false }; + }, + watch: { + offline(value) { + console.log(`we are ${value ? "offline" : "online"}`); + }, + }, + methods: { + raise(msg: Notification) { + if (this.offline) return; + if (!msg.level) msg.level = "error"; + const now = new Date(); + const latestMsg = this.notifications[0]; + if (latestMsg && latestMsg.message === msg.message && latestMsg.lp === msg.lp) { + latestMsg.count++; + latestMsg.time = now; + } else { + this.notifications = [ + { + ...msg, + count: 1, + time: now, + }, + ...this.notifications, + ]; + } + }, + clear() { + this.notifications = []; + }, + setOnline() { + this.offline = false; + sendToApp({ type: "online" }); + }, + setOffline() { + this.offline = true; + sendToApp({ type: "offline" }); + }, + }, + render() { + return h(App, { notifications: this.notifications, offline: this.offline }); + }, + }) +); + +const i18n = setupI18n(); +const head = createHead(); + +app.use(i18n); +app.use(setupRouter(i18n.global)); +app.use(featureflags); +app.use(head); +app.mixin(VueHeadMixin); +window.app = app.mount("#app"); + +watchThemeChanges(); +appDetection(); + +if (window.evcc.customCss === "true") { + const link = document.createElement("link"); + link.href = `./custom.css`; + link.rel = "stylesheet"; + document.head.appendChild(link); +} diff --git a/assets/js/components/Auth/auth.ts b/assets/js/components/Auth/auth.ts index cbc4b8520..d49554a7b 100644 --- a/assets/js/components/Auth/auth.ts +++ b/assets/js/components/Auth/auth.ts @@ -71,7 +71,7 @@ export function getAndClearNextModal() { return nextModal; } -export function openLoginModal(nextUrl = null, nextModal = null) { +export function openLoginModal(nextUrl: string | null = null, nextModal: Modal | null = null) { auth.nextUrl = nextUrl; auth.nextModal = nextModal; const modal = Modal.getOrCreateInstance(document.getElementById("loginModal") as HTMLElement); diff --git a/assets/js/components/GlobalSettings/UserInterfaceSettings.vue b/assets/js/components/GlobalSettings/UserInterfaceSettings.vue index 2ccaaea23..5e1c67bbb 100644 --- a/assets/js/components/GlobalSettings/UserInterfaceSettings.vue +++ b/assets/js/components/GlobalSettings/UserInterfaceSettings.vue @@ -95,13 +95,18 @@ import TelemetrySettings from "../TelemetrySettings.vue"; import FormRow from "../Helper/FormRow.vue"; import SelectGroup from "../Helper/SelectGroup.vue"; -import { getLocalePreference, setLocalePreference, LOCALES, removeLocalePreference } from "@/i18n"; -import { getThemePreference, setThemePreference, THEMES } from "@/theme"; +import { + getLocalePreference, + setLocalePreference, + LOCALES, + removeLocalePreference, +} from "@/i18n.ts"; +import { getThemePreference, setThemePreference } from "@/theme.ts"; import { getUnits, setUnits, UNITS, is12hFormat, set12hFormat } from "@/units"; -import { getHiddenFeatures, setHiddenFeatures } from "@/featureflags"; +import { getHiddenFeatures, setHiddenFeatures } from "@/featureflags.ts"; import { isApp } from "@/utils/native"; import { defineComponent, type PropType } from "vue"; -import type { Sponsor } from "@/types/evcc"; +import { THEME, type Sponsor } from "@/types/evcc"; const TIME_12H = "12"; const TIME_24H = "24"; @@ -120,7 +125,7 @@ export default defineComponent({ timeFormat: is12hFormat() ? TIME_12H : TIME_24H, hiddenFeatures: getHiddenFeatures(), fullscreenActive: false, - THEMES, + THEMES: Object.values(THEME), UNITS, TIME_FORMATS: [TIME_24H, TIME_12H], }; @@ -157,7 +162,8 @@ export default defineComponent({ }, language(value) { const i18n = this.$root?.$i18n; - if (value) { + if (!i18n) return; + else if (value) { setLocalePreference(i18n, value); } else { removeLocalePreference(i18n); diff --git a/assets/js/components/Savings/Savings.vue b/assets/js/components/Savings/Savings.vue index 2cc69b13b..17db3a73a 100644 --- a/assets/js/components/Savings/Savings.vue +++ b/assets/js/components/Savings/Savings.vue @@ -210,8 +210,8 @@ import LiveCommunity from "./LiveCommunity.vue"; import TelemetrySettings from "../TelemetrySettings.vue"; import CustomSelect from "../Helper/CustomSelect.vue"; import settings from "@/settings"; -import api, { allowClientError } from "@/api"; -import { docsPrefix } from "@/i18n"; +import api, { allowClientError } from "@/api.ts"; +import { docsPrefix } from "@/i18n.ts"; import { defineComponent, type PropType } from "vue"; import type { CURRENCY, Rate, SelectOption, Sponsor as SponsorType } from "@/types/evcc"; import type { Period } from "./types"; diff --git a/assets/js/featureflags.js b/assets/js/featureflags.js deleted file mode 100644 index 7932143e9..000000000 --- a/assets/js/featureflags.js +++ /dev/null @@ -1,15 +0,0 @@ -import settings from "./settings"; - -export function setHiddenFeatures(value) { - settings.hiddenFeatures = value; -} - -export function getHiddenFeatures() { - return settings.hiddenFeatures; -} - -export default { - install: (app) => { - app.config.globalProperties.$hiddenFeatures = getHiddenFeatures; - }, -}; diff --git a/assets/js/featureflags.ts b/assets/js/featureflags.ts new file mode 100644 index 000000000..0d3a996e8 --- /dev/null +++ b/assets/js/featureflags.ts @@ -0,0 +1,16 @@ +import type { App } from "vue"; +import settings from "./settings"; + +export function setHiddenFeatures(value: boolean) { + settings.hiddenFeatures = value; +} + +export function getHiddenFeatures() { + return settings.hiddenFeatures; +} + +export default { + install: (app: App) => { + app.config.globalProperties.$hiddenFeatures = getHiddenFeatures; + }, +}; diff --git a/assets/js/i18n.js b/assets/js/i18n.js deleted file mode 100644 index ac9000193..000000000 --- a/assets/js/i18n.js +++ /dev/null @@ -1,109 +0,0 @@ -import { nextTick } from "vue"; -import { createI18n } from "vue-i18n"; -import en from "../../i18n/en.json"; -import { i18n as i18nApi } from "./api"; -import settings from "./settings"; - -// https://github.com/joker-x/languages.js/blob/master/languages.json -export const LOCALES = { - ar: ["Arabic", "العربية"], - bg: ["Bulgarian", "Български"], - ca: ["Catalan", "Català"], - cs: ["Czech", "Česky"], - da: ["Danish", "Dansk"], - de: ["German", "Deutsch"], - el: ["Greek", "Ελληνικά"], - en: ["English", "English"], - es: ["Spanish", "Español"], - et: ["Estonian", "Eesti"], - fi: ["Finnish", "Suomi"], - fr: ["French", "Français"], - hr: ["Croatian", "Hrvatski"], - hu: ["Hungarian", "Magyar"], - it: ["Italian", "Italiano"], - lb: ["Luxembourgish", "Lëtzebuergesch"], - lt: ["Lithuanian", "Lietuvių"], - nl: ["Dutch", "Nederlands"], - no: ["Norwegian", "Norsk"], - pl: ["Polish", "Polski"], - pt: ["Portuguese", "Português"], - ro: ["Romanian", "Română"], - ru: ["Russian", "Русский"], - sk: ["Slovak", "Slovenčina"], - sl: ["Slovenian", "Slovenščina"], - sv: ["Swedish", "Svenska"], - ta: ["Tamil", "தமிழ்"], - tr: ["Turkish", "Türkçe"], - uk: ["Ukrainian", "Українська"], - "zh-Hans": ["Chinese (Simplified)", "简体中文"], -}; - -export const DEFAULT_LOCALE = "en"; -const htmlLang = document.querySelector("html").getAttribute("lang"); -const DEFAULT_BROWSER_LOCALE = htmlLang?.length == 2 ? htmlLang : DEFAULT_LOCALE; - -export function getLocalePreference() { - return settings.locale; -} - -export function removeLocalePreference(i18n) { - settings.locale = null; - setI18nLanguage(i18n, DEFAULT_BROWSER_LOCALE); - ensureCurrentLocaleMessages(i18n); -} - -export function setLocalePreference(i18n, locale) { - if (!LOCALES[locale]) { - console.error("unknown locale", locale); - return; - } - settings.locale = locale; - setI18nLanguage(i18n, locale); - ensureCurrentLocaleMessages(i18n); -} - -function getLocale() { - return getLocalePreference() || DEFAULT_BROWSER_LOCALE; -} - -export default function setupI18n() { - const i18n = createI18n({ - legacy: true, - silentFallbackWarn: true, - silentTranslationWarn: true, - locale: DEFAULT_LOCALE, - fallbackLocale: DEFAULT_LOCALE, - messages: { en }, - }); - setI18nLanguage(i18n.global, getLocale()); - return i18n; -} - -export function setI18nLanguage(i18n, locale) { - i18n.locale = locale; - document.querySelector("html").setAttribute("lang", locale); -} - -async function loadLocaleMessages(i18n, locale) { - try { - const response = await i18nApi.get(`${locale}.json`, { params: { v: window.evcc?.version } }); - i18n.setLocaleMessage(locale, response.data); - } catch (e) { - console.error(`unable to load translation for [${locale}]`, e); - } - - return nextTick(); -} - -export async function ensureCurrentLocaleMessages(i18n) { - const { locale } = i18n; - if (!i18n.availableLocales.includes(locale)) { - await loadLocaleMessages(i18n, locale); - } -} - -export function docsPrefix() { - const locale = getLocale(); - const path = locale === "de" ? "" : `/en`; - return `https://docs.evcc.io${path}`; -} diff --git a/assets/js/i18n.ts b/assets/js/i18n.ts new file mode 100644 index 000000000..0918c6e06 --- /dev/null +++ b/assets/js/i18n.ts @@ -0,0 +1,113 @@ +import { nextTick } from "vue"; +import { createI18n, type VueI18nInstance } from "vue-i18n"; +import en from "../../i18n/en.json"; +import { i18n as i18nApi } from "./api"; +import settings from "./settings"; + +// https://github.com/joker-x/languages.js/blob/master/languages.json +export const LOCALES = { + ar: ["Arabic", "العربية"], + bg: ["Bulgarian", "Български"], + ca: ["Catalan", "Català"], + cs: ["Czech", "Česky"], + da: ["Danish", "Dansk"], + de: ["German", "Deutsch"], + el: ["Greek", "Ελληνικά"], + en: ["English", "English"], + es: ["Spanish", "Español"], + et: ["Estonian", "Eesti"], + fi: ["Finnish", "Suomi"], + fr: ["French", "Français"], + hr: ["Croatian", "Hrvatski"], + hu: ["Hungarian", "Magyar"], + it: ["Italian", "Italiano"], + lb: ["Luxembourgish", "Lëtzebuergesch"], + lt: ["Lithuanian", "Lietuvių"], + nl: ["Dutch", "Nederlands"], + no: ["Norwegian", "Norsk"], + pl: ["Polish", "Polski"], + pt: ["Portuguese", "Português"], + ro: ["Romanian", "Română"], + ru: ["Russian", "Русский"], + sk: ["Slovak", "Slovenčina"], + sl: ["Slovenian", "Slovenščina"], + sv: ["Swedish", "Svenska"], + ta: ["Tamil", "தமிழ்"], + tr: ["Turkish", "Türkçe"], + uk: ["Ukrainian", "Українська"], + "zh-Hans": ["Chinese (Simplified)", "简体中文"], +}; + +export const DEFAULT_LOCALE = "en"; +const htmlLang = document.querySelector("html")?.getAttribute("lang"); +const DEFAULT_BROWSER_LOCALE = htmlLang?.length == 2 ? htmlLang : DEFAULT_LOCALE; + +export function getLocalePreference() { + return settings.locale; +} + +export function removeLocalePreference(i18n: VueI18nInstance) { + settings.locale = null; + setI18nLanguage(i18n, DEFAULT_BROWSER_LOCALE as typeof i18n.locale); + ensureCurrentLocaleMessages(i18n); +} + +export function setLocalePreference(i18n: VueI18nInstance, locale: typeof i18n.locale) { + if (!LOCALES.hasOwnProperty(locale)) { + console.error("unknown locale", locale); + return; + } + settings.locale = locale; + setI18nLanguage(i18n, locale); + ensureCurrentLocaleMessages(i18n); +} + +function getLocale() { + return getLocalePreference() || DEFAULT_BROWSER_LOCALE; +} + +export default function setupI18n() { + const i18n = createI18n({ + legacy: true, + silentFallbackWarn: true, + silentTranslationWarn: true, + locale: DEFAULT_LOCALE, + fallbackLocale: DEFAULT_LOCALE, + messages: { "en-US": en }, + }); + setI18nLanguage(i18n.global, getLocale()); + return i18n; +} + +export function setI18nLanguage(i18n: VueI18nInstance, locale: typeof i18n.locale) { + i18n.locale = locale; + document.querySelector("html")?.setAttribute("lang", locale); +} + +async function loadLocaleMessages(i18n: VueI18nInstance, locale: typeof i18n.locale) { + try { + const response = await i18nApi.get(`${locale}.json`, { + params: { v: window.evcc?.version }, + }); + if ("setLocaleMessage" in i18n) { + i18n.setLocaleMessage(locale, response.data); + } + } catch (e) { + console.error(`unable to load translation for [${locale}]`, e); + } + + return nextTick(); +} + +export async function ensureCurrentLocaleMessages(i18n: VueI18nInstance) { + const { locale } = i18n; + if (!i18n.availableLocales.includes(locale)) { + await loadLocaleMessages(i18n, locale); + } +} + +export function docsPrefix() { + const locale = getLocale(); + const path = locale === "de" ? "" : `/en`; + return `https://docs.evcc.io${path}`; +} diff --git a/assets/js/restart.js b/assets/js/restart.js deleted file mode 100644 index 8acaa3223..000000000 --- a/assets/js/restart.js +++ /dev/null @@ -1,23 +0,0 @@ -import { reactive } from "vue"; -import api from "./api"; - -const restart = reactive({ - restartNeeded: false, - restarting: false, -}); - -export async function performRestart() { - try { - await api.post("/system/shutdown"); - restart.restarting = true; - } catch (e) { - alert("Unabled to restart server.", e); - } -} - -export function restartComplete() { - restart.restarting = false; - restart.restartNeeded = false; -} - -export default restart; diff --git a/assets/js/restart.ts b/assets/js/restart.ts new file mode 100644 index 000000000..8ef55bcf6 --- /dev/null +++ b/assets/js/restart.ts @@ -0,0 +1,23 @@ +import { reactive } from "vue"; +import api from "./api"; + +const restart = reactive({ + restartNeeded: false, + restarting: false, +}); + +export async function performRestart() { + try { + await api.post("/system/shutdown"); + restart.restarting = true; + } catch (e) { + alert(`Unable to restart server. ${e}`); + } +} + +export function restartComplete() { + restart.restarting = false; + restart.restartNeeded = false; +} + +export default restart; diff --git a/assets/js/router.js b/assets/js/router.js deleted file mode 100644 index 6c06ed48c..000000000 --- a/assets/js/router.js +++ /dev/null @@ -1,97 +0,0 @@ -import { createRouter, createWebHashHistory } from "vue-router"; -import Modal from "bootstrap/js/dist/modal"; -import { ensureCurrentLocaleMessages } from "./i18n"; -import { - openLoginModal, - statusUnknown, - updateAuthStatus, - isLoggedIn, - isConfigured, -} from "./components/Auth/auth"; - -function hideAllModals() { - [...document.querySelectorAll(".modal.show")].forEach((modal) => { - // skip unclosable modals - if (modal.getAttribute("data-bs-backdrop") === "static") return; - - const modalInstance = Modal.getInstance(modal); - if (modalInstance) { - modalInstance.hide(); - } - }); -} - -async function ensureAuth(to) { - await updateAuthStatus(); - if (!isConfigured()) { - return false; - } - if (!isLoggedIn() && !statusUnknown()) { - openLoginModal(to.path); - return false; - } - return true; -} - -export default function setupRouter(i18n) { - const router = createRouter({ - history: createWebHashHistory(), - routes: [ - { - path: "/", - component: () => import("./views/Main.vue"), - props: (route) => { - const { lp } = route.query; - return { - selectedLoadpointIndex: lp ? parseInt(lp, 10) - 1 : undefined, - }; - }, - }, - { - path: "/config", - component: () => import("./views/Config.vue"), - beforeEnter: ensureAuth, - props: true, - }, - { - path: "/sessions", - component: () => import("./views/Sessions.vue"), - props: (route) => { - const { month, year, loadpoint, vehicle, period } = route.query; - return { - month: month ? parseInt(month, 10) : undefined, - year: year ? parseInt(year, 10) : undefined, - period, - loadpointFilter: loadpoint, - vehicleFilter: vehicle, - }; - }, - }, - { - path: "/energy", - component: () => import("./views/Energy.vue"), - props: true, - }, - { - path: "/log", - component: () => import("./views/Log.vue"), - beforeEnter: ensureAuth, - props: (route) => { - const { areas, level } = route.query; - return { - areas: areas ? areas.split(",") : undefined, - level, - }; - }, - }, - ], - }); - router.beforeEach(async () => { - await ensureCurrentLocaleMessages(i18n.global); - return true; - }); - router.afterEach(() => { - hideAllModals(); - }); - return router; -} diff --git a/assets/js/router.ts b/assets/js/router.ts new file mode 100644 index 000000000..8a7db7f11 --- /dev/null +++ b/assets/js/router.ts @@ -0,0 +1,102 @@ +import { + createRouter, + createWebHashHistory, + type RouteLocationNormalizedGeneric, +} from "vue-router"; +import Modal from "bootstrap/js/dist/modal"; +import { ensureCurrentLocaleMessages } from "./i18n.ts"; +import { + openLoginModal, + statusUnknown, + updateAuthStatus, + isLoggedIn, + isConfigured, +} from "./components/Auth/auth"; +import type { VueI18nInstance } from "vue-i18n"; + +function hideAllModals() { + [...document.querySelectorAll(".modal.show")].forEach((modal) => { + // skip unclosable modals + if (modal.getAttribute("data-bs-backdrop") === "static") return; + + const modalInstance = Modal.getInstance(modal); + if (modalInstance) { + modalInstance.hide(); + } + }); +} + +async function ensureAuth(to: RouteLocationNormalizedGeneric) { + await updateAuthStatus(); + if (!isConfigured()) { + return false; + } + if (!isLoggedIn() && !statusUnknown()) { + openLoginModal(to.path); + return false; + } + return true; +} + +export default function setupRouter(i18n: VueI18nInstance) { + const router = createRouter({ + history: createWebHashHistory(), + routes: [ + { + path: "/", + component: () => import("./views/Main.vue"), + props: (route) => { + const { lp } = route.query; + return { + selectedLoadpointIndex: lp ? parseInt(lp as string, 10) - 1 : undefined, + }; + }, + }, + { + path: "/config", + component: () => import("./views/Config.vue"), + beforeEnter: ensureAuth, + props: true, + }, + { + path: "/sessions", + component: () => import("./views/Sessions.vue"), + props: (route) => { + const { month, year, loadpoint, vehicle, period } = route.query; + return { + month: month ? parseInt(month as string, 10) : undefined, + year: year ? parseInt(year as string, 10) : undefined, + period, + loadpointFilter: loadpoint, + vehicleFilter: vehicle, + }; + }, + }, + { + path: "/energy", + component: () => import("./views/Energy.vue"), + props: true, + }, + { + path: "/log", + component: () => import("./views/Log.vue"), + beforeEnter: ensureAuth, + props: (route) => { + const { areas, level } = route.query; + return { + areas: typeof areas === "string" ? areas.split(",") : undefined, + level: typeof level === "string" ? level : undefined, + }; + }, + }, + ], + }); + router.beforeEach(async () => { + await ensureCurrentLocaleMessages(i18n); + return true; + }); + router.afterEach(() => { + hideAllModals(); + }); + return router; +} diff --git a/assets/js/theme.js b/assets/js/theme.js deleted file mode 100644 index a4f42fc83..000000000 --- a/assets/js/theme.js +++ /dev/null @@ -1,73 +0,0 @@ -import settings from "./settings"; - -const darkModeMatcher = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)"); - -const THEME_AUTO = "auto"; -const THEME_LIGHT = "light"; -const THEME_DARK = "dark"; -export const THEMES = [THEME_AUTO, THEME_LIGHT, THEME_DARK]; - -export function getThemePreference() { - const theme = settings.theme; - if (THEMES.includes(theme)) { - return theme; - } - return THEME_AUTO; -} - -export function setThemePreference(theme) { - if (!THEMES.includes(theme)) { - return; - } - settings.theme = theme; - updateTheme(); -} - -function setMetaThemeColor(theme) { - const themeColors = { light: "#f3f3f7", dark: "#020318" }; - const $metaThemeColor = document.querySelector("meta[name=theme-color]"); - if ($metaThemeColor) { - $metaThemeColor.setAttribute("content", themeColors[theme]); - } -} - -function getCurrentTheme() { - let theme = getThemePreference(); - if (theme === THEME_AUTO) { - theme = darkModeMatcher?.matches ? THEME_DARK : THEME_LIGHT; - } - return theme; -} - -function updateTheme() { - const theme = getCurrentTheme(); - - // update iOS title bar color - setMetaThemeColor(theme); - - // toggle the class on html root - const $html = document.querySelector("html"); - $html.classList.add("no-transitions"); - $html.classList.toggle("dark", theme === THEME_DARK); - window.setTimeout(function () { - $html.classList.remove("no-transitions"); - }, 100); -} - -function updateMetaThemeForBackdrop() { - const $backdrop = document.querySelector("[data-bs-backdrop=true][aria-modal=true]"); - // dark if there is a backdrop, otherwise use the current theme - const theme = $backdrop ? THEME_DARK : getCurrentTheme(); - setMetaThemeColor(theme); -} - -export function watchThemeChanges() { - if (darkModeMatcher && darkModeMatcher.addEventListener) { - darkModeMatcher.addEventListener("change", updateTheme); - } - updateTheme(); - - // listen for modal backdrops - document.addEventListener("shown.bs.modal", updateMetaThemeForBackdrop); - document.addEventListener("hidden.bs.modal", updateMetaThemeForBackdrop); -} diff --git a/assets/js/theme.ts b/assets/js/theme.ts new file mode 100644 index 000000000..d607ece8f --- /dev/null +++ b/assets/js/theme.ts @@ -0,0 +1,71 @@ +import settings from "./settings"; +import { THEME } from "./types/evcc"; + +const darkModeMatcher = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)"); + +export function getThemePreference(): THEME { + const theme = settings.theme; + if (Object.values(THEME).includes(theme)) { + return theme; + } + return THEME.AUTO; +} + +export function setThemePreference(theme: THEME) { + if (!Object.values(THEME).includes(theme)) { + return; + } + settings.theme = theme; + updateTheme(); +} + +function setMetaThemeColor(theme: Exclude) { + const themeColors = { light: "#f3f3f7", dark: "#020318" }; + const $metaThemeColor = document.querySelector("meta[name=theme-color]"); + if ($metaThemeColor) { + $metaThemeColor.setAttribute("content", themeColors[theme]); + } +} + +function getCurrentTheme() { + let theme = getThemePreference(); + if (theme === THEME.AUTO) { + theme = darkModeMatcher?.matches ? THEME.DARK : THEME.LIGHT; + } + return theme; +} + +function updateTheme() { + const theme = getCurrentTheme(); + + // update iOS title bar color + setMetaThemeColor(theme); + + // toggle the class on html root + const $html = document.querySelector("html"); + if ($html) { + $html.classList.add("no-transitions"); + $html.classList.toggle("dark", theme === THEME.DARK); + window.setTimeout(function () { + $html.classList.remove("no-transitions"); + }, 100); + } +} + +function updateMetaThemeForBackdrop() { + const $backdrop = document.querySelector("[data-bs-backdrop=true][aria-modal=true]"); + // dark if there is a backdrop, otherwise use the current theme + const theme = $backdrop ? THEME.DARK : getCurrentTheme(); + setMetaThemeColor(theme); +} + +export function watchThemeChanges() { + if (darkModeMatcher && darkModeMatcher.addEventListener) { + darkModeMatcher.addEventListener("change", updateTheme); + } + updateTheme(); + + // listen for modal backdrops + document.addEventListener("shown.bs.modal", updateMetaThemeForBackdrop); + document.addEventListener("hidden.bs.modal", updateMetaThemeForBackdrop); +} diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index f83dd6714..000873e0c 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -7,6 +7,7 @@ declare global { evcc: { version: string; commit: string; + customCss: string; }; } } @@ -48,6 +49,12 @@ export interface LoadpointCompact { chargerFeatureHeating: boolean; } +export enum THEME { + AUTO = "auto", + LIGHT = "light", + DARK = "dark", +} + export enum CURRENCY { AUD = "AUD", BGN = "BGN", diff --git a/package-lock.json b/package-lock.json index f5e0bbe5b..f642759e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,6 +69,7 @@ "@storybook/test": "^8.6.4", "@storybook/vue3": "^8.6.4", "@storybook/vue3-vite": "^8.6.4", + "@types/smoothscroll-polyfill": "^0.3.4", "cross-env": "^7.0.3", "eslint-plugin-storybook": "^0.12.0", "prettier-plugin-sort-json": "^4.1.1", @@ -3459,6 +3460,13 @@ "@types/node": "*" } }, + "node_modules/@types/smoothscroll-polyfill": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@types/smoothscroll-polyfill/-/smoothscroll-polyfill-0.3.4.tgz", + "integrity": "sha512-w1krF+TJh7Azz/2/bEN1tmyKoNt3B6bLCn6SfJErzhKAr1w1b06ePx79NXA+3lTw+DbqXN12nk/Ce1UQDVY8yA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", diff --git a/package.json b/package.json index f532c2170..aeca7a9ce 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "@storybook/test": "^8.6.4", "@storybook/vue3": "^8.6.4", "@storybook/vue3-vite": "^8.6.4", + "@types/smoothscroll-polyfill": "^0.3.4", "cross-env": "^7.0.3", "eslint-plugin-storybook": "^0.12.0", "prettier-plugin-sort-json": "^4.1.1",