generated from Seekra/repository-template
Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afd0c47b63
|
||
|
|
fbb8e028fb
|
||
|
|
6a0c41bb0b
|
||
|
|
378f19d3b7
|
||
|
|
64f4116820
|
||
|
|
8f47a6dde0
|
||
|
|
9e0b47b672
|
||
|
|
aecd23ddf9
|
||
|
|
de5bee2c4a
|
||
|
|
baa924a2b2
|
||
|
|
26535cad45
|
||
|
|
5fdf0490b6
|
||
|
|
61817bf4fc
|
||
|
|
0f71f1240e
|
||
|
|
d67c8aa80e
|
||
|
|
44c457ba20
|
||
|
|
4b010d03ac
|
||
|
|
813cc7c7fc
|
||
|
|
a77bbec3fa
|
||
|
|
447207e5f0
|
||
|
|
0939379c0a
|
||
|
|
48e979ac64
|
||
|
|
0a58944157
|
||
|
|
63d9e31d29
|
||
|
|
e43784bff0
|
||
|
|
5b1f2b57a7
|
||
|
|
c70e285026
|
||
|
|
8f83efb16b
|
||
|
|
f41c5c407f
|
||
|
|
35bfedbd6b
|
||
|
|
8f5b9e3601
|
||
|
|
00caf4d692
|
||
|
|
f39a69f76d
|
||
|
|
8697557259
|
||
|
|
cd17486054
|
||
|
|
f2050ceed0
|
||
|
|
805512c9de
|
||
|
|
88bda40e87
|
||
|
|
0aee1ab9a6
|
||
|
|
d5a8f852b2
|
||
|
|
5ba1045a17
|
||
|
|
ba1ee27ba2
|
||
|
|
e6acab73e3
|
||
|
|
463fcff615
|
@@ -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
|
||||
@@ -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 |
@@ -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,8 +40,20 @@ const copyrightPeriod =
|
||||
</RouterLink>
|
||||
<LanguageSwitchButton />
|
||||
</div>
|
||||
<div class="footer-segment">
|
||||
© {{ copyrightPeriod }} Seekra
|
||||
<div class="footer-segment legal-segment">
|
||||
<div></div>
|
||||
<div class="copyright-notice">
|
||||
© {{ 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>
|
||||
@@ -63,4 +75,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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
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 { useSettingsStore } from '../stores/settingsStore';
|
||||
import { getSettingRecursively } from '../utils/getSetting';
|
||||
import { loadSettingsConfig } from '../utils/settingsParser';
|
||||
|
||||
/**
|
||||
* 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 = async function getSetting (key) {
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const settings = await loadSettingsConfig();
|
||||
return (
|
||||
settingsStore.get(key) ??
|
||||
getSettingRecursively(
|
||||
key, settings.contents ?? []
|
||||
)?.default
|
||||
);
|
||||
};
|
||||
|
||||
return { getSetting };
|
||||
};
|
||||
@@ -557,6 +557,30 @@ describe('settingsValidator', () => {
|
||||
{ name: 'maybe', i18n: 'selectSomething.options.maybe' }
|
||||
] }],
|
||||
|
||||
[{ name: 'selectMultiple', type: 'selection', i18n: 'selectMultiple.title', default: ['1', '2', '3'], allowMultiple: true, options: [
|
||||
{ name: '1', i18n: 'selectMultiple.options.1' },
|
||||
{ name: '2', i18n: 'selectMultiple.options.2' },
|
||||
{ name: '3', i18n: 'selectMultiple.options.3' }
|
||||
] }],
|
||||
|
||||
[{ name: 'selectMultiple', type: 'selection', i18n: 'selectMultiple.title', default: ['2', '3'], allowMultiple: true, options: [
|
||||
{ name: '1', i18n: 'selectMultiple.options.1' },
|
||||
{ name: '2', i18n: 'selectMultiple.options.2' },
|
||||
{ name: '3', i18n: 'selectMultiple.options.3' }
|
||||
] }],
|
||||
|
||||
[{ name: 'selectMultiple', type: 'selection', i18n: 'selectMultiple.title', default: ['2'], allowMultiple: true, options: [
|
||||
{ name: '1', i18n: 'selectMultiple.options.1' },
|
||||
{ name: '2', i18n: 'selectMultiple.options.2' },
|
||||
{ name: '3', i18n: 'selectMultiple.options.3' }
|
||||
] }],
|
||||
|
||||
[{ name: 'selectMultiple', type: 'selection', i18n: 'selectMultiple.title', default: [], allowMultiple: true, options: [
|
||||
{ name: '1', i18n: 'selectMultiple.options.1' },
|
||||
{ name: '2', i18n: 'selectMultiple.options.2' },
|
||||
{ name: '3', i18n: 'selectMultiple.options.3' }
|
||||
] }],
|
||||
|
||||
[{ name: 'aSection', type: 'section', i18n: 'sections.aSection.title', content: [] }],
|
||||
[{ name: 'aSection', type: 'section', i18n: 'sections.aSection.title', content: [
|
||||
{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: true }
|
||||
@@ -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: [] }],
|
||||
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', default: ['a'], options: [
|
||||
{ name: 'a', i18n: 'a' },
|
||||
{ name: 'b', i18n: 'b' }
|
||||
] }],
|
||||
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', default: ['a', 'b'], options: [
|
||||
{ name: 'a', i18n: 'a' },
|
||||
{ name: 'b', i18n: 'b' }
|
||||
] }],
|
||||
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', allowMultiple: true, default: 'a', options: [
|
||||
{ name: 'a', i18n: 'a' },
|
||||
{ name: 'b', i18n: 'b' }
|
||||
] }],
|
||||
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', allowMultiple: true, default: 'a', options: [
|
||||
{ name: 'a', i18n: 'a' },
|
||||
{ name: 'b', i18n: 'b' }
|
||||
] }],
|
||||
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', allowMultiple: true, default: ['c'], options: [
|
||||
{ name: 'a', i18n: 'a' },
|
||||
{ name: 'b', i18n: 'b' }
|
||||
] }],
|
||||
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', allowMultiple: true, default: [1], options: [
|
||||
{ name: 'a', i18n: 'a' },
|
||||
{ name: 'b', i18n: 'b' }
|
||||
] }],
|
||||
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', allowMultiple: true, default: ['a', 1], options: [
|
||||
{ name: 'a', i18n: 'a' },
|
||||
{ name: 'b', i18n: 'b' }
|
||||
] }],
|
||||
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', default: 'c', options: [
|
||||
{ name: 'a', i18n: 'a' },
|
||||
{ name: 'b', i18n: 'b' }
|
||||
|
||||
@@ -73,12 +73,27 @@ export const validateEntry = function validateEntry (entry, path) {
|
||||
throw new Error(`[settings] "${path}.default" must be a string`);
|
||||
}
|
||||
if (entry.type === 'selection') {
|
||||
if (typeof entry.default !== 'string') {
|
||||
throw new Error(`[settings] "${path}.default" must be a string`);
|
||||
};
|
||||
if (!entry.options.map((option) => option.name).includes(entry.default)) {
|
||||
throw new Error(`[settings] option "${path}.default" does not exist`);
|
||||
};
|
||||
if (entry.allowMultiple) {
|
||||
if (!Array.isArray(entry.default)) {
|
||||
throw new Error(`[settings] "${path}.default" must be an array`);
|
||||
}
|
||||
const allOptions = entry.options.map((option) => option.name);
|
||||
entry.default.forEach((defaultValue, index) => {
|
||||
if (typeof defaultValue !== 'string') {
|
||||
throw new Error(`[settings] "${path}.default[${index}]" must be a string`);
|
||||
}
|
||||
if (!allOptions.includes(defaultValue)) {
|
||||
throw new Error(`[settings] option "${path}.default[${index}]" does not exist`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (typeof entry.default !== 'string') {
|
||||
throw new Error(`[settings] "${path}.default" must be a string`);
|
||||
};
|
||||
if (!entry.options.map((option) => option.name).includes(entry.default)) {
|
||||
throw new Error(`[settings] option "${path}.default" does not exist`);
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,10 @@ onMounted(async () => {
|
||||
});
|
||||
settingsLoaded.value = true;
|
||||
});
|
||||
|
||||
const toggleSidebarIfSmallScreen = function toggleSidebarIfSmallScreen (toggleSidebarFunction) {
|
||||
if (matchMedia('(max-width: 48rem)').matches) toggleSidebarFunction();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -71,13 +75,14 @@ onMounted(async () => {
|
||||
</h1>
|
||||
</header>
|
||||
<LeftSidebarLayout class="layout">
|
||||
<template #sidebar>
|
||||
<template #sidebar="{ toggleSidebar }">
|
||||
<ul class="sidebar-sections-list">
|
||||
<li v-for="section in settings">
|
||||
<RouterLink
|
||||
:to="`/settings/${section.name}`"
|
||||
class="button button-link link sidebar-section"
|
||||
:class="{ active: activeSection === section.name }"
|
||||
@click="toggleSidebarIfSmallScreen(toggleSidebar)"
|
||||
>
|
||||
{{ t(section.i18n) }}
|
||||
</RouterLink>
|
||||
|
||||
@@ -14,15 +14,60 @@ See the License for the specific language governing permissions and
|
||||
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>
|
||||
<nav class="sidebar">
|
||||
<slot />
|
||||
<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 />
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
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-top: 0;
|
||||
}
|
||||
</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>
|
||||
@@ -16,15 +16,34 @@ limitations under the License.
|
||||
|
||||
<script setup>
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="layout-container">
|
||||
<Sidebar>
|
||||
<slot name="sidebar" />
|
||||
<div class="layout-container" :class="{ expanded }">
|
||||
<div class="sidebar-expand-button-container">
|
||||
<SidebarExpandButton class="sidebar-expand-button" @click="toggleSidebar" />
|
||||
</div>
|
||||
<Sidebar @toggle-expanded="toggleSidebar" :expanded="expanded" class="sidebar">
|
||||
<slot name="sidebar" :toggleSidebar="toggleSidebar" />
|
||||
</Sidebar>
|
||||
<main class="main-content">
|
||||
<slot />
|
||||
<slot :toggleSidebar="toggleSidebar" />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -32,11 +51,53 @@ import Sidebar from '@/features/sidebar/components/Sidebar.vue';
|
||||
<style scoped>
|
||||
.layout-container {
|
||||
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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
grid-area: main-content;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -61,5 +61,11 @@
|
||||
"ariaLabel": "Wählt die Option {option} aus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"legal": {
|
||||
"notice": {
|
||||
"title": "Impressum",
|
||||
"loadError": "Das Impressum konnte nicht geladen werden."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,5 +61,11 @@
|
||||
"ariaLabel": "Selects option {option}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"legal": {
|
||||
"notice": {
|
||||
"title": "Legal Notice",
|
||||
"loadError": "The legal notice could not be loaded."
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { getAssetsUrl } from './config/env';
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia';
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
|
||||
@@ -25,10 +26,29 @@ import './styles/common.css'
|
||||
import './styles/variables/colors.css'
|
||||
import './styles/variables/dimensions.css';
|
||||
|
||||
import rawFonts from './styles/fonts.json';
|
||||
import './styles/common.css';
|
||||
import './styles/variables/colors.css';
|
||||
|
||||
(async () => {
|
||||
const fonts = rawFonts.map((font) => new FontFace(
|
||||
font.family ?? '',
|
||||
Array.isArray(font.source)
|
||||
? font.source.map(
|
||||
(source) => `url('${source.url
|
||||
? new URL(source.url, getAssetsUrl()).href
|
||||
: ''
|
||||
}') format('${source.format ?? ''}')`
|
||||
).join(',')
|
||||
: '',
|
||||
{
|
||||
weight: font.weight ?? undefined,
|
||||
style: font.style ?? undefined
|
||||
}
|
||||
));
|
||||
await Promise.all(fonts.map((font) => font.load()));
|
||||
fonts.forEach((font) => document.fonts.add(font));
|
||||
|
||||
await loadLanguage(fallbackLocale);
|
||||
await loadLanguage(getCurrentLanguage());
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
+18
-3
@@ -19,13 +19,15 @@ body {
|
||||
color: var(--dark);
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
.link {
|
||||
text-decoration: none;
|
||||
color: var(--dark);
|
||||
}
|
||||
|
||||
.link:hover:not(.button-link), .link:focus:not(.button-link) {
|
||||
.link:hover:not(.button-link), .link:focus-visible:not(.button-link) {
|
||||
outline: none;
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -78,11 +80,24 @@ input {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.button:focus {
|
||||
.button:focus-visible {
|
||||
background-color: var(--light-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background-color: var(--light-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* Temporary until a custom tap highlight is implemented. */
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: var(--color-negative-bg);
|
||||
border: 1px solid var(--color-negative-border);
|
||||
padding: 1em 2em;
|
||||
border-radius: 1.2em;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user