120 Commits
Author SHA1 Message Date
jakob.scheidandGitea 447207e5f0 Merge pull request 'Feat(sidebar): implement sidebar toggling' (#157) from feature/expandable-sidebar into main
Deploy on dev / Deploy on dev (push) Successful in 39s
Reviewed-on: #157
Reviewed-by: Jakob Gregory
2026-07-31 21:00:19 +02:00
jakob.scheidandGitea 0939379c0a feat(sidebar): decrease horizontal padding when sidebar is not expanded
Set the horizontal padding of the unexpanded sidebar to 1em.
2026-07-31 20:59:19 +02:00
jakob.scheidandGitea 48e979ac64 fix(sidebar): fix main content displaying
Do not show main content on small screens when the sidebar is expanded.
2026-07-31 20:59:19 +02:00
jakob.scheidandGitea 0a58944157 feat(sidebar): add sidebar expand button to left sidebar layout
Added a sidebar expand button to the left sidebar layout that is shown
on small screens when the sidebar is not expanded.
2026-07-31 20:59:19 +02:00
jakob.scheidandGitea 63d9e31d29 fix(sidebar): do not show sidebar expanding button on small screens when sidebar is not expanded 2026-07-31 20:59:19 +02:00
jakob.scheidandGitea e43784bff0 feat(sidebar): implement sidebar visibility toggling 2026-07-31 20:59:19 +02:00
jakob.scheidandGitea 5b1f2b57a7 feat(sidebar): add sidebar expand button
Added a button to expand the sidebar to the sidebar and reorganized
sidebar elements accordingly.
2026-07-31 20:59:19 +02:00
jakob.scheidandGitea c70e285026 feat(icons): add sidebar expanding icon 2026-07-31 20:59:19 +02:00
jakob.scheidandGitea 8f83efb16b Merge pull request 'Fix(settings): settings validator selection setting default values array handling' (#155) from fix/settings-validator-selection-default-values-array into main
Deploy on dev / Deploy on dev (push) Successful in 36s
Reviewed-on: #155
Reviewed-by: Jakob Gregory
2026-07-31 20:58:29 +02:00
jakob.scheid f41c5c407f test(settings): add settings validator test cases
Added test cases to the settings validator unit test to test the
selection setting default value array handling when allowMultiple is
true.
2026-07-30 10:45:58 +02:00
jakob.scheid 35bfedbd6b fix(settings): expect array default values for multiple choice selections 2026-07-30 10:43:47 +02:00
jakob.scheidandGitea 8f5b9e3601 Merge pull request 'Complete missing settings translations' (#154) from chore/complete-translations into main
Deploy on dev / Deploy on dev (push) Successful in 33s
Reviewed-on: #154
Reviewed-by: Jakob Scheid
2026-07-30 10:06:50 +02:00
jakob.scheid 00caf4d692 style(i18n): update Portuguese translations indentation 2026-07-30 10:04:47 +02:00
jakob.scheid f39a69f76d style(i18n): update Italian translations indentation 2026-07-30 10:04:16 +02:00
jakob.scheid 8697557259 style(i18n): update French translations indentation 2026-07-30 10:03:24 +02:00
jakob.scheid cd17486054 style(i18n): update Spanish translations indentation 2026-07-30 10:02:31 +02:00
jakob.gregoryandGitea f2050ceed0 Delete the unnecessary space in pt.json 2026-07-29 18:45:20 +02:00
jakob.gregoryandGitea 805512c9de Change wrong commit email 2026-07-29 18:42:13 +02:00
jakob.gregoryandGitea 88bda40e87 chore(i18n): complete Portuguese translations 2026-07-29 18:40:12 +02:00
jakob.gregoryandGitea 0aee1ab9a6 chore(i18n): complete Italian translations 2026-07-29 18:39:55 +02:00
jakob.gregoryandGitea d5a8f852b2 chore(i18n): complete French translations 2026-07-29 18:38:59 +02:00
jakob.gregoryandGitea 5ba1045a17 chore(i18n): complete Spanish translations 2026-07-29 18:14:11 +02:00
jakob.scheidandGitea ba1ee27ba2 Merge pull request 'Use :focus-visible instead of :focus' (#153) from refactor/use-focus-visible into main
Deploy on dev / Deploy on dev (push) Successful in 36s
Reviewed-on: #153
Reviewed-by: Jakob Scheid
2026-07-29 17:51:57 +02:00
jakob.gregoryandGitea e6acab73e3 refactor(global): replace :focus with :focus-visible 2026-07-29 17:47:54 +02:00
jakob.scheidandGitea 463fcff615 Merge pull request 'Chore(licenses): license headers in setup scripts' (#147) from chore/setup-scripts-license-headers into main
Deploy on dev / Deploy on dev (push) Successful in 36s
Reviewed-on: #147
Reviewed-by: Jakob Gregory
2026-07-28 21:44:44 +02:00
jakob.scheidandGitea 5a9191e69b chore(licenses): add license headers to setup scripts 2026-07-28 21:44:27 +02:00
jakob.scheidandGitea 32c972d043 Merge pull request 'Test: describe in all test files' (#144) from testing/describe-in-all-files into main
Deploy on dev / Deploy on dev (push) Successful in 40s
Reviewed-on: #144
Reviewed-by: Jakob Gregory
2026-07-28 21:44:17 +02:00
jakob.scheidandGitea 68a827a113 test(settings): add describe to settingsValidator test 2026-07-28 20:15:58 +02:00
jakob.scheidandGitea c047060b7c test(i18n): add describe to LanguageSwitchButton test 2026-07-28 20:15:58 +02:00
jakob.scheidandGitea cd3a288935 test(i18n): add describe to currentLanguage test 2026-07-28 20:15:58 +02:00
jakob.scheidandGitea 7ee9c45339 test(style): add describe to cssDimensions test 2026-07-28 20:15:58 +02:00
jakob.scheidandGitea 799d7ee0bc Merge pull request 'Feat(settings): add renderer for settings configuration' (#142) from feature/settings-renderer into main
Deploy on dev / Deploy on dev (push) Successful in 32s
Reviewed-on: #142
Reviewed-by: Jakob Gregory
2026-07-28 20:14:50 +02:00
jakob.scheid 71b6601360 fix(settings): catch all of /settings route subpath 2026-07-28 20:13:08 +02:00
jakob.scheid eb43e2f49f chore: resolve merge conflicts 2026-07-28 20:10:40 +02:00
jakob.scheid 79267d0059 feat(settings): add selection option ARIA label 2026-07-28 20:10:29 +02:00
jakob.scheid 39f482fc37 feat(settings): add title to selection options 2026-07-28 20:10:28 +02:00
jakob.scheid 70ad0420bb feat(settings): improve label relationship in selection
Added attributes to indicate that div.label is the label for
ul.options-list in the selection component.
2026-07-28 20:10:28 +02:00
jakob.scheid 641cc4956d refactor(settings): remove unused parameter
Removed unused oldValue parameter in the selection component.
2026-07-28 20:10:28 +02:00
jakob.scheid e0e2033fdf refactor(settings)!: pass path to getSettingsRecursively using an array instead of a string
Tests and all files the utility is used in are also updated.

Warning: This change is breaking!
2026-07-28 20:10:27 +02:00
jakob.scheid e18c066629 refactor(settings): use oneline if statement
Use a oneline if statement in the getSetting utility.
2026-07-28 20:10:27 +02:00
jakob.scheid edb63b0316 test(settings): refactor switch component test
Added and used getWrapper function to switch component test to mount the
component and avoid repeating code.
2026-07-28 20:10:27 +02:00
jakob.scheid 9a8e764f53 test(settings): refactor selection component test
Use exampleOptions instead of an own options array, which was equal to
exampleOptions.
2026-07-28 20:10:26 +02:00
jakob.scheid 26a5783d19 test(settings): refactor selection test getWrapper function
Updated the getWrapper function to use object destructuring.
2026-07-28 20:10:26 +02:00
jakob.scheid 880beeee07 style(settings): remove unnecessary commas
Removed unnecessary commas in settings input component and settings page
component.
2026-07-28 20:10:25 +02:00
jakob.scheid 151947b09c style(settings): remove unnecessary comma
Removed unnecessary trailing comma in the selection component.
2026-07-28 20:10:25 +02:00
jakob.scheid 4054d0793f fix(settings): update settings input to use default value
The SettingsInput component now uses the default value if there is a
default value. Tests for this were added.
2026-07-28 20:10:25 +02:00
jakob.scheid 2d61e9f927 test(settings): refactor getWrapper function in settings input test
Updated the getWrapper function to use object destructuring.
2026-07-28 20:10:24 +02:00
jakob.scheid 67ffd05566 docs(changelog): add settings renderer 2026-07-28 20:10:24 +02:00
jakob.scheid b429074071 test(settings): add tests for settings page
Added tests for the SettingsPage component:
  - correct heading
  - correct settings
2026-07-28 20:10:24 +02:00
jakob.scheid 9dfd16d68d test(settings): add tests for settings page link 2026-07-28 20:10:23 +02:00
jakob.scheid fb0d80d7f6 test(settings): add tests for settings view
Added tests for the settings view (SettingsView.vue):
  - heading translation
  - sidebar top-level sections
  - correct main element
2026-07-28 20:10:23 +02:00
jakob.scheid bd30579923 refactor(settings): move setttings path normalization utility to a separate file
Moved the normalizePagePath utility from the settings page composable to
a separate utility file and restructured tests accordingly.
2026-07-28 20:10:23 +02:00
jakob.scheid d6e7104f82 refactor(settings): remove unused utility
Removed the utility getSectionHeadingLevel and its tests.
2026-07-28 20:10:22 +02:00
jakob.scheid 24b4e45116 feat(settings): add page path normalization type check
Added a type check to the normalizePagePath function. The function now
returns an empty array if the path is not a string.
2026-07-28 20:10:22 +02:00
jakob.scheid 6b347bb845 feat(settings): omit empty settings page segments
The composable useSettingsPage now omits empty segments in the path.
2026-07-28 20:10:21 +02:00
jakob.scheid c5a16d2797 feat(settings): filter characters that would cause a broken URL from settings page path 2026-07-28 20:10:21 +02:00
jakob.scheid f7f4de8341 test(settings): distinguish between unit tests and integration tests
Separated unit tests and integration tests in the test for
useSettingsPage more clearly.
2026-07-28 20:10:21 +02:00
jakob.scheid cd4956fbe6 refactor(settings): push route object instead of raw string
Modified the useSettingsPage composable to use a route object instead
of a raw string in goToSettingsPage and moved settings route child
routes into the settings route itself using 'rest(.*)*'.
2026-07-28 20:10:20 +02:00
jakob.scheid 241ac889fc feat(settings): make settings page composable more compatible
The composable now uses replace instead of replaceAll which is not
supported by some old browsers.
2026-07-28 20:10:20 +02:00
jakob.scheid d3f3ef37ed fix(settings): omit leading/trailing and consecuting dots in noralizePagePath 2026-07-28 20:10:20 +02:00
jakob.scheid c2402f9360 fix(settings): return router.push promise
The settings page composable function to go to a settings page now
returns the promise router.push returns.
2026-07-28 20:10:19 +02:00
jakob.scheidandjakob.scheid f6fdae6b0c test(settings): add tests for settings page composable 2026-07-28 20:10:19 +02:00
jakob.scheidandjakob.scheid c7fe1f2f60 test(settings): add tests for selection setting
Added tests for the selection setting component:
  - correct display of options
  - shows values correctly
  - store of values
  - correct translation
2026-07-28 20:10:19 +02:00
jakob.scheidandjakob.scheid c7490d2e48 feat(settings): wrap selection option label
Added a wrapper for the settings selection option label to make it
easier to identify (internal).
2026-07-28 20:10:18 +02:00
jakob.scheidandjakob.scheid e1e5ca7a76 fix(settings): use wrapper only if settings shown
The .settings-main-content wrapper in the settings view is now only
used if there are settings shown. It is not used if the settings are
still loading or no section is selected.
2026-07-28 20:10:18 +02:00
jakob.scheidandjakob.scheid 2eafa3c093 feat(settings): wrap selection setting label
Added a wrapper for the selection setting label to make it easier to
find (internal).
2026-07-28 20:10:17 +02:00
jakob.scheidandjakob.scheid 07901f9ab2 fix(settings): add section fallback
Added a simple message as a fallback that is used if no section is
selected.
2026-07-28 20:10:17 +02:00
jakob.scheidandjakob.scheid 167ddbfb4e fix(settings): fix handling of non-existent paths
Previously, the settings view now does not check whether the settings
content is falsy. But, if the setting does not exist, the setting itself
is undefined. So, if the specified settings path did not exist, an
error was thrown.
Now, the setting is checked for being falsy, not the setting content.
2026-07-28 20:10:17 +02:00
jakob.scheidandjakob.scheid b062015f23 chore: resolve merge conflicts 2026-07-28 20:10:15 +02:00
jakob.scheidandjakob.scheid e808ec9a26 test(settings): add settings input tests 2026-07-28 20:08:53 +02:00
jakob.scheidandjakob.scheid 3eddb7d196 feat(test-utils): add store setup functionality to mountComponent utility 2026-07-28 20:08:53 +02:00
jakob.scheidandjakob.scheid d7ce212095 fix(settings): prevent setting input done button from animating initially when mounting 2026-07-28 20:08:52 +02:00
jakob.scheidandjakob.scheid da330db355 feat(settings): make setting input reactive to stored setting 2026-07-28 20:08:52 +02:00
jakob.scheidandjakob.scheid f65b65c053 feat(test-utils): add pinia options to mountComponent test utility 2026-07-28 20:08:52 +02:00
jakob.scheidandjakob.scheid 6fbdc87669 test(settings): add switch component tests 2026-07-28 20:08:51 +02:00
jakob.scheidandjakob.scheid afdc98beb3 chore(dependencies): add @pinia/testing dependency 2026-07-28 20:08:50 +02:00
jakob.scheidandjakob.scheid 248adf40b0 feat(settings): add settings page link 2026-07-28 20:08:39 +02:00
jakob.scheidandjakob.scheid d78beffabd fix(settings): add key prop to settings in settings page 2026-07-28 20:08:39 +02:00
jakob.scheidandjakob.scheid f6a0553b99 feat(settings): implement settings pages navigation 2026-07-28 20:08:38 +02:00
jakob.scheidandjakob.scheid 22311dc5c9 feat(settigns): add settings page composable 2026-07-28 20:08:38 +02:00
jakob.scheidandjakob.scheid 2fb747f587 feat(settings): add utility functions to get settings
Utility function getSetting for getting a first-level setting from a
list of settings and unit tests.
Utility function getSettingRecursively to get any setting from a list
of settings. The setting can be at any nesting level in the settings
list and unit tests.
2026-07-28 20:08:38 +02:00
jakob.scheidandjakob.scheid 0c6d6c76f4 feat(icons): add chevron right icon 2026-07-28 20:08:37 +02:00
jakob.scheidandjakob.scheid 4b6881808d refactor(settings): move settings button style into a CSS module 2026-07-28 20:08:37 +02:00
jakob.scheidandjakob.scheid 12d8c4fa23 fix(settings): fix switch label cursor 2026-07-28 20:08:37 +02:00
jakob.scheidandjakob.scheid c9cb0fce27 feat(settings): add selection setting 2026-07-28 20:08:36 +02:00
jakob.scheidandjakob.scheid 31fbfbde07 fix(settings): prevent input setting done button from extending beyond viewport border 2026-07-28 20:08:36 +02:00
jakob.scheidandjakob.scheid 942645df68 fix(settings): make switch setting style scoped 2026-07-28 20:08:35 +02:00
jakob.scheidandjakob.scheid 1e283c7392 refactor(settings): rename variable
Renamed the variable 'inputClass' to 'inputType' because this name is
more accurate
2026-07-28 20:08:35 +02:00
jakob.scheidandjakob.scheid 9cf2245217 feat(settings): improve switch setting accessibility 2026-07-28 20:08:35 +02:00
jakob.scheidandjakob.scheid 3136f40530 feat(settings): improve input setting accessibility 2026-07-28 20:08:34 +02:00
jakob.scheidandjakob.scheid 62fbd5de3a feat(settings): add animation to settings input done button 2026-07-28 20:08:34 +02:00
jakob.scheidandjakob.scheid 25d7ee0c6d feat(settings): add settings component for inputs 2026-07-28 20:08:34 +02:00
jakob.scheidandjakob.scheid a0a9cac244 feat(icons): add check mark icon 2026-07-28 20:08:33 +02:00
jakob.scheidandjakob.scheid d02c1fb702 feat(style): add common input style 2026-07-28 20:08:32 +02:00
jakob.scheidandjakob.scheid e4bd6df2d5 refactor(settings): remove unused setting class 2026-07-28 20:08:23 +02:00
jakob.scheidandjakob.scheid 76b4887205 feat(settings): remove large settings padding on small screens 2026-07-28 20:08:23 +02:00
jakob.scheidandjakob.scheid 17351e9f44 feat(settings): add support for boolean settings 2026-07-28 20:08:23 +02:00
jakob.scheidandjakob.scheid 5101450d59 feat(settings): make switch use the settings store 2026-07-28 20:08:22 +02:00
jakob.scheidandjakob.scheid 139c6f4ef5 feat(settings): display current settings page 2026-07-28 20:08:22 +02:00
jakob.scheidandjakob.scheid d63a0e5bca feat(settings): add settings store 2026-07-28 20:08:21 +02:00
jakob.scheidandjakob.scheid 5428006a4a Revert "feat(dependencies): add lodash and its types to package.json and package-lock.json" 2026-07-28 20:07:59 +02:00
jakob.scheidandjakob.scheid 17629e0691 chore(dependencies): add pinia-plugin-persistedstate dependency 2026-07-28 20:07:45 +02:00
jakob.scheidandjakob.scheid 22dc39fe51 feat(dependencies): add lodash and its types to package.json and package-lock.json 2026-07-28 20:07:35 +02:00
jakob.scheidandjakob.scheid 4a4b62936a set up Pinia 2026-07-28 20:07:33 +02:00
jakob.scheidandjakob.scheid 5efd3ac5b1 feat(settings): add switch transitions 2026-07-28 20:06:26 +02:00
jakob.scheidandjakob.scheid c11ab2a52c feat(settings): add switch component 2026-07-28 20:06:26 +02:00
jakob.scheidandjakob.scheid 9a193d28af feat(settings): add utility function to determine the level of a settings section heading 2026-07-28 20:06:25 +02:00
jakob.scheidandjakob.scheid 5979177650 feat(settings): get current section using watchEffect 2026-07-28 20:06:25 +02:00
jakob.scheidandjakob.scheid e337e1db3e feat(settings): make active section content reactive 2026-07-28 20:06:24 +02:00
jakob.scheidandjakob.scheid 81252bf95d set button cursor to pointer 2026-07-28 20:06:24 +02:00
jakob.scheidandjakob.scheid e278c51676 feat(settings): go to the settings route if the settings sub-route does not exist 2026-07-28 20:06:24 +02:00
jakob.scheidandjakob.scheid 16db5394ad feat(settings): focus sidebar section button that is currently active 2026-07-28 20:06:23 +02:00
jakob.scheidandjakob.scheid a49383154f feat(settings): remove sidebar section link button container 2026-07-28 20:06:23 +02:00
jakob.scheidandjakob.scheid 7734bef1ef feat(settings): show first-level sections in the sidebar 2026-07-28 20:06:23 +02:00
jakob.scheidandjakob.scheid ae84fd742c add common class for button links 2026-07-28 20:06:22 +02:00
jakob.scheidandjakob.scheid 0cd7f7e5a7 underline focused links that are no buttons 2026-07-28 20:06:22 +02:00
jakob.scheidandjakob.scheid 7152771a48 add button CSS class 2026-07-28 20:06:22 +02:00
jakob.scheidandjakob.scheid 964c247098 feature(settings): load settings configuration 2026-07-28 20:05:59 +02:00
jakob.scheidandjakob.scheid 493c705abc fix(footer): update settings router link target 2026-07-28 20:05:59 +02:00
jakob.scheidandjakob.scheid c7d79efcc2 feature(settings): catch all subpaths of the settings path in the router 2026-07-28 20:05:58 +02:00
45 changed files with 4115 additions and 157 deletions
+2 -1
View File
@@ -12,4 +12,5 @@
- Icons
- Internationalization and some major languages
- 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
+900 -129
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -11,17 +11,20 @@
"test:run": "vitest run"
},
"dependencies": {
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"terser": "^5.47.1",
"vue": "^3.5.32",
"vue-i18n": "^11.4.4",
"vue-router": "^5.0.6"
},
"devDependencies": {
"@vue/test-utils": "^2.4.6",
"@pinia/testing": "^1.0.3",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/test-utils": "^2.4.6",
"jsdom": "^29.1.1",
"vite": "^8.0.10",
"vite-plugin-html": "^3.2.2",
"vitest": "^4.1.7"
}
}
}
+14
View File
@@ -1,3 +1,17 @@
#!/usr/bin/env pwsh
# Copyright 2026 Seekra
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
git config --local commit.template .gitmessage
+14
View File
@@ -1,3 +1,17 @@
#!/usr/bin/bash
# Copyright 2026 Seekra
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
git config --local commit.template .gitmessage
+26
View File
@@ -0,0 +1,26 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path
stroke="currentColor"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
d="M5 12 l6 8 l8 -16"
/>
</svg>

After

Width:  |  Height:  |  Size: 832 B

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

After

Width:  |  Height:  |  Size: 833 B

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

After

Width:  |  Height:  |  Size: 1.0 KiB

+1 -1
View File
@@ -35,7 +35,7 @@ const copyrightPeriod =
<template>
<footer class="global-footer">
<div class="footer-segment">
<RouterLink to="settings" class="link">
<RouterLink to="/settings" class="link">
{{ t('preferences.settings') }}
</RouterLink>
<LanguageSwitchButton />
@@ -0,0 +1,126 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import Icon from '@/features/icons/components/Icon.vue';
import { useSettingsStore } from '../stores/settingsStore';
import { computed, ref, useId, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const normalizeSelectedValue = function normalizeSelectedValue (value) {
if (props.setting.allowMultiple) {
if (Array.isArray(value)) {
return value;
}
if (value === undefined || value === null) {
return [];
}
return [value];
}
if (Array.isArray(value)) {
return value[0] ?? null;
}
return value ?? null;
};
const props = defineProps({
setting: {
required: true
},
path: {
required: true
}
});
const store = useSettingsStore();
const optionType = computed(() => props.setting.allowMultiple ? 'checkbox' : 'radio');
const selected = ref(normalizeSelectedValue(store.get(props.path) ?? props.setting.default));
watch(selected, (newValue) => {
store.set(props.path, newValue);
});
const labelId = useId();
</script>
<template>
<div>
<div class="label" :id="labelId">
{{ t(props.setting.i18n) }}
</div>
<ul class="options-list" :aria-labelledby="labelId">
<li v-for="option in props.setting.options" :key="option.name">
<label
class="settings-button"
:title="t(option.i18n)"
:aria-label="t('settings.selection.option.ariaLabel', { option: t(option.i18n) })"
>
<input
:type="optionType"
:name="props.path"
:value="option.name"
class="option-input"
v-model="selected"
>
<div class="option-check-mark-wrapper">
<Icon class="option-check-mark" name="check-mark" size="1em" />
</div>
<div class="input-label">
{{ t(option.i18n) }}
</div>
</label>
</li>
</ul>
</div>
</template>
<style scoped src="./SettingsButton.css"></style>
<style scoped>
.options-list {
list-style: none;
padding: 0;
margin: 0;
margin-top: 0.5em;
}
.option-input {
appearance: none;
display: none;
}
.option-check-mark-wrapper {
width: 1em;
height: 1em;
display: flex;
justify-content: center;
align-items: center;
}
.option-check-mark {
visibility: hidden;
}
.option-input:checked + .option-check-mark-wrapper .option-check-mark {
visibility: visible;
}
</style>
@@ -0,0 +1,19 @@
.settings-button {
--padding-x: 0.7em;
padding: 0.3em var(--padding-x);
margin-top: 0.2em;
width: calc(100% - 2 * var(--padding-x));
border-radius: 0.8em;
display: inline-flex;
align-items: center;
gap: 0.5em;
cursor: pointer;
background-color: var(--light-bg);
user-select: none;
-moz-user-select: none;
}
.settings-button:hover {
background-color: var(--light-hover);
}
@@ -0,0 +1,215 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import Icon from '@/features/icons/components/Icon.vue';
import { useSettingsStore } from '../stores/settingsStore';
import { computed, ref, useId, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
setting: {
required: true
},
path: {
required: true
}
});
const store = useSettingsStore();
const inputModel = ref(store.get(props.path) ?? props.setting.default);
let oldInputValue = inputModel.value;
const doneButtonInitial = ref(true);
const inputId = useId();
const inputLabelId = useId();
const inputType = computed(() => ({
number: 'number',
string: 'text'
}[props.setting.type]
));
const hasChanged = computed(() =>
inputModel.value !== store.get(props.path)
);
watch(store, (newValue) => {
const newInputValue = newValue.get(props.path);
if (inputModel.value === oldInputValue) {
inputModel.value = newInputValue;
oldInputValue = newInputValue;
};
});
watch(inputModel, () => {
doneButtonInitial.value = false;
});
const apply = function apply () {
store.set(props.path, inputModel.value);
};
</script>
<template>
<div class="input-setting-container">
<label :for="inputId" :id="inputLabelId">
{{ t(props.setting.i18n) }}
</label>
<form
@submit.prevent="apply"
class="input-container"
:class="{ 'done-button-shown': hasChanged }"
>
<input
:type="inputType"
:id="inputId"
:aria-labelledby="inputLabelId"
v-model="inputModel"
class="input"
/>
<button
ref="doneButton"
class="done-button button"
:class="{ initial: doneButtonInitial }"
type="submit"
:title="t('settings.input.done.title')"
:aria-label="t('settings.input.done.ariaLabel')"
>
<Icon name="check-mark" />
</button>
</form>
</div>
</template>
<style scoped>
.input-setting-container {
display: flex;
justify-content: space-between;
width: 100%;
align-items: center;
}
.input-container {
position: relative;
width: 50%;
white-space: nowrap;
--total-animation-duration: 0.3s;
}
.input {
box-sizing: border-box;
width: 100%;
height: calc(var(--input-height) + 2 * var(--input-padding-y));
display: inline;
vertical-align: middle;
--input-height: 24px;
transition: width calc(var(--total-animation-duration) / 3);
}
.input-container:not(.done-button-shown) .input {
transition-delay: calc((2 / 3) * var(--total-animation-duration));
}
.input-container.done-button-shown .input {
width: calc(100% - 1.5 * var(--input-padding) - 26px - 0.75 * var(--input-height) + 1.5px);
}
.done-button {
position: absolute;
right: 0;
top: 50%;
border: 1px solid var(--border);
min-height: var(--input-height);
padding: var(--input-padding);
transform: translateY(-50%) scale(0);
transform-origin: center;
vertical-align: middle;
align-items: center;
justify-content: center;
border-radius: calc(var(--input-padding) + 13px);
font-size: 1em;
font-family: inherit;
}
.done-button:not(.initial) {
animation: done-button-disappear var(--total-animation-duration);
}
.input-container.done-button-shown .done-button {
animation: done-button-appear var(--total-animation-duration);
display: inline-flex;
transform: translateY(-50%) scale(1);
}
@keyframes done-button-appear {
0% {
transform: translateY(-50%) scale(0);
}
10% {
transform: translateY(-50%) scale(0);
}
95% {
transform: translateY(-50%) scale(1.05);
}
100% {
transform: translateY(-50%) scale(1);
}
}
@keyframes done-button-disappear {
0% {
transform: translateY(-50%) scale(1);
}
100% {
transform: translateY(-50%) scale(0);
}
}
@media (prefers-reduced-motion: reduce) {
@keyframes done-button-appear {
0% {
transform: translateY(-50%) scale(1);
opacity: 0;
}
10% {
transform: translateY(-50%) scale(1);
opacity: 0;
}
100% {
transform: translateY(-50%) scale(1);
opacity: 1;
}
}
@keyframes done-button-disappear {
0% {
opacity: 1;
transform: translateY(-50%) scale(1);
}
100% {
opacity: 0;
transform: translateY(-50%) scale(1);
}
}
}
</style>
@@ -0,0 +1,93 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import Switch from './Switch.vue';
import SettingsInput from './SettingsInput.vue';
import Selection from './Selection.vue';
import SettingsPageLink from './SettingsPageLink.vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
setting: {
required: true
},
path: {
required: true
}
});
</script>
<template>
<div>
<h2 class="section-heading">
{{ t(props.setting.i18n) }}
</h2>
<div
v-for="setting in props.setting.content"
:key="setting"
class="setting"
>
<Switch
v-if="setting.type === 'bool'"
:setting="setting"
:path="`${props.path}.${setting.name}`"
/>
<SettingsInput
v-if="setting.type === 'string' || setting.type === 'number'"
:setting="setting"
:path="`${props.path}.${setting.name}`"
/>
<Selection
v-if="setting.type === 'selection'"
:setting="setting"
:path="`${props.path}.${setting.name}`"
/>
<SettingsPageLink
v-if="setting.type === 'section'"
:display-name="t(setting.i18n)"
:target="`${props.path}.${setting.name}`"
/>
</div>
</div>
</template>
<style scoped>
.setting {
margin-bottom: 1.2em;
}
.section-heading {
--size: 1.2em;
font-size: var(--size);
margin-bottom: calc(var(--size) - 0.2em);
}
.section-heading[aria-level="2"] {
--size: 1.8em;
}
.section-heading[aria-level="3"] {
--size: 1.6em;
}
.section-heading[aria-level="4"] {
--size: 1.4em;
}
</style>
@@ -0,0 +1,61 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import Icon from '@/features/icons/components/Icon.vue';
import { useSettingsPage } from '../composables/useSettingsPage';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const { goToSettingsPage } = useSettingsPage();
const props = defineProps({
target: {
required: true,
type: String
},
displayName: {
required: true,
type: String
}
});
const followLink = function followLink () {
goToSettingsPage(props.target);
};
</script>
<template>
<button
class="settings-button button settings-page-link"
:title="props.displayName"
:aria-label="t('settings.pages.link.ariaLabel', { page: props.displayName })"
@click="followLink"
>
{{ props.displayName }}
<Icon name="chevron-right" size="1em" />
</button>
</template>
<style scoped src="./SettingsButton.css"></style>
<style scoped>
.settings-page-link {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
</style>
+101
View File
@@ -0,0 +1,101 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import { useSettingsStore } from '../stores/settingsStore';
import { useId } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
setting: {
required: true
},
path: {
required: true
}
});
const labelId = useId();
const switchId = useId();
const store = useSettingsStore();
const toggle = function toggle () {
store.set(props.path, !store.get(props.path));
};
</script>
<template>
<div class="switch-container" @click="toggle">
<label :for="switchId" :id="labelId" class="switch-label">
{{ t(props.setting.i18n) }}
</label>
<div
class="switch-wrapper"
:class="{ enabled: store.get(props.path) }"
:title="t('settings.switch.title')"
:aria-label="t('settings.switch.ariaLabel')"
:aria-labelledby="labelId"
>
<div class="switch-point"></div>
</div>
</div>
</template>
<style scoped>
.switch-container {
display: flex;
justify-content: space-between;
width: 100%;
align-items: center;
cursor: pointer;
}
.switch-label {
cursor: inherit;
}
.switch-wrapper {
--point-size: 0.9em;
--padding: 2px;
--transition-duration: 0.2s;
background-color: var(--light-bg);
outline: 1px solid var(--border);
width: calc(2 * var(--point-size));
padding: var(--padding);
border-radius: calc(0.5 * var(--point-size) + var(--padding));
transition: background-color var(--transition-duration) ease;
}
.switch-point {
height: var(--point-size);
width: var(--point-size);
background-color: var(--border);
border-radius: calc(0.5 * var(--point-size));
margin-left: 0;
transition: margin-left var(--transition-duration) ease;
}
.switch-wrapper.enabled {
background-color: var(--primary-color);
}
.switch-wrapper.enabled .switch-point {
margin-left: var(--point-size);
}
</style>
@@ -0,0 +1,267 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import Selection from '../Selection.vue';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { useSettingsStore } from '../../stores/settingsStore.js';
import { expect, describe, test } from 'vitest';
import { nextTick } from 'vue';
const getWrapper = function getWrapper ({
i18n = 'selection',
options = [],
allowMultiple = false,
defaultValue = undefined,
translations = {},
piniaOptions = {}
} = {}) {
return mountComponent(Selection, {
attrs: {
setting: {
name: 'selection',
type: 'selection',
i18n,
options,
allowMultiple,
default: defaultValue
},
path: 'selection'
}
}, translations, [], piniaOptions);
};
const getChecked = function getChecked (inputs) {
return inputs.map((input) => input.element.matches(':checked'));
};
const exampleOptions = [
{ name: 'o0', i18n: 'o0' },
{ name: 'o1', i18n: 'o1' },
{ name: 'o2', i18n: 'o2' }
];
describe('Selection', () => {
test('displays options correctly', () => {
const wrapper = getWrapper({ options: exampleOptions });
const listItems = wrapper.findAll('li');
expect(listItems.length).toBe(3);
for (let i = 0; i <= 2; i++) {
const option = exampleOptions[i];
const listItem = listItems[i];
expect(listItem.find('.input-label').text()).toBe(option.i18n);
const input = listItem.find('input');
expect(input.attributes('type')).toBe('radio');
expect(input.attributes('name')).toBe('selection');
expect(input.element.matches(':checked')).toBe(false);
};
});
test('shows value from store', () => {
const wrapper = getWrapper({
options: exampleOptions,
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.set('selection', 'o1');
}
}
});
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([false, true, false]);
});
test('shows multiple values from store', () => {
const wrapper = getWrapper({
options: exampleOptions,
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.set('selection', ['o1', 'o2']);
}
},
allowMultiple: true
});
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([false, true, true]);
});
test('shows default value', () => {
const wrapper = getWrapper({
options: exampleOptions,
defaultValue: 'o2'
});
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([false, false, true]);
});
test('shows multiple default values', () => {
const wrapper = getWrapper({
options: exampleOptions,
defaultValue: ['o0', 'o1'],
allowMultiple: true
});
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([true, true, false]);
});
test('shows store value even if default value is set', () => {
const wrapper = getWrapper({
options: exampleOptions,
default: 'o2',
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.set('selection', 'o1');
}
}
});
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([false, true, false]);
});
test('stores one value', async () => {
const wrapper = getWrapper({ options: exampleOptions });
const store = useSettingsStore();
expect(store.get('selection')).toBeUndefined();
const inputs = wrapper.findAll('input');
const inputO0 = inputs[0];
const inputO1 = inputs[1];
const inputO2 = inputs[2];
await inputO0.setValue(true);
await nextTick();
expect(store.get('selection')).toBe('o0');
await inputO1.setValue(true);
await nextTick();
expect(store.get('selection')).toBe('o1');
await inputO2.setValue(true);
await nextTick();
expect(store.get('selection')).toBe('o2');
expect(inputO2.element.matches(':checked')).toBe(true);
});
test('stores multiple values', async () => {
const setValue = async function setValue (input, value) {
input.setValue(value);
await nextTick();
};
const check = function check (input) {
return setValue(input, true);
};
const uncheck = function uncheck (input) {
return setValue(input, false);
};
const expectSelectionValue = function expectSelectionValue (value, options = exampleOptions) {
value.sort();
expect(store.get('selection').sort()).toStrictEqual(value);
const checkedInputs = getChecked(inputs);
expect(
options
.map((option) => option.name)
.filter((entry, i) => checkedInputs[i])
).toStrictEqual(value);
};
const wrapper = getWrapper({
options: exampleOptions,
allowMultiple: true
});
const store = useSettingsStore();
expect(store.get('selection')).toBeUndefined();
const inputs = wrapper.findAll('input');
const inputO0 = inputs[0];
const inputO1 = inputs[1];
const inputO2 = inputs[2];
await check(inputO0);
expectSelectionValue(['o0'])
await uncheck(inputO0);
expectSelectionValue([]);
await check(inputO1);
expectSelectionValue(['o1']);
await check(inputO0);
expectSelectionValue(['o0', 'o1']);
await check(inputO2);
expectSelectionValue(['o0', 'o1', 'o2']);
await uncheck(inputO2);
expectSelectionValue(['o0', 'o1']);
await check(inputO2);
expectSelectionValue(['o0', 'o1', 'o2']);
await uncheck(inputO1);
expectSelectionValue(['o0', 'o2']);
await inputO0.setValue(true);
expectSelectionValue(['o0', 'o2']);
await inputO0.setValue(false);
expectSelectionValue(['o2']);
await inputO1.setValue(true);
expectSelectionValue(['o1', 'o2']);
await inputO2.setValue(false);
expectSelectionValue(['o1']);
await inputO1.setValue(true);
expectSelectionValue(['o1']);
await inputO1.setValue(false);
expectSelectionValue([]);
});
test('shows correct translation', () => {
const translation = 'Selection setting';
const wrapper = getWrapper({
translations: {
selection: translation
}
});
const label = wrapper.find('.label');
expect(label.text()).toBe(translation);
});
});
@@ -0,0 +1,210 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import SettingsInput from '../SettingsInput.vue';
import { mountComponent } from '@/test-utils/mountComponent';
import { useSettingsStore } from '../../stores/settingsStore.js';
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
const getWrapper = function getWrapper({ type = 'string', i18n = 'input', defaultValue = undefined, piniaOptions = {} } = {}) {
return mountComponent(SettingsInput, {
attrs: {
setting: {
name: 'input',
default: defaultValue,
i18n,
type
},
path: 'input'
}
}, {}, [], piniaOptions);
}
describe('SettingsInput', () => {
test('uses correct input type', () => {
const stringSettingWrapper = getWrapper();
const stringInputElement = stringSettingWrapper.find('input');
expect(stringInputElement.attributes('type')).toBe('text');
const numberSettingWrapper = getWrapper({ type: 'number' });
const numberInputElement = numberSettingWrapper.find('input');
expect(numberInputElement.attributes('type')).toBe('number');
});
test('shows empty value', () => {
const stringSettingWrapper = getWrapper();
const inputElement = stringSettingWrapper.find('input');
expect(inputElement.attributes('value')).toBeUndefined();
});
test('shows value from store', () => {
const value = 'example value';
const stringSettingWrapper = getWrapper({
type: 'string',
i18n: 'input',
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.settings.input = value;
}
}
});
const inputElement = stringSettingWrapper.find('input');
expect(inputElement.element.value).toBe(value);
});
test('shows default value', () => {
const value = 'example value';
const wrapper = getWrapper({
type: 'string',
i18n: 'input',
defaultValue: value
});
const inputElement = wrapper.find('input');
expect(inputElement.element.value).toBe(value);
});
test('store value instead of default value', () => {
const storeValue = 'store value';
const defaultValue = 'default value';
const wrapper = getWrapper({
type: 'string',
i18n: 'input',
defaultValue: defaultValue,
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.settings.input = storeValue;
}
}
});
const inputElement = wrapper.find('input');
expect(inputElement.element.value).toBe(storeValue);
});
test('stores value', async () => {
const value = 'example value';
const wrapper = getWrapper();
const store = useSettingsStore();
expect(store.get('input')).toBeUndefined();
const inputElement = wrapper.find('input');
await inputElement.setValue(value);
const form = wrapper.find('form');
await form.trigger('submit');
expect(store.get('input')).toBe(value);
});
test('reacts on store change', async () => {
const value = 'example value';
const value2 = 'example value 2';
const anotherValue = 'another value';
const wrapper = getWrapper();
const inputElement = wrapper.find('input');
expect(inputElement.element.value).toBe('');
const store = useSettingsStore();
store.set('input', value);
await nextTick();
expect(inputElement.element.value).toBe(value);
await inputElement.setValue(anotherValue);
expect(inputElement.element.value).toBe(anotherValue);
// input does not update if there are unsaved changes
store.set('input', value2);
expect(inputElement.element.value).toBe(anotherValue);
const form = wrapper.find('form');
expect(form.classes('done-button-shown')).toBeTruthy();
await form.trigger('submit');
expect(store.get('input')).toBe(anotherValue);
expect(form.classes('done-button-shown')).toBeFalsy();
});
test('shows done button correctly', async () => {
const wrapper = getWrapper();
const form = wrapper.find('form');
const inputElement = wrapper.find('input');
expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue('value');
expect(form.classes('done-button-shown')).toBeTruthy();
await form.trigger('submit');
expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue('Hello, World!');
expect(form.classes('done-button-shown')).toBeTruthy();
await form.trigger('submit');
expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue('');
expect(form.classes('done-button-shown')).toBeTruthy();
await form.trigger('submit');
expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue('a');
expect(form.classes('done-button-shown')).toBeTruthy();
const store = useSettingsStore();
store.set('input', 'a');
await nextTick();
expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue('');
store.set('input', '');
await nextTick();
expect(form.classes('done-button-shown')).toBeFalsy();
});
test('shows correct translation', () => {
const translation = 'Text input';
const wrapper = mountComponent(SettingsInput, {
attrs: {
setting: {
name: 'input',
i18n: 'input',
type: 'string',
},
path: 'input'
}
}, {
input: translation
});
const label = wrapper.find('label');
expect(label.text()).toBe(translation);
});
});
@@ -0,0 +1,160 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import Selection from '../Selection.vue';
import SettingsInput from '../SettingsInput.vue';
import SettingsPage from '../SettingsPage.vue';
import SettingsPageLink from '../SettingsPageLink.vue';
import Switch from '../Switch.vue';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { describe, expect, test } from 'vitest';
const getWrapper = function getWrapper({ content = [], translations = {}, piniaOptions = {} }) {
return mountComponent(SettingsPage, {
attrs: {
setting: {
name: 'section',
type: 'section',
i18n: 'section',
content: content
},
path: 'section'
}
}, translations, [], piniaOptions);
};
const exampleSettings = [
{
type: 'bool',
name: 'b0',
i18n: 'settings.section.b0'
},
{
type: 'number',
name: 'n0',
i18n: 'settings.section.n0'
},
{
type: 'string',
name: 'st0',
i18n: 'settings.section1.st0'
},
{
type: 'selection',
name: 'se0',
i18n: 'settings.section.se0',
options: [
{ name: 'o0', i18n: 'settings.section.se0.o0' },
{ name: 'o1', i18n: 'settings.section.se0.o1' },
{ name: 'o2', i18n: 'settings.section.se0.o2' },
{ name: 'o3', i18n: 'settings.section.se0.o3' }
]
},
{
type: 'section',
name: 's0',
i18n: 'settings.section.s0',
content: []
},
{
type: 'section',
name: 's1',
i18n: 'settings.section.s1',
content: [
{
type: 'bool',
name: 'b0',
i18n: 'settings.section.s1.b0',
default: true
}
]
}
];
describe('SettingsPage', () => {
test('shows correct heading', () => {
const translation = 'Example section';
const wrapper = getWrapper({
translations: { section: translation }
});
const heading = wrapper.find('.section-heading');
expect(heading.text()).toBe(translation);
});
test('shows settings correctly', () => {
const wrapper = getWrapper({
content: exampleSettings
});
const settingsWrappers = wrapper.findAll('.setting');
expect(settingsWrappers.length).toBe(exampleSettings.length);
exampleSettings.forEach((expectedSetting, index) => {
const settingWrapper = settingsWrappers[index];
const switchSetting = settingWrapper.findComponent(Switch);
const inputSetting = settingWrapper.findComponent(SettingsInput);
const selectionSetting = settingWrapper.findComponent(Selection);
const settingsPageLink = settingWrapper.findComponent(SettingsPageLink);
let setting;
if (expectedSetting.type === 'bool') {
expect(switchSetting.exists()).toBe(true);
expect(inputSetting.exists()).toBe(false);
expect(selectionSetting.exists()).toBe(false);
expect(settingsPageLink.exists()).toBe(false);
setting = switchSetting;
} else if (expectedSetting.type === 'string' || expectedSetting.type === 'number') {
expect(switchSetting.exists()).toBe(false);
expect(inputSetting.exists()).toBe(true);
expect(selectionSetting.exists()).toBe(false);
expect(settingsPageLink.exists()).toBe(false);
setting = inputSetting;
} else if (expectedSetting.type === 'selection') {
expect(switchSetting.exists()).toBe(false);
expect(inputSetting.exists()).toBe(false);
expect(selectionSetting.exists()).toBe(true);
expect(settingsPageLink.exists()).toBe(false);
setting = selectionSetting;
} else {
expect(switchSetting.exists()).toBe(false);
expect(inputSetting.exists()).toBe(false);
expect(selectionSetting.exists()).toBe(false);
expect(settingsPageLink.exists()).toBe(true);
setting = settingsPageLink;
}
if (
expectedSetting.type === 'bool' ||
expectedSetting.type === 'string' ||
expectedSetting.type === 'number' ||
expectedSetting.type === 'selection'
) {
expect(setting.props('setting')).toMatchObject(expectedSetting);
expect(setting.props('path')).toBe(`section.${expectedSetting.name}`);
} else {
expect(setting.props('displayName')).toBe(expectedSetting.i18n);
expect(setting.props('target')).toBe(`section.${expectedSetting.name}`);
}
});
});
});
@@ -0,0 +1,59 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import SettingsPageLink from '../SettingsPageLink.vue';
import { mount } from '@vue/test-utils';
import { describe, expect, test, vi } from 'vitest';
import { createI18n } from 'vue-i18n';
const goToSettingsPage = vi.fn();
vi.mock('../../composables/useSettingsPage.js', () => ({
useSettingsPage: () => ({
goToSettingsPage
})
}));
const getWrapper = function getWrapper({ target, displayName }) {
return mount(SettingsPageLink, {
attrs: {
target,
displayName
},
global: {
plugins: [
createI18n()
]
}
});
};
describe('SettingsPageLink', () => {
test('shows correct display name', () => {
const displayName = 'A settings page link';
const wrapper = getWrapper({ displayName });
expect(wrapper.text()).toBe(displayName);
});
test('follows link', async () => {
const target = 'section1.subsection2';
const wrapper = getWrapper({ target });
const button = wrapper.find('button');
await button.trigger('click');
expect(goToSettingsPage).toHaveBeenCalledWith(target);
});
});
@@ -0,0 +1,83 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import Switch from '../Switch.vue';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { useSettingsStore } from '../../stores/settingsStore.js';
import { expect, describe, test } from 'vitest';
import { nextTick } from 'vue';
const getWrapper = function getWrapper ({ i18n = 'switch1', translations = {} } = {}) {
return mountComponent(Switch, {
attrs: {
setting: {
type: 'bool',
name: 'switch',
i18n
},
path: 'switch'
}
}, translations);
};
describe('Switch', () => {
test('shows value from store', async () => {
const wrapper = getWrapper();
const store = useSettingsStore();
store.set('switch', true);
await nextTick();
const switchElement = wrapper.find('.switch-wrapper');
const switchElementClasses = switchElement.classes();
expect(switchElementClasses).toContain('enabled');
store.set('switch', false);
await nextTick();
const switchElementClasses2 = switchElement.classes();
expect(switchElementClasses2).not.toContain('enabled');
});
test('toggles value in store', async () => {
const wrapper = getWrapper();
const switchElement = wrapper.find('.switch-wrapper');
const store = useSettingsStore();
expect(store.get('switch')).toBeUndefined();
switchElement.trigger('click');
await nextTick();
expect(store.get('switch')).toBe(true);
switchElement.trigger('click');
await nextTick();
expect(store.get('switch')).toBe(false);
});
test('shows correct translation', () => {
const translation = 'Switch';
const wrapper = getWrapper({
translations: { switch1: translation }
})
const label = wrapper.find('label');
expect(label.text()).toBe(translation);
});
});
@@ -0,0 +1,40 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useSettingsPage } from '../useSettingsPage.js';
import { describe, expect, test, vi } from 'vitest';
const pushRoute = vi.fn();
vi.mock('vue-router', () => ({
useRouter: () => ({
push: pushRoute
})
}));
describe('useSettingsPage', () => {
describe('goToSettingsPage', () => {
test('pushes correct route', async () => {
const { goToSettingsPage } = useSettingsPage();
await goToSettingsPage('..a.b...c.d..e......');
expect(pushRoute).toHaveBeenCalledWith({
name: 'settings',
params: {
rest: ['a', 'b', 'c', 'd', 'e']
}
});
});
});
});
@@ -0,0 +1,33 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { getSettingsPagePathSegments } from '../utils/settingsPage';
import { useRouter } from 'vue-router';
export const useSettingsPage = function useSettingsPage () {
const router = useRouter();
const goToSettingsPage = function goToSettingsPage (pagePath) {
return router.push({
name: 'settings',
params: {
rest: getSettingsPagePathSegments(pagePath)
}
});
};
return { goToSettingsPage };
};
@@ -0,0 +1,42 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { defineStore } from 'pinia';
import { reactive } from 'vue';
export const useSettingsStore = defineStore('settings', () => {
const settings = reactive({});
const set = function set (key, value) {
settings[key] = value;
};
const get = function get (key) {
return settings[key];
};
const remove = function remove (key) {
delete settings[key];
};
const clear = function clear () {
Object.keys(settings).forEach(key => delete settings[key]);
};
return { set, get, remove, clear, settings };
}, {
persist: true
});
@@ -0,0 +1,526 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the 'License');
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an 'AS IS' BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { getSetting, getSettingRecursively } from '../getSetting.js';
import { test, expect } from 'vitest';
test.for([
({ settings: [], name: '', expected: undefined }),
({ settings: [], name: 'a', expected: undefined }),
({ settings: [
{
type: 'bool',
name: 'a',
i18n: 'a'
}
], name: 'a', expected: {
type: 'bool',
name: 'a',
i18n: 'a'
} })
])('returns $expected', ({ settings, name, expected }) => {
expect(getSetting(name, settings)).toStrictEqual(expected);
});
const veryNestedSettingsList = [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'bool',
name: '3a',
i18n: '3a'
},
{
type: 'section',
name: '3b',
i18n: '3b',
content: [
{
type: 'bool',
name: '4a',
i18n: '4a'
},
{
type: 'section',
name: '4b',
i18n: '4b',
content: [
{
type: 'bool',
name: '5a',
i18n: '5a'
},
{
type: 'section',
name: '5b',
i18n: '5b',
content: [
{
type: 'bool',
name: '6a',
i18n: '6a'
},
{
type: 'section',
name: '6b',
i18n: '6b',
content: [
{
type: 'bool',
name: '7a',
i18n: '7a'
}
]
}
]
}
]
}
]
}
]
}
]
}
];
test.for([
({ settings: [], path: ['a'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
}
], path: ['1a'], expected: {
type: 'bool',
name: '1a',
i18n: '1a'
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
}
]
}
], path: ['1a'], expected: {
type: 'bool',
name: '1a',
i18n: '1a'
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
}
]
}
], path: ['1b', '2a'], expected: {
type: 'bool',
name: '2a',
i18n: '2a'
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'number',
name: '2b',
i18n: '2b',
default: 42
}
]
}
], path: ['1b', '2b'], expected: {
type: 'number',
name: '2b',
i18n: '2b',
default: 42
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2b', '3a'], expected: {
type: 'string',
name: '3a',
i18n: '3a'
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b'], expected: {
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2b'], expected: {
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2b', '3b'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2a', '3a'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2c', '3a'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1a', '2b', '3a'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1c', '2b', '3a'], expected: undefined }),
({ settings: veryNestedSettingsList, path: ['1b', '2b', '3b', '4b', '5b', '6b', '7a'], expected: {
type: 'bool',
name: '7a',
i18n: '7a'
} }),
({ settings: veryNestedSettingsList, path: ['1b', '2b', '3b', '4b', '5b'], expected: {
type: 'section',
name: '5b',
i18n: '5b',
content: [
{
type: 'bool',
name: '6a',
i18n: '6a'
},
{
type: 'section',
name: '6b',
i18n: '6b',
content: [
{
type: 'bool',
name: '7a',
i18n: '7a'
}
]
}
]
} }),
({ settings: veryNestedSettingsList, path: ['1b', '2b', '3b', '4ba', '5b', '6b', '7a'], expected: undefined })
])('returns $expected', ({ settings, path, expected }) => {
expect(getSettingRecursively(path, settings)).toStrictEqual(expected);
});
@@ -0,0 +1,63 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { getSettingsPagePathSegments } from '../settingsPage.js';
import { describe, expect, test } from 'vitest';
describe('settingsPage', () => {
describe('getSettingsPagePathSegments', () => {
test.for([
{ path: 'a.b.c', expected: ['a', 'b', 'c'] },
{ path: 'a', expected: ['a'] },
{ path: 'a.b', expected: ['a', 'b'] },
{ path: 'a..b', expected: ['a', 'b'] },
{ path: '.a.b', expected: ['a', 'b'] },
{ path: '..a.b', expected: ['a', 'b'] },
{ path: '.a.b.', expected: ['a', 'b'] },
{ path: '..a.b..', expected: ['a', 'b'] },
{ path: 'a.b.', expected: ['a', 'b'] },
{ path: 'a.b..', expected: ['a', 'b'] },
{ path: '.a.b..', expected: ['a', 'b'] },
{ path: '..a.b.', expected: ['a', 'b'] },
{ path: '.a..b', expected: ['a', 'b'] },
{ path: '..a..b', expected: ['a', 'b'] },
{ path: '.a..b.', expected: ['a', 'b'] },
{ path: '..a..b..', expected: ['a', 'b'] },
{ path: 'a..b.', expected: ['a', 'b'] },
{ path: 'a..b..', expected: ['a', 'b'] },
{ path: '.a..b..', expected: ['a', 'b'] },
{ path: '..a..b.', expected: ['a', 'b'] },
{ path: '..a.....b.c..d....', expected: ['a', 'b', 'c', 'd'] },
{ path: 'a.b.c/', expected: ['a', 'b', 'c'] },
{ path: 'a.#b.c', expected: ['a', 'b', 'c'] },
{ path: 'a.#b.c/', expected: ['a', 'b', 'c'] },
{ path: 'a1.#b.c/', expected: ['a1', 'b', 'c'] },
{ path: 'a-1.#b.c/', expected: ['a-1', 'b', 'c'] },
{ path: 'a-1.b@.c', expected: ['a-1', 'b', 'c'] },
{ path: '....@a/#...)!&§[b.#§c..d....', expected: ['a', 'b', 'c', 'd'] },
{ path: '....@a/#...)!&§[b.#§c..dä....', expected: ['a', 'b', 'c', 'd'] },
{ path: '..,...~..@a/#.+..)!&§[b.#§c..dä..)..', expected: ['a', 'b', 'c', 'd'] },
{ path: 'a.@.b', expected: ['a', 'b'] },
{ path: 1, expected: [] },
{ path: false, expected: [] },
{ path: true, expected: [] },
{ path: null, expected: [] },
{ path: undefined, expected: [] }
])('returns $expected', async ({ path, expected }) => {
expect(getSettingsPagePathSegments(path)).toStrictEqual(expected);
});
})
})
@@ -557,6 +557,30 @@ describe('settingsValidator', () => {
{ name: 'maybe', i18n: 'selectSomething.options.maybe' }
] }],
[{ name: 'selectMultiple', type: 'selection', i18n: 'selectMultiple.title', default: ['1', '2', '3'], allowMultiple: true, options: [
{ name: '1', i18n: 'selectMultiple.options.1' },
{ name: '2', i18n: 'selectMultiple.options.2' },
{ name: '3', i18n: 'selectMultiple.options.3' }
] }],
[{ name: 'selectMultiple', type: 'selection', i18n: 'selectMultiple.title', default: ['2', '3'], allowMultiple: true, options: [
{ name: '1', i18n: 'selectMultiple.options.1' },
{ name: '2', i18n: 'selectMultiple.options.2' },
{ name: '3', i18n: 'selectMultiple.options.3' }
] }],
[{ name: 'selectMultiple', type: 'selection', i18n: 'selectMultiple.title', default: ['2'], allowMultiple: true, options: [
{ name: '1', i18n: 'selectMultiple.options.1' },
{ name: '2', i18n: 'selectMultiple.options.2' },
{ name: '3', i18n: 'selectMultiple.options.3' }
] }],
[{ name: 'selectMultiple', type: 'selection', i18n: 'selectMultiple.title', default: [], allowMultiple: true, options: [
{ name: '1', i18n: 'selectMultiple.options.1' },
{ name: '2', i18n: 'selectMultiple.options.2' },
{ name: '3', i18n: 'selectMultiple.options.3' }
] }],
[{ name: 'aSection', type: 'section', i18n: 'sections.aSection.title', content: [] }],
[{ name: 'aSection', type: 'section', i18n: 'sections.aSection.title', content: [
{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: true }
@@ -616,6 +640,34 @@ describe('settingsValidator', () => {
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', default: 'a', options: true }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', default: 'a', options: [] }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', default: ['a'], options: [
{ name: 'a', i18n: 'a' },
{ name: 'b', i18n: 'b' }
] }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', default: ['a', 'b'], options: [
{ name: 'a', i18n: 'a' },
{ name: 'b', i18n: 'b' }
] }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', allowMultiple: true, default: 'a', options: [
{ name: 'a', i18n: 'a' },
{ name: 'b', i18n: 'b' }
] }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', allowMultiple: true, default: 'a', options: [
{ name: 'a', i18n: 'a' },
{ name: 'b', i18n: 'b' }
] }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', allowMultiple: true, default: ['c'], options: [
{ name: 'a', i18n: 'a' },
{ name: 'b', i18n: 'b' }
] }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', allowMultiple: true, default: [1], options: [
{ name: 'a', i18n: 'a' },
{ name: 'b', i18n: 'b' }
] }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', allowMultiple: true, default: ['a', 1], options: [
{ name: 'a', i18n: 'a' },
{ name: 'b', i18n: 'b' }
] }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', default: 'c', options: [
{ name: 'a', i18n: 'a' },
{ name: 'b', i18n: 'b' }
+42
View File
@@ -0,0 +1,42 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export const getSetting = function getSetting (name, settings) {
return settings.filter((setting) => setting.name === name)[0];
};
export const getSettingRecursively = function getSettingRecursively (sectionPath, settings) {
const oneSettingsLevel = function oneSettingsLevel (cursor, acc) {
const setting = getSetting(cursor[0], acc.content);
if (!setting) return;
if (setting.type === undefined) setting.type = 'section';
if (setting.type === 'section') {
const newCursor = cursor.slice(1, cursor.length);
if (newCursor.length !== 0) {
return oneSettingsLevel(cursor.slice(1, cursor.length), setting);
} else {
return setting;
};
} else if (cursor.length <= 1) {
return setting;
} else {
return;
};
};
return oneSettingsLevel(sectionPath, { content: settings });
};
@@ -0,0 +1,31 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Extracts the segments of a settings page path and normalizes each segment.
* @param {string} path - The settings page path.
* @returns {Array} The settings page path segments.
*/
export const getSettingsPagePathSegments = function getSettingsPagePathSegments (path) {
if (typeof path !== 'string') return [];
return path
.replace(/^\.+|\.+$/g, '')
.split('.')
.map(
(segment) => segment.replace(/[^a-zA-Z0-9-]/g, '')
)
.filter(Boolean);
};
@@ -73,12 +73,27 @@ export const validateEntry = function validateEntry (entry, path) {
throw new Error(`[settings] "${path}.default" must be a string`);
}
if (entry.type === 'selection') {
if (typeof entry.default !== 'string') {
throw new Error(`[settings] "${path}.default" must be a string`);
};
if (!entry.options.map((option) => option.name).includes(entry.default)) {
throw new Error(`[settings] option "${path}.default" does not exist`);
};
if (entry.allowMultiple) {
if (!Array.isArray(entry.default)) {
throw new Error(`[settings] "${path}.default" must be an array`);
}
const allOptions = entry.options.map((option) => option.name);
entry.default.forEach((defaultValue, index) => {
if (typeof defaultValue !== 'string') {
throw new Error(`[settings] "${path}.default[${index}]" must be a string`);
}
if (!allOptions.includes(defaultValue)) {
throw new Error(`[settings] option "${path}.default[${index}]" does not exist`);
}
});
} else {
if (typeof entry.default !== 'string') {
throw new Error(`[settings] "${path}.default" must be a string`);
};
if (!entry.options.map((option) => option.name).includes(entry.default)) {
throw new Error(`[settings] option "${path}.default" does not exist`);
};
}
};
}
}
@@ -16,10 +16,51 @@ limitations under the License.
<script setup>
import LeftSidebarLayout from '@/layouts/LeftSidebarLayout.vue';
import SettingsPage from '../components/SettingsPage.vue';
import { getSettingRecursively } from '../utils/getSetting.js';
import { loadSettingsConfig } from '../utils/settingsParser';
import { onMounted, ref, watchEffect } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const settingsLoaded = ref(false)
const settings = ref([]);
const activeSectionContent = ref({});
const activeSection = ref(null);
watchEffect(() => {
const segments = route.path
.split('/')
.filter(Boolean);
activeSection.value = segments.slice(1, segments.length).join('.');
});
const updateSettings = function updateSettings () {
if (activeSection.value) {
const setting = getSettingRecursively(activeSection.value.split('.'), settings.value);
if (!setting) {
router.push('/settings');
} else {
activeSectionContent.value = setting;
};
} else {
activeSectionContent.value = { content: [] };
};
};
onMounted(async () => {
settings.value = (await loadSettingsConfig()).contents;
watchEffect(() => {
updateSettings();
});
settingsLoaded.value = true;
});
</script>
<template>
@@ -31,7 +72,29 @@ const { t } = useI18n();
</header>
<LeftSidebarLayout class="layout">
<template #sidebar>
<ul class="sidebar-sections-list">
<li v-for="section in settings">
<RouterLink
:to="`/settings/${section.name}`"
class="button button-link link sidebar-section"
:class="{ active: activeSection === section.name }"
>
{{ t(section.i18n) }}
</RouterLink>
</li>
</ul>
</template>
<div v-if="!settingsLoaded" class="main-content--loading">
{{ t('loading') }}
</div>
<div v-else-if="activeSection" class="main-content--loaded">
<div class="settings-main-content">
<SettingsPage :setting="activeSectionContent" :path="activeSection" />
</div>
</div>
<div v-else class="main-content--no-section">
{{ t('settings.noSectionSelected') }}
</div>
</LeftSidebarLayout>
</div>
</template>
@@ -46,6 +109,10 @@ const { t } = useI18n();
flex-direction: column;
}
.active {
background-color: var(--light-hover);
}
.header {
padding: var(--main-content-padding-y) var(--main-content-padding-x);
}
@@ -53,4 +120,31 @@ const { t } = useI18n();
.header h1 {
margin: 0;
}
.sidebar-sections-list {
list-style: none;
margin: 0;
padding: 0;
}
.sidebar-section {
--padding: 0.8em;
border-radius: var(--padding);
padding: var(--padding);
margin-bottom: 4px;
width: calc(100% - 2 * var(--padding));
text-align: start;
font-size: 1rem;
display: block;
}
.settings-main-content {
padding: 0 30%;
}
@media (max-width: 80em) {
.settings-main-content {
padding: 0;
}
}
</style>
@@ -0,0 +1,306 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import SettingsView from '../SettingsView.vue';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { flushPromises } from '@vue/test-utils';
import { beforeEach, describe, expect, test, vi } from 'vitest';
const settingsHoisted = vi.hoisted(() => [
{
name: 's0',
i18n: 'settings.s0',
content: []
},
{
name: 's1',
i18n: 'settings.s1',
content: [
{
type: 'bool',
name: 'b0',
i18n: 'settings.s1.b0'
},
{
type: 'number',
name: 'n0',
i18n: 'settings.s1.n0'
},
{
type: 'string',
name: 'st0',
i18n: 'settings.s1.st0'
},
{
type: 'selection',
name: 'se0',
i18n: 'settings.s1.se0',
options: [
{ name: 'o0', i18n: 'settings.s1.se0.o0' },
{ name: 'o1', i18n: 'settings.s1.se0.o1' },
{ name: 'o2', i18n: 'settings.s1.se0.o2' },
{ name: 'o3', i18n: 'settings.s1.se0.o3' }
]
},
{
type: 'section',
name: 's0',
i18n: 'settings.s1.s0',
content: []
},
{
type: 'section',
name: 's1',
i18n: 'settings.s1.s1',
content: [
{
type: 'bool',
name: 'b0',
i18n: 'settings.s1.s1.b0',
default: true
}
]
}
]
},
{
name: 's2',
i18n: 'settings.s2',
content: [
{
type: 'section',
name: 's0',
i18n: 'settings.s2.s0',
content: [
{
type: 'bool',
name: 'b0',
i18n: 'settings.s2.s0.b0',
default: true
}
]
}
]
},
{
name: 's3',
i18n: 'settings.s3',
content: [
{
type: 'section',
name: 's0',
i18n: 'settings.s3.s0',
content: []
}
]
},
{
name: 's4',
i18n: 'settings.s4',
content: [
{
type: 'selection',
name: 'se0',
i18n: 'settings.s4.se0',
options: [
{ name: 'o0', i18n: 'settings.s4.se0.o0' },
{ name: 'o1', i18n: 'settings.s4.se0.o1' },
{ name: 'o2', i18n: 'settings.s4.se0.o2' },
{ name: 'o3', i18n: 'settings.s4.se0.o3' }
]
}
]
},
{
name: 's5',
i18n: 'settings.s5',
content: [
{
type: 'string',
name: 'st0',
i18n: 'settings.s5.st0'
}
]
},
{
name: 's6',
i18n: 'settings.s6',
content: [
{
type: 'number',
name: 'n0',
i18n: 'settings.s6.n0'
}
]
},
{
name: 's7',
i18n: 'settings.s7',
content: [
{
type: 'bool',
name: 'b0',
i18n: 'settings.s7.b0'
}
]
},
{
name: 's8',
i18n: 'settings.s8',
content: [
{
type: 'section',
name: 's0',
i18n: 'settings.s8.s0',
content: [
{
type: 'section',
name: 's0',
i18n: 'settings.s8.s0.s0',
content: [
{
type: 'section',
name: 's0',
i18n: 'settings.s8.s0.s0.s0',
content: [
{
type: 'selection',
name: 'se0',
i18n: 'settings.s8.s0.s0.s0.se0',
allowMultiple: true,
options: []
}
]
}
]
}
]
}
]
}
]);
const settings = settingsHoisted.values();
const originalCurrentSection = 's0';
let currentSection = originalCurrentSection;
vi.mock('../../utils/settingsParser', () => ({
loadSettingsConfig: vi.fn().mockResolvedValue({
contents: settingsHoisted
})
}));
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
useRoute: () => ({
path: `/settings/${currentSection}`
})
};
});
const getWrapper = function getWrapper ({ translations = {} } = {}) {
return mountComponent(SettingsView, {}, translations, [], {});
};
describe('SettingsView', () => {
describe('heading', () => {
test('shows correct translation', () => {
const translation = 'Settings';
const wrapper = getWrapper({
translations: {
'preferences.settings': translation
}
});
const header = wrapper.find('header');
expect(header.text()).toBe(translation);
});
});
describe('sidebar', () => {
test('shows all top-level sections', async () => {
const wrapper = getWrapper();
await flushPromises();
await wrapper.vm.$nextTick();
const sidebarItems = wrapper.findAll('.sidebar-sections-list > li');
settings.forEach((setting, index) => {
const sidebarItem = sidebarItems[index];
expect(sidebarItem.text()).toBe(setting.i18n);
const link = sidebarItem.find('*');
expect(link.attributes('href')).toBe(`/settings/${setting.name}`);
expect(link.classes('active')).toBe(setting.name === currentSection);
});
});
});
describe('main', () => {
describe('correct element', () => {
test('shows that it is loading', () => {
const wrapper = getWrapper();
const mainContentLoading = wrapper.find('.main-content--loading');
const mainContentLoaded = wrapper.find('.main-content--loaded');
const mainContentNoSection = wrapper.find('.main-content--no-section');
expect(mainContentLoading.exists()).toBe(true);
expect(mainContentLoaded.exists()).toBe(false);
expect(mainContentNoSection.exists()).toBe(false);
});
test('shows that no section is selected', async () => {
currentSection = '';
const wrapper = getWrapper();
await flushPromises();
await wrapper.vm.$nextTick();
const mainContentLoading = wrapper.find('.main-content--loading');
const mainContentLoaded = wrapper.find('.main-content--loaded');
const mainContentNoSection = wrapper.find('.main-content--no-section');
expect(mainContentLoading.exists()).toBe(false);
expect(mainContentLoaded.exists()).toBe(false);
expect(mainContentNoSection.exists()).toBe(true);
});
test('shows main content', async () => {
const wrapper = getWrapper();
await flushPromises();
await wrapper.vm.$nextTick();
const mainContentLoading = wrapper.find('.main-content--loading');
const mainContentLoaded = wrapper.find('.main-content--loaded');
const mainContentNoSection = wrapper.find('.main-content--no-section');
expect(mainContentLoading.exists()).toBe(false);
expect(mainContentLoaded.exists()).toBe(true);
expect(mainContentNoSection.exists()).toBe(false);
});
});
});
});
beforeEach(() => {
currentSection = originalCurrentSection;
});
+47 -2
View File
@@ -14,15 +14,60 @@ See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import SidebarExpandButton from './SidebarExpandButton.vue';
const props = defineProps({
'expanded': {
default: false,
required: false,
type: Boolean
}
});
const emit = defineEmits(['toggleExpanded']);
const toggleSidebar = function toggleSidebar () {
emit('toggleExpanded');
};
</script>
<template>
<nav class="sidebar">
<slot />
<nav class="sidebar" :class="{ expanded }">
<div class="sidebar-controls">
<SidebarExpandButton @click="toggleSidebar" class="sidebar-expand-button" />
</div>
<div class="sidebar-content" v-if="props.expanded">
<slot />
</div>
</nav>
</template>
<style scoped>
.sidebar {
border-right: 1px solid var(--border);
}
.sidebar-controls {
display: flex;
align-items: center;
justify-content: end;
padding: 1em 20px;
padding-bottom: 0;
margin-bottom: 8px;
}
.sidebar:not(.expanded) .sidebar-controls {
padding: 1em;
}
@media (max-width: 48rem) {
.sidebar:not(.expanded) .sidebar-expand-button {
display: none;
}
}
.sidebar-content {
padding: 20px;
padding-top: 0;
}
</style>
@@ -0,0 +1,41 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import Icon from '@/features/icons/components/Icon.vue';
const emit = defineEmits(['click']);
const click = function click (event) {
emit('click', event);
};
</script>
<template>
<button class="button sidebar-expand-button">
<Icon name="sidebar-expand" @click="click" />
</button>
</template>
<style scoped>
.sidebar-expand-button {
display: flex;
justify-content: center;
align-items: center;
padding: 4px;
border-radius: 10px;
}
</style>
+64 -3
View File
@@ -16,11 +16,30 @@ limitations under the License.
<script setup>
import Sidebar from '@/features/sidebar/components/Sidebar.vue';
import SidebarExpandButton from '@/features/sidebar/components/SidebarExpandButton.vue';
import { ref } from 'vue';
const props = defineProps({
'expanded': {
default: false,
required: false,
type: Boolean
}
});
const expanded = ref(props.expanded);
const toggleExpanded = function toggleExpanded () {
expanded.value = !expanded.value;
};
</script>
<template>
<div class="layout-container">
<Sidebar>
<div class="layout-container" :class="{ expanded }">
<div class="sidebar-expand-button-container">
<SidebarExpandButton class="sidebar-expand-button" @click="toggleExpanded" />
</div>
<Sidebar @toggle-expanded="toggleExpanded" :expanded="expanded" class="sidebar">
<slot name="sidebar" />
</Sidebar>
<main class="main-content">
@@ -32,11 +51,53 @@ import Sidebar from '@/features/sidebar/components/Sidebar.vue';
<style scoped>
.layout-container {
display: grid;
grid-template-columns: min(24%, 280px) 1fr;
grid-template-columns: calc(32px + 2em) 1fr;
grid-template-rows: auto 1fr;
grid-template-areas:
"sidebar-expand sidebar-expand"
"sidebar main-content";
border-top: 1px solid var(--border);
}
.layout-container.expanded {
grid-template-columns: min(24%, 280px) 1fr;
}
.sidebar-expand-button-container {
grid-area: sidebar-expand;
display: none;
}
.sidebar {
grid-area: sidebar;
}
@media (max-width: 48rem) {
.layout-container {
grid-template-columns: 0 1fr;
}
.layout-container.expanded {
grid-template-columns: 1fr 0;
}
.layout-container:not(.expanded) .sidebar-expand-button-container {
justify-content: end;
display: block;
padding: 20px;
}
.layout-container:not(.expanded) .main-content {
padding-top: 0;
}
.layout-container.expanded .main-content {
display: none;
}
}
.main-content {
grid-area: main-content;
padding: 20px;
}
</style>
+25 -1
View File
@@ -1,4 +1,5 @@
{
"loading": "Laden ...",
"search": {
"searchBar": {
"submit": "Suchen",
@@ -37,5 +38,28 @@
}
}
},
"slogan": "Gebaut zum Suchen."
"slogan": "Gebaut zum Suchen.",
"settings": {
"input": {
"done": {
"title": "Wert speichern",
"ariaLabel": "Speichert den eingegebenen Wert"
}
},
"switch": {
"title": "Wert umschalten",
"ariaLabel": "Schaltet den Wert um"
},
"pages": {
"link": {
"ariaLabel": "Geht zur Einstellungsseite {page}"
}
},
"noSectionSelected": "Kein Einstellungsabschnitt ist ausgewählt.",
"selection": {
"option": {
"ariaLabel": "Wählt die Option {option} aus"
}
}
}
}
+25 -1
View File
@@ -1,4 +1,5 @@
{
"loading": "Loading ...",
"search": {
"searchBar": {
"submit": "Search",
@@ -37,5 +38,28 @@
}
}
},
"slogan": "Built to search."
"slogan": "Built to search.",
"settings": {
"input": {
"done": {
"title": "Save value",
"ariaLabel": "Saves the entered value"
}
},
"switch": {
"title": "Toggle value",
"ariaLabel": "Toggles the value"
},
"pages": {
"link": {
"ariaLabel": "Goes to settings page {page}"
}
},
"noSectionSelected": "No settings section is selected.",
"selection": {
"option": {
"ariaLabel": "Selects option {option}"
}
}
}
}
+25 -1
View File
@@ -1,4 +1,5 @@
{
"loading": "Cargando ...",
"search": {
"searchBar": {
"submit": "Buscar",
@@ -37,5 +38,28 @@
}
}
},
"slogan": "Hecho para buscar."
"slogan": "Hecho para buscar.",
"settings": {
"input": {
"done": {
"title": "Guardar valor",
"ariaLabel": "Guarda el valor introducido"
}
},
"switch": {
"title": "Cambiar valor",
"ariaLabel": "Cambia el valor"
},
"pages": {
"link": {
"ariaLabel": "Va a la página de configuración {page}"
}
},
"noSectionSelected": "No se ha seleccionado ninguna sección de configuración.",
"selection": {
"option": {
"ariaLabel": "Selecciona la opción {option}"
}
}
}
}
+25 -1
View File
@@ -1,4 +1,5 @@
{
"loading": "Chargement ...",
"search": {
"searchBar": {
"submit": "Rechercher",
@@ -37,5 +38,28 @@
}
}
},
"slogan": "Conçu pour chercher."
"slogan": "Conçu pour chercher.",
"settings": {
"input": {
"done": {
"title": "Enregistrer la valeur",
"ariaLabel": "Enregistre la valeur saisie"
}
},
"switch": {
"title": "Basculer la valeur",
"ariaLabel": "Bascule la valeur"
},
"pages": {
"link": {
"ariaLabel": "Accéder à la page de paramètres {page}"
}
},
"noSectionSelected": "Aucune section des paramètres n'est sélectionnée.",
"selection": {
"option": {
"ariaLabel": "Sélectionne l'option {option}"
}
}
}
}
+25 -1
View File
@@ -1,4 +1,5 @@
{
"loading": "Caricamento ...",
"search": {
"searchBar": {
"submit": "Cerca",
@@ -37,5 +38,28 @@
}
}
},
"slogan": "Costruito per cercare."
"slogan": "Costruito per cercare.",
"settings": {
"input": {
"done": {
"title": "Salva valore",
"ariaLabel": "Salva il valore inserito"
}
},
"switch": {
"title": "Attiva/disattiva valore",
"ariaLabel": "Attiva o disattiva il valore"
},
"pages": {
"link": {
"ariaLabel": "Vai alla pagina delle impostazioni {page}"
}
},
"noSectionSelected": "Nessuna sezione delle impostazioni è selezionata.",
"selection": {
"option": {
"ariaLabel": "Seleziona l'opzione {option}"
}
}
}
}
+25 -1
View File
@@ -1,4 +1,5 @@
{
"loading": "A carregar ...",
"search": {
"searchBar": {
"submit": "Pesquisar",
@@ -37,5 +38,28 @@
}
}
},
"slogan": "Feito para pesquisar."
"slogan": "Feito para pesquisar.",
"settings": {
"input": {
"done": {
"title": "Guardar valor",
"ariaLabel": "Guarda o valor introduzido"
}
},
"switch": {
"title": "Alternar valor",
"ariaLabel": "Alterna o valor"
},
"pages": {
"link": {
"ariaLabel": "Ir para a página de definições {page}"
}
},
"noSectionSelected": "Nenhuma secção de definições está selecionada.",
"selection": {
"option": {
"ariaLabel": "Seleciona a opção {option}"
}
}
}
}
+14 -5
View File
@@ -14,11 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { createApp } from 'vue';
import App from './App.vue';
import { createApp } from 'vue'
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
import App from './App.vue'
import { i18n, loadLanguage, fallbackLocale } from './i18n';
import getCurrentLanguage from './utils/currentLanguage';
import router from './router';
import router from './router'
import './styles/common.css'
import './styles/variables/colors.css'
import './styles/variables/dimensions.css';
import './styles/common.css';
import './styles/variables/colors.css';
@@ -26,9 +31,13 @@ import './styles/variables/colors.css';
(async () => {
await loadLanguage(fallbackLocale);
await loadLanguage(getCurrentLanguage());
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
createApp(App)
.use(router)
.use(i18n)
.use(pinia)
.mount('#app');
})();
})();
+13 -1
View File
@@ -40,7 +40,7 @@ const routes = [
}
},
{
path: '/settings',
path: '/settings/:rest(.*)*',
name: 'settings',
component: SettingsView,
meta: {
@@ -72,4 +72,16 @@ export const updatePageTitle = function updatePageTitle (route) {
};
};
// remove trailing slash(es)
router.beforeEach((route) => {
if (route.path !== '/' && route.path.endsWith('/')) {
return {
path: route.path.replace(/\/+$/, ''),
query: route.query,
hash: route.hash,
replace: true
};
};
});
export default router;
+50 -1
View File
@@ -25,15 +25,64 @@ body {
text-decoration: none;
}
.link:hover:not(.button-link) {
.link:hover:not(.button-link), .link:focus-visible:not(.button-link) {
outline: none;
text-decoration: underline;
}
.button-link {
color: var(--dark);
}
.button-link:hover {
color: var(--dark);
}
.button-link:visited {
color: var(--dark);
}
input {
color: var(--dark);
}
.input {
padding: var(--input-padding);
display: block;
border-radius: calc(var(--input-padding) + 0.5 * var(--input-height) + 1px);
height: var(--input-height);
border: 1px solid var(--border);
background: none;
transition: border-color 0.2s ease;
color: inherit;
font-family: inherit;
font-size: inherit;
}
.input:focus-visible {
border-color: var(--primary-color);
outline: none;
}
.main-content-padding {
padding: var(--main-content-padding);
width: calc(100% - var(--main-content-padding-x) * 2);
}
.button {
background-color: var(--light-bg);
border: none;
cursor: pointer;
font-family: inherit;
font-size: inherit;
color: inherit;
}
.button:focus-visible {
background-color: var(--light-hover);
outline: none;
}
.button:hover {
background-color: var(--light-hover);
}
+4
View File
@@ -0,0 +1,4 @@
:root {
--input-padding: 0.7em;
--input-height: 1em;
}
+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.
*/
import { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createI18n } from 'vue-i18n';
import { createMemoryHistory, createRouter } from 'vue-router';
export const mountComponent = function mountComponent (component, options = {}, i18nMessages = {}, routes = [], piniaOptions = {}) {
const {
setupStores,
...testingPiniaOptions
} = piniaOptions;
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: i18nMessages
}
});
const router = createRouter({
history: createMemoryHistory(),
routes
});
const testingPinia = createTestingPinia({
...testingPiniaOptions,
stubActions: false,
createSpy: vi.fn
});
if (setupStores) {
setupStores();
}
return mount(component, {
global: {
plugins: [
i18n,
router,
testingPinia
],
},
...options
});
};