chore: Typescript: Rewrite js (#21719)

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
This commit is contained in:
Maschga 2025-06-13 15:19:24 +02:00 • committed by GitHub
parent cca4f254c2
commit c7eedb9655
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 512 additions and 478 deletions

View file

@ -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;
},
};

69
assets/js/api.ts Normal file
View file

@ -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;
},
};

View file

@ -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);
}

87
assets/js/app.ts Normal file
View file

@ -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);
}

View file

@ -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);

View file

@ -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);

View file

@ -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";

View file

@ -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;
},
};

16
assets/js/featureflags.ts Normal file
View file

@ -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;
},
};

View file

@ -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}`;
}

113
assets/js/i18n.ts Normal file
View file

@ -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}`;
}

View file

@ -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;

23
assets/js/restart.ts Normal file
View file

@ -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;

View file

@ -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;
}

102
assets/js/router.ts Normal file
View file

@ -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;
}

View file

@ -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);
}

71
assets/js/theme.ts Normal file
View file

@ -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<THEME, THEME.AUTO>) {
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);
}

View file

@ -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",

8
package-lock.json generated
View file

@ -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",

View file

@ -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",