42 Commits
Author SHA1 Message Date
jakob.scheid 1b5db32c33 hotfix: update Node.js project version to 0.1.0
Deploy on dev / Deploy on dev (push) Successful in 45s
Deploy on production / Deploy on production (release) Skipped
Deploy on staging / Deploy on staging (release) Successful in 45s
The Node.js project version previously was updated to 1.0.0 for this
release although it is 0.1.0.
2026-08-09 19:39:40 +02:00
jakob.scheidandGitea f29658ce61 Merge pull request 'Chore: prepare release 0.1.0' (#189) from chore/prepare-1-0-0 into main
Deploy on dev / Deploy on dev (push) Successful in 41s
Deploy on production / Deploy on production (release) Skipped
Deploy on staging / Deploy on staging (release) Successful in 44s
Reviewed-on: #189
Reviewed-by: Jakob Gregory
2026-08-09 19:27:28 +02:00
jakob.scheid cdb00f7be0 chore(cd): add workflow to deploy on production environment 2026-08-09 19:26:23 +02:00
jakob.scheid 68f0faaa28 chore(cd): add workflow to deploy on staging environment 2026-08-09 19:25:50 +02:00
jakob.scheid 2b730506ee chore: update Node.js project version information 2026-08-09 18:02:36 +02:00
jakob.scheid a2f3313ea0 docs(changelog): mark changelog section as 0.1.0 2026-08-09 18:01:46 +02:00
jakob.scheid e893c213b0 docs(changelog): fix typo in changelog 2026-08-09 17:59:00 +02:00
jakob.scheid a6b9c439bd docs(changelog): complete changelog 2026-08-09 17:58:40 +02:00
jakob.scheidandGitea 168cdc96c9 Merge pull request 'Feat: remove placeholder slogan' (#188) from feature/remove-placeholder-slogan into main
Deploy on dev / Deploy on dev (push) Successful in 42s
Reviewed-on: #188
Reviewed-by: Jakob Gregory
2026-08-09 14:33:46 +02:00
jakob.scheid bed6bb655e refactor(i18n): remove unused slogan translation 2026-08-09 13:16:12 +02:00
jakob.scheid 220127125b feat: remove placeholder slogan 2026-08-09 13:16:04 +02:00
jakob.scheidandGitea 0abad9a144 Merge pull request 'Style: make code style consistent' (#186) from style/make-code-style-consistent into main
Deploy on dev / Deploy on dev (push) Successful in 48s
Reviewed-on: #186
Reviewed-by: Jakob Gregory
2026-08-09 13:09:54 +02:00
jakob.scheidandGitea 007b46223a refactor(search): move search view to search feature directory for consistency 2026-08-09 13:09:39 +02:00
jakob.scheidandGitea aecce32842 style: format code using Prettier 2026-08-09 13:09:39 +02:00
jakob.scheidandGitea 6e0c14ac48 chore(format): set up Prettier 2026-08-09 13:09:39 +02:00
jakob.scheidandGitea 32a77bbcf0 fix(settings): add :key to sidebar section links 2026-08-09 13:09:39 +02:00
jakob.scheidandGitea 0a7bf94c4c refactor: always use shorthand object properties 2026-08-09 13:09:39 +02:00
jakob.scheidandGitea e1dab8a4c7 refactor(icons): fix typo
Used 'icon' instead of 'Icon'.
2026-08-09 13:09:39 +02:00
jakob.scheidandGitea 034256e81b refactor(icons): rename Icon to UIIcon
Vue.js components should have multi-word names.
2026-08-09 13:09:39 +02:00
jakob.scheidandGitea 049857fe91 refactor(search)!: rename Searchbar to SearchBar
Vue.js components should have multi-word names.
2026-08-09 13:09:39 +02:00
jakob.scheidandGitea bf79f25a33 refactor(footer)!: rename Footer to AppFooter
Vue.js components should have multi-word names.
2026-08-09 13:09:39 +02:00
jakob.scheidandGitea 8fe3d9ad61 refactor(nav)!: rename Navbar to AppNavbar
Vue.js components should have multi-word names.
2026-08-09 13:09:39 +02:00
jakob.scheidandGitea 79b508dcf4 refactor(settings)!: rename Selection to SettingsSelection
Vue.js components should have multi-word names.
2026-08-09 13:09:39 +02:00
jakob.scheidandGitea 9ef4077916 refactor(settings)!: rename Switch to SettingsSwitch
Vue.js components should have multi-word names.
2026-08-09 13:09:39 +02:00
jakob.scheidandGitea c2a4ac77c7 refactor(sidebar)!: rename Sidebar to AppSidebar
Vue.js components should have multi-word names.
2026-08-09 13:09:39 +02:00
jakob.scheidandGitea 7262024b78 refactor: avoid asynchronous functions without await 2026-08-09 13:09:39 +02:00
jakob.scheidandGitea 870a26d766 feat(lint): add ESLint rules
Added some ESLint rules (see eslint.config.js).
2026-08-09 13:09:39 +02:00
jakob.scheidandGitea fe0c1cfd94 chore(lint): set up ESLint
Added ESLint dependencies and ESLint configuration.
2026-08-09 13:09:39 +02:00
jakob.scheidandGitea a221b5f04f refactor: remove all unused variables 2026-08-09 13:09:39 +02:00
jakob.scheidandGitea 1065eb144c style: reorder imports in entrypoint script
Reordered the imports in src/main.js so that they are more clear and
more readable.
2026-08-09 13:09:39 +02:00
jakob.scheidandGitea d8b4aa7a1a style: reorder imports alphabetically 2026-08-09 13:09:39 +02:00
jakob.scheidandGitea db3758a54a style(legal): remove function wrapper 2026-08-09 13:09:39 +02:00
jakob.scheidandGitea a086affbef style: ensure named functions 2026-08-09 13:09:39 +02:00
jakob.scheidandGitea dd07392821 style: ensure function expressions
Replaced all function statements with function expressions.
2026-08-09 13:09:39 +02:00
jakob.scheidandGitea 995e77b69a style: remove unnecessary function whitespace
Remove the unnecessary whitespace between the function name or the
'function' keyword and the opening parenthesis.
2026-08-09 13:09:39 +02:00
jakob.scheidandGitea 6bf315e4b8 style: remove unnecessary whitespace 2026-08-09 13:09:39 +02:00
jakob.scheidandGitea cd1755693b style: remove unnecessary semicolons after blocks 2026-08-09 13:09:39 +02:00
jakob.scheidandGitea 722865133d refactor(i18n): fix typo in language switch 2026-08-09 13:09:39 +02:00
jakob.scheidandGitea dc60b968e7 style: ensure semicolons at line ends 2026-08-09 13:09:39 +02:00
jakob.scheidandGitea 18ba0f2a70 test: use describe in all test files 2026-08-09 13:09:39 +02:00
jakob.scheidandGitea 7ae4fb9a3f style: ensure newlines at the end of each file 2026-08-09 13:09:39 +02:00
jakob.scheidandGitea f4f8993e87 Merge pull request 'Feat(i18n): make language a setting' (#185) from feature/make-language-a-setting into main
Deploy on dev / Deploy on dev (push) Successful in 39s
Reviewed-on: #185
Reviewed-by: Jakob Gregory
2026-08-07 11:44:32 +02:00
93 changed files with 14385 additions and 6049 deletions
+1 -1
View File
@@ -42,4 +42,4 @@ jobs:
printf "Update current build pointer ... " printf "Update current build pointer ... "
echo "$BUILD_ID" | mc pipe "minio/$BUCKET/current" echo "$BUILD_ID" | mc pipe "minio/$BUCKET/current"
echo "done" echo "done"
+62
View File
@@ -0,0 +1,62 @@
# 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
@@ -0,0 +1,62 @@
# 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"
+1 -1
View File
@@ -1,3 +1,3 @@
.vscode/ .vscode/
node_modules/ node_modules/
dist/ dist/
+3
View File
@@ -0,0 +1,3 @@
.gitea/
.github/
eslint.config.js
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"tabWidth": 4
}
+1 -1
View File
@@ -5,4 +5,4 @@ This software is provided "as is", without warranty of any kind.
This software uses the Vue.js framework. This software uses the Vue.js framework.
This software makes use of various open-source components. This software makes use of various open-source components.
See THIRD_PARTY_LICENSES for details. See THIRD_PARTY_LICENSES for details.
+1 -1
View File
@@ -8,4 +8,4 @@ Please execute the setup script in `scripts/` for your platform when cloning the
## Legal ## Legal
After each update to the dependencies, please run the script `scripts/generate-license-files.js` to regenerate the third-party license files. After each update to the dependencies, please run the script `scripts/generate-license-files.js` to regenerate the third-party license files.
+181 -9
View File
@@ -213,6 +213,30 @@ 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
@@ -560,6 +584,67 @@ 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
@@ -591,6 +676,36 @@ 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
@@ -683,9 +798,12 @@ SOFTWARE.
The following npm packages may be included in this product: The following npm packages may be included in this product:
- @vue/devtools-api@8.1.1 - @vue/devtools-api@7.7.10
- @vue/devtools-kit@8.1.1 - @vue/devtools-api@8.1.5
- @vue/devtools-shared@8.1.1 - @vue/devtools-kit@7.7.10
- @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:
@@ -715,7 +833,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.16 - tinyglobby@0.2.17
This package contains the following license: This package contains the following license:
@@ -775,7 +893,9 @@ 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
@@ -1222,6 +1342,57 @@ 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
@@ -1284,7 +1455,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.4 - picomatch@4.0.5
This package contains the following license: This package contains the following license:
@@ -1351,11 +1522,12 @@ THE SOFTWARE.
----------- -----------
The following npm package may be included in this product: The following npm packages may be included in this product:
- pinia@3.0.4
- vue-router@5.0.6 - vue-router@5.0.6
This package contains the following license: These packages each contain the following license:
The MIT License (MIT) The MIT License (MIT)
@@ -1587,7 +1759,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.14 - postcss@8.5.26
This package contains the following license: This package contains the following license:
@@ -1616,7 +1788,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.12 - nanoid@3.3.18
This package contains the following license: This package contains the following license:
+4062 -23
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -1,6 +1,6 @@
# Changelog # Changelog
## [Unreleased] ## [0.1.0] - 2026-08-09
### Added ### Added
@@ -8,9 +8,14 @@
- Settings page - Settings page
- Footer - Footer
- Navbar - Navbar
- Searchbar - Search bar
- Icons - Icons
- Internationalization and some major languages - Internationalization and some major languages
- Color scheme - Color scheme
- Search results view with error message which indicates that the search is not available - Search results view with error message which indicates that the search is not available
- Settings renderer - Settings
- Setup scripts
- License script
- Prettier configuration
- ESLint configuration
- Legal notice page
+50
View File
@@ -0,0 +1,50 @@
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'],
]);
+18 -8
View File
@@ -24,7 +24,19 @@ limitations under the License.
<noscript> <noscript>
<style> <style>
body { body {
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; font-family:
'Inter',
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Open Sans',
'Helvetica Neue',
sans-serif;
} }
a { a {
@@ -40,17 +52,15 @@ limitations under the License.
</head> </head>
<body> <body>
<noscript> <noscript>
<h1> <h1>Seekra</h1>
Seekra <p>You need to enable JavaScript to use Seekra.</p>
</h1>
<p>
You need to enable JavaScript to use Seekra.
</p>
<section> <section>
<h2>Legal Content</h2> <h2>Legal Content</h2>
<ul> <ul>
<li> <li>
<a href="https://assets.seekra.jcloud-services.ddns.net/2026-08-04-76a067e/legal/legal_notice.html"> <a
href="https://assets.seekra.jcloud-services.ddns.net/2026-08-04-76a067e/legal/legal_notice.html"
>
Legal Notice Legal Notice
</a> </a>
</li> </li>
+5 -5
View File
@@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
}
} }
} }
}
+6957 -3857
View File
File diff suppressed because it is too large Load Diff
+38 -28
View File
@@ -1,30 +1,40 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.0.0", "version": "0.1.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": "vitest",
"test:run": "vitest run" "test:run": "vitest run",
}, "lint": "eslint .",
"dependencies": { "lint:fix": "eslint . --fix",
"pinia": "^3.0.4", "format": "prettier . --write",
"pinia-plugin-persistedstate": "^4.7.1", "format:check": "prettier . --check"
"terser": "^5.47.1", },
"vue": "^3.5.32", "dependencies": {
"vue-i18n": "^11.4.4", "pinia": "^3.0.4",
"vue-router": "^5.0.6" "pinia-plugin-persistedstate": "^4.7.1",
}, "terser": "^5.47.1",
"devDependencies": { "vue": "^3.5.32",
"@pinia/testing": "^1.0.3", "vue-i18n": "^11.4.4",
"@vitejs/plugin-vue": "^6.0.6", "vue-router": "^5.0.6"
"@vue/test-utils": "^2.4.6", },
"jsdom": "^29.1.1", "devDependencies": {
"vite": "^8.0.10", "@eslint/js": "^10.0.1",
"vite-plugin-html": "^3.2.2", "@pinia/testing": "^1.0.3",
"vitest": "^4.1.7" "@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-plugin-html": "^3.2.2",
"vitest": "^4.1.7"
}
} }
+6 -17
View File
@@ -22,21 +22,16 @@ import { generateLicenseFile } from 'generate-license-file';
const packageJson = './package.json'; const packageJson = './package.json';
const packageJsonBackup = `${packageJson}~`; const packageJsonBackup = `${packageJson}~`;
await generateLicenseFile( await generateLicenseFile(packageJson, 'THIRD_PARTY_LICENSES');
packageJson,
'THIRD_PARTY_LICENSES'
);
const pkg = JSON.parse( const pkg = JSON.parse(await fs.readFile(packageJson, 'utf-8'));
await fs.readFile(packageJson, 'utf-8')
);
await fs.rename(packageJson, packageJsonBackup); await fs.rename(packageJson, packageJsonBackup);
const devPackage = { const devPackage = {
...pkg, ...pkg,
dependencies: pkg.devDependencies ?? {}, dependencies: pkg.devDependencies ?? {},
devDependencies: {} devDependencies: {},
}; };
/* /*
@@ -45,14 +40,8 @@ const devPackage = {
https://github.com/TobyAndToby/generate-license-file/issues/752 https://github.com/TobyAndToby/generate-license-file/issues/752
*/ */
await fs.writeFile( await fs.writeFile(packageJson, JSON.stringify(devPackage));
packageJson,
JSON.stringify(devPackage)
);
await generateLicenseFile( await generateLicenseFile(packageJson, './THIRD_PARTY_LICENSES_DEV');
packageJson,
'./THIRD_PARTY_LICENSES_DEV'
);
await fs.rename(packageJsonBackup, packageJson); await fs.rename(packageJsonBackup, packageJson);
+1 -1
View File
@@ -14,4 +14,4 @@
# 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.
git config --local commit.template .gitmessage git config --local commit.template .gitmessage
+1 -1
View File
@@ -14,4 +14,4 @@
# 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.
git config --local commit.template .gitmessage git config --local commit.template .gitmessage
+11 -11
View File
@@ -15,13 +15,12 @@ limitations under the License.
--> -->
<script setup> <script setup>
import Navbar from './features/nav/components/Navbar.vue';
import Footer from './features/footer/components/Footer.vue';
import { LANGUAGE_PATH } from './config/featureSettings.js'; import { LANGUAGE_PATH } from './config/featureSettings.js';
import { updatePageTitle } from './router'; import AppFooter from './features/footer/components/AppFooter.vue';
import AppNavbar from './features/nav/components/AppNavbar.vue';
import { useSettings } from './features/settings/composables/useSettings.js'; import { useSettings } from './features/settings/composables/useSettings.js';
import { setLocale } from './i18n'; import { setLocale } from './i18n';
import { updatePageTitle } from './router';
import { computed, onMounted, watch, watchEffect } from 'vue'; import { computed, onMounted, watch, watchEffect } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
@@ -30,11 +29,11 @@ const route = useRoute();
const { getSetting } = useSettings(); const { getSetting } = useSettings();
const colorScheme = computed(() => getSetting(['appearance', 'colorScheme'])); const colorScheme = computed(() => getSetting(['appearance', 'colorScheme']));
const updateColorScheme = function updateColorScheme (newColorScheme) { const updateColorScheme = function updateColorScheme(newColorScheme) {
document.body.style.setProperty(colorScheme, { document.body.style.setProperty(colorScheme, {
auto: 'normal', auto: 'normal',
dark: 'dark', dark: 'dark',
light: 'light' light: 'light',
}); });
if (newColorScheme === 'dark') { if (newColorScheme === 'dark') {
document.body.classList.add('dark'); document.body.classList.add('dark');
@@ -46,7 +45,7 @@ const updateColorScheme = function updateColorScheme (newColorScheme) {
} else { } else {
document.body.classList.remove('color-scheme-auto'); document.body.classList.remove('color-scheme-auto');
} }
} };
watch(colorScheme, (newColorScheme) => { watch(colorScheme, (newColorScheme) => {
updateColorScheme(newColorScheme); updateColorScheme(newColorScheme);
}); });
@@ -61,11 +60,11 @@ onMounted(() => {
<template> <template>
<div id="app-wrapper"> <div id="app-wrapper">
<Navbar /> <AppNavbar />
<router-view class="main-content" /> <router-view class="main-content" />
<Footer /> <AppFooter />
</div> </div>
</template> </template>
@@ -73,7 +72,8 @@ onMounted(() => {
#app-wrapper { #app-wrapper {
--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) var(--main-content-padding-x); --main-content-padding: var(--main-content-padding-y)
var(--main-content-padding-x);
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -90,4 +90,4 @@ onMounted(() => {
--main-content-padding-x: 15px; --main-content-padding-x: 15px;
} }
} }
</style> </style>
+1 -1
View File
@@ -23,4 +23,4 @@ limitations under the License.
stroke-linejoin="round" stroke-linejoin="round"
d="M5 12 l6 8 l8 -16" d="M5 12 l6 8 l8 -16"
/> />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 832 B

After

Width:  |  Height:  |  Size: 833 B

+1 -1
View File
@@ -23,4 +23,4 @@ limitations under the License.
stroke-linejoin="round" stroke-linejoin="round"
d="M4 8 l8 8 l8 -8" d="M4 8 l8 8 l8 -8"
/> />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 830 B

After

Width:  |  Height:  |  Size: 831 B

+1 -1
View File
@@ -23,4 +23,4 @@ limitations under the License.
stroke-linejoin="round" stroke-linejoin="round"
d="M8 20 l8 -8 l-8 -8" d="M8 20 l8 -8 l-8 -8"
/> />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 833 B

After

Width:  |  Height:  |  Size: 834 B

+1 -1
View File
@@ -35,4 +35,4 @@ limitations under the License.
a9 9 0 0 0 -9 -9 a9 9 0 0 0 -9 -9
" "
/> />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

+1 -1
View File
@@ -33,4 +33,4 @@ limitations under the License.
c-2.140625 4.351562000000001 -6.480468999999999 7.058593000000002 -11.328125 7.058593000000002 c-2.140625 4.351562000000001 -6.480468999999999 7.058593000000002 -11.328125 7.058593000000002
" "
/> />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

+1 -1
View File
@@ -33,4 +33,4 @@ limitations under the License.
cy="8.5" cy="8.5"
r="6.5" r="6.5"
/> />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

+1 -1
View File
@@ -35,4 +35,4 @@ limitations under the License.
v16 v16
" "
/> />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

+1 -1
View File
@@ -42,4 +42,4 @@ limitations under the License.
<!-- bottom right --> <!-- bottom right -->
<circle fill="currentColor" r="2" cx="18.36396103067893" cy="18.36396103067893" /> <circle fill="currentColor" r="2" cx="18.36396103067893" cy="18.36396103067893" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

+1 -1
View File
@@ -16,4 +16,4 @@ limitations under the License.
<svg xmlns="http://www.w3.org/2000/svg" width="758.75" height="192.5"> <svg xmlns="http://www.w3.org/2000/svg" width="758.75" height="192.5">
<defs><linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" style="stop-color:#4ba8eb"/><stop offset="100%" style="stop-color:#043485"/></linearGradient></defs> <defs><linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" style="stop-color:#4ba8eb"/><stop offset="100%" style="stop-color:#043485"/></linearGradient></defs>
<path stroke="url(#gradient)" stroke-width="15" fill="none" d=" M94 51 a43.75 43.75 0 0 0 -43.75 -43.75 a43.75 43.75 0 0 0 -43.75 43.75 a43.75 43.75 0 0 0 43.75 43.75 a43.75 43.75 0 0 1 43.75 43.75 a43.75 43.75 0 0 1 -43.75 43.75 a43.75 43.75 0 0 1 -43.75 -43.75 M138.75 95.25 h43.75 a43.75 43.75 0 0 0 43.75 -43.75 a43.75 43.75 0 0 0 -43.75 -43.75 a43.75 43.75 0 0 0 -43.75 43.75 v43.75 v43.75 a43.75 43.75 0 0 0 43.75 43.75 a43.75 43.75 0 0 0 43.75 -43.75 M270 95.25 h43.75 a43.75 43.75 0 0 0 43.75 -43.75 a43.75 43.75 0 0 0 -43.75 -43.75 a43.75 43.75 0 0 0 -43.75 43.75 v43.75 v43.75 a43.75 43.75 0 0 0 43.75 43.75 a43.75 43.75 0 0 0 43.75 -43.75 M401.25 0 v192.5 m0 -96.125 l87.5 -87.5 m-87.5 87.5 l87.5 87.5 M532.5 192.5 v-192.5 m0 51.25 a43.75 43.75 0 0 1 43.75 -43.75 a43.75 43.75 0 0 1 43.75 43.75 M663.75 50.25 a43.75 43.75 0 0 1 43.75 -43.75 a43.75 43.75 0 0 1 43.75 43.75 v43.75 v43.75 v43.75 m0 -43.75 a43.75 43.75 0 0 1 -43.75 43.75 a43.75 43.75 0 0 1 -43.75 -43.75 a43.75 43.75 0 0 1 43.75 -43.75 a43.75 43.75 0 0 1 43.75 43.75 v50"/></svg> <path stroke="url(#gradient)" stroke-width="15" fill="none" d=" M94 51 a43.75 43.75 0 0 0 -43.75 -43.75 a43.75 43.75 0 0 0 -43.75 43.75 a43.75 43.75 0 0 0 43.75 43.75 a43.75 43.75 0 0 1 43.75 43.75 a43.75 43.75 0 0 1 -43.75 43.75 a43.75 43.75 0 0 1 -43.75 -43.75 M138.75 95.25 h43.75 a43.75 43.75 0 0 0 43.75 -43.75 a43.75 43.75 0 0 0 -43.75 -43.75 a43.75 43.75 0 0 0 -43.75 43.75 v43.75 v43.75 a43.75 43.75 0 0 0 43.75 43.75 a43.75 43.75 0 0 0 43.75 -43.75 M270 95.25 h43.75 a43.75 43.75 0 0 0 43.75 -43.75 a43.75 43.75 0 0 0 -43.75 -43.75 a43.75 43.75 0 0 0 -43.75 43.75 v43.75 v43.75 a43.75 43.75 0 0 0 43.75 43.75 a43.75 43.75 0 0 0 43.75 -43.75 M401.25 0 v192.5 m0 -96.125 l87.5 -87.5 m-87.5 87.5 l87.5 87.5 M532.5 192.5 v-192.5 m0 51.25 a43.75 43.75 0 0 1 43.75 -43.75 a43.75 43.75 0 0 1 43.75 43.75 M663.75 50.25 a43.75 43.75 0 0 1 43.75 -43.75 a43.75 43.75 0 0 1 43.75 43.75 v43.75 v43.75 v43.75 m0 -43.75 a43.75 43.75 0 0 1 -43.75 43.75 a43.75 43.75 0 0 1 -43.75 -43.75 a43.75 43.75 0 0 1 43.75 -43.75 a43.75 43.75 0 0 1 43.75 43.75 v50"/></svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

+2 -2
View File
@@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export const getAssetsUrl = function getAssetsUrl () { export const getAssetsUrl = function getAssetsUrl() {
return new URL( return new URL(
`${import.meta.env.VITE_ASSETS_BUILD_ID}/`, `${import.meta.env.VITE_ASSETS_BUILD_ID}/`,
import.meta.env.VITE_ASSETS_BASE_URL import.meta.env.VITE_ASSETS_BASE_URL,
).href; ).href;
}; };
@@ -15,8 +15,7 @@ limitations under the License.
--> -->
<script setup> <script setup>
import Icon from '@/features/icons/components/Icon.vue'; import UIIcon from '@/features/icons/components/UIIcon.vue';
import { useSettings } from '@/features/settings/composables/useSettings'; import { useSettings } from '@/features/settings/composables/useSettings';
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@@ -27,33 +26,36 @@ const { getSetting, setSetting } = useSettings();
const colorScheme = computed(() => getSetting(['appearance', 'colorScheme'])); const colorScheme = computed(() => getSetting(['appearance', '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: 'crescent-moon',
'light': 'sun', light: 'sun',
'auto': 'circle-black-white' auto: 'circle-black-white',
}; };
const getTooltipTranslation = function (colorScheme) { const getTooltipTranslation = function getTooltipTranslation(colorScheme) {
return t(`preferences.colorScheme.switch.${colorSchemeNextMapper[colorScheme]}`); return t(
`preferences.colorScheme.switch.${colorSchemeNextMapper[colorScheme]}`,
);
}; };
const setColorScheme = function setColorScheme (newColorScheme) { const setColorScheme = function setColorScheme(newColorScheme) {
setSetting(['appearance', 'colorScheme'], newColorScheme); setSetting(['appearance', 'colorScheme'], newColorScheme);
}; };
</script> </script>
<template> <template>
<button class="color-scheme-button" <button
class="color-scheme-button"
@click="setColorScheme(colorSchemeNextMapper[colorScheme])" @click="setColorScheme(colorSchemeNextMapper[colorScheme])"
:aria-label="getTooltipTranslation(colorScheme)" :aria-label="getTooltipTranslation(colorScheme)"
:title="getTooltipTranslation(colorScheme)" :title="getTooltipTranslation(colorScheme)"
> >
<Icon <UIIcon
:name="colorSchemeIconMapper[colorSchemeNextMapper[colorScheme]]" :name="colorSchemeIconMapper[colorSchemeNextMapper[colorScheme]]"
size="16" size="16"
/> />
@@ -78,4 +80,4 @@ const setColorScheme = function setColorScheme (newColorScheme) {
.color-scheme-button:hover { .color-scheme-button:hover {
background: var(--light-hover); background: var(--light-hover);
} }
</style> </style>
@@ -21,9 +21,9 @@ import { describe, expect, test } from 'vitest';
const { getSetting } = useSettings(); const { getSetting } = useSettings();
const colorSchemeSettingPath = ['appearance', 'colorScheme'] const colorSchemeSettingPath = ['appearance', 'colorScheme'];
const getWrapper = function getWrapper () { const getWrapper = function getWrapper() {
return mountComponent(ColorSchemeButton); return mountComponent(ColorSchemeButton);
}; };
@@ -32,7 +32,7 @@ describe('ColorSchemeButton', () => {
const wrapper = getWrapper(); const wrapper = getWrapper();
expect(getSetting(colorSchemeSettingPath)).toBe('auto'); expect(getSetting(colorSchemeSettingPath)).toBe('auto');
const colorSchemeButton = wrapper.find('button'); const colorSchemeButton = wrapper.find('button');
await colorSchemeButton.trigger('click'); await colorSchemeButton.trigger('click');
@@ -16,20 +16,17 @@ limitations under the License.
<script setup> <script setup>
import LanguageSwitchButton from '@/features/i18n/components/LanguageSwitchButton.vue'; 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>
@@ -103,4 +100,4 @@ const copyrightPeriod =
margin-top: 0.8em; margin-top: 0.8em;
} }
} }
</style> </style>
@@ -15,13 +15,12 @@ limitations under the License.
--> -->
<script setup> <script setup>
import Icon from '@/features/icons/components/Icon.vue';
import { LANGUAGE_PATH } from '@/config/featureSettings'; import { LANGUAGE_PATH } from '@/config/featureSettings';
import UIIcon from '@/features/icons/components/UIIcon.vue';
import { useSettings } from '@/features/settings/composables/useSettings'; import { useSettings } from '@/features/settings/composables/useSettings';
import { setLocale, SUPPORTED_LANGUAGES } from '@/i18n';
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { setLocale, SUPPORTED_LANGUAGES } from '@/i18n';
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const { setSetting, getSetting } = useSettings(); const { setSetting, getSetting } = useSettings();
@@ -29,33 +28,33 @@ 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) { const selectLanguage = function selectLanguage(code) {
setSetting(LANGUAGE_PATH, code); setSetting(LANGUAGE_PATH, code);
close(); close();
}; };
watch(() => getSetting(LANGUAGE_PATH), setLocale); watch(() => getSetting(LANGUAGE_PATH), setLocale);
const close = function () { const close = function close() {
document.removeEventListener('click', closeWrapperOnClickOutsite); document.removeEventListener('click', closeWrapperOnClickOutside);
isOpen.value = false; isOpen.value = false;
}; };
const closeWrapperOnClickOutsite = function (e) { const closeWrapperOnClickOutside = function closeWrapperOnClickOutside(e) {
if (languageDropdown.value) { if (languageDropdown.value) {
if (!languageDropdown.value.contains(e.target)) { if (!languageDropdown.value.contains(e.target)) {
close(); close();
}; }
}; }
}; };
const open = function () { const open = function open() {
if (!isOpen.value) { if (!isOpen.value) {
isOpen.value = true; isOpen.value = true;
setTimeout(() => { setTimeout(() => {
document.addEventListener('click', closeWrapperOnClickOutsite); document.addEventListener('click', closeWrapperOnClickOutside);
}, 0); }, 0);
}; }
}; };
</script> </script>
@@ -67,9 +66,11 @@ const open = function () {
:aria-expanded="isOpen" :aria-expanded="isOpen"
aria-haspopup="listbox" aria-haspopup="listbox"
> >
<span class="lang-code">{{ t(`preferences.locale.languages.${locale}`) }}</span> <span class="lang-code">{{
t(`preferences.locale.languages.${locale}`)
}}</span>
<span class="chevron" :class="{ open: isOpen }"> <span class="chevron" :class="{ open: isOpen }">
<Icon name="chevron-down" size="1em" /> <UIIcon name="chevron-down" size="1em" />
</span> </span>
</button> </button>
@@ -79,7 +80,7 @@ const open = function () {
class="language-dropdown" class="language-dropdown"
role="listbox" role="listbox"
:style="{ :style="{
bottom: 'calc(var(--offset) + 2 * var(--trigger-padding-y) + 1em)' // easier to add auto adaption to the available space bottom: 'calc(var(--offset) + 2 * var(--trigger-padding-y) + 1em)', // easier to add auto adaption to the available space
}" }"
> >
<li <li
@@ -90,7 +91,9 @@ const open = function () {
:class="{ active: lang === locale }" :class="{ active: lang === locale }"
@click="selectLanguage(lang)" @click="selectLanguage(lang)"
> >
<span class="lang-label">{{ t(`preferences.locale.languages.${lang}`) }}</span> <span class="lang-label">{{
t(`preferences.locale.languages.${lang}`)
}}</span>
</li> </li>
</ul> </ul>
</div> </div>
@@ -163,4 +166,4 @@ const open = function () {
.flag { .flag {
font-size: 1.1rem; font-size: 1.1rem;
} }
</style> </style>
@@ -14,31 +14,33 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import LanguageSwitchButton from '../LanguageSwitchButton.vue';
import { LANGUAGE_PATH } from '@/config/featureSettings.js'; import { LANGUAGE_PATH } from '@/config/featureSettings.js';
import { useSettings } from '@/features/settings/composables/useSettings.js'; import { useSettings } from '@/features/settings/composables/useSettings.js';
import { mountComponent } from '@/test-utils/mountComponent.js'; import { mountComponent } from '@/test-utils/mountComponent.js';
import { expect, test, describe } from 'vitest'; import { describe, expect, test } from 'vitest';
import LanguageSwitchButton from '../LanguageSwitchButton.vue';
const getWrapper = function getWrapper () { const getWrapper = function getWrapper() {
return mountComponent(LanguageSwitchButton); return mountComponent(LanguageSwitchButton);
}; };
describe('LanguageSwitchButton', () => { describe('LanguageSwitchButton', () => {
test('renders correctly with initial state closed', () => { test('renders correctly with initial state closed', () => {
const wrapper = getWrapper(); const wrapper = getWrapper();
expect(wrapper.find('.language-button').exists()).toBe(true); expect(wrapper.find('.language-button').exists()).toBe(true);
expect(wrapper.find('.language-dropdown').exists()).toBe(false); expect(wrapper.find('.language-dropdown').exists()).toBe(false);
expect(wrapper.find('.language-button').attributes('aria-expanded')).toBe('false'); expect(
wrapper.find('.language-button').attributes('aria-expanded'),
).toBe('false');
}); });
test('opens the dropdown when language button is clicked', async () => { test('opens the dropdown when language button is clicked', async () => {
const wrapper = getWrapper(); const wrapper = getWrapper();
const button = wrapper.find('.language-button'); const button = wrapper.find('.language-button');
await button.trigger('click'); await button.trigger('click');
expect(wrapper.find('.language-dropdown').exists()).toBe(true); expect(wrapper.find('.language-dropdown').exists()).toBe(true);
expect(button.attributes('aria-expanded')).toBe('true'); expect(button.attributes('aria-expanded')).toBe('true');
}); });
@@ -47,17 +49,17 @@ describe('LanguageSwitchButton', () => {
const { getSetting } = useSettings(); const { getSetting } = useSettings();
const wrapper = getWrapper(); const wrapper = getWrapper();
await wrapper.find('.language-button').trigger('click'); await wrapper.find('.language-button').trigger('click');
const options = wrapper.findAll('.language-dropdown li'); const options = wrapper.findAll('.language-dropdown li');
const enOption = options.find(opt => opt.text().includes('en')); const enOption = options.find((opt) => opt.text().includes('en'));
await enOption.trigger('click'); await enOption.trigger('click');
expect(getSetting(LANGUAGE_PATH)).toBe('en'); expect(getSetting(LANGUAGE_PATH)).toBe('en');
expect(wrapper.find('.language-dropdown').exists()).toBe(false); expect(wrapper.find('.language-dropdown').exists()).toBe(false);
const deOption = options.find(opt => opt.text().includes('de')); const deOption = options.find((opt) => opt.text().includes('de'));
await deOption.trigger('click'); await deOption.trigger('click');
expect(getSetting(LANGUAGE_PATH)).toBe('de'); expect(getSetting(LANGUAGE_PATH)).toBe('de');
expect(wrapper.find('.language-dropdown').exists()).toBe(false); expect(wrapper.find('.language-dropdown').exists()).toBe(false);
@@ -69,14 +71,14 @@ describe('LanguageSwitchButton', () => {
await wrapper.find('.language-button').trigger('click'); await wrapper.find('.language-button').trigger('click');
expect(wrapper.find('.language-dropdown').exists()).toBe(true); expect(wrapper.find('.language-dropdown').exists()).toBe(true);
await new Promise(resolve => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
const externalDiv = document.createElement('div'); const externalDiv = document.createElement('div');
document.body.appendChild(externalDiv); document.body.appendChild(externalDiv);
const clickEvent = new MouseEvent('click', { bubbles: true }); const clickEvent = new MouseEvent('click', { bubbles: true });
externalDiv.dispatchEvent(clickEvent); externalDiv.dispatchEvent(clickEvent);
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(wrapper.find('.language-dropdown').exists()).toBe(false); expect(wrapper.find('.language-dropdown').exists()).toBe(false);
@@ -84,4 +86,4 @@ describe('LanguageSwitchButton', () => {
wrapper.unmount(); wrapper.unmount();
externalDiv.remove(); externalDiv.remove();
}); });
}); });
@@ -19,33 +19,36 @@ import { describe, expect, test } from 'vitest';
describe('navigatorLanguage', () => { describe('navigatorLanguage', () => {
test.for([ test.for([
{ navigatorLanguage: 'en', expected: 'en'}, { navigatorLanguage: 'en', expected: 'en' },
{ navigatorLanguage: 'de', expected: 'de'}, { navigatorLanguage: 'de', expected: 'de' },
{ navigatorLanguage: 'fr', expected: 'fr'}, { navigatorLanguage: 'fr', expected: 'fr' },
{ navigatorLanguage: 'en-US', expected: 'en'}, { navigatorLanguage: 'en-US', expected: 'en' },
{ navigatorLanguage: 'en-AU', expected: 'en'}, { navigatorLanguage: 'en-AU', expected: 'en' },
{ navigatorLanguage: 'de-DE', expected: 'de'}, { navigatorLanguage: 'de-DE', expected: 'de' },
{ navigatorLanguage: 'fr-FR', expected: 'fr'}, { navigatorLanguage: 'fr-FR', expected: 'fr' },
{ navigatorLanguage: 'en-us', expected: 'en'}, { navigatorLanguage: 'en-us', expected: 'en' },
{ navigatorLanguage: 'en-au', expected: 'en'}, { navigatorLanguage: 'en-au', expected: 'en' },
{ navigatorLanguage: 'de-de', expected: 'de'}, { navigatorLanguage: 'de-de', expected: 'de' },
{ navigatorLanguage: 'fr-fr', expected: 'fr'}, { navigatorLanguage: 'fr-fr', expected: 'fr' },
{ navigatorLanguage: 'zh-Hans-CN', expected: 'zh'}, { navigatorLanguage: 'zh-Hans-CN', expected: 'zh' },
{ navigatorLanguage: 'zh-Hant-TW', expected: 'zh'}, { navigatorLanguage: 'zh-Hant-TW', expected: 'zh' },
{ navigatorLanguage: 'uz-Latn-UZ', expected: 'uz'}, { navigatorLanguage: 'uz-Latn-UZ', expected: 'uz' },
{ navigatorLanguage: 'en-US-u-ca-gregory', expected: 'en'}, { navigatorLanguage: 'en-US-u-ca-gregory', expected: 'en' },
{ navigatorLanguage: 'de-DE-u-co-phonebk', expected: 'de'}, { navigatorLanguage: 'de-DE-u-co-phonebk', expected: 'de' },
{ navigatorLanguage: 'zh-Hant-TW-u-co-phonebk', expected: 'zh'} { navigatorLanguage: 'zh-Hant-TW-u-co-phonebk', expected: 'zh' },
])('returns language $expected with navigator language $navigatorLanguage', ({ navigatorLanguage, expected }) => { ])(
Object.defineProperty(navigator, 'language', { 'returns language $expected with navigator language $navigatorLanguage',
value: navigatorLanguage, ({ navigatorLanguage, expected }) => {
configurable: true Object.defineProperty(navigator, 'language', {
}); value: navigatorLanguage,
configurable: true,
});
expect(getNavigatorLanguage()).toBe(expected); expect(getNavigatorLanguage()).toBe(expected);
}); },
);
}); });
+2 -2
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export const getNavigatorLanguage = function getNavigatorLanguage () { export const getNavigatorLanguage = function getNavigatorLanguage() {
const locale = new Intl.Locale(navigator.language); const locale = new Intl.Locale(navigator.language);
return locale.language; return locale.language;
}; };
@@ -15,33 +15,33 @@ limitations under the License.
--> -->
<script setup> <script setup>
import { computed } from 'vue';
import { ensureUnit } from '@/utils/cssDimensions'; import { ensureUnit } from '@/utils/cssDimensions';
import { computed } from 'vue';
const props = defineProps({ const props = defineProps({
name: { name: {
required: true, required: true,
type: String type: String,
}, },
size: { size: {
type: [Number, String], type: [Number, String],
default: 24 default: 24,
} },
}) });
const icons = import.meta.glob('@/assets/icons/*.svg', { const icons = import.meta.glob('@/assets/icons/*.svg', {
eager: true, eager: true,
import: 'default' import: 'default',
}) });
const Icon = computed(() => icons[`/src/assets/icons/${props.name}.svg`]) const icon = computed(() => icons[`/src/assets/icons/${props.name}.svg`]);
const size = computed(() => ensureUnit(props.size)) const size = computed(() => ensureUnit(props.size));
</script> </script>
<template> <template>
<div class="icon-container"> <div class="icon-container">
<img :src="Icon" :style="{ height: `${size}`, width: `${size}` }" /> <img :src="icon" :style="{ height: `${size}`, width: `${size}` }" />
</div> </div>
</template> </template>
@@ -51,4 +51,4 @@ const size = computed(() => ensureUnit(props.size))
align-items: center; align-items: center;
filter: var(--invert); filter: var(--invert);
} }
</style> </style>
+3 -3
View File
@@ -15,8 +15,8 @@ limitations under the License.
*/ */
export class FetchError extends Error { export class FetchError extends Error {
constructor (message) { constructor(message) {
super(message); super(message);
this.name = 'FetchError'; this.name = 'FetchError';
}; }
}; }
@@ -60,29 +60,25 @@ describe('fetchLegalContent', () => {
describe('getLegalNotice', () => { describe('getLegalNotice', () => {
describe('success', () => { describe('success', () => {
test('returns HTML body', async () => { test('returns HTML body', async () => {
fetchSpy.mockResolvedValue( fetchSpy.mockResolvedValue(new Response(exampleHtmlDocument));
new Response(exampleHtmlDocument)
);
expect( expect((await getLegalNotice()).trim()).toBe(
(await getLegalNotice()).trim() exampleHtmlWithLinkClass,
).toBe(exampleHtmlWithLinkClass); );
}); });
test('returns HTML <main>', async () => { test('returns HTML <main>', async () => {
fetchSpy.mockResolvedValue( fetchSpy.mockResolvedValue(
new Response(exampleHtmlDocumentWithMain) new Response(exampleHtmlDocumentWithMain),
); );
expect( expect((await getLegalNotice()).trim()).toBe(
(await getLegalNotice()).trim() exampleHtmlWithLinkClass,
).toBe(exampleHtmlWithLinkClass); );
}); });
test('does not fail on success status code', async () => { test('does not fail on success status code', async () => {
fetchSpy.mockResolvedValue( fetchSpy.mockResolvedValue(new Response(null, { status: 206 }));
new Response(null, { status: 206 })
);
await getLegalNotice(); await getLegalNotice();
}); });
@@ -95,9 +91,7 @@ describe('fetchLegalContent', () => {
}); });
test('throws FetchError on HTTP error', async () => { test('throws FetchError on HTTP error', async () => {
fetchSpy.mockResolvedValue( fetchSpy.mockResolvedValue(new Response(null, { status: 404 }));
new Response(null, { status: 404 })
)
await expect(getLegalNotice()).rejects.toThrow(FetchError); await expect(getLegalNotice()).rejects.toThrow(FetchError);
}); });
@@ -17,7 +17,7 @@ limitations under the License.
import { FetchError } from '../errors'; import { FetchError } from '../errors';
import { getAssetsUrl } from '@/config/env'; import { getAssetsUrl } from '@/config/env';
const fetchHtml = async function fetchHtml (url, options) { const fetchHtml = async function fetchHtml(url, options) {
let response; let response;
try { try {
response = await fetch(url, options); response = await fetch(url, options);
@@ -29,16 +29,17 @@ const fetchHtml = async function fetchHtml (url, options) {
else throw new FetchError(); else throw new FetchError();
}; };
export const getLegalNotice = async function getLegalNotice () { export const getLegalNotice = async function getLegalNotice() {
const legalNoticeUrl = new URL('legal/legal_notice.html', getAssetsUrl()).href; const legalNoticeUrl = new URL('legal/legal_notice.html', getAssetsUrl())
.href;
const html = await fetchHtml(legalNoticeUrl); const html = await fetchHtml(legalNoticeUrl);
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html'); const doc = parser.parseFromString(html, 'text/html');
const legalNoticeContainer = doc.querySelector('main') ?? doc.body; const legalNoticeContainer = doc.querySelector('main') ?? doc.body;
legalNoticeContainer.querySelectorAll('a').forEach( legalNoticeContainer
(link) => link.classList.add('link') .querySelectorAll('a')
); .forEach((link) => link.classList.add('link'));
return legalNoticeContainer.innerHTML; return legalNoticeContainer.innerHTML;
}; };
@@ -20,16 +20,18 @@ import { mountComponent } from '@/test-utils/mountComponent';
import { flushPromises } from '@vue/test-utils'; import { flushPromises } from '@vue/test-utils';
import { afterEach, describe, expect, test, vi } from 'vitest'; import { afterEach, describe, expect, test, vi } from 'vitest';
const getLegalNotice = vi.hoisted(() => vi.fn().mockResolvedValue('legal notice')); const getLegalNotice = vi.hoisted(() =>
vi.fn().mockResolvedValue('legal notice'),
);
vi.mock('../../services/fetchLegalContent', async (importOriginal) => { vi.mock('../../services/fetchLegalContent', async (importOriginal) => {
const actual = await importOriginal(); const actual = await importOriginal();
return { return {
...actual, ...actual,
getLegalNotice getLegalNotice,
}; };
}); });
afterEach(() => getLegalNotice.mockRestore()); afterEach(getLegalNotice.mockRestore);
describe('LegalNoticeView', () => { describe('LegalNoticeView', () => {
test('shows loading indicator', () => { test('shows loading indicator', () => {
@@ -15,9 +15,9 @@ limitations under the License.
--> -->
<script setup> <script setup>
import ColorSchemeButton from '@/features/colorScheme/components/ColorSchemeButton.vue';
import logo from '@/assets/images/logo.svg';
import NavbarSearchBarWrapper from './NavbarSearchBarWrapper.vue'; import NavbarSearchBarWrapper from './NavbarSearchBarWrapper.vue';
import logo from '@/assets/images/logo.svg';
import ColorSchemeButton from '@/features/colorScheme/components/ColorSchemeButton.vue';
</script> </script>
<template> <template>
@@ -33,7 +33,9 @@ import NavbarSearchBarWrapper from './NavbarSearchBarWrapper.vue';
</ul> </ul>
</nav> </nav>
<div class="navbar-search-bar-wrapper-small-screens-wrapper"> <div class="navbar-search-bar-wrapper-small-screens-wrapper">
<NavbarSearchBarWrapper class="navbar-search-bar-wrapper-small-screens" /> <NavbarSearchBarWrapper
class="navbar-search-bar-wrapper-small-screens"
/>
</div> </div>
</template> </template>
@@ -60,7 +62,7 @@ import NavbarSearchBarWrapper from './NavbarSearchBarWrapper.vue';
color: var(--dark); color: var(--dark);
} }
.global-nav .right-links a:hover{ .global-nav .right-links a:hover {
text-decoration: underline; text-decoration: underline;
} }
@@ -15,29 +15,35 @@ limitations under the License.
--> -->
<script setup> <script setup>
import Searchbar from '@/features/search/components/Searchbar.vue'; import SearchBar from '@/features/search/components/SearchBar.vue';
import { watch } from 'vue'; import { watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
const route = useRoute(); const route = useRoute();
const searchQueryModel = defineModel(); const searchQueryModel = defineModel();
watch(() => route.name, name => { watch(
searchQueryModel.value = name === 'searchResults' ? route.query.q || '' : ''; () => route.name,
}); (name) => {
searchQueryModel.value =
name === 'searchResults' ? route.query.q || '' : '';
},
);
watch(() => route.query.q, q => { watch(
if (route.name === 'searchResults') { () => route.query.q,
searchQueryModel.value = q || ''; (q) => {
} if (route.name === 'searchResults') {
}); searchQueryModel.value = q || '';
}
},
);
</script> </script>
<template> <template>
<Searchbar <SearchBar
v-if="route.name === 'searchResults'" v-if="route.name === 'searchResults'"
v-model="searchQueryModel" v-model="searchQueryModel"
auto-submit auto-submit
/> />
</template> </template>
@@ -15,26 +15,25 @@ limitations under the License.
--> -->
<script setup> <script setup>
import Icon from '@/features/icons/components/Icon.vue'; 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 () { const submitSearch = function submitSearch() {
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>
@@ -42,10 +41,10 @@ const submitSearch = function () {
<form @submit.prevent="submitSearch" class="search-form"> <form @submit.prevent="submitSearch" class="search-form">
<div class="search-wrapper"> <div class="search-wrapper">
<input <input
v-model="searchQuery" v-model="searchQuery"
type="search" type="search"
:placeholder="t('search.searchBar.placeholder')" :placeholder="t('search.searchBar.placeholder')"
required required
/> />
<button <button
type="submit" type="submit"
@@ -53,7 +52,11 @@ const submitSearch = function () {
:title="t('search.searchBar.submit')" :title="t('search.searchBar.submit')"
:aria-label="t('search.searchBar.submit')" :aria-label="t('search.searchBar.submit')"
> >
<Icon class="search-icon" name="magnifying-glass" size="1.1em" /> <UIIcon
class="search-icon"
name="magnifying-glass"
size="1.1em"
/>
</button> </button>
</div> </div>
</form> </form>
@@ -70,7 +73,10 @@ const submitSearch = function () {
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(var(--content-height) * 0.5 + var(--submit-button-padding-y) + var(--padding)); border-radius: calc(
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: 100%;
@@ -91,7 +97,9 @@ const submitSearch = function () {
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)); 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);
@@ -114,4 +122,4 @@ const submitSearch = function () {
.search-form { .search-form {
width: 100%; width: 100%;
} }
</style> </style>
@@ -28,8 +28,7 @@ const { t } = useI18n();
<p>{{ t('error.tryAgainToAnotherTime') }}</p> <p>{{ t('error.tryAgainToAnotherTime') }}</p>
</div> </div>
</div> </div>
<div class="search-results-container"> <div class="search-results-container"></div>
</div>
</div> </div>
</template> </template>
@@ -94,4 +93,4 @@ const { t } = useI18n();
transform: scale(1); transform: scale(1);
} }
} }
</style> </style>
@@ -15,33 +15,26 @@ limitations under the License.
--> -->
<script setup> <script setup>
import Searchbar from '../features/search/components/Searchbar.vue';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import logo from '@/assets/images/logo.svg'; import logo from '@/assets/images/logo.svg';
import SearchBar from '@/features/search/components/SearchBar.vue';
const { t } = useI18n(); import { ref } from 'vue';
const router = useRouter();
const searchQuery = ref(''); const searchQuery = ref('');
const submitSearch = function () {
};
</script> </script>
<template> <template>
<div class="search-content main-content-padding"> <div class="search-content main-content-padding">
<header class="global-header"> <header class="global-header">
<img :src="logo" alt="Seekra" class="header-logo" /> <img :src="logo" alt="Seekra" class="header-logo" />
<span class="slogan"> </header>
{{ t('slogan') }}
</span>
</header>
<div class="search-container"> <div class="search-container">
<Searchbar v-model="searchQuery" ref="searchbar" class="search-bar" auto-submit /> <SearchBar
v-model="searchQuery"
ref="searchbar"
class="search-bar"
auto-submit
/>
</div> </div>
</div> </div>
</template> </template>
@@ -67,12 +60,6 @@ const submitSearch = function () {
max-width: 100%; max-width: 100%;
} }
.slogan {
margin-top: 1rem;
font-size: small;
line-height: normal;
}
.search-container { .search-container {
width: 70%; width: 70%;
max-width: 624px; max-width: 624px;
@@ -83,4 +70,4 @@ const submitSearch = function () {
width: 100%; width: 100%;
} }
} }
</style> </style>
@@ -16,4 +16,4 @@
.settings-button:hover { .settings-button:hover {
background-color: var(--light-hover); background-color: var(--light-hover);
} }
@@ -15,10 +15,8 @@ limitations under the License.
--> -->
<script setup> <script setup>
import Icon from '@/features/icons/components/Icon.vue';
import { useSettingsStore } from '../stores/settingsStore'; import { useSettingsStore } from '../stores/settingsStore';
import UIIcon from '@/features/icons/components/UIIcon.vue';
import { computed, ref, useId, watch } from 'vue'; import { computed, ref, useId, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@@ -26,11 +24,11 @@ const { t } = useI18n();
const props = defineProps({ const props = defineProps({
setting: { setting: {
required: true required: true,
}, },
path: { path: {
required: true required: true,
} },
}); });
const store = useSettingsStore(); const store = useSettingsStore();
@@ -43,29 +41,29 @@ const doneButtonInitial = ref(true);
const inputId = useId(); const inputId = useId();
const inputLabelId = useId(); const inputLabelId = useId();
const inputType = computed(() => ({ const inputType = computed(
number: 'number', () =>
string: 'text' ({
}[props.setting.type] number: 'number',
)); string: 'text',
})[props.setting.type],
const hasChanged = computed(() =>
inputModel.value !== store.get(props.path)
); );
const hasChanged = computed(() => inputModel.value !== store.get(props.path));
watch(store, (newValue) => { watch(store, (newValue) => {
const newInputValue = newValue.get(props.path); const newInputValue = newValue.get(props.path);
if (inputModel.value === oldInputValue) { if (inputModel.value === oldInputValue) {
inputModel.value = newInputValue; inputModel.value = newInputValue;
oldInputValue = newInputValue; oldInputValue = newInputValue;
}; }
}); });
watch(inputModel, () => { watch(inputModel, () => {
doneButtonInitial.value = false; doneButtonInitial.value = false;
}); });
const apply = function apply () { const apply = function apply() {
store.set(props.path, inputModel.value); store.set(props.path, inputModel.value);
}; };
</script> </script>
@@ -95,7 +93,7 @@ const apply = function apply () {
:title="t('settings.input.done.title')" :title="t('settings.input.done.title')"
:aria-label="t('settings.input.done.ariaLabel')" :aria-label="t('settings.input.done.ariaLabel')"
> >
<Icon name="check-mark" /> <UIIcon name="check-mark" />
</button> </button>
</form> </form>
</div> </div>
@@ -131,7 +129,10 @@ const apply = function apply () {
} }
.input-container.done-button-shown .input { .input-container.done-button-shown .input {
width: calc(100% - 1.5 * var(--input-padding) - 26px - 0.75 * var(--input-height) + 1.5px); width: calc(
100% - 1.5 * var(--input-padding) - 26px - 0.75 * var(--input-height) +
1.5px
);
} }
.done-button { .done-button {
@@ -200,7 +201,7 @@ const apply = function apply () {
opacity: 1; opacity: 1;
} }
} }
@keyframes done-button-disappear { @keyframes done-button-disappear {
0% { 0% {
opacity: 1; opacity: 1;
@@ -212,4 +213,4 @@ const apply = function apply () {
} }
} }
} }
</style> </style>
@@ -15,22 +15,21 @@ limitations under the License.
--> -->
<script setup> <script setup>
import Switch from './Switch.vue'; import SettingsSelection from './SettingsSelection.vue';
import SettingsInput from './SettingsInput.vue'; import SettingsInput from './SettingsInput.vue';
import Selection from './Selection.vue';
import SettingsPageLink from './SettingsPageLink.vue'; import SettingsPageLink from './SettingsPageLink.vue';
import SettingsSwitch from './SettingsSwitch.vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
const props = defineProps({ const props = defineProps({
setting: { setting: {
required: true required: true,
}, },
path: { path: {
required: true required: true,
} },
}); });
</script> </script>
@@ -44,7 +43,7 @@ const props = defineProps({
:key="setting" :key="setting"
class="setting" class="setting"
> >
<Switch <SettingsSwitch
v-if="setting.type === 'bool'" v-if="setting.type === 'bool'"
:setting="setting" :setting="setting"
:path="`${props.path}.${setting.name}`" :path="`${props.path}.${setting.name}`"
@@ -54,7 +53,7 @@ const props = defineProps({
:setting="setting" :setting="setting"
:path="`${props.path}.${setting.name}`" :path="`${props.path}.${setting.name}`"
/> />
<Selection <SettingsSelection
v-if="setting.type === 'selection'" v-if="setting.type === 'selection'"
:setting="setting" :setting="setting"
:path="`${props.path}.${setting.name}`" :path="`${props.path}.${setting.name}`"
@@ -79,15 +78,15 @@ const props = defineProps({
margin-bottom: calc(var(--size) - 0.2em); margin-bottom: calc(var(--size) - 0.2em);
} }
.section-heading[aria-level="2"] { .section-heading[aria-level='2'] {
--size: 1.8em; --size: 1.8em;
} }
.section-heading[aria-level="3"] { .section-heading[aria-level='3'] {
--size: 1.6em; --size: 1.6em;
} }
.section-heading[aria-level="4"] { .section-heading[aria-level='4'] {
--size: 1.4em; --size: 1.4em;
} }
</style> </style>
@@ -15,8 +15,8 @@ limitations under the License.
--> -->
<script setup> <script setup>
import Icon from '@/features/icons/components/Icon.vue';
import { useSettingsPage } from '../composables/useSettingsPage'; import { useSettingsPage } from '../composables/useSettingsPage';
import UIIcon from '@/features/icons/components/UIIcon.vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
@@ -25,15 +25,15 @@ const { goToSettingsPage } = useSettingsPage();
const props = defineProps({ const props = defineProps({
target: { target: {
required: true, required: true,
type: String type: String,
}, },
displayName: { displayName: {
required: true, required: true,
type: String type: String,
} },
}); });
const followLink = function followLink () { const followLink = function followLink() {
goToSettingsPage(props.target); goToSettingsPage(props.target);
}; };
</script> </script>
@@ -42,11 +42,13 @@ const followLink = function followLink () {
<button <button
class="settings-button button settings-page-link" class="settings-button button settings-page-link"
:title="props.displayName" :title="props.displayName"
:aria-label="t('settings.pages.link.ariaLabel', { page: props.displayName })" :aria-label="
t('settings.pages.link.ariaLabel', { page: props.displayName })
"
@click="followLink" @click="followLink"
> >
{{ props.displayName }} {{ props.displayName }}
<Icon name="chevron-right" size="1em" /> <UIIcon name="chevron-right" size="1em" />
</button> </button>
</template> </template>
@@ -58,4 +60,4 @@ const followLink = function followLink () {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
</style> </style>
@@ -15,14 +15,14 @@ limitations under the License.
--> -->
<script setup> <script setup>
import Icon from '@/features/icons/components/Icon.vue';
import { useSettingsStore } from '../stores/settingsStore'; import { useSettingsStore } from '../stores/settingsStore';
import { computed, ref, useId, watch } from 'vue'; import UIIcon from '@/features/icons/components/UIIcon.vue';
import { computed, useId } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
const normalizeSelectedValue = function normalizeSelectedValue (value) { const normalizeSelectedValue = function normalizeSelectedValue(value) {
if (props.setting.allowMultiple) { if (props.setting.allowMultiple) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value; return value;
@@ -44,19 +44,22 @@ const normalizeSelectedValue = function normalizeSelectedValue (value) {
const props = defineProps({ const props = defineProps({
setting: { setting: {
required: true required: true,
}, },
path: { path: {
required: true required: true,
} },
}); });
const store = useSettingsStore(); const store = useSettingsStore();
const optionType = computed(() => props.setting.allowMultiple ? 'checkbox' : 'radio'); const optionType = computed(() =>
props.setting.allowMultiple ? 'checkbox' : 'radio',
);
const selected = computed({ const selected = computed({
get: () => normalizeSelectedValue(store.get(props.path) ?? props.setting.default), get: () =>
set: (value) => store.set(props.path, value) normalizeSelectedValue(store.get(props.path) ?? props.setting.default),
set: (value) => store.set(props.path, value),
}); });
const labelId = useId(); const labelId = useId();
@@ -72,7 +75,11 @@ const labelId = useId();
<label <label
class="settings-button" class="settings-button"
:title="t(option.i18n)" :title="t(option.i18n)"
:aria-label="t('settings.selection.option.ariaLabel', { option: t(option.i18n) })" :aria-label="
t('settings.selection.option.ariaLabel', {
option: t(option.i18n),
})
"
> >
<input <input
:type="optionType" :type="optionType"
@@ -80,9 +87,13 @@ const labelId = useId();
:value="option.name" :value="option.name"
class="option-input" class="option-input"
v-model="selected" v-model="selected"
> />
<div class="option-check-mark-wrapper"> <div class="option-check-mark-wrapper">
<Icon class="option-check-mark" name="check-mark" size="1em" /> <UIIcon
class="option-check-mark"
name="check-mark"
size="1em"
/>
</div> </div>
<div class="input-label"> <div class="input-label">
{{ t(option.i18n) }} {{ t(option.i18n) }}
@@ -122,4 +133,4 @@ const labelId = useId();
.option-input:checked + .option-check-mark-wrapper .option-check-mark { .option-input:checked + .option-check-mark-wrapper .option-check-mark {
visibility: visible; visibility: visible;
} }
</style> </style>
@@ -23,11 +23,11 @@ const { t } = useI18n();
const props = defineProps({ const props = defineProps({
setting: { setting: {
required: true required: true,
}, },
path: { path: {
required: true required: true,
} },
}); });
const labelId = useId(); const labelId = useId();
@@ -42,7 +42,7 @@ watch(store, (newStore) => {
// set value after registering watcher to avoid immediate value change // set value after registering watcher to avoid immediate value change
enabled.value = store.get(props.path) ?? props.setting.default; enabled.value = store.get(props.path) ?? props.setting.default;
const toggle = function toggle () { const toggle = function toggle() {
enabled.value = !enabled.value; enabled.value = !enabled.value;
store.set(props.path, enabled.value); store.set(props.path, enabled.value);
}; };
@@ -106,4 +106,4 @@ const toggle = function toggle () {
.switch-wrapper.enabled .switch-point { .switch-wrapper.enabled .switch-point {
margin-left: var(--point-size); margin-left: var(--point-size);
} }
</style> </style>
@@ -14,25 +14,36 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { useSettingsStore } from '../../stores/settingsStore.js';
import SettingsInput from '../SettingsInput.vue'; import SettingsInput from '../SettingsInput.vue';
import { mountComponent } from '@/test-utils/mountComponent'; import { mountComponent } from '@/test-utils/mountComponent';
import { useSettingsStore } from '../../stores/settingsStore.js';
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
const getWrapper = function getWrapper({ type = 'string', i18n = 'input', defaultValue = undefined, piniaOptions = {} } = {}) { const getWrapper = function getWrapper({
return mountComponent(SettingsInput, { type = 'string',
attrs: { i18n = 'input',
setting: { defaultValue = undefined,
name: 'input', piniaOptions = {},
default: defaultValue, } = {}) {
i18n, return mountComponent(
type SettingsInput,
{
attrs: {
setting: {
name: 'input',
default: defaultValue,
i18n,
type,
},
path: 'input',
}, },
path: 'input' },
} {},
}, {}, [], piniaOptions); [],
} piniaOptions,
);
};
describe('SettingsInput', () => { describe('SettingsInput', () => {
test('uses correct input type', () => { test('uses correct input type', () => {
@@ -63,8 +74,8 @@ describe('SettingsInput', () => {
setupStores: () => { setupStores: () => {
const store = useSettingsStore(); const store = useSettingsStore();
store.settings.input = value; store.settings.input = value;
} },
} },
}); });
const inputElement = stringSettingWrapper.find('input'); const inputElement = stringSettingWrapper.find('input');
@@ -76,7 +87,7 @@ describe('SettingsInput', () => {
const wrapper = getWrapper({ const wrapper = getWrapper({
type: 'string', type: 'string',
i18n: 'input', i18n: 'input',
defaultValue: value defaultValue: value,
}); });
const inputElement = wrapper.find('input'); const inputElement = wrapper.find('input');
@@ -89,13 +100,13 @@ describe('SettingsInput', () => {
const wrapper = getWrapper({ const wrapper = getWrapper({
type: 'string', type: 'string',
i18n: 'input', i18n: 'input',
defaultValue: defaultValue, defaultValue,
piniaOptions: { piniaOptions: {
setupStores: () => { setupStores: () => {
const store = useSettingsStore(); const store = useSettingsStore();
store.settings.input = storeValue; store.settings.input = storeValue;
} },
} },
}); });
const inputElement = wrapper.find('input'); const inputElement = wrapper.find('input');
@@ -173,15 +184,15 @@ describe('SettingsInput', () => {
await form.trigger('submit'); await form.trigger('submit');
expect(form.classes('done-button-shown')).toBeFalsy(); expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue('a'); await inputElement.setValue('a');
expect(form.classes('done-button-shown')).toBeTruthy(); expect(form.classes('done-button-shown')).toBeTruthy();
const store = useSettingsStore(); const store = useSettingsStore();
store.set('input', 'a'); store.set('input', 'a');
await nextTick(); await nextTick();
expect(form.classes('done-button-shown')).toBeFalsy(); expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue(''); await inputElement.setValue('');
store.set('input', ''); store.set('input', '');
await nextTick(); await nextTick();
@@ -191,20 +202,24 @@ describe('SettingsInput', () => {
test('shows correct translation', () => { test('shows correct translation', () => {
const translation = 'Text input'; const translation = 'Text input';
const wrapper = mountComponent(SettingsInput, { const wrapper = mountComponent(
attrs: { SettingsInput,
setting: { {
name: 'input', attrs: {
i18n: 'input', setting: {
type: 'string', name: 'input',
i18n: 'input',
type: 'string',
},
path: 'input',
}, },
path: 'input' },
} {
}, { input: translation,
input: translation },
}); );
const label = wrapper.find('label'); const label = wrapper.find('label');
expect(label.text()).toBe(translation); expect(label.text()).toBe(translation);
}); });
}); });
@@ -14,44 +14,53 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import Selection from '../Selection.vue'; import SettingsSelection from '../SettingsSelection.vue';
import SettingsInput from '../SettingsInput.vue'; import SettingsInput from '../SettingsInput.vue';
import SettingsPage from '../SettingsPage.vue'; import SettingsPage from '../SettingsPage.vue';
import SettingsPageLink from '../SettingsPageLink.vue'; import SettingsPageLink from '../SettingsPageLink.vue';
import Switch from '../Switch.vue'; import SettingsSwitch from '../SettingsSwitch.vue';
import { mountComponent } from '@/test-utils/mountComponent.js'; import { mountComponent } from '@/test-utils/mountComponent.js';
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
const getWrapper = function getWrapper({
const getWrapper = function getWrapper({ content = [], translations = {}, piniaOptions = {} }) { content = [],
return mountComponent(SettingsPage, { translations = {},
attrs: { piniaOptions = {},
setting: { }) {
name: 'section', return mountComponent(
type: 'section', SettingsPage,
i18n: 'section', {
content: content attrs: {
setting: {
name: 'section',
type: 'section',
i18n: 'section',
content,
},
path: 'section',
}, },
path: 'section' },
} translations,
}, translations, [], piniaOptions); [],
piniaOptions,
);
}; };
const exampleSettings = [ const exampleSettings = [
{ {
type: 'bool', type: 'bool',
name: 'b0', name: 'b0',
i18n: 'settings.section.b0' i18n: 'settings.section.b0',
}, },
{ {
type: 'number', type: 'number',
name: 'n0', name: 'n0',
i18n: 'settings.section.n0' i18n: 'settings.section.n0',
}, },
{ {
type: 'string', type: 'string',
name: 'st0', name: 'st0',
i18n: 'settings.section1.st0' i18n: 'settings.section1.st0',
}, },
{ {
type: 'selection', type: 'selection',
@@ -61,14 +70,14 @@ const exampleSettings = [
{ name: 'o0', i18n: 'settings.section.se0.o0' }, { name: 'o0', i18n: 'settings.section.se0.o0' },
{ name: 'o1', i18n: 'settings.section.se0.o1' }, { name: 'o1', i18n: 'settings.section.se0.o1' },
{ name: 'o2', i18n: 'settings.section.se0.o2' }, { name: 'o2', i18n: 'settings.section.se0.o2' },
{ name: 'o3', i18n: 'settings.section.se0.o3' } { name: 'o3', i18n: 'settings.section.se0.o3' },
] ],
}, },
{ {
type: 'section', type: 'section',
name: 's0', name: 's0',
i18n: 'settings.section.s0', i18n: 'settings.section.s0',
content: [] content: [],
}, },
{ {
type: 'section', type: 'section',
@@ -79,17 +88,17 @@ const exampleSettings = [
type: 'bool', type: 'bool',
name: 'b0', name: 'b0',
i18n: 'settings.section.s1.b0', i18n: 'settings.section.s1.b0',
default: true default: true,
} },
] ],
} },
]; ];
describe('SettingsPage', () => { describe('SettingsPage', () => {
test('shows correct heading', () => { test('shows correct heading', () => {
const translation = 'Example section'; const translation = 'Example section';
const wrapper = getWrapper({ const wrapper = getWrapper({
translations: { section: translation } translations: { section: translation },
}); });
const heading = wrapper.find('.section-heading'); const heading = wrapper.find('.section-heading');
@@ -98,20 +107,21 @@ describe('SettingsPage', () => {
test('shows settings correctly', () => { test('shows settings correctly', () => {
const wrapper = getWrapper({ const wrapper = getWrapper({
content: exampleSettings content: exampleSettings,
}); });
const settingsWrappers = wrapper.findAll('.setting'); const settingsWrappers = wrapper.findAll('.setting');
expect(settingsWrappers.length).toBe(exampleSettings.length); expect(settingsWrappers.length).toBe(exampleSettings.length);
exampleSettings.forEach((expectedSetting, index) => { exampleSettings.forEach((expectedSetting, index) => {
const settingWrapper = settingsWrappers[index]; const settingWrapper = settingsWrappers[index];
const switchSetting = settingWrapper.findComponent(Switch); const switchSetting = settingWrapper.findComponent(SettingsSwitch);
const inputSetting = settingWrapper.findComponent(SettingsInput); const inputSetting = settingWrapper.findComponent(SettingsInput);
const selectionSetting = settingWrapper.findComponent(Selection); const selectionSetting =
const settingsPageLink = settingWrapper.findComponent(SettingsPageLink); settingWrapper.findComponent(SettingsSelection);
const settingsPageLink =
settingWrapper.findComponent(SettingsPageLink);
let setting; let setting;
if (expectedSetting.type === 'bool') { if (expectedSetting.type === 'bool') {
@@ -121,7 +131,10 @@ describe('SettingsPage', () => {
expect(settingsPageLink.exists()).toBe(false); expect(settingsPageLink.exists()).toBe(false);
setting = switchSetting; setting = switchSetting;
} else if (expectedSetting.type === 'string' || expectedSetting.type === 'number') { } else if (
expectedSetting.type === 'string' ||
expectedSetting.type === 'number'
) {
expect(switchSetting.exists()).toBe(false); expect(switchSetting.exists()).toBe(false);
expect(inputSetting.exists()).toBe(true); expect(inputSetting.exists()).toBe(true);
expect(selectionSetting.exists()).toBe(false); expect(selectionSetting.exists()).toBe(false);
@@ -150,11 +163,15 @@ describe('SettingsPage', () => {
expectedSetting.type === 'selection' expectedSetting.type === 'selection'
) { ) {
expect(setting.props('setting')).toMatchObject(expectedSetting); expect(setting.props('setting')).toMatchObject(expectedSetting);
expect(setting.props('path')).toBe(`section.${expectedSetting.name}`); expect(setting.props('path')).toBe(
`section.${expectedSetting.name}`,
);
} else { } else {
expect(setting.props('displayName')).toBe(expectedSetting.i18n); expect(setting.props('displayName')).toBe(expectedSetting.i18n);
expect(setting.props('target')).toBe(`section.${expectedSetting.name}`); expect(setting.props('target')).toBe(
`section.${expectedSetting.name}`,
);
} }
}); });
}); });
}); });
@@ -22,21 +22,19 @@ import { createI18n } from 'vue-i18n';
const goToSettingsPage = vi.fn(); const goToSettingsPage = vi.fn();
vi.mock('../../composables/useSettingsPage.js', () => ({ vi.mock('../../composables/useSettingsPage.js', () => ({
useSettingsPage: () => ({ useSettingsPage: () => ({
goToSettingsPage goToSettingsPage,
}) }),
})); }));
const getWrapper = function getWrapper({ target, displayName }) { const getWrapper = function getWrapper({ target, displayName }) {
return mount(SettingsPageLink, { return mount(SettingsPageLink, {
attrs: { attrs: {
target, target,
displayName displayName,
}, },
global: { global: {
plugins: [ plugins: [createI18n()],
createI18n() },
]
}
}); });
}; };
@@ -56,4 +54,4 @@ describe('SettingsPageLink', () => {
expect(goToSettingsPage).toHaveBeenCalledWith(target); expect(goToSettingsPage).toHaveBeenCalledWith(target);
}); });
}); });
@@ -14,46 +14,52 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import Selection from '../Selection.vue';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { useSettingsStore } from '../../stores/settingsStore.js'; 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 { expect, describe, test } from 'vitest';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
const getWrapper = function getWrapper ({ const getWrapper = function getWrapper({
i18n = 'selection', i18n = 'selection',
options = [], options = [],
allowMultiple = false, allowMultiple = false,
defaultValue = undefined, defaultValue = undefined,
translations = {}, translations = {},
piniaOptions = {} piniaOptions = {},
} = {}) { } = {}) {
return mountComponent(Selection, { return mountComponent(
attrs: { SettingsSelection,
setting: { {
name: 'selection', attrs: {
type: 'selection', setting: {
i18n, name: 'selection',
options, type: 'selection',
allowMultiple, i18n,
default: defaultValue options,
allowMultiple,
default: defaultValue,
},
path: 'selection',
}, },
path: 'selection' },
} translations,
}, translations, [], piniaOptions); [],
piniaOptions,
);
}; };
const getChecked = function getChecked (inputs) { const getChecked = function getChecked(inputs) {
return inputs.map((input) => input.element.matches(':checked')); return inputs.map((input) => input.element.matches(':checked'));
}; };
const exampleOptions = [ const exampleOptions = [
{ name: 'o0', i18n: 'o0' }, { name: 'o0', i18n: 'o0' },
{ name: 'o1', i18n: 'o1' }, { name: 'o1', i18n: 'o1' },
{ name: 'o2', i18n: 'o2' } { name: 'o2', i18n: 'o2' },
]; ];
describe('Selection', () => { describe('SettingsSelection', () => {
test('displays options correctly', () => { test('displays options correctly', () => {
const wrapper = getWrapper({ options: exampleOptions }); const wrapper = getWrapper({ options: exampleOptions });
@@ -71,7 +77,7 @@ describe('Selection', () => {
expect(input.attributes('name')).toBe('selection'); expect(input.attributes('name')).toBe('selection');
expect(input.element.matches(':checked')).toBe(false); expect(input.element.matches(':checked')).toBe(false);
}; }
}); });
test('shows value from store', () => { test('shows value from store', () => {
@@ -81,8 +87,8 @@ describe('Selection', () => {
setupStores: () => { setupStores: () => {
const store = useSettingsStore(); const store = useSettingsStore();
store.set('selection', 'o1'); store.set('selection', 'o1');
} },
} },
}); });
const inputs = wrapper.findAll('input'); const inputs = wrapper.findAll('input');
@@ -97,8 +103,8 @@ describe('Selection', () => {
setupStores: () => { setupStores: () => {
const store = useSettingsStore(); const store = useSettingsStore();
store.set('selection', 'o1'); store.set('selection', 'o1');
} },
} },
}); });
const settings = useSettingsStore(); const settings = useSettingsStore();
@@ -123,9 +129,9 @@ describe('Selection', () => {
setupStores: () => { setupStores: () => {
const store = useSettingsStore(); const store = useSettingsStore();
store.set('selection', ['o1', 'o2']); store.set('selection', ['o1', 'o2']);
} },
}, },
allowMultiple: true allowMultiple: true,
}); });
const inputs = wrapper.findAll('input'); const inputs = wrapper.findAll('input');
@@ -136,7 +142,7 @@ describe('Selection', () => {
test('shows default value', () => { test('shows default value', () => {
const wrapper = getWrapper({ const wrapper = getWrapper({
options: exampleOptions, options: exampleOptions,
defaultValue: 'o2' defaultValue: 'o2',
}); });
const inputs = wrapper.findAll('input'); const inputs = wrapper.findAll('input');
@@ -148,7 +154,7 @@ describe('Selection', () => {
const wrapper = getWrapper({ const wrapper = getWrapper({
options: exampleOptions, options: exampleOptions,
defaultValue: ['o0', 'o1'], defaultValue: ['o0', 'o1'],
allowMultiple: true allowMultiple: true,
}); });
const inputs = wrapper.findAll('input'); const inputs = wrapper.findAll('input');
@@ -164,8 +170,8 @@ describe('Selection', () => {
setupStores: () => { setupStores: () => {
const store = useSettingsStore(); const store = useSettingsStore();
store.set('selection', 'o1'); store.set('selection', 'o1');
} },
} },
}); });
const inputs = wrapper.findAll('input'); const inputs = wrapper.findAll('input');
@@ -199,33 +205,36 @@ describe('Selection', () => {
}); });
test('stores multiple values', async () => { test('stores multiple values', async () => {
const setValue = async function setValue (input, value) { const setValue = async function setValue(input, value) {
input.setValue(value); input.setValue(value);
await nextTick(); await nextTick();
}; };
const check = function check (input) { const check = function check(input) {
return setValue(input, true); return setValue(input, true);
}; };
const uncheck = function uncheck (input) { const uncheck = function uncheck(input) {
return setValue(input, false); return setValue(input, false);
}; };
const expectSelectionValue = function expectSelectionValue (value, options = exampleOptions) { const expectSelectionValue = function expectSelectionValue(
value,
options = exampleOptions,
) {
value.sort(); value.sort();
expect(store.get('selection').sort()).toStrictEqual(value); expect(store.get('selection').sort()).toStrictEqual(value);
const checkedInputs = getChecked(inputs); const checkedInputs = getChecked(inputs);
expect( expect(
options options
.map((option) => option.name) .map((option) => option.name)
.filter((entry, i) => checkedInputs[i]) .filter((_, i) => checkedInputs[i]),
).toStrictEqual(value); ).toStrictEqual(value);
}; };
const wrapper = getWrapper({ const wrapper = getWrapper({
options: exampleOptions, options: exampleOptions,
allowMultiple: true allowMultiple: true,
}); });
const store = useSettingsStore(); const store = useSettingsStore();
@@ -237,7 +246,7 @@ describe('Selection', () => {
const inputO2 = inputs[2]; const inputO2 = inputs[2];
await check(inputO0); await check(inputO0);
expectSelectionValue(['o0']) expectSelectionValue(['o0']);
await uncheck(inputO0); await uncheck(inputO0);
expectSelectionValue([]); expectSelectionValue([]);
@@ -283,11 +292,11 @@ describe('Selection', () => {
const translation = 'Selection setting'; const translation = 'Selection setting';
const wrapper = getWrapper({ const wrapper = getWrapper({
translations: { translations: {
selection: translation selection: translation,
} },
}); });
const label = wrapper.find('.label'); const label = wrapper.find('.label');
expect(label.text()).toBe(translation); expect(label.text()).toBe(translation);
}); });
}); });
@@ -14,32 +14,38 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import Switch from '../Switch.vue';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { useSettingsStore } from '../../stores/settingsStore.js'; import { useSettingsStore } from '../../stores/settingsStore.js';
import { expect, describe, test } from 'vitest'; import SettingsSwitch from '../SettingsSwitch.vue';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
const getWrapper = function getWrapper ({ const getWrapper = function getWrapper({
i18n = 'switch1', i18n = 'switch1',
defaultValue = undefined, defaultValue = undefined,
translations = {}, translations = {},
piniaOptions = {} piniaOptions = {},
} = {}) { } = {}) {
return mountComponent(Switch, { return mountComponent(
attrs: { SettingsSwitch,
setting: { {
type: 'bool', attrs: {
name: 'switch', setting: {
i18n, type: 'bool',
default: defaultValue name: 'switch',
i18n,
default: defaultValue,
},
path: 'switch',
}, },
path: 'switch' },
} translations,
}, translations, [], piniaOptions); [],
piniaOptions,
);
}; };
describe('Switch', () => { describe('SettingsSwitch', () => {
test('shows value from store', async () => { test('shows value from store', async () => {
const wrapper = getWrapper(); const wrapper = getWrapper();
@@ -61,7 +67,7 @@ describe('Switch', () => {
test('shows default value', () => { test('shows default value', () => {
const wrapper = getWrapper({ const wrapper = getWrapper({
defaultValue: true defaultValue: true,
}); });
const switchElement = wrapper.find('.switch-wrapper'); const switchElement = wrapper.find('.switch-wrapper');
@@ -75,8 +81,8 @@ describe('Switch', () => {
setupStores: () => { setupStores: () => {
const store = useSettingsStore(); const store = useSettingsStore();
store.set('switch', false); store.set('switch', false);
} },
} },
}); });
const switchElement = wrapper.find('.switch-wrapper'); const switchElement = wrapper.find('.switch-wrapper');
@@ -89,7 +95,7 @@ describe('Switch', () => {
const switchElement = wrapper.find('.switch-wrapper'); const switchElement = wrapper.find('.switch-wrapper');
expect(switchElement.classes('enabled')).toBeFalsy(); expect(switchElement.classes('enabled')).toBeFalsy();
store.set('switch', true); store.set('switch', true);
await nextTick(); await nextTick();
expect(switchElement.classes('enabled')).toBeTruthy(); expect(switchElement.classes('enabled')).toBeTruthy();
@@ -97,29 +103,29 @@ describe('Switch', () => {
test('toggles value in store', async () => { test('toggles value in store', async () => {
const wrapper = getWrapper(); const wrapper = getWrapper();
const switchElement = wrapper.find('.switch-wrapper'); const switchElement = wrapper.find('.switch-wrapper');
const store = useSettingsStore(); const store = useSettingsStore();
expect(store.get('switch')).toBeUndefined(); expect(store.get('switch')).toBeUndefined();
switchElement.trigger('click'); switchElement.trigger('click');
await nextTick(); await nextTick();
expect(store.get('switch')).toBe(true); expect(store.get('switch')).toBe(true);
switchElement.trigger('click'); switchElement.trigger('click');
await nextTick(); await nextTick();
expect(store.get('switch')).toBe(false); expect(store.get('switch')).toBe(false);
}); });
test('shows correct translation', () => { test('shows correct translation', () => {
const translation = 'Switch'; const translation = 'Switch';
const wrapper = getWrapper({ const wrapper = getWrapper({
translations: { switch1: translation } translations: { switch1: translation },
}) });
const label = wrapper.find('label'); const label = wrapper.find('label');
expect(label.text()).toBe(translation); expect(label.text()).toBe(translation);
}); });
}); });
@@ -33,17 +33,17 @@ vi.mock('../../config.js', () => ({
type: 'number', type: 'number',
i18n: '', i18n: '',
name: 'number', name: 'number',
default: settingDefaultValue default: settingDefaultValue,
}, },
{ {
type: 'bool', type: 'bool',
i18n: '', i18n: '',
name: 'bool' name: 'bool',
} },
] ],
} },
] ],
} },
})); }));
describe('useSettings', () => { describe('useSettings', () => {
@@ -59,7 +59,7 @@ describe('useSettings', () => {
}); });
test('returns default value', () => { test('returns default value', () => {
const { getSetting, setSetting } = useSettings(); const { getSetting } = useSettings();
expect(getSetting(settingPath)).toBe(settingDefaultValue); expect(getSetting(settingPath)).toBe(settingDefaultValue);
}); });
@@ -85,7 +85,7 @@ beforeEach(() => {
setActivePinia( setActivePinia(
createTestingPinia({ createTestingPinia({
createSpy: vi.fn, createSpy: vi.fn,
stubActions: false stubActions: false,
}) }),
); );
}); });
@@ -20,8 +20,8 @@ import { describe, expect, test, vi } from 'vitest';
const pushRoute = vi.fn(); const pushRoute = vi.fn();
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
useRouter: () => ({ useRouter: () => ({
push: pushRoute push: pushRoute,
}) }),
})); }));
describe('useSettingsPage', () => { describe('useSettingsPage', () => {
@@ -32,9 +32,9 @@ describe('useSettingsPage', () => {
expect(pushRoute).toHaveBeenCalledWith({ expect(pushRoute).toHaveBeenCalledWith({
name: 'settings', name: 'settings',
params: { params: {
rest: ['a', 'b', 'c', 'd', 'e'] rest: ['a', 'b', 'c', 'd', 'e'],
} },
}); });
}); });
}); });
}); });
@@ -21,20 +21,18 @@ import { getSettingRecursively } from '../utils/getSetting';
/** /**
* Provides access to the stored settings. * Provides access to the stored settings.
*/ */
export const useSettings = function useSettings () { export const useSettings = function useSettings() {
/** /**
* Returns the value of a specific setting. * Returns the value of a specific setting.
* @param {string[]} key - The settings key. * @param {string[]} key - The settings key.
* @return The value of the setting. * @return The value of the setting.
*/ */
const getSetting = function getSetting (key) { const getSetting = function getSetting(key) {
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
return ( return (
settingsStore.get(key.join('.')) ?? settingsStore.get(key.join('.')) ??
getSettingRecursively( getSettingRecursively(key, settings.contents ?? [])?.default
key, settings.contents ?? []
)?.default
); );
}; };
@@ -43,7 +41,7 @@ export const useSettings = function useSettings () {
* @param {string[]} key - The setting key. * @param {string[]} key - The setting key.
* @param value - The new value for the setting. * @param value - The new value for the setting.
*/ */
const setSetting = function setSetting (key, value) { const setSetting = function setSetting(key, value) {
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
settingsStore.set(key.join('.'), value); settingsStore.set(key.join('.'), value);
}; };
@@ -17,17 +17,17 @@ limitations under the License.
import { getSettingsPagePathSegments } from '../utils/settingsPage'; import { getSettingsPagePathSegments } from '../utils/settingsPage';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
export const useSettingsPage = function useSettingsPage () { export const useSettingsPage = function useSettingsPage() {
const router = useRouter(); const router = useRouter();
const goToSettingsPage = function goToSettingsPage (pagePath) { const goToSettingsPage = function goToSettingsPage(pagePath) {
return router.push({ return router.push({
name: 'settings', name: 'settings',
params: { params: {
rest: getSettingsPagePathSegments(pagePath) rest: getSettingsPagePathSegments(pagePath),
} },
}); });
}; };
return { goToSettingsPage }; return { goToSettingsPage };
}; };
+13 -13
View File
@@ -40,10 +40,10 @@ export default {
default: defaultLanguage, default: defaultLanguage,
options: SUPPORTED_LANGUAGES.map((language) => ({ options: SUPPORTED_LANGUAGES.map((language) => ({
name: language, name: language,
i18n: `preferences.locale.languages.${language}` i18n: `preferences.locale.languages.${language}`,
})) })),
} },
] ],
}, },
{ {
name: 'appearance', name: 'appearance',
@@ -57,19 +57,19 @@ export default {
options: [ options: [
{ {
name: 'auto', name: 'auto',
i18n: 'settings.settings.appearance.contents.colorScheme.options.auto' i18n: 'settings.settings.appearance.contents.colorScheme.options.auto',
}, },
{ {
name: 'light', name: 'light',
i18n: 'settings.settings.appearance.contents.colorScheme.options.light' i18n: 'settings.settings.appearance.contents.colorScheme.options.light',
}, },
{ {
name: 'dark', name: 'dark',
i18n: 'settings.settings.appearance.contents.colorScheme.options.dark' i18n: 'settings.settings.appearance.contents.colorScheme.options.dark',
} },
] ],
} },
] ],
} },
] ],
}; };
+22 -18
View File
@@ -17,26 +17,30 @@ limitations under the License.
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { reactive } from 'vue'; import { reactive } from 'vue';
export const useSettingsStore = defineStore('settings', () => { export const useSettingsStore = defineStore(
const settings = reactive({}); 'settings',
() => {
const settings = reactive({});
const set = function set (key, value) { const set = function set(key, value) {
settings[key] = value; settings[key] = value;
}; };
const get = function get (key) { const get = function get(key) {
return settings[key]; return settings[key];
}; };
const remove = function remove (key) { const remove = function remove(key) {
delete settings[key]; delete settings[key];
}; };
const clear = function clear () { const clear = function clear() {
Object.keys(settings).forEach(key => delete settings[key]); Object.keys(settings).forEach((key) => delete settings[key]);
}; };
return { set, get, remove, clear, settings }; return { set, get, remove, clear, settings };
}, { },
persist: true {
}); persist: true,
},
);
@@ -62,4 +62,4 @@ limitations under the License.
* @typedef {BoolSettingConfig | NumberSettingConfig | StringSettingConfig | SelectionSettingConfig | SectionSettingConfig} SettingConfigEntry * @typedef {BoolSettingConfig | NumberSettingConfig | StringSettingConfig | SelectionSettingConfig | SectionSettingConfig} SettingConfigEntry
* @typedef {{ contents: SettingConfigEntry[] }} FirstLevelSettingConfigEntry * @typedef {{ contents: SettingConfigEntry[] }} FirstLevelSettingConfigEntry
* @typedef {{ contents: FirstLevelSettingConfigEntry[] }} SettingsConfig * @typedef {{ contents: FirstLevelSettingConfigEntry[] }} SettingsConfig
*/ */
File diff suppressed because it is too large Load Diff
@@ -47,17 +47,26 @@ describe('settingsPage', () => {
{ path: 'a1.#b.c/', expected: ['a1', '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-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....',
{ path: '..,...~..@a/#.+..)!&§[b.#§c..dä..)..', expected: ['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: 'a.@.b', expected: ['a', 'b'] },
{ path: 1, expected: [] }, { path: 1, expected: [] },
{ path: false, expected: [] }, { path: false, expected: [] },
{ path: true, expected: [] }, { path: true, expected: [] },
{ path: null, expected: [] }, { path: null, expected: [] },
{ path: undefined, expected: [] } { path: undefined, expected: [] },
])('returns $expected', async ({ path, expected }) => { ])('returns $expected', ({ path, expected }) => {
expect(getSettingsPagePathSegments(path)).toStrictEqual(expected); expect(getSettingsPagePathSegments(path)).toStrictEqual(expected);
}); });
}) });
}) });
File diff suppressed because it is too large Load Diff
+13 -7
View File
@@ -14,12 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export const getSetting = function getSetting (name, settings) { export const getSetting = function getSetting(name, settings) {
return settings.filter((setting) => setting.name === name)[0]; return settings.filter((setting) => setting.name === name)[0];
}; };
export const getSettingRecursively = function getSettingRecursively (sectionPath, settings) { export const getSettingRecursively = function getSettingRecursively(
const oneSettingsLevel = function oneSettingsLevel (cursor, acc) { sectionPath,
settings,
) {
const oneSettingsLevel = function oneSettingsLevel(cursor, acc) {
const setting = getSetting(cursor[0], acc.content); const setting = getSetting(cursor[0], acc.content);
if (!setting) return; if (!setting) return;
@@ -28,15 +31,18 @@ export const getSettingRecursively = function getSettingRecursively (sectionPath
const newCursor = cursor.slice(1, cursor.length); const newCursor = cursor.slice(1, cursor.length);
if (newCursor.length !== 0) { if (newCursor.length !== 0) {
return oneSettingsLevel(cursor.slice(1, cursor.length), setting); return oneSettingsLevel(
cursor.slice(1, cursor.length),
setting,
);
} else { } else {
return setting; return setting;
}; }
} else if (cursor.length <= 1) { } else if (cursor.length <= 1) {
return setting; return setting;
} else { } else {
return; return;
}; }
}; };
return oneSettingsLevel(sectionPath, { content: settings }); return oneSettingsLevel(sectionPath, { content: settings });
}; };
+5 -5
View File
@@ -19,13 +19,13 @@ limitations under the License.
* @param {string} path - The settings page path. * @param {string} path - The settings page path.
* @returns {Array} The settings page path segments. * @returns {Array} The settings page path segments.
*/ */
export const getSettingsPagePathSegments = function getSettingsPagePathSegments (path) { export const getSettingsPagePathSegments = function getSettingsPagePathSegments(
path,
) {
if (typeof path !== 'string') return []; if (typeof path !== 'string') return [];
return path return path
.replace(/^\.+|\.+$/g, '') .replace(/^\.+|\.+$/g, '')
.split('.') .split('.')
.map( .map((segment) => segment.replace(/[^a-zA-Z0-9-]/g, ''))
(segment) => segment.replace(/[^a-zA-Z0-9-]/g, '')
)
.filter(Boolean); .filter(Boolean);
}; };
@@ -16,33 +16,38 @@ 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) { export const assertString = 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) { export const assertType = 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 (options, path) { export const validateSelectionOptions = function validateSelectionOptions(
options,
path,
) {
if (!Array.isArray(options) || options.length === 0) { if (!Array.isArray(options) || options.length === 0) {
throw new Error(`[settings] "${path}.options" must be a non-empty array`); throw new Error(
}; `[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) { export const validateEntry = function validateEntry(entry, path) {
assertType(entry.type, `${path}.type`); assertType(entry.type, `${path}.type`);
assertString(entry.name, `${path}.name`); assertString(entry.name, `${path}.name`);
assertString(entry.i18n, `${path}.i18n`); assertString(entry.i18n, `${path}.i18n`);
@@ -51,17 +56,19 @@ export const validateEntry = function validateEntry (entry, path) {
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') { if (entry.type === 'selection') {
validateSelectionOptions(entry.options, path); validateSelectionOptions(entry.options, path);
if (typeof entry.allowMultiple !== 'boolean' && entry.allowMultiple) { if (typeof entry.allowMultiple !== 'boolean' && entry.allowMultiple) {
throw new Error(`[settings] "${path}.allowMultiple" must be a boolean`); 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`);
@@ -75,39 +82,56 @@ export const validateEntry = function validateEntry (entry, path) {
if (entry.type === 'selection') { if (entry.type === 'selection') {
if (entry.allowMultiple) { if (entry.allowMultiple) {
if (!Array.isArray(entry.default)) { if (!Array.isArray(entry.default)) {
throw new Error(`[settings] "${path}.default" must be an array`); throw new Error(
`[settings] "${path}.default" must be an array`,
);
} }
const allOptions = entry.options.map((option) => option.name); const allOptions = entry.options.map((option) => option.name);
entry.default.forEach((defaultValue, index) => { entry.default.forEach((defaultValue, index) => {
if (typeof defaultValue !== 'string') { if (typeof defaultValue !== 'string') {
throw new Error(`[settings] "${path}.default[${index}]" must be a string`); throw new Error(
`[settings] "${path}.default[${index}]" must be a string`,
);
} }
if (!allOptions.includes(defaultValue)) { if (!allOptions.includes(defaultValue)) {
throw new Error(`[settings] option "${path}.default[${index}]" does not exist`); throw new Error(
`[settings] option "${path}.default[${index}]" does not exist`,
);
} }
}); });
} else { } else {
if (typeof entry.default !== 'string') { if (typeof entry.default !== 'string') {
throw new Error(`[settings] "${path}.default" must be a string`); throw new Error(
}; `[settings] "${path}.default" must be a string`,
if (!entry.options.map((option) => option.name).includes(entry.default)) { );
throw new Error(`[settings] option "${path}.default" does not exist`); }
}; 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) { export const validateFirstLevelSection = function validateFirstLevelSection(
section,
path,
) {
assertString(section.name); assertString(section.name);
assertString(section.i18n); assertString(section.i18n);
if (!Array.isArray(section.content)) { if (!Array.isArray(section.content)) {
throw new Error(`[settings] "${path}.content" must be an array`); throw new Error(`[settings] "${path}.content" must be an array`);
}; }
section.content.forEach((entry, i) => section.content.forEach((entry, i) =>
validateEntry(entry, `${path}.content[${i}]`) validateEntry(entry, `${path}.content[${i}]`),
); );
}; };
@@ -116,7 +140,7 @@ export const validateFirstLevelSection = function validateFirstLevelSection (sec
* @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 function validateSettingsConfig(raw) { export const validateSettingsConfig = function validateSettingsConfig(raw) {
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');
} }
@@ -124,6 +148,6 @@ export 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}]`) validateFirstLevelSection(entry, `contents[${i}]`),
); );
} };
+13 -10
View File
@@ -15,11 +15,10 @@ limitations under the License.
--> -->
<script setup> <script setup>
import LeftSidebarLayout from '@/layouts/LeftSidebarLayout.vue';
import SettingsPage from '../components/SettingsPage.vue'; import SettingsPage from '../components/SettingsPage.vue';
import settingsConfiguration from '../config.js'; import settingsConfiguration from '../config.js';
import { getSettingRecursively } from '../utils/getSetting.js'; import { getSettingRecursively } from '../utils/getSetting.js';
import LeftSidebarLayout from '@/layouts/LeftSidebarLayout.vue';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
@@ -31,15 +30,13 @@ const router = useRouter();
const settings = settingsConfiguration.contents; const settings = settingsConfiguration.contents;
const activePath = computed(() => { const activePath = computed(() => {
const segments = route.path const segments = route.path.split('/').filter(Boolean);
.split('/')
.filter(Boolean);
return segments.slice(1, segments.length); return segments.slice(1, segments.length);
}); });
const activeSection = ref(null); const activeSection = ref(null);
const updateSettings = async function updateSettings () { const updateSettings = async function updateSettings() {
const setting = getSettingRecursively(activePath.value, settings); const setting = getSettingRecursively(activePath.value, settings);
if (setting) { if (setting) {
activeSection.value = setting; activeSection.value = setting;
@@ -50,7 +47,9 @@ const updateSettings = async function updateSettings () {
watch(route, (newRoute) => updateSettings(newRoute)); watch(route, (newRoute) => updateSettings(newRoute));
onMounted(updateSettings); onMounted(updateSettings);
const toggleSidebarIfSmallScreen = function toggleSidebarIfSmallScreen (toggleSidebarFunction) { const toggleSidebarIfSmallScreen = function toggleSidebarIfSmallScreen(
toggleSidebarFunction,
) {
if (matchMedia('(max-width: 48rem)').matches) toggleSidebarFunction(); if (matchMedia('(max-width: 48rem)').matches) toggleSidebarFunction();
}; };
</script> </script>
@@ -65,11 +64,15 @@ const toggleSidebarIfSmallScreen = function toggleSidebarIfSmallScreen (toggleSi
<LeftSidebarLayout class="layout"> <LeftSidebarLayout class="layout">
<template #sidebar="{ toggleSidebar }"> <template #sidebar="{ toggleSidebar }">
<ul class="sidebar-sections-list"> <ul class="sidebar-sections-list">
<li v-for="section in settings"> <li v-for="section in settings" :key="section">
<RouterLink <RouterLink
:to="`/settings/${section.name}`" :to="`/settings/${section.name}`"
class="button button-link link sidebar-section" class="button button-link link sidebar-section"
:class="{ active: activeSection && activeSection.name === section.name }" :class="{
active:
activeSection &&
activeSection.name === section.name,
}"
@click="toggleSidebarIfSmallScreen(toggleSidebar)" @click="toggleSidebarIfSmallScreen(toggleSidebar)"
> >
{{ t(section.i18n) }} {{ t(section.i18n) }}
@@ -137,4 +140,4 @@ const toggleSidebarIfSmallScreen = function toggleSidebarIfSmallScreen (toggleSi
padding: 0; padding: 0;
} }
} }
</style> </style>
@@ -23,7 +23,7 @@ const settingsHoisted = vi.hoisted(() => [
{ {
name: 's0', name: 's0',
i18n: 'settings.s0', i18n: 'settings.s0',
content: [] content: [],
}, },
{ {
name: 's1', name: 's1',
@@ -32,17 +32,17 @@ const settingsHoisted = vi.hoisted(() => [
{ {
type: 'bool', type: 'bool',
name: 'b0', name: 'b0',
i18n: 'settings.s1.b0' i18n: 'settings.s1.b0',
}, },
{ {
type: 'number', type: 'number',
name: 'n0', name: 'n0',
i18n: 'settings.s1.n0' i18n: 'settings.s1.n0',
}, },
{ {
type: 'string', type: 'string',
name: 'st0', name: 'st0',
i18n: 'settings.s1.st0' i18n: 'settings.s1.st0',
}, },
{ {
type: 'selection', type: 'selection',
@@ -52,14 +52,14 @@ const settingsHoisted = vi.hoisted(() => [
{ name: 'o0', i18n: 'settings.s1.se0.o0' }, { name: 'o0', i18n: 'settings.s1.se0.o0' },
{ name: 'o1', i18n: 'settings.s1.se0.o1' }, { name: 'o1', i18n: 'settings.s1.se0.o1' },
{ name: 'o2', i18n: 'settings.s1.se0.o2' }, { name: 'o2', i18n: 'settings.s1.se0.o2' },
{ name: 'o3', i18n: 'settings.s1.se0.o3' } { name: 'o3', i18n: 'settings.s1.se0.o3' },
] ],
}, },
{ {
type: 'section', type: 'section',
name: 's0', name: 's0',
i18n: 'settings.s1.s0', i18n: 'settings.s1.s0',
content: [] content: [],
}, },
{ {
type: 'section', type: 'section',
@@ -70,11 +70,11 @@ const settingsHoisted = vi.hoisted(() => [
type: 'bool', type: 'bool',
name: 'b0', name: 'b0',
i18n: 'settings.s1.s1.b0', i18n: 'settings.s1.s1.b0',
default: true default: true,
} },
] ],
} },
] ],
}, },
{ {
name: 's2', name: 's2',
@@ -89,11 +89,11 @@ const settingsHoisted = vi.hoisted(() => [
type: 'bool', type: 'bool',
name: 'b0', name: 'b0',
i18n: 'settings.s2.s0.b0', i18n: 'settings.s2.s0.b0',
default: true default: true,
} },
] ],
} },
] ],
}, },
{ {
name: 's3', name: 's3',
@@ -103,9 +103,9 @@ const settingsHoisted = vi.hoisted(() => [
type: 'section', type: 'section',
name: 's0', name: 's0',
i18n: 'settings.s3.s0', i18n: 'settings.s3.s0',
content: [] content: [],
} },
] ],
}, },
{ {
name: 's4', name: 's4',
@@ -119,10 +119,10 @@ const settingsHoisted = vi.hoisted(() => [
{ name: 'o0', i18n: 'settings.s4.se0.o0' }, { name: 'o0', i18n: 'settings.s4.se0.o0' },
{ name: 'o1', i18n: 'settings.s4.se0.o1' }, { name: 'o1', i18n: 'settings.s4.se0.o1' },
{ name: 'o2', i18n: 'settings.s4.se0.o2' }, { name: 'o2', i18n: 'settings.s4.se0.o2' },
{ name: 'o3', i18n: 'settings.s4.se0.o3' } { name: 'o3', i18n: 'settings.s4.se0.o3' },
] ],
} },
] ],
}, },
{ {
name: 's5', name: 's5',
@@ -131,9 +131,9 @@ const settingsHoisted = vi.hoisted(() => [
{ {
type: 'string', type: 'string',
name: 'st0', name: 'st0',
i18n: 'settings.s5.st0' i18n: 'settings.s5.st0',
} },
] ],
}, },
{ {
name: 's6', name: 's6',
@@ -142,9 +142,9 @@ const settingsHoisted = vi.hoisted(() => [
{ {
type: 'number', type: 'number',
name: 'n0', name: 'n0',
i18n: 'settings.s6.n0' i18n: 'settings.s6.n0',
} },
] ],
}, },
{ {
name: 's7', name: 's7',
@@ -153,9 +153,9 @@ const settingsHoisted = vi.hoisted(() => [
{ {
type: 'bool', type: 'bool',
name: 'b0', name: 'b0',
i18n: 'settings.s7.b0' i18n: 'settings.s7.b0',
} },
] ],
}, },
{ {
name: 's8', name: 's8',
@@ -181,16 +181,16 @@ const settingsHoisted = vi.hoisted(() => [
name: 'se0', name: 'se0',
i18n: 'settings.s8.s0.s0.s0.se0', i18n: 'settings.s8.s0.s0.s0.se0',
allowMultiple: true, allowMultiple: true,
options: [] options: [],
} },
] ],
} },
] ],
} },
] ],
} },
] ],
} },
]); ]);
const settings = settingsHoisted.values(); const settings = settingsHoisted.values();
@@ -198,7 +198,7 @@ const originalCurrentSection = 's0';
let currentSection = originalCurrentSection; let currentSection = originalCurrentSection;
vi.mock('../../config.js', () => ({ vi.mock('../../config.js', () => ({
default: { contents: settingsHoisted } default: { contents: settingsHoisted },
})); }));
vi.mock('vue-router', async (importOriginal) => { vi.mock('vue-router', async (importOriginal) => {
@@ -206,12 +206,12 @@ vi.mock('vue-router', async (importOriginal) => {
return { return {
...actual, ...actual,
useRoute: () => ({ useRoute: () => ({
path: `/settings/${currentSection}` path: `/settings/${currentSection}`,
}) }),
}; };
}); });
const getWrapper = function getWrapper ({ translations = {} } = {}) { const getWrapper = function getWrapper({ translations = {} } = {}) {
return mountComponent(SettingsView, {}, translations, [], {}); return mountComponent(SettingsView, {}, translations, [], {});
}; };
@@ -221,8 +221,8 @@ describe('SettingsView', () => {
const translation = 'Settings'; const translation = 'Settings';
const wrapper = getWrapper({ const wrapper = getWrapper({
translations: { translations: {
'preferences.settings': translation 'preferences.settings': translation,
} },
}); });
const header = wrapper.find('header'); const header = wrapper.find('header');
@@ -242,8 +242,12 @@ describe('SettingsView', () => {
expect(sidebarItem.text()).toBe(setting.i18n); expect(sidebarItem.text()).toBe(setting.i18n);
const link = sidebarItem.find('*'); const link = sidebarItem.find('*');
expect(link.attributes('href')).toBe(`/settings/${setting.name}`); expect(link.attributes('href')).toBe(
expect(link.classes('active')).toBe(setting.name === currentSection); `/settings/${setting.name}`,
);
expect(link.classes('active')).toBe(
setting.name === currentSection,
);
}); });
}); });
}); });
@@ -257,7 +261,9 @@ describe('SettingsView', () => {
await nextTick(); await nextTick();
const mainContent = wrapper.find('.settings-main-content'); const mainContent = wrapper.find('.settings-main-content');
const mainContentNoSection = wrapper.find('.main-content--no-section'); const mainContentNoSection = wrapper.find(
'.main-content--no-section',
);
expect(mainContent.exists()).toBe(false); expect(mainContent.exists()).toBe(false);
expect(mainContentNoSection.exists()).toBe(true); expect(mainContentNoSection.exists()).toBe(true);
@@ -268,7 +274,9 @@ describe('SettingsView', () => {
await nextTick(); await nextTick();
const mainContent = wrapper.find('.settings-main-content'); const mainContent = wrapper.find('.settings-main-content');
const mainContentNoSection = wrapper.find('.main-content--no-section'); const mainContentNoSection = wrapper.find(
'.main-content--no-section',
);
expect(mainContent.exists()).toBe(true); expect(mainContent.exists()).toBe(true);
expect(mainContentNoSection.exists()).toBe(false); expect(mainContentNoSection.exists()).toBe(false);
@@ -279,4 +287,4 @@ describe('SettingsView', () => {
beforeEach(() => { beforeEach(() => {
currentSection = originalCurrentSection; currentSection = originalCurrentSection;
}); });
@@ -18,15 +18,15 @@ limitations under the License.
import SidebarExpandButton from './SidebarExpandButton.vue'; import SidebarExpandButton from './SidebarExpandButton.vue';
const props = defineProps({ const props = defineProps({
'expanded': { expanded: {
default: false, default: false,
required: false, required: false,
type: Boolean type: Boolean,
} },
}); });
const emit = defineEmits(['toggleExpanded']); const emit = defineEmits(['toggleExpanded']);
const toggleSidebar = function toggleSidebar () { const toggleSidebar = function toggleSidebar() {
emit('toggleExpanded'); emit('toggleExpanded');
}; };
</script> </script>
@@ -34,7 +34,10 @@ const toggleSidebar = function toggleSidebar () {
<template> <template>
<nav class="sidebar" :class="{ expanded }"> <nav class="sidebar" :class="{ expanded }">
<div class="sidebar-controls"> <div class="sidebar-controls">
<SidebarExpandButton @click="toggleSidebar" class="sidebar-expand-button" /> <SidebarExpandButton
@click="toggleSidebar"
class="sidebar-expand-button"
/>
</div> </div>
<div class="sidebar-content" v-show="props.expanded"> <div class="sidebar-content" v-show="props.expanded">
<slot /> <slot />
@@ -74,4 +77,4 @@ const toggleSidebar = function toggleSidebar () {
padding: 20px; padding: 20px;
padding-top: 0; padding-top: 0;
} }
</style> </style>
@@ -15,18 +15,18 @@ limitations under the License.
--> -->
<script setup> <script setup>
import Icon from '@/features/icons/components/Icon.vue'; import UIIcon from '@/features/icons/components/UIIcon.vue';
const emit = defineEmits(['click']); const emit = defineEmits(['click']);
const click = function click (event) { const click = function click(event) {
emit('click', event); emit('click', event);
}; };
</script> </script>
<template> <template>
<button class="button sidebar-expand-button"> <button class="button sidebar-expand-button">
<Icon name="sidebar-expand" @click="click" /> <UIIcon name="sidebar-expand" @click="click" />
</button> </button>
</template> </template>
@@ -38,4 +38,4 @@ const click = function click (event) {
padding: 4px; padding: 4px;
border-radius: 10px; border-radius: 10px;
} }
</style> </style>
+10 -18
View File
@@ -18,29 +18,19 @@ import { createI18n } from 'vue-i18n';
export const fallbackLocale = 'en'; export const fallbackLocale = 'en';
export const LANGUAGES_RTL = ['ar'];
export const LANGUAGES_RTL = [ export const SUPPORTED_LANGUAGES = ['en', 'de', 'fr', 'es', 'pt', 'it'];
'ar'
];
export const SUPPORTED_LANGUAGES = [
'en',
'de',
'fr',
'es',
'pt',
'it'
];
export const i18n = createI18n({ export const i18n = createI18n({
legacy: false, legacy: false,
locale: fallbackLocale, locale: fallbackLocale,
fallbackLocale: fallbackLocale, fallbackLocale,
messages: {} messages: {},
}); });
const loadedLanguages = new Set(); const loadedLanguages = new Set();
export async function loadLanguage (locale) { export const loadLanguage = async function loadLanguage(locale) {
if (!SUPPORTED_LANGUAGES.includes(locale)) { if (!SUPPORTED_LANGUAGES.includes(locale)) {
locale = fallbackLocale; locale = fallbackLocale;
} }
@@ -48,7 +38,7 @@ export 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;
@@ -58,8 +48,10 @@ export async function loadLanguage (locale) {
loadedLanguages.add(locale); loadedLanguages.add(locale);
}; };
export const setLocale = async function setLocale (locale) { export const setLocale = async function setLocale(locale) {
await loadLanguage(locale); await loadLanguage(locale);
document.documentElement.lang = locale; document.documentElement.lang = locale;
document.documentElement.dir = LANGUAGES_RTL.includes(locale) ? 'rtl' : 'ltr'; document.documentElement.dir = LANGUAGES_RTL.includes(locale)
? 'rtl'
: 'ltr';
}; };
+19 -12
View File
@@ -15,21 +15,21 @@ limitations under the License.
--> -->
<script setup> <script setup>
import Sidebar from '@/features/sidebar/components/Sidebar.vue'; import AppSidebar from '@/features/sidebar/components/AppSidebar.vue';
import SidebarExpandButton from '@/features/sidebar/components/SidebarExpandButton.vue'; import SidebarExpandButton from '@/features/sidebar/components/SidebarExpandButton.vue';
import { ref } from 'vue'; import { ref } from 'vue';
const props = defineProps({ const props = defineProps({
'expanded': { expanded: {
default: false, default: false,
required: false, required: false,
type: Boolean type: Boolean,
} },
}); });
const expanded = ref(props.expanded); const expanded = ref(props.expanded);
const toggleSidebar = function toggleSidebar () { const toggleSidebar = function toggleSidebar() {
expanded.value = !expanded.value; expanded.value = !expanded.value;
}; };
</script> </script>
@@ -37,11 +37,18 @@ const toggleSidebar = function toggleSidebar () {
<template> <template>
<div class="layout-container" :class="{ expanded }"> <div class="layout-container" :class="{ expanded }">
<div class="sidebar-expand-button-container"> <div class="sidebar-expand-button-container">
<SidebarExpandButton class="sidebar-expand-button" @click="toggleSidebar" /> <SidebarExpandButton
class="sidebar-expand-button"
@click="toggleSidebar"
/>
</div> </div>
<Sidebar @toggle-expanded="toggleSidebar" :expanded="expanded" class="sidebar"> <AppSidebar
@toggle-expanded="toggleSidebar"
:expanded="expanded"
class="sidebar"
>
<slot name="sidebar" :toggleSidebar="toggleSidebar" /> <slot name="sidebar" :toggleSidebar="toggleSidebar" />
</Sidebar> </AppSidebar>
<main class="main-content"> <main class="main-content">
<slot :toggleSidebar="toggleSidebar" /> <slot :toggleSidebar="toggleSidebar" />
</main> </main>
@@ -54,8 +61,8 @@ const toggleSidebar = function toggleSidebar () {
grid-template-columns: calc(32px + 2em) 1fr; grid-template-columns: calc(32px + 2em) 1fr;
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;
grid-template-areas: grid-template-areas:
"sidebar-expand sidebar-expand" 'sidebar-expand sidebar-expand'
"sidebar main-content"; 'sidebar main-content';
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
@@ -80,7 +87,7 @@ const toggleSidebar = function toggleSidebar () {
.layout-container.expanded { .layout-container.expanded {
grid-template-columns: 1fr 0; grid-template-columns: 1fr 0;
} }
.layout-container:not(.expanded) .sidebar-expand-button-container { .layout-container:not(.expanded) .sidebar-expand-button-container {
justify-content: end; justify-content: end;
display: block; display: block;
@@ -100,4 +107,4 @@ const toggleSidebar = function toggleSidebar () {
grid-area: main-content; grid-area: main-content;
padding: 20px; padding: 20px;
} }
</style> </style>
+1 -2
View File
@@ -38,7 +38,6 @@
} }
} }
}, },
"slogan": "Gebaut zum Suchen.",
"settings": { "settings": {
"input": { "input": {
"done": { "done": {
@@ -91,4 +90,4 @@
"loadError": "Das Impressum konnte nicht geladen werden." "loadError": "Das Impressum konnte nicht geladen werden."
} }
} }
} }
+1 -2
View File
@@ -38,7 +38,6 @@
} }
} }
}, },
"slogan": "Built to search.",
"settings": { "settings": {
"input": { "input": {
"done": { "done": {
@@ -91,4 +90,4 @@
"loadError": "The legal notice could not be loaded." "loadError": "The legal notice could not be loaded."
} }
} }
} }
+1 -2
View File
@@ -38,7 +38,6 @@
} }
} }
}, },
"slogan": "Hecho para buscar.",
"settings": { "settings": {
"input": { "input": {
"done": { "done": {
@@ -62,4 +61,4 @@
} }
} }
} }
} }
+1 -2
View File
@@ -38,7 +38,6 @@
} }
} }
}, },
"slogan": "Conçu pour chercher.",
"settings": { "settings": {
"input": { "input": {
"done": { "done": {
@@ -62,4 +61,4 @@
} }
} }
} }
} }
+2 -3
View File
@@ -38,11 +38,10 @@
} }
} }
}, },
"slogan": "Costruito per cercare.",
"settings": { "settings": {
"input": { "input": {
"done": { "done": {
"title": "Salva valore", "title": "Salva valore",
"ariaLabel": "Salva il valore inserito" "ariaLabel": "Salva il valore inserito"
} }
}, },
@@ -62,4 +61,4 @@
} }
} }
} }
} }
+2 -3
View File
@@ -38,11 +38,10 @@
} }
} }
}, },
"slogan": "Feito para pesquisar.",
"settings": { "settings": {
"input": { "input": {
"done": { "done": {
"title": "Guardar valor", "title": "Guardar valor",
"ariaLabel": "Guarda o valor introduzido" "ariaLabel": "Guarda o valor introduzido"
} }
}, },
@@ -62,4 +61,4 @@
} }
} }
} }
} }
+36 -32
View File
@@ -14,40 +14,48 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { getAssetsUrl } from './config/env'; import { i18n, loadLanguage, fallbackLocale } from './i18n';
import { createApp } from 'vue' import router from './router';
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'; import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
import App from './App.vue' import { createApp } from 'vue';
import { i18n, loadLanguage, fallbackLocale } from './i18n';
import router from './router' import App from './App.vue';
import './styles/common.css'
import './styles/variables/colors.css' import './styles/common.css';
import rawFonts from './styles/fonts.json';
import './styles/variables/colors.css';
import './styles/variables/dimensions.css'; import './styles/variables/dimensions.css';
import rawFonts from './styles/fonts.json';
import './styles/common.css';
import './styles/variables/colors.css';
import settings from './features/settings/config.js'; import settings from './features/settings/config.js';
import { getAssetsUrl } from './config/env';
import { validateSettingsConfig } from './features/settings/utils/settingsValidator'; import { validateSettingsConfig } from './features/settings/utils/settingsValidator';
(async () => { (async () => {
const fonts = rawFonts.map((font) => new FontFace( const fonts = rawFonts.map(
font.family ?? '', (font) =>
Array.isArray(font.source) new FontFace(
? font.source.map( font.family ?? '',
(source) => `url('${source.url Array.isArray(font.source)
? new URL(source.url, getAssetsUrl()).href ? font.source
: '' .map(
}') format('${source.format ?? ''}')` (source) =>
).join(',') `url('${
: '', source.url
{ ? new URL(source.url, getAssetsUrl())
weight: font.weight ?? undefined, .href
style: font.style ?? undefined : ''
} }') format('${source.format ?? ''}')`,
)); )
.join(',')
: '',
{
weight: font.weight ?? undefined,
style: font.style ?? undefined,
},
),
);
await Promise.all(fonts.map((font) => font.load())); await Promise.all(fonts.map((font) => font.load()));
fonts.forEach((font) => document.fonts.add(font)); fonts.forEach((font) => document.fonts.add(font));
@@ -57,10 +65,6 @@ import { validateSettingsConfig } from './features/settings/utils/settingsValida
pinia.use(piniaPluginPersistedstate); pinia.use(piniaPluginPersistedstate);
validateSettingsConfig(settings); validateSettingsConfig(settings);
createApp(App) createApp(App).use(router).use(i18n).use(pinia).mount('#app');
.use(router)
.use(i18n)
.use(pinia)
.mount('#app');
})(); })();
+23 -23
View File
@@ -14,71 +14,71 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { createRouter, createWebHistory } from 'vue-router';
import { i18n } from '@/i18n';
import SearchView from '../views/SearchView.vue';
import LegalNoticeView from '@/features/legal/views/LegalNoticeView.vue'; import LegalNoticeView from '@/features/legal/views/LegalNoticeView.vue';
import SearchResultsView from '@/features/search/views/SearchResultsView.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 SettingsView from '@/features/settings/views/SettingsView.vue';
import NotFound from '../views/NotFound.vue'; import NotFound from '@/views/NotFound.vue';
import { i18n } from '@/i18n';
import { createRouter, createWebHistory } from 'vue-router';
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: '/legal/notice',
name: 'legalNotice', name: 'legalNotice',
component: LegalNoticeView, component: LegalNoticeView,
meta: { meta: {
title: () => i18n.global.t('legal.notice.title') title: () => i18n.global.t('legal.notice.title'),
} },
}, },
{ {
path: '/settings/:rest(.*)*', 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,
}, },
]; ];
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes routes,
}); });
export const updatePageTitle = function updatePageTitle (route) { export const updatePageTitle = function updatePageTitle(route) {
const title = const title =
typeof route.meta.title === 'function' typeof route.meta.title === 'function'
? route.meta.title(route) ? route.meta.title(route)
: route.meta.title; : route.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) // remove trailing slash(es)
@@ -88,9 +88,9 @@ router.beforeEach((route) => {
path: route.path.replace(/\/+$/, ''), path: route.path.replace(/\/+$/, ''),
query: route.query, query: route.query,
hash: route.hash, hash: route.hash,
replace: true replace: true,
}; };
}; }
}); });
export default router; export default router;
+15 -2
View File
@@ -19,7 +19,19 @@ body {
color: var(--dark); color: var(--dark);
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; font-family:
'Inter',
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Open Sans',
'Helvetica Neue',
sans-serif;
} }
.link { .link {
@@ -27,7 +39,8 @@ body {
color: var(--dark); color: var(--dark);
} }
.link:hover:not(.button-link), .link:focus-visible:not(.button-link) { .link:hover:not(.button-link),
.link:focus-visible:not(.button-link) {
outline: none; outline: none;
text-decoration: underline; text-decoration: underline;
} }
+107 -107
View File
@@ -15,144 +15,144 @@ limitations under the License.
*/ */
body { body {
--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);
--primary-color-l-6: oklch(0.747 0.202 260); --primary-color-l-6: oklch(0.747 0.202 260);
--primary-color-l-4: oklch(0.697 0.202 260); --primary-color-l-4: oklch(0.697 0.202 260);
--primary-color-l-3: oklch(0.647 0.202 260); --primary-color-l-3: oklch(0.647 0.202 260);
--primary-color-l-2: oklch(0.597 0.202 260); --primary-color-l-2: oklch(0.597 0.202 260);
--primary-color-l-1: oklch(0.547 0.202 260); --primary-color-l-1: oklch(0.547 0.202 260);
--primary-color: oklch(0.497 0.202 260); --primary-color: oklch(0.497 0.202 260);
--primary-color-d-1: oklch(0.447 0.202 260); --primary-color-d-1: oklch(0.447 0.202 260);
--primary-color-d-2: oklch(0.397 0.202 260); --primary-color-d-2: oklch(0.397 0.202 260);
--primary-color-d-3: oklch(0.347 0.202 260); --primary-color-d-3: oklch(0.347 0.202 260);
--primary-color-d-4: oklch(0.297 0.202 260); --primary-color-d-4: oklch(0.297 0.202 260);
--primary-color-d-5: oklch(0.247 0.202 260); --primary-color-d-5: oklch(0.247 0.202 260);
--primary-color-d-6: oklch(0.197 0.202 260); --primary-color-d-6: oklch(0.197 0.202 260);
--primary-color-d-7: oklch(0.147 0.202 260); --primary-color-d-7: oklch(0.147 0.202 260);
--primary-color-d-8: oklch(0.097 0.202 260); --primary-color-d-8: oklch(0.097 0.202 260);
--black-l-8: oklch(0.4 0 0); --black-l-8: oklch(0.4 0 0);
--black-l-7: oklch(0.35 0 0); --black-l-7: oklch(0.35 0 0);
--black-l-6: oklch(0.3 0 0); --black-l-6: oklch(0.3 0 0);
--black-l-5: oklch(0.25 0 0); --black-l-5: oklch(0.25 0 0);
--black-l-4: oklch(0.2 0 0); --black-l-4: oklch(0.2 0 0);
--black-l-3: oklch(0.15 0 0); --black-l-3: oklch(0.15 0 0);
--black-l-2: oklch(0.1 0 0); --black-l-2: oklch(0.1 0 0);
--black-l-1: oklch(0.05 0 0); --black-l-1: oklch(0.05 0 0);
--black: oklch(0 0 0); --black: oklch(0 0 0);
--white: oklch(1 0 0); --white: oklch(1 0 0);
--white-d-1: oklch(0.95 0 0); --white-d-1: oklch(0.95 0 0);
--white-d-2: oklch(0.9 0 0); --white-d-2: oklch(0.9 0 0);
--white-d-3: oklch(0.85 0 0); --white-d-3: oklch(0.85 0 0);
--white-d-4: oklch(0.8 0 0); --white-d-4: oklch(0.8 0 0);
--white-d-5: oklch(0.75 0 0); --white-d-5: oklch(0.75 0 0);
--white-d-6: oklch(0.7 0 0); --white-d-6: oklch(0.7 0 0);
--white-d-7: oklch(0.65 0 0); --white-d-7: oklch(0.65 0 0);
--white-d-8: oklch(0.6 0 0); --white-d-8: oklch(0.6 0 0);
--dark-l-8: var(--black-l-8); --dark-l-8: var(--black-l-8);
--dark-l-7: var(--black-l-7); --dark-l-7: var(--black-l-7);
--dark-l-6: var(--black-l-6); --dark-l-6: var(--black-l-6);
--dark-l-5: var(--black-l-5); --dark-l-5: var(--black-l-5);
--dark-l-4: var(--black-l-4); --dark-l-4: var(--black-l-4);
--dark-l-3: var(--black-l-3); --dark-l-3: var(--black-l-3);
--dark-l-2: var(--black-l-2); --dark-l-2: var(--black-l-2);
--dark-l-1: var(--black-l-1); --dark-l-1: var(--black-l-1);
--dark: var(--black); --dark: var(--black);
--light: var(--white); --light: var(--white);
--light-d-1: var(--white-d-1); --light-d-1: var(--white-d-1);
--light-d-2: var(--white-d-2); --light-d-2: var(--white-d-2);
--light-d-3: var(--white-d-3); --light-d-3: var(--white-d-3);
--light-d-4: var(--white-d-4); --light-d-4: var(--white-d-4);
--light-d-5: var(--white-d-5); --light-d-5: var(--white-d-5);
--light-d-6: var(--white-d-6); --light-d-6: var(--white-d-6);
--light-d-7: var(--white-d-7); --light-d-7: var(--white-d-7);
--light-d-8: var(--white-d-8); --light-d-8: var(--white-d-8);
--dark-bg: var(--black-l-2); --dark-bg: var(--black-l-2);
--light-bg: var(--white); --light-bg: var(--white);
--border: var(--white-d-3); --border: var(--white-d-3);
--gray-box-shadow: oklch(0.8 0.0001 271 / 0.7); --gray-box-shadow: oklch(0.8 0.0001 271 / 0.7);
--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); --invert: invert(0);
--color-negative-bg: oklch(59% 0.22706 29.244); --color-negative-bg: oklch(59% 0.22706 29.244);
--color-negative-border: oklch(51% 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 { body.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);
--dark-l-5: var(--white-d-5); --dark-l-5: var(--white-d-5);
--dark-l-4: var(--white-d-4); --dark-l-4: var(--white-d-4);
--dark-l-3: var(--white-d-3); --dark-l-3: var(--white-d-3);
--dark-l-2: var(--white-d-2); --dark-l-2: var(--white-d-2);
--dark-l-1: var(--white-d-1); --dark-l-1: var(--white-d-1);
--dark: var(--white); --dark: var(--white);
--light: var(--black); --light: var(--black);
--light-d-1: oklch(0.10 0 0); --light-d-1: oklch(0.1 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);
--light-d-5: var(--black-l-5); --light-d-5: var(--black-l-5);
--light-d-6: var(--black-l-6); --light-d-6: var(--black-l-6);
--light-d-7: var(--black-l-7); --light-d-7: var(--black-l-7);
--light-d-8: var(--black-l-8); --light-d-8: var(--black-l-8);
--dark-bg: var(--white); --dark-bg: var(--white);
--light-bg: oklch(0.18 0 0); --light-bg: oklch(0.18 0 0);
--border: var(--black-l-6); --border: var(--black-l-6);
--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); --invert: invert(1);
} }
} }
body.dark { body.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);
--dark-l-5: var(--white-d-5); --dark-l-5: var(--white-d-5);
--dark-l-4: var(--white-d-4); --dark-l-4: var(--white-d-4);
--dark-l-3: var(--white-d-3); --dark-l-3: var(--white-d-3);
--dark-l-2: var(--white-d-2); --dark-l-2: var(--white-d-2);
--dark-l-1: var(--white-d-1); --dark-l-1: var(--white-d-1);
--dark: var(--white); --dark: var(--white);
--light: var(--black); --light: var(--black);
--light-d-1: oklch(0.10 0 0); --light-d-1: oklch(0.1 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);
--light-d-5: var(--black-l-5); --light-d-5: var(--black-l-5);
--light-d-6: var(--black-l-6); --light-d-6: var(--black-l-6);
--light-d-7: var(--black-l-7); --light-d-7: var(--black-l-7);
--light-d-8: var(--black-l-8); --light-d-8: var(--black-l-8);
--dark-bg: var(--white); --dark-bg: var(--white);
--light-bg: oklch(0.18 0 0); --light-bg: oklch(0.18 0 0);
--border: var(--black-l-6); --border: var(--black-l-6);
--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); --invert: invert(1);
} }
+1 -1
View File
@@ -1,4 +1,4 @@
:root { :root {
--input-padding: 0.7em; --input-padding: 0.7em;
--input-height: 1em; --input-height: 1em;
} }
+16 -17
View File
@@ -15,34 +15,37 @@ limitations under the License.
*/ */
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { vi } from 'vitest';
import { createI18n } from 'vue-i18n'; import { createI18n } from 'vue-i18n';
import { createMemoryHistory, createRouter } from 'vue-router'; import { createMemoryHistory, createRouter } from 'vue-router';
export const mountComponent = function mountComponent (component, options = {}, i18nMessages = {}, routes = [], piniaOptions = {}) { export const mountComponent = function mountComponent(
const { component,
setupStores, options = {},
...testingPiniaOptions i18nMessages = {},
} = piniaOptions; routes = [],
piniaOptions = {},
) {
const { setupStores, ...testingPiniaOptions } = piniaOptions;
const i18n = createI18n({ const i18n = createI18n({
legacy: false, legacy: false,
locale: 'en', locale: 'en',
messages: { messages: {
en: i18nMessages en: i18nMessages,
} },
}); });
const router = createRouter({ const router = createRouter({
history: createMemoryHistory(), history: createMemoryHistory(),
routes routes,
}); });
const testingPinia = createTestingPinia({ const testingPinia = createTestingPinia({
...testingPiniaOptions, ...testingPiniaOptions,
stubActions: false, stubActions: false,
createSpy: vi.fn createSpy: vi.fn,
}); });
if (setupStores) { if (setupStores) {
@@ -51,12 +54,8 @@ export const mountComponent = function mountComponent (component, options = {},
return mount(component, { return mount(component, {
global: { global: {
plugins: [ plugins: [i18n, router, testingPinia],
i18n,
router,
testingPinia
],
}, },
...options ...options,
}); });
}; };
+102 -90
View File
@@ -14,111 +14,123 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { describe, expect, test } from 'vitest';
import { ensureUnit } from '../cssDimensions'; import { ensureUnit } from '../cssDimensions';
import { describe, expect, test } from 'vitest';
describe('cssDimensions', () => { describe('cssDimensions', () => {
test.for([ test.for([
{ dimension: null, expected: '0px' }, { dimension: null, expected: '0px' },
{ dimension: undefined, expected: '0px' }, { dimension: undefined, expected: '0px' },
{ dimension: 0, expected: '0px' }, { dimension: 0, expected: '0px' },
{ dimension: -0, expected: '0px' }, { dimension: -0, expected: '0px' },
{ dimension: -1, expected: '-1px' }, { dimension: -1, expected: '-1px' },
{ dimension: -42, expected: '-42px' }, { dimension: -42, expected: '-42px' },
{ dimension: 1, expected: '1px' }, { dimension: 1, expected: '1px' },
{ dimension: 42, expected: '42px' }, { dimension: 42, expected: '42px' },
{ dimension: 0.0, expected: '0px' }, { dimension: 0.0, expected: '0px' },
{ dimension: -0.0, expected: '0px' }, { dimension: -0.0, expected: '0px' },
{ dimension: -1.0, expected: '-1px' }, { dimension: -1.0, expected: '-1px' },
{ dimension: -42.0, expected: '-42px' }, { dimension: -42.0, expected: '-42px' },
{ dimension: 1.0, expected: '1px' }, { dimension: 1.0, expected: '1px' },
{ dimension: 42.0, expected: '42px' }, { dimension: 42.0, expected: '42px' },
{ dimension: 0.42, expected: '0.42px' }, { dimension: 0.42, expected: '0.42px' },
{ dimension: -0.42, expected: '-0.42px' }, { dimension: -0.42, expected: '-0.42px' },
{ dimension: -1.42, expected: '-1.42px' }, { dimension: -1.42, expected: '-1.42px' },
{ dimension: -42.42, expected: '-42.42px' }, { dimension: -42.42, expected: '-42.42px' },
{ dimension: 1.42, expected: '1.42px' }, { dimension: 1.42, expected: '1.42px' },
{ dimension: 42.42, expected: '42.42px' }, { dimension: 42.42, expected: '42.42px' },
{ dimension: '0', expected: '0px' }, { dimension: '0', expected: '0px' },
{ dimension: '-0', expected: '-0px' }, { dimension: '-0', expected: '-0px' },
{ dimension: '-1', expected: '-1px' }, { dimension: '-1', expected: '-1px' },
{ dimension: '-42', expected: '-42px' }, { dimension: '-42', expected: '-42px' },
{ dimension: '1', expected: '1px' }, { dimension: '1', expected: '1px' },
{ dimension: '42', expected: '42px' }, { dimension: '42', expected: '42px' },
{ dimension: '0.0', expected: '0.0px' }, { dimension: '0.0', expected: '0.0px' },
{ dimension: '-0.0', expected: '-0.0px' }, { dimension: '-0.0', expected: '-0.0px' },
{ dimension: '-1.0', expected: '-1.0px' }, { dimension: '-1.0', expected: '-1.0px' },
{ dimension: '-42.0', expected: '-42.0px' }, { dimension: '-42.0', expected: '-42.0px' },
{ dimension: '1.0', expected: '1.0px' }, { dimension: '1.0', expected: '1.0px' },
{ dimension: '42.0', expected: '42.0px' }, { dimension: '42.0', expected: '42.0px' },
{ dimension: '0.42', expected: '0.42px' }, { dimension: '0.42', expected: '0.42px' },
{ dimension: '-0.42', expected: '-0.42px' }, { dimension: '-0.42', expected: '-0.42px' },
{ dimension: '-1.42', expected: '-1.42px' }, { dimension: '-1.42', expected: '-1.42px' },
{ dimension: '-42.42', expected: '-42.42px' }, { dimension: '-42.42', expected: '-42.42px' },
{ dimension: '1.42', expected: '1.42px' }, { dimension: '1.42', expected: '1.42px' },
{ dimension: '42.42', expected: '42.42px' }, { dimension: '42.42', expected: '42.42px' },
{ dimension: '0px', expected: '0px' }, { dimension: '0px', expected: '0px' },
{ dimension: '-0px', expected: '-0px' }, { dimension: '-0px', expected: '-0px' },
{ dimension: '-1px', expected: '-1px' }, { dimension: '-1px', expected: '-1px' },
{ dimension: '-42px', expected: '-42px' }, { dimension: '-42px', expected: '-42px' },
{ dimension: '1px', expected: '1px' }, { dimension: '1px', expected: '1px' },
{ dimension: '42px', expected: '42px' }, { dimension: '42px', expected: '42px' },
{ dimension: '0.0px', expected: '0.0px' }, { dimension: '0.0px', expected: '0.0px' },
{ dimension: '-0.0px', expected: '-0.0px' }, { dimension: '-0.0px', expected: '-0.0px' },
{ dimension: '-1.0px', expected: '-1.0px' }, { dimension: '-1.0px', expected: '-1.0px' },
{ dimension: '-42.0px', expected: '-42.0px' }, { dimension: '-42.0px', expected: '-42.0px' },
{ dimension: '1.0px', expected: '1.0px' }, { dimension: '1.0px', expected: '1.0px' },
{ dimension: '42.0px', expected: '42.0px' }, { dimension: '42.0px', expected: '42.0px' },
{ dimension: '0.42px', expected: '0.42px' }, { dimension: '0.42px', expected: '0.42px' },
{ dimension: '-0.42px', expected: '-0.42px' }, { dimension: '-0.42px', expected: '-0.42px' },
{ dimension: '-1.42px', expected: '-1.42px' }, { dimension: '-1.42px', expected: '-1.42px' },
{ dimension: '-42.42px', expected: '-42.42px' }, { dimension: '-42.42px', expected: '-42.42px' },
{ dimension: '1.42px', expected: '1.42px' }, { dimension: '1.42px', expected: '1.42px' },
{ dimension: '42.42px', expected: '42.42px' }, { dimension: '42.42px', expected: '42.42px' },
{ dimension: '0em', expected: '0em' }, { dimension: '0em', expected: '0em' },
{ dimension: '-0em', expected: '-0em' }, { dimension: '-0em', expected: '-0em' },
{ dimension: '-1em', expected: '-1em' }, { dimension: '-1em', expected: '-1em' },
{ dimension: '-42em', expected: '-42em' }, { dimension: '-42em', expected: '-42em' },
{ dimension: '1em', expected: '1em' }, { dimension: '1em', expected: '1em' },
{ dimension: '42em', expected: '42em' }, { dimension: '42em', expected: '42em' },
{ dimension: '0.0em', expected: '0.0em' }, { dimension: '0.0em', expected: '0.0em' },
{ dimension: '-0.0em', expected: '-0.0em' }, { dimension: '-0.0em', expected: '-0.0em' },
{ dimension: '-1.0em', expected: '-1.0em' }, { dimension: '-1.0em', expected: '-1.0em' },
{ dimension: '-42.0em', expected: '-42.0em' }, { dimension: '-42.0em', expected: '-42.0em' },
{ dimension: '1.0em', expected: '1.0em' }, { dimension: '1.0em', expected: '1.0em' },
{ dimension: '42.0em', expected: '42.0em' }, { dimension: '42.0em', expected: '42.0em' },
{ dimension: '0.42em', expected: '0.42em' }, { dimension: '0.42em', expected: '0.42em' },
{ dimension: '-0.42em', expected: '-0.42em' }, { dimension: '-0.42em', expected: '-0.42em' },
{ dimension: '-1.42em', expected: '-1.42em' }, { dimension: '-1.42em', expected: '-1.42em' },
{ dimension: '-42.42em', expected: '-42.42em' }, { dimension: '-42.42em', expected: '-42.42em' },
{ dimension: '1.42em', expected: '1.42em' }, { dimension: '1.42em', expected: '1.42em' },
{ dimension: '42.42em', expected: '42.42em' }, { dimension: '42.42em', expected: '42.42em' },
{ dimension: 'calc(42px - 1em)', expected: 'calc(42px - 1em)' }, { dimension: 'calc(42px - 1em)', expected: 'calc(42px - 1em)' },
{ dimension: 'calc(42px)', expected: 'calc(42px)' }, { dimension: 'calc(42px)', expected: 'calc(42px)' },
{ dimension: 'calc(42vh)', expected: 'calc(42vh)' }, { dimension: 'calc(42vh)', expected: 'calc(42vh)' },
{ dimension: 'min(42px, 1em)', expected: 'min(42px, 1em)' }, { dimension: 'min(42px, 1em)', expected: 'min(42px, 1em)' },
{ dimension: 'max(42px, 5rem)', expected: 'max(42px, 5rem)' }, { 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)',
},
{ dimension: 'calc(42px-1em)', expected: 'calc(42px-1em)' }, { dimension: 'calc(42px-1em)', expected: 'calc(42px-1em)' },
{ dimension: 'min(42px,1em)', expected: 'min(42px,1em)' }, { dimension: 'min(42px,1em)', expected: 'min(42px,1em)' },
{ dimension: 'max(42px,5rem)', expected: 'max(42px,5rem)' }, { 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 )' } dimension: 'clamp(42vh,23vw,13cap)',
])('ensureUnit returns $expected with input $dimension', ({ dimension, expected }) => { expected: 'clamp(42vh,23vw,13cap)',
expect(ensureUnit(dimension)).toBe(expected); },
}); {
}); dimension: 'clamp( 42vh,23vw,13cap )',
expected: 'clamp( 42vh,23vw,13cap )',
},
])(
'ensureUnit returns $expected with input $dimension',
({ dimension, expected }) => {
expect(ensureUnit(dimension)).toBe(expected);
},
);
});
+3 -3
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export const ensureUnit = function (d) { export const ensureUnit = function ensureUnit(d) {
if (!d) d = 0; if (!d) d = 0;
// ensure that it is a string // ensure that it is a string
@@ -25,7 +25,7 @@ export const ensureUnit = function (d) {
// if it ends with a number // if it ends with a number
if (/\d$/.test(d)) { if (/\d$/.test(d)) {
d += 'px'; d += 'px';
}; }
return d; return d;
}; };
+3 -3
View File
@@ -22,7 +22,7 @@ const { t } = useI18n();
<template> <template>
<div class="not-found main-content-padding"> <div class="not-found main-content-padding">
<span class="error-message"> <span class="error-message">
{{ t('error.pageNotFound') }} {{ t('error.pageNotFound') }}
</span> </span>
<RouterLink to="/" id="link"> <RouterLink to="/" id="link">
@@ -40,7 +40,7 @@ const { t } = useI18n();
align-items: center; align-items: center;
gap: 12px; gap: 12px;
} }
.error-message{ .error-message {
margin: 0; margin: 0;
font-weight: 600; font-weight: 600;
font-size: 1.75rem; font-size: 1.75rem;
@@ -53,4 +53,4 @@ const { t } = useI18n();
padding: 10px 20px; padding: 10px 20px;
text-decoration: none; text-decoration: none;
} }
</style> </style>
+21 -21
View File
@@ -14,29 +14,29 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue';
import vue from '@vitejs/plugin-vue'
import path from 'path'; import path from 'path';
import { defineConfig } from 'vite';
import { createHtmlPlugin } from 'vite-plugin-html'; import { createHtmlPlugin } from 'vite-plugin-html';
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
build: { build: {
target: 'es2020' target: 'es2020',
}, },
plugins: [ plugins: [
vue(), vue(),
createHtmlPlugin({ createHtmlPlugin({
minify: true minify: true,
}) }),
], ],
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src') '@': path.resolve(__dirname, './src'),
} },
}, },
test: { test: {
globals: false, globals: false,
environment: 'jsdom' environment: 'jsdom',
} },
}) });