Introduce global settings dialog (#5473)

This commit is contained in:
Michael Geers 2022-12-18 11:15:35 +01:00 • committed by GitHub
parent fae7366b31
commit a0fd8fc1c9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 549 additions and 149 deletions

View file

@ -145,12 +145,17 @@ a:hover {
color: var(--evcc-background);
}
.btn-primary:hover,
.btn-primary:active {
.btn-primary:hover {
background-color: var(--evcc-accent3);
border-color: var(--evcc-accent3);
}
.btn-primary:active {
color: var(--evcc-default-text) !important;
background-color: var(--evcc-accent3) !important;
border-color: var(--evcc-accent3) !important;
}
.btn:disabled {
color: inherit !important;
background-color: inherit !important;
@ -164,12 +169,17 @@ a:hover {
border-width: 2px;
border-color: var(--bs-primary);
}
.btn-outline-primary:hover,
.btn-outline-primary:active {
color: var(--evcc-accent3);
background-color: transparent;
.btn-outline-primary:hover {
color: var(--evcc-accent3) !important;
background-color: transparent !important;
border-width: 2px;
border-color: var(--evcc-accent3);
border-color: var(--evcc-accent3) !important;
}
.btn-outline-primary:active {
color: var(--evcc-default-text) !important;
background-color: var(--evcc-accent3) !important;
border-width: 2px;
border-color: var(--evcc-accent3) !important;
}
.text-evcc {
@ -219,7 +229,8 @@ a:hover {
}
}
@media (--lg-and-up) {
/* breakpoint lg */
@media (min-width: 992px) {
.modal-lg,
.modal-xl {
--bs-modal-width: 850px;
@ -247,7 +258,8 @@ a:hover {
color: var(--evcc-default-text);
}
@media (--sm-and-up) {
/* breakpoint sm */
@media (min-width: 576px) {
.modal-content {
padding: 2rem;
}

View file

@ -0,0 +1,20 @@
<template>
<div class="mb-4 row">
<label :for="id" class="col-sm-4 col-form-label pt-0 pt-sm-1">
{{ label }}
</label>
<div class="col-sm-8 pe-0">
<slot />
</div>
</div>
</template>
<script>
export default {
name: "FormRow",
props: {
id: String,
label: String,
},
};
</script>

View file

@ -0,0 +1,133 @@
<template>
<Teleport to="body">
<div
id="globalSettingsModal"
class="modal fade text-dark"
data-bs-backdrop="true"
tabindex="-1"
role="dialog"
aria-hidden="true"
>
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t("settings.title") }}</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<div class="container">
<FormRow id="settingsDesign" :label="$t('settings.theme.label')">
<SelectGroup
id="settingsDesign"
v-model="theme"
class="w-100"
:options="
THEMES.map((value) => ({
value,
name: $t(`settings.theme.${value}`),
}))
"
/>
</FormRow>
<FormRow id="settingsLanguage" :label="$t('settings.language.label')">
<select
id="settingsLanguage"
v-model="language"
class="form-select form-select-sm w-75"
>
<option value="">{{ $t("settings.language.auto") }}</option>
<option
v-for="option in languageOptions"
:key="option"
:value="option.value"
>
{{ option.name }}
</option>
</select>
</FormRow>
<FormRow id="settingsUnit" :label="$t('settings.unit.label')">
<SelectGroup
id="settingsUnit"
v-model="unit"
class="w-75"
:options="
UNITS.map((value) => ({
value,
name: $t(`settings.unit.${value}`),
}))
"
/>
</FormRow>
<FormRow id="telemetryEnabled" :label="$t('settings.telemetry.label')">
<TelemetrySettings :sponsor="sponsor" class="mt-1 mb-0" />
</FormRow>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script>
import TelemetrySettings from "./TelemetrySettings.vue";
import FormRow from "./FormRow.vue";
import SelectGroup from "./SelectGroup.vue";
import { getLocalePreference, setLocalePreference, LOCALES, removeLocalePreference } from "../i18n";
import { getThemePreference, setThemePreference, THEMES } from "../theme";
import { getUnits, setUnits, UNITS } from "../units";
export default {
name: "GlobalSettingsModal",
components: { TelemetrySettings, FormRow, SelectGroup },
props: {
sponsor: String,
},
data: function () {
return {
theme: getThemePreference(),
language: getLocalePreference() || "",
unit: getUnits(),
THEMES,
UNITS,
};
},
computed: {
languageOptions: () => {
const locales = Object.entries(LOCALES).map(([key, value]) => {
return { value: key, name: value[1] };
});
// sort by name
locales.sort((a, b) => (a.name < b.name ? -1 : 1));
return locales;
},
},
watch: {
unit(value) {
setUnits(value);
},
theme(value) {
setThemePreference(value);
},
language(value) {
const i18n = this.$root.$i18n;
if (value) {
setLocalePreference(i18n, value);
} else {
removeLocalePreference(i18n);
}
},
},
};
</script>
<style scoped>
.container {
margin-left: calc(var(--bs-gutter-x) * -0.5);
margin-right: calc(var(--bs-gutter-x) * -0.5);
}
</style>

View file

@ -326,7 +326,8 @@ export default {
opacity: 0.5;
margin: 0 -1rem;
}
@media (--sm-and-up) {
/* breakpoint sm */
@media (min-width: 576px) {
.divider {
margin: 0 -1.5rem;
}

View file

@ -63,7 +63,9 @@ export default {
width: 40px;
flex: 0 0 auto;
}
@media (--lg-and-up) {
/* breakpoint lg */
@media (min-width: 992px) {
.tile-icon {
width: 70px;
}

View file

@ -0,0 +1,29 @@
<script setup>
import { reactive } from "vue";
import SelectGroup from "./SelectGroup.vue";
const state = reactive({
value: "orange",
});
const options = [
{ value: "green", name: "Green" },
{ value: "orange", name: "Orange" },
{ value: "red", name: "Red" },
];
const controlOptions = options.reduce((res, { value, name }) => {
res[value] = name;
return res;
}, {});
</script>
<template>
<Story auto-props-disabled>
<Variant title="standard">
<SelectGroup v-model="state.value" :options="options" />
<template #controls>
<HstSelect v-model="state.value" title="value" :options="controlOptions" />
</template>
</Variant>
</Story>
</template>

View file

@ -0,0 +1,57 @@
<template>
<div class="mode-group border d-inline-flex" role="group">
<button
v-for="(option, i) in options"
:id="i === 0 ? id : null"
:key="option.value"
type="button"
class="btn btn-sm flex-grow-1 flex-shrink-1"
:class="{ active: option.value === modelValue }"
@click="$emit('update:modelValue', option.value)"
>
{{ option.name }}
</button>
</div>
</template>
<script>
export default {
name: "SelectGroup",
props: {
id: String,
options: Array,
modelValue: [Number, String],
},
emits: ["update:modelValue"],
};
</script>
<style scoped>
.mode-group {
border: 2px solid var(--evcc-default-text);
border-radius: 17px;
padding: 4px;
}
.btn {
/* equal width buttons */
flex-basis: 0;
white-space: nowrap;
border-radius: 12px;
padding: 0.1em 0.8em;
color: var(--evcc-default-text);
border: none;
overflow-x: hidden;
text-overflow: ellipsis;
}
.btn:hover {
color: var(--evcc-gray);
}
.btn.active {
color: var(--evcc-background);
background: var(--evcc-default-text);
}
.btn-group {
border-radius: 16px;
}
</style>

View file

@ -111,7 +111,7 @@ export default {
},
topNavigation: function () {
const vehicleLogins = this.auth ? this.auth.vehicles : {};
return { vehicleLogins };
return { vehicleLogins, ...this.collectProps(TopNavigation) };
},
showParkingLot: function () {
// work in progess

View file

@ -106,13 +106,17 @@ export default {
.become-sponsor {
width: 100%;
}
@media (--sm-and-up) {
/* breakpoint sm */
@media (min-width: 576px) {
.confetti-button,
.become-sponsor {
width: 75%;
}
}
@media (--lg-and-up) {
/* breakpoint lg */
@media (min-width: 992px) {
.confetti-button,
.become-sponsor {
width: 40%;

View file

@ -22,6 +22,7 @@
<script>
import LabelAndValue from "./LabelAndValue.vue";
import AnimatedNumber from "./AnimatedNumber.vue";
import { distanceUnit } from "../units";
export default {
name: "TargetSoCSelect",
@ -63,7 +64,7 @@ export default {
return `${Math.round(value)}%`;
},
formatKm: function (value) {
return `${Math.round(value)} km`;
return `${Math.round(value)} ${distanceUnit()}`;
},
},
};

View file

@ -30,6 +30,7 @@
<script>
import api from "../api";
import settings from "../settings";
function parseMarkdown(markdownText) {
const htmlText = markdownText
@ -44,10 +45,14 @@ export default {
props: { sponsor: String },
data() {
return {
enabled: false,
error: null,
};
},
computed: {
enabled() {
return settings.telemetry;
},
},
async mounted() {
await this.update();
},
@ -56,15 +61,18 @@ export default {
try {
this.error = null;
const response = await api.post(`settings/telemetry/${e.target.checked}`);
this.enabled = response.data.result;
settings.telemetry = response.data.result;
} catch (err) {
if (err.response) {
this.error = parseMarkdown("**Error:** " + err.response.data.error);
this.enabled = false;
settings.telemetry = false;
}
}
},
async update() {
if (settings.telemetry !== null) {
return;
}
try {
const response = await api.get("settings/telemetry");
this.enabled = response.data.result;

View file

@ -25,8 +25,13 @@
</li>
<li>
<button type="button" class="dropdown-item" @click.stop="toggleTheme">
{{ $t(`header.theme.${theme}`) }}
<button
type="button"
class="dropdown-item"
data-bs-toggle="modal"
data-bs-target="#globalSettingsModal"
>
{{ $t("header.settings") }}
</button>
</li>
<template v-if="providerLogins.length > 0">
@ -94,18 +99,20 @@
</a>
</li>
</ul>
<GlobalSettingsModal :sponsor="sponsor" />
</div>
</template>
<script>
import "@h2d2/shopicons/es/regular/menu";
import "@h2d2/shopicons/es/regular/newtab";
import GlobalSettingsModal from "./GlobalSettingsModal.vue";
import baseAPI from "../baseapi";
import { getThemePreference, setThemePreference, THEMES } from "../theme";
export default {
name: "TopNavigation",
components: { GlobalSettingsModal },
props: {
vehicleLogins: {
type: Object,
@ -113,9 +120,7 @@ export default {
return {};
},
},
},
data: function () {
return { theme: getThemePreference() };
sponsor: String,
},
computed: {
logoutCount() {
@ -131,12 +136,6 @@ export default {
},
},
methods: {
toggleTheme: function () {
const currentIndex = THEMES.indexOf(this.theme);
const nextIndex = currentIndex < THEMES.length - 1 ? currentIndex + 1 : 0;
this.theme = THEMES[nextIndex];
setThemePreference(this.theme);
},
handleProviderAuthorization: async function (provider) {
if (!provider.loggedIn) {
baseAPI.post(provider.loginPath).then(function (response) {

View file

@ -19,7 +19,7 @@
class="flex-grow-1"
:label="$t('main.vehicle.vehicleSoC')"
:value="vehicleSoC ? `${vehicleSoC}%` : '--'"
:extraValue="vehicleRange ? `${vehicleRange} km` : null"
:extraValue="range ? `${Math.round(range)} ${rangeUnit}` : null"
align="start"
/>
<LabelAndValue
@ -71,6 +71,7 @@ import VehicleStatus from "./VehicleStatus.vue";
import TargetCharge from "./TargetCharge.vue";
import TargetSoCSelect from "./TargetSoCSelect.vue";
import TargetEnergySelect from "./TargetEnergySelect.vue";
import { distanceUnit, distanceValue } from "../units";
export default {
name: "Vehicle",
@ -139,9 +140,15 @@ export default {
targetCharge: function () {
return this.collectProps(TargetCharge);
},
range: function () {
return distanceValue(this.vehicleRange);
},
rangeUnit: function () {
return distanceUnit();
},
rangePerSoC: function () {
if (this.vehicleSoC > 10 && this.vehicleRange) {
return this.vehicleRange / this.vehicleSoC;
if (this.vehicleSoC > 10 && this.range) {
return this.range / this.vehicleSoC;
}
return null;
},

View file

@ -3,8 +3,22 @@ import { nextTick } from "vue";
import { createI18n } from "vue-i18n";
import en from "../../i18n/en.toml";
import { i18n as i18nApi } from "./api";
import settings from "./settings";
const PREFERRED_LOCALE_KEY = "preferred_locale";
// https://github.com/joker-x/languages.js/blob/master/languages.json
export const LOCALES = {
de: ["German", "Deutsch"],
en: ["English", "English"],
es: ["Spanish", "Español"],
fr: ["French", "Français"],
it: ["Italian", "Italiano"],
lb: ["Luxembourgish", "Lëtzebuergesch"],
lt: ["Lithuanian", "Lietuvių"],
nl: ["Dutch", "Nederlands"],
no: ["Norwegian", "Norsk"],
pl: ["Polish", "Polski"],
pt: ["Portuguese", "Português"],
};
function getBrowserLocale() {
const navigatorLocale =
@ -15,8 +29,27 @@ function getBrowserLocale() {
return navigatorLocale.trim().split(/-|_/)[0];
}
export function getLocalePreference() {
return settings.locale;
}
export function removeLocalePreference(i18n) {
settings.locale = null;
setI18nLanguage(i18n, getBrowserLocale());
}
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 window.localStorage[PREFERRED_LOCALE_KEY] || getBrowserLocale();
return getLocalePreference() || getBrowserLocale();
}
export default function setupI18n() {
@ -28,12 +61,12 @@ export default function setupI18n() {
fallbackLocale: "en",
messages: { en },
});
setI18nLanguage(i18n, getLocale());
setI18nLanguage(i18n.global, getLocale());
return i18n;
}
export function setI18nLanguage(i18n, locale) {
i18n.global.locale = locale;
i18n.locale = locale;
document.querySelector("html").setAttribute("lang", locale);
}
@ -41,7 +74,7 @@ async function loadLocaleMessages(i18n, locale) {
try {
const response = await i18nApi.get(`${locale}.toml`, { params: { v: window.evcc?.version } });
const messages = toml.parse(response.data);
i18n.global.setLocaleMessage(locale, messages);
i18n.setLocaleMessage(locale, messages);
} catch (e) {
console.error(`unable to load translation for [${locale}]`, e);
}
@ -50,8 +83,8 @@ async function loadLocaleMessages(i18n, locale) {
}
export async function ensureCurrentLocaleMessages(i18n) {
const { locale } = i18n.global;
if (!i18n.global.availableLocales.includes(locale)) {
const { locale } = i18n;
if (!i18n.availableLocales.includes(locale)) {
await loadLocaleMessages(i18n, locale);
}
}

View file

@ -13,7 +13,7 @@ export default function setupRouter(i18n) {
],
});
router.beforeEach(async () => {
await ensureCurrentLocaleMessages(i18n);
await ensureCurrentLocaleMessages(i18n.global);
return true;
});
return router;

36
assets/js/settings.js Normal file
View file

@ -0,0 +1,36 @@
import { reactive, watch } from "vue";
const SETTINGS_LOCALE = "settings_locale";
const SETTINGS_THEME = "settings_theme";
const SETTINGS_UNIT = "settings_unit";
function read(key) {
return window.localStorage[key];
}
function save(key) {
return (value) => {
try {
if (value) {
window.localStorage[key] = value;
} else {
delete window.localStorage[key];
}
} catch (e) {
console.error("unable to write to localstorage", { key, value });
}
};
}
const settings = reactive({
telemetry: null,
locale: read(SETTINGS_LOCALE),
theme: read(SETTINGS_THEME),
unit: read(SETTINGS_UNIT),
});
watch(() => settings.locale, save(SETTINGS_LOCALE));
watch(() => settings.theme, save(SETTINGS_THEME));
watch(() => settings.unit, save(SETTINGS_UNIT));
export default settings;

View file

@ -1,34 +1,26 @@
import settings from "./settings";
const darkModeMatcher = 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];
const LOCAL_STORAGE_KEY = "theme";
export function getThemePreference() {
try {
const theme = window.localStorage[LOCAL_STORAGE_KEY];
if (THEMES.includes(theme)) {
return theme;
}
} catch (e) {
console.error("unable to read theme from localStorage", e);
const theme = settings.theme;
if (THEMES.includes(theme)) {
return theme;
}
return THEME_AUTO;
}
export function setThemePreference(theme) {
console.log({ theme });
if (!THEMES.includes(theme)) {
return;
}
try {
window.localStorage[LOCAL_STORAGE_KEY] = theme;
updateTheme();
} catch (e) {
console.error("unable to write theme to localStorage", e);
}
settings.theme = theme;
updateTheme();
}
function updateTheme() {

27
assets/js/units.js Normal file
View file

@ -0,0 +1,27 @@
import settings from "./settings";
const KM = "km";
const MILES = "mi";
export const UNITS = [KM, MILES];
const MILES_FACTOR = 0.6213711922;
function isMiles() {
return settings.unit === MILES;
}
export function distanceValue(value) {
return isMiles() ? value * MILES_FACTOR : value;
}
export function distanceUnit() {
return isMiles() ? "mi" : "km";
}
export function getUnits() {
return isMiles() ? MILES : KM;
}
export function setUnits(value) {
settings.unit = value;
}

View file

@ -15,7 +15,7 @@
<div class="mb-4">
<a
class="btn btn-outline-secondary text-nowrap my-2"
href="./api/sessions?format=csv"
:href="`./api/sessions?format=csv&amp;lang=${$i18n.locale}`"
download="sessions.csv"
>
{{ $t("sessions.downloadCsv") }}
@ -217,7 +217,8 @@ export default {
white-space: nowrap;
}
@media (--sm-and-up) {
/* breakpoint sm */
@media (min-width: 576px) {
.breakdown-item:after {
content: ", ";
white-space: wrap;

File diff suppressed because one or more lines are too long

74
dist/assets/index.43115d36.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View file

@ -20,8 +20,8 @@
<meta name="theme-color" content="#020318" />
<title>evcc</title>
<script type="module" crossorigin src="./assets/index.9ac28ff8.js"></script>
<link rel="stylesheet" href="./assets/index.b49e9a55.css">
<script type="module" crossorigin src="./assets/index.43115d36.js"></script>
<link rel="stylesheet" href="./assets/index.24d10a15.css">
</head>
<body>

View file

@ -1,15 +1,32 @@
[header]
sessions = "Ladevorgänge"
docs = "Doku"
settings = "Einstellungen"
docs = "Dokumentation"
blog = "Blog"
github = "GitHub"
login = "Fahrzeug Logins"
about = "Über evcc"
[header.theme]
auto = "Design: System"
light = "Design: Hell"
dark = "Design: Dunkel"
[settings]
title = "Einstellungen"
[settings.theme]
label = "Design"
auto = "System"
light = "Hell"
dark = "Dunkel"
[settings.language]
label = "Sprache"
auto = "Automatisch"
[settings.unit]
label = "Einheit"
km = "Km"
mi = "Meilen"
[settings.telemetry]
label = "Telemetrie"
[footer.version]
availableShort = "Update"
@ -53,7 +70,7 @@ greenEnergySub1 = "über evcc geladen"
greenEnergySub2 = "seit Oktober 2022"
[footer.telemetry]
optIn = "Ich möchte meine Daten auch beisteuern."
optIn = "Ich möchte meine Ladedaten mit der Community teilen."
optInMoreDetails = "Mehr Details gibts {0}."
optInMoreDetailsLink = "hier"
optInSponsorship = "Sponsoring erforderlich."

View file

@ -1,15 +1,32 @@
[header]
sessions = "Charging sessions"
settings = "Settings"
docs = "Documentation"
blog = "Blog"
github = "GitHub"
login = "Vehicle logins"
about = "About"
[header.theme]
auto = "Design: system"
light = "Design: light"
dark = "Design: dark"
[settings]
title = "Settings"
[settings.theme]
label = "Design"
auto = "system"
light = "light"
dark = "dark"
[settings.language]
label = "Language"
auto = "Automatic"
[settings.unit]
label = "Units"
km = "km"
mi = "miles"
[settings.telemetry]
label = "Telemetry"
[footer.version]
availableShort = "install"

View file

@ -197,10 +197,13 @@ func sessionHandler(w http.ResponseWriter, r *http.Request) {
}
if r.URL.Query().Get("format") == "csv" {
// get request language
lang := r.Header.Get("Accept-Language")
if tags, _, err := language.ParseAcceptLanguage(lang); err == nil && len(tags) > 0 {
lang = tags[0].String()
lang := r.URL.Query().Get("lang")
if lang == "" {
// get request language
lang = r.Header.Get("Accept-Language")
if tags, _, err := language.ParseAcceptLanguage(lang); err == nil && len(tags) > 0 {
lang = tags[0].String()
}
}
ctx := context.WithValue(context.Background(), locale.Locale, lang)

View file

@ -11,6 +11,7 @@ export default defineConfig({
outDir: "../dist/",
emptyOutDir: true,
assetsInlineLimit: 1024,
chunkSizeWarningLimit: 550,
rollupOptions: {
output: {
manualChunks: undefined,