Introduce global settings dialog (#5473)
This commit is contained in:
parent
fae7366b31
commit
a0fd8fc1c9
27 changed files with 549 additions and 149 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
20
assets/js/components/FormRow.vue
Normal file
20
assets/js/components/FormRow.vue
Normal 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>
|
||||
133
assets/js/components/GlobalSettingsModal.vue
Normal file
133
assets/js/components/GlobalSettingsModal.vue
Normal 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
29
assets/js/components/SelectGroup.story.vue
Normal file
29
assets/js/components/SelectGroup.story.vue
Normal 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>
|
||||
57
assets/js/components/SelectGroup.vue
Normal file
57
assets/js/components/SelectGroup.vue
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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()}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
36
assets/js/settings.js
Normal 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;
|
||||
|
|
@ -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
27
assets/js/units.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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&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
74
dist/assets/index.43115d36.js
vendored
Normal file
File diff suppressed because one or more lines are too long
74
dist/assets/index.9ac28ff8.js
vendored
74
dist/assets/index.9ac28ff8.js
vendored
File diff suppressed because one or more lines are too long
4
dist/index.html
vendored
4
dist/index.html
vendored
|
|
@ -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>
|
||||
|
|
|
|||
29
i18n/de.toml
29
i18n/de.toml
|
|
@ -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."
|
||||
|
|
|
|||
25
i18n/en.toml
25
i18n/en.toml
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export default defineConfig({
|
|||
outDir: "../dist/",
|
||||
emptyOutDir: true,
|
||||
assetsInlineLimit: 1024,
|
||||
chunkSizeWarningLimit: 550,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: undefined,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue