Author SHA1 Message Date
jakob.scheidandGitea 9d10acb7bd test(legal): add test for the legal notice view
Added tests for the legal notice view (LegalNoticeView.vue).
2026-08-06 21:01:18 +02:00
jakob.scheidandGitea 28c47e91d5 test(legal): add tests for getLegalNotice 2026-08-06 21:01:18 +02:00
jakob.scheidandGitea b64807f25c feat(legal): add legal notice loading indicator 2026-08-06 21:01:18 +02:00
jakob.scheidandGitea 9fa3f5df50 feat(legal): add legal notice error handling
The legal notice view now shows an error box if the legal notice could
not be loaded.
2026-08-06 21:01:18 +02:00
jakob.scheidandGitea e363ce090d fix(style): set link color to --dark variable
This change was necessary due to the dynamically loaded legal notice.
Links would have the default color without this fix.
2026-08-06 21:01:18 +02:00
jakob.scheidandGitea 320393aa21 feat(legal): load and display legal notice 2026-08-06 21:01:18 +02:00
jakob.scheidandGitea 7a67677a99 feat(legal): add legal notice link in the footer
Added a link to the legal notice in the footer and revised the layout of
the last footer segment: The segment is now a grid container and the
legal links (currently only the legal notice link) are displayed at the
end. The copyright notice remains centered. On small screens, the legal
links are displayed centered below the copyright notice.
2026-08-06 21:01:18 +02:00
jakob.scheidandGitea ee242fe307 feat(legal): add route for the legal notice 2026-08-06 21:01:18 +02:00
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
jakob.scheidandGitea a01abe67e1 fix(color-scheme): load color scheme initially
The color scheme is now loaded and set after registering the watcher to
ensure that the callback is also called initially.
2026-08-06 12:48:55 +02:00
jakob.scheidandGitea 3eaf81a465 Merge pull request 'Feat(footer): align footer segment items vertically centered' (#172) from fix/footer-segments-not-vertically-centered into main
Deploy on dev / Deploy on dev (push) Successful in 35s
Reviewed-on: #172
Reviewed-by: Jakob Gregory
2026-08-06 12:48:34 +02:00
jakob.scheidandGitea 2addd779a7 feat(footer): align footer segment items vertically centered 2026-08-06 12:48:18 +02:00
jakob.scheidandGitea 6afbf3efed Merge pull request 'Fix(settings): switch setting default value as store value fallback' (#176) from fix/switch-setting-default-value into main
Deploy on dev / Deploy on dev (push) Failing after 38s
Reviewed-on: #176
Reviewed-by: Jakob Gregory
2026-08-06 12:48:12 +02:00
jakob.scheidandGitea dca7f2176b test(settings): add tests for switch
Added test cases for the switch to test the default value functionality.
2026-08-06 12:48:00 +02:00
jakob.scheidandGitea 60d3296816 fix(settings): use switch default value as store value fallback
The switch now uses the default value as a fallback for the stored value
and updates the switch state when stored value changes.
2026-08-06 12:48:00 +02:00
jakob.scheidandGitea b0642cc826 Merge pull request 'Fix(sidebar): hide sidebar instead of removing it when it is not expanded' (#174) from fix/settings-view into main
Deploy on dev / Deploy on dev (push) Successful in 35s
Reviewed-on: #174
Reviewed-by: Jakob Gregory
2026-08-06 12:47:40 +02:00
jakob.scheidandGitea 32555d9acb fix(sidebar): hide sidebar instead of removing it
Previously, the sidebar was fully removed from DOM. This could cause
unwanted behavior, such as tests which test its content failing. Now, it
is just hidden. This does not affect tests any more.
2026-08-05 20:46:35 +02:00
jakob.scheidandGitea d1e95582a8 Merge pull request 'Fix(sidebar): hide sidebar border on small screens' (#173) from fix/sidebar-border-on-small-screens into main
Deploy on dev / Deploy on dev (push) Successful in 40s
Reviewed-on: #173
Reviewed-by: Jakob Gregory
2026-08-05 20:44:38 +02:00
jakob.scheid e5f27041d2 fix(sidebar): hide border on small screens 2026-08-05 19:33:34 +02:00
16 changed files with 465 additions and 12 deletions
+2 -1
View File
@@ -26,7 +26,7 @@ import { useRoute } from 'vue-router';
const route = useRoute();
const { getColorScheme, updateColorScheme } = useColorScheme();
const colorScheme = ref(getColorScheme());
const colorScheme = ref(null);
provide('colorScheme', colorScheme);
watch(colorScheme, (newValue) => {
updateColorScheme(newValue);
@@ -46,6 +46,7 @@ watch(colorScheme, (newValue) => {
document.body.classList.remove('color-scheme-auto');
}
});
colorScheme.value = getColorScheme();
watchEffect(() => updatePageTitle(route));
</script>
+42 -2
View File
@@ -40,8 +40,20 @@ const copyrightPeriod =
</RouterLink>
<LanguageSwitchButton />
</div>
<div class="footer-segment">
&copy; {{ copyrightPeriod }} Seekra
<div class="footer-segment legal-segment">
<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>
</footer>
</template>
@@ -54,6 +66,7 @@ const copyrightPeriod =
.footer-segment {
display: flex;
justify-content: center;
align-items: center;
gap: 32px;
padding: var(--padding-y);
background-color: var(--light-bg);
@@ -63,4 +76,31 @@ 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>
+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);
});
});
+11 -3
View File
@@ -16,7 +16,7 @@ limitations under the License.
<script setup>
import { useSettingsStore } from '../stores/settingsStore';
import { useId } from 'vue';
import { ref, useId, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
@@ -35,8 +35,16 @@ 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 () {
store.set(props.path, !store.get(props.path));
enabled.value = !enabled.value;
store.set(props.path, enabled.value);
};
</script>
@@ -47,7 +55,7 @@ const toggle = function toggle () {
</label>
<div
class="switch-wrapper"
:class="{ enabled: store.get(props.path) }"
:class="{ enabled }"
:title="t('settings.switch.title')"
:aria-label="t('settings.switch.ariaLabel')"
:aria-labelledby="labelId"
@@ -20,17 +20,23 @@ import { useSettingsStore } from '../../stores/settingsStore.js';
import { expect, describe, test } from 'vitest';
import { nextTick } from 'vue';
const getWrapper = function getWrapper ({ i18n = 'switch1', translations = {} } = {}) {
const getWrapper = function getWrapper ({
i18n = 'switch1',
defaultValue = undefined,
translations = {},
piniaOptions = {}
} = {}) {
return mountComponent(Switch, {
attrs: {
setting: {
type: 'bool',
name: 'switch',
i18n
i18n,
default: defaultValue
},
path: 'switch'
}
}, translations);
}, translations, [], piniaOptions);
};
describe('Switch', () => {
@@ -53,6 +59,42 @@ 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();
@@ -40,5 +40,15 @@ export const useSettings = function useSettings () {
);
};
return { getSetting };
/**
* 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 };
};
+5 -1
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-if="props.expanded">
<div class="sidebar-content" v-show="props.expanded">
<slot />
</div>
</nav>
@@ -61,6 +61,10 @@ const toggleSidebar = function toggleSidebar () {
}
@media (max-width: 48rem) {
.sidebar {
border: none;
}
.sidebar:not(.expanded) .sidebar-expand-button {
display: none;
}
+6
View File
@@ -61,5 +61,11 @@
"ariaLabel": "Wählt die Option {option} aus"
}
}
},
"legal": {
"notice": {
"title": "Impressum",
"loadError": "Das Impressum konnte nicht geladen werden."
}
}
}
+6
View File
@@ -61,5 +61,11 @@
"ariaLabel": "Selects option {option}"
}
}
},
"legal": {
"notice": {
"title": "Legal Notice",
"loadError": "The legal notice could not be loaded."
}
}
}
+9
View File
@@ -18,6 +18,7 @@ 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';
@@ -39,6 +40,14 @@ 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',
+9
View File
@@ -24,6 +24,7 @@ body {
.link {
text-decoration: none;
color: var(--dark);
}
.link:hover:not(.button-link), .link:focus-visible:not(.button-link) {
@@ -92,3 +93,11 @@ input {
* {
-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);
--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) {