110 changed files with 2983 additions and 26363 deletions
-16
View File
@@ -1,16 +0,0 @@
# Copyright 2026 Seekra
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
VITE_ASSETS_BUILD_ID=2026-08-04-76a067e
VITE_ASSETS_BASE_URL=https://assets.seekra.jcloud-services.ddns.net
-62
View File
@@ -1,62 +0,0 @@
# Copyright 2026 Seekra
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: Deploy on production
on:
release:
types:
- published
permissions:
contents: read
jobs:
deploy:
if: ${{ !github.event.release.prerelease && !github.event.release.draft }}
name: Deploy on production
runs-on: node-minio
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
set -euo pipefail
npm install
- name: Build
run: |
set -euo pipefail
npm run build
- name: Set alias
run: mc alias set minio ${{ vars.S3_PROD_URL }} ${{ secrets.S3_PROD_ACCESS_KEY }} ${{ secrets.S3_PROD_SECRET_KEY }}
- name: Upload build artifacts to S3
run: |
set -euo pipefail
BUCKET=${{ vars.S3_PROD_BUCKET_NAME }}
BUILD_ID=$(date +%s)-$(git rev-parse --short HEAD)
echo "Build ID: $BUILD_ID"
mc cp --recursive ./dist/ "minio/$BUCKET/builds/$BUILD_ID/"
echo "Copied files"
echo "$BUILD_ID" | mc pipe "minio/$BUCKET/current"
echo "Updated current build pointer"
-62
View File
@@ -1,62 +0,0 @@
# Copyright 2026 Seekra
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: Deploy on staging
on:
release:
types:
- published
permissions:
contents: read
jobs:
deploy:
if: ${{ github.event.release.prerelease && !github.event.release.draft }}
name: Deploy on staging
runs-on: node-minio
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
set -euo pipefail
npm install
- name: Build
run: |
set -euo pipefail
npm run build
- name: Set alias
run: mc alias set minio ${{ vars.S3_STAGING_URL }} ${{ secrets.S3_STAGING_ACCESS_KEY }} ${{ secrets.S3_STAGING_SECRET_KEY }}
- name: Upload build artifacts to S3
run: |
set -euo pipefail
BUCKET=${{ vars.S3_STAGING_BUCKET_NAME }}
BUILD_ID=$(date +%s)-$(git rev-parse --short HEAD)
echo "Build ID: $BUILD_ID"
mc cp --recursive ./dist/ "minio/$BUCKET/builds/$BUILD_ID/"
echo "Copied files"
echo "$BUILD_ID" | mc pipe "minio/$BUCKET/current"
echo "Updated current build pointer"
-8
View File
@@ -1,8 +0,0 @@
# ------------------------------------------------ SUBJECT AREA
# ---------------------------------------------------------------------- DETAIL AREA
# Please enter a subject that is not longer than 50 characters.
# If you enter a detailed description, each line should not be longer
# than 72 characters. Break the line as soon as you reach this limit.
-3
View File
@@ -1,3 +0,0 @@
.gitea/
.github/
eslint.config.js
-4
View File
@@ -1,4 +0,0 @@
{
"singleQuote": true,
"tabWidth": 4
}
-8
View File
@@ -1,11 +1,3 @@
# frontend # frontend
The frontend of the search engine Seekra. The frontend of the search engine Seekra.
## Setup
Please execute the setup script in `scripts/` for your platform when cloning the repository.
## Legal
After each update to the dependencies, please run the script `scripts/generate-license-files.js` to regenerate the third-party license files.
+10 -182
View File
@@ -213,30 +213,6 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
----------- -----------
The following npm package may be included in this product:
- rfdc@1.4.1
This package contains the following license:
Copyright 2019 "David Mark Clements <david.mark.clements@gmail.com>"
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and
to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
-----------
The following npm package may be included in this product: The following npm package may be included in this product:
- @jridgewell/resolve-uri@3.1.2 - @jridgewell/resolve-uri@3.1.2
@@ -494,7 +470,7 @@ The following npm packages may be included in this product:
- @babel/generator@7.29.1 - @babel/generator@7.29.1
- @babel/helper-string-parser@7.27.1 - @babel/helper-string-parser@7.27.1
- @babel/helper-validator-identifier@7.29.7 - @babel/helper-validator-identifier@7.28.5
- @babel/types@7.29.0 - @babel/types@7.29.0
These packages each contain the following license: These packages each contain the following license:
@@ -584,67 +560,6 @@ SOFTWARE.
----------- -----------
The following npm packages may be included in this product:
- copy-anything@4.0.5
- is-what@5.5.0
These packages each contain the following license:
MIT License
Copyright (c) 2018 Luca Ban - Mesqueeb
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-----------
The following npm package may be included in this product:
- superjson@2.2.6
This package contains the following license:
MIT License
Copyright (c) 2020 Simon Knott and superjson contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-----------
The following npm packages may be included in this product: The following npm packages may be included in this product:
- birpc@2.9.0 - birpc@2.9.0
@@ -676,36 +591,6 @@ SOFTWARE.
----------- -----------
The following npm package may be included in this product:
- mitt@3.0.1
This package contains the following license:
MIT License
Copyright (c) 2021 Jason Miller
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-----------
The following npm package may be included in this product: The following npm package may be included in this product:
- unplugin@3.0.0 - unplugin@3.0.0
@@ -798,12 +683,9 @@ SOFTWARE.
The following npm packages may be included in this product: The following npm packages may be included in this product:
- @vue/devtools-api@7.7.10 - @vue/devtools-api@8.1.1
- @vue/devtools-api@8.1.5 - @vue/devtools-kit@8.1.1
- @vue/devtools-kit@7.7.10 - @vue/devtools-shared@8.1.1
- @vue/devtools-kit@8.1.5
- @vue/devtools-shared@7.7.10
- @vue/devtools-shared@8.1.5
These packages each contain the following license: These packages each contain the following license:
@@ -833,7 +715,7 @@ SOFTWARE.
The following npm package may be included in this product: The following npm package may be included in this product:
- tinyglobby@0.2.17 - tinyglobby@0.2.16
This package contains the following license: This package contains the following license:
@@ -893,9 +775,7 @@ SOFTWARE.
The following npm packages may be included in this product: The following npm packages may be included in this product:
- defu@6.1.7
- mlly@1.8.2 - mlly@1.8.2
- perfect-debounce@1.0.0
- perfect-debounce@2.1.0 - perfect-debounce@2.1.0
- scule@1.3.0 - scule@1.3.0
- ufo@1.6.4 - ufo@1.6.4
@@ -1342,57 +1222,6 @@ USE OR OTHER DEALINGS IN THE SOFTWARE.
----------- -----------
The following npm package may be included in this product:
- pinia-plugin-persistedstate@4.7.1
This package contains the following license:
MIT License
Copyright (c) 2021 praz <https://codeberg.org/praz>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-----------
The following npm package may be included in this product:
- speakingurl@14.0.1
This package contains the following license:
The BSD 3-Clause License (BSD3)
Copyright (c) 2013-2017 Sascha Droste <pid@posteo.net>
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
* Neither the name of the author nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-----------
The following npm package may be included in this product: The following npm package may be included in this product:
- chokidar@5.0.0 - chokidar@5.0.0
@@ -1455,7 +1284,7 @@ SOFTWARE.
The following npm package may be included in this product: The following npm package may be included in this product:
- picomatch@4.0.5 - picomatch@4.0.4
This package contains the following license: This package contains the following license:
@@ -1522,12 +1351,11 @@ THE SOFTWARE.
----------- -----------
The following npm packages may be included in this product: The following npm package may be included in this product:
- pinia@3.0.4
- vue-router@5.0.6 - vue-router@5.0.6
These packages each contain the following license: This package contains the following license:
The MIT License (MIT) The MIT License (MIT)
@@ -1759,7 +1587,7 @@ THE SOFTWARE.
The following npm package may be included in this product: The following npm package may be included in this product:
- postcss@8.5.26 - postcss@8.5.14
This package contains the following license: This package contains the following license:
@@ -1788,7 +1616,7 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
The following npm package may be included in this product: The following npm package may be included in this product:
- nanoid@3.3.18 - nanoid@3.3.12
This package contains the following license: This package contains the following license:
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
# Changelog
## [0.1.0] - 2026-08-09
### Added
- Start page
- Settings page
- Footer
- Navbar
- Search bar
- Icons
- Internationalization and some major languages
- Color scheme
- Search results view with error message which indicates that the search is not available
- Settings
- Setup scripts
- License script
- Prettier configuration
- ESLint configuration
- Legal notice page
-50
View File
@@ -1,50 +0,0 @@
import js from '@eslint/js';
import { defineConfig } from 'eslint/config';
import pluginVue from 'eslint-plugin-vue';
import globals from 'globals';
export default defineConfig([
{
files: ['**/*.{js,mjs,cjs,vue}'],
plugins: { js },
extends: ['js/recommended'],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
},
rules: {
'eqeqeq': 'error',
'no-unused-vars': 'error',
'no-undef': 'error',
'prefer-const': 'error',
'no-var': 'error',
'no-duplicate-imports': 'error',
'no-import-assign': 'error',
'no-empty-function': 'error',
'default-param-last': 'error',
'prefer-arrow-callback': 'error',
'require-await': 'warn',
'no-async-promise-executor': 'error',
'no-unreachable': 'error',
'no-constant-condition': 'error',
'no-debugger': 'error',
'no-alert': 'error',
'no-console': 'warn',
'no-new-object': 'error',
'no-array-constructor': 'error',
'no-prototype-builtins': 'error',
'object-shorthand': 'error',
'prefer-template': 'error',
},
},
pluginVue.configs['flat/essential'],
]);
-45
View File
@@ -19,54 +19,9 @@ limitations under the License.
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0158D2" />
<title>Seekra</title> <title>Seekra</title>
<noscript>
<style>
body {
font-family:
'Inter',
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Open Sans',
'Helvetica Neue',
sans-serif;
}
a {
color: #000;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>
</noscript>
</head> </head>
<body> <body>
<noscript>
<h1>Seekra</h1>
<p>You need to enable JavaScript to use Seekra.</p>
<section>
<h2>Legal Content</h2>
<ul>
<li>
<a
href="https://assets.seekra.jcloud-services.ddns.net/2026-08-04-76a067e/legal/legal_notice.html"
>
Legal Notice
</a>
</li>
</ul>
</section>
</noscript>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
+132 -4838
View File
File diff suppressed because it is too large Load Diff
+3 -21
View File
@@ -1,40 +1,22 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.1.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview"
"test": "vitest",
"test:run": "vitest run",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier . --write",
"format:check": "prettier . --check"
}, },
"dependencies": { "dependencies": {
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"terser": "^5.47.1", "terser": "^5.47.1",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-i18n": "^11.4.4", "vue-i18n": "^11.4.4",
"vue-router": "^5.0.6" "vue-router": "^5.0.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@pinia/testing": "^1.0.3",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vue/test-utils": "^2.4.6",
"eslint": "^10.8.1",
"eslint-plugin-vue": "^10.10.0",
"generate-license-file": "^4.2.1",
"globals": "^17.9.0",
"jsdom": "^29.1.1",
"prettier": "^3.9.6",
"vite": "^8.0.10", "vite": "^8.0.10",
"vite-plugin-html": "^3.2.2", "vite-plugin-html": "^3.2.2"
"vitest": "^4.1.7"
} }
} }
-47
View File
@@ -1,47 +0,0 @@
#!/usr/bin/env node
/*
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 fs from 'node:fs/promises';
import { generateLicenseFile } from 'generate-license-file';
const packageJson = './package.json';
const packageJsonBackup = `${packageJson}~`;
await generateLicenseFile(packageJson, 'THIRD_PARTY_LICENSES');
const pkg = JSON.parse(await fs.readFile(packageJson, 'utf-8'));
await fs.rename(packageJson, packageJsonBackup);
const devPackage = {
...pkg,
dependencies: pkg.devDependencies ?? {},
devDependencies: {},
};
/*
The file including the dependencies for the current license file has
to be always package.json due to a bug of generate-license file. See
https://github.com/TobyAndToby/generate-license-file/issues/752
*/
await fs.writeFile(packageJson, JSON.stringify(devPackage));
await generateLicenseFile(packageJson, './THIRD_PARTY_LICENSES_DEV');
await fs.rename(packageJsonBackup, packageJson);
-17
View File
@@ -1,17 +0,0 @@
#!/usr/bin/env pwsh
# 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.
git config --local commit.template .gitmessage
-17
View File
@@ -1,17 +0,0 @@
#!/usr/bin/bash
# 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.
git config --local commit.template .gitmessage
+21 -55
View File
@@ -15,79 +15,45 @@ limitations under the License.
--> -->
<script setup> <script setup>
import { LANGUAGE_PATH } from './config/featureSettings.js'; import Navbar from './features/nav/components/Navbar.vue';
import AppFooter from './features/footer/components/AppFooter.vue'; import Footer from './features/footer/components/Footer.vue';
import AppNavbar from './features/nav/components/AppNavbar.vue';
import { useSettings } from './features/settings/composables/useSettings.js';
import { setLocale } from './i18n';
import { updatePageTitle } from './router';
import { computed, onMounted, watch, watchEffect } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute(); import { useColorScheme } from './features/colorScheme/composables/useColorScheme';
import { ref, provide, watch } from 'vue';
const { getSetting } = useSettings(); const { getColorScheme, updateColorScheme } = useColorScheme();
const colorScheme = computed(() => getSetting(['appearance', 'colorScheme'])); const colorScheme = ref(getColorScheme());
provide('colorScheme', colorScheme);
const updateColorScheme = function updateColorScheme(newColorScheme) { watch(colorScheme, val => updateColorScheme(val))
document.body.style.setProperty(colorScheme, {
auto: 'normal',
dark: 'dark',
light: 'light',
});
if (newColorScheme === 'dark') {
document.body.classList.add('dark');
} else {
document.body.classList.remove('dark');
}
if (newColorScheme === 'auto') {
document.body.classList.add('color-scheme-auto');
} else {
document.body.classList.remove('color-scheme-auto');
}
};
watch(colorScheme, (newColorScheme) => {
updateColorScheme(newColorScheme);
});
updateColorScheme(colorScheme.value);
watchEffect(() => updatePageTitle(route));
onMounted(() => {
setLocale(getSetting(LANGUAGE_PATH));
});
</script> </script>
<template> <template>
<div id="app-wrapper"> <div
<AppNavbar /> :style="{ colorScheme: colorScheme === 'auto' ? 'normal' : (colorScheme === 'dark' ? 'dark' : 'light')}"
:class="{ dark: colorScheme === 'dark', 'color-scheme-auto': colorScheme === 'auto' }"
id="app-wrapper"
>
<Navbar />
<router-view class="main-content" /> <router-view class="main-content" />
<AppFooter /> <Footer />
</div> </div>
</template> </template>
<style scoped> <style scoped>
#app-wrapper { .main-content {
--main-content-padding-x: 30px; --main-content-padding-x: 30px;
--main-content-padding-y: 40px; --main-content-padding-y: 40px;
--main-content-padding: var(--main-content-padding-y) --main-content-padding: var(--main-content-padding-y) var(--main-content-padding-x) 0;
var(--main-content-padding-x); flex-grow: 1;
}
#app-wrapper {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: var(--light-bg); background-color: var(--light-bg);
color: var(--dark); color: var(--dark);
} }
.main-content {
flex-grow: 1;
}
@media (max-width: 48rem) {
#app-wrapper {
--main-content-padding-x: 15px;
}
}
</style> </style>
-26
View File
@@ -1,26 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path
stroke="currentColor"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
d="M5 12 l6 8 l8 -16"
/>
</svg>

Before

Width:  |  Height:  |  Size: 833 B

-26
View File
@@ -1,26 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path
stroke="currentColor"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
d="M4 8 l8 8 l8 -8"
/>
</svg>

Before

Width:  |  Height:  |  Size: 831 B

-26
View File
@@ -1,26 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path
stroke="currentColor"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
d="M8 20 l8 -8 l-8 -8"
/>
</svg>

Before

Width:  |  Height:  |  Size: 834 B

-38
View File
@@ -1,38 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path
fill="currentColor"
d="
M12 2
a10 10 0 0 0 -10 10
a10 10 0 0 0 10 10
a10 10 0 0 0 10 -10
a10 10 0 0 0 -10 -10
"
/>
<path
style="filter: invert(1)"
fill="currentColor"
d="
M12 3
v18
a9 9 0 0 0 9 -9
a9 9 0 0 0 -9 -9
"
/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-36
View File
@@ -1,36 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path
fill="currentColor"
stroke="none"
stroke-width="0"
d="
M12.621094 23.988281
c-6.960938 0 -12.621094 -5.664062000000001 -12.621094 -12.621093
c0 -4.8203130000000005 2.683594 -9.148438 7.003906 -11.300781800000001
c0.19140600000000063 -0.09765619999999942 0.4257819999999999 -0.058593699999999416 0.578125 0.09374980000000058
c0.15234400000000026 0.152344 0.19531300000000051 0.38671900000000003 0.09765699999999988 0.582032
c-0.7890629999999996 1.601562 -1.1875 3.320312 -1.1875 5.113281000000001
c0.0 6.402342999999999 5.207031 11.613281 11.609374000000003 11.613281
c1.8125 0.0 3.550781999999998 -0.41015600000000063 5.167968999999999 -1.2148439999999994
c0.19531299999999874 -0.09765600000000063 0.42968799999999874 -0.05859399999999937 0.5820310000000006 0.09375
c0.15234399999999937 0.15234399999999937 0.1914069999999981 0.38671899999999937 0.0976569999999981 0.5820319999999981
c-2.140625 4.351562000000001 -6.480468999999999 7.058593000000002 -11.328125 7.058593000000002
"
/>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

-36
View File
@@ -1,36 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path
stroke="currentColor"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
d="M2 22 l8 -8"
/>
<circle
stroke="currentColor"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
cx="15.5"
cy="8.5"
r="6.5"
/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-38
View File
@@ -1,38 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<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>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-45
View File
@@ -1,45 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<circle fill="currentColor" r="6" cx="12" cy="12" />
<!-- sunbeams -->
<!-- left -->
<circle fill="currentColor" r="2" cx="3" cy="12" />
<!-- right -->
<circle fill="currentColor" r="2" cx="21" cy="12" />
<!-- top -->
<circle fill="currentColor" r="2" cx="12" cy="3" />
<!-- top -->
<circle fill="currentColor" r="2" cx="12" cy="21" />
<!-- top left -->
<circle fill="currentColor" r="2" cx="5.636038969321072" cy="5.636038969321072" />
<!-- top right -->
<circle fill="currentColor" r="2" cx="18.36396103067893" cy="5.636038969321072" />
<!-- bottom left -->
<circle fill="currentColor" r="2" cx="5.636038969321072" cy="18.36396103067893" />
<!-- bottom right -->
<circle fill="currentColor" r="2" cx="18.36396103067893" cy="18.36396103067893" />
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -15,50 +15,37 @@ limitations under the License.
--> -->
<script setup> <script setup>
import UIIcon from '@/features/icons/components/UIIcon.vue'; import { inject } from 'vue';
import { useSettings } from '@/features/settings/composables/useSettings';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
const { getSetting, setSetting } = useSettings();
const colorScheme = computed(() => getSetting(['appearance', 'colorScheme'])); const colorScheme = inject('colorScheme');
const colorSchemeNextMapper = { const colorSchemeNextMapper = {
light: 'dark', 'light': 'dark',
dark: 'auto', 'dark': 'auto',
auto: 'light', 'auto': 'light'
}; };
const colorSchemeIconMapper = { const colorSchemeIconMapper = {
dark: 'crescent-moon', 'dark': '⏾',
light: 'sun', 'light': '',
auto: 'circle-black-white', 'auto': '◐'
}; };
const getTooltipTranslation = function getTooltipTranslation(colorScheme) { const getTooltipTranslation = function (colorScheme) {
return t( return t(`preferences.colorScheme.switch.${colorSchemeNextMapper[colorScheme]}`);
`preferences.colorScheme.switch.${colorSchemeNextMapper[colorScheme]}`,
);
};
const setColorScheme = function setColorScheme(newColorScheme) {
setSetting(['appearance', 'colorScheme'], newColorScheme);
}; };
</script> </script>
<template> <template>
<button <button class="color-scheme-button"
class="color-scheme-button" @click="colorScheme = colorSchemeNextMapper[colorScheme]"
@click="setColorScheme(colorSchemeNextMapper[colorScheme])"
:aria-label="getTooltipTranslation(colorScheme)" :aria-label="getTooltipTranslation(colorScheme)"
:title="getTooltipTranslation(colorScheme)" :title="getTooltipTranslation(colorScheme)"
> >
<UIIcon {{ colorSchemeIconMapper[colorSchemeNextMapper[colorScheme]] }}
:name="colorSchemeIconMapper[colorSchemeNextMapper[colorScheme]]"
size="16"
/>
</button> </button>
</template> </template>
@@ -1,47 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import ColorSchemeButton from '../ColorSchemeButton.vue';
import { useSettings } from '@/features/settings/composables/useSettings.js';
import { mountComponent } from '@/test-utils/mountComponent';
import { describe, expect, test } from 'vitest';
const { getSetting } = useSettings();
const colorSchemeSettingPath = ['appearance', 'colorScheme'];
const getWrapper = function getWrapper() {
return mountComponent(ColorSchemeButton);
};
describe('ColorSchemeButton', () => {
test('sets color scheme setting', async () => {
const wrapper = getWrapper();
expect(getSetting(colorSchemeSettingPath)).toBe('auto');
const colorSchemeButton = wrapper.find('button');
await colorSchemeButton.trigger('click');
expect(getSetting(colorSchemeSettingPath)).toBe('light');
await colorSchemeButton.trigger('click');
expect(getSetting(colorSchemeSettingPath)).toBe('dark');
await colorSchemeButton.trigger('click');
expect(getSetting(colorSchemeSettingPath)).toBe('auto');
});
});
@@ -0,0 +1,39 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export const useColorScheme = function () {
const getColorScheme = function () {
let colorScheme = localStorage.getItem('colorScheme') || 'auto';
if (!(colorScheme === 'dark' || colorScheme === 'light' || colorScheme === 'auto')) {
colorScheme = 'auto';
};
return colorScheme;
};
const updateColorScheme = function (newScheme) {
let actualNewScheme = newScheme;
if (!(actualNewScheme === 'dark' || actualNewScheme === 'light' || actualNewScheme === 'auto')) {
actualNewScheme = 'auto';
};
localStorage.setItem('colorScheme', actualNewScheme);
};
return {
getColorScheme,
updateColorScheme
};
};
@@ -15,42 +15,32 @@ limitations under the License.
--> -->
<script setup> <script setup>
import LanguageSwitchButton from '@/features/i18n/components/LanguageSwitchButton.vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
const startYear = 2026; const startYear = 2026
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear()
const copyrightPeriod = const copyrightPeriod =
startYear === currentYear startYear === currentYear
? `${currentYear}` ? `${currentYear}`
: `${startYear}-${currentYear}`; : `${startYear}-${currentYear}`
</script> </script>
<template> <template>
<footer class="global-footer"> <footer class="global-footer">
<div class="footer-segment"> <div class="footer-segment">
<RouterLink to="/settings" class="link"> <RouterLink to="settings" class="link">
{{ t('preferences.settings') }} {{ t('preferences.settings') }}
</RouterLink> </RouterLink>
<LanguageSwitchButton />
</div> </div>
<div class="footer-segment legal-segment"> <div class="footer-segment">
<div></div> <div class="copyright-note">
<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> </div>
</footer> </footer>
</template> </template>
@@ -61,10 +51,6 @@ const copyrightPeriod =
} }
.footer-segment { .footer-segment {
display: flex;
justify-content: center;
align-items: center;
gap: 32px;
padding: var(--padding-y); padding: var(--padding-y);
background-color: var(--light-bg); background-color: var(--light-bg);
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
@@ -74,30 +60,8 @@ const copyrightPeriod =
color: var(--dark); color: var(--dark);
} }
.legal-segment { .copyright-note {
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; display: flex;
flex-direction: column;
justify-content: center; justify-content: center;
gap: 0;
}
.legal-links {
margin-top: 0.8em;
}
} }
</style> </style>
@@ -15,46 +15,43 @@ limitations under the License.
--> -->
<script setup> <script setup>
import { LANGUAGE_PATH } from '@/config/featureSettings'; import { ref } from 'vue';
import UIIcon from '@/features/icons/components/UIIcon.vue';
import { useSettings } from '@/features/settings/composables/useSettings';
import { setLocale, SUPPORTED_LANGUAGES } from '@/i18n';
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { loadLanguage, LANGUAGES_RTL, SUPPORTED_LANGUAGES } from '@/i18n';
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const { setSetting, getSetting } = useSettings();
const isOpen = ref(false); const isOpen = ref(false);
const languageDropdown = ref(null); const languageDropdown = ref(null);
const selectLanguage = function selectLanguage(code) { async function selectLanguage(code) {
setSetting(LANGUAGE_PATH, code); await loadLanguage(code);
localStorage.setItem('locale', code);
document.documentElement.lang = code;
document.documentElement.dir = LANGUAGES_RTL.includes(code) ? 'rtl' : 'ltr';
close(); close();
}; };
watch(() => getSetting(LANGUAGE_PATH), setLocale); const close = function () {
document.removeEventListener('click', closeWrapperOnClickOutsite);
const close = function close() {
document.removeEventListener('click', closeWrapperOnClickOutside);
isOpen.value = false; isOpen.value = false;
}; };
const closeWrapperOnClickOutside = function closeWrapperOnClickOutside(e) { const closeWrapperOnClickOutsite = function (e) {
if (languageDropdown.value) { if (languageDropdown.value) {
if (!languageDropdown.value.contains(e.target)) { if (!languageDropdown.value.contains(e.target)) {
close(); close();
} };
} };
}; };
const open = function open() { const open = function () {
if (!isOpen.value) { if (!isOpen.value) {
isOpen.value = true; isOpen.value = true;
setTimeout(() => { setTimeout(() => {
document.addEventListener('click', closeWrapperOnClickOutside); document.addEventListener('click', closeWrapperOnClickOutsite);
}, 0); }, 0);
} };
}; };
</script> </script>
@@ -66,23 +63,11 @@ const open = function open() {
:aria-expanded="isOpen" :aria-expanded="isOpen"
aria-haspopup="listbox" aria-haspopup="listbox"
> >
<span class="lang-code">{{ <span class="lang-code">{{ t(`preferences.locale.languages.${locale}`) }}</span>
t(`preferences.locale.languages.${locale}`) <span class="chevron" :class="{ open: isOpen }"></span>
}}</span>
<span class="chevron" :class="{ open: isOpen }">
<UIIcon name="chevron-down" size="1em" />
</span>
</button> </button>
<ul <ul v-if="isOpen" ref="languageDropdown" class="language-dropdown" role="listbox">
v-if="isOpen"
ref="languageDropdown"
class="language-dropdown"
role="listbox"
:style="{
bottom: 'calc(var(--offset) + 2 * var(--trigger-padding-y) + 1em)', // easier to add auto adaption to the available space
}"
>
<li <li
v-for="lang in SUPPORTED_LANGUAGES" v-for="lang in SUPPORTED_LANGUAGES"
:key="lang" :key="lang"
@@ -91,9 +76,7 @@ const open = function open() {
:class="{ active: lang === locale }" :class="{ active: lang === locale }"
@click="selectLanguage(lang)" @click="selectLanguage(lang)"
> >
<span class="lang-label">{{ <span class="lang-label">{{ t(`preferences.locale.languages.${lang}`) }}</span>
t(`preferences.locale.languages.${lang}`)
}}</span>
</li> </li>
</ul> </ul>
</div> </div>
@@ -101,7 +84,6 @@ const open = function open() {
<style scoped> <style scoped>
.language-switch { .language-switch {
--trigger-padding-y: 4px;
position: relative; position: relative;
} }
@@ -112,7 +94,7 @@ const open = function open() {
background: none; background: none;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
padding: var(--trigger-padding-y) 10px; padding: 4px 10px;
cursor: pointer; cursor: pointer;
color: var(--dark); color: var(--dark);
} }
@@ -132,9 +114,9 @@ const open = function open() {
} }
.language-dropdown { .language-dropdown {
--offset: 6px;
position: absolute; position: absolute;
right: 0; right: 0;
top: calc(100% + 6px);
background-color: var(--light-bg); background-color: var(--light-bg);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
@@ -1,89 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import LanguageSwitchButton from '../LanguageSwitchButton.vue';
import { LANGUAGE_PATH } from '@/config/featureSettings.js';
import { useSettings } from '@/features/settings/composables/useSettings.js';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { describe, expect, test } from 'vitest';
const getWrapper = function getWrapper() {
return mountComponent(LanguageSwitchButton);
};
describe('LanguageSwitchButton', () => {
test('renders correctly with initial state closed', () => {
const wrapper = getWrapper();
expect(wrapper.find('.language-button').exists()).toBe(true);
expect(wrapper.find('.language-dropdown').exists()).toBe(false);
expect(
wrapper.find('.language-button').attributes('aria-expanded'),
).toBe('false');
});
test('opens the dropdown when language button is clicked', async () => {
const wrapper = getWrapper();
const button = wrapper.find('.language-button');
await button.trigger('click');
expect(wrapper.find('.language-dropdown').exists()).toBe(true);
expect(button.attributes('aria-expanded')).toBe('true');
});
test('sets the language when clicking it', async () => {
const { getSetting } = useSettings();
const wrapper = getWrapper();
await wrapper.find('.language-button').trigger('click');
const options = wrapper.findAll('.language-dropdown li');
const enOption = options.find((opt) => opt.text().includes('en'));
await enOption.trigger('click');
expect(getSetting(LANGUAGE_PATH)).toBe('en');
expect(wrapper.find('.language-dropdown').exists()).toBe(false);
const deOption = options.find((opt) => opt.text().includes('de'));
await deOption.trigger('click');
expect(getSetting(LANGUAGE_PATH)).toBe('de');
expect(wrapper.find('.language-dropdown').exists()).toBe(false);
});
test('closes the dropdown when clicking outside the component', async () => {
const wrapper = getWrapper();
await wrapper.find('.language-button').trigger('click');
expect(wrapper.find('.language-dropdown').exists()).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 0));
const externalDiv = document.createElement('div');
document.body.appendChild(externalDiv);
const clickEvent = new MouseEvent('click', { bubbles: true });
externalDiv.dispatchEvent(clickEvent);
await wrapper.vm.$nextTick();
expect(wrapper.find('.language-dropdown').exists()).toBe(false);
wrapper.unmount();
externalDiv.remove();
});
});
@@ -1,54 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { getNavigatorLanguage } from '../navigatorLanguage';
import { describe, expect, test } from 'vitest';
describe('navigatorLanguage', () => {
test.for([
{ navigatorLanguage: 'en', expected: 'en' },
{ navigatorLanguage: 'de', expected: 'de' },
{ navigatorLanguage: 'fr', expected: 'fr' },
{ navigatorLanguage: 'en-US', expected: 'en' },
{ navigatorLanguage: 'en-AU', expected: 'en' },
{ navigatorLanguage: 'de-DE', expected: 'de' },
{ navigatorLanguage: 'fr-FR', expected: 'fr' },
{ navigatorLanguage: 'en-us', expected: 'en' },
{ navigatorLanguage: 'en-au', expected: 'en' },
{ navigatorLanguage: 'de-de', expected: 'de' },
{ navigatorLanguage: 'fr-fr', expected: 'fr' },
{ navigatorLanguage: 'zh-Hans-CN', expected: 'zh' },
{ navigatorLanguage: 'zh-Hant-TW', expected: 'zh' },
{ navigatorLanguage: 'uz-Latn-UZ', expected: 'uz' },
{ navigatorLanguage: 'en-US-u-ca-gregory', expected: 'en' },
{ navigatorLanguage: 'de-DE-u-co-phonebk', expected: 'de' },
{ navigatorLanguage: 'zh-Hant-TW-u-co-phonebk', expected: 'zh' },
])(
'returns language $expected with navigator language $navigatorLanguage',
({ navigatorLanguage, expected }) => {
Object.defineProperty(navigator, 'language', {
value: navigatorLanguage,
configurable: true,
});
expect(getNavigatorLanguage()).toBe(expected);
},
);
});
-54
View File
@@ -1,54 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import { ensureUnit } from '@/utils/cssDimensions';
import { computed } from 'vue';
const props = defineProps({
name: {
required: true,
type: String,
},
size: {
type: [Number, String],
default: 24,
},
});
const icons = import.meta.glob('@/assets/icons/*.svg', {
eager: true,
import: 'default',
});
const icon = computed(() => icons[`/src/assets/icons/${props.name}.svg`]);
const size = computed(() => ensureUnit(props.size));
</script>
<template>
<div class="icon-container">
<img :src="icon" :style="{ height: `${size}`, width: `${size}` }" />
</div>
</template>
<style scoped>
.icon-container {
display: flex;
align-items: center;
filter: var(--invert);
}
</style>
@@ -1,104 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { FetchError } from '../../errors';
import { getLegalNotice } from '../fetchLegalContent';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
let fetchSpy;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
});
const exampleHtml = `<h1>Heading</h1>
<p>
<a>...</a>
</p>`;
const exampleHtmlWithLinkClass = `<h1>Heading</h1>
<p>
<a class="link">...</a>
</p>`;
const exampleHtmlDocument = `<html>
<head>
<title>Legal Notice</title>
</head>
<body>
${exampleHtml}
</body>
</html>`;
const exampleHtmlDocumentWithMain = `<html>
<head>
<title>Legal Notice</title>
</head>
<body>
<main>
${exampleHtml}
</main>
</body>
</html>`;
describe('fetchLegalContent', () => {
describe('getLegalNotice', () => {
describe('success', () => {
test('returns HTML body', async () => {
fetchSpy.mockResolvedValue(new Response(exampleHtmlDocument));
expect((await getLegalNotice()).trim()).toBe(
exampleHtmlWithLinkClass,
);
});
test('returns HTML <main>', async () => {
fetchSpy.mockResolvedValue(
new Response(exampleHtmlDocumentWithMain),
);
expect((await getLegalNotice()).trim()).toBe(
exampleHtmlWithLinkClass,
);
});
test('does not fail on success status code', async () => {
fetchSpy.mockResolvedValue(new Response(null, { status: 206 }));
await getLegalNotice();
});
});
describe('error', () => {
test('throws FetchError when fetch fails', async () => {
fetchSpy.mockRejectedValue(new TypeError());
await expect(getLegalNotice()).rejects.toThrow(FetchError);
});
test('throws FetchError on HTTP error', async () => {
fetchSpy.mockResolvedValue(new Response(null, { status: 404 }));
await expect(getLegalNotice()).rejects.toThrow(FetchError);
});
test('does not throw FetchError when fetch throws another error than TypeError', async () => {
fetchSpy.mockRejectedValue(new Error());
await expect(getLegalNotice()).rejects.toThrow(Error);
});
});
});
});
@@ -1,45 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { FetchError } from '../errors';
import { getAssetsUrl } from '@/config/env';
const fetchHtml = async function fetchHtml(url, options) {
let response;
try {
response = await fetch(url, options);
} catch (err) {
if (err instanceof TypeError) throw new FetchError(err);
else throw err;
}
if (response.ok) return await response.text();
else throw new FetchError();
};
export const getLegalNotice = async function getLegalNotice() {
const legalNoticeUrl = new URL('legal/legal_notice.html', getAssetsUrl())
.href;
const html = await fetchHtml(legalNoticeUrl);
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const legalNoticeContainer = doc.querySelector('main') ?? doc.body;
legalNoticeContainer
.querySelectorAll('a')
.forEach((link) => link.classList.add('link'));
return legalNoticeContainer.innerHTML;
};
@@ -13,5 +13,3 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export const LANGUAGE_PATH = ['general', 'language'];
@@ -13,10 +13,3 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. 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;
};
@@ -1,61 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import { getLegalNotice } from '../services/fetchLegalContent';
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const loading = ref(true);
const legalNoticeError = ref(null);
const legalNoticeHtml = ref(null);
onMounted(async () => {
try {
legalNoticeHtml.value = await getLegalNotice();
} catch {
legalNoticeError.value = 'legal.notice.loadError';
} finally {
loading.value = false;
}
});
</script>
<template>
<div class="main-content-padding legal-notice-container">
<header class="header">
<h1>
{{ t('legal.notice.title') }}
</h1>
</header>
<main>
<div class="legal-notice-loading" v-if="loading">
{{ t('loading') }}
</div>
<div class="error" v-else-if="legalNoticeError">
{{ t(legalNoticeError) }}
</div>
<div class="legal-notice" v-html="legalNoticeHtml" v-else></div>
</main>
</div>
</template>
<style scoped>
.header h1 {
margin-top: 0;
}
</style>
@@ -0,0 +1,69 @@
```vue
<!--
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 { computed } from 'vue';
import { useI18n } from 'vue-i18n';
// 1. ALLE Sprachen importieren (Verhindert den ReferenceError)
import de from '@/legal/privacy/de.md?raw';
import en from '@/legal/privacy/en.md?raw';
import fr from '@/legal/privacy/fr.md?raw';
import es from '@/legal/privacy/es.md?raw';
import it from '@/legal/privacy/it.md?raw';
import pt from '@/legal/privacy/pt.md?raw';
const { locale } = useI18n();
const content = computed(() => {
const map = {
de,
en,
fr,
es,
it,
pt
};
// Falls eine Sprache mal nicht existiert, nutzen wir 'de' oder 'en' als Fallback
return map[locale.value] || de;
});
</script>
<template>
<main class="privacy-policy-content main-content-padding">
<h1>{{ $t('legal.privacy.title') }}</h1>
<div class="markdown-body">{{ content }}</div>
</main>
</template>
<style scoped>
.privacy-policy-content {
max-width: 900px;
margin: 0 auto;
padding-top: 40px;
padding-bottom: 40px;
}
/* Sorgt dafür, dass die Zeilenumbrüche aus den .md Dateien erhalten bleiben */
.markdown-body {
white-space: pre-wrap;
font-family: inherit;
line-height: 1.6;
}
</style>
@@ -1,80 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { FetchError } from '../../errors.js';
import LegalNoticeView from '../LegalNoticeView.vue';
import { mountComponent } from '@/test-utils/mountComponent';
import { flushPromises } from '@vue/test-utils';
import { afterEach, describe, expect, test, vi } from 'vitest';
const getLegalNotice = vi.hoisted(() =>
vi.fn().mockResolvedValue('legal notice'),
);
vi.mock('../../services/fetchLegalContent', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
getLegalNotice,
};
});
afterEach(getLegalNotice.mockRestore);
describe('LegalNoticeView', () => {
test('shows loading indicator', () => {
const wrapper = mountComponent(LegalNoticeView);
const legalNoticeLoading = wrapper.find('.legal-notice-loading');
const legalNoticeError = wrapper.find('.error');
const legalNotice = wrapper.find('.legal-notice');
expect(legalNoticeLoading.exists()).toBe(true);
expect(legalNoticeError.exists()).toBe(false);
expect(legalNotice.exists()).toBe(false);
});
test('shows error', async () => {
getLegalNotice.mockRejectedValue(new FetchError());
const wrapper = mountComponent(LegalNoticeView);
await flushPromises();
await wrapper.vm.$nextTick();
const legalNoticeLoading = wrapper.find('.legal-notice-loading');
const legalNoticeError = wrapper.find('.error');
const legalNotice = wrapper.find('.legal-notice');
expect(legalNoticeLoading.exists()).toBe(false);
expect(legalNoticeError.exists()).toBe(true);
expect(legalNotice.exists()).toBe(false);
});
test('shows legal notice', async () => {
const wrapper = mountComponent(LegalNoticeView);
await flushPromises();
await wrapper.vm.$nextTick();
const legalNoticeLoading = wrapper.find('.legal-notice-loading');
const legalNoticeError = wrapper.find('.error');
const legalNotice = wrapper.find('.legal-notice');
expect(legalNoticeLoading.exists()).toBe(false);
expect(legalNoticeError.exists()).toBe(false);
expect(legalNotice.exists()).toBe(true);
});
});
@@ -15,9 +15,25 @@ limitations under the License.
--> -->
<script setup> <script setup>
import NavbarSearchBarWrapper from './NavbarSearchBarWrapper.vue'; import { ref, watch } from 'vue';
import logo from '@/assets/images/logo.svg'; import { useRoute } from 'vue-router';
import ColorSchemeButton from '@/features/colorScheme/components/ColorSchemeButton.vue'; import ColorSchemeButton from '@/features/colorScheme/components/ColorSchemeButton.vue';
import LanguageSwitchButton from '@/features/i18n/components/LanguageSwitchButton.vue';
import logo from '@/assets/images/logo.svg';
import Searchbar from '@/features/search/components/Searchbar.vue';
const route = useRoute();
const searchQueryModel = defineModel();
watch(() => route.name, name => {
searchQueryModel.value = name === 'searchResults' ? route.query.q || '' : '';
});
watch(() => route.query.q, q => {
if (route.name === 'searchResults') {
searchQueryModel.value = q || '';
}
});
</script> </script>
<template> <template>
@@ -25,18 +41,21 @@ import ColorSchemeButton from '@/features/colorScheme/components/ColorSchemeButt
<RouterLink to="/" class="link button link"> <RouterLink to="/" class="link button link">
<img :src="logo" alt="Seekra" class="nav-logo" /> <img :src="logo" alt="Seekra" class="nav-logo" />
</RouterLink> </RouterLink>
<NavbarSearchBarWrapper class="navbar-search-bar-wrapper" /> <Searchbar
v-if="route.name === 'searchResults'"
class="search-bar"
v-model="searchQueryModel"
auto-submit
/>
<ul class="right-links"> <ul class="right-links">
<li>
<LanguageSwitchButton />
</li>
<li> <li>
<ColorSchemeButton /> <ColorSchemeButton />
</li> </li>
</ul> </ul>
</nav> </nav>
<div class="navbar-search-bar-wrapper-small-screens-wrapper">
<NavbarSearchBarWrapper
class="navbar-search-bar-wrapper-small-screens"
/>
</div>
</template> </template>
<style scoped> <style scoped>
@@ -44,7 +63,7 @@ import ColorSchemeButton from '@/features/colorScheme/components/ColorSchemeButt
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 18px var(--main-content-padding-x); padding: 18px 40px;
height: 42px; height: 42px;
} }
@@ -70,27 +89,7 @@ import ColorSchemeButton from '@/features/colorScheme/components/ColorSchemeButt
height: 24px; height: 24px;
width: auto; width: auto;
} }
.search-bar {
.navbar-search-bar-wrapper { width: 70%;
width: 100%;
padding: 0 30px;
}
.navbar-search-bar-wrapper-small-screens-wrapper {
padding: 0 15px 15px;
}
.navbar-search-bar-wrapper-small-screens {
display: none;
}
@media (max-width: 48rem) {
.navbar-search-bar-wrapper {
display: none;
}
.navbar-search-bar-wrapper-small-screens {
display: block;
}
} }
</style> </style>
@@ -1,49 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import SearchBar from '@/features/search/components/SearchBar.vue';
import { watch } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const searchQueryModel = defineModel();
watch(
() => route.name,
(name) => {
searchQueryModel.value =
name === 'searchResults' ? route.query.q || '' : '';
},
);
watch(
() => route.query.q,
(q) => {
if (route.name === 'searchResults') {
searchQueryModel.value = q || '';
}
},
);
</script>
<template>
<SearchBar
v-if="route.name === 'searchResults'"
v-model="searchQueryModel"
auto-submit
/>
</template>
@@ -15,30 +15,29 @@ limitations under the License.
--> -->
<script setup> <script setup>
import UIIcon from '@/features/icons/components/UIIcon.vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
const searchQuery = defineModel(); const searchQuery = defineModel();
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter()
const props = defineProps(['autoSubmit']); const props = defineProps(['autoSubmit'])
const submitSearch = function submitSearch() { const submitSearch = function () {
if (props.autoSubmit !== undefined) { if (props.autoSubmit !== undefined) {
router.push({ router.push({
name: 'searchResults', name: 'searchResults',
query: { q: searchQuery.value }, query: { q: searchQuery.value }
}); });
}
}; };
}
</script> </script>
<template> <template>
<div> <div>
<form @submit.prevent="submitSearch" class="search-form"> <form @submit.prevent="submitSearch">
<div class="search-wrapper"> <div class="search-wrapper">
<input <input
v-model="searchQuery" v-model="searchQuery"
@@ -46,18 +45,7 @@ const submitSearch = function submitSearch() {
:placeholder="t('search.searchBar.placeholder')" :placeholder="t('search.searchBar.placeholder')"
required required
/> />
<button <button type="submit" class="search-button">{{ t('search.searchBar.submit') }}</button>
type="submit"
class="search-button"
:title="t('search.searchBar.submit')"
:aria-label="t('search.searchBar.submit')"
>
<UIIcon
class="search-icon"
name="magnifying-glass"
size="1.1em"
/>
</button>
</div> </div>
</form> </form>
</div> </div>
@@ -73,14 +61,10 @@ const submitSearch = function submitSearch() {
align-items: center; align-items: center;
border: 1.5px solid var(--border); border: 1.5px solid var(--border);
box-shadow: 0 0px 32px var(--blue-box-shadow); box-shadow: 0 0px 32px var(--blue-box-shadow);
border-radius: calc( border-radius: calc(var(--content-height) * 0.5 + var(--submit-button-padding-y) + var(--padding));
var(--content-height) * 0.5 + var(--submit-button-padding-y) +
var(--padding)
);
padding: var(--padding); padding: var(--padding);
padding-left: var(--padding-left); padding-left: var(--padding-left);
width: 100%; width: calc(100% - var(--padding-left));
box-sizing: border-box;
} }
.search-wrapper input { .search-wrapper input {
@@ -90,36 +74,21 @@ const submitSearch = function submitSearch() {
font-size: 1rem; font-size: 1rem;
background: transparent; background: transparent;
height: calc(var(--content-height) + 2 * var(--submit-button-padding-y)); height: calc(var(--content-height) + 2 * var(--submit-button-padding-y));
padding-left: 12px;
} }
.search-button { .search-button {
font-size: 1rem; font-size: 1rem;
height: calc(var(--content-height) + 2 * var(--submit-button-padding-y)); height: calc(var(--content-height) + 2 * var(--submit-button-padding-y));
width: calc(var(--content-height) + 2 * var(--submit-button-padding-y)); border-radius: calc(var(--content-height) * 0.5 + var(--submit-button-padding-y));
border-radius: calc(
var(--content-height) * 0.5 + var(--submit-button-padding-y)
);
border: none; border: none;
padding: var(--submit-button-padding-y) 20px; padding: var(--submit-button-padding-y) 20px;
background: var(--primary-color); background: var(--primary-color);
color: var(--white); color: var(--white);
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
display: flex;
justify-content: center;
align-items: center;
}
.search-button .search-icon {
filter: invert(1);
} }
.search-button:hover { .search-button:hover {
background: var(--primary-color-l-1); background: var(--primary-color-l-1);
} }
.search-form {
width: 100%;
}
</style> </style>
@@ -28,7 +28,8 @@ const { t } = useI18n();
<p>{{ t('error.tryAgainToAnotherTime') }}</p> <p>{{ t('error.tryAgainToAnotherTime') }}</p>
</div> </div>
</div> </div>
<div class="search-results-container"></div> <div class="search-results-container">
</div>
</div> </div>
</template> </template>
@@ -52,7 +53,9 @@ const { t } = useI18n();
--error-message-padding: 2em; --error-message-padding: 2em;
display: flex; display: flex;
justify-content: center; justify-content: center;
width: 100%; width: calc(100% - 2 * var(--main-content-padding-x));
position: absolute;
top: calc(50vh - 0.5 * var(--error-message-height) - var(--error-message-padding));
} }
.search-results-error-message { .search-results-error-message {
@@ -1,19 +0,0 @@
.settings-button {
--padding-x: 0.7em;
padding: 0.3em var(--padding-x);
margin-top: 0.2em;
width: calc(100% - 2 * var(--padding-x));
border-radius: 0.8em;
display: inline-flex;
align-items: center;
gap: 0.5em;
cursor: pointer;
background-color: var(--light-bg);
user-select: none;
-moz-user-select: none;
}
.settings-button:hover {
background-color: var(--light-hover);
}
@@ -1,216 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import { useSettingsStore } from '../stores/settingsStore';
import UIIcon from '@/features/icons/components/UIIcon.vue';
import { computed, ref, useId, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
setting: {
required: true,
},
path: {
required: true,
},
});
const store = useSettingsStore();
const inputModel = ref(store.get(props.path) ?? props.setting.default);
let oldInputValue = inputModel.value;
const doneButtonInitial = ref(true);
const inputId = useId();
const inputLabelId = useId();
const inputType = computed(
() =>
({
number: 'number',
string: 'text',
})[props.setting.type],
);
const hasChanged = computed(() => inputModel.value !== store.get(props.path));
watch(store, (newValue) => {
const newInputValue = newValue.get(props.path);
if (inputModel.value === oldInputValue) {
inputModel.value = newInputValue;
oldInputValue = newInputValue;
}
});
watch(inputModel, () => {
doneButtonInitial.value = false;
});
const apply = function apply() {
store.set(props.path, inputModel.value);
};
</script>
<template>
<div class="input-setting-container">
<label :for="inputId" :id="inputLabelId">
{{ t(props.setting.i18n) }}
</label>
<form
@submit.prevent="apply"
class="input-container"
:class="{ 'done-button-shown': hasChanged }"
>
<input
:type="inputType"
:id="inputId"
:aria-labelledby="inputLabelId"
v-model="inputModel"
class="input"
/>
<button
ref="doneButton"
class="done-button button"
:class="{ initial: doneButtonInitial }"
type="submit"
:title="t('settings.input.done.title')"
:aria-label="t('settings.input.done.ariaLabel')"
>
<UIIcon name="check-mark" />
</button>
</form>
</div>
</template>
<style scoped>
.input-setting-container {
display: flex;
justify-content: space-between;
width: 100%;
align-items: center;
}
.input-container {
position: relative;
width: 50%;
white-space: nowrap;
--total-animation-duration: 0.3s;
}
.input {
box-sizing: border-box;
width: 100%;
height: calc(var(--input-height) + 2 * var(--input-padding-y));
display: inline;
vertical-align: middle;
--input-height: 24px;
transition: width calc(var(--total-animation-duration) / 3);
}
.input-container:not(.done-button-shown) .input {
transition-delay: calc((2 / 3) * var(--total-animation-duration));
}
.input-container.done-button-shown .input {
width: calc(
100% - 1.5 * var(--input-padding) - 26px - 0.75 * var(--input-height) +
1.5px
);
}
.done-button {
position: absolute;
right: 0;
top: 50%;
border: 1px solid var(--border);
min-height: var(--input-height);
padding: var(--input-padding);
transform: translateY(-50%) scale(0);
transform-origin: center;
vertical-align: middle;
align-items: center;
justify-content: center;
border-radius: calc(var(--input-padding) + 13px);
font-size: 1em;
font-family: inherit;
}
.done-button:not(.initial) {
animation: done-button-disappear var(--total-animation-duration);
}
.input-container.done-button-shown .done-button {
animation: done-button-appear var(--total-animation-duration);
display: inline-flex;
transform: translateY(-50%) scale(1);
}
@keyframes done-button-appear {
0% {
transform: translateY(-50%) scale(0);
}
10% {
transform: translateY(-50%) scale(0);
}
95% {
transform: translateY(-50%) scale(1.05);
}
100% {
transform: translateY(-50%) scale(1);
}
}
@keyframes done-button-disappear {
0% {
transform: translateY(-50%) scale(1);
}
100% {
transform: translateY(-50%) scale(0);
}
}
@media (prefers-reduced-motion: reduce) {
@keyframes done-button-appear {
0% {
transform: translateY(-50%) scale(1);
opacity: 0;
}
10% {
transform: translateY(-50%) scale(1);
opacity: 0;
}
100% {
transform: translateY(-50%) scale(1);
opacity: 1;
}
}
@keyframes done-button-disappear {
0% {
opacity: 1;
transform: translateY(-50%) scale(1);
}
100% {
opacity: 0;
transform: translateY(-50%) scale(1);
}
}
}
</style>
@@ -1,92 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import SettingsSelection from './SettingsSelection.vue';
import SettingsInput from './SettingsInput.vue';
import SettingsPageLink from './SettingsPageLink.vue';
import SettingsSwitch from './SettingsSwitch.vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
setting: {
required: true,
},
path: {
required: true,
},
});
</script>
<template>
<div>
<h2 class="section-heading">
{{ t(props.setting.i18n) }}
</h2>
<div
v-for="setting in props.setting.content"
:key="setting"
class="setting"
>
<SettingsSwitch
v-if="setting.type === 'bool'"
:setting="setting"
:path="`${props.path}.${setting.name}`"
/>
<SettingsInput
v-if="setting.type === 'string' || setting.type === 'number'"
:setting="setting"
:path="`${props.path}.${setting.name}`"
/>
<SettingsSelection
v-if="setting.type === 'selection'"
:setting="setting"
:path="`${props.path}.${setting.name}`"
/>
<SettingsPageLink
v-if="setting.type === 'section'"
:display-name="t(setting.i18n)"
:target="`${props.path}.${setting.name}`"
/>
</div>
</div>
</template>
<style scoped>
.setting {
margin-bottom: 1.2em;
}
.section-heading {
--size: 1.2em;
font-size: var(--size);
margin-bottom: calc(var(--size) - 0.2em);
}
.section-heading[aria-level='2'] {
--size: 1.8em;
}
.section-heading[aria-level='3'] {
--size: 1.6em;
}
.section-heading[aria-level='4'] {
--size: 1.4em;
}
</style>
@@ -1,63 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import { useSettingsPage } from '../composables/useSettingsPage';
import UIIcon from '@/features/icons/components/UIIcon.vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const { goToSettingsPage } = useSettingsPage();
const props = defineProps({
target: {
required: true,
type: String,
},
displayName: {
required: true,
type: String,
},
});
const followLink = function followLink() {
goToSettingsPage(props.target);
};
</script>
<template>
<button
class="settings-button button settings-page-link"
:title="props.displayName"
:aria-label="
t('settings.pages.link.ariaLabel', { page: props.displayName })
"
@click="followLink"
>
{{ props.displayName }}
<UIIcon name="chevron-right" size="1em" />
</button>
</template>
<style scoped src="./SettingsButton.css"></style>
<style scoped>
.settings-page-link {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
</style>
@@ -1,136 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import { useSettingsStore } from '../stores/settingsStore';
import UIIcon from '@/features/icons/components/UIIcon.vue';
import { computed, useId } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const normalizeSelectedValue = function normalizeSelectedValue(value) {
if (props.setting.allowMultiple) {
if (Array.isArray(value)) {
return value;
}
if (value === undefined || value === null) {
return [];
}
return [value];
}
if (Array.isArray(value)) {
return value[0] ?? null;
}
return value ?? null;
};
const props = defineProps({
setting: {
required: true,
},
path: {
required: true,
},
});
const store = useSettingsStore();
const optionType = computed(() =>
props.setting.allowMultiple ? 'checkbox' : 'radio',
);
const selected = computed({
get: () =>
normalizeSelectedValue(store.get(props.path) ?? props.setting.default),
set: (value) => store.set(props.path, value),
});
const labelId = useId();
</script>
<template>
<div>
<div class="label" :id="labelId">
{{ t(props.setting.i18n) }}
</div>
<ul class="options-list" :aria-labelledby="labelId">
<li v-for="option in props.setting.options" :key="option.name">
<label
class="settings-button"
:title="t(option.i18n)"
:aria-label="
t('settings.selection.option.ariaLabel', {
option: t(option.i18n),
})
"
>
<input
:type="optionType"
:name="props.path"
:value="option.name"
class="option-input"
v-model="selected"
/>
<div class="option-check-mark-wrapper">
<UIIcon
class="option-check-mark"
name="check-mark"
size="1em"
/>
</div>
<div class="input-label">
{{ t(option.i18n) }}
</div>
</label>
</li>
</ul>
</div>
</template>
<style scoped src="./SettingsButton.css"></style>
<style scoped>
.options-list {
list-style: none;
padding: 0;
margin: 0;
margin-top: 0.5em;
}
.option-input {
appearance: none;
display: none;
}
.option-check-mark-wrapper {
width: 1em;
height: 1em;
display: flex;
justify-content: center;
align-items: center;
}
.option-check-mark {
visibility: hidden;
}
.option-input:checked + .option-check-mark-wrapper .option-check-mark {
visibility: visible;
}
</style>
@@ -1,109 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import { useSettingsStore } from '../stores/settingsStore';
import { ref, useId, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
setting: {
required: true,
},
path: {
required: true,
},
});
const labelId = useId();
const switchId = useId();
const store = useSettingsStore();
const enabled = ref(null);
watch(store, (newStore) => {
enabled.value = newStore.get(props.path);
});
// set value after registering watcher to avoid immediate value change
enabled.value = store.get(props.path) ?? props.setting.default;
const toggle = function toggle() {
enabled.value = !enabled.value;
store.set(props.path, enabled.value);
};
</script>
<template>
<div class="switch-container" @click="toggle">
<label :for="switchId" :id="labelId" class="switch-label">
{{ t(props.setting.i18n) }}
</label>
<div
class="switch-wrapper"
:class="{ enabled }"
:title="t('settings.switch.title')"
:aria-label="t('settings.switch.ariaLabel')"
:aria-labelledby="labelId"
>
<div class="switch-point"></div>
</div>
</div>
</template>
<style scoped>
.switch-container {
display: flex;
justify-content: space-between;
width: 100%;
align-items: center;
cursor: pointer;
}
.switch-label {
cursor: inherit;
}
.switch-wrapper {
--point-size: 0.9em;
--padding: 2px;
--transition-duration: 0.2s;
background-color: var(--light-bg);
outline: 1px solid var(--border);
width: calc(2 * var(--point-size));
padding: var(--padding);
border-radius: calc(0.5 * var(--point-size) + var(--padding));
transition: background-color var(--transition-duration) ease;
}
.switch-point {
height: var(--point-size);
width: var(--point-size);
background-color: var(--border);
border-radius: calc(0.5 * var(--point-size));
margin-left: 0;
transition: margin-left var(--transition-duration) ease;
}
.switch-wrapper.enabled {
background-color: var(--primary-color);
}
.switch-wrapper.enabled .switch-point {
margin-left: var(--point-size);
}
</style>
@@ -1,225 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useSettingsStore } from '../../stores/settingsStore.js';
import SettingsInput from '../SettingsInput.vue';
import { mountComponent } from '@/test-utils/mountComponent';
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
const getWrapper = function getWrapper({
type = 'string',
i18n = 'input',
defaultValue = undefined,
piniaOptions = {},
} = {}) {
return mountComponent(
SettingsInput,
{
attrs: {
setting: {
name: 'input',
default: defaultValue,
i18n,
type,
},
path: 'input',
},
},
{},
[],
piniaOptions,
);
};
describe('SettingsInput', () => {
test('uses correct input type', () => {
const stringSettingWrapper = getWrapper();
const stringInputElement = stringSettingWrapper.find('input');
expect(stringInputElement.attributes('type')).toBe('text');
const numberSettingWrapper = getWrapper({ type: 'number' });
const numberInputElement = numberSettingWrapper.find('input');
expect(numberInputElement.attributes('type')).toBe('number');
});
test('shows empty value', () => {
const stringSettingWrapper = getWrapper();
const inputElement = stringSettingWrapper.find('input');
expect(inputElement.attributes('value')).toBeUndefined();
});
test('shows value from store', () => {
const value = 'example value';
const stringSettingWrapper = getWrapper({
type: 'string',
i18n: 'input',
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.settings.input = value;
},
},
});
const inputElement = stringSettingWrapper.find('input');
expect(inputElement.element.value).toBe(value);
});
test('shows default value', () => {
const value = 'example value';
const wrapper = getWrapper({
type: 'string',
i18n: 'input',
defaultValue: value,
});
const inputElement = wrapper.find('input');
expect(inputElement.element.value).toBe(value);
});
test('store value instead of default value', () => {
const storeValue = 'store value';
const defaultValue = 'default value';
const wrapper = getWrapper({
type: 'string',
i18n: 'input',
defaultValue,
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.settings.input = storeValue;
},
},
});
const inputElement = wrapper.find('input');
expect(inputElement.element.value).toBe(storeValue);
});
test('stores value', async () => {
const value = 'example value';
const wrapper = getWrapper();
const store = useSettingsStore();
expect(store.get('input')).toBeUndefined();
const inputElement = wrapper.find('input');
await inputElement.setValue(value);
const form = wrapper.find('form');
await form.trigger('submit');
expect(store.get('input')).toBe(value);
});
test('reacts on store change', async () => {
const value = 'example value';
const value2 = 'example value 2';
const anotherValue = 'another value';
const wrapper = getWrapper();
const inputElement = wrapper.find('input');
expect(inputElement.element.value).toBe('');
const store = useSettingsStore();
store.set('input', value);
await nextTick();
expect(inputElement.element.value).toBe(value);
await inputElement.setValue(anotherValue);
expect(inputElement.element.value).toBe(anotherValue);
// input does not update if there are unsaved changes
store.set('input', value2);
expect(inputElement.element.value).toBe(anotherValue);
const form = wrapper.find('form');
expect(form.classes('done-button-shown')).toBeTruthy();
await form.trigger('submit');
expect(store.get('input')).toBe(anotherValue);
expect(form.classes('done-button-shown')).toBeFalsy();
});
test('shows done button correctly', async () => {
const wrapper = getWrapper();
const form = wrapper.find('form');
const inputElement = wrapper.find('input');
expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue('value');
expect(form.classes('done-button-shown')).toBeTruthy();
await form.trigger('submit');
expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue('Hello, World!');
expect(form.classes('done-button-shown')).toBeTruthy();
await form.trigger('submit');
expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue('');
expect(form.classes('done-button-shown')).toBeTruthy();
await form.trigger('submit');
expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue('a');
expect(form.classes('done-button-shown')).toBeTruthy();
const store = useSettingsStore();
store.set('input', 'a');
await nextTick();
expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue('');
store.set('input', '');
await nextTick();
expect(form.classes('done-button-shown')).toBeFalsy();
});
test('shows correct translation', () => {
const translation = 'Text input';
const wrapper = mountComponent(
SettingsInput,
{
attrs: {
setting: {
name: 'input',
i18n: 'input',
type: 'string',
},
path: 'input',
},
},
{
input: translation,
},
);
const label = wrapper.find('label');
expect(label.text()).toBe(translation);
});
});
@@ -1,177 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import SettingsSelection from '../SettingsSelection.vue';
import SettingsInput from '../SettingsInput.vue';
import SettingsPage from '../SettingsPage.vue';
import SettingsPageLink from '../SettingsPageLink.vue';
import SettingsSwitch from '../SettingsSwitch.vue';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { describe, expect, test } from 'vitest';
const getWrapper = function getWrapper({
content = [],
translations = {},
piniaOptions = {},
}) {
return mountComponent(
SettingsPage,
{
attrs: {
setting: {
name: 'section',
type: 'section',
i18n: 'section',
content,
},
path: 'section',
},
},
translations,
[],
piniaOptions,
);
};
const exampleSettings = [
{
type: 'bool',
name: 'b0',
i18n: 'settings.section.b0',
},
{
type: 'number',
name: 'n0',
i18n: 'settings.section.n0',
},
{
type: 'string',
name: 'st0',
i18n: 'settings.section1.st0',
},
{
type: 'selection',
name: 'se0',
i18n: 'settings.section.se0',
options: [
{ name: 'o0', i18n: 'settings.section.se0.o0' },
{ name: 'o1', i18n: 'settings.section.se0.o1' },
{ name: 'o2', i18n: 'settings.section.se0.o2' },
{ name: 'o3', i18n: 'settings.section.se0.o3' },
],
},
{
type: 'section',
name: 's0',
i18n: 'settings.section.s0',
content: [],
},
{
type: 'section',
name: 's1',
i18n: 'settings.section.s1',
content: [
{
type: 'bool',
name: 'b0',
i18n: 'settings.section.s1.b0',
default: true,
},
],
},
];
describe('SettingsPage', () => {
test('shows correct heading', () => {
const translation = 'Example section';
const wrapper = getWrapper({
translations: { section: translation },
});
const heading = wrapper.find('.section-heading');
expect(heading.text()).toBe(translation);
});
test('shows settings correctly', () => {
const wrapper = getWrapper({
content: exampleSettings,
});
const settingsWrappers = wrapper.findAll('.setting');
expect(settingsWrappers.length).toBe(exampleSettings.length);
exampleSettings.forEach((expectedSetting, index) => {
const settingWrapper = settingsWrappers[index];
const switchSetting = settingWrapper.findComponent(SettingsSwitch);
const inputSetting = settingWrapper.findComponent(SettingsInput);
const selectionSetting =
settingWrapper.findComponent(SettingsSelection);
const settingsPageLink =
settingWrapper.findComponent(SettingsPageLink);
let setting;
if (expectedSetting.type === 'bool') {
expect(switchSetting.exists()).toBe(true);
expect(inputSetting.exists()).toBe(false);
expect(selectionSetting.exists()).toBe(false);
expect(settingsPageLink.exists()).toBe(false);
setting = switchSetting;
} else if (
expectedSetting.type === 'string' ||
expectedSetting.type === 'number'
) {
expect(switchSetting.exists()).toBe(false);
expect(inputSetting.exists()).toBe(true);
expect(selectionSetting.exists()).toBe(false);
expect(settingsPageLink.exists()).toBe(false);
setting = inputSetting;
} else if (expectedSetting.type === 'selection') {
expect(switchSetting.exists()).toBe(false);
expect(inputSetting.exists()).toBe(false);
expect(selectionSetting.exists()).toBe(true);
expect(settingsPageLink.exists()).toBe(false);
setting = selectionSetting;
} else {
expect(switchSetting.exists()).toBe(false);
expect(inputSetting.exists()).toBe(false);
expect(selectionSetting.exists()).toBe(false);
expect(settingsPageLink.exists()).toBe(true);
setting = settingsPageLink;
}
if (
expectedSetting.type === 'bool' ||
expectedSetting.type === 'string' ||
expectedSetting.type === 'number' ||
expectedSetting.type === 'selection'
) {
expect(setting.props('setting')).toMatchObject(expectedSetting);
expect(setting.props('path')).toBe(
`section.${expectedSetting.name}`,
);
} else {
expect(setting.props('displayName')).toBe(expectedSetting.i18n);
expect(setting.props('target')).toBe(
`section.${expectedSetting.name}`,
);
}
});
});
});
@@ -1,57 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import SettingsPageLink from '../SettingsPageLink.vue';
import { mount } from '@vue/test-utils';
import { describe, expect, test, vi } from 'vitest';
import { createI18n } from 'vue-i18n';
const goToSettingsPage = vi.fn();
vi.mock('../../composables/useSettingsPage.js', () => ({
useSettingsPage: () => ({
goToSettingsPage,
}),
}));
const getWrapper = function getWrapper({ target, displayName }) {
return mount(SettingsPageLink, {
attrs: {
target,
displayName,
},
global: {
plugins: [createI18n()],
},
});
};
describe('SettingsPageLink', () => {
test('shows correct display name', () => {
const displayName = 'A settings page link';
const wrapper = getWrapper({ displayName });
expect(wrapper.text()).toBe(displayName);
});
test('follows link', async () => {
const target = 'section1.subsection2';
const wrapper = getWrapper({ target });
const button = wrapper.find('button');
await button.trigger('click');
expect(goToSettingsPage).toHaveBeenCalledWith(target);
});
});
@@ -1,302 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useSettingsStore } from '../../stores/settingsStore.js';
import SettingsSelection from '../SettingsSelection.vue';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { expect, describe, test } from 'vitest';
import { nextTick } from 'vue';
const getWrapper = function getWrapper({
i18n = 'selection',
options = [],
allowMultiple = false,
defaultValue = undefined,
translations = {},
piniaOptions = {},
} = {}) {
return mountComponent(
SettingsSelection,
{
attrs: {
setting: {
name: 'selection',
type: 'selection',
i18n,
options,
allowMultiple,
default: defaultValue,
},
path: 'selection',
},
},
translations,
[],
piniaOptions,
);
};
const getChecked = function getChecked(inputs) {
return inputs.map((input) => input.element.matches(':checked'));
};
const exampleOptions = [
{ name: 'o0', i18n: 'o0' },
{ name: 'o1', i18n: 'o1' },
{ name: 'o2', i18n: 'o2' },
];
describe('SettingsSelection', () => {
test('displays options correctly', () => {
const wrapper = getWrapper({ options: exampleOptions });
const listItems = wrapper.findAll('li');
expect(listItems.length).toBe(3);
for (let i = 0; i <= 2; i++) {
const option = exampleOptions[i];
const listItem = listItems[i];
expect(listItem.find('.input-label').text()).toBe(option.i18n);
const input = listItem.find('input');
expect(input.attributes('type')).toBe('radio');
expect(input.attributes('name')).toBe('selection');
expect(input.element.matches(':checked')).toBe(false);
}
});
test('shows value from store', () => {
const wrapper = getWrapper({
options: exampleOptions,
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.set('selection', 'o1');
},
},
});
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([false, true, false]);
});
test('reacts on store change', async () => {
const wrapper = getWrapper({
options: exampleOptions,
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.set('selection', 'o1');
},
},
});
const settings = useSettingsStore();
{
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([false, true, false]);
}
{
settings.set('selection', 'o2');
await nextTick();
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([false, false, true]);
}
});
test('shows multiple values from store', () => {
const wrapper = getWrapper({
options: exampleOptions,
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.set('selection', ['o1', 'o2']);
},
},
allowMultiple: true,
});
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([false, true, true]);
});
test('shows default value', () => {
const wrapper = getWrapper({
options: exampleOptions,
defaultValue: 'o2',
});
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([false, false, true]);
});
test('shows multiple default values', () => {
const wrapper = getWrapper({
options: exampleOptions,
defaultValue: ['o0', 'o1'],
allowMultiple: true,
});
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([true, true, false]);
});
test('shows store value even if default value is set', () => {
const wrapper = getWrapper({
options: exampleOptions,
default: 'o2',
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.set('selection', 'o1');
},
},
});
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([false, true, false]);
});
test('stores one value', async () => {
const wrapper = getWrapper({ options: exampleOptions });
const store = useSettingsStore();
expect(store.get('selection')).toBeUndefined();
const inputs = wrapper.findAll('input');
const inputO0 = inputs[0];
const inputO1 = inputs[1];
const inputO2 = inputs[2];
await inputO0.setValue(true);
await nextTick();
expect(store.get('selection')).toBe('o0');
await inputO1.setValue(true);
await nextTick();
expect(store.get('selection')).toBe('o1');
await inputO2.setValue(true);
await nextTick();
expect(store.get('selection')).toBe('o2');
expect(inputO2.element.matches(':checked')).toBe(true);
});
test('stores multiple values', async () => {
const setValue = async function setValue(input, value) {
input.setValue(value);
await nextTick();
};
const check = function check(input) {
return setValue(input, true);
};
const uncheck = function uncheck(input) {
return setValue(input, false);
};
const expectSelectionValue = function expectSelectionValue(
value,
options = exampleOptions,
) {
value.sort();
expect(store.get('selection').sort()).toStrictEqual(value);
const checkedInputs = getChecked(inputs);
expect(
options
.map((option) => option.name)
.filter((_, i) => checkedInputs[i]),
).toStrictEqual(value);
};
const wrapper = getWrapper({
options: exampleOptions,
allowMultiple: true,
});
const store = useSettingsStore();
expect(store.get('selection')).toBeUndefined();
const inputs = wrapper.findAll('input');
const inputO0 = inputs[0];
const inputO1 = inputs[1];
const inputO2 = inputs[2];
await check(inputO0);
expectSelectionValue(['o0']);
await uncheck(inputO0);
expectSelectionValue([]);
await check(inputO1);
expectSelectionValue(['o1']);
await check(inputO0);
expectSelectionValue(['o0', 'o1']);
await check(inputO2);
expectSelectionValue(['o0', 'o1', 'o2']);
await uncheck(inputO2);
expectSelectionValue(['o0', 'o1']);
await check(inputO2);
expectSelectionValue(['o0', 'o1', 'o2']);
await uncheck(inputO1);
expectSelectionValue(['o0', 'o2']);
await inputO0.setValue(true);
expectSelectionValue(['o0', 'o2']);
await inputO0.setValue(false);
expectSelectionValue(['o2']);
await inputO1.setValue(true);
expectSelectionValue(['o1', 'o2']);
await inputO2.setValue(false);
expectSelectionValue(['o1']);
await inputO1.setValue(true);
expectSelectionValue(['o1']);
await inputO1.setValue(false);
expectSelectionValue([]);
});
test('shows correct translation', () => {
const translation = 'Selection setting';
const wrapper = getWrapper({
translations: {
selection: translation,
},
});
const label = wrapper.find('.label');
expect(label.text()).toBe(translation);
});
});
@@ -1,131 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useSettingsStore } from '../../stores/settingsStore.js';
import SettingsSwitch from '../SettingsSwitch.vue';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
const getWrapper = function getWrapper({
i18n = 'switch1',
defaultValue = undefined,
translations = {},
piniaOptions = {},
} = {}) {
return mountComponent(
SettingsSwitch,
{
attrs: {
setting: {
type: 'bool',
name: 'switch',
i18n,
default: defaultValue,
},
path: 'switch',
},
},
translations,
[],
piniaOptions,
);
};
describe('SettingsSwitch', () => {
test('shows value from store', async () => {
const wrapper = getWrapper();
const store = useSettingsStore();
store.set('switch', true);
await nextTick();
const switchElement = wrapper.find('.switch-wrapper');
const switchElementClasses = switchElement.classes();
expect(switchElementClasses).toContain('enabled');
store.set('switch', false);
await nextTick();
const switchElementClasses2 = switchElement.classes();
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();
const switchElement = wrapper.find('.switch-wrapper');
const store = useSettingsStore();
expect(store.get('switch')).toBeUndefined();
switchElement.trigger('click');
await nextTick();
expect(store.get('switch')).toBe(true);
switchElement.trigger('click');
await nextTick();
expect(store.get('switch')).toBe(false);
});
test('shows correct translation', () => {
const translation = 'Switch';
const wrapper = getWrapper({
translations: { switch1: translation },
});
const label = wrapper.find('label');
expect(label.text()).toBe(translation);
});
});
@@ -1,91 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useSettings } from '../useSettings';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { beforeEach, describe, expect, test, vi } from 'vitest';
const settingDefaultValue = vi.hoisted(() => 42);
const settingPath = ['test', 'number'];
vi.mock('../../config.js', () => ({
default: {
contents: [
{
name: 'test',
i18n: '',
content: [
{
type: 'number',
i18n: '',
name: 'number',
default: settingDefaultValue,
},
{
type: 'bool',
i18n: '',
name: 'bool',
},
],
},
],
},
}));
describe('useSettings', () => {
describe('getSetting', () => {
test('returns stored value', () => {
const { getSetting, setSetting } = useSettings();
const key = ['setting'];
const value = 42;
setSetting(key, value);
expect(getSetting(key)).toBe(value);
});
test('returns default value', () => {
const { getSetting } = useSettings();
expect(getSetting(settingPath)).toBe(settingDefaultValue);
});
test('returns stored value instead of default value', () => {
const { getSetting, setSetting } = useSettings();
const value = 43;
expect(getSetting(settingPath)).toBe(settingDefaultValue);
setSetting(settingPath, value);
expect(getSetting(settingPath)).toBe(value);
});
test('returns undefined if there is no value', () => {
const { getSetting } = useSettings();
expect(getSetting(['test', 'bool'])).toBeUndefined();
});
});
});
beforeEach(() => {
setActivePinia(
createTestingPinia({
createSpy: vi.fn,
stubActions: false,
}),
);
});
@@ -1,40 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useSettingsPage } from '../useSettingsPage.js';
import { describe, expect, test, vi } from 'vitest';
const pushRoute = vi.fn();
vi.mock('vue-router', () => ({
useRouter: () => ({
push: pushRoute,
}),
}));
describe('useSettingsPage', () => {
describe('goToSettingsPage', () => {
test('pushes correct route', async () => {
const { goToSettingsPage } = useSettingsPage();
await goToSettingsPage('..a.b...c.d..e......');
expect(pushRoute).toHaveBeenCalledWith({
name: 'settings',
params: {
rest: ['a', 'b', 'c', 'd', 'e'],
},
});
});
});
});
@@ -1,50 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import settings from '../config.js';
import { useSettingsStore } from '../stores/settingsStore';
import { getSettingRecursively } from '../utils/getSetting';
/**
* Provides access to the stored settings.
*/
export const useSettings = function useSettings() {
/**
* Returns the value of a specific setting.
* @param {string[]} key - The settings key.
* @return The value of the setting.
*/
const getSetting = function getSetting(key) {
const settingsStore = useSettingsStore();
return (
settingsStore.get(key.join('.')) ??
getSettingRecursively(key, settings.contents ?? [])?.default
);
};
/**
* Sets the value of a specific setting.
* @param {string[]} key - The setting key.
* @param value - The new value for the setting.
*/
const setSetting = function setSetting(key, value) {
const settingsStore = useSettingsStore();
settingsStore.set(key.join('.'), value);
};
return { getSetting, setSetting };
};
@@ -0,0 +1,48 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ref, readonly } from 'vue';
import { loadSettingsConfig } from '../utils/settingsParser.js';
const config = ref(null);
const error = ref(null);
const loading = ref(false);
/**
* Provides reactive access to the parsed settings configuration.
* The config is loaded once and shared across all consumers.
*/
export function useSettingsConfig() {
async function load() {
loading.value = true;
error.value = null;
try {
config.value = await loadSettingsConfig();
} catch (e) {
error.value = e.message;
config.value = null;
} finally {
loading.value = false;
}
}
return {
config: readonly(config),
error: readonly(error),
loading: readonly(loading),
load,
};
}
@@ -1,33 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { getSettingsPagePathSegments } from '../utils/settingsPage';
import { useRouter } from 'vue-router';
export const useSettingsPage = function useSettingsPage() {
const router = useRouter();
const goToSettingsPage = function goToSettingsPage(pagePath) {
return router.push({
name: 'settings',
params: {
rest: getSettingsPagePathSegments(pagePath),
},
});
};
return { goToSettingsPage };
};
-75
View File
@@ -1,75 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { getNavigatorLanguage } from '../i18n/utils/navigatorLanguage';
import { fallbackLocale, SUPPORTED_LANGUAGES } from '@/i18n';
let defaultLanguage;
{
const navigatorLanguage = getNavigatorLanguage();
if (SUPPORTED_LANGUAGES.includes(navigatorLanguage)) {
defaultLanguage = navigatorLanguage;
} else {
defaultLanguage = fallbackLocale;
}
}
export default {
contents: [
{
name: 'general',
i18n: 'settings.settings.general.title',
content: [
{
name: 'language',
type: 'selection',
i18n: 'settings.settings.general.settings.language.title',
default: defaultLanguage,
options: SUPPORTED_LANGUAGES.map((language) => ({
name: language,
i18n: `preferences.locale.languages.${language}`,
})),
},
],
},
{
name: 'appearance',
i18n: 'settings.settings.appearance.title',
content: [
{
name: 'colorScheme',
type: 'selection',
i18n: 'settings.settings.appearance.contents.colorScheme.title',
default: 'auto',
options: [
{
name: 'auto',
i18n: 'settings.settings.appearance.contents.colorScheme.options.auto',
},
{
name: 'light',
i18n: 'settings.settings.appearance.contents.colorScheme.options.light',
},
{
name: 'dark',
i18n: 'settings.settings.appearance.contents.colorScheme.options.dark',
},
],
},
],
},
],
};
+3
View File
@@ -0,0 +1,3 @@
{
"contents": []
}
@@ -1,46 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { defineStore } from 'pinia';
import { reactive } from 'vue';
export const useSettingsStore = defineStore(
'settings',
() => {
const settings = reactive({});
const set = function set(key, value) {
settings[key] = value;
};
const get = function get(key) {
return settings[key];
};
const remove = function remove(key) {
delete settings[key];
};
const clear = function clear() {
Object.keys(settings).forEach((key) => delete settings[key]);
};
return { set, get, remove, clear, settings };
},
{
persist: true,
},
);
@@ -53,6 +53,10 @@ limitations under the License.
* @typedef {BoolSettingConfig | NumberSettingConfig | StringSettingConfig | SelectionSettingConfig | SectionSettingConfig} SettingConfigEntry * @typedef {BoolSettingConfig | NumberSettingConfig | StringSettingConfig | SelectionSettingConfig | SectionSettingConfig} SettingConfigEntry
*/ */
/**
* @typedef {Object} SettingsConfig
* @property {SettingConfigEntry[]} contents
*/
/** /**
* @typedef {{ type: 'bool', name: string, i18n: string, description?: string, default: boolean }} BoolSettingConfig * @typedef {{ type: 'bool', name: string, i18n: string, description?: string, default: boolean }} BoolSettingConfig
* @typedef {{ type: 'number', name: string, i18n: string, description?: string, default: number }} NumberSettingConfig * @typedef {{ type: 'number', name: string, i18n: string, description?: string, default: number }} NumberSettingConfig
@@ -60,6 +64,5 @@ limitations under the License.
* @typedef {{ type: 'selection', name: string, i18n: string, description?: string, default: string | string[], allowMultiple?: boolean, options: SelectionOption[] }} SelectionSettingConfig * @typedef {{ type: 'selection', name: string, i18n: string, description?: string, default: string | string[], allowMultiple?: boolean, options: SelectionOption[] }} SelectionSettingConfig
* @typedef {{ type: 'section', name: string, i18n?: string, description?: string, content: SettingConfigEntry[] }} SectionSettingConfig * @typedef {{ type: 'section', name: string, i18n?: string, description?: string, content: SettingConfigEntry[] }} SectionSettingConfig
* @typedef {BoolSettingConfig | NumberSettingConfig | StringSettingConfig | SelectionSettingConfig | SectionSettingConfig} SettingConfigEntry * @typedef {BoolSettingConfig | NumberSettingConfig | StringSettingConfig | SelectionSettingConfig | SectionSettingConfig} SettingConfigEntry
* @typedef {{ contents: SettingConfigEntry[] }} FirstLevelSettingConfigEntry * @typedef {{ contents: SettingConfigEntry[] }} SettingsConfig
* @typedef {{ contents: FirstLevelSettingConfigEntry[] }} SettingsConfig
*/ */
@@ -1,592 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the 'License');
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an 'AS IS' BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { getSetting, getSettingRecursively } from '../getSetting.js';
import { describe, test, expect } from 'vitest';
describe('getSetting', () => {
test.for([
{ settings: [], name: '', expected: undefined },
{ settings: [], name: 'a', expected: undefined },
{
settings: [
{
type: 'bool',
name: 'a',
i18n: 'a',
},
],
name: 'a',
expected: {
type: 'bool',
name: 'a',
i18n: 'a',
},
},
])('returns $expected', ({ settings, name, expected }) => {
expect(getSetting(name, settings)).toStrictEqual(expected);
});
const veryNestedSettingsList = [
{
type: 'bool',
name: '1a',
i18n: '1a',
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a',
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'bool',
name: '3a',
i18n: '3a',
},
{
type: 'section',
name: '3b',
i18n: '3b',
content: [
{
type: 'bool',
name: '4a',
i18n: '4a',
},
{
type: 'section',
name: '4b',
i18n: '4b',
content: [
{
type: 'bool',
name: '5a',
i18n: '5a',
},
{
type: 'section',
name: '5b',
i18n: '5b',
content: [
{
type: 'bool',
name: '6a',
i18n: '6a',
},
{
type: 'section',
name: '6b',
i18n: '6b',
content: [
{
type: 'bool',
name: '7a',
i18n: '7a',
},
],
},
],
},
],
},
],
},
],
},
],
},
];
test.for([
{ settings: [], path: ['a'], expected: undefined },
{
settings: [
{
type: 'bool',
name: '1a',
i18n: '1a',
},
],
path: ['1a'],
expected: {
type: 'bool',
name: '1a',
i18n: '1a',
},
},
{
settings: [
{
type: 'bool',
name: '1a',
i18n: '1a',
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a',
},
],
},
],
path: ['1a'],
expected: {
type: 'bool',
name: '1a',
i18n: '1a',
},
},
{
settings: [
{
type: 'bool',
name: '1a',
i18n: '1a',
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a',
},
],
},
],
path: ['1b', '2a'],
expected: {
type: 'bool',
name: '2a',
i18n: '2a',
},
},
{
settings: [
{
type: 'bool',
name: '1a',
i18n: '1a',
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a',
},
{
type: 'number',
name: '2b',
i18n: '2b',
default: 42,
},
],
},
],
path: ['1b', '2b'],
expected: {
type: 'number',
name: '2b',
i18n: '2b',
default: 42,
},
},
{
settings: [
{
type: 'bool',
name: '1a',
i18n: '1a',
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a',
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a',
},
],
},
],
},
],
path: ['1b', '2b', '3a'],
expected: {
type: 'string',
name: '3a',
i18n: '3a',
},
},
{
settings: [
{
type: 'bool',
name: '1a',
i18n: '1a',
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a',
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a',
},
],
},
],
},
],
path: ['1b'],
expected: {
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a',
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a',
},
],
},
],
},
},
{
settings: [
{
type: 'bool',
name: '1a',
i18n: '1a',
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a',
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a',
},
],
},
],
},
],
path: ['1b', '2b'],
expected: {
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a',
},
],
},
},
{
settings: [
{
type: 'bool',
name: '1a',
i18n: '1a',
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a',
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a',
},
],
},
],
},
],
path: ['1b', '2b', '3b'],
expected: undefined,
},
{
settings: [
{
type: 'bool',
name: '1a',
i18n: '1a',
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a',
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a',
},
],
},
],
},
],
path: ['1b', '2a', '3a'],
expected: undefined,
},
{
settings: [
{
type: 'bool',
name: '1a',
i18n: '1a',
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a',
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a',
},
],
},
],
},
],
path: ['1b', '2c', '3a'],
expected: undefined,
},
{
settings: [
{
type: 'bool',
name: '1a',
i18n: '1a',
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a',
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a',
},
],
},
],
},
],
path: ['1a', '2b', '3a'],
expected: undefined,
},
{
settings: [
{
type: 'bool',
name: '1a',
i18n: '1a',
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a',
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a',
},
],
},
],
},
],
path: ['1c', '2b', '3a'],
expected: undefined,
},
{
settings: veryNestedSettingsList,
path: ['1b', '2b', '3b', '4b', '5b', '6b', '7a'],
expected: {
type: 'bool',
name: '7a',
i18n: '7a',
},
},
{
settings: veryNestedSettingsList,
path: ['1b', '2b', '3b', '4b', '5b'],
expected: {
type: 'section',
name: '5b',
i18n: '5b',
content: [
{
type: 'bool',
name: '6a',
i18n: '6a',
},
{
type: 'section',
name: '6b',
i18n: '6b',
content: [
{
type: 'bool',
name: '7a',
i18n: '7a',
},
],
},
],
},
},
{
settings: veryNestedSettingsList,
path: ['1b', '2b', '3b', '4ba', '5b', '6b', '7a'],
expected: undefined,
},
])('returns $expected', ({ settings, path, expected }) => {
expect(getSettingRecursively(path, settings)).toStrictEqual(expected);
});
});
@@ -1,72 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { getSettingsPagePathSegments } from '../settingsPage.js';
import { describe, expect, test } from 'vitest';
describe('settingsPage', () => {
describe('getSettingsPagePathSegments', () => {
test.for([
{ path: 'a.b.c', expected: ['a', 'b', 'c'] },
{ path: 'a', expected: ['a'] },
{ path: 'a.b', expected: ['a', 'b'] },
{ path: 'a..b', expected: ['a', 'b'] },
{ path: '.a.b', expected: ['a', 'b'] },
{ path: '..a.b', expected: ['a', 'b'] },
{ path: '.a.b.', expected: ['a', 'b'] },
{ path: '..a.b..', expected: ['a', 'b'] },
{ path: 'a.b.', expected: ['a', 'b'] },
{ path: 'a.b..', expected: ['a', 'b'] },
{ path: '.a.b..', expected: ['a', 'b'] },
{ path: '..a.b.', expected: ['a', 'b'] },
{ path: '.a..b', expected: ['a', 'b'] },
{ path: '..a..b', expected: ['a', 'b'] },
{ path: '.a..b.', expected: ['a', 'b'] },
{ path: '..a..b..', expected: ['a', 'b'] },
{ path: 'a..b.', expected: ['a', 'b'] },
{ path: 'a..b..', expected: ['a', 'b'] },
{ path: '.a..b..', expected: ['a', 'b'] },
{ path: '..a..b.', expected: ['a', 'b'] },
{ path: '..a.....b.c..d....', expected: ['a', 'b', 'c', 'd'] },
{ path: 'a.b.c/', expected: ['a', 'b', 'c'] },
{ path: 'a.#b.c', expected: ['a', 'b', 'c'] },
{ path: 'a.#b.c/', expected: ['a', 'b', 'c'] },
{ path: 'a1.#b.c/', expected: ['a1', 'b', 'c'] },
{ path: 'a-1.#b.c/', expected: ['a-1', 'b', 'c'] },
{ path: 'a-1.b@.c', expected: ['a-1', 'b', 'c'] },
{
path: '....@a/#...)!&§[b.#§c..d....',
expected: ['a', 'b', 'c', 'd'],
},
{
path: '....@a/#...)!&§[b.#§c..dä....',
expected: ['a', 'b', 'c', 'd'],
},
{
path: '..,...~..@a/#.+..)!&§[b.#§c..dä..)..',
expected: ['a', 'b', 'c', 'd'],
},
{ path: 'a.@.b', expected: ['a', 'b'] },
{ path: 1, expected: [] },
{ path: false, expected: [] },
{ path: true, expected: [] },
{ path: null, expected: [] },
{ path: undefined, expected: [] },
])('returns $expected', ({ path, expected }) => {
expect(getSettingsPagePathSegments(path)).toStrictEqual(expected);
});
});
});
File diff suppressed because it is too large Load Diff
-48
View File
@@ -1,48 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export const getSetting = function getSetting(name, settings) {
return settings.filter((setting) => setting.name === name)[0];
};
export const getSettingRecursively = function getSettingRecursively(
sectionPath,
settings,
) {
const oneSettingsLevel = function oneSettingsLevel(cursor, acc) {
const setting = getSetting(cursor[0], acc.content);
if (!setting) return;
if (setting.type === undefined) setting.type = 'section';
if (setting.type === 'section') {
const newCursor = cursor.slice(1, cursor.length);
if (newCursor.length !== 0) {
return oneSettingsLevel(
cursor.slice(1, cursor.length),
setting,
);
} else {
return setting;
}
} else if (cursor.length <= 1) {
return setting;
} else {
return;
}
};
return oneSettingsLevel(sectionPath, { content: settings });
};
@@ -1,31 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Extracts the segments of a settings page path and normalizes each segment.
* @param {string} path - The settings page path.
* @returns {Array} The settings page path segments.
*/
export const getSettingsPagePathSegments = function getSettingsPagePathSegments(
path,
) {
if (typeof path !== 'string') return [];
return path
.replace(/^\.+|\.+$/g, '')
.split('.')
.map((segment) => segment.replace(/[^a-zA-Z0-9-]/g, ''))
.filter(Boolean);
};
@@ -0,0 +1,38 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { validateSettingsConfig } from './settingsValidator.js';
/**
* Loads and parses the settings configuration via dynamic import.
* @returns {Promise<import('../types/settingsConfig').SettingsConfig>}
*/
export async function loadSettingsConfig() {
let raw;
try {
raw = (await import('../settings.json')).default;
} catch (e) {
throw new Error(`[settings] Failed to load settings.json: ${e.message}`);
}
const result = validateSettingsConfig(raw);
if (!result.valid) {
throw new Error(result.error);
}
return result.config;
}
@@ -16,59 +16,47 @@ limitations under the License.
const VALID_TYPES = ['bool', 'number', 'string', 'selection', 'section']; const VALID_TYPES = ['bool', 'number', 'string', 'selection', 'section'];
export const assertString = function assertString(value, path) { function assertString(value, path) {
if (typeof value !== 'string' || value.trim() === '') { if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`[settings] "${path}" must be a non-empty string`); throw new Error(`[settings] "${path}" must be a non-empty string`);
} }
}; }
export const assertType = function assertType(value, path) { function assertType(value, path) {
if (!VALID_TYPES.includes(value)) { if (!VALID_TYPES.includes(value)) {
throw new Error( throw new Error(
`[settings] "${path}" has invalid type "${value}". Must be one of: ${VALID_TYPES.join(', ')}`, `[settings] "${path}" has invalid type "${value}". Must be one of: ${VALID_TYPES.join(', ')}`
); );
} }
}; }
export const validateSelectionOptions = function validateSelectionOptions( function validateSelectionOptions(options, path) {
options,
path,
) {
if (!Array.isArray(options) || options.length === 0) { if (!Array.isArray(options) || options.length === 0) {
throw new Error( throw new Error(`[settings] "${path}.options" must be a non-empty array`);
`[settings] "${path}.options" must be a non-empty array`,
);
} }
options.forEach((opt, i) => { options.forEach((opt, i) => {
assertString(opt.name, `${path}.options[${i}].name`); assertString(opt.name, `${path}.options[${i}].name`);
assertString(opt.i18n, `${path}.options[${i}].i18n`); assertString(opt.i18n, `${path}.options[${i}].i18n`);
}); });
}; }
export const validateEntry = function validateEntry(entry, path) { function validateEntry(entry, path) {
assertType(entry.type, `${path}.type`); assertType(entry.type, `${path}.type`);
assertString(entry.name, `${path}.name`);
assertString(entry.i18n, `${path}.i18n`);
if (entry.type === 'section') { if (entry.type === 'section') {
assertString(entry.name, `${path}.name`);
if (!Array.isArray(entry.content)) { if (!Array.isArray(entry.content)) {
throw new Error(`[settings] "${path}.content" must be an array`); throw new Error(`[settings] "${path}.content" must be an array`);
} }
entry.content.forEach((child, i) => entry.content.forEach((child, i) =>
validateEntry(child, `${path}.content[${i}]`), validateEntry(child, `${path}.content[${i}]`)
); );
return; return;
} }
if (entry.type === 'selection') { assertString(entry.name, `${path}.name`);
validateSelectionOptions(entry.options, path); assertString(entry.i18n, `${path}.i18n`);
if (typeof entry.allowMultiple !== 'boolean' && entry.allowMultiple) {
throw new Error(
`[settings] "${path}.allowMultiple" must be a boolean`,
);
}
}
if (entry.default !== undefined) { if (entry.default !== undefined) {
if (entry.type === 'bool' && typeof entry.default !== 'boolean') { if (entry.type === 'bool' && typeof entry.default !== 'boolean') {
throw new Error(`[settings] "${path}.default" must be a boolean`); throw new Error(`[settings] "${path}.default" must be a boolean`);
@@ -80,67 +68,21 @@ 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) { validateSelectionOptions(entry.options, path);
if (!Array.isArray(entry.default)) { if (typeof entry.allowMultiple !== 'boolean') {
throw new Error( throw new Error(`[settings] "${path}.allowMultiple" must be a boolean`);
`[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`,
);
} }
} }
} }
} }
};
export const validateFirstLevelSection = function validateFirstLevelSection(
section,
path,
) {
assertString(section.name);
assertString(section.i18n);
if (!Array.isArray(section.content)) {
throw new Error(`[settings] "${path}.content" must be an array`);
}
section.content.forEach((entry, i) =>
validateEntry(entry, `${path}.content[${i}]`),
);
};
/** /**
* Validates a raw settings config object. * Validates a raw settings config object.
* @param {unknown} raw * @param {unknown} raw
* @returns {{ valid: true, config: import('../types/settingsConfig').SettingsConfig } | { valid: false, error: string }} * @returns {{ valid: true, config: import('../types/settingsConfig').SettingsConfig } | { valid: false, error: string }}
*/ */
export const validateSettingsConfig = function validateSettingsConfig(raw) { export function validateSettingsConfig(raw) {
try {
if (!raw || typeof raw !== 'object') { if (!raw || typeof raw !== 'object') {
throw new Error('[settings] Config must be an object'); throw new Error('[settings] Config must be an object');
} }
@@ -148,6 +90,10 @@ export const validateSettingsConfig = function validateSettingsConfig(raw) {
throw new Error('[settings] "contents" must be an array'); throw new Error('[settings] "contents" must be an array');
} }
raw.contents.forEach((entry, i) => raw.contents.forEach((entry, i) =>
validateFirstLevelSection(entry, `contents[${i}]`), validateEntry(entry, `contents[${i}]`)
); );
}; return { valid: true, config: raw };
} catch (e) {
return { valid: false, error: e.message };
}
}
+2 -89
View File
@@ -15,43 +15,11 @@ limitations under the License.
--> -->
<script setup> <script setup>
import SettingsPage from '../components/SettingsPage.vue';
import settingsConfiguration from '../config.js';
import { getSettingRecursively } from '../utils/getSetting.js';
import LeftSidebarLayout from '@/layouts/LeftSidebarLayout.vue'; import LeftSidebarLayout from '@/layouts/LeftSidebarLayout.vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const settings = settingsConfiguration.contents;
const activePath = computed(() => {
const segments = route.path.split('/').filter(Boolean);
return segments.slice(1, segments.length);
});
const activeSection = ref(null);
const updateSettings = async function updateSettings() {
const setting = getSettingRecursively(activePath.value, settings);
if (setting) {
activeSection.value = setting;
} else {
await router.push('/settings');
}
};
watch(route, (newRoute) => updateSettings(newRoute));
onMounted(updateSettings);
const toggleSidebarIfSmallScreen = function toggleSidebarIfSmallScreen(
toggleSidebarFunction,
) {
if (matchMedia('(max-width: 48rem)').matches) toggleSidebarFunction();
};
</script> </script>
<template> <template>
@@ -62,32 +30,8 @@ const toggleSidebarIfSmallScreen = function toggleSidebarIfSmallScreen(
</h1> </h1>
</header> </header>
<LeftSidebarLayout class="layout"> <LeftSidebarLayout class="layout">
<template #sidebar="{ toggleSidebar }"> <template #sidebar>
<ul class="sidebar-sections-list">
<li v-for="section in settings" :key="section">
<RouterLink
:to="`/settings/${section.name}`"
class="button button-link link sidebar-section"
:class="{
active:
activeSection &&
activeSection.name === section.name,
}"
@click="toggleSidebarIfSmallScreen(toggleSidebar)"
>
{{ t(section.i18n) }}
</RouterLink>
</li>
</ul>
</template> </template>
<div v-if="activeSection">
<div class="settings-main-content">
<SettingsPage :setting="activeSection" :path="activePath" />
</div>
</div>
<div v-else class="main-content--no-section">
{{ t('settings.noSectionSelected') }}
</div>
</LeftSidebarLayout> </LeftSidebarLayout>
</div> </div>
</template> </template>
@@ -102,10 +46,6 @@ const toggleSidebarIfSmallScreen = function toggleSidebarIfSmallScreen(
flex-direction: column; flex-direction: column;
} }
.active {
background-color: var(--light-hover);
}
.header { .header {
padding: var(--main-content-padding-y) var(--main-content-padding-x); padding: var(--main-content-padding-y) var(--main-content-padding-x);
} }
@@ -113,31 +53,4 @@ const toggleSidebarIfSmallScreen = function toggleSidebarIfSmallScreen(
.header h1 { .header h1 {
margin: 0; margin: 0;
} }
.sidebar-sections-list {
list-style: none;
margin: 0;
padding: 0;
}
.sidebar-section {
--padding: 0.8em;
border-radius: var(--padding);
padding: var(--padding);
margin-bottom: 4px;
width: calc(100% - 2 * var(--padding));
text-align: start;
font-size: 1rem;
display: block;
}
.settings-main-content {
padding: 0 30%;
}
@media (max-width: 80em) {
.settings-main-content {
padding: 0;
}
}
</style> </style>
@@ -1,290 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import SettingsView from '../SettingsView.vue';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { nextTick } from 'vue';
const settingsHoisted = vi.hoisted(() => [
{
name: 's0',
i18n: 'settings.s0',
content: [],
},
{
name: 's1',
i18n: 'settings.s1',
content: [
{
type: 'bool',
name: 'b0',
i18n: 'settings.s1.b0',
},
{
type: 'number',
name: 'n0',
i18n: 'settings.s1.n0',
},
{
type: 'string',
name: 'st0',
i18n: 'settings.s1.st0',
},
{
type: 'selection',
name: 'se0',
i18n: 'settings.s1.se0',
options: [
{ name: 'o0', i18n: 'settings.s1.se0.o0' },
{ name: 'o1', i18n: 'settings.s1.se0.o1' },
{ name: 'o2', i18n: 'settings.s1.se0.o2' },
{ name: 'o3', i18n: 'settings.s1.se0.o3' },
],
},
{
type: 'section',
name: 's0',
i18n: 'settings.s1.s0',
content: [],
},
{
type: 'section',
name: 's1',
i18n: 'settings.s1.s1',
content: [
{
type: 'bool',
name: 'b0',
i18n: 'settings.s1.s1.b0',
default: true,
},
],
},
],
},
{
name: 's2',
i18n: 'settings.s2',
content: [
{
type: 'section',
name: 's0',
i18n: 'settings.s2.s0',
content: [
{
type: 'bool',
name: 'b0',
i18n: 'settings.s2.s0.b0',
default: true,
},
],
},
],
},
{
name: 's3',
i18n: 'settings.s3',
content: [
{
type: 'section',
name: 's0',
i18n: 'settings.s3.s0',
content: [],
},
],
},
{
name: 's4',
i18n: 'settings.s4',
content: [
{
type: 'selection',
name: 'se0',
i18n: 'settings.s4.se0',
options: [
{ name: 'o0', i18n: 'settings.s4.se0.o0' },
{ name: 'o1', i18n: 'settings.s4.se0.o1' },
{ name: 'o2', i18n: 'settings.s4.se0.o2' },
{ name: 'o3', i18n: 'settings.s4.se0.o3' },
],
},
],
},
{
name: 's5',
i18n: 'settings.s5',
content: [
{
type: 'string',
name: 'st0',
i18n: 'settings.s5.st0',
},
],
},
{
name: 's6',
i18n: 'settings.s6',
content: [
{
type: 'number',
name: 'n0',
i18n: 'settings.s6.n0',
},
],
},
{
name: 's7',
i18n: 'settings.s7',
content: [
{
type: 'bool',
name: 'b0',
i18n: 'settings.s7.b0',
},
],
},
{
name: 's8',
i18n: 'settings.s8',
content: [
{
type: 'section',
name: 's0',
i18n: 'settings.s8.s0',
content: [
{
type: 'section',
name: 's0',
i18n: 'settings.s8.s0.s0',
content: [
{
type: 'section',
name: 's0',
i18n: 'settings.s8.s0.s0.s0',
content: [
{
type: 'selection',
name: 'se0',
i18n: 'settings.s8.s0.s0.s0.se0',
allowMultiple: true,
options: [],
},
],
},
],
},
],
},
],
},
]);
const settings = settingsHoisted.values();
const originalCurrentSection = 's0';
let currentSection = originalCurrentSection;
vi.mock('../../config.js', () => ({
default: { contents: settingsHoisted },
}));
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
useRoute: () => ({
path: `/settings/${currentSection}`,
}),
};
});
const getWrapper = function getWrapper({ translations = {} } = {}) {
return mountComponent(SettingsView, {}, translations, [], {});
};
describe('SettingsView', () => {
describe('heading', () => {
test('shows correct translation', () => {
const translation = 'Settings';
const wrapper = getWrapper({
translations: {
'preferences.settings': translation,
},
});
const header = wrapper.find('header');
expect(header.text()).toBe(translation);
});
});
describe('sidebar', () => {
test('shows all top-level sections', async () => {
const wrapper = getWrapper();
await nextTick();
const sidebarItems = wrapper.findAll('.sidebar-sections-list > li');
settings.forEach((setting, index) => {
const sidebarItem = sidebarItems[index];
expect(sidebarItem.text()).toBe(setting.i18n);
const link = sidebarItem.find('*');
expect(link.attributes('href')).toBe(
`/settings/${setting.name}`,
);
expect(link.classes('active')).toBe(
setting.name === currentSection,
);
});
});
});
describe('main', () => {
describe('correct element', () => {
test('shows that no section is selected', async () => {
currentSection = '';
const wrapper = getWrapper();
await nextTick();
const mainContent = wrapper.find('.settings-main-content');
const mainContentNoSection = wrapper.find(
'.main-content--no-section',
);
expect(mainContent.exists()).toBe(false);
expect(mainContentNoSection.exists()).toBe(true);
});
test('shows main content', async () => {
const wrapper = getWrapper();
await nextTick();
const mainContent = wrapper.find('.settings-main-content');
const mainContentNoSection = wrapper.find(
'.main-content--no-section',
);
expect(mainContent.exists()).toBe(true);
expect(mainContentNoSection.exists()).toBe(false);
});
});
});
});
beforeEach(() => {
currentSection = originalCurrentSection;
});
@@ -1,80 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import 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" :class="{ expanded }">
<div class="sidebar-controls">
<SidebarExpandButton
@click="toggleSidebar"
class="sidebar-expand-button"
/>
</div>
<div class="sidebar-content" v-show="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 {
border: none;
}
.sidebar:not(.expanded) .sidebar-expand-button {
display: none;
}
}
.sidebar-content {
padding: 20px;
padding-top: 0;
}
</style>
@@ -0,0 +1,28 @@
<!--
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.
-->
<template>
<nav class="sidebar">
<slot />
</nav>
</template>
<style scoped>
.sidebar {
border-right: 1px solid var(--border);
padding: 20px;
}
</style>
@@ -1,41 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import UIIcon from '@/features/icons/components/UIIcon.vue';
const emit = defineEmits(['click']);
const click = function click(event) {
emit('click', event);
};
</script>
<template>
<button class="button sidebar-expand-button">
<UIIcon 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>
+18 -15
View File
@@ -15,22 +15,33 @@ limitations under the License.
*/ */
import { createI18n } from 'vue-i18n'; import { createI18n } from 'vue-i18n';
import getCurrentLanguage from './utils/currentLanguage';
export const fallbackLocale = 'en'; export const fallbackLocale = 'en';
export const LANGUAGES_RTL = ['ar'];
export const SUPPORTED_LANGUAGES = ['en', 'de', 'fr', 'es', 'pt', 'it']; export const LANGUAGES_RTL = [
'ar'
];
export const SUPPORTED_LANGUAGES = [
'en',
'de',
'fr',
'es',
'it',
'pt'
];
export const i18n = createI18n({ export const i18n = createI18n({
legacy: false, legacy: false,
locale: fallbackLocale, locale: getCurrentLanguage(),
fallbackLocale, fallbackLocale: fallbackLocale,
messages: {}, messages: {}
}); });
const loadedLanguages = new Set(); const loadedLanguages = new Set();
export const loadLanguage = async function loadLanguage(locale) { export async function loadLanguage (locale) {
if (!SUPPORTED_LANGUAGES.includes(locale)) { if (!SUPPORTED_LANGUAGES.includes(locale)) {
locale = fallbackLocale; locale = fallbackLocale;
} }
@@ -38,7 +49,7 @@ export const loadLanguage = async function loadLanguage(locale) {
if (loadedLanguages.has(locale)) { if (loadedLanguages.has(locale)) {
i18n.global.locale.value = locale; i18n.global.locale.value = locale;
return; return;
} };
const messages = (await import(`./locales/${locale}.json`)).default; const messages = (await import(`./locales/${locale}.json`)).default;
@@ -47,11 +58,3 @@ export const loadLanguage = async function loadLanguage(locale) {
loadedLanguages.add(locale); loadedLanguages.add(locale);
}; };
export const setLocale = async function setLocale(locale) {
await loadLanguage(locale);
document.documentElement.lang = locale;
document.documentElement.dir = LANGUAGES_RTL.includes(locale)
? 'rtl'
: 'ltr';
};
+7 -75
View File
@@ -15,42 +15,16 @@ limitations under the License.
--> -->
<script setup> <script setup>
import AppSidebar from '@/features/sidebar/components/AppSidebar.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" :class="{ expanded }"> <div class="layout-container">
<div class="sidebar-expand-button-container"> <Sidebar>
<SidebarExpandButton <slot name="sidebar" />
class="sidebar-expand-button" </Sidebar>
@click="toggleSidebar"
/>
</div>
<AppSidebar
@toggle-expanded="toggleSidebar"
:expanded="expanded"
class="sidebar"
>
<slot name="sidebar" :toggleSidebar="toggleSidebar" />
</AppSidebar>
<main class="main-content"> <main class="main-content">
<slot :toggleSidebar="toggleSidebar" /> <slot />
</main> </main>
</div> </div>
</template> </template>
@@ -58,53 +32,11 @@ const toggleSidebar = function toggleSidebar() {
<style scoped> <style scoped>
.layout-container { .layout-container {
display: grid; display: grid;
grid-template-columns: calc(32px + 2em) 1fr; grid-template-columns: min(24%, 280px) 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>
+3
View File
@@ -0,0 +1,3 @@
# Datenschutzerklärung
Hier steht deine Datenschutzerklärung auf Deutsch.
+3
View File
@@ -0,0 +1,3 @@
# Privacy Policy
Here is your privacy policy in English.
+3
View File
@@ -0,0 +1,3 @@
# Política de privacidad
Aquí tienes tu política de privacidad en español.
+3
View File
@@ -0,0 +1,3 @@
# Politique de confidentialité
Voici ta politique de confidentialité en français.
+3
View File
@@ -0,0 +1,3 @@
# Informativa sulla privacy
Qui trovi la tua informativa sulla privacy in italiano.
+3
View File
@@ -0,0 +1,3 @@
# Política de Privacidade
Aqui está a tua Política de Privacidade em português.
+4 -50
View File
@@ -1,5 +1,4 @@
{ {
"loading": "Laden ...",
"search": { "search": {
"searchBar": { "searchBar": {
"submit": "Suchen", "submit": "Suchen",
@@ -38,56 +37,11 @@
} }
} }
}, },
"settings": { "slogan": "Gebaut zum Suchen.",
"input": {
"done": {
"title": "Wert speichern",
"ariaLabel": "Speichert den eingegebenen Wert"
}
},
"switch": {
"title": "Wert umschalten",
"ariaLabel": "Schaltet den Wert um"
},
"pages": {
"link": {
"ariaLabel": "Geht zur Einstellungsseite {page}"
}
},
"noSectionSelected": "Kein Einstellungsabschnitt ist ausgewählt.",
"selection": {
"option": {
"ariaLabel": "Wählt die Option {option} aus"
}
},
"settings": {
"appearance": {
"title": "Erscheinungsbild",
"contents": {
"colorScheme": {
"title": "Farbschema",
"options": {
"auto": "An Systemfarbschema anpassen",
"light": "Hell",
"dark": "Dunkel"
}
}
}
},
"general": {
"title": "Allgemein",
"settings": {
"language": {
"title": "Sprache"
}
}
}
}
},
"legal": { "legal": {
"notice": { "privacy": {
"title": "Impressum", "title": "Datenschutzerklärung"
"loadError": "Das Impressum konnte nicht geladen werden."
} }
} }
} }
+4 -50
View File
@@ -1,5 +1,4 @@
{ {
"loading": "Loading ...",
"search": { "search": {
"searchBar": { "searchBar": {
"submit": "Search", "submit": "Search",
@@ -38,56 +37,11 @@
} }
} }
}, },
"settings": { "slogan": "Built to search.",
"input": {
"done": {
"title": "Save value",
"ariaLabel": "Saves the entered value"
}
},
"switch": {
"title": "Toggle value",
"ariaLabel": "Toggles the value"
},
"pages": {
"link": {
"ariaLabel": "Goes to settings page {page}"
}
},
"noSectionSelected": "No settings section is selected.",
"selection": {
"option": {
"ariaLabel": "Selects option {option}"
}
},
"settings": {
"appearance": {
"title": "Appearance",
"contents": {
"colorScheme": {
"title": "Color Scheme",
"options": {
"auto": "Adapt to system color scheme",
"light": "Light",
"dark": "Dark"
}
}
}
},
"general": {
"title": "General",
"settings": {
"language": {
"title": "Language"
}
}
}
}
},
"legal": { "legal": {
"notice": { "privacy": {
"title": "Legal Notice", "title": "Privacy Policy"
"loadError": "The legal notice could not be loaded."
} }
} }
} }
+5 -22
View File
@@ -1,5 +1,4 @@
{ {
"loading": "Cargando ...",
"search": { "search": {
"searchBar": { "searchBar": {
"submit": "Buscar", "submit": "Buscar",
@@ -38,27 +37,11 @@
} }
} }
}, },
"settings": { "slogan": "Hecho para buscar.",
"input": {
"done": { "legal": {
"title": "Guardar valor", "privacy": {
"ariaLabel": "Guarda el valor introducido" "title": "Política de Privacidad"
}
},
"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}"
}
} }
} }
} }
+5 -22
View File
@@ -1,5 +1,4 @@
{ {
"loading": "Chargement ...",
"search": { "search": {
"searchBar": { "searchBar": {
"submit": "Rechercher", "submit": "Rechercher",
@@ -38,27 +37,11 @@
} }
} }
}, },
"settings": { "slogan": "Conçu pour chercher.",
"input": {
"done": { "legal": {
"title": "Enregistrer la valeur", "privacy": {
"ariaLabel": "Enregistre la valeur saisie" "title": "Politique de Confidentialité"
}
},
"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}"
}
} }
} }
} }
+5 -22
View File
@@ -1,5 +1,4 @@
{ {
"loading": "Caricamento ...",
"search": { "search": {
"searchBar": { "searchBar": {
"submit": "Cerca", "submit": "Cerca",
@@ -38,27 +37,11 @@
} }
} }
}, },
"settings": { "slogan": "Costruito per cercare.",
"input": {
"done": { "legal": {
"title": "Salva valore", "privacy": {
"ariaLabel": "Salva il valore inserito" "title": "Politica di Privacy"
}
},
"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}"
}
} }
} }
} }
+5 -22
View File
@@ -1,5 +1,4 @@
{ {
"loading": "A carregar ...",
"search": { "search": {
"searchBar": { "searchBar": {
"submit": "Pesquisar", "submit": "Pesquisar",
@@ -38,27 +37,11 @@
} }
} }
}, },
"settings": { "slogan": "Feito para pesquisar.",
"input": {
"done": { "legal": {
"title": "Guardar valor", "privacy": {
"ariaLabel": "Guarda o valor introduzido" "title": "Política de Privacidade"
}
},
"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}"
}
} }
} }
} }
+12 -52
View File
@@ -14,57 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { i18n, loadLanguage, fallbackLocale } from './i18n'; import { createApp } from 'vue'
import router from './router'; import App from './App.vue'
import { createPinia } from 'pinia'; import { i18n, loadLanguage } from './i18n';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'; import getCurrentLanguage from './utils/currentLanguage';
import { createApp } from 'vue'; import router from './router'
import './styles/common.css'
import './styles/variables/colors.css'
import App from './App.vue'; await loadLanguage(getCurrentLanguage());
import './styles/common.css'; createApp(App)
import rawFonts from './styles/fonts.json'; .use(router)
import './styles/variables/colors.css'; .use(i18n)
import './styles/variables/dimensions.css'; .mount('#app')
import settings from './features/settings/config.js';
import { getAssetsUrl } from './config/env';
import { validateSettingsConfig } from './features/settings/utils/settingsValidator';
(async () => {
const fonts = rawFonts.map(
(font) =>
new FontFace(
font.family ?? '',
Array.isArray(font.source)
? font.source
.map(
(source) =>
`url('${
source.url
? new URL(source.url, getAssetsUrl())
.href
: ''
}') format('${source.format ?? ''}')`,
)
.join(',')
: '',
{
weight: font.weight ?? undefined,
style: font.style ?? undefined,
},
),
);
await Promise.all(fonts.map((font) => font.load()));
fonts.forEach((font) => document.fonts.add(font));
await loadLanguage(fallbackLocale);
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
validateSettingsConfig(settings);
createApp(App).use(router).use(i18n).use(pinia).mount('#app');
})();
+31 -41
View File
@@ -14,83 +14,73 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import LegalNoticeView from '@/features/legal/views/LegalNoticeView.vue';
import SearchResultsView from '@/features/search/views/SearchResultsView.vue';
import SearchView from '@/features/search/views/SearchView.vue';
import SettingsView from '@/features/settings/views/SettingsView.vue';
import NotFound from '@/views/NotFound.vue';
import { i18n } from '@/i18n';
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import { i18n } from '@/i18n';
import SearchView from '../views/SearchView.vue';
import SearchResultsView from '@/features/search/views/SearchResultsView.vue';
import SettingsView from '@/features/settings/views/SettingsView.vue';
import NotFound from '../views/NotFound.vue';
import PrivacyPolicyView from '@/features/legal/views/PrivacyPolicyView.vue';
const routes = [ const routes = [
{ {
path: '/', path: '/',
name: 'startPage', name: 'startPage',
component: SearchView, component: SearchView
}, },
{ {
path: '/search', path: '/search',
name: 'searchResults', name: 'searchResults',
component: SearchResultsView, component: SearchResultsView,
props: (route) => ({ props: route => ({
searchQuery: route.query.q, searchQuery: route.query.q
}), }),
meta: { meta: {
title: (route) => route.query.q, title: (route) => route.query.q
}, }
}, },
{ {
path: '/legal/notice', path: '/settings',
name: 'legalNotice',
component: LegalNoticeView,
meta: {
title: () => i18n.global.t('legal.notice.title'),
},
},
{
path: '/settings/:rest(.*)*',
name: 'settings', name: 'settings',
component: SettingsView, component: SettingsView,
meta: { meta: {
title: () => i18n.global.t('preferences.settings'), title: () => i18n.global.t('preferences.settings')
}, }
}, },
{ {
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',
name: 'notFound', name: 'notFound',
component: NotFound, component: NotFound
}, },
{
path: '/privacy',
name: 'privacyPolicy',
component: PrivacyPolicyView,
meta: {
title: () => 'Privacy Policy'
}
},
]; ];
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes, routes
}); });
export const updatePageTitle = function updatePageTitle(route) { // set page title
router.afterEach(to => {
const title = const title =
typeof route.meta.title === 'function' typeof to.meta.title === 'function'
? route.meta.title(route) ? to.meta.title(to)
: route.meta.title; : to.meta.title;
if (title) { if (title) {
document.title = `${title} - Seekra`; document.title = `${title} - Seekra`;
} else { } else {
document.title = 'Seekra'; document.title = 'Seekra';
}
}; };
// remove trailing slash(es)
router.beforeEach((route) => {
if (route.path !== '/' && route.path.endsWith('/')) {
return {
path: route.path.replace(/\/+$/, ''),
query: route.query,
hash: route.hash,
replace: true,
};
}
}); });
export default router; export default router;
+1 -80
View File
@@ -15,102 +15,23 @@ limitations under the License.
*/ */
body { body {
background-color: var(--light-bg);
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:hover:not(.button-link) {
.link:focus-visible:not(.button-link) {
outline: none;
text-decoration: underline; text-decoration: underline;
} }
.button-link {
color: var(--dark);
}
.button-link:hover {
color: var(--dark);
}
.button-link:visited {
color: var(--dark);
}
input { input {
color: var(--dark); color: var(--dark);
} }
.input {
padding: var(--input-padding);
display: block;
border-radius: calc(var(--input-padding) + 0.5 * var(--input-height) + 1px);
height: var(--input-height);
border: 1px solid var(--border);
background: none;
transition: border-color 0.2s ease;
color: inherit;
font-family: inherit;
font-size: inherit;
}
.input:focus-visible {
border-color: var(--primary-color);
outline: none;
}
.main-content-padding { .main-content-padding {
padding: var(--main-content-padding); padding: var(--main-content-padding);
width: calc(100% - var(--main-content-padding-x) * 2); width: calc(100% - var(--main-content-padding-x) * 2);
} }
.button {
background-color: var(--light-bg);
border: none;
cursor: pointer;
font-family: inherit;
font-size: inherit;
color: inherit;
}
.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);
}
-32
View File
@@ -1,32 +0,0 @@
[
{
"family": "Inter",
"weight": "100 900",
"style": "normal",
"source": [
{
"url": "fonts/Inter/Inter-Variable.woff2",
"format": "woff2"
},
{
"url": "fonts/Inter/Inter-Variable.ttf",
"format": "truetype"
}
]
},
{
"family": "Inter",
"weight": "100 900",
"style": "italic",
"source": [
{
"url": "fonts/Inter/Inter-Italic-Variable.woff2",
"format": "woff2"
},
{
"url": "fonts/Inter/Inter-Italic-Variable.ttf",
"format": "truetype"
}
]
}
]
@@ -14,9 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export class FetchError extends Error { form {
constructor(message) { display: flex;
super(message); justify-content: center;
this.name = 'FetchError'; margin-top: 60px;
} }
footer{
text-align: center;
margin-top: 20px;
} }
+5 -14
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
body { #app-wrapper {
--primary-color-l-8: oklch(0.897 0.202 260); --primary-color-l-8: oklch(0.897 0.202 260);
--primary-color-l-7: oklch(0.847 0.202 260); --primary-color-l-7: oklch(0.847 0.202 260);
--primary-color-l-6: oklch(0.797 0.202 260); --primary-color-l-6: oklch(0.797 0.202 260);
@@ -82,15 +82,10 @@ body {
--blue-box-shadow: oklch(0.52 0.15 268 / 0.25); --blue-box-shadow: oklch(0.52 0.15 268 / 0.25);
--light-hover: var(--light-d-2); --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) { @media (prefers-color-scheme: dark) {
body.color-scheme-auto { #app-wrapper.color-scheme-auto {
--dark-l-8: var(--white-d-8); --dark-l-8: var(--white-d-8);
--dark-l-7: var(--white-d-7); --dark-l-7: var(--white-d-7);
--dark-l-6: var(--white-d-6); --dark-l-6: var(--white-d-6);
@@ -102,7 +97,7 @@ body {
--dark: var(--white); --dark: var(--white);
--light: var(--black); --light: var(--black);
--light-d-1: oklch(0.1 0 0); --light-d-1: oklch(0.10 0 0);
--light-d-2: var(--black-l-2); --light-d-2: var(--black-l-2);
--light-d-3: var(--black-l-3); --light-d-3: var(--black-l-3);
--light-d-4: var(--black-l-4); --light-d-4: var(--black-l-4);
@@ -119,12 +114,10 @@ body {
--gray-box-shadow: oklch(0.25 0.0001 271 / 0.7); --gray-box-shadow: oklch(0.25 0.0001 271 / 0.7);
--light-hover: var(--light-d-5); --light-hover: var(--light-d-5);
--invert: invert(1);
} }
} }
body.dark { #app-wrapper.dark {
--dark-l-8: var(--white-d-8); --dark-l-8: var(--white-d-8);
--dark-l-7: var(--white-d-7); --dark-l-7: var(--white-d-7);
--dark-l-6: var(--white-d-6); --dark-l-6: var(--white-d-6);
@@ -136,7 +129,7 @@ body.dark {
--dark: var(--white); --dark: var(--white);
--light: var(--black); --light: var(--black);
--light-d-1: oklch(0.1 0 0); --light-d-1: oklch(0.10 0 0);
--light-d-2: var(--black-l-2); --light-d-2: var(--black-l-2);
--light-d-3: var(--black-l-3); --light-d-3: var(--black-l-3);
--light-d-4: var(--black-l-4); --light-d-4: var(--black-l-4);
@@ -153,6 +146,4 @@ body.dark {
--gray-box-shadow: oklch(0.25 0.0001 271 / 0.7); --gray-box-shadow: oklch(0.25 0.0001 271 / 0.7);
--light-hover: var(--light-d-5); --light-hover: var(--light-d-5);
--invert: invert(1);
} }
-4
View File
@@ -1,4 +0,0 @@
:root {
--input-padding: 0.7em;
--input-height: 1em;
}
-61
View File
@@ -1,61 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { createTestingPinia } from '@pinia/testing';
import { mount } from '@vue/test-utils';
import { vi } from 'vitest';
import { createI18n } from 'vue-i18n';
import { createMemoryHistory, createRouter } from 'vue-router';
export const mountComponent = function mountComponent(
component,
options = {},
i18nMessages = {},
routes = [],
piniaOptions = {},
) {
const { setupStores, ...testingPiniaOptions } = piniaOptions;
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: i18nMessages,
},
});
const router = createRouter({
history: createMemoryHistory(),
routes,
});
const testingPinia = createTestingPinia({
...testingPiniaOptions,
stubActions: false,
createSpy: vi.fn,
});
if (setupStores) {
setupStores();
}
return mount(component, {
global: {
plugins: [i18n, router, testingPinia],
},
...options,
});
};
-136
View File
@@ -1,136 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ensureUnit } from '../cssDimensions';
import { describe, expect, test } from 'vitest';
describe('cssDimensions', () => {
test.for([
{ dimension: null, expected: '0px' },
{ dimension: undefined, expected: '0px' },
{ dimension: 0, expected: '0px' },
{ dimension: -0, expected: '0px' },
{ dimension: -1, expected: '-1px' },
{ dimension: -42, expected: '-42px' },
{ dimension: 1, expected: '1px' },
{ dimension: 42, expected: '42px' },
{ dimension: 0.0, expected: '0px' },
{ dimension: -0.0, expected: '0px' },
{ dimension: -1.0, expected: '-1px' },
{ dimension: -42.0, expected: '-42px' },
{ dimension: 1.0, expected: '1px' },
{ dimension: 42.0, expected: '42px' },
{ dimension: 0.42, expected: '0.42px' },
{ dimension: -0.42, expected: '-0.42px' },
{ dimension: -1.42, expected: '-1.42px' },
{ dimension: -42.42, expected: '-42.42px' },
{ dimension: 1.42, expected: '1.42px' },
{ dimension: 42.42, expected: '42.42px' },
{ dimension: '0', expected: '0px' },
{ dimension: '-0', expected: '-0px' },
{ dimension: '-1', expected: '-1px' },
{ dimension: '-42', expected: '-42px' },
{ dimension: '1', expected: '1px' },
{ dimension: '42', expected: '42px' },
{ dimension: '0.0', expected: '0.0px' },
{ dimension: '-0.0', expected: '-0.0px' },
{ dimension: '-1.0', expected: '-1.0px' },
{ dimension: '-42.0', expected: '-42.0px' },
{ dimension: '1.0', expected: '1.0px' },
{ dimension: '42.0', expected: '42.0px' },
{ dimension: '0.42', expected: '0.42px' },
{ dimension: '-0.42', expected: '-0.42px' },
{ dimension: '-1.42', expected: '-1.42px' },
{ dimension: '-42.42', expected: '-42.42px' },
{ dimension: '1.42', expected: '1.42px' },
{ dimension: '42.42', expected: '42.42px' },
{ dimension: '0px', expected: '0px' },
{ dimension: '-0px', expected: '-0px' },
{ dimension: '-1px', expected: '-1px' },
{ dimension: '-42px', expected: '-42px' },
{ dimension: '1px', expected: '1px' },
{ dimension: '42px', expected: '42px' },
{ dimension: '0.0px', expected: '0.0px' },
{ dimension: '-0.0px', expected: '-0.0px' },
{ dimension: '-1.0px', expected: '-1.0px' },
{ dimension: '-42.0px', expected: '-42.0px' },
{ dimension: '1.0px', expected: '1.0px' },
{ dimension: '42.0px', expected: '42.0px' },
{ dimension: '0.42px', expected: '0.42px' },
{ dimension: '-0.42px', expected: '-0.42px' },
{ dimension: '-1.42px', expected: '-1.42px' },
{ dimension: '-42.42px', expected: '-42.42px' },
{ dimension: '1.42px', expected: '1.42px' },
{ dimension: '42.42px', expected: '42.42px' },
{ dimension: '0em', expected: '0em' },
{ dimension: '-0em', expected: '-0em' },
{ dimension: '-1em', expected: '-1em' },
{ dimension: '-42em', expected: '-42em' },
{ dimension: '1em', expected: '1em' },
{ dimension: '42em', expected: '42em' },
{ dimension: '0.0em', expected: '0.0em' },
{ dimension: '-0.0em', expected: '-0.0em' },
{ dimension: '-1.0em', expected: '-1.0em' },
{ dimension: '-42.0em', expected: '-42.0em' },
{ dimension: '1.0em', expected: '1.0em' },
{ dimension: '42.0em', expected: '42.0em' },
{ dimension: '0.42em', expected: '0.42em' },
{ dimension: '-0.42em', expected: '-0.42em' },
{ dimension: '-1.42em', expected: '-1.42em' },
{ dimension: '-42.42em', expected: '-42.42em' },
{ dimension: '1.42em', expected: '1.42em' },
{ dimension: '42.42em', expected: '42.42em' },
{ dimension: 'calc(42px - 1em)', expected: 'calc(42px - 1em)' },
{ dimension: 'calc(42px)', expected: 'calc(42px)' },
{ dimension: 'calc(42vh)', expected: 'calc(42vh)' },
{ dimension: 'min(42px, 1em)', expected: 'min(42px, 1em)' },
{ dimension: 'max(42px, 5rem)', expected: 'max(42px, 5rem)' },
{
dimension: 'clamp(42vh, 23vw, 13cap)',
expected: 'clamp(42vh, 23vw, 13cap)',
},
{ dimension: 'calc(42px-1em)', expected: 'calc(42px-1em)' },
{ dimension: 'min(42px,1em)', expected: 'min(42px,1em)' },
{ dimension: 'max(42px,5rem)', expected: 'max(42px,5rem)' },
{
dimension: 'clamp(42vh,23vw,13cap)',
expected: 'clamp(42vh,23vw,13cap)',
},
{
dimension: 'clamp( 42vh,23vw,13cap )',
expected: 'clamp( 42vh,23vw,13cap )',
},
])(
'ensureUnit returns $expected with input $dimension',
({ dimension, expected }) => {
expect(ensureUnit(dimension)).toBe(expected);
},
);
});

Some files were not shown because too many files have changed in this diff Show More