Author SHA1 Message Date
jakob.scheid 93ac3e4a79 test(legal): add test for the legal notice view
Added tests for the legal notice view (LegalNoticeView.vue).
2026-08-05 18:57:02 +02:00
jakob.scheid 69eba3e570 test(legal): add tests for getLegalNotice 2026-08-05 18:57:02 +02:00
jakob.scheid 11f941aced feat(legal): add legal notice loading indicator 2026-08-05 18:57:02 +02:00
jakob.scheid ba56cfe494 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-05 18:57:01 +02:00
jakob.scheid 83c0524329 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-05 18:57:01 +02:00
jakob.scheid 031444c12e feat(legal): load and display legal notice 2026-08-05 18:57:01 +02:00
jakob.scheid 558387695e 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-05 18:57:00 +02:00
jakob.scheid 8dad3a3c2b feat(legal): add route for the legal notice 2026-08-05 18:56:56 +02:00
jakob.scheidandGitea 26535cad45 feat(fonts): use Inter 2026-08-04 19:41:08 +02:00
jakob.scheidandGitea 5fdf0490b6 feat(fonts): load all fonts specified in font configuration 2026-08-04 19:41:08 +02:00
jakob.scheidandGitea 61817bf4fc feat(assets): add assets environment variables
Added the environment variables VITE_ASSETS_BUILD_ID for the ID of the
used build and VITE_ASSETS_BASE_URL for the base URL of the assets.
A script for reading this configuration was also added.
2026-08-04 19:41:08 +02:00
jakob.scheidandGitea 0f71f1240e Merge pull request 'Temporarily disable the default tap highlight' (#160) from fix/temporarily-disable-default-tap-highlight into main
Deploy on dev / Deploy on dev (push) Successful in 37s
Reviewed-on: #160
Reviewed-by: Jakob Scheid
2026-08-04 12:59:25 +02:00
jakob.gregoryandGitea d67c8aa80e Temporarily disable the default tap highlight
This is a temporary change until a custom tap highlight effect is implemented.
2026-08-04 12:00:00 +02:00
jakob.scheidandGitea 44c457ba20 Merge pull request 'Feat(sidebar): allow left sidebar layout content to toggle the sidebar' (#159) from feature/left-sidebar-layout-content-sidebar-toggling into main
Deploy on dev / Deploy on dev (push) Successful in 41s
Reviewed-on: #159
Reviewed-by: Johannes Vos
2026-08-03 17:29:23 +02:00
jakob.scheid 4b010d03ac feat(settings): toggle sidebar on small screens when section is selected
The settings view now hides the sidebar on small screens when a settings
section is selected.
2026-08-02 13:58:37 +02:00
jakob.scheid 813cc7c7fc feat(sidebar): allow left sidebar layout content to toggle sidebar 2026-08-02 13:57:11 +02:00
jakob.scheid a77bbec3fa refactor(sidebar): rename sidebar toggling function
Renamed the sidebar toggling function in the left sidebar layout to
toggleSidebar.
2026-08-02 13:56:25 +02:00
jakob.scheidandGitea 447207e5f0 Merge pull request 'Feat(sidebar): implement sidebar toggling' (#157) from feature/expandable-sidebar into main
Deploy on dev / Deploy on dev (push) Successful in 39s
Reviewed-on: #157
Reviewed-by: Jakob Gregory
2026-07-31 21:00:19 +02:00
jakob.scheidandGitea 0939379c0a feat(sidebar): decrease horizontal padding when sidebar is not expanded
Set the horizontal padding of the unexpanded sidebar to 1em.
2026-07-31 20:59:19 +02:00
jakob.scheidandGitea 48e979ac64 fix(sidebar): fix main content displaying
Do not show main content on small screens when the sidebar is expanded.
2026-07-31 20:59:19 +02:00
jakob.scheidandGitea 0a58944157 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 20:59:19 +02:00
jakob.scheidandGitea 63d9e31d29 fix(sidebar): do not show sidebar expanding button on small screens when sidebar is not expanded 2026-07-31 20:59:19 +02:00
jakob.scheidandGitea e43784bff0 feat(sidebar): implement sidebar visibility toggling 2026-07-31 20:59:19 +02:00
jakob.scheidandGitea 5b1f2b57a7 feat(sidebar): add sidebar expand button
Added a button to expand the sidebar to the sidebar and reorganized
sidebar elements accordingly.
2026-07-31 20:59:19 +02:00
jakob.scheidandGitea c70e285026 feat(icons): add sidebar expanding icon 2026-07-31 20:59:19 +02:00
jakob.scheidandGitea 8f83efb16b Merge pull request 'Fix(settings): settings validator selection setting default values array handling' (#155) from fix/settings-validator-selection-default-values-array into main
Deploy on dev / Deploy on dev (push) Successful in 36s
Reviewed-on: #155
Reviewed-by: Jakob Gregory
2026-07-31 20:58:29 +02:00
jakob.scheid f41c5c407f test(settings): add settings validator test cases
Added test cases to the settings validator unit test to test the
selection setting default value array handling when allowMultiple is
true.
2026-07-30 10:45:58 +02:00
jakob.scheid 35bfedbd6b fix(settings): expect array default values for multiple choice selections 2026-07-30 10:43:47 +02:00
jakob.scheidandGitea 8f5b9e3601 Merge pull request 'Complete missing settings translations' (#154) from chore/complete-translations into main
Deploy on dev / Deploy on dev (push) Successful in 33s
Reviewed-on: #154
Reviewed-by: Jakob Scheid
2026-07-30 10:06:50 +02:00
jakob.scheid 00caf4d692 style(i18n): update Portuguese translations indentation 2026-07-30 10:04:47 +02:00
jakob.scheid f39a69f76d style(i18n): update Italian translations indentation 2026-07-30 10:04:16 +02:00
jakob.scheid 8697557259 style(i18n): update French translations indentation 2026-07-30 10:03:24 +02:00
jakob.scheid cd17486054 style(i18n): update Spanish translations indentation 2026-07-30 10:02:31 +02:00
jakob.gregoryandGitea f2050ceed0 Delete the unnecessary space in pt.json 2026-07-29 18:45:20 +02:00
jakob.gregoryandGitea 805512c9de Change wrong commit email 2026-07-29 18:42:13 +02:00
jakob.gregoryandGitea 88bda40e87 chore(i18n): complete Portuguese translations 2026-07-29 18:40:12 +02:00
jakob.gregoryandGitea 0aee1ab9a6 chore(i18n): complete Italian translations 2026-07-29 18:39:55 +02:00
jakob.gregoryandGitea d5a8f852b2 chore(i18n): complete French translations 2026-07-29 18:38:59 +02:00
jakob.gregoryandGitea 5ba1045a17 chore(i18n): complete Spanish translations 2026-07-29 18:14:11 +02:00
jakob.scheidandGitea ba1ee27ba2 Merge pull request 'Use :focus-visible instead of :focus' (#153) from refactor/use-focus-visible into main
Deploy on dev / Deploy on dev (push) Successful in 36s
Reviewed-on: #153
Reviewed-by: Jakob Scheid
2026-07-29 17:51:57 +02:00
jakob.gregoryandGitea e6acab73e3 refactor(global): replace :focus with :focus-visible 2026-07-29 17:47:54 +02:00
jakob.scheidandGitea 463fcff615 Merge pull request 'Chore(licenses): license headers in setup scripts' (#147) from chore/setup-scripts-license-headers into main
Deploy on dev / Deploy on dev (push) Successful in 36s
Reviewed-on: #147
Reviewed-by: Jakob Gregory
2026-07-28 21:44:44 +02:00
26 changed files with 855 additions and 23 deletions
+16
View File
@@ -0,0 +1,16 @@
# 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
+38
View File
@@ -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.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path
stroke="currentColor"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
d="
M6 4
h12
a4 4 0 0 1 4 4
v8
a4 4 0 0 1 -4 4
h-12
a4 4 0 0 1 -4 -4
v-8
a4 4 0 0 1 4 -4
m2 0
v16
"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+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 const getAssetsUrl = function getAssetsUrl () {
return new URL(
`${import.meta.env.VITE_ASSETS_BUILD_ID}/`,
import.meta.env.VITE_ASSETS_BASE_URL
).href;
};
+40 -1
View File
@@ -40,9 +40,21 @@ const copyrightPeriod =
</RouterLink> </RouterLink>
<LanguageSwitchButton /> <LanguageSwitchButton />
</div> </div>
<div class="footer-segment"> <div class="footer-segment legal-segment">
<div></div>
<div class="copyright-notice">
&copy; {{ copyrightPeriod }} Seekra &copy; {{ copyrightPeriod }} Seekra
</div> </div>
<div class="legal-links">
<RouterLink
:to="{ name: 'legalNotice' }"
class="link"
:title="t('legal.notice.title')"
>
{{ t('legal.notice.title') }}
</RouterLink>
</div>
</div>
</footer> </footer>
</template> </template>
@@ -63,4 +75,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);
});
});
@@ -557,6 +557,30 @@ describe('settingsValidator', () => {
{ name: 'maybe', i18n: 'selectSomething.options.maybe' } { 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: '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 } { name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: true }
@@ -616,6 +640,34 @@ describe('settingsValidator', () => {
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', default: 'a', options: true }], [{ 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: '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: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', default: 'c', options: [
{ name: 'a', i18n: 'a' }, { name: 'a', i18n: 'a' },
{ name: 'b', i18n: 'b' } { name: 'b', i18n: 'b' }
@@ -73,12 +73,27 @@ export const validateEntry = function validateEntry (entry, path) {
throw new Error(`[settings] "${path}.default" must be a string`); throw new Error(`[settings] "${path}.default" must be a string`);
} }
if (entry.type === 'selection') { 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') { if (typeof entry.default !== 'string') {
throw new Error(`[settings] "${path}.default" must be a string`); throw new Error(`[settings] "${path}.default" must be a string`);
}; };
if (!entry.options.map((option) => option.name).includes(entry.default)) { if (!entry.options.map((option) => option.name).includes(entry.default)) {
throw new Error(`[settings] option "${path}.default" does not exist`); throw new Error(`[settings] option "${path}.default" does not exist`);
}; };
}
}; };
} }
} }
+6 -1
View File
@@ -61,6 +61,10 @@ onMounted(async () => {
}); });
settingsLoaded.value = true; settingsLoaded.value = true;
}); });
const toggleSidebarIfSmallScreen = function toggleSidebarIfSmallScreen (toggleSidebarFunction) {
if (matchMedia('(max-width: 48rem)').matches) toggleSidebarFunction();
};
</script> </script>
<template> <template>
@@ -71,13 +75,14 @@ onMounted(async () => {
</h1> </h1>
</header> </header>
<LeftSidebarLayout class="layout"> <LeftSidebarLayout class="layout">
<template #sidebar> <template #sidebar="{ toggleSidebar }">
<ul class="sidebar-sections-list"> <ul class="sidebar-sections-list">
<li v-for="section in settings"> <li v-for="section in settings">
<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 === section.name }"
@click="toggleSidebarIfSmallScreen(toggleSidebar)"
> >
{{ t(section.i18n) }} {{ t(section.i18n) }}
</RouterLink> </RouterLink>
+46 -1
View File
@@ -14,15 +14,60 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
--> -->
<script setup>
import SidebarExpandButton from './SidebarExpandButton.vue';
const props = defineProps({
'expanded': {
default: false,
required: false,
type: Boolean
}
});
const emit = defineEmits(['toggleExpanded']);
const toggleSidebar = function toggleSidebar () {
emit('toggleExpanded');
};
</script>
<template> <template>
<nav class="sidebar"> <nav class="sidebar" :class="{ expanded }">
<div class="sidebar-controls">
<SidebarExpandButton @click="toggleSidebar" class="sidebar-expand-button" />
</div>
<div class="sidebar-content" v-if="props.expanded">
<slot /> <slot />
</div>
</nav> </nav>
</template> </template>
<style scoped> <style scoped>
.sidebar { .sidebar {
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
}
.sidebar-controls {
display: flex;
align-items: center;
justify-content: end;
padding: 1em 20px;
padding-bottom: 0;
margin-bottom: 8px;
}
.sidebar:not(.expanded) .sidebar-controls {
padding: 1em;
}
@media (max-width: 48rem) {
.sidebar:not(.expanded) .sidebar-expand-button {
display: none;
}
}
.sidebar-content {
padding: 20px; padding: 20px;
padding-top: 0;
} }
</style> </style>
@@ -0,0 +1,41 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import Icon from '@/features/icons/components/Icon.vue';
const emit = defineEmits(['click']);
const click = function click (event) {
emit('click', event);
};
</script>
<template>
<button class="button sidebar-expand-button">
<Icon name="sidebar-expand" @click="click" />
</button>
</template>
<style scoped>
.sidebar-expand-button {
display: flex;
justify-content: center;
align-items: center;
padding: 4px;
border-radius: 10px;
}
</style>
+66 -5
View File
@@ -16,15 +16,34 @@ limitations under the License.
<script setup> <script setup>
import Sidebar from '@/features/sidebar/components/Sidebar.vue'; import Sidebar from '@/features/sidebar/components/Sidebar.vue';
import SidebarExpandButton from '@/features/sidebar/components/SidebarExpandButton.vue';
import { ref } from 'vue';
const props = defineProps({
'expanded': {
default: false,
required: false,
type: Boolean
}
});
const expanded = ref(props.expanded);
const toggleSidebar = function toggleSidebar () {
expanded.value = !expanded.value;
};
</script> </script>
<template> <template>
<div class="layout-container"> <div class="layout-container" :class="{ expanded }">
<Sidebar> <div class="sidebar-expand-button-container">
<slot name="sidebar" /> <SidebarExpandButton class="sidebar-expand-button" @click="toggleSidebar" />
</div>
<Sidebar @toggle-expanded="toggleSidebar" :expanded="expanded" class="sidebar">
<slot name="sidebar" :toggleSidebar="toggleSidebar" />
</Sidebar> </Sidebar>
<main class="main-content"> <main class="main-content">
<slot /> <slot :toggleSidebar="toggleSidebar" />
</main> </main>
</div> </div>
</template> </template>
@@ -32,11 +51,53 @@ import Sidebar from '@/features/sidebar/components/Sidebar.vue';
<style scoped> <style scoped>
.layout-container { .layout-container {
display: grid; display: grid;
grid-template-columns: min(24%, 280px) 1fr; grid-template-columns: calc(32px + 2em) 1fr;
grid-template-rows: auto 1fr;
grid-template-areas:
"sidebar-expand sidebar-expand"
"sidebar main-content";
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
.layout-container.expanded {
grid-template-columns: min(24%, 280px) 1fr;
}
.sidebar-expand-button-container {
grid-area: sidebar-expand;
display: none;
}
.sidebar {
grid-area: sidebar;
}
@media (max-width: 48rem) {
.layout-container {
grid-template-columns: 0 1fr;
}
.layout-container.expanded {
grid-template-columns: 1fr 0;
}
.layout-container:not(.expanded) .sidebar-expand-button-container {
justify-content: end;
display: block;
padding: 20px;
}
.layout-container:not(.expanded) .main-content {
padding-top: 0;
}
.layout-container.expanded .main-content {
display: none;
}
}
.main-content { .main-content {
grid-area: main-content;
padding: 20px; padding: 20px;
} }
</style> </style>
+6
View File
@@ -61,5 +61,11 @@
"ariaLabel": "Wählt die Option {option} aus" "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}" "ariaLabel": "Selects option {option}"
} }
} }
},
"legal": {
"notice": {
"title": "Legal Notice",
"loadError": "The legal notice could not be loaded."
}
} }
} }
+24 -1
View File
@@ -38,5 +38,28 @@
} }
} }
}, },
"slogan": "Hecho para buscar." "slogan": "Hecho para buscar.",
"settings": {
"input": {
"done": {
"title": "Guardar valor",
"ariaLabel": "Guarda el valor introducido"
}
},
"switch": {
"title": "Cambiar valor",
"ariaLabel": "Cambia el valor"
},
"pages": {
"link": {
"ariaLabel": "Va a la página de configuración {page}"
}
},
"noSectionSelected": "No se ha seleccionado ninguna sección de configuración.",
"selection": {
"option": {
"ariaLabel": "Selecciona la opción {option}"
}
}
}
} }
+24 -1
View File
@@ -38,5 +38,28 @@
} }
} }
}, },
"slogan": "Conçu pour chercher." "slogan": "Conçu pour chercher.",
"settings": {
"input": {
"done": {
"title": "Enregistrer la valeur",
"ariaLabel": "Enregistre la valeur saisie"
}
},
"switch": {
"title": "Basculer la valeur",
"ariaLabel": "Bascule la valeur"
},
"pages": {
"link": {
"ariaLabel": "Accéder à la page de paramètres {page}"
}
},
"noSectionSelected": "Aucune section des paramètres n'est sélectionnée.",
"selection": {
"option": {
"ariaLabel": "Sélectionne l'option {option}"
}
}
}
} }
+24 -1
View File
@@ -38,5 +38,28 @@
} }
} }
}, },
"slogan": "Costruito per cercare." "slogan": "Costruito per cercare.",
"settings": {
"input": {
"done": {
"title": "Salva valore",
"ariaLabel": "Salva il valore inserito"
}
},
"switch": {
"title": "Attiva/disattiva valore",
"ariaLabel": "Attiva o disattiva il valore"
},
"pages": {
"link": {
"ariaLabel": "Vai alla pagina delle impostazioni {page}"
}
},
"noSectionSelected": "Nessuna sezione delle impostazioni è selezionata.",
"selection": {
"option": {
"ariaLabel": "Seleziona l'opzione {option}"
}
}
}
} }
+24 -1
View File
@@ -38,5 +38,28 @@
} }
} }
}, },
"slogan": "Feito para pesquisar." "slogan": "Feito para pesquisar.",
"settings": {
"input": {
"done": {
"title": "Guardar valor",
"ariaLabel": "Guarda o valor introduzido"
}
},
"switch": {
"title": "Alternar valor",
"ariaLabel": "Alterna o valor"
},
"pages": {
"link": {
"ariaLabel": "Ir para a página de definições {page}"
}
},
"noSectionSelected": "Nenhuma secção de definições está selecionada.",
"selection": {
"option": {
"ariaLabel": "Seleciona a opção {option}"
}
}
}
} }
+20
View File
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { getAssetsUrl } from './config/env';
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'; import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
@@ -25,10 +26,29 @@ import './styles/common.css'
import './styles/variables/colors.css' import './styles/variables/colors.css'
import './styles/variables/dimensions.css'; import './styles/variables/dimensions.css';
import rawFonts from './styles/fonts.json';
import './styles/common.css'; import './styles/common.css';
import './styles/variables/colors.css'; import './styles/variables/colors.css';
(async () => { (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(fallbackLocale);
await loadLanguage(getCurrentLanguage()); await loadLanguage(getCurrentLanguage());
+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',
+17 -2
View File
@@ -19,13 +19,15 @@ body {
color: var(--dark); color: var(--dark);
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
} }
.link { .link {
text-decoration: none; text-decoration: none;
color: var(--dark);
} }
.link:hover:not(.button-link), .link:focus:not(.button-link) { .link:hover:not(.button-link), .link:focus-visible:not(.button-link) {
outline: none; outline: none;
text-decoration: underline; text-decoration: underline;
} }
@@ -78,7 +80,7 @@ input {
color: inherit; color: inherit;
} }
.button:focus { .button:focus-visible {
background-color: var(--light-hover); background-color: var(--light-hover);
outline: none; outline: none;
} }
@@ -86,3 +88,16 @@ input {
.button:hover { .button:hover {
background-color: var(--light-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
@@ -0,0 +1,32 @@
[
{
"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,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) {