Author SHA1 Message Date
jakob.scheidandGitea 9d10acb7bd test(legal): add test for the legal notice view
Added tests for the legal notice view (LegalNoticeView.vue).
2026-08-06 21:01:18 +02:00
jakob.scheidandGitea 28c47e91d5 test(legal): add tests for getLegalNotice 2026-08-06 21:01:18 +02:00
jakob.scheidandGitea b64807f25c feat(legal): add legal notice loading indicator 2026-08-06 21:01:18 +02:00
jakob.scheidandGitea 9fa3f5df50 feat(legal): add legal notice error handling
The legal notice view now shows an error box if the legal notice could
not be loaded.
2026-08-06 21:01:18 +02:00
jakob.scheidandGitea e363ce090d fix(style): set link color to --dark variable
This change was necessary due to the dynamically loaded legal notice.
Links would have the default color without this fix.
2026-08-06 21:01:18 +02:00
jakob.scheidandGitea 320393aa21 feat(legal): load and display legal notice 2026-08-06 21:01:18 +02:00
jakob.scheidandGitea 7a67677a99 feat(legal): add legal notice link in the footer
Added a link to the legal notice in the footer and revised the layout of
the last footer segment: The segment is now a grid container and the
legal links (currently only the legal notice link) are displayed at the
end. The copyright notice remains centered. On small screens, the legal
links are displayed centered below the copyright notice.
2026-08-06 21:01:18 +02:00
jakob.scheidandGitea ee242fe307 feat(legal): add route for the legal notice 2026-08-06 21:01:18 +02:00
84 changed files with 1290 additions and 1311 deletions
-34
View File
@@ -21,42 +21,8 @@ limitations under the License.
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0158D2" /> <meta name="theme-color" content="#0158D2" />
<title>Seekra</title> <title>Seekra</title>
<noscript>
<style>
body {
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
a {
color: #000;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>
</noscript>
</head> </head>
<body> <body>
<noscript>
<h1>
Seekra
</h1>
<p>
You need to enable JavaScript to use Seekra.
</p>
<section>
<h2>Legal Content</h2>
<ul>
<li>
<a href="https://assets.seekra.jcloud-services.ddns.net/2026-08-04-76a067e/legal/legal_notice.html">
Legal Notice
</a>
</li>
</ul>
</section>
</noscript>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
+10 -18
View File
@@ -18,45 +18,37 @@ limitations under the License.
import Navbar from './features/nav/components/Navbar.vue'; import Navbar from './features/nav/components/Navbar.vue';
import Footer from './features/footer/components/Footer.vue'; import Footer from './features/footer/components/Footer.vue';
import { LANGUAGE_PATH } from './config/featureSettings.js';
import { updatePageTitle } from './router'; import { updatePageTitle } from './router';
import { useSettings } from './features/settings/composables/useSettings.js'; import { useColorScheme } from './features/colorScheme/composables/useColorScheme';
import { setLocale } from './i18n'; import { ref, provide, watch, watchEffect } from 'vue';
import { computed, onMounted, watch, watchEffect } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
const route = useRoute(); const route = useRoute();
const { getSetting } = useSettings(); const { getColorScheme, updateColorScheme } = useColorScheme();
const colorScheme = computed(() => getSetting(['appearance', 'colorScheme'])); const colorScheme = ref(null);
provide('colorScheme', colorScheme);
const updateColorScheme = function updateColorScheme (newColorScheme) { watch(colorScheme, (newValue) => {
updateColorScheme(newValue);
document.body.style.setProperty(colorScheme, { document.body.style.setProperty(colorScheme, {
auto: 'normal', auto: 'normal',
dark: 'dark', dark: 'dark',
light: 'light' light: 'light'
}); });
if (newColorScheme === 'dark') { if (newValue === 'dark') {
document.body.classList.add('dark'); document.body.classList.add('dark');
} else { } else {
document.body.classList.remove('dark'); document.body.classList.remove('dark');
} }
if (newColorScheme === 'auto') { if (newValue === 'auto') {
document.body.classList.add('color-scheme-auto'); document.body.classList.add('color-scheme-auto');
} else { } else {
document.body.classList.remove('color-scheme-auto'); document.body.classList.remove('color-scheme-auto');
} }
};
watch(colorScheme, (newColorScheme) => {
updateColorScheme(newColorScheme);
}); });
updateColorScheme(colorScheme.value); colorScheme.value = getColorScheme();
watchEffect(() => updatePageTitle(route)); watchEffect(() => updatePageTitle(route));
onMounted(() => {
setLocale(getSetting(LANGUAGE_PATH));
});
</script> </script>
<template> <template>
@@ -17,14 +17,12 @@ limitations under the License.
<script setup> <script setup>
import Icon from '@/features/icons/components/Icon.vue'; import Icon from '@/features/icons/components/Icon.vue';
import { useSettings } from '@/features/settings/composables/useSettings'; import { inject } from 'vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
const { getSetting, setSetting } = useSettings();
const colorScheme = computed(() => getSetting(['appearance', 'colorScheme'])); const colorScheme = inject('colorScheme');
const colorSchemeNextMapper = { const colorSchemeNextMapper = {
'light': 'dark', 'light': 'dark',
@@ -41,15 +39,11 @@ const colorSchemeIconMapper = {
const getTooltipTranslation = function (colorScheme) { const getTooltipTranslation = function (colorScheme) {
return t(`preferences.colorScheme.switch.${colorSchemeNextMapper[colorScheme]}`); return t(`preferences.colorScheme.switch.${colorSchemeNextMapper[colorScheme]}`);
}; };
const setColorScheme = function setColorScheme (newColorScheme) {
setSetting(['appearance', 'colorScheme'], newColorScheme);
};
</script> </script>
<template> <template>
<button class="color-scheme-button" <button class="color-scheme-button"
@click="setColorScheme(colorSchemeNextMapper[colorScheme])" @click="colorScheme = colorSchemeNextMapper[colorScheme]"
:aria-label="getTooltipTranslation(colorScheme)" :aria-label="getTooltipTranslation(colorScheme)"
:title="getTooltipTranslation(colorScheme)" :title="getTooltipTranslation(colorScheme)"
> >
@@ -1,47 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import ColorSchemeButton from '../ColorSchemeButton.vue';
import { useSettings } from '@/features/settings/composables/useSettings.js';
import { mountComponent } from '@/test-utils/mountComponent';
import { describe, expect, test } from 'vitest';
const { getSetting } = useSettings();
const colorSchemeSettingPath = ['appearance', 'colorScheme'];
const getWrapper = function getWrapper () {
return mountComponent(ColorSchemeButton);
};
describe('ColorSchemeButton', () => {
test('sets color scheme setting', async () => {
const wrapper = getWrapper();
expect(getSetting(colorSchemeSettingPath)).toBe('auto');
const colorSchemeButton = wrapper.find('button');
await colorSchemeButton.trigger('click');
expect(getSetting(colorSchemeSettingPath)).toBe('light');
await colorSchemeButton.trigger('click');
expect(getSetting(colorSchemeSettingPath)).toBe('dark');
await colorSchemeButton.trigger('click');
expect(getSetting(colorSchemeSettingPath)).toBe('auto');
});
});
@@ -0,0 +1,39 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export const useColorScheme = function () {
const getColorScheme = function () {
let colorScheme = localStorage.getItem('colorScheme') || 'auto';
if (!(colorScheme === 'dark' || colorScheme === 'light' || colorScheme === 'auto')) {
colorScheme = 'auto';
};
return colorScheme;
};
const updateColorScheme = function (newScheme) {
let actualNewScheme = newScheme;
if (!(actualNewScheme === 'dark' || actualNewScheme === 'light' || actualNewScheme === 'auto')) {
actualNewScheme = 'auto';
};
localStorage.setItem('colorScheme', actualNewScheme);
};
return {
getColorScheme,
updateColorScheme
};
};
+3 -3
View File
@@ -21,13 +21,13 @@ import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
const startYear = 2026; const startYear = 2026
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear()
const copyrightPeriod = const copyrightPeriod =
startYear === currentYear startYear === currentYear
? `${currentYear}` ? `${currentYear}`
: `${startYear}-${currentYear}`; : `${startYear}-${currentYear}`
</script> </script>
@@ -17,31 +17,29 @@ limitations under the License.
<script setup> <script setup>
import Icon from '@/features/icons/components/Icon.vue'; import Icon from '@/features/icons/components/Icon.vue';
import { LANGUAGE_PATH } from '@/config/featureSettings'; import { ref } from 'vue';
import { useSettings } from '@/features/settings/composables/useSettings';
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { setLocale, SUPPORTED_LANGUAGES } from '@/i18n'; import { loadLanguage, LANGUAGES_RTL, SUPPORTED_LANGUAGES } from '@/i18n';
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const { setSetting, getSetting } = useSettings();
const isOpen = ref(false); const isOpen = ref(false);
const languageDropdown = ref(null); const languageDropdown = ref(null);
const selectLanguage = function selectLanguage (code) { async function selectLanguage(code) {
setSetting(LANGUAGE_PATH, code); await loadLanguage(code);
localStorage.setItem('locale', code);
document.documentElement.lang = code;
document.documentElement.dir = LANGUAGES_RTL.includes(code) ? 'rtl' : 'ltr';
close(); close();
}; };
watch(() => getSetting(LANGUAGE_PATH), setLocale);
const close = function () { const close = function () {
document.removeEventListener('click', closeWrapperOnClickOutside); document.removeEventListener('click', closeWrapperOnClickOutsite);
isOpen.value = false; isOpen.value = false;
}; };
const closeWrapperOnClickOutside = function (e) { const closeWrapperOnClickOutsite = function (e) {
if (languageDropdown.value) { if (languageDropdown.value) {
if (!languageDropdown.value.contains(e.target)) { if (!languageDropdown.value.contains(e.target)) {
close(); close();
@@ -53,7 +51,7 @@ const open = function () {
if (!isOpen.value) { if (!isOpen.value) {
isOpen.value = true; isOpen.value = true;
setTimeout(() => { setTimeout(() => {
document.addEventListener('click', closeWrapperOnClickOutside); document.addEventListener('click', closeWrapperOnClickOutsite);
}, 0); }, 0);
}; };
}; };
@@ -14,19 +14,41 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { LANGUAGE_PATH } from '@/config/featureSettings.js'; import { expect, test, describe, vi, beforeEach } from 'vitest';
import { useSettings } from '@/features/settings/composables/useSettings.js'; import { mount } from '@vue/test-utils';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { expect, test, describe } from 'vitest';
import LanguageSwitchButton from '../LanguageSwitchButton.vue'; import LanguageSwitchButton from '../LanguageSwitchButton.vue';
import { loadLanguage } from '@/i18n';
const getWrapper = function getWrapper () { vi.mock('@/i18n', () => ({
return mountComponent(LanguageSwitchButton); loadLanguage: vi.fn(() => Promise.resolve()),
}; LANGUAGES_RTL: ['ar', 'he'],
SUPPORTED_LANGUAGES: ['en', 'de', 'ar']
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key,
locale: { value: 'de' }
})
}));
vi.mock('@/features/icons/components/Icon.vue', () => ({
default: {
name: 'Icon',
template: '<span>Icon</span>'
}
}));
describe('LanguageSwitchButton', () => { describe('LanguageSwitchButton', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
document.documentElement.lang = '';
document.documentElement.dir = '';
});
test('renders correctly with initial state closed', () => { test('renders correctly with initial state closed', () => {
const wrapper = getWrapper(); const wrapper = mount(LanguageSwitchButton);
expect(wrapper.find('.language-button').exists()).toBe(true); expect(wrapper.find('.language-button').exists()).toBe(true);
expect(wrapper.find('.language-dropdown').exists()).toBe(false); expect(wrapper.find('.language-dropdown').exists()).toBe(false);
@@ -34,7 +56,7 @@ describe('LanguageSwitchButton', () => {
}); });
test('opens the dropdown when language button is clicked', async () => { test('opens the dropdown when language button is clicked', async () => {
const wrapper = getWrapper(); const wrapper = mount(LanguageSwitchButton);
const button = wrapper.find('.language-button'); const button = wrapper.find('.language-button');
await button.trigger('click'); await button.trigger('click');
@@ -43,28 +65,33 @@ describe('LanguageSwitchButton', () => {
expect(button.attributes('aria-expanded')).toBe('true'); expect(button.attributes('aria-expanded')).toBe('true');
}); });
test('sets the language when clicking it', async () => { const languageTestCases = [
const { getSetting } = useSettings(); { code: 'en', expectedDir: 'ltr' },
{ code: 'de', expectedDir: 'ltr' },
{ code: 'ar', expectedDir: 'rtl' }
];
const wrapper = getWrapper(); test.for(languageTestCases)('selectLanguage($code) sets localStorage, html attributes and changes layout direction to $expectedDir', async ({ code, expectedDir }) => {
const wrapper = mount(LanguageSwitchButton);
await wrapper.find('.language-button').trigger('click'); await wrapper.find('.language-button').trigger('click');
const options = wrapper.findAll('.language-dropdown li'); const options = wrapper.findAll('.language-dropdown li');
const optionToClick = options.find(opt => opt.text().includes(code));
const enOption = options.find(opt => opt.text().includes('en')); await optionToClick.trigger('click');
await enOption.trigger('click');
expect(getSetting(LANGUAGE_PATH)).toBe('en');
expect(wrapper.find('.language-dropdown').exists()).toBe(false);
const deOption = options.find(opt => opt.text().includes('de')); expect(loadLanguage).toHaveBeenCalledWith(code);
await deOption.trigger('click'); expect(localStorage.getItem('locale')).toBe(code);
expect(getSetting(LANGUAGE_PATH)).toBe('de'); expect(document.documentElement.lang).toBe(code);
expect(document.documentElement.dir).toBe(expectedDir);
expect(wrapper.find('.language-dropdown').exists()).toBe(false); expect(wrapper.find('.language-dropdown').exists()).toBe(false);
}); });
test('closes the dropdown when clicking outside the component', async () => { test('closes the dropdown when clicking outside the component', async () => {
const wrapper = getWrapper(); const wrapper = mount(LanguageSwitchButton, {
attachTo: document.body
});
await wrapper.find('.language-button').trigger('click'); await wrapper.find('.language-button').trigger('click');
expect(wrapper.find('.language-dropdown').exists()).toBe(true); expect(wrapper.find('.language-dropdown').exists()).toBe(true);
@@ -1,51 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { getNavigatorLanguage } from '../navigatorLanguage';
import { describe, expect, test } from 'vitest';
describe('navigatorLanguage', () => {
test.for([
{ navigatorLanguage: 'en', expected: 'en'},
{ navigatorLanguage: 'de', expected: 'de'},
{ navigatorLanguage: 'fr', expected: 'fr'},
{ navigatorLanguage: 'en-US', expected: 'en'},
{ navigatorLanguage: 'en-AU', expected: 'en'},
{ navigatorLanguage: 'de-DE', expected: 'de'},
{ navigatorLanguage: 'fr-FR', expected: 'fr'},
{ navigatorLanguage: 'en-us', expected: 'en'},
{ navigatorLanguage: 'en-au', expected: 'en'},
{ navigatorLanguage: 'de-de', expected: 'de'},
{ navigatorLanguage: 'fr-fr', expected: 'fr'},
{ navigatorLanguage: 'zh-Hans-CN', expected: 'zh'},
{ navigatorLanguage: 'zh-Hant-TW', expected: 'zh'},
{ navigatorLanguage: 'uz-Latn-UZ', expected: 'uz'},
{ navigatorLanguage: 'en-US-u-ca-gregory', expected: 'en'},
{ navigatorLanguage: 'de-DE-u-co-phonebk', expected: 'de'},
{ navigatorLanguage: 'zh-Hant-TW-u-co-phonebk', expected: 'zh'}
])('returns language $expected with navigator language $navigatorLanguage', ({ navigatorLanguage, expected }) => {
Object.defineProperty(navigator, 'language', {
value: navigatorLanguage,
configurable: true
});
expect(getNavigatorLanguage()).toBe(expected);
});
});
+4 -4
View File
@@ -27,16 +27,16 @@ const props = defineProps({
type: [Number, String], type: [Number, String],
default: 24 default: 24
} }
}); })
const icons = import.meta.glob('@/assets/icons/*.svg', { const icons = import.meta.glob('@/assets/icons/*.svg', {
eager: true, eager: true,
import: 'default' import: 'default'
}); })
const Icon = computed(() => icons[`/src/assets/icons/${props.name}.svg`]); const Icon = computed(() => icons[`/src/assets/icons/${props.name}.svg`])
const size = computed(() => ensureUnit(props.size)); const size = computed(() => ensureUnit(props.size))
</script> </script>
<template> <template>
+1 -1
View File
@@ -18,5 +18,5 @@ export class FetchError extends Error {
constructor (message) { constructor (message) {
super(message); super(message);
this.name = 'FetchError'; this.name = 'FetchError';
} };
}; };
+4 -4
View File
@@ -24,8 +24,8 @@ import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter()
const props = defineProps(['autoSubmit']); const props = defineProps(['autoSubmit'])
const submitSearch = function () { const submitSearch = function () {
if (props.autoSubmit !== undefined) { if (props.autoSubmit !== undefined) {
@@ -33,8 +33,8 @@ const submitSearch = function () {
name: 'searchResults', name: 'searchResults',
query: { q: searchQuery.value } query: { q: searchQuery.value }
}); });
} };
}; }
</script> </script>
<template> <template>
@@ -54,9 +54,10 @@ const props = defineProps({
const store = useSettingsStore(); const store = useSettingsStore();
const optionType = computed(() => props.setting.allowMultiple ? 'checkbox' : 'radio'); const optionType = computed(() => props.setting.allowMultiple ? 'checkbox' : 'radio');
const selected = computed({ const selected = ref(normalizeSelectedValue(store.get(props.path) ?? props.setting.default));
get: () => normalizeSelectedValue(store.get(props.path) ?? props.setting.default),
set: (value) => store.set(props.path, value) watch(selected, (newValue) => {
store.set(props.path, newValue);
}); });
const labelId = useId(); const labelId = useId();
@@ -90,32 +90,6 @@ describe('Selection', () => {
expect(inputsChecked).toStrictEqual([false, true, false]); expect(inputsChecked).toStrictEqual([false, true, false]);
}); });
test('reacts on store change', async () => {
const wrapper = getWrapper({
options: exampleOptions,
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.set('selection', 'o1');
}
}
});
const settings = useSettingsStore();
{
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([false, true, false]);
}
{
settings.set('selection', 'o2');
await nextTick();
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([false, false, true]);
}
});
test('shows multiple values from store', () => { test('shows multiple values from store', () => {
const wrapper = getWrapper({ const wrapper = getWrapper({
options: exampleOptions, options: exampleOptions,
@@ -237,7 +211,7 @@ describe('Selection', () => {
const inputO2 = inputs[2]; const inputO2 = inputs[2];
await check(inputO0); await check(inputO0);
expectSelectionValue(['o0']); expectSelectionValue(['o0'])
await uncheck(inputO0); await uncheck(inputO0);
expectSelectionValue([]); expectSelectionValue([]);
@@ -32,7 +32,7 @@ const getWrapper = function getWrapper({ type = 'string', i18n = 'input', defaul
path: 'input' path: 'input'
} }
}, {}, [], piniaOptions); }, {}, [], piniaOptions);
}; }
describe('SettingsInput', () => { describe('SettingsInput', () => {
test('uses correct input type', () => { test('uses correct input type', () => {
@@ -117,7 +117,7 @@ describe('Switch', () => {
const wrapper = getWrapper({ const wrapper = getWrapper({
translations: { switch1: translation } translations: { switch1: translation }
}); })
const label = wrapper.find('label'); const label = wrapper.find('label');
expect(label.text()).toBe(translation); expect(label.text()).toBe(translation);
@@ -14,16 +14,63 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { useSettingsConfigCacheStore } from '../../stores/settingsConfigCacheStore';
import { useSettingsStore } from '../../stores/settingsStore';
import { useSettings } from '../useSettings'; import { useSettings } from '../useSettings';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia'; import { setActivePinia } from 'pinia';
import { beforeEach, describe, expect, test, vi } from 'vitest'; import { beforeEach, describe, expect, test, vi } from 'vitest';
const settingDefaultValue = vi.hoisted(() => 42); const settingDefaultValue = 42;
const settingPath = ['test', 'number']; const settingPath = ['test', 'number'];
vi.mock('../../config.js', () => ({ describe('useSettings', () => {
default: { describe('getSetting', () => {
test('returns stored value', () => {
const { getSetting } = useSettings();
const settings = useSettingsStore();
const key = 'setting';
const value = 42;
settings.set(key, value);
expect(getSetting(key)).toBe(value);
});
test('returns default value', () => {
const { getSetting } = useSettings();
expect(getSetting(settingPath)).toBe(settingDefaultValue);
});
test('returns stored value instead of default value', () => {
const { getSetting } = useSettings();
const settings = useSettingsStore();
const value = 43;
expect(getSetting(settingPath)).toBe(settingDefaultValue);
settings.set(settingPath, value);
expect(getSetting(settingPath)).toBe(value);
});
test('returns undefined if there is no value', () => {
const { getSetting } = useSettings();
expect(getSetting(['test', 'bool'])).toBeUndefined();
});
});
});
beforeEach(() => {
setActivePinia(
createTestingPinia({
createSpy: vi.fn,
stubActions: false
})
);
const settingsConfigCache = useSettingsConfigCacheStore();
settingsConfigCache.set({
contents: [ contents: [
{ {
name: 'test', name: 'test',
@@ -43,49 +90,5 @@ vi.mock('../../config.js', () => ({
] ]
} }
] ]
}
}));
describe('useSettings', () => {
describe('getSetting', () => {
test('returns stored value', () => {
const { getSetting, setSetting } = useSettings();
const key = ['setting'];
const value = 42;
setSetting(key, value);
expect(getSetting(key)).toBe(value);
});
test('returns default value', () => {
const { getSetting, setSetting } = useSettings();
expect(getSetting(settingPath)).toBe(settingDefaultValue);
});
test('returns stored value instead of default value', () => {
const { getSetting, setSetting } = useSettings();
const value = 43;
expect(getSetting(settingPath)).toBe(settingDefaultValue);
setSetting(settingPath, value);
expect(getSetting(settingPath)).toBe(value);
});
test('returns undefined if there is no value', () => {
const { getSetting } = useSettings();
expect(getSetting(['test', 'bool'])).toBeUndefined();
});
}); });
}); });
beforeEach(() => {
setActivePinia(
createTestingPinia({
createSpy: vi.fn,
stubActions: false
})
);
});
@@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import settings from '../config.js'; import { useSettingsConfigCacheStore } from '../stores/settingsConfigCacheStore';
import { useSettingsStore } from '../stores/settingsStore'; import { useSettingsStore } from '../stores/settingsStore';
import { getSettingRecursively } from '../utils/getSetting'; import { getSettingRecursively } from '../utils/getSetting';
import { loadSettingsConfig } from '../utils/settingsParser';
/** /**
* Provides access to the stored settings. * Provides access to the stored settings.
@@ -30,8 +31,9 @@ export const useSettings = function useSettings () {
const getSetting = function getSetting (key) { const getSetting = function getSetting (key) {
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const settings = useSettingsConfigCacheStore().get();
return ( return (
settingsStore.get(key.join('.')) ?? settingsStore.get(key) ??
getSettingRecursively( getSettingRecursively(
key, settings.contents ?? [] key, settings.contents ?? []
)?.default )?.default
@@ -0,0 +1,48 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ref, readonly } from 'vue';
import { loadSettingsConfig } from '../utils/settingsParser.js';
const config = ref(null);
const error = ref(null);
const loading = ref(false);
/**
* Provides reactive access to the parsed settings configuration.
* The config is loaded once and shared across all consumers.
*/
export function useSettingsConfig() {
async function load() {
loading.value = true;
error.value = null;
try {
config.value = await loadSettingsConfig();
} catch (e) {
error.value = e.message;
config.value = null;
} finally {
loading.value = false;
}
}
return {
config: readonly(config),
error: readonly(error),
loading: readonly(loading),
load,
};
}
-75
View File
@@ -1,75 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { getNavigatorLanguage } from '../i18n/utils/navigatorLanguage';
import { fallbackLocale, SUPPORTED_LANGUAGES } from '@/i18n';
let defaultLanguage;
{
const navigatorLanguage = getNavigatorLanguage();
if (SUPPORTED_LANGUAGES.includes(navigatorLanguage)) {
defaultLanguage = navigatorLanguage;
} else {
defaultLanguage = fallbackLocale;
}
}
export default {
contents: [
{
name: 'general',
i18n: 'settings.settings.general.title',
content: [
{
name: 'language',
type: 'selection',
i18n: 'settings.settings.general.settings.language.title',
default: defaultLanguage,
options: SUPPORTED_LANGUAGES.map((language) => ({
name: language,
i18n: `preferences.locale.languages.${language}`
}))
}
]
},
{
name: 'appearance',
i18n: 'settings.settings.appearance.title',
content: [
{
name: 'colorScheme',
type: 'selection',
i18n: 'settings.settings.appearance.contents.colorScheme.title',
default: 'auto',
options: [
{
name: 'auto',
i18n: 'settings.settings.appearance.contents.colorScheme.options.auto'
},
{
name: 'light',
i18n: 'settings.settings.appearance.contents.colorScheme.options.light'
},
{
name: 'dark',
i18n: 'settings.settings.appearance.contents.colorScheme.options.dark'
}
]
}
]
}
]
};
+3
View File
@@ -0,0 +1,3 @@
{
"contents": []
}
@@ -14,4 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export const LANGUAGE_PATH = ['general', 'language']; import { defineStore } from 'pinia';
export const useSettingsConfigCacheStore = defineStore('settingsConfigCache', () => {
let settingsConfig = undefined;
const set = function set (value) {
settingsConfig = value;
};
const get = function get () {
return settingsConfig;
};
return { set, get };
});
@@ -15,28 +15,166 @@ limitations under the License.
*/ */
import { getSetting, getSettingRecursively } from '../getSetting.js'; import { getSetting, getSettingRecursively } from '../getSetting.js';
import { describe, test, expect } from 'vitest'; import { test, expect } from 'vitest';
describe('getSetting', () => { test.for([
test.for([ ({ settings: [], name: '', expected: undefined }),
({ settings: [], name: '', expected: undefined }), ({ settings: [], name: 'a', expected: undefined }),
({ settings: [], name: 'a', expected: undefined }), ({ settings: [
({ settings: [ {
{
type: 'bool',
name: 'a',
i18n: 'a'
}
], name: 'a', expected: {
type: 'bool', type: 'bool',
name: 'a', name: 'a',
i18n: 'a' i18n: 'a'
} }) }
])('returns $expected', ({ settings, name, expected }) => { ], name: 'a', expected: {
expect(getSetting(name, settings)).toStrictEqual(expected); type: 'bool',
}); name: 'a',
i18n: 'a'
} })
])('returns $expected', ({ settings, name, expected }) => {
expect(getSetting(name, settings)).toStrictEqual(expected);
});
const veryNestedSettingsList = [ const veryNestedSettingsList = [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'bool',
name: '3a',
i18n: '3a'
},
{
type: 'section',
name: '3b',
i18n: '3b',
content: [
{
type: 'bool',
name: '4a',
i18n: '4a'
},
{
type: 'section',
name: '4b',
i18n: '4b',
content: [
{
type: 'bool',
name: '5a',
i18n: '5a'
},
{
type: 'section',
name: '5b',
i18n: '5b',
content: [
{
type: 'bool',
name: '6a',
i18n: '6a'
},
{
type: 'section',
name: '6b',
i18n: '6b',
content: [
{
type: 'bool',
name: '7a',
i18n: '7a'
}
]
}
]
}
]
}
]
}
]
}
]
}
];
test.for([
({ settings: [], path: ['a'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
}
], path: ['1a'], expected: {
type: 'bool',
name: '1a',
i18n: '1a'
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
}
]
}
], path: ['1a'], expected: {
type: 'bool',
name: '1a',
i18n: '1a'
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
}
]
}
], path: ['1b', '2a'], expected: {
type: 'bool',
name: '2a',
i18n: '2a'
} }),
({ settings: [
{ {
type: 'bool', type: 'bool',
name: '1a', name: '1a',
@@ -53,224 +191,26 @@ describe('getSetting', () => {
i18n: '2a' i18n: '2a'
}, },
{ {
type: 'section', type: 'number',
name: '2b', name: '2b',
i18n: '2b', i18n: '2b',
content: [ default: 42
{
type: 'bool',
name: '3a',
i18n: '3a'
},
{
type: 'section',
name: '3b',
i18n: '3b',
content: [
{
type: 'bool',
name: '4a',
i18n: '4a'
},
{
type: 'section',
name: '4b',
i18n: '4b',
content: [
{
type: 'bool',
name: '5a',
i18n: '5a'
},
{
type: 'section',
name: '5b',
i18n: '5b',
content: [
{
type: 'bool',
name: '6a',
i18n: '6a'
},
{
type: 'section',
name: '6b',
i18n: '6b',
content: [
{
type: 'bool',
name: '7a',
i18n: '7a'
}
]
}
]
}
]
}
]
}
]
} }
] ]
} }
]; ], path: ['1b', '2b'], expected: {
type: 'number',
test.for([ name: '2b',
({ settings: [], path: ['a'], expected: undefined }), i18n: '2b',
({ settings: [ default: 42
{ } }),
type: 'bool', ({ settings: [
name: '1a', {
i18n: '1a'
}
], path: ['1a'], expected: {
type: 'bool', type: 'bool',
name: '1a', name: '1a',
i18n: '1a' i18n: '1a'
} }), },
({ settings: [ {
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
}
]
}
], path: ['1a'], expected: {
type: 'bool',
name: '1a',
i18n: '1a'
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
}
]
}
], path: ['1b', '2a'], expected: {
type: 'bool',
name: '2a',
i18n: '2a'
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'number',
name: '2b',
i18n: '2b',
default: 42
}
]
}
], path: ['1b', '2b'], expected: {
type: 'number',
name: '2b',
i18n: '2b',
default: 42
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2b', '3a'], expected: {
type: 'string',
name: '3a',
i18n: '3a'
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b'], expected: {
type: 'section', type: 'section',
name: '1b', name: '1b',
i18n: '1b', i18n: '1b',
@@ -293,236 +233,294 @@ describe('getSetting', () => {
] ]
} }
] ]
} }), }
({ settings: [ ], path: ['1b', '2b', '3a'], expected: {
{ type: 'string',
type: 'bool', name: '3a',
name: '1a', i18n: '3a'
i18n: '1a' } }),
}, ({ settings: [
{ {
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2b'], expected: {
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2b', '3b'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2a', '3a'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2c', '3a'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1a', '2b', '3a'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1c', '2b', '3a'], expected: undefined }),
({ settings: veryNestedSettingsList, path: ['1b', '2b', '3b', '4b', '5b', '6b', '7a'], expected: {
type: 'bool', type: 'bool',
name: '7a', name: '1a',
i18n: '7a' i18n: '1a'
} }), },
({ settings: veryNestedSettingsList, path: ['1b', '2b', '3b', '4b', '5b'], expected: { {
type: 'section', type: 'section',
name: '5b', name: '1b',
i18n: '5b', i18n: '1b',
content: [ content: [
{ {
type: 'bool', type: 'bool',
name: '6a', name: '2a',
i18n: '6a' i18n: '2a'
}, },
{ {
type: 'section', type: 'section',
name: '6b', name: '2b',
i18n: '6b', i18n: '2b',
content: [ content: [
{ {
type: 'bool', type: 'string',
name: '7a', name: '3a',
i18n: '7a' i18n: '3a'
} }
] ]
} }
] ]
} }), }
({ settings: veryNestedSettingsList, path: ['1b', '2b', '3b', '4ba', '5b', '6b', '7a'], expected: undefined }) ], path: ['1b'], expected: {
])('returns $expected', ({ settings, path, expected }) => { type: 'section',
expect(getSettingRecursively(path, settings)).toStrictEqual(expected); name: '1b',
}); i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2b'], expected: {
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2b', '3b'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2a', '3a'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2c', '3a'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1a', '2b', '3a'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1c', '2b', '3a'], expected: undefined }),
({ settings: veryNestedSettingsList, path: ['1b', '2b', '3b', '4b', '5b', '6b', '7a'], expected: {
type: 'bool',
name: '7a',
i18n: '7a'
} }),
({ settings: veryNestedSettingsList, path: ['1b', '2b', '3b', '4b', '5b'], expected: {
type: 'section',
name: '5b',
i18n: '5b',
content: [
{
type: 'bool',
name: '6a',
i18n: '6a'
},
{
type: 'section',
name: '6b',
i18n: '6b',
content: [
{
type: 'bool',
name: '7a',
i18n: '7a'
}
]
}
]
} }),
({ settings: veryNestedSettingsList, path: ['1b', '2b', '3b', '4ba', '5b', '6b', '7a'], expected: undefined })
])('returns $expected', ({ settings, path, expected }) => {
expect(getSettingRecursively(path, settings)).toStrictEqual(expected);
}); });
@@ -59,5 +59,5 @@ describe('settingsPage', () => {
])('returns $expected', async ({ path, expected }) => { ])('returns $expected', async ({ path, expected }) => {
expect(getSettingsPagePathSegments(path)).toStrictEqual(expected); expect(getSettingsPagePathSegments(path)).toStrictEqual(expected);
}); });
}); })
}); })
@@ -20,10 +20,18 @@ import { validateSettingsConfig, validateEntry, validateSelectionOptions, assert
describe('settingsValidator', () => { describe('settingsValidator', () => {
describe('validateSettingsConfig', () => { describe('validateSettingsConfig', () => {
test.for([ test.for([
{ settings: { { raw: false, expected: false },
{ raw: true, expected: false },
{ raw: 0, expected: false },
{ raw: 42, expected: false },
{ raw: '', expected: false },
{ raw: ' ', expected: false },
{ raw: 'a', expected: false },
{ raw: {}, expected: false },
{ raw: {
contents: [] contents: []
} }, }, expected: true },
{ settings: { { raw: {
contents: [ contents: [
{ {
name: 'general', name: 'general',
@@ -37,8 +45,8 @@ describe('settingsValidator', () => {
] ]
} }
] ]
} }, }, expected: true },
{ settings: { { raw: {
contents: [ contents: [
{ {
name: 'general', name: 'general',
@@ -63,8 +71,8 @@ describe('settingsValidator', () => {
] ]
}, },
] ]
} }, }, expected: true },
{ settings: { { raw: {
contents: [ contents: [
{ {
name: 'general', name: 'general',
@@ -120,12 +128,12 @@ describe('settingsValidator', () => {
default: 'str' default: 'str'
} }
] ]
} },
] ]
} },
] ]
} }, }, expected: true },
{ settings: { { raw: {
contents: [ contents: [
{ {
name: 'general', name: 'general',
@@ -182,283 +190,12 @@ describe('settingsValidator', () => {
default: 'str' default: 'str'
} }
] ]
} },
] ]
} },
] ]
} } }, expected: true },
])('does not throw an error and returns input', ({ settings }) => { { raw: {
validateSettingsConfig(settings);
});
test.for([
{ settings: false },
{ settings: true },
{ settings: 0 },
{ settings: 42 },
{ settings: '' },
{ settings: ' ' },
{ settings: 'a' },
{ settings: {} },
{ settings: {
content: [
{
type: 'sectio',
name: 'general',
i18n: 'settings.settings.general',
content: [
{
type: 'bool',
name: 'Enable feature 42',
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'copyOfGeneral',
i18n: 'settings.settings.copyOfGeneral',
content: [
{
type: 'bool',
name: 'Enable feature 42',
default: false,
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'aSection',
i18n: 'settings.settings.aSection',
content: [
{
type: 'bool',
name: 'Enable feature 43',
i18n: 'settings.settings.aSection.enableFeature43'
},
{
type: 'selection',
name: 'language',
i18n: 'settings.settings.aSection.language.label',
default: 'en',
allowMultiple: 'false',
options: [
{ name: 'en', i18n: 'settings.settings.aSection.language.options.en' },
{ name: 'de', i18n: 'settings.settings.aSection.language.options.de' },
]
},
{
type: 'section',
name: 'section2',
i18n: 'settings.settings.aSection.section2.label',
content: [
{
type: 'string',
name: 'string',
i18n: 'settings.settings.aSection.sections.string',
default: 'str'
}
]
}
]
}
]
} },
{ settings: {
content: [
{
type: 'sectio',
name: 'general',
i18n: 'settings.settings.general',
content: [
{
type: 'bool',
name: 'Enable feature 42',
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'copyOfGeneral',
i18n: 'settings.settings.copyOfGeneral',
content: [
{
type: 'bool',
name: 'Enable feature 42',
default: false,
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'aSection',
i18n: 'settings.settings.aSection',
content: [
{
type: 'bool',
name: 'Enable feature 43',
i18n: 'settings.settings.aSection.enableFeature43'
},
{
type: 'selection',
name: 'language',
i18n: 'settings.settings.aSection.language.label',
default: 'en',
options: [
{ name: 'en', i18n: 'settings.settings.aSection.language.options.en' },
{ name: 'de', i18n: 'settings.settings.aSection.language.options.de' },
]
},
{
type: 'section',
name: 'section2',
i18n: 'settings.settings.aSection.section2.label',
content: [
{
type: 'string',
name: 'string',
i18n: 'settings.settings.aSection.sections.string',
default: 'str'
}
]
}
]
}
]
} },
{ settings: {
content: [
{
type: 'section',
name: 'general',
i18n: 'settings.settings.general',
content: [
{
type: 'bool',
name: 'Enable feature 42',
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'copyOfGeneral',
i18n: 'settings.settings.copyOfGeneral',
content: [
{
type: 'bool',
name: 'Enable feature 42',
default: false,
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'aSection',
i18n: 'settings.settings.aSection',
content: [
{
type: 'bool',
name: 'Enable feature 43',
i18n: 'settings.settings.aSection.enableFeature43'
},
{
type: 'selection',
name: 'language',
i18n: 'settings.settings.aSection.language.label',
default: 'en',
options: [
{ name: 'en', i18n: 'settings.settings.aSection.language.options.en' },
{ name: 'de', i18n: 'settings.settings.aSection.language.options.de' },
]
},
{
type: 'section',
name: 'section2',
i18n: 'settings.settings.aSection.section2.label',
content: [
{
type: 'string',
name: 'string',
i18n: 'settings.settings.aSection.sections.string',
default: 'str'
}
]
}
]
}
]
} },
{ settings: {
contents: [
{
type: 'section',
name: 'general',
i18n: 'settings.settings.general',
content: [
{
type: 'bool',
name: 'Enable feature 42',
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'copyOfGeneral',
i18n: 'settings.settings.copyOfGeneral',
content: [
{
type: 'bool',
name: 'Enable feature 42',
default: false,
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'aSection',
i18n: 'settings.settings.aSection',
content: [
{
type: 'bool',
name: 'Enable feature 43',
i18n: 'settings.settings.aSection.enableFeature43',
default: 'true'
},
{
type: 'selection',
name: 'language',
i18n: 'settings.settings.aSection.language.label',
default: 'en',
options: [
{ name: 'en', i18n: 'settings.settings.aSection.language.options.en' },
{ name: 'de', i18n: 'settings.settings.aSection.language.options.de' },
]
},
{
type: 'section',
name: 'section2',
i18n: 'settings.settings.aSection.section2.label',
content: [
{
type: 'string',
name: 'string',
i18n: 'settings.settings.aSection.sections.string',
default: 42
}
]
}
]
}
]
} },
{ settings: {
contents: [ contents: [
{ {
type: 'bool', type: 'bool',
@@ -521,13 +258,275 @@ describe('settingsValidator', () => {
default: 'str' default: 'str'
} }
] ]
},
]
},
]
}, expected: false },
{ raw: {
contents: [
{
type: 'section',
name: 'general',
i18n: 'settings.settings.general',
content: [
{
type: 'bool',
name: 'Enable feature 42',
i18n: 'settings.settings.general.enableFeature42'
} }
] ]
} },
{
type: 'section',
name: 'copyOfGeneral',
i18n: 'settings.settings.copyOfGeneral',
content: [
{
type: 'bool',
name: 'Enable feature 42',
default: false,
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'aSection',
i18n: 'settings.settings.aSection',
content: [
{
type: 'bool',
name: 'Enable feature 43',
i18n: 'settings.settings.aSection.enableFeature43',
default: 'true'
},
{
type: 'selection',
name: 'language',
i18n: 'settings.settings.aSection.language.label',
default: 'en',
options: [
{ name: 'en', i18n: 'settings.settings.aSection.language.options.en' },
{ name: 'de', i18n: 'settings.settings.aSection.language.options.de' },
]
},
{
type: 'section',
name: 'section2',
i18n: 'settings.settings.aSection.section2.label',
content: [
{
type: 'string',
name: 'string',
i18n: 'settings.settings.aSection.sections.string',
default: 42
}
]
},
]
},
] ]
} } }, expected: false },
])('throws error', ({ settings }) => { { raw: {
expect(() => validateSettingsConfig(settings)).toThrow(Error); content: [
{
type: 'section',
name: 'general',
i18n: 'settings.settings.general',
content: [
{
type: 'bool',
name: 'Enable feature 42',
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'copyOfGeneral',
i18n: 'settings.settings.copyOfGeneral',
content: [
{
type: 'bool',
name: 'Enable feature 42',
default: false,
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'aSection',
i18n: 'settings.settings.aSection',
content: [
{
type: 'bool',
name: 'Enable feature 43',
i18n: 'settings.settings.aSection.enableFeature43'
},
{
type: 'selection',
name: 'language',
i18n: 'settings.settings.aSection.language.label',
default: 'en',
options: [
{ name: 'en', i18n: 'settings.settings.aSection.language.options.en' },
{ name: 'de', i18n: 'settings.settings.aSection.language.options.de' },
]
},
{
type: 'section',
name: 'section2',
i18n: 'settings.settings.aSection.section2.label',
content: [
{
type: 'string',
name: 'string',
i18n: 'settings.settings.aSection.sections.string',
default: 'str'
}
]
},
]
},
]
}, expected: false },
{ raw: {
content: [
{
type: 'sectio',
name: 'general',
i18n: 'settings.settings.general',
content: [
{
type: 'bool',
name: 'Enable feature 42',
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'copyOfGeneral',
i18n: 'settings.settings.copyOfGeneral',
content: [
{
type: 'bool',
name: 'Enable feature 42',
default: false,
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'aSection',
i18n: 'settings.settings.aSection',
content: [
{
type: 'bool',
name: 'Enable feature 43',
i18n: 'settings.settings.aSection.enableFeature43'
},
{
type: 'selection',
name: 'language',
i18n: 'settings.settings.aSection.language.label',
default: 'en',
options: [
{ name: 'en', i18n: 'settings.settings.aSection.language.options.en' },
{ name: 'de', i18n: 'settings.settings.aSection.language.options.de' },
]
},
{
type: 'section',
name: 'section2',
i18n: 'settings.settings.aSection.section2.label',
content: [
{
type: 'string',
name: 'string',
i18n: 'settings.settings.aSection.sections.string',
default: 'str'
}
]
},
]
},
]
}, expected: false },
{ raw: {
content: [
{
type: 'sectio',
name: 'general',
i18n: 'settings.settings.general',
content: [
{
type: 'bool',
name: 'Enable feature 42',
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'copyOfGeneral',
i18n: 'settings.settings.copyOfGeneral',
content: [
{
type: 'bool',
name: 'Enable feature 42',
default: false,
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'aSection',
i18n: 'settings.settings.aSection',
content: [
{
type: 'bool',
name: 'Enable feature 43',
i18n: 'settings.settings.aSection.enableFeature43'
},
{
type: 'selection',
name: 'language',
i18n: 'settings.settings.aSection.language.label',
default: 'en',
allowMultiple: 'false',
options: [
{ name: 'en', i18n: 'settings.settings.aSection.language.options.en' },
{ name: 'de', i18n: 'settings.settings.aSection.language.options.de' },
]
},
{
type: 'section',
name: 'section2',
i18n: 'settings.settings.aSection.section2.label',
content: [
{
type: 'string',
name: 'string',
i18n: 'settings.settings.aSection.sections.string',
default: 'str'
}
]
},
]
},
]
}, expected: false }
])('returns valid: $expected', ({ raw, expected }) => {
const result = validateSettingsConfig(raw);
if (!result.valid) {
console.error('Error message:', result.error);
};
expect(result.valid).toBe(expected);
}); });
}); });
+2 -2
View File
@@ -31,12 +31,12 @@ export const getSettingRecursively = function getSettingRecursively (sectionPath
return oneSettingsLevel(cursor.slice(1, cursor.length), setting); return oneSettingsLevel(cursor.slice(1, cursor.length), setting);
} else { } else {
return setting; return setting;
} };
} else if (cursor.length <= 1) { } else if (cursor.length <= 1) {
return setting; return setting;
} else { } else {
return; return;
} };
}; };
return oneSettingsLevel(sectionPath, { content: settings }); return oneSettingsLevel(sectionPath, { content: settings });
}; };
@@ -0,0 +1,41 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useSettingsConfigCacheStore } from '../stores/settingsConfigCacheStore';
import { validateSettingsConfig } from './settingsValidator.js';
/**
* Loads and parses the settings configuration via dynamic import.
* @returns {Promise<import('../types/settingsConfig').SettingsConfig>}
*/
export async function loadSettingsConfig() {
const settingsConfigCache = useSettingsConfigCacheStore();
let raw;
try {
raw = (await import('../settings.json')).default;
settingsConfigCache.set(raw);
} catch (e) {
throw new Error(`[settings] Failed to load settings.json: ${e.message}`);
}
const result = validateSettingsConfig(raw);
if (!result.valid) {
throw new Error(result.error);
}
return result.config;
}
@@ -20,7 +20,7 @@ export const assertString = function assertString (value, path) {
if (typeof value !== 'string' || value.trim() === '') { if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`[settings] "${path}" must be a non-empty string`); throw new Error(`[settings] "${path}" must be a non-empty string`);
} }
}; }
export const assertType = function assertType (value, path) { export const assertType = function assertType (value, path) {
if (!VALID_TYPES.includes(value)) { if (!VALID_TYPES.includes(value)) {
@@ -28,17 +28,17 @@ export const assertType = function assertType (value, path) {
`[settings] "${path}" has invalid type "${value}". Must be one of: ${VALID_TYPES.join(', ')}` `[settings] "${path}" has invalid type "${value}". Must be one of: ${VALID_TYPES.join(', ')}`
); );
} }
}; }
export const validateSelectionOptions = function validateSelectionOptions (options, path) { export const validateSelectionOptions = function validateSelectionOptions (options, path) {
if (!Array.isArray(options) || options.length === 0) { if (!Array.isArray(options) || options.length === 0) {
throw new Error(`[settings] "${path}.options" must be a non-empty array`); throw new Error(`[settings] "${path}.options" must be a non-empty array`);
} };
options.forEach((opt, i) => { options.forEach((opt, i) => {
assertString(opt.name, `${path}.options[${i}].name`); assertString(opt.name, `${path}.options[${i}].name`);
assertString(opt.i18n, `${path}.options[${i}].i18n`); assertString(opt.i18n, `${path}.options[${i}].i18n`);
}); });
}; }
export const validateEntry = function validateEntry (entry, path) { export const validateEntry = function validateEntry (entry, path) {
assertType(entry.type, `${path}.type`); assertType(entry.type, `${path}.type`);
@@ -61,7 +61,7 @@ export const validateEntry = function validateEntry (entry, path) {
if (typeof entry.allowMultiple !== 'boolean' && entry.allowMultiple) { if (typeof entry.allowMultiple !== 'boolean' && entry.allowMultiple) {
throw new Error(`[settings] "${path}.allowMultiple" must be a boolean`); throw new Error(`[settings] "${path}.allowMultiple" must be a boolean`);
} }
} };
if (entry.default !== undefined) { if (entry.default !== undefined) {
if (entry.type === 'bool' && typeof entry.default !== 'boolean') { if (entry.type === 'bool' && typeof entry.default !== 'boolean') {
throw new Error(`[settings] "${path}.default" must be a boolean`); throw new Error(`[settings] "${path}.default" must be a boolean`);
@@ -89,14 +89,14 @@ export const validateEntry = function validateEntry (entry, path) {
} else { } else {
if (typeof entry.default !== 'string') { if (typeof entry.default !== 'string') {
throw new Error(`[settings] "${path}.default" must be a string`); throw new Error(`[settings] "${path}.default" must be a string`);
} };
if (!entry.options.map((option) => option.name).includes(entry.default)) { if (!entry.options.map((option) => option.name).includes(entry.default)) {
throw new Error(`[settings] option "${path}.default" does not exist`); throw new Error(`[settings] option "${path}.default" does not exist`);
} };
} }
} };
} }
}; }
export const validateFirstLevelSection = function validateFirstLevelSection (section, path) { export const validateFirstLevelSection = function validateFirstLevelSection (section, path) {
assertString(section.name); assertString(section.name);
@@ -104,7 +104,7 @@ export const validateFirstLevelSection = function validateFirstLevelSection (sec
if (!Array.isArray(section.content)) { if (!Array.isArray(section.content)) {
throw new Error(`[settings] "${path}.content" must be an array`); throw new Error(`[settings] "${path}.content" must be an array`);
} };
section.content.forEach((entry, i) => section.content.forEach((entry, i) =>
validateEntry(entry, `${path}.content[${i}]`) validateEntry(entry, `${path}.content[${i}]`)
@@ -117,13 +117,18 @@ export const validateFirstLevelSection = function validateFirstLevelSection (sec
* @returns {{ valid: true, config: import('../types/settingsConfig').SettingsConfig } | { valid: false, error: string }} * @returns {{ valid: true, config: import('../types/settingsConfig').SettingsConfig } | { valid: false, error: string }}
*/ */
export function validateSettingsConfig(raw) { export function validateSettingsConfig(raw) {
if (!raw || typeof raw !== 'object') { try {
throw new Error('[settings] Config must be an object'); if (!raw || typeof raw !== 'object') {
throw new Error('[settings] Config must be an object');
}
if (!Array.isArray(raw.contents)) {
throw new Error('[settings] "contents" must be an array');
}
raw.contents.forEach((entry, i) =>
validateFirstLevelSection(entry, `contents[${i}]`)
);
return { valid: true, config: raw };
} catch (e) {
return { valid: false, error: e.message };
} }
if (!Array.isArray(raw.contents)) { }
throw new Error('[settings] "contents" must be an array');
}
raw.contents.forEach((entry, i) =>
validateFirstLevelSection(entry, `contents[${i}]`)
);
};
+32 -17
View File
@@ -18,9 +18,9 @@ limitations under the License.
import LeftSidebarLayout from '@/layouts/LeftSidebarLayout.vue'; import LeftSidebarLayout from '@/layouts/LeftSidebarLayout.vue';
import SettingsPage from '../components/SettingsPage.vue'; import SettingsPage from '../components/SettingsPage.vue';
import settingsConfiguration from '../config.js';
import { getSettingRecursively } from '../utils/getSetting.js'; import { getSettingRecursively } from '../utils/getSetting.js';
import { computed, onMounted, ref, watch } from 'vue'; import { loadSettingsConfig } from '../utils/settingsParser';
import { onMounted, ref, watchEffect } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
@@ -28,27 +28,39 @@ const { t } = useI18n();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const settings = settingsConfiguration.contents; const settingsLoaded = ref(false)
const settings = ref([]);
const activeSectionContent = ref({});
const activeSection = ref(null);
const activePath = computed(() => { watchEffect(() => {
const segments = route.path const segments = route.path
.split('/') .split('/')
.filter(Boolean); .filter(Boolean);
return segments.slice(1, segments.length); activeSection.value = segments.slice(1, segments.length).join('.');
}); });
const activeSection = ref(null); const updateSettings = function updateSettings () {
const updateSettings = async function updateSettings () { if (activeSection.value) {
const setting = getSettingRecursively(activePath.value, settings); const setting = getSettingRecursively(activeSection.value.split('.'), settings.value);
if (setting) { if (!setting) {
activeSection.value = setting; router.push('/settings');
} else {
activeSectionContent.value = setting;
};
} else { } else {
await router.push('/settings'); activeSectionContent.value = { content: [] };
} };
}; };
watch(route, (newRoute) => updateSettings(newRoute));
onMounted(updateSettings); onMounted(async () => {
settings.value = (await loadSettingsConfig()).contents;
watchEffect(() => {
updateSettings();
});
settingsLoaded.value = true;
});
const toggleSidebarIfSmallScreen = function toggleSidebarIfSmallScreen (toggleSidebarFunction) { const toggleSidebarIfSmallScreen = function toggleSidebarIfSmallScreen (toggleSidebarFunction) {
if (matchMedia('(max-width: 48rem)').matches) toggleSidebarFunction(); if (matchMedia('(max-width: 48rem)').matches) toggleSidebarFunction();
@@ -69,7 +81,7 @@ const toggleSidebarIfSmallScreen = function toggleSidebarIfSmallScreen (toggleSi
<RouterLink <RouterLink
:to="`/settings/${section.name}`" :to="`/settings/${section.name}`"
class="button button-link link sidebar-section" class="button button-link link sidebar-section"
:class="{ active: activeSection && activeSection.name === section.name }" :class="{ active: activeSection === section.name }"
@click="toggleSidebarIfSmallScreen(toggleSidebar)" @click="toggleSidebarIfSmallScreen(toggleSidebar)"
> >
{{ t(section.i18n) }} {{ t(section.i18n) }}
@@ -77,9 +89,12 @@ const toggleSidebarIfSmallScreen = function toggleSidebarIfSmallScreen (toggleSi
</li> </li>
</ul> </ul>
</template> </template>
<div v-if="activeSection"> <div v-if="!settingsLoaded" class="main-content--loading">
{{ t('loading') }}
</div>
<div v-else-if="activeSection" class="main-content--loaded">
<div class="settings-main-content"> <div class="settings-main-content">
<SettingsPage :setting="activeSection" :path="activePath" /> <SettingsPage :setting="activeSectionContent" :path="activeSection" />
</div> </div>
</div> </div>
<div v-else class="main-content--no-section"> <div v-else class="main-content--no-section">
@@ -16,8 +16,8 @@ limitations under the License.
import SettingsView from '../SettingsView.vue'; import SettingsView from '../SettingsView.vue';
import { mountComponent } from '@/test-utils/mountComponent.js'; import { mountComponent } from '@/test-utils/mountComponent.js';
import { flushPromises } from '@vue/test-utils';
import { beforeEach, describe, expect, test, vi } from 'vitest'; import { beforeEach, describe, expect, test, vi } from 'vitest';
import { nextTick } from 'vue';
const settingsHoisted = vi.hoisted(() => [ const settingsHoisted = vi.hoisted(() => [
{ {
@@ -197,8 +197,10 @@ const settings = settingsHoisted.values();
const originalCurrentSection = 's0'; const originalCurrentSection = 's0';
let currentSection = originalCurrentSection; let currentSection = originalCurrentSection;
vi.mock('../../config.js', () => ({ vi.mock('../../utils/settingsParser', () => ({
default: { contents: settingsHoisted } loadSettingsConfig: vi.fn().mockResolvedValue({
contents: settingsHoisted
})
})); }));
vi.mock('vue-router', async (importOriginal) => { vi.mock('vue-router', async (importOriginal) => {
@@ -233,7 +235,9 @@ describe('SettingsView', () => {
describe('sidebar', () => { describe('sidebar', () => {
test('shows all top-level sections', async () => { test('shows all top-level sections', async () => {
const wrapper = getWrapper(); const wrapper = getWrapper();
await nextTick();
await flushPromises();
await wrapper.vm.$nextTick();
const sidebarItems = wrapper.findAll('.sidebar-sections-list > li'); const sidebarItems = wrapper.findAll('.sidebar-sections-list > li');
settings.forEach((setting, index) => { settings.forEach((setting, index) => {
@@ -250,27 +254,47 @@ describe('SettingsView', () => {
describe('main', () => { describe('main', () => {
describe('correct element', () => { describe('correct element', () => {
test('shows that it is loading', () => {
const wrapper = getWrapper();
const mainContentLoading = wrapper.find('.main-content--loading');
const mainContentLoaded = wrapper.find('.main-content--loaded');
const mainContentNoSection = wrapper.find('.main-content--no-section');
expect(mainContentLoading.exists()).toBe(true);
expect(mainContentLoaded.exists()).toBe(false);
expect(mainContentNoSection.exists()).toBe(false);
});
test('shows that no section is selected', async () => { test('shows that no section is selected', async () => {
currentSection = ''; currentSection = '';
const wrapper = getWrapper(); const wrapper = getWrapper();
await nextTick();
const mainContent = wrapper.find('.settings-main-content'); await flushPromises();
await wrapper.vm.$nextTick();
const mainContentLoading = wrapper.find('.main-content--loading');
const mainContentLoaded = wrapper.find('.main-content--loaded');
const mainContentNoSection = wrapper.find('.main-content--no-section'); const mainContentNoSection = wrapper.find('.main-content--no-section');
expect(mainContent.exists()).toBe(false); expect(mainContentLoading.exists()).toBe(false);
expect(mainContentLoaded.exists()).toBe(false);
expect(mainContentNoSection.exists()).toBe(true); expect(mainContentNoSection.exists()).toBe(true);
}); });
test('shows main content', async () => { test('shows main content', async () => {
const wrapper = getWrapper(); const wrapper = getWrapper();
await nextTick();
const mainContent = wrapper.find('.settings-main-content'); await flushPromises();
await wrapper.vm.$nextTick();
const mainContentLoading = wrapper.find('.main-content--loading');
const mainContentLoaded = wrapper.find('.main-content--loaded');
const mainContentNoSection = wrapper.find('.main-content--no-section'); const mainContentNoSection = wrapper.find('.main-content--no-section');
expect(mainContent.exists()).toBe(true); expect(mainContentLoading.exists()).toBe(false);
expect(mainContentLoaded.exists()).toBe(true);
expect(mainContentNoSection.exists()).toBe(false); expect(mainContentNoSection.exists()).toBe(false);
}); });
}); });
+3 -8
View File
@@ -15,6 +15,7 @@ limitations under the License.
*/ */
import { createI18n } from 'vue-i18n'; import { createI18n } from 'vue-i18n';
import getCurrentLanguage from './utils/currentLanguage';
export const fallbackLocale = 'en'; export const fallbackLocale = 'en';
@@ -33,7 +34,7 @@ export const SUPPORTED_LANGUAGES = [
export const i18n = createI18n({ export const i18n = createI18n({
legacy: false, legacy: false,
locale: fallbackLocale, locale: getCurrentLanguage(),
fallbackLocale: fallbackLocale, fallbackLocale: fallbackLocale,
messages: {} messages: {}
}); });
@@ -48,7 +49,7 @@ export async function loadLanguage (locale) {
if (loadedLanguages.has(locale)) { if (loadedLanguages.has(locale)) {
i18n.global.locale.value = locale; i18n.global.locale.value = locale;
return; return;
} };
const messages = (await import(`./locales/${locale}.json`)).default; const messages = (await import(`./locales/${locale}.json`)).default;
@@ -57,9 +58,3 @@ export async function loadLanguage (locale) {
loadedLanguages.add(locale); loadedLanguages.add(locale);
}; };
export const setLocale = async function setLocale (locale) {
await loadLanguage(locale);
document.documentElement.lang = locale;
document.documentElement.dir = LANGUAGES_RTL.includes(locale) ? 'rtl' : 'ltr';
};
-23
View File
@@ -60,29 +60,6 @@
"option": { "option": {
"ariaLabel": "Wählt die Option {option} aus" "ariaLabel": "Wählt die Option {option} aus"
} }
},
"settings": {
"appearance": {
"title": "Erscheinungsbild",
"contents": {
"colorScheme": {
"title": "Farbschema",
"options": {
"auto": "An Systemfarbschema anpassen",
"light": "Hell",
"dark": "Dunkel"
}
}
}
},
"general": {
"title": "Allgemein",
"settings": {
"language": {
"title": "Sprache"
}
}
}
} }
}, },
"legal": { "legal": {
-23
View File
@@ -60,29 +60,6 @@
"option": { "option": {
"ariaLabel": "Selects option {option}" "ariaLabel": "Selects option {option}"
} }
},
"settings": {
"appearance": {
"title": "Appearance",
"contents": {
"colorScheme": {
"title": "Color Scheme",
"options": {
"auto": "Adapt to system color scheme",
"light": "Light",
"dark": "Dark"
}
}
}
},
"general": {
"title": "General",
"settings": {
"language": {
"title": "Language"
}
}
}
} }
}, },
"legal": { "legal": {
+7 -10
View File
@@ -15,23 +15,21 @@ limitations under the License.
*/ */
import { getAssetsUrl } from './config/env'; import { getAssetsUrl } from './config/env';
import { createApp } from 'vue'; import { createApp } from 'vue'
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'; import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
import App from './App.vue'; import App from './App.vue'
import { i18n, loadLanguage, fallbackLocale } from './i18n'; import { i18n, loadLanguage, fallbackLocale } from './i18n';
import router from './router'; import getCurrentLanguage from './utils/currentLanguage';
import './styles/common.css'; import router from './router'
import './styles/variables/colors.css'; import './styles/common.css'
import './styles/variables/colors.css'
import './styles/variables/dimensions.css'; import './styles/variables/dimensions.css';
import rawFonts from './styles/fonts.json'; import rawFonts from './styles/fonts.json';
import './styles/common.css'; import './styles/common.css';
import './styles/variables/colors.css'; import './styles/variables/colors.css';
import settings from './features/settings/config.js';
import { validateSettingsConfig } from './features/settings/utils/settingsValidator';
(async () => { (async () => {
const fonts = rawFonts.map((font) => new FontFace( const fonts = rawFonts.map((font) => new FontFace(
font.family ?? '', font.family ?? '',
@@ -52,12 +50,11 @@ import { validateSettingsConfig } from './features/settings/utils/settingsValida
fonts.forEach((font) => document.fonts.add(font)); fonts.forEach((font) => document.fonts.add(font));
await loadLanguage(fallbackLocale); await loadLanguage(fallbackLocale);
await loadLanguage(getCurrentLanguage());
const pinia = createPinia(); const pinia = createPinia();
pinia.use(piniaPluginPersistedstate); pinia.use(piniaPluginPersistedstate);
validateSettingsConfig(settings);
createApp(App) createApp(App)
.use(router) .use(router)
.use(i18n) .use(i18n)
+2 -2
View File
@@ -78,7 +78,7 @@ export const updatePageTitle = function updatePageTitle (route) {
document.title = `${title} - Seekra`; document.title = `${title} - Seekra`;
} else { } else {
document.title = 'Seekra'; document.title = 'Seekra';
} };
}; };
// remove trailing slash(es) // remove trailing slash(es)
@@ -90,7 +90,7 @@ router.beforeEach((route) => {
hash: route.hash, hash: route.hash,
replace: true replace: true
}; };
} };
}); });
export default router; export default router;
@@ -0,0 +1,61 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, expect, test } from 'vitest';
import getCurrentLanguage from '../currentLanguage';
describe('currentLanguage', () => {
test.for([
{ navigatorLanguage: 'en', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'de', localStorageLanguage: null, expected: 'de'},
{ navigatorLanguage: 'fr', localStorageLanguage: null, expected: 'fr'},
{ navigatorLanguage: 'en-US', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'en-AU', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'de-DE', localStorageLanguage: null, expected: 'de'},
{ navigatorLanguage: 'fr-FR', localStorageLanguage: null, expected: 'fr'},
{ navigatorLanguage: 'en-us', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'en-au', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'de-de', localStorageLanguage: null, expected: 'de'},
{ navigatorLanguage: 'fr-fr', localStorageLanguage: null, expected: 'fr'},
{ navigatorLanguage: 'zh-Hans-CN', localStorageLanguage: null, expected: 'zh'},
{ navigatorLanguage: 'zh-Hant-TW', localStorageLanguage: null, expected: 'zh'},
{ navigatorLanguage: 'uz-Latn-UZ', localStorageLanguage: null, expected: 'uz'},
{ navigatorLanguage: 'en-US-u-ca-gregory', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'de-DE-u-co-phonebk', localStorageLanguage: null, expected: 'de'},
{ navigatorLanguage: 'zh-Hant-TW-u-co-phonebk', localStorageLanguage: null, expected: 'zh'},
{ navigatorLanguage: 'en', localStorageLanguage: 'de', expected: 'de'},
{ navigatorLanguage: 'de-DE', localStorageLanguage: 'en', expected: 'en'},
{ navigatorLanguage: 'de-de', localStorageLanguage: 'en', expected: 'en'},
{ navigatorLanguage: 'zh-Hans-CN', localStorageLanguage: 'fr', expected: 'fr'},
{ navigatorLanguage: 'en-US-u-ca-gregory', localStorageLanguage: 'zh', expected: 'zh'}
])('returns the language $expected (navigator: $navigatorLanguage; local storage: $localStorageLanguage)', ({ navigatorLanguage, localStorageLanguage, expected }) => {
Object.defineProperty(navigator, 'language', {
value: navigatorLanguage,
configurable: true
});
if (localStorageLanguage) {
localStorage.setItem('locale', localStorageLanguage);
};
expect(getCurrentLanguage()).toBe(expected);
});
});
+1 -1
View File
@@ -25,7 +25,7 @@ export const ensureUnit = function (d) {
// if it ends with a number // if it ends with a number
if (/\d$/.test(d)) { if (/\d$/.test(d)) {
d += 'px'; d += 'px';
} };
return d; return d;
}; };
@@ -14,7 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export const getNavigatorLanguage = function getNavigatorLanguage () { export default function getCurrentLanguage () {
const saved = localStorage.getItem('locale');
if (saved) return saved;
const locale = new Intl.Locale(navigator.language); const locale = new Intl.Locale(navigator.language);
return locale.language; return locale.language;
}; };
+3 -3
View File
@@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { defineConfig } from 'vite'; import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue'
import path from 'path'; import path from 'path';
import { createHtmlPlugin } from 'vite-plugin-html'; import { createHtmlPlugin } from 'vite-plugin-html';
@@ -39,4 +39,4 @@ export default defineConfig({
globals: false, globals: false,
environment: 'jsdom' environment: 'jsdom'
} }
}); })