31 Commits
Author SHA1 Message Date
jakob.scheidandGitea 8f44609702 Merge pull request 'Feat(legal): add legal notice' (#170) from feature/legal-notice into main
Deploy on dev / Deploy on dev (push) Successful in 34s
Reviewed-on: #170
Reviewed-by: Jakob Gregory
2026-08-06 23:43:56 +02:00
jakob.scheid 5dc3ddd549 feat(style): add style for JavaScript disabled
Add inline stylesheet which is used if JavaScript is disabled.
2026-08-06 23:43:34 +02:00
jakob.scheid f739676a1a feat(legal): add <noscript> containing legal notice link
Added a <noscript> with a link to the legal notice and a note to enable
JavaScript to use Seekra. This ensures that also users with JavaScript
disabled can access legal content.
2026-08-06 23:43:33 +02:00
jakob.scheid f73e2edae7 test(legal): add test for the legal notice view
Added tests for the legal notice view (LegalNoticeView.vue).
2026-08-06 23:43:33 +02:00
jakob.scheid f7358ceb34 test(legal): add tests for getLegalNotice 2026-08-06 23:43:33 +02:00
jakob.scheid f8d2086433 feat(legal): add legal notice loading indicator 2026-08-06 23:43:32 +02:00
jakob.scheid 7613122658 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 23:43:32 +02:00
jakob.scheid b27c89945f 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 23:43:31 +02:00
jakob.scheid d9ed16f3de feat(legal): load and display legal notice 2026-08-06 23:43:31 +02:00
jakob.scheid 4c39fadd9a 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 23:43:30 +02:00
jakob.scheid dccdab0d4a feat(legal): add route for the legal notice 2026-08-06 23:43:30 +02:00
jakob.scheidandGitea f6cf1e9777 Merge pull request 'Fix(settings): selection reactivity' (#184) from fix/selection-setting-reactivity into main
Deploy on dev / Deploy on dev (push) Successful in 38s
Reviewed-on: #184
Reviewed-by: Jakob Gregory
2026-08-06 23:41:53 +02:00
jakob.scheid 53bd67b7a0 test(settings): add tests for selection reactivity 2026-08-06 23:24:09 +02:00
jakob.scheid 1f51a0b3ae fix(settings): make selection reactive
Selection now uses computed with getter and setter.
2026-08-06 23:23:21 +02:00
jakob.scheidandGitea bcb0c05d74 Merge pull request 'Feat(color-scheme): make color scheme a setting' (#182) from feature/make-color-scheme-a-setting into main
Deploy on dev / Deploy on dev (push) Successful in 35s
Reviewed-on: #182
Reviewed-by: Jakob Gregory
2026-08-06 23:06:13 +02:00
jakob.scheid 069b75b3c5 test(color-scheme): add tests for color scheme button 2026-08-06 20:56:46 +02:00
jakob.scheid 589be2a5bc refactor(color-scheme): remove unused composable
Removed the unused composable 'useColorScheme.js'.
2026-08-06 20:26:04 +02:00
jakob.scheid 287f02505e feat(color-scheme): make color scheme a setting
The color scheme now uses settings to store the currect color scheme.
2026-08-06 20:23:57 +02:00
jakob.scheid ad6f59a85c fix(settings): fix useSettings getSetting store path 2026-08-06 20:23:50 +02:00
jakob.scheid f9a53a8911 feat(settings)!: make settings module import static
Migrated the settings to a static settings configuration import.
2026-08-06 19:38:52 +02:00
jakob.scheid f485a2d476 feat(color-scheme): add color scheme settings
Added an appearance settings section containing a color scheme setting
and translations for that.
2026-08-06 17:38:05 +02:00
jakob.scheidandGitea dde56c42ae Merge pull request 'Feat(settings): function to set settings' (#179) from feature/set-setting-function into main
Deploy on dev / Deploy on dev (push) Successful in 35s
Reviewed-on: #179
Reviewed-by: Jakob Gregory
2026-08-06 17:22:49 +02:00
jakob.scheid 15697144b1 feat(settings): add function to set settings
Added function setSetting, which sets a setting, to useSettings
composable.
2026-08-06 15:33:55 +02:00
jakob.scheidandGitea 396c025ec4 Merge pull request 'Fix(color-scheme): fix system color scheme initial loading' (#177) from fix/system-color-scheme-initial into main
Deploy on dev / Deploy on dev (push) Successful in 34s
Reviewed-on: #177
Reviewed-by: Jakob Gregory
2026-08-06 15:27:14 +02:00
jakob.scheid 30f6b82416 fix(color-scheme): set color scheme dummy value to null
Set the initial value of the color scheme ref to null to make setting it
to the actual value trigger a change if the color scheme is auto.
2026-08-06 15:06:30 +02:00
jakob.scheidandGitea 7b09cd74cb Merge pull request 'Feat(settings): function to get the value of a setting' (#168) from feature/setting-value-function into main
Deploy on dev / Deploy on dev (push) Successful in 38s
Reviewed-on: #168
Reviewed-by: Jakob Gregory
2026-08-06 12:50:51 +02:00
jakob.scheidandGitea fab7d7e901 test(settings): add test cases for empty setting value return value 2026-08-06 12:50:33 +02:00
jakob.scheidandGitea 04c148a3b1 feat(settings): make setting value function synchronous
Added a store to cache the settings configuration. The getSetting
function from useSettings is now synchronous and uses this cache.
The settings cache is set when loading the settings initially
(asnychronously).
2026-08-06 12:50:33 +02:00
jakob.scheidandGitea ff6ab7a78c test(settings): add tests for useSettings composable
Added some tests for the getSetting function from the useSettings
composable.
2026-08-06 12:50:33 +02:00
jakob.scheidandGitea 294fdab550 feat(settings): add composable for settings values
Added the composable useSettings that provides the function getSetting.
This function returns the set value of the setting, or otherwise the
default value. If there is no default value, it returns undefined.
2026-08-06 12:50:33 +02:00
jakob.scheidandGitea 26eec92f42 Merge pull request 'Fix(color-scheme): fix initial color scheme loading' (#171) from fix/initial-color-scheme into main
Deploy on dev / Deploy on dev (push) Successful in 35s
Reviewed-on: #171
Reviewed-by: Jakob Gregory
2026-08-06 12:50:01 +02:00
28 changed files with 1054 additions and 516 deletions
+34
View File
@@ -21,8 +21,42 @@ 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>
+12 -10
View File
@@ -19,34 +19,36 @@ 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 { updatePageTitle } from './router'; import { updatePageTitle } from './router';
import { useColorScheme } from './features/colorScheme/composables/useColorScheme'; import { useSettings } from './features/settings/composables/useSettings.js';
import { ref, provide, watch, watchEffect } from 'vue'; import { computed, watch, watchEffect } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
const route = useRoute(); const route = useRoute();
const { getColorScheme, updateColorScheme } = useColorScheme(); const { getSetting } = useSettings();
const colorScheme = ref('auto'); const colorScheme = computed(() => getSetting(['appearance', 'colorScheme']));
provide('colorScheme', colorScheme);
watch(colorScheme, (newValue) => { const updateColorScheme = function updateColorScheme (newColorScheme) {
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 (newValue === 'dark') { if (newColorScheme === 'dark') {
document.body.classList.add('dark'); document.body.classList.add('dark');
} else { } else {
document.body.classList.remove('dark'); document.body.classList.remove('dark');
} }
if (newValue === 'auto') { if (newColorScheme === '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);
}); });
colorScheme.value = getColorScheme(); updateColorScheme(colorScheme.value);
watchEffect(() => updatePageTitle(route)); watchEffect(() => updatePageTitle(route));
</script> </script>
@@ -17,12 +17,14 @@ 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 { inject } from 'vue'; import { useSettings } from '@/features/settings/composables/useSettings';
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 = inject('colorScheme'); const colorScheme = computed(() => getSetting(['appearance', 'colorScheme']));
const colorSchemeNextMapper = { const colorSchemeNextMapper = {
'light': 'dark', 'light': 'dark',
@@ -39,11 +41,15 @@ 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="colorScheme = colorSchemeNextMapper[colorScheme]" @click="setColorScheme(colorSchemeNextMapper[colorScheme])"
:aria-label="getTooltipTranslation(colorScheme)" :aria-label="getTooltipTranslation(colorScheme)"
:title="getTooltipTranslation(colorScheme)" :title="getTooltipTranslation(colorScheme)"
> >
@@ -0,0 +1,47 @@
/*
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');
});
});
@@ -1,39 +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 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
};
};
+41 -2
View File
@@ -40,8 +40,20 @@ const copyrightPeriod =
</RouterLink> </RouterLink>
<LanguageSwitchButton /> <LanguageSwitchButton />
</div> </div>
<div class="footer-segment"> <div class="footer-segment legal-segment">
&copy; {{ copyrightPeriod }} Seekra <div></div>
<div class="copyright-notice">
&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> </div>
</footer> </footer>
</template> </template>
@@ -64,4 +76,31 @@ const copyrightPeriod =
.global-footer a { .global-footer a {
color: var(--dark); 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> </style>
+22
View File
@@ -0,0 +1,22 @@
/*
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';
};
};
@@ -0,0 +1,110 @@
/*
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);
});
});
});
});
@@ -0,0 +1,44 @@
/*
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;
};
@@ -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.
-->
<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>
@@ -0,0 +1,78 @@
/*
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,10 +54,9 @@ 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 = ref(normalizeSelectedValue(store.get(props.path) ?? props.setting.default)); const selected = computed({
get: () => normalizeSelectedValue(store.get(props.path) ?? props.setting.default),
watch(selected, (newValue) => { set: (value) => store.set(props.path, value)
store.set(props.path, newValue);
}); });
const labelId = useId(); const labelId = useId();
@@ -90,6 +90,32 @@ 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,
@@ -0,0 +1,91 @@
/*
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('../../settings.json', () => ({
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
})
);
});
@@ -0,0 +1,52 @@
/*
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 '../settings.json';
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 };
};
@@ -1,48 +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 { 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,
};
}
+29 -2
View File
@@ -1,3 +1,30 @@
{ {
"contents": [] "contents": [
} {
"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"
}
]
}
]
}
]
}
@@ -20,18 +20,10 @@ import { validateSettingsConfig, validateEntry, validateSelectionOptions, assert
describe('settingsValidator', () => { describe('settingsValidator', () => {
describe('validateSettingsConfig', () => { describe('validateSettingsConfig', () => {
test.for([ test.for([
{ raw: false, expected: false }, { settings: {
{ 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 }, } },
{ raw: { { settings: {
contents: [ contents: [
{ {
name: 'general', name: 'general',
@@ -45,8 +37,8 @@ describe('settingsValidator', () => {
] ]
} }
] ]
}, expected: true }, } },
{ raw: { { settings: {
contents: [ contents: [
{ {
name: 'general', name: 'general',
@@ -71,8 +63,8 @@ describe('settingsValidator', () => {
] ]
}, },
] ]
}, expected: true }, } },
{ raw: { { settings: {
contents: [ contents: [
{ {
name: 'general', name: 'general',
@@ -128,12 +120,12 @@ describe('settingsValidator', () => {
default: 'str' default: 'str'
} }
] ]
}, }
] ]
}, }
] ]
}, expected: true }, } },
{ raw: { { settings: {
contents: [ contents: [
{ {
name: 'general', name: 'general',
@@ -190,12 +182,283 @@ describe('settingsValidator', () => {
default: 'str' 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'
}
]
}
]
}
] ]
}, expected: true }, } },
{ raw: { { 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',
@@ -258,276 +521,14 @@ describe('settingsValidator', () => {
default: 'str' default: 'str'
} }
] ]
}, }
] ]
}, }
] ]
}, expected: false }, } }
{ raw: { ])('throws error', ({ settings }) => {
contents: [ expect(() => validateSettingsConfig(settings)).toThrow(Error)
{ })
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 },
{ 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', () => { describe('validateEntry', () => {
@@ -1,38 +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 { 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;
}
@@ -117,18 +117,13 @@ 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) {
try { if (!raw || typeof raw !== 'object') {
if (!raw || typeof raw !== 'object') { throw new Error('[settings] Config must be an 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}]`)
);
} }
+17 -32
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 '../settings.json';
import { getSettingRecursively } from '../utils/getSetting.js'; import { getSettingRecursively } from '../utils/getSetting.js';
import { loadSettingsConfig } from '../utils/settingsParser'; import { computed, onMounted, ref, watch } from 'vue';
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,39 +28,27 @@ const { t } = useI18n();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const settingsLoaded = ref(false) const settings = settingsConfiguration.contents;
const settings = ref([]);
const activeSectionContent = ref({});
const activeSection = ref(null);
watchEffect(() => { const activePath = computed(() => {
const segments = route.path const segments = route.path
.split('/') .split('/')
.filter(Boolean); .filter(Boolean);
activeSection.value = segments.slice(1, segments.length).join('.'); return segments.slice(1, segments.length);
}); });
const updateSettings = function updateSettings () { const activeSection = ref(null);
if (activeSection.value) { const updateSettings = async function updateSettings () {
const setting = getSettingRecursively(activeSection.value.split('.'), settings.value); const setting = getSettingRecursively(activePath.value, settings);
if (!setting) { if (setting) {
router.push('/settings'); activeSection.value = setting;
} else {
activeSectionContent.value = setting;
};
} else { } else {
activeSectionContent.value = { content: [] }; await router.push('/settings');
}; }
}; };
watch(route, (newRoute) => updateSettings(newRoute));
onMounted(async () => { onMounted(updateSettings);
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();
@@ -81,7 +69,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 === section.name }" :class="{ active: activeSection && activeSection.name === section.name }"
@click="toggleSidebarIfSmallScreen(toggleSidebar)" @click="toggleSidebarIfSmallScreen(toggleSidebar)"
> >
{{ t(section.i18n) }} {{ t(section.i18n) }}
@@ -89,12 +77,9 @@ const toggleSidebarIfSmallScreen = function toggleSidebarIfSmallScreen (toggleSi
</li> </li>
</ul> </ul>
</template> </template>
<div v-if="!settingsLoaded" class="main-content--loading"> <div v-if="activeSection">
{{ t('loading') }}
</div>
<div v-else-if="activeSection" class="main-content--loaded">
<div class="settings-main-content"> <div class="settings-main-content">
<SettingsPage :setting="activeSectionContent" :path="activeSection" /> <SettingsPage :setting="activeSection" :path="activePath" />
</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,10 +197,8 @@ const settings = settingsHoisted.values();
const originalCurrentSection = 's0'; const originalCurrentSection = 's0';
let currentSection = originalCurrentSection; let currentSection = originalCurrentSection;
vi.mock('../../utils/settingsParser', () => ({ vi.mock('../../settings.json', () => ({
loadSettingsConfig: vi.fn().mockResolvedValue({ default: { contents: settingsHoisted }
contents: settingsHoisted
})
})); }));
vi.mock('vue-router', async (importOriginal) => { vi.mock('vue-router', async (importOriginal) => {
@@ -235,9 +233,7 @@ 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) => {
@@ -254,47 +250,27 @@ 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();
await flushPromises(); const mainContent = wrapper.find('.settings-main-content');
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(mainContentLoading.exists()).toBe(false); expect(mainContent.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();
await flushPromises(); const mainContent = wrapper.find('.settings-main-content');
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(mainContentLoading.exists()).toBe(false); expect(mainContent.exists()).toBe(true);
expect(mainContentLoaded.exists()).toBe(true);
expect(mainContentNoSection.exists()).toBe(false); expect(mainContentNoSection.exists()).toBe(false);
}); });
}); });
+21
View File
@@ -60,6 +60,27 @@
"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"
}
}
}
}
}
},
"legal": {
"notice": {
"title": "Impressum",
"loadError": "Das Impressum konnte nicht geladen werden."
} }
} }
} }
+21
View File
@@ -60,6 +60,27 @@
"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"
}
}
}
}
}
},
"legal": {
"notice": {
"title": "Legal Notice",
"loadError": "The legal notice could not be loaded."
} }
} }
} }
+5
View File
@@ -30,6 +30,9 @@ 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/settings.json';
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 ?? '',
@@ -54,6 +57,8 @@ import './styles/variables/colors.css';
const pinia = createPinia(); const pinia = createPinia();
pinia.use(piniaPluginPersistedstate); pinia.use(piniaPluginPersistedstate);
validateSettingsConfig(settings);
createApp(App) createApp(App)
.use(router) .use(router)
+9
View File
@@ -18,6 +18,7 @@ import { createRouter, createWebHistory } from 'vue-router';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import SearchView from '../views/SearchView.vue'; import SearchView from '../views/SearchView.vue';
import LegalNoticeView from '@/features/legal/views/LegalNoticeView.vue';
import SearchResultsView from '@/features/search/views/SearchResultsView.vue'; import SearchResultsView from '@/features/search/views/SearchResultsView.vue';
import SettingsView from '@/features/settings/views/SettingsView.vue'; import SettingsView from '@/features/settings/views/SettingsView.vue';
import NotFound from '../views/NotFound.vue'; import NotFound from '../views/NotFound.vue';
@@ -39,6 +40,14 @@ const routes = [
title: (route) => route.query.q title: (route) => route.query.q
} }
}, },
{
path: '/legal/notice',
name: 'legalNotice',
component: LegalNoticeView,
meta: {
title: () => i18n.global.t('legal.notice.title')
}
},
{ {
path: '/settings/:rest(.*)*', path: '/settings/:rest(.*)*',
name: 'settings', name: 'settings',
+10 -1
View File
@@ -24,6 +24,7 @@ body {
.link { .link {
text-decoration: none; text-decoration: none;
color: var(--dark);
} }
.link:hover:not(.button-link), .link:focus-visible:not(.button-link) { .link:hover:not(.button-link), .link:focus-visible:not(.button-link) {
@@ -91,4 +92,12 @@ input {
/* Temporary until a custom tap highlight is implemented. */ /* Temporary until a custom tap highlight is implemented. */
* { * {
-webkit-tap-highlight-color: transparent; -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);
}
+3
View File
@@ -84,6 +84,9 @@ body {
--light-hover: var(--light-d-2); --light-hover: var(--light-d-2);
--invert: invert(0); --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) { @media (prefers-color-scheme: dark) {