Author SHA1 Message Date
jakob.scheid 414dc66388 feat(sidebar): decrease horizontal padding when sidebar is not expanded
Set the horizontal padding of the unexpanded sidebar to 1em.
2026-07-31 16:35:54 +02:00
jakob.scheid 8c1ee57242 fix(sidebar): fix main content displaying
Do not show main content on small screens when the sidebar is expanded.
2026-07-31 16:35:54 +02:00
jakob.scheid 551def1de5 feat(sidebar): add sidebar expand button to left sidebar layout
Added a sidebar expand button to the left sidebar layout that is shown
on small screens when the sidebar is not expanded.
2026-07-31 16:35:53 +02:00
jakob.scheid a26610a749 fix(sidebar): do not show sidebar expanding button on small screens when sidebar is not expanded 2026-07-31 16:35:53 +02:00
jakob.scheid a25d6b93ee feat(sidebar): implement sidebar visibility toggling 2026-07-31 16:35:49 +02:00
jakob.scheid 1c58993cc1 feat(sidebar): add sidebar expand button
Added a button to expand the sidebar to the sidebar and reorganized
sidebar elements accordingly.
2026-07-30 18:21:29 +02:00
jakob.scheid af9f6b7ab4 feat(icons): add sidebar expanding icon 2026-07-30 18:19:54 +02:00
43 changed files with 658 additions and 1470 deletions
-16
View File
@@ -1,16 +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.
VITE_ASSETS_BUILD_ID=2026-08-04-76a067e
VITE_ASSETS_BASE_URL=https://assets.seekra.jcloud-services.ddns.net
-34
View File
@@ -21,42 +21,8 @@ limitations under the License.
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0158D2" />
<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>
<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>
<script type="module" src="/src/main.js"></script>
</body>
+9 -20
View File
@@ -18,47 +18,36 @@ limitations under the License.
import Navbar from './features/nav/components/Navbar.vue';
import Footer from './features/footer/components/Footer.vue';
import { LANGUAGE_PATH } from './config/featureSettings.js';
import { updatePageTitle } from './router';
import { useSettings } from './features/settings/composables/useSettings.js';
import { setLocale } from './i18n';
import { computed, onMounted, watch, watchEffect } from 'vue';
import { useColorScheme } from './features/colorScheme/composables/useColorScheme';
import { ref, provide, watch, watchEffect } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const { getSetting } = useSettings();
const colorScheme = computed(() => getSetting(['appearance', 'colorScheme']));
const updateColorScheme = function updateColorScheme (newColorScheme) {
const { getColorScheme, updateColorScheme } = useColorScheme();
const colorScheme = ref(getColorScheme());
provide('colorScheme', colorScheme);
watch(colorScheme, (newValue) => {
updateColorScheme(newValue);
document.body.style.setProperty(colorScheme, {
auto: 'normal',
dark: 'dark',
light: 'light'
});
if (newColorScheme === 'dark') {
if (newValue === 'dark') {
document.body.classList.add('dark');
} else {
document.body.classList.remove('dark');
}
if (newColorScheme === 'auto') {
if (newValue === 'auto') {
document.body.classList.add('color-scheme-auto');
} else {
document.body.classList.remove('color-scheme-auto');
}
}
watch(colorScheme, (newColorScheme) => {
updateColorScheme(newColorScheme);
});
updateColorScheme(colorScheme.value);
watchEffect(() => updatePageTitle(route));
watch(() => getSetting(LANGUAGE_PATH), setLocale);
onMounted(() => {
setLocale(getSetting(LANGUAGE_PATH));
});
</script>
<template>
-22
View File
@@ -1,22 +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.
*/
export const getAssetsUrl = function getAssetsUrl () {
return new URL(
`${import.meta.env.VITE_ASSETS_BUILD_ID}/`,
import.meta.env.VITE_ASSETS_BASE_URL
).href;
};
-17
View File
@@ -1,17 +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.
*/
export const LANGUAGE_PATH = ['general', 'language'];
@@ -17,14 +17,12 @@ limitations under the License.
<script setup>
import Icon from '@/features/icons/components/Icon.vue';
import { useSettings } from '@/features/settings/composables/useSettings';
import { computed } from 'vue';
import { inject } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const { getSetting, setSetting } = useSettings();
const colorScheme = computed(() => getSetting(['appearance', 'colorScheme']));
const colorScheme = inject('colorScheme');
const colorSchemeNextMapper = {
'light': 'dark',
@@ -41,15 +39,11 @@ const colorSchemeIconMapper = {
const getTooltipTranslation = function (colorScheme) {
return t(`preferences.colorScheme.switch.${colorSchemeNextMapper[colorScheme]}`);
};
const setColorScheme = function setColorScheme (newColorScheme) {
setSetting(['appearance', 'colorScheme'], newColorScheme);
};
</script>
<template>
<button class="color-scheme-button"
@click="setColorScheme(colorSchemeNextMapper[colorScheme])"
@click="colorScheme = colorSchemeNextMapper[colorScheme]"
:aria-label="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
};
};
+1 -41
View File
@@ -40,21 +40,9 @@ const copyrightPeriod =
</RouterLink>
<LanguageSwitchButton />
</div>
<div class="footer-segment legal-segment">
<div></div>
<div class="copyright-notice">
<div class="footer-segment">
&copy; {{ copyrightPeriod }} Seekra
</div>
<div class="legal-links">
<RouterLink
:to="{ name: 'legalNotice' }"
class="link"
:title="t('legal.notice.title')"
>
{{ t('legal.notice.title') }}
</RouterLink>
</div>
</div>
</footer>
</template>
@@ -66,7 +54,6 @@ const copyrightPeriod =
.footer-segment {
display: flex;
justify-content: center;
align-items: center;
gap: 32px;
padding: var(--padding-y);
background-color: var(--light-bg);
@@ -76,31 +63,4 @@ const copyrightPeriod =
.global-footer a {
color: var(--dark);
}
.legal-segment {
display: grid;
grid-template-columns: 1fr auto 1fr;
text-align: center;
}
.copyright-notice {
justify-self: center;
}
.legal-links {
justify-self: end;
}
@media (max-width: 48rem) {
.legal-segment {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0;
}
.legal-links {
margin-top: 0.8em;
}
}
</style>
@@ -17,20 +17,20 @@ limitations under the License.
<script setup>
import Icon from '@/features/icons/components/Icon.vue';
import { LANGUAGE_PATH } from '@/config/featureSettings';
import { useSettings } from '@/features/settings/composables/useSettings';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { SUPPORTED_LANGUAGES } from '@/i18n';
import { loadLanguage, LANGUAGES_RTL, SUPPORTED_LANGUAGES } from '@/i18n';
const { t, locale } = useI18n();
const { setSetting } = useSettings();
const isOpen = ref(false);
const languageDropdown = ref(null);
const selectLanguage = function selectLanguage (code) {
setSetting(LANGUAGE_PATH, code);
async function selectLanguage(code) {
await loadLanguage(code);
localStorage.setItem('locale', code);
document.documentElement.lang = code;
document.documentElement.dir = LANGUAGES_RTL.includes(code) ? 'rtl' : 'ltr';
close();
};
@@ -14,19 +14,41 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { LANGUAGE_PATH } from '@/config/featureSettings.js';
import { useSettings } from '@/features/settings/composables/useSettings.js';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { expect, test, describe } from 'vitest';
import { expect, test, describe, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import LanguageSwitchButton from '../LanguageSwitchButton.vue';
import { loadLanguage } from '@/i18n';
const getWrapper = function getWrapper () {
return mountComponent(LanguageSwitchButton);
};
vi.mock('@/i18n', () => ({
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', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
document.documentElement.lang = '';
document.documentElement.dir = '';
});
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-dropdown').exists()).toBe(false);
@@ -34,7 +56,7 @@ describe('LanguageSwitchButton', () => {
});
test('opens the dropdown when language button is clicked', async () => {
const wrapper = getWrapper();
const wrapper = mount(LanguageSwitchButton);
const button = wrapper.find('.language-button');
await button.trigger('click');
@@ -43,28 +65,33 @@ describe('LanguageSwitchButton', () => {
expect(button.attributes('aria-expanded')).toBe('true');
});
test('sets the language when clicking it', async () => {
const { getSetting } = useSettings();
const languageTestCases = [
{ 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');
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 enOption.trigger('click');
expect(getSetting(LANGUAGE_PATH)).toBe('en');
expect(wrapper.find('.language-dropdown').exists()).toBe(false);
await optionToClick.trigger('click');
const deOption = options.find(opt => opt.text().includes('de'));
await deOption.trigger('click');
expect(getSetting(LANGUAGE_PATH)).toBe('de');
expect(loadLanguage).toHaveBeenCalledWith(code);
expect(localStorage.getItem('locale')).toBe(code);
expect(document.documentElement.lang).toBe(code);
expect(document.documentElement.dir).toBe(expectedDir);
expect(wrapper.find('.language-dropdown').exists()).toBe(false);
});
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');
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);
});
});
-22
View File
@@ -1,22 +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.
*/
export class FetchError extends Error {
constructor (message) {
super(message);
this.name = 'FetchError';
};
};
@@ -1,110 +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 { FetchError } from '../../errors';
import { getLegalNotice } from '../fetchLegalContent';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
let fetchSpy;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
});
const exampleHtml = `<h1>Heading</h1>
<p>
<a>...</a>
</p>`;
const exampleHtmlWithLinkClass = `<h1>Heading</h1>
<p>
<a class="link">...</a>
</p>`;
const exampleHtmlDocument = `<html>
<head>
<title>Legal Notice</title>
</head>
<body>
${exampleHtml}
</body>
</html>`;
const exampleHtmlDocumentWithMain = `<html>
<head>
<title>Legal Notice</title>
</head>
<body>
<main>
${exampleHtml}
</main>
</body>
</html>`;
describe('fetchLegalContent', () => {
describe('getLegalNotice', () => {
describe('success', () => {
test('returns HTML body', async () => {
fetchSpy.mockResolvedValue(
new Response(exampleHtmlDocument)
);
expect(
(await getLegalNotice()).trim()
).toBe(exampleHtmlWithLinkClass);
});
test('returns HTML <main>', async () => {
fetchSpy.mockResolvedValue(
new Response(exampleHtmlDocumentWithMain)
);
expect(
(await getLegalNotice()).trim()
).toBe(exampleHtmlWithLinkClass);
});
test('does not fail on success status code', async () => {
fetchSpy.mockResolvedValue(
new Response(null, { status: 206 })
);
await getLegalNotice();
});
});
describe('error', () => {
test('throws FetchError when fetch fails', async () => {
fetchSpy.mockRejectedValue(new TypeError());
await expect(getLegalNotice()).rejects.toThrow(FetchError);
});
test('throws FetchError on HTTP error', async () => {
fetchSpy.mockResolvedValue(
new Response(null, { status: 404 })
)
await expect(getLegalNotice()).rejects.toThrow(FetchError);
});
test('does not throw FetchError when fetch throws another error than TypeError', async () => {
fetchSpy.mockRejectedValue(new Error());
await expect(getLegalNotice()).rejects.toThrow(Error);
});
});
});
});
@@ -1,44 +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 { FetchError } from '../errors';
import { getAssetsUrl } from '@/config/env';
const fetchHtml = async function fetchHtml (url, options) {
let response;
try {
response = await fetch(url, options);
} catch (err) {
if (err instanceof TypeError) throw new FetchError(err);
else throw err;
}
if (response.ok) return await response.text();
else throw new FetchError();
};
export const getLegalNotice = async function getLegalNotice () {
const legalNoticeUrl = new URL('legal/legal_notice.html', getAssetsUrl()).href;
const html = await fetchHtml(legalNoticeUrl);
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const legalNoticeContainer = doc.querySelector('main') ?? doc.body;
legalNoticeContainer.querySelectorAll('a').forEach(
(link) => link.classList.add('link')
);
return legalNoticeContainer.innerHTML;
};
@@ -1,61 +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.
-->
<script setup>
import { getLegalNotice } from '../services/fetchLegalContent';
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const loading = ref(true);
const legalNoticeError = ref(null);
const legalNoticeHtml = ref(null);
onMounted(async () => {
try {
legalNoticeHtml.value = await getLegalNotice();
} catch {
legalNoticeError.value = 'legal.notice.loadError';
} finally {
loading.value = false;
}
});
</script>
<template>
<div class="main-content-padding legal-notice-container">
<header class="header">
<h1>
{{ t('legal.notice.title') }}
</h1>
</header>
<main>
<div class="legal-notice-loading" v-if="loading">
{{ t('loading') }}
</div>
<div class="error" v-else-if="legalNoticeError">
{{ t(legalNoticeError) }}
</div>
<div class="legal-notice" v-html="legalNoticeHtml" v-else></div>
</main>
</div>
</template>
<style scoped>
.header h1 {
margin-top: 0;
}
</style>
@@ -1,78 +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 { FetchError } from '../../errors.js';
import LegalNoticeView from '../LegalNoticeView.vue';
import { mountComponent } from '@/test-utils/mountComponent';
import { flushPromises } from '@vue/test-utils';
import { afterEach, describe, expect, test, vi } from 'vitest';
const getLegalNotice = vi.hoisted(() => vi.fn().mockResolvedValue('legal notice'));
vi.mock('../../services/fetchLegalContent', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
getLegalNotice
};
});
afterEach(() => getLegalNotice.mockRestore());
describe('LegalNoticeView', () => {
test('shows loading indicator', () => {
const wrapper = mountComponent(LegalNoticeView);
const legalNoticeLoading = wrapper.find('.legal-notice-loading');
const legalNoticeError = wrapper.find('.error');
const legalNotice = wrapper.find('.legal-notice');
expect(legalNoticeLoading.exists()).toBe(true);
expect(legalNoticeError.exists()).toBe(false);
expect(legalNotice.exists()).toBe(false);
});
test('shows error', async () => {
getLegalNotice.mockRejectedValue(new FetchError());
const wrapper = mountComponent(LegalNoticeView);
await flushPromises();
await wrapper.vm.$nextTick();
const legalNoticeLoading = wrapper.find('.legal-notice-loading');
const legalNoticeError = wrapper.find('.error');
const legalNotice = wrapper.find('.legal-notice');
expect(legalNoticeLoading.exists()).toBe(false);
expect(legalNoticeError.exists()).toBe(true);
expect(legalNotice.exists()).toBe(false);
});
test('shows legal notice', async () => {
const wrapper = mountComponent(LegalNoticeView);
await flushPromises();
await wrapper.vm.$nextTick();
const legalNoticeLoading = wrapper.find('.legal-notice-loading');
const legalNoticeError = wrapper.find('.error');
const legalNotice = wrapper.find('.legal-notice');
expect(legalNoticeLoading.exists()).toBe(false);
expect(legalNoticeError.exists()).toBe(false);
expect(legalNotice.exists()).toBe(true);
});
});
@@ -54,9 +54,10 @@ const props = defineProps({
const store = useSettingsStore();
const optionType = computed(() => props.setting.allowMultiple ? 'checkbox' : 'radio');
const selected = computed({
get: () => normalizeSelectedValue(store.get(props.path) ?? props.setting.default),
set: (value) => store.set(props.path, value)
const selected = ref(normalizeSelectedValue(store.get(props.path) ?? props.setting.default));
watch(selected, (newValue) => {
store.set(props.path, newValue);
});
const labelId = useId();
+3 -11
View File
@@ -16,7 +16,7 @@ limitations under the License.
<script setup>
import { useSettingsStore } from '../stores/settingsStore';
import { ref, useId, watch } from 'vue';
import { useId } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
@@ -35,16 +35,8 @@ const switchId = useId();
const store = useSettingsStore();
const enabled = ref(null);
watch(store, (newStore) => {
enabled.value = newStore.get(props.path);
});
// set value after registering watcher to avoid immediate value change
enabled.value = store.get(props.path) ?? props.setting.default;
const toggle = function toggle () {
enabled.value = !enabled.value;
store.set(props.path, enabled.value);
store.set(props.path, !store.get(props.path));
};
</script>
@@ -55,7 +47,7 @@ const toggle = function toggle () {
</label>
<div
class="switch-wrapper"
:class="{ enabled }"
:class="{ enabled: store.get(props.path) }"
:title="t('settings.switch.title')"
:aria-label="t('settings.switch.ariaLabel')"
:aria-labelledby="labelId"
@@ -90,32 +90,6 @@ describe('Selection', () => {
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', () => {
const wrapper = getWrapper({
options: exampleOptions,
@@ -20,23 +20,17 @@ import { useSettingsStore } from '../../stores/settingsStore.js';
import { expect, describe, test } from 'vitest';
import { nextTick } from 'vue';
const getWrapper = function getWrapper ({
i18n = 'switch1',
defaultValue = undefined,
translations = {},
piniaOptions = {}
} = {}) {
const getWrapper = function getWrapper ({ i18n = 'switch1', translations = {} } = {}) {
return mountComponent(Switch, {
attrs: {
setting: {
type: 'bool',
name: 'switch',
i18n,
default: defaultValue
i18n
},
path: 'switch'
}
}, translations, [], piniaOptions);
}, translations);
};
describe('Switch', () => {
@@ -59,42 +53,6 @@ describe('Switch', () => {
expect(switchElementClasses2).not.toContain('enabled');
});
test('shows default value', () => {
const wrapper = getWrapper({
defaultValue: true
});
const switchElement = wrapper.find('.switch-wrapper');
expect(switchElement.classes('enabled')).toBeTruthy();
});
test('shows stored value instead of default value', () => {
const wrapper = getWrapper({
defaultValue: true,
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.set('switch', false);
}
}
});
const switchElement = wrapper.find('.switch-wrapper');
expect(switchElement.classes('enabled')).toBeFalsy();
});
test('reacts on store change', async () => {
const wrapper = getWrapper();
const store = useSettingsStore();
const switchElement = wrapper.find('.switch-wrapper');
expect(switchElement.classes('enabled')).toBeFalsy();
store.set('switch', true);
await nextTick();
expect(switchElement.classes('enabled')).toBeTruthy();
});
test('toggles value in store', async () => {
const wrapper = getWrapper();
@@ -1,91 +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 { useSettings } from '../useSettings';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { beforeEach, describe, expect, test, vi } from 'vitest';
const settingDefaultValue = vi.hoisted(() => 42);
const settingPath = ['test', 'number'];
vi.mock('../../config.js', () => ({
default: {
contents: [
{
name: 'test',
i18n: '',
content: [
{
type: 'number',
i18n: '',
name: 'number',
default: settingDefaultValue
},
{
type: 'bool',
i18n: '',
name: 'bool'
}
]
}
]
}
}));
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
})
);
});
@@ -1,52 +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 settings from '../config.js';
import { useSettingsStore } from '../stores/settingsStore';
import { getSettingRecursively } from '../utils/getSetting';
/**
* Provides access to the stored settings.
*/
export const useSettings = function useSettings () {
/**
* Returns the value of a specific setting.
* @param {string[]} key - The settings key.
* @return The value of the setting.
*/
const getSetting = function getSetting (key) {
const settingsStore = useSettingsStore();
return (
settingsStore.get(key.join('.')) ??
getSettingRecursively(
key, settings.contents ?? []
)?.default
);
};
/**
* Sets the value of a specific setting.
* @param {string[]} key - The setting key.
* @param value - The new value for the setting.
*/
const setSetting = function setSetting (key, value) {
const settingsStore = useSettingsStore();
settingsStore.set(key.join('.'), value);
};
return { getSetting, setSetting };
};
@@ -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": []
}
@@ -20,10 +20,18 @@ import { validateSettingsConfig, validateEntry, validateSelectionOptions, assert
describe('settingsValidator', () => {
describe('validateSettingsConfig', () => {
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: []
} },
{ settings: {
}, expected: true },
{ raw: {
contents: [
{
name: 'general',
@@ -37,8 +45,8 @@ describe('settingsValidator', () => {
]
}
]
} },
{ settings: {
}, expected: true },
{ raw: {
contents: [
{
name: 'general',
@@ -63,8 +71,8 @@ describe('settingsValidator', () => {
]
},
]
} },
{ settings: {
}, expected: true },
{ raw: {
contents: [
{
name: 'general',
@@ -120,12 +128,12 @@ describe('settingsValidator', () => {
default: 'str'
}
]
}
},
]
}
},
]
} },
{ settings: {
}, expected: true },
{ raw: {
contents: [
{
name: 'general',
@@ -182,283 +190,12 @@ describe('settingsValidator', () => {
default: 'str'
}
]
}
]
}
]
} }
])('does not throw an error and returns input', ({ settings }) => {
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: {
}, expected: true },
{ raw: {
contents: [
{
type: 'bool',
@@ -521,14 +258,276 @@ describe('settingsValidator', () => {
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'
}
]
} }
])('throws error', ({ settings }) => {
expect(() => validateSettingsConfig(settings)).toThrow(Error)
})
},
{
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 },
{ raw: {
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);
});
});
describe('validateEntry', () => {
@@ -558,30 +557,6 @@ describe('settingsValidator', () => {
{ name: 'maybe', i18n: 'selectSomething.options.maybe' }
] }],
[{ name: 'selectMultiple', type: 'selection', i18n: 'selectMultiple.title', default: ['1', '2', '3'], allowMultiple: true, options: [
{ name: '1', i18n: 'selectMultiple.options.1' },
{ name: '2', i18n: 'selectMultiple.options.2' },
{ name: '3', i18n: 'selectMultiple.options.3' }
] }],
[{ name: 'selectMultiple', type: 'selection', i18n: 'selectMultiple.title', default: ['2', '3'], allowMultiple: true, options: [
{ name: '1', i18n: 'selectMultiple.options.1' },
{ name: '2', i18n: 'selectMultiple.options.2' },
{ name: '3', i18n: 'selectMultiple.options.3' }
] }],
[{ name: 'selectMultiple', type: 'selection', i18n: 'selectMultiple.title', default: ['2'], allowMultiple: true, options: [
{ name: '1', i18n: 'selectMultiple.options.1' },
{ name: '2', i18n: 'selectMultiple.options.2' },
{ name: '3', i18n: 'selectMultiple.options.3' }
] }],
[{ name: 'selectMultiple', type: 'selection', i18n: 'selectMultiple.title', default: [], allowMultiple: true, options: [
{ name: '1', i18n: 'selectMultiple.options.1' },
{ name: '2', i18n: 'selectMultiple.options.2' },
{ name: '3', i18n: 'selectMultiple.options.3' }
] }],
[{ name: 'aSection', type: 'section', i18n: 'sections.aSection.title', content: [] }],
[{ name: 'aSection', type: 'section', i18n: 'sections.aSection.title', content: [
{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: true }
@@ -641,34 +616,6 @@ describe('settingsValidator', () => {
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', default: 'a', options: true }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', default: 'a', options: [] }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', default: ['a'], options: [
{ name: 'a', i18n: 'a' },
{ name: 'b', i18n: 'b' }
] }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', default: ['a', 'b'], options: [
{ name: 'a', i18n: 'a' },
{ name: 'b', i18n: 'b' }
] }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', allowMultiple: true, default: 'a', options: [
{ name: 'a', i18n: 'a' },
{ name: 'b', i18n: 'b' }
] }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', allowMultiple: true, default: 'a', options: [
{ name: 'a', i18n: 'a' },
{ name: 'b', i18n: 'b' }
] }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', allowMultiple: true, default: ['c'], options: [
{ name: 'a', i18n: 'a' },
{ name: 'b', i18n: 'b' }
] }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', allowMultiple: true, default: [1], options: [
{ name: 'a', i18n: 'a' },
{ name: 'b', i18n: 'b' }
] }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', allowMultiple: true, default: ['a', 1], options: [
{ name: 'a', i18n: 'a' },
{ name: 'b', i18n: 'b' }
] }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', default: 'c', options: [
{ name: 'a', i18n: 'a' },
{ name: 'b', i18n: 'b' }
@@ -0,0 +1,38 @@
/*
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 { validateSettingsConfig } from './settingsValidator.js';
/**
* Loads and parses the settings configuration via dynamic import.
* @returns {Promise<import('../types/settingsConfig').SettingsConfig>}
*/
export async function loadSettingsConfig() {
let raw;
try {
raw = (await import('../settings.json')).default;
} 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;
}
@@ -73,27 +73,12 @@ export const validateEntry = function validateEntry (entry, path) {
throw new Error(`[settings] "${path}.default" must be a string`);
}
if (entry.type === 'selection') {
if (entry.allowMultiple) {
if (!Array.isArray(entry.default)) {
throw new Error(`[settings] "${path}.default" must be an array`);
}
const allOptions = entry.options.map((option) => option.name);
entry.default.forEach((defaultValue, index) => {
if (typeof defaultValue !== 'string') {
throw new Error(`[settings] "${path}.default[${index}]" must be a string`);
}
if (!allOptions.includes(defaultValue)) {
throw new Error(`[settings] option "${path}.default[${index}]" does not exist`);
}
});
} else {
if (typeof entry.default !== 'string') {
throw new Error(`[settings] "${path}.default" must be a string`);
};
if (!entry.options.map((option) => option.name).includes(entry.default)) {
throw new Error(`[settings] option "${path}.default" does not exist`);
};
}
};
}
}
@@ -117,6 +102,7 @@ export const validateFirstLevelSection = function validateFirstLevelSection (sec
* @returns {{ valid: true, config: import('../types/settingsConfig').SettingsConfig } | { valid: false, error: string }}
*/
export function validateSettingsConfig(raw) {
try {
if (!raw || typeof raw !== 'object') {
throw new Error('[settings] Config must be an object');
}
@@ -126,4 +112,8 @@ export function validateSettingsConfig(raw) {
raw.contents.forEach((entry, i) =>
validateFirstLevelSection(entry, `contents[${i}]`)
);
return { valid: true, config: raw };
} catch (e) {
return { valid: false, error: e.message };
}
}
+32 -22
View File
@@ -18,9 +18,9 @@ limitations under the License.
import LeftSidebarLayout from '@/layouts/LeftSidebarLayout.vue';
import SettingsPage from '../components/SettingsPage.vue';
import settingsConfiguration from '../config.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 { useRoute, useRouter } from 'vue-router';
@@ -28,31 +28,39 @@ const { t } = useI18n();
const route = useRoute();
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
.split('/')
.filter(Boolean);
return segments.slice(1, segments.length);
activeSection.value = segments.slice(1, segments.length).join('.');
});
const activeSection = ref(null);
const updateSettings = async function updateSettings () {
const setting = getSettingRecursively(activePath.value, settings);
if (setting) {
activeSection.value = setting;
const updateSettings = function updateSettings () {
if (activeSection.value) {
const setting = getSettingRecursively(activeSection.value.split('.'), settings.value);
if (!setting) {
router.push('/settings');
} else {
await router.push('/settings');
}
activeSectionContent.value = setting;
};
} else {
activeSectionContent.value = { content: [] };
};
};
watch(route, (newRoute) => updateSettings(newRoute));
onMounted(updateSettings);
const toggleSidebarIfSmallScreen = function toggleSidebarIfSmallScreen (toggleSidebarFunction) {
if (matchMedia('(max-width: 48rem)').matches) toggleSidebarFunction();
};
onMounted(async () => {
settings.value = (await loadSettingsConfig()).contents;
watchEffect(() => {
updateSettings();
});
settingsLoaded.value = true;
});
</script>
<template>
@@ -63,23 +71,25 @@ const toggleSidebarIfSmallScreen = function toggleSidebarIfSmallScreen (toggleSi
</h1>
</header>
<LeftSidebarLayout class="layout">
<template #sidebar="{ toggleSidebar }">
<template #sidebar>
<ul class="sidebar-sections-list">
<li v-for="section in settings">
<RouterLink
:to="`/settings/${section.name}`"
class="button button-link link sidebar-section"
:class="{ active: activeSection && activeSection.name === section.name }"
@click="toggleSidebarIfSmallScreen(toggleSidebar)"
:class="{ active: activeSection === section.name }"
>
{{ t(section.i18n) }}
</RouterLink>
</li>
</ul>
</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">
<SettingsPage :setting="activeSection" :path="activePath" />
<SettingsPage :setting="activeSectionContent" :path="activeSection" />
</div>
</div>
<div v-else class="main-content--no-section">
@@ -16,8 +16,8 @@ limitations under the License.
import SettingsView from '../SettingsView.vue';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { flushPromises } from '@vue/test-utils';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { nextTick } from 'vue';
const settingsHoisted = vi.hoisted(() => [
{
@@ -197,8 +197,10 @@ const settings = settingsHoisted.values();
const originalCurrentSection = 's0';
let currentSection = originalCurrentSection;
vi.mock('../../config.js', () => ({
default: { contents: settingsHoisted }
vi.mock('../../utils/settingsParser', () => ({
loadSettingsConfig: vi.fn().mockResolvedValue({
contents: settingsHoisted
})
}));
vi.mock('vue-router', async (importOriginal) => {
@@ -233,7 +235,9 @@ describe('SettingsView', () => {
describe('sidebar', () => {
test('shows all top-level sections', async () => {
const wrapper = getWrapper();
await nextTick();
await flushPromises();
await wrapper.vm.$nextTick();
const sidebarItems = wrapper.findAll('.sidebar-sections-list > li');
settings.forEach((setting, index) => {
@@ -250,27 +254,47 @@ describe('SettingsView', () => {
describe('main', () => {
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 () => {
currentSection = '';
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');
expect(mainContent.exists()).toBe(false);
expect(mainContentLoading.exists()).toBe(false);
expect(mainContentLoaded.exists()).toBe(false);
expect(mainContentNoSection.exists()).toBe(true);
});
test('shows main content', async () => {
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');
expect(mainContent.exists()).toBe(true);
expect(mainContentLoading.exists()).toBe(false);
expect(mainContentLoaded.exists()).toBe(true);
expect(mainContentNoSection.exists()).toBe(false);
});
});
+1 -5
View File
@@ -36,7 +36,7 @@ const toggleSidebar = function toggleSidebar () {
<div class="sidebar-controls">
<SidebarExpandButton @click="toggleSidebar" class="sidebar-expand-button" />
</div>
<div class="sidebar-content" v-show="props.expanded">
<div class="sidebar-content" v-if="props.expanded">
<slot />
</div>
</nav>
@@ -61,10 +61,6 @@ const toggleSidebar = function toggleSidebar () {
}
@media (max-width: 48rem) {
.sidebar {
border: none;
}
.sidebar:not(.expanded) .sidebar-expand-button {
display: none;
}
+2 -7
View File
@@ -15,6 +15,7 @@ limitations under the License.
*/
import { createI18n } from 'vue-i18n';
import getCurrentLanguage from './utils/currentLanguage';
export const fallbackLocale = 'en';
@@ -33,7 +34,7 @@ export const SUPPORTED_LANGUAGES = [
export const i18n = createI18n({
legacy: false,
locale: fallbackLocale,
locale: getCurrentLanguage(),
fallbackLocale: fallbackLocale,
messages: {}
});
@@ -57,9 +58,3 @@ export async function loadLanguage (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';
};
+5 -5
View File
@@ -29,7 +29,7 @@ const props = defineProps({
const expanded = ref(props.expanded);
const toggleSidebar = function toggleSidebar () {
const toggleExpanded = function toggleExpanded () {
expanded.value = !expanded.value;
};
</script>
@@ -37,13 +37,13 @@ const toggleSidebar = function toggleSidebar () {
<template>
<div class="layout-container" :class="{ expanded }">
<div class="sidebar-expand-button-container">
<SidebarExpandButton class="sidebar-expand-button" @click="toggleSidebar" />
<SidebarExpandButton class="sidebar-expand-button" @click="toggleExpanded" />
</div>
<Sidebar @toggle-expanded="toggleSidebar" :expanded="expanded" class="sidebar">
<slot name="sidebar" :toggleSidebar="toggleSidebar" />
<Sidebar @toggle-expanded="toggleExpanded" :expanded="expanded" class="sidebar">
<slot name="sidebar" />
</Sidebar>
<main class="main-content">
<slot :toggleSidebar="toggleSidebar" />
<slot />
</main>
</div>
</template>
-29
View File
@@ -60,35 +60,6 @@
"option": {
"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": {
"notice": {
"title": "Impressum",
"loadError": "Das Impressum konnte nicht geladen werden."
}
}
}
-29
View File
@@ -60,35 +60,6 @@
"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": {
"notice": {
"title": "Legal Notice",
"loadError": "The legal notice could not be loaded."
}
}
}
+2 -25
View File
@@ -14,50 +14,27 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { getAssetsUrl } from './config/env';
import { createApp } from 'vue'
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
import App from './App.vue'
import { i18n, loadLanguage, fallbackLocale } from './i18n';
import getCurrentLanguage from './utils/currentLanguage';
import router from './router'
import './styles/common.css'
import './styles/variables/colors.css'
import './styles/variables/dimensions.css';
import rawFonts from './styles/fonts.json';
import './styles/common.css';
import './styles/variables/colors.css';
import settings from './features/settings/config.js';
import { validateSettingsConfig } from './features/settings/utils/settingsValidator';
(async () => {
const fonts = rawFonts.map((font) => new FontFace(
font.family ?? '',
Array.isArray(font.source)
? font.source.map(
(source) => `url('${source.url
? new URL(source.url, getAssetsUrl()).href
: ''
}') format('${source.format ?? ''}')`
).join(',')
: '',
{
weight: font.weight ?? undefined,
style: font.style ?? undefined
}
));
await Promise.all(fonts.map((font) => font.load()));
fonts.forEach((font) => document.fonts.add(font));
await loadLanguage(fallbackLocale);
await loadLanguage(getCurrentLanguage());
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
validateSettingsConfig(settings);
createApp(App)
.use(router)
.use(i18n)
-9
View File
@@ -18,7 +18,6 @@ import { createRouter, createWebHistory } from 'vue-router';
import { i18n } from '@/i18n';
import SearchView from '../views/SearchView.vue';
import LegalNoticeView from '@/features/legal/views/LegalNoticeView.vue';
import SearchResultsView from '@/features/search/views/SearchResultsView.vue';
import SettingsView from '@/features/settings/views/SettingsView.vue';
import NotFound from '../views/NotFound.vue';
@@ -40,14 +39,6 @@ const routes = [
title: (route) => route.query.q
}
},
{
path: '/legal/notice',
name: 'legalNotice',
component: LegalNoticeView,
meta: {
title: () => i18n.global.t('legal.notice.title')
}
},
{
path: '/settings/:rest(.*)*',
name: 'settings',
-15
View File
@@ -19,12 +19,10 @@ body {
color: var(--dark);
margin: 0;
font-size: 16px;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
.link {
text-decoration: none;
color: var(--dark);
}
.link:hover:not(.button-link), .link:focus-visible:not(.button-link) {
@@ -88,16 +86,3 @@ input {
.button:hover {
background-color: var(--light-hover);
}
/* Temporary until a custom tap highlight is implemented. */
* {
-webkit-tap-highlight-color: transparent;
}
.error {
background-color: var(--color-negative-bg);
border: 1px solid var(--color-negative-border);
padding: 1em 2em;
border-radius: 1.2em;
color: var(--white);
}
-32
View File
@@ -1,32 +0,0 @@
[
{
"family": "Inter",
"weight": "100 900",
"style": "normal",
"source": [
{
"url": "fonts/Inter/Inter-Variable.woff2",
"format": "woff2"
},
{
"url": "fonts/Inter/Inter-Variable.ttf",
"format": "truetype"
}
]
},
{
"family": "Inter",
"weight": "100 900",
"style": "italic",
"source": [
{
"url": "fonts/Inter/Inter-Italic-Variable.woff2",
"format": "woff2"
},
{
"url": "fonts/Inter/Inter-Italic-Variable.ttf",
"format": "truetype"
}
]
}
]
-3
View File
@@ -84,9 +84,6 @@ body {
--light-hover: var(--light-d-2);
--invert: invert(0);
--color-negative-bg: oklch(59% 0.22706 29.244);
--color-negative-border: oklch(51% 0.22706 29.244);
}
@media (prefers-color-scheme: dark) {
@@ -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);
});
});
@@ -14,7 +14,9 @@ See the License for the specific language governing permissions and
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);
return locale.language;
};