420 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
jakob.scheid 55164c3b2f Merge branch 'fix/update-title-on-language-change'
Deploy on dev / Deploy on dev (push) Successful in 38s
2026-07-28 19:55:22 +02:00
jakob.scheid b245aa4a39 feat: update the page title using a watcher
Added a watcher (watchEffect) to App.vue to update the page title
always when a dependency changes. The afterEach hook was removed from
the router.
2026-07-28 19:55:12 +02:00
jakob.scheidandGitea 5b93f547f3 Merge pull request 'Feat: update the page title using a watcher' (#148) from fix/update-title-on-language-change into main
Deploy on dev / Deploy on dev (push) Successful in 38s
Reviewed-on: #148
Reviewed-by: Jakob Gregory
2026-07-28 19:50:52 +02:00
jakob.scheid 3e30e85354 feat: update the page title using a watcher
Added a watcher (watchEffect) to App.vue to update the page title
always when a dependency changes. The afterEach hook was removed from
the router.
2026-07-28 19:50:19 +02:00
jakob.scheidandGitea f4d8952c54 Merge pull request 'Feat(color-scheme): set the color scheme on the document body' (#149) from feature/remove-top-offset-on-mobile into main
Deploy on dev / Deploy on dev (push) Successful in 37s
Reviewed-on: #149
Reviewed-by: Jakob Gregory
2026-07-28 19:47:47 +02:00
jakob.scheid 2206b9952b feat(color-scheme): set color scheme on body
The color scheme is now set on the document body. The reactivity is
ensured by a watcher on the color scheme ref. The color CSS variables
are now set on the body.
2026-07-28 19:46:18 +02:00
jakob.scheidandGitea 1b7b7cbc68 Merge pull request 'Chore(licenses): add scripts to generate the license files' (#146) from chore/scripts-to-generate-license-files into main
Deploy on dev / Deploy on dev (push) Successful in 35s
Reviewed-on: #146
Reviewed-by: Jakob Gregory
2026-07-28 19:37:01 +02:00
jakob.scheidandGitea 3bcd71d60c chore(licenses): update third-party licenses
Updated the third-party licenses using the new license files script.
2026-07-28 19:36:43 +02:00
jakob.scheidandGitea cc7a562887 docs(readme): add reference to the license script 2026-07-28 19:36:43 +02:00
jakob.scheidandGitea 3f40e2142c chore(licenses): add script to generate the license files
Added a script to generate the license files for the development
dependencies and the production dependencies.
2026-07-28 19:36:43 +02:00
jakob.scheidandGitea 2917d04ffe Merge pull request 'Refactor: merge two rules for #app-wrapper into one' (#151) from refactor/merge-two-app-wrapper-css-rules into main
Deploy on dev / Deploy on dev (push) Successful in 38s
Reviewed-on: #151
Reviewed-by: Jakob Gregory
2026-07-28 19:36:03 +02:00
jakob.scheid f1430622dd refactor: merge two rules for #app-wrapper
Merged the two CSS rules for #app-wrapper into one rule.
2026-07-28 18:51:38 +02:00
jakob.scheidandGitea d5f80eba67 Merge pull request 'Load the default language on startup' (#133) from fix/load-default-language into main
Deploy on dev / Deploy on dev (push) Successful in 37s
Reviewed-on: #133
Reviewed-by: Jakob Scheid
2026-07-21 19:59:06 +02:00
jakob.gregoryandGitea f343d99406 fix(i18n): always load fallback language on startup 2026-07-21 19:19:31 +02:00
jakob.gregory 2060134563 Merge branch 'main' into fix/load-default-language 2026-07-21 19:14:51 +02:00
jakob.scheidandGitea 3e7f3d3c14 Merge pull request 'Chore: commit message template' (#132) from chore/commit-template-and-setup-script into main
Deploy on dev / Deploy on dev (push) Successful in 41s
Reviewed-on: #132
Reviewed-by: Jakob Gregory <7+jakob.gregory@noreply.localhost>
2026-07-21 19:10:29 +02:00
jakob.gregoryandGitea d4363f7515 fix(i18n): load default language on startup 2026-07-21 19:08:26 +02:00
jakob.scheid 80dec73fb0 chore: add setup scripts 2026-07-21 19:02:31 +02:00
jakob.scheid 8faace292e chore: add commit template 2026-07-21 18:56:58 +02:00
jakob.scheidandGitea 7c858d53d8 Merge pull request 'Set theme color to the primary color' (#126) from feature/theme-color into main
Deploy on dev / Deploy on dev (push) Successful in 41s
Reviewed-on: #126
Reviewed-by: Jakob Scheid
2026-07-20 22:12:47 +02:00
jakob.gregoryandGitea 2c10be6b99 feat(global): set theme color to primary color 2026-07-20 19:17:23 +02:00
jakob.scheidandGitea 44a5d326ac Merge pull request 'Refactor(style): remove unused stylesheet' (#125) from refactor/remove-unnecessary-stylesheet into main
Deploy on dev / Deploy on dev (push) Successful in 38s
Reviewed-on: #125
Reviewed-by: Jakob Scheid <1+jakob.scheid@noreply.localhost>
2026-07-20 19:04:07 +02:00
jakob.gregoryandGitea e00d3a1d41 Remove old and unused stylesheet 2026-07-20 18:51:23 +02:00
jakob.scheidandGitea d8a97678cd Merge pull request 'Swap Italian and Portuguese in the available languages array' (#121) from feature/update-languages-order into main
Deploy on dev / Deploy on dev (push) Successful in 39s
Reviewed-on: #121
Reviewed-by: Johannes D. Vos
2026-07-01 13:39:54 +02:00
jakob.scheidandGitea 8d690ac875 feat(i18n): swap Italian and Portuguese in the available languages array
This increases the accessibility of the languages selection.
2026-07-01 13:36:54 +02:00
jakob.scheidandGitea 235cf04b09 Merge pull request 'Fix top-level await in main.js' (#119) from chore/Old-browser-incompatibility into main
Deploy on dev / Deploy on dev (push) Successful in 42s
Reviewed-on: #119
Reviewed-by: Jakob Scheid
2026-06-19 14:59:28 +02:00
Johannes D. Vos 8a0d4ac949 fix(main): use async IIFE instead of top-level await 2026-06-19 14:55:51 +02:00
Johannes D. Vos 5740b354f9 fix(main): remove top-level await and fix bootstrap flow 2026-06-19 14:49:22 +02:00
jakob.scheidandGitea eea417013d Merge pull request 'Allow only sections as first-level settings' (#117) from feature/restrict-first-level-settings into main
Deploy on dev / Deploy on dev (push) Successful in 39s
Reviewed-on: #117
Reviewed-by: Jakob Gregory
2026-06-05 12:29:51 +02:00
jakob.scheid 192f3c37a9 feat(settings): add test cases to test that only sections are allowed as first-level settings 2026-06-05 11:37:17 +02:00
jakob.scheid b729b85ad0 feat(settings): allow only sections as first-level settings 2026-06-05 11:35:41 +02:00
jakob.scheidandGitea a9f805e1fd Merge pull request 'Fix font scaling on Not Found page by removing vw units' (#115) from feature/fix-not-found-font-scaling into main
Deploy on dev / Deploy on dev (push) Successful in 39s
Reviewed-on: #115
Reviewed-by: Jakob Scheid
2026-06-04 18:54:44 +02:00
Johannes D. Vos af418197a4 style(not-found): replace 2vw font size with stable 1.75rem dimension 2026-06-04 18:41:15 +02:00
jakob.scheidandGitea 8a718b4bab Merge pull request 'Add settings validator unit test' (#113) from testing/settings-validator into main
Deploy on dev / Deploy on dev (push) Successful in 42s
Reviewed-on: #113
Reviewed-by: Jakob Gregory
2026-06-04 17:48:08 +02:00
jakob.scheidandGitea 1473dcf060 test(settings): add test cases for the selection setting allowMultiple property to the settings validator unit test 2026-06-04 17:27:42 +02:00
jakob.scheidandGitea 8702648624 test(settings): add unit test for the settings validator validateSettingsConfig function 2026-06-04 17:27:42 +02:00
jakob.scheidandGitea 88819f5684 test(settings): add test cases for the settings validator validateSelectionOptions function for test cases that should throw an error 2026-06-04 17:27:42 +02:00
jakob.scheidandGitea 98940eeec4 test(settings): add test cases for the settings validator validateEntry function for test cases that should throw an error 2026-06-04 17:27:42 +02:00
jakob.scheidandGitea 775e206322 fix(settings): check selection default value in the settings validator 2026-06-04 17:27:42 +02:00
jakob.scheidandGitea 7407366f45 fix(settings): check the selection options and allowMultiple before and not after the default check in the settings validator 2026-06-04 17:27:42 +02:00
jakob.scheidandGitea deaf3935c9 fix(settings): fix indentation in validateEntry in the settings validator 2026-06-04 17:27:42 +02:00
jakob.scheidandGitea 5f07e66915 fix(settings): check the selection options and allowMultiple after and not in the default check in the settings validator 2026-06-04 17:27:42 +02:00
jakob.scheidandGitea 122663e2e3 fix(settings): check the name and i18n before the checks for a section in validateEntry in the settings validator 2026-06-04 17:27:42 +02:00
jakob.scheidandGitea 5c8073d264 test(settings): add unit test for the settings validator validateEntry function for test cases that should throw an error 2026-06-04 17:27:42 +02:00
jakob.scheidandGitea 970f4a74d1 fix(settings): do not enforce allowMultiple set for selection settings 2026-06-04 17:27:42 +02:00
jakob.scheidandGitea ecf9074522 test(settings): add unit test for the settings validator validateEntry function for test cases that should not throw an error 2026-06-04 17:27:42 +02:00
jakob.scheidandGitea ea87e0832a test(settings): add unit test for the settings validator validateSelectionOptions function for test cases that should throw an error 2026-06-04 17:27:42 +02:00
jakob.scheidandGitea f4121bf419 test(settings): add unit test for the settings validator validateSelectionOptions function for test cases that should not throw an error 2026-06-04 17:27:42 +02:00
jakob.scheidandGitea bf317eea35 test(settings): update test description for the unit test for the settings validator assertString function for test cases that should throw an error 2026-06-04 17:27:42 +02:00
jakob.scheidandGitea 948d6d41e8 test(settings): add unit test for the settings validator assertType function for test cases that should throw an error 2026-06-04 17:27:42 +02:00
jakob.scheidandGitea 867b3a41f8 test(settings): add unit test for the settings validator assertType function for test cases that should not throw an error 2026-06-04 17:27:42 +02:00
jakob.scheidandGitea e0268b6e6c test(settings): add unit test for the settings validator assertString function for test cases that should not throw an error 2026-06-04 17:27:42 +02:00
jakob.scheidandGitea 156b3b552c test(settings): add unit test for the settings validator assertString function for test cases that should throw an error 2026-06-04 17:27:42 +02:00
jakob.scheidandGitea 39e6c94d09 test(settings): Add settings validator utility test boilerplate 2026-06-04 17:27:42 +02:00
jakob.scheidandGitea 76fda9e5c6 Merge pull request 'Fix centering of search bar on small screens' (#112) from bug/center-searchbar into main
Deploy on dev / Deploy on dev (push) Successful in 37s
Reviewed-on: #112
Reviewed-by: Jakob Scheid
2026-06-04 15:33:15 +02:00
jakob.scheid d9c19b252e fix(search-bar): re-add left padding 2026-06-04 15:32:46 +02:00
johannes.vos 91fa06898f style(searchbar): apply internal text indentation to the input field 2026-06-04 14:19:34 +02:00
johannes.vos 6d7dd7dc40 style(searchbar): make wrapper padding symmetric and set width to 100% 2026-06-04 14:19:04 +02:00
jakob.scheidandGitea 5122d93072 Merge pull request 'Language switch button unit test' (#108) from testing/language-switch into main
Deploy on dev / Deploy on dev (push) Successful in 38s
Reviewed-on: #108
Reviewed-by: Jakob Scheid
2026-06-04 13:46:17 +02:00
jakob.scheid dc4c47e2ef test(language-switch-button): remove redundant test file 2026-06-04 13:45:22 +02:00
jakob.scheid a5dede3485 Merge remote-tracking branch 'refs/remotes/origin/testing/language-switch' into testing/language-switch 2026-06-04 13:44:26 +02:00
jakob.scheid eb892d9725 test(language-switch-button): move test file to the correct directory 2026-06-04 13:43:55 +02:00
johannes.vosandGitea 27f696247d test: move language switch test, fix header comments and code indentation 2026-06-04 13:43:07 +02:00
johannes.vosandGitea 3a07b0482f chore: add @vue/test-utils to devDependencies 2026-06-04 13:43:07 +02:00
johannes.vosandGitea 88634e10cd validate dropdown closes on click outside the component 2026-06-04 13:43:07 +02:00
johannes.vosandGitea 98cc6b7556 assert language selection updates localStorage and HTML attributes 2026-06-04 13:43:07 +02:00
johannes.vosandGitea 0bb0f23347 verify initial closed state and toggle opening of dropdown 2026-06-04 13:43:07 +02:00
johannes.vosandGitea 02af96a07d initialize language switch test suite with beforeEach setup 2026-06-04 13:43:07 +02:00
johannes.vosandGitea a6194bb874 mock i18n and icon dependencies for language switch 2026-06-04 13:43:07 +02:00
johannes.vosandGitea d6700d6965 add imports 2026-06-04 13:43:07 +02:00
johannes.vosandGitea 54ffaf73e3 add license 2026-06-04 13:43:07 +02:00
johannes.vosandGitea ebe21f7f4d add LanguageSwitchButton.test.js file - empty 2026-06-04 13:43:07 +02:00
jakob.scheidandGitea 37515268d7 Merge pull request 'Fix navbar alignment on mobile devices' (#110) from feature/fix-navbar-alignment-on-mobile-devices into main
Deploy on dev / Deploy on dev (push) Successful in 32s
Reviewed-on: #110
Reviewed-by: Jakob Gregory <7+jakob.gregory@noreply.localhost>
Reviewed-by: Jakob Scheid
2026-06-04 13:40:29 +02:00
jakob.scheidandGitea c919263c36 feat(navbar-padding): use the responsivity of the main content padding CSS variables 2026-06-04 13:39:58 +02:00
jakob.scheidandGitea 592eb7222f move main content padding CSS variables to the app wrapper 2026-06-04 13:39:58 +02:00
johannes.vosandGitea 98579b726b style(nav): reduce navbar horizontal padding on small screens for better alignment 2026-06-04 13:39:58 +02:00
johannes.vos 918d6768e8 test: move language switch test, fix header comments and code indentation 2026-06-04 13:34:48 +02:00
johannes.vos 924698df24 chore: add @vue/test-utils to devDependencies 2026-06-04 13:33:46 +02:00
jakob.scheidandGitea 4e6c94afc8 Merge pull request 'Fix spacing between Seekra logo and slogan' (#109) from feature/vertical-spacing-slogan-logo into main
Deploy on dev / Deploy on dev (push) Successful in 32s
Reviewed-on: #109
Reviewed-by: Jakob Gregory
2026-06-04 13:22:17 +02:00
johannes.vos f2f7e9e23c remove unnecesary empty code lines 2026-06-04 13:16:31 +02:00
johannes.vos 08949d5bb4 remove @media (max-width: 48rem) - unneccesary 2026-06-04 13:13:58 +02:00
johannes.vos 65153db414 style(search): implement responsive mobile spacing for slogan via media query 2026-06-04 13:11:55 +02:00
johannes.vos 58e4640965 style(search): add desktop vertical spacing and fix line-height for slogan 2026-06-04 13:10:43 +02:00
johannes.vos 521f1becb4 validate dropdown closes on click outside the component 2026-06-04 12:59:59 +02:00
johannes.vos dc1caac2ed assert language selection updates localStorage and HTML attributes 2026-06-04 12:59:41 +02:00
johannes.vos e8cb978a61 verify initial closed state and toggle opening of dropdown 2026-06-04 12:59:08 +02:00
johannes.vos 1c3175a3e4 initialize language switch test suite with beforeEach setup 2026-06-04 12:58:47 +02:00
johannes.vos a97ee3c660 mock i18n and icon dependencies for language switch 2026-06-04 12:58:20 +02:00
johannes.vos baa1f6ed44 add imports 2026-06-04 12:56:46 +02:00
johannes.vos d5b76b0a17 add license 2026-06-04 12:51:24 +02:00
johannes.vos 5c3d7ce535 add LanguageSwitchButton.test.js file - empty 2026-06-04 12:50:35 +02:00
jakob.scheidandGitea 072f3472f7 Merge pull request 'Add current language utility unit test' (#106) from testing/current-language-util into main
Deploy on dev / Deploy on dev (push) Successful in 30s
Reviewed-on: #106
Reviewed-by: Johannes D. Vos
2026-06-04 12:41:54 +02:00
jakob.scheidandGitea 7d15d29409 test(current-language-util): shorten test description 2026-06-04 12:41:25 +02:00
jakob.scheidandGitea 8ce315e1c7 test(current-language-util): add local storage language test cases 2026-06-04 12:41:25 +02:00
jakob.scheidandGitea 33dfee951a test(current-language-util): set local storage item 'locale' to the local storage language if it is specified 2026-06-04 12:41:25 +02:00
jakob.scheidandGitea 80bc8339ed test(current-language-util): add test cases localStorageLanguage key 2026-06-04 12:41:25 +02:00
jakob.scheidandGitea 12f9d85841 test(current-language-util): remove local storage language test cases 2026-06-04 12:41:25 +02:00
jakob.scheidandGitea a78fa93b51 test(current-language-util): use objects for test cases 2026-06-04 12:41:25 +02:00
jakob.scheidandGitea 06c5e9b2c1 test(current-language-util): add local storage languages array 2026-06-04 12:41:25 +02:00
jakob.scheidandGitea bf3e6b2e2e refactor(current-language-util): move the locales array to a separate const in the test 2026-06-04 12:41:25 +02:00
jakob.scheidandGitea b58ee95ad2 fix(current-language-util): use the full locale instead of the language for the testing environment 2026-06-04 12:41:25 +02:00
jakob.scheidandGitea 093d5840fc test(current-language-util): update test description 2026-06-04 12:41:25 +02:00
jakob.scheidandGitea 352bd5baf5 test(current-language-util): add actual test 2026-06-04 12:41:25 +02:00
jakob.scheidandGitea 2117c28606 test(current-language-util): add navigator.language configuration 2026-06-04 12:41:25 +02:00
jakob.scheidandGitea 7f01a807b9 test(current-language-util): add test cases with extension 2026-06-04 12:41:25 +02:00
jakob.scheidandGitea a7babaf94b test(current-language-util): add test cases with writing system 2026-06-04 12:41:25 +02:00
jakob.scheidandGitea a14318c4c5 test(current-language-util): add test cases with country code 2026-06-04 12:41:25 +02:00
jakob.scheidandGitea c3c7fdb945 test(current-language-util): add simple test cases 2026-06-04 12:41:25 +02:00
jakob.scheidandGitea 1418e52cbf test(current-language-util): add test boilerplate 2026-06-04 12:41:25 +02:00
jakob.scheidandGitea 2d3d2457cf Merge pull request 'Add CSS dimensions utility unit test' (#107) from testing/css-dimensions-util into main
Deploy on dev / Deploy on dev (push) Successful in 36s
Reviewed-on: #107
Reviewed-by: Johannes D. Vos
2026-06-04 12:41:14 +02:00
jakob.scheid 6c12550ee7 test(css-dimensions-util): add test cases with complex dimensions 2026-06-04 08:55:59 +02:00
jakob.scheid 9ec2d7fd6f test(css-dimensions-util): add test cases with numbers and units as dimensions 2026-06-04 08:50:15 +02:00
jakob.scheid 975e84be46 test(css-dimensions-util): add test cases with numbers as dimensions 2026-06-04 08:50:04 +02:00
jakob.scheid a365322a5b test(css-dimensions-util): add test cases with empty dimensions 2026-06-03 23:43:48 +02:00
jakob.scheid 67fc878575 test(css-dimensions-util): add actual test 2026-06-03 23:40:59 +02:00
jakob.scheid 008e38e54a test(css-dimensions-util): add test boilerplate 2026-06-03 23:40:14 +02:00
jakob.scheid a0d592d26e test(css-dimensions-util): add test boilerplate 2026-06-03 23:38:04 +02:00
jakob.scheid 0b6adfa41d test(css-dimensions-util): add empty test file 2026-06-03 23:36:17 +02:00
jakob.scheidandGitea dc557afbc2 Merge pull request 'Set main content bottom padding' (#104) from feature/main-content-margin-bottom into main
Deploy on dev / Deploy on dev (push) Successful in 33s
Reviewed-on: #104
Reviewed-by: Jakob Gregory
2026-06-02 21:07:51 +02:00
jakob.scheid 88d419fd17 feat(main-content-bottom-margin): remove main content class bottom padding 0 2026-06-02 17:41:24 +02:00
jakob.scheidandGitea 37bba31fd4 Merge pull request 'Position the search results error message statically instead of absolutely' (#101) from fix/search-results-error-message into main
Deploy on dev / Deploy on dev (push) Successful in 34s
Reviewed-on: #101
Reviewed-by: Johannes D. Vos
2026-06-02 13:19:35 +02:00
jakob.scheidandGitea ca70d46df3 fix(search-results-error-message): fix width 2026-06-01 18:54:13 +02:00
jakob.scheidandGitea 2a0b5c9b86 fix(search-results-error-message): remove absolute positioning 2026-06-01 18:54:13 +02:00
jakob.scheidandGitea 316a8fd56f Merge pull request 'Move language switch button to the footer' (#100) from feature/language-switch-in-footer into main
Deploy on dev / Deploy on dev (push) Successful in 34s
Reviewed-on: #100
Reviewed-by: Johannes D. Vos
2026-06-01 18:50:48 +02:00
jakob.scheidandGitea 7bf44ca720 feat(language-switch-in-footer): show the dropdown menu above instead of below the button 2026-06-01 18:50:14 +02:00
jakob.scheidandGitea 53c7cb1c79 feat(language-switch-in-footer): use CSS variable for the language switch button vertical padding 2026-06-01 18:50:14 +02:00
jakob.scheidandGitea 368747f323 feat(language-switch-in-footer): add footer segment flexbox gap 2026-06-01 18:50:14 +02:00
jakob.scheidandGitea a46a4da005 feat(language-switch-in-footer): make footer segment class flexbox 2026-06-01 18:50:14 +02:00
jakob.scheidandGitea 5e15c01ed6 feat(language-switch-in-footer): add language switch button component to the footer 2026-06-01 18:50:14 +02:00
jakob.scheidandGitea c64539539d feat(language-switch-in-footer): remove language switch button component from the navbar 2026-06-01 18:50:14 +02:00
jakob.scheidandGitea fea440edaa Merge pull request 'Show search bar below the navbar on the search results view on small screens' (#99) from feature/place-search-bar-below-navbar into main
Deploy on dev / Deploy on dev (push) Successful in 35s
Reviewed-on: #99
Reviewed-by: Johannes D. Vos
2026-06-01 18:49:58 +02:00
jakob.scheid 47744e7be6 feat(place-search-bar-below-navbar): add horizontal padding to the search bar wrapper in the navbar (on large screens) 2026-06-01 17:49:27 +02:00
jakob.scheid 6a31373b7d feat(place-search-bar-below-navbar): set width of the search bar wrapper in the navbar (on large screens) 2026-06-01 17:48:50 +02:00
jakob.scheid a824ee57df feat(place-search-bar-below-navbar): add padding to the search bar wrapper below the navbar (on small screens) 2026-06-01 17:45:20 +02:00
jakob.scheid 9ed8790649 feat(place-search-bar-below-navbar): show search bar wrapper component below the navbar on small screens 2026-06-01 17:44:06 +02:00
jakob.scheid dd678fb393 set search form width to 100% 2026-06-01 17:39:11 +02:00
jakob.scheid 17570d400d feat(place-search-bar-below-navbar): hide navbar search bar wrapper component on small screens 2026-06-01 15:26:47 +02:00
jakob.scheid cabebfb9fe feat(place-search-bar-below-navbar): use navbar search bar wrapper component in the navbar 2026-06-01 15:23:57 +02:00
jakob.scheid 9b175e3923 feat(place-search-bar-below-navbar): add search bar model to the navbar search bar wrapper component 2026-06-01 15:23:35 +02:00
jakob.scheid 873bd74804 feat(place-search-bar-below-navbar): show search bar in the navbar search bar wrapper component only when the search results view is active 2026-06-01 15:20:30 +02:00
jakob.scheid 0df77ef6ee feat(place-search-bar-below-navbar): integrate search bar in the navbar search bar wrapper component 2026-06-01 15:17:32 +02:00
jakob.scheid 4daf550568 feat(place-search-bar-below-navbar): add empty navbar search bar wrapper component 2026-06-01 15:16:09 +02:00
jakob.scheidandGitea f7a76d5d20 Merge pull request 'Make the search bar on the start page fully wide on small screens' (#94) from feature/fully-wide-search-bar-on-startpage-on-small-screens into main
Deploy on dev / Deploy on dev (push) Successful in 35s
Reviewed-on: #94
Reviewed-by: Johannes D. Vos
2026-06-01 15:00:41 +02:00
jakob.scheid 34af4c874f add ARIA label to the search submit button 2026-06-01 14:34:11 +02:00
jakob.scheid c98e8d1f96 add title to the search submit button 2026-06-01 14:33:55 +02:00
jakob.scheid a2268ea275 adapt the color of the magnifying glass search icon to the color scheme 2026-06-01 14:23:05 +02:00
jakob.scheid 720fbd5697 support old browsers 2026-06-01 14:15:30 +02:00
jakob.scheid ba754771f4 Center magnifying glass icon on the search submit button 2026-06-01 13:45:53 +02:00
jakob.scheid b0bf852e26 replace 'search' text on the search submit button with a magnifying glass icon 2026-06-01 13:44:03 +02:00
jakob.scheid 83351110b3 add magnifying glass icon 2026-06-01 13:35:48 +02:00
jakob.scheid a30703dc3e feat(fully-wide-search-bar): Reduce main content horizontal padding on small screens 2026-06-01 13:29:36 +02:00
jakob.scheidandGitea f480794817 Merge pull request 'Use icons instead of Unicode characters' (#91) from feature/icons into main
Deploy on dev / Deploy on dev (push) Successful in 33s
Reviewed-on: #91
Reviewed-by: Johannes D. Vos
2026-06-01 12:28:35 +02:00
jakob.scheidandGitea 0e9e0ecec7 feat(icons): use icon at the language switching button 2026-06-01 12:28:20 +02:00
jakob.scheidandGitea 54090751a5 feat(icons): use icons at the color scheme button 2026-06-01 12:28:20 +02:00
jakob.scheidandGitea 274d25d654 feat(icons): Invert icons according to the color scheme 2026-06-01 12:28:20 +02:00
jakob.scheidandGitea 35db1d65f2 Add --invert CSS variable for the invertion (according to the color scheme) 2026-06-01 12:28:20 +02:00
jakob.scheidandGitea 20e350d88b Add icon component <img> flexbox wrapper 2026-06-01 12:28:20 +02:00
jakob.scheidandGitea 534734ce9a Ensure unit of the size prop in the icon component 2026-06-01 12:28:20 +02:00
jakob.scheidandGitea 1679800272 Add utility function to ensure that a CSS dimension has a unit 2026-06-01 12:28:20 +02:00
jakob.scheidandGitea bf3786249b Add icon component prop to specify the size of the icon 2026-06-01 12:28:20 +02:00
jakob.scheidandGitea f24749423b Add icon component 2026-06-01 12:28:20 +02:00
jakob.scheidandGitea b617078e58 Add black and white circle icon 2026-06-01 12:28:20 +02:00
jakob.scheidandGitea c5467620c5 Add sun icon 2026-06-01 12:28:20 +02:00
jakob.scheidandGitea fc4584f932 Add crescent moon icon 2026-06-01 12:28:20 +02:00
jakob.scheidandGitea da319c28d5 Add chevron down icon 2026-06-01 12:28:20 +02:00
jakob.scheidandGitea 6f2aaa62f0 Merge pull request 'Add changelog' (#92) from docs/changelog into main
Deploy on dev / Deploy on dev (push) Successful in 35s
Reviewed-on: #92
Reviewed-by: Johannes D. Vos
2026-06-01 12:28:05 +02:00
jakob.scheidandGitea 8f65d3ae60 docs(changelog): add 'Added' section to the unreleased release 2026-06-01 12:27:51 +02:00
jakob.scheidandGitea f6d848a714 docs(changelog): add changelog boilerplate 2026-06-01 12:27:51 +02:00
jakob.scheidandGitea 2340a6a193 Merge pull request 'Set up Vitest' (#93) from testing/set-up-vitest into main
Deploy on dev / Deploy on dev (push) Successful in 35s
Reviewed-on: #93
Reviewed-by: Jakob Gregory
2026-06-01 12:26:52 +02:00
jakob.scheid 0dcb6f0821 chore(set-up-vitest): Add testing configuration 2026-06-01 01:01:38 +02:00
jakob.scheid f0dc5d4bdc chore(set-up-vitest): add jsdom dependency 2026-06-01 00:58:14 +02:00
jakob.scheid 59e19c7666 chore(set-up-vitest): add test scripts 2026-06-01 00:55:02 +02:00
jakob.scheid 0373ea20f7 chore(set-up-vitest): add Vitest dependency 2026-06-01 00:53:31 +02:00
jakob.scheidandGitea 3b8c387c44 Merge pull request 'Move the search bar in the search results view into the navigation bar' (#82) from chore/searchbar into main
Deploy on dev / Deploy on dev (push) Successful in 33s
Reviewed-on: #82
Reviewed-by: Jakob Scheid
2026-05-29 18:30:05 +02:00
jakob.scheid d461da90f2 Make search query model cleaner 2026-05-29 18:28:02 +02:00
jakob.scheid 295bfc19e6 Increase navbar vertical padding 2026-05-29 18:16:28 +02:00
jakob.scheid 7a7f698b44 Set navbar height to prevent layout shifting when the search bar is shown when the search results view is visible 2026-05-29 18:13:39 +02:00
jakob.scheid 2fd010ddfa Remove redundant search bar component 2026-05-29 18:08:21 +02:00
jakob.scheidandGitea f72a2bf2b1 Remove unnecessary whitespaces 2026-05-29 16:18:40 +02:00
johannes.vos 926010f128 chore(search-results): clean up unused search props and imports 2026-05-29 15:56:47 +02:00
johannes.vos 98c954e361 feat(navbar): show search input only on search results page 2026-05-29 15:55:38 +02:00
johannes.vos 325551d253 copy/paste searchbar.vue code to searchbar-searchresults.vue (placeholder) 2026-05-29 15:42:09 +02:00
johannes.vos a33bc047fa move Searchbar-SearchResults.vue file to nav/components 2026-05-29 15:41:33 +02:00
johannes.vos 1421789e43 create empty Searchbar-SearchResults.vue file 2026-05-29 15:37:40 +02:00
jakob.scheidandGitea 27a22ce569 Merge pull request 'add settings configuration parser' (#78) from feature/settings-config-parser into main
Deploy on dev / Deploy on dev (push) Successful in 37s
Reviewed-on: #78
Reviewed-by: Jakob Schei <1+jakob.scheid@noreply.localhost>
2026-05-29 14:03:15 +02:00
jakob.scheid b28af20d11 Merge branch 'main' into feature/settings-config-parser 2026-05-29 14:02:59 +02:00
jakob.scheidandGitea c81de2dce3 Merge pull request 'Bug/Link-color' (#81) from bug/link-color into main
Deploy on dev / Deploy on dev (push) Successful in 32s
Reviewed-on: #81
Reviewed-by: Jakob Scheid
2026-05-29 13:22:44 +02:00
johannes.vos 1b60984b5c add style - make links white/black 2026-05-28 21:48:22 +02:00
johannes.vos 733bc2b16a add class to link 2026-05-28 21:47:46 +02:00
jakob.scheidandGitea 11800f6ef4 feat(settings): Remove old JSDoc typedefs 2026-05-26 19:57:36 +02:00
jakob.scheidandGitea b47ab0355e feat(settings): Remove parameter 'url' from the loading function of the settings composable 2026-05-26 19:55:45 +02:00
jakob.scheidandGitea 34a7cb3f2c feat(settings): Make setting default value optional 2026-05-26 19:51:45 +02:00
johannes.vos da76d14d15 WIP 2026-05-26 14:55:18 +02:00
johannes.vos 547bc241da feat(settings): use type field as discriminant in JSDoc type definitions 2026-05-26 14:55:17 +02:00
johannes.vos 4ef6008976 feat(settings): default allowMultiple to false if not specified 2026-05-26 14:55:11 +02:00
johannes.vos 035aa1aa77 feat(settings): replace fetch with dynamic import for settings.json 2026-05-26 14:53:52 +02:00
johannes.vos 462ae00506 feat(settings): move settings.json to src and remove example content 2026-05-26 14:53:52 +02:00
jakob.scheidandGitea 5d2064134b feat(settings): Make setting default value optional 2026-05-26 12:21:54 +02:00
johannes.vos aeb348fcfd feat(settings): add example settings.json to public/ 2026-05-26 11:37:34 +02:00
johannes.vos 37ca0baa6d add emty public/settings.json 2026-05-26 11:36:26 +02:00
johannes.vos a165c13d7c feat(settings): add useSettingsConfig composable 2026-05-26 11:35:35 +02:00
johannes.vos 1378813783 add emtpy useSettingsConfig.js 2026-05-26 11:35:26 +02:00
johannes.vos e376e9b362 feat(settings): add settings config loader/parser 2026-05-26 11:34:56 +02:00
johannes.vos d09514f71f add emtpy settingsParser.js 2026-05-26 11:34:40 +02:00
johannes.vos f6d72dbea3 feat(settings): add settings config validator 2026-05-26 11:34:14 +02:00
johannes.vos 0d7814d655 add emty settingsValidator.js 2026-05-26 11:33:58 +02:00
johannes.vos c387c1486f feat(settings): add JSDoc type definitions for settings config 2026-05-26 11:32:51 +02:00
johannes.vos 878b558603 add emty settingsConfig.js 2026-05-26 11:31:41 +02:00
jakob.scheidandGitea 0251d5c577 Merge pull request 'Restructure assets directory' (#76) from refactor/restructure-assets-directory into main
Deploy on dev / Deploy on dev (push) Successful in 32s
Reviewed-on: #76
Reviewed-by: Jakob Gregory
2026-05-23 13:29:04 +02:00
jakob.scheid 280dd9d7c0 Remove .gitkeep in the assets directory 2026-05-23 13:21:38 +02:00
jakob.scheid 8e2fc6bfa5 Move image assets into a subdirectory for images in the assets directory 2026-05-23 13:21:21 +02:00
jakob.scheidandGitea 69d752f79b Merge pull request 'Add base settings page' (#74) from feature/base-settings-page into main
Deploy on dev / Deploy on dev (push) Successful in 30s
Reviewed-on: #74
Reviewed-by: Jakob Gregory
2026-05-23 13:16:37 +02:00
jakob.scheid 4d62ca2b75 Merge branch 'main' into feature/base-settings-page 2026-05-23 13:16:26 +02:00
jakob.scheidandGitea f5b88df831 Merge pull request 'Remove license placeholders' (#75) from bugfix/remove-placeholders-in-license-files into main
Deploy on dev / Deploy on dev (push) Successful in 35s
Reviewed-on: #75
Reviewed-by: Jakob Gregory
2026-05-23 13:14:02 +02:00
jakob.scheid bd25409d9f Add license header to left sidebar layout 2026-05-23 12:08:05 +02:00
jakob.scheid fe20a618fe Add license header to sidebar 2026-05-23 12:07:45 +02:00
jakob.scheid 17b49cdb6e Add license header to settings view 2026-05-23 12:07:32 +02:00
jakob.scheid 287d7ad713 Add padding to the settings view header 2026-05-23 12:03:03 +02:00
jakob.scheid d3e4d54c57 Add padding to the sidebar 2026-05-23 11:58:53 +02:00
jakob.scheid ead8821b0a Use semantic HTML elements in the left sidebar layout 2026-05-23 11:57:23 +02:00
jakob.scheid d3e8a0125d Use semantic HTML elements in the sidebar 2026-05-23 11:56:55 +02:00
jakob.scheid 34b7cfbf5a Use semantic HTML elements in the settings view 2026-05-23 11:53:47 +02:00
jakob.scheid 1ebf5780d2 Move main content padding frm the app root component to the views 2026-05-23 11:51:49 +02:00
jakob.scheid f354c1867c Add left sidebar layout top border 2026-05-23 11:46:28 +02:00
jakob.scheid 0583de0dfa Add settings view page wrapper 2026-05-23 11:43:53 +02:00
jakob.scheid 2a5e79b55d Add padding to the main content in the left sidebar layout 2026-05-23 11:42:45 +02:00
jakob.scheid 4df51a970d Include sidebar in the left sidebar layout 2026-05-23 11:42:25 +02:00
jakob.scheid 7013c60a20 Remove top margin from footer 2026-05-23 11:40:58 +02:00
jakob.scheid 3623265ae0 Remove main content bottom padding 2026-05-23 11:40:32 +02:00
jakob.scheid fbefb2e7fd Rename base layout to left sidebar layout 2026-05-23 11:40:03 +02:00
jakob.scheid b0d3258369 Remove main content wrapper 2026-05-23 11:26:06 +02:00
jakob.scheid f65c3d58aa Add containers to components without a content container 2026-05-23 11:25:48 +02:00
jakob.scheid 33e9ca7cf1 Add check whether there is a sidebar component in the base layout 2026-05-23 10:45:28 +02:00
jakob.scheid def3a84e59 Use base layout in the app root component 2026-05-23 10:44:58 +02:00
jakob.scheid b48a487d2f Rename left sidebar layout to base layout 2026-05-23 03:54:53 +02:00
jakob.scheid 76815c6a93 Add sidebar to the settings view 2026-05-23 03:32:32 +02:00
jakob.scheid aa47015d3a Add sidebar right border 2026-05-23 03:30:18 +02:00
jakob.scheid 989664422c Add sidebar container 2026-05-23 03:28:59 +02:00
jakob.scheid 2ad95385d2 Add CSS grid container in the left sidebar layout 2026-05-23 03:27:44 +02:00
jakob.scheid b00aaabbb1 Add sidebar component slot 2026-05-23 03:13:52 +02:00
jakob.scheid 5104c4e8e7 Add left sidebar layout content 2026-05-23 03:13:34 +02:00
jakob.scheid d1c428eccd Rename file 2026-05-23 03:10:37 +02:00
jakob.scheid ed2b3224b4 Remove sidebar section component 2026-05-23 03:09:15 +02:00
jakob.scheid 6cc94b651f Bugfix: Fix spelling mistake in the sidebar section component 2026-05-23 03:07:40 +02:00
jakob.scheid 16ee164d76 Add sidebar section boilerplate 2026-05-23 02:41:04 +02:00
jakob.scheid 2989817278 Use sidebar component in left sidebar layout 2026-05-23 02:38:56 +02:00
jakob.scheid 0a65bcdae1 Add left sidebar layout boilerplate 2026-05-23 02:36:33 +02:00
jakob.scheid d5601b8fdb Add sidebar boilerplate 2026-05-23 02:35:16 +02:00
jakob.scheid be96541b3e Replace hard-coded settings link text in the footer with a translation 2026-05-23 02:29:06 +02:00
jakob.scheid ad8bbfa666 Update footer flexbox 2026-05-23 02:27:43 +02:00
jakob.scheid b324b93141 Reduce footer height 2026-05-23 02:25:58 +02:00
jakob.scheid 7bcdf77ca8 Add link to settings page in the footer 2026-05-23 02:25:36 +02:00
jakob.scheid 6726600c8e Add settings view heading 2026-05-23 02:22:40 +02:00
jakob.scheid 027ee2d191 Add settings route 2026-05-23 02:21:37 +02:00
jakob.scheid 6d5c7e4270 Add translations for 'settings' 2026-05-23 02:17:53 +02:00
jakob.scheid 8901967ab0 Add settings view boilerplate 2026-05-23 02:13:41 +02:00
jakob.scheid e177ed279c Remove license placeholder in the search results view 2026-05-23 02:10:02 +02:00
jakob.scheid 923942f1b1 Remove license placeholder in the footer 2026-05-23 02:09:39 +02:00
jakob.scheid 539991fac7 Remove license placeholder in the not found view 2026-05-23 02:09:18 +02:00
jakob.scheid 9e049bc5c9 Remove license placeholder in the logo graphic 2026-05-23 02:09:07 +02:00
jakob.scheidandGitea 495c2379ce Merge pull request 'Change Colors' (#65) from bug/colors into main
Deploy on dev / Deploy on dev (push) Successful in 31s
Reviewed-on: #65
Reviewed-by: Jakob Scheid
2026-05-23 02:05:53 +02:00
jakob.scheid c50fe25d04 Add CSS variables for light hover background 2026-05-22 21:13:05 +02:00
jakob.scheid 1300ab46a0 Use the --border CSS variable in the language switch button component 2026-05-22 17:51:56 +02:00
jakob.scheid 28fcfa1f92 Use --border variable in the footer 2026-05-22 17:50:13 +02:00
jakob.scheid f808525d56 Merge branch 'main' into bug/colors 2026-05-22 17:46:56 +02:00
jakob.scheid 1c7a0c0e58 Use the CSS variable for dark in the navbar right links 2026-05-22 17:46:35 +02:00
jakob.scheid 0eccd33919 Add CSS variable for blue box shadow 2026-05-22 17:45:46 +02:00
jakob.scheid 1f67e95735 Add CSS variable for gray box shadow 2026-05-22 17:44:06 +02:00
jakob.scheid 3c82baca67 Use variable for the search results view error message background 2026-05-22 17:35:22 +02:00
jakob.scheid 6d95601399 Add CSS variable for borders 2026-05-22 17:33:03 +02:00
jakob.scheid 9aecc195a9 Merge pull request 'Add more Languages and switch button' (#70) from feature/add-languages into main
Deploy on dev / Deploy on dev (push) Successful in 29s
Reviewed-on: #70
Reviewed-by: Jakob Scheid
2026-05-22 17:19:25 +02:00
jakob.scheid 2bf433621a Remove flags from language labels 2026-05-22 17:18:15 +02:00
jakob.scheid abbfd0ad9d Include flags in the language name translations instead of hard-coding it in the language switch button component 2026-05-22 15:20:08 +02:00
jakob.scheid 619d4065b3 Align right navbar links vertically centered 2026-05-22 14:20:49 +02:00
jakob.scheid 3ca938d65b Update border color of language button 2026-05-22 14:19:12 +02:00
jakob.scheid 666ecb4e98 Update border color of language dropdown 2026-05-22 14:17:25 +02:00
jakob.scheid 3af6cb3c16 Remove CSS font-size property 2026-05-21 23:33:22 +02:00
jakob.scheid 006bb5136e Add feature to close the language switching dropdown when the user clicks outside of it 2026-05-21 23:28:42 +02:00
jakob.scheid 258a4025bc Use translations in the translation files instead of hard-coded strings for the language names 2026-05-21 22:32:39 +02:00
jakob.scheid 420f47dca4 Format SUPPORTED_LANGUAGES more readable 2026-05-21 22:06:23 +02:00
jakob.scheid 6f164a0256 Remove unsupported languages from SUPPORTED_LANGUAGES 2026-05-21 22:06:02 +02:00
jakob.scheid b30aea57ad Add list with languages written from right to left 2026-05-21 22:04:58 +02:00
jakob.scheid be19a3c29e Speak English in a comment 2026-05-21 21:59:59 +02:00
jakob.scheid f08acad086 Address the user formally in the German translations 2026-05-21 21:57:05 +02:00
jakob.scheid 90f99e4240 Rename 'src/features/language/' to 'src/features/i18n/' 2026-05-21 15:45:11 +02:00
johannes.vos 46d92675eb save lanugage in local storage 2026-05-20 20:33:07 +02:00
johannes.vos dc532d3848 implement switch button 2026-05-20 20:31:45 +02:00
johannes.vos cb42c9d368 remove lanugages 2026-05-20 20:31:24 +02:00
johannes.vos 6fa3ba6298 add switch button 2026-05-20 20:26:51 +02:00
johannes.vos 7c8cf6406d add content in langauge files 2026-05-20 20:25:26 +02:00
johannes.vos 010d29c74e add emty LangaugeSwitchButton.bue file 2026-05-20 20:22:10 +02:00
johannes.vos 5a04e2a2f1 Add suported languages in i18n.js 2026-05-20 20:20:57 +02:00
johannes.vos a7ff5e2bf4 Add emty langauge json files 2026-05-20 20:17:25 +02:00
johannes.vos 66483a3a6a Add Border 2026-05-19 19:11:27 +02:00
johannes.vos d5c714cbd8 Change Colors of Footer 2026-05-19 19:09:51 +02:00
johannes.vos d8a4c0023b Change Colors of Footer 2026-05-19 19:07:24 +02:00
johannes.vos 5b8c13c8cf Remove Background 2026-05-19 18:59:39 +02:00
johannes.vos b172e3cf2b Change Backgorund Color 2026-05-19 18:57:18 +02:00
johannes.vos 46a2caf845 Make Border of Button visible / lighter in DarkMode 2026-05-19 18:55:01 +02:00
johannes.vos 63d14ddefd Change Black Varbiable to gray 2026-05-19 18:49:00 +02:00
80 changed files with 15547 additions and 246 deletions
+8
View File
@@ -0,0 +1,8 @@
# ------------------------------------------------ SUBJECT AREA
# ---------------------------------------------------------------------- DETAIL AREA
# Please enter a subject that is not longer than 50 characters.
# If you enter a detailed description, each line should not be longer
# than 72 characters. Break the line as soon as you reach this limit.
+8
View File
@@ -1,3 +1,11 @@
# frontend
The frontend of the search engine Seekra.
## Setup
Please execute the setup script in `scripts/` for your platform when cloning the repository.
## Legal
After each update to the dependencies, please run the script `scripts/generate-license-files.js` to regenerate the third-party license files.
+1 -1
View File
@@ -470,7 +470,7 @@ The following npm packages may be included in this product:
- @babel/generator@7.29.1
- @babel/helper-string-parser@7.27.1
- @babel/helper-validator-identifier@7.28.5
- @babel/helper-validator-identifier@7.29.7
- @babel/types@7.29.0
These packages each contain the following license:
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
# Changelog
## [Unreleased]
### Added
- Start page
- Settings page
- Footer
- Navbar
- Searchbar
- Icons
- Internationalization and some major languages
- Color scheme
- Search results view with error message which indicates that the search is not available
- Settings renderer
+1
View File
@@ -19,6 +19,7 @@ limitations under the License.
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0158D2" />
<title>Seekra</title>
</head>
<body>
+1734 -127
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -6,17 +6,25 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"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": {
"@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"
"vite-plugin-html": "^3.2.2",
"vitest": "^4.1.7"
}
}
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env node
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import fs from 'node:fs/promises';
import { generateLicenseFile } from 'generate-license-file';
const packageJson = './package.json';
const packageJsonBackup = `${packageJson}~`;
await generateLicenseFile(
packageJson,
'THIRD_PARTY_LICENSES'
);
const pkg = JSON.parse(
await fs.readFile(packageJson, 'utf-8')
);
await fs.rename(packageJson, packageJsonBackup);
const devPackage = {
...pkg,
dependencies: pkg.devDependencies ?? {},
devDependencies: {}
};
/*
The file including the dependencies for the current license file has
to be always package.json due to a bug of generate-license file. See
https://github.com/TobyAndToby/generate-license-file/issues/752
*/
await fs.writeFile(
packageJson,
JSON.stringify(devPackage)
);
await generateLicenseFile(
packageJson,
'./THIRD_PARTY_LICENSES_DEV'
);
await fs.rename(packageJsonBackup, packageJson);
+17
View File
@@ -0,0 +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
+17
View File
@@ -0,0 +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
+39 -17
View File
@@ -18,45 +18,67 @@ limitations under the License.
import Navbar from './features/nav/components/Navbar.vue';
import Footer from './features/footer/components/Footer.vue';
import { updatePageTitle } from './router';
import { useColorScheme } from './features/colorScheme/composables/useColorScheme';
import { ref, provide, watch } from 'vue';
import { ref, provide, watch, watchEffect } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const { getColorScheme, updateColorScheme } = useColorScheme();
const colorScheme = ref(getColorScheme());
provide('colorScheme', colorScheme);
watch(colorScheme, val => updateColorScheme(val))
watch(colorScheme, (newValue) => {
updateColorScheme(newValue);
document.body.style.setProperty(colorScheme, {
auto: 'normal',
dark: 'dark',
light: 'light'
});
if (newValue === 'dark') {
document.body.classList.add('dark');
} else {
document.body.classList.remove('dark');
}
if (newValue === 'auto') {
document.body.classList.add('color-scheme-auto');
} else {
document.body.classList.remove('color-scheme-auto');
}
});
watchEffect(() => updatePageTitle(route));
</script>
<template>
<div
:style="{ colorScheme: colorScheme === 'auto' ? 'normal' : (colorScheme === 'dark' ? 'dark' : 'light')}"
:class="{ dark: colorScheme === 'dark', 'color-scheme-auto': colorScheme === 'auto' }"
id="app-wrapper"
>
<div id="app-wrapper">
<Navbar />
<div class="main-content">
<router-view />
</div>
<router-view class="main-content" />
<Footer />
</div>
</template>
<style scoped>
.main-content {
#app-wrapper {
--main-content-padding-x: 30px;
--main-content-padding-y: 40px;
padding: var(--main-content-padding-y) var(--main-content-padding-x);
width: calc(100% - var(--main-content-padding-x) * 2);
flex-grow: 1;
}
#app-wrapper {
--main-content-padding: var(--main-content-padding-y) var(--main-content-padding-x);
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: var(--light-bg);
color: var(--dark);
}
.main-content {
flex-grow: 1;
}
@media (max-width: 48rem) {
#app-wrapper {
--main-content-padding-x: 15px;
}
}
</style>
View File
+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="M4 8 l8 8 l8 -8"
/>
</svg>

After

Width:  |  Height:  |  Size: 830 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
fill="currentColor"
d="
M12 2
a10 10 0 0 0 -10 10
a10 10 0 0 0 10 10
a10 10 0 0 0 10 -10
a10 10 0 0 0 -10 -10
"
/>
<path
style="filter: invert(1)"
fill="currentColor"
d="
M12 3
v18
a9 9 0 0 0 9 -9
a9 9 0 0 0 -9 -9
"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

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

After

Width:  |  Height:  |  Size: 1.8 KiB

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

After

Width:  |  Height:  |  Size: 1.0 KiB

+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

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

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,5 +1,5 @@
<!--
Copyright [yyyy] [name of copyright owner]
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.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -15,6 +15,8 @@ limitations under the License.
-->
<script setup>
import Icon from '@/features/icons/components/Icon.vue';
import { inject } from 'vue';
import { useI18n } from 'vue-i18n';
@@ -29,9 +31,9 @@ const colorSchemeNextMapper = {
};
const colorSchemeIconMapper = {
'dark': '',
'light': '',
'auto': ''
'dark': 'crescent-moon',
'light': 'sun',
'auto': 'circle-black-white'
};
const getTooltipTranslation = function (colorScheme) {
@@ -45,14 +47,17 @@ const getTooltipTranslation = function (colorScheme) {
:aria-label="getTooltipTranslation(colorScheme)"
:title="getTooltipTranslation(colorScheme)"
>
{{ colorSchemeIconMapper[colorSchemeNextMapper[colorScheme]] }}
<Icon
:name="colorSchemeIconMapper[colorSchemeNextMapper[colorScheme]]"
size="16"
/>
</button>
</template>
<style scoped>
.color-scheme-button {
background: none;
border: 1.5px solid var(--light-d-3);
border: 1.5px solid var(--border);
border-radius: 50%;
width: 36px;
height: 36px;
@@ -65,6 +70,6 @@ const getTooltipTranslation = function (colorScheme) {
}
.color-scheme-button:hover {
background: var(--light-d-2);
background: var(--light-hover);
}
</style>
+25 -5
View File
@@ -1,5 +1,5 @@
<!--
Copyright [yyyy] [name of copyright owner]
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.
@@ -15,6 +15,12 @@ limitations under the License.
-->
<script setup>
import LanguageSwitchButton from '@/features/i18n/components/LanguageSwitchButton.vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const startYear = 2026
const currentYear = new Date().getFullYear()
@@ -28,7 +34,13 @@ const copyrightPeriod =
<template>
<footer class="global-footer">
<div class="copyright-note">
<div class="footer-segment">
<RouterLink to="/settings" class="link">
{{ t('preferences.settings') }}
</RouterLink>
<LanguageSwitchButton />
</div>
<div class="footer-segment">
&copy; {{ copyrightPeriod }} Seekra
</div>
</footer>
@@ -36,11 +48,19 @@ const copyrightPeriod =
<style scoped>
.global-footer {
--padding-y: 22px;
--padding-y: 16px;
}
.footer-segment {
display: flex;
justify-content: center;
gap: 32px;
padding: var(--padding-y);
background-color: var(--light-d-1);
margin-top: var(--padding-y);
background-color: var(--light-bg);
border-top: 1px solid var(--border);
}
.global-footer a {
color: var(--dark);
}
</style>
@@ -0,0 +1,164 @@
<!--
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 { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { loadLanguage, LANGUAGES_RTL, SUPPORTED_LANGUAGES } from '@/i18n';
const { t, locale } = useI18n();
const isOpen = ref(false);
const languageDropdown = ref(null);
async function selectLanguage(code) {
await loadLanguage(code);
localStorage.setItem('locale', code);
document.documentElement.lang = code;
document.documentElement.dir = LANGUAGES_RTL.includes(code) ? 'rtl' : 'ltr';
close();
};
const close = function () {
document.removeEventListener('click', closeWrapperOnClickOutsite);
isOpen.value = false;
};
const closeWrapperOnClickOutsite = function (e) {
if (languageDropdown.value) {
if (!languageDropdown.value.contains(e.target)) {
close();
};
};
};
const open = function () {
if (!isOpen.value) {
isOpen.value = true;
setTimeout(() => {
document.addEventListener('click', closeWrapperOnClickOutsite);
}, 0);
};
};
</script>
<template>
<div class="language-switch" tabindex="-1">
<button
class="language-button button"
@click="open"
:aria-expanded="isOpen"
aria-haspopup="listbox"
>
<span class="lang-code">{{ t(`preferences.locale.languages.${locale}`) }}</span>
<span class="chevron" :class="{ open: isOpen }">
<Icon name="chevron-down" size="1em" />
</span>
</button>
<ul
v-if="isOpen"
ref="languageDropdown"
class="language-dropdown"
role="listbox"
:style="{
bottom: 'calc(var(--offset) + 2 * var(--trigger-padding-y) + 1em)' // easier to add auto adaption to the available space
}"
>
<li
v-for="lang in SUPPORTED_LANGUAGES"
:key="lang"
role="option"
:aria-selected="lang === locale"
:class="{ active: lang === locale }"
@click="selectLanguage(lang)"
>
<span class="lang-label">{{ t(`preferences.locale.languages.${lang}`) }}</span>
</li>
</ul>
</div>
</template>
<style scoped>
.language-switch {
--trigger-padding-y: 4px;
position: relative;
}
.language-button {
display: flex;
align-items: center;
gap: 6px;
background: none;
border: 1px solid var(--border);
border-radius: 6px;
padding: var(--trigger-padding-y) 10px;
cursor: pointer;
color: var(--dark);
}
.language-button:hover {
background-color: var(--light-hover);
}
.chevron {
font-size: 0.75rem;
transition: transform 0.2s;
display: inline-block;
}
.chevron.open {
transform: rotate(180deg);
}
.language-dropdown {
--offset: 6px;
position: absolute;
right: 0;
background-color: var(--light-bg);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
list-style: none;
margin: 0;
padding: 6px 0;
min-width: 160px;
z-index: 100;
}
.language-dropdown li {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
cursor: pointer;
font-size: 0.9rem;
}
.language-dropdown li:hover {
background-color: var(--light-hover);
}
.language-dropdown li.active {
font-weight: bold;
}
.flag {
font-size: 1.1rem;
}
</style>
@@ -0,0 +1,114 @@
/*
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 { expect, test, describe, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import LanguageSwitchButton from '../LanguageSwitchButton.vue';
import { loadLanguage } from '@/i18n';
vi.mock('@/i18n', () => ({
loadLanguage: vi.fn(() => Promise.resolve()),
LANGUAGES_RTL: ['ar', 'he'],
SUPPORTED_LANGUAGES: ['en', 'de', 'ar']
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key,
locale: { value: 'de' }
})
}));
vi.mock('@/features/icons/components/Icon.vue', () => ({
default: {
name: 'Icon',
template: '<span>Icon</span>'
}
}));
describe('LanguageSwitchButton', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
document.documentElement.lang = '';
document.documentElement.dir = '';
});
test('renders correctly with initial state closed', () => {
const wrapper = mount(LanguageSwitchButton);
expect(wrapper.find('.language-button').exists()).toBe(true);
expect(wrapper.find('.language-dropdown').exists()).toBe(false);
expect(wrapper.find('.language-button').attributes('aria-expanded')).toBe('false');
});
test('opens the dropdown when language button is clicked', async () => {
const wrapper = mount(LanguageSwitchButton);
const button = wrapper.find('.language-button');
await button.trigger('click');
expect(wrapper.find('.language-dropdown').exists()).toBe(true);
expect(button.attributes('aria-expanded')).toBe('true');
});
const languageTestCases = [
{ code: 'en', expectedDir: 'ltr' },
{ code: 'de', expectedDir: 'ltr' },
{ code: 'ar', expectedDir: 'rtl' }
];
test.for(languageTestCases)('selectLanguage($code) sets localStorage, html attributes and changes layout direction to $expectedDir', async ({ code, expectedDir }) => {
const wrapper = mount(LanguageSwitchButton);
await wrapper.find('.language-button').trigger('click');
const options = wrapper.findAll('.language-dropdown li');
const optionToClick = options.find(opt => opt.text().includes(code));
await optionToClick.trigger('click');
expect(loadLanguage).toHaveBeenCalledWith(code);
expect(localStorage.getItem('locale')).toBe(code);
expect(document.documentElement.lang).toBe(code);
expect(document.documentElement.dir).toBe(expectedDir);
expect(wrapper.find('.language-dropdown').exists()).toBe(false);
});
test('closes the dropdown when clicking outside the component', async () => {
const wrapper = mount(LanguageSwitchButton, {
attachTo: document.body
});
await wrapper.find('.language-button').trigger('click');
expect(wrapper.find('.language-dropdown').exists()).toBe(true);
await new Promise(resolve => setTimeout(resolve, 0));
const externalDiv = document.createElement('div');
document.body.appendChild(externalDiv);
const clickEvent = new MouseEvent('click', { bubbles: true });
externalDiv.dispatchEvent(clickEvent);
await wrapper.vm.$nextTick();
expect(wrapper.find('.language-dropdown').exists()).toBe(false);
wrapper.unmount();
externalDiv.remove();
});
});
+54
View File
@@ -0,0 +1,54 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import { computed } from 'vue';
import { ensureUnit } from '@/utils/cssDimensions';
const props = defineProps({
name: {
required: true,
type: String
},
size: {
type: [Number, String],
default: 24
}
})
const icons = import.meta.glob('@/assets/icons/*.svg', {
eager: true,
import: 'default'
})
const Icon = computed(() => icons[`/src/assets/icons/${props.name}.svg`])
const size = computed(() => ensureUnit(props.size))
</script>
<template>
<div class="icon-container">
<img :src="Icon" :style="{ height: `${size}`, width: `${size}` }" />
</div>
</template>
<style scoped>
.icon-container {
display: flex;
align-items: center;
filter: var(--invert);
}
</style>
+40 -10
View File
@@ -16,20 +16,25 @@ limitations under the License.
<script setup>
import ColorSchemeButton from '@/features/colorScheme/components/ColorSchemeButton.vue';
import logo from '@/assets/logo.svg';
import logo from '@/assets/images/logo.svg';
import NavbarSearchBarWrapper from './NavbarSearchBarWrapper.vue';
</script>
<template>
<nav class="global-nav">
<RouterLink to="/" class="link button link">
<img :src="logo" alt="Seekra" class="nav-logo" />
<img :src="logo" alt="Seekra" class="nav-logo" />
</RouterLink>
<ul class="right-links">
<li>
<ColorSchemeButton />
</li>
</ul>
<NavbarSearchBarWrapper class="navbar-search-bar-wrapper" />
<ul class="right-links">
<li>
<ColorSchemeButton />
</li>
</ul>
</nav>
<div class="navbar-search-bar-wrapper-small-screens-wrapper">
<NavbarSearchBarWrapper class="navbar-search-bar-wrapper-small-screens" />
</div>
</template>
<style scoped>
@@ -37,7 +42,8 @@ import logo from '@/assets/logo.svg';
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 40px;
padding: 18px var(--main-content-padding-x);
height: 42px;
}
.global-nav .right-links {
@@ -46,11 +52,12 @@ import logo from '@/assets/logo.svg';
list-style: none;
margin: 0;
padding: 0;
align-items: center;
}
.global-nav .right-links a {
text-decoration: none;
color: #000;
color: var(--dark);
}
.global-nav .right-links a:hover{
@@ -61,4 +68,27 @@ import logo from '@/assets/logo.svg';
height: 24px;
width: auto;
}
.navbar-search-bar-wrapper {
width: 100%;
padding: 0 30px;
}
.navbar-search-bar-wrapper-small-screens-wrapper {
padding: 0 15px 15px;
}
.navbar-search-bar-wrapper-small-screens {
display: none;
}
@media (max-width: 48rem) {
.navbar-search-bar-wrapper {
display: none;
}
.navbar-search-bar-wrapper-small-screens {
display: block;
}
}
</style>
@@ -0,0 +1,43 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import Searchbar from '@/features/search/components/Searchbar.vue';
import { watch } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const searchQueryModel = defineModel();
watch(() => route.name, name => {
searchQueryModel.value = name === 'searchResults' ? route.query.q || '' : '';
});
watch(() => route.query.q, q => {
if (route.name === 'searchResults') {
searchQueryModel.value = q || '';
}
});
</script>
<template>
<Searchbar
v-if="route.name === 'searchResults'"
v-model="searchQueryModel"
auto-submit
/>
</template>
+36 -11
View File
@@ -15,6 +15,8 @@ limitations under the License.
-->
<script setup>
import Icon from '@/features/icons/components/Icon.vue';
const searchQuery = defineModel();
import { useRouter } from 'vue-router';
@@ -36,18 +38,27 @@ const submitSearch = function () {
</script>
<template>
<form @submit.prevent="submitSearch">
<div class="search-wrapper">
<input
<div>
<form @submit.prevent="submitSearch" class="search-form">
<div class="search-wrapper">
<input
v-model="searchQuery"
type="search"
:placeholder="t('search.searchBar.placeholder')"
required
/>
<button type="submit" class="search-button">{{ t('search.searchBar.submit') }}</button>
</div>
</form>
</template>
/>
<button
type="submit"
class="search-button"
:title="t('search.searchBar.submit')"
:aria-label="t('search.searchBar.submit')"
>
<Icon class="search-icon" name="magnifying-glass" size="1.1em" />
</button>
</div>
</form>
</div>
</template>
<style scoped>
.search-wrapper {
@@ -57,12 +68,13 @@ const submitSearch = function () {
--padding-left: calc(var(--content-height) + var(--padding));
display: flex;
align-items: center;
border: 1.5px solid var(--light-d-3);
box-shadow: 0 0px 32px rgba(69, 98, 190, 0.25);
border: 1.5px solid var(--border);
box-shadow: 0 0px 32px var(--blue-box-shadow);
border-radius: calc(var(--content-height) * 0.5 + var(--submit-button-padding-y) + var(--padding));
padding: var(--padding);
padding-left: var(--padding-left);
width: calc(100% - var(--padding-left));
width: 100%;
box-sizing: border-box;
}
.search-wrapper input {
@@ -72,11 +84,13 @@ const submitSearch = function () {
font-size: 1rem;
background: transparent;
height: calc(var(--content-height) + 2 * var(--submit-button-padding-y));
padding-left: 12px;
}
.search-button {
font-size: 1rem;
height: calc(var(--content-height) + 2 * var(--submit-button-padding-y));
width: calc(var(--content-height) + 2 * var(--submit-button-padding-y));
border-radius: calc(var(--content-height) * 0.5 + var(--submit-button-padding-y));
border: none;
padding: var(--submit-button-padding-y) 20px;
@@ -84,9 +98,20 @@ const submitSearch = function () {
color: var(--white);
cursor: pointer;
white-space: nowrap;
display: flex;
justify-content: center;
align-items: center;
}
.search-button .search-icon {
filter: invert(1);
}
.search-button:hover {
background: var(--primary-color-l-1);
}
.search-form {
width: 100%;
}
</style>
+13 -22
View File
@@ -1,5 +1,5 @@
<!--
Copyright [yyyy] [name of copyright owner]
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.
@@ -15,28 +15,21 @@ limitations under the License.
-->
<script setup>
import Searchbar from '@/features/search/components/Searchbar.vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps(['searchQuery']);
const searchQueryModel = defineModel();
searchQueryModel.value = props.searchQuery;
</script>
<template>
<Searchbar class="search-bar" v-model="searchQueryModel" auto-submit />
<div class="search-results-error-message-container">
<div class="search-results-error-message">
<p>{{ t('search.error.searchNotAvailable') }}</p>
<p>{{ t('error.tryAgainToAnotherTime') }}</p>
<div class="main-content-padding">
<div class="search-results-error-message-container">
<div class="search-results-error-message">
<p>{{ t('search.error.searchNotAvailable') }}</p>
<p>{{ t('error.tryAgainToAnotherTime') }}</p>
</div>
</div>
<div class="search-results-container">
</div>
</div>
<div class="search-results-container">
</div>
</template>
@@ -60,24 +53,22 @@ searchQueryModel.value = props.searchQuery;
--error-message-padding: 2em;
display: flex;
justify-content: center;
width: calc(100% - 2 * var(--main-content-padding-x));
position: absolute;
top: calc(50vh - 0.5 * var(--error-message-height) - var(--error-message-padding));
width: 100%;
}
.search-results-error-message {
padding: var(--error-message-padding);
font-size: 18px;
box-shadow: 0px 0px 42px #cdcdcdb3;
box-shadow: 0px 0px 42px var(--gray-box-shadow);
border-radius: 28px;
text-align: center;
background-color: #f7f7f7;
background-color: var(--light-bg);
animation: fade-in 0.8s ease-out;
height: var(--error-message-height);
display: flex;
justify-content: center;
flex-direction: column;
outline: 1px solid var(--light-d-2);
outline: 1px solid var(--border);
}
@media (max-width: 48rem) {
@@ -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,48 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ref, readonly } from 'vue';
import { loadSettingsConfig } from '../utils/settingsParser.js';
const config = ref(null);
const error = ref(null);
const loading = ref(false);
/**
* Provides reactive access to the parsed settings configuration.
* The config is loaded once and shared across all consumers.
*/
export function useSettingsConfig() {
async function load() {
loading.value = true;
error.value = null;
try {
config.value = await loadSettingsConfig();
} catch (e) {
error.value = e.message;
config.value = null;
} finally {
loading.value = false;
}
}
return {
config: readonly(config),
error: readonly(error),
loading: readonly(loading),
load,
};
}
@@ -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 };
};
+3
View File
@@ -0,0 +1,3 @@
{
"contents": []
}
@@ -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,65 @@
/*
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.
*/
/**
* @typedef {'bool' | 'number' | 'string' | 'selection' | 'section'} SettingType
*/
/**
* @typedef {Object} SelectionOption
* @property {string} name
* @property {string} i18n
*/
/**
* @typedef {Object} BaseSettingConfig
* @property {SettingType} type
* @property {string} name
* @property {string} i18n
* @property {string} [description]
*/
/**
* @typedef {BaseSettingConfig & {
* default: string | string[],
* allowMultiple: boolean,
* options: SelectionOption[]
* }} SelectionSettingConfig
*/
/**
* @typedef {Object} SectionSettingConfig
* @property {'section'} type
* @property {string} name
* @property {string} [i18n]
* @property {string} [description]
* @property {SettingConfigEntry[]} content
*/
/**
* @typedef {BoolSettingConfig | NumberSettingConfig | StringSettingConfig | SelectionSettingConfig | SectionSettingConfig} SettingConfigEntry
*/
/**
* @typedef {{ type: 'bool', name: string, i18n: string, description?: string, default: boolean }} BoolSettingConfig
* @typedef {{ type: 'number', name: string, i18n: string, description?: string, default: number }} NumberSettingConfig
* @typedef {{ type: 'string', name: string, i18n: string, description?: string, default: string }} StringSettingConfig
* @typedef {{ type: 'selection', name: string, i18n: string, description?: string, default: string | string[], allowMultiple?: boolean, options: SelectionOption[] }} SelectionSettingConfig
* @typedef {{ type: 'section', name: string, i18n?: string, description?: string, content: SettingConfigEntry[] }} SectionSettingConfig
* @typedef {BoolSettingConfig | NumberSettingConfig | StringSettingConfig | SelectionSettingConfig | SectionSettingConfig} SettingConfigEntry
* @typedef {{ contents: SettingConfigEntry[] }} FirstLevelSettingConfigEntry
* @typedef {{ contents: FirstLevelSettingConfigEntry[] }} SettingsConfig
*/
@@ -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);
});
})
})
@@ -0,0 +1,812 @@
/*
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 { describe, test, expect } from 'vitest';
import { validateSettingsConfig, validateEntry, validateSelectionOptions, assertType, assertString } from '../settingsValidator';
describe('settingsValidator', () => {
describe('validateSettingsConfig', () => {
test.for([
{ raw: false, expected: false },
{ raw: true, expected: false },
{ raw: 0, expected: false },
{ raw: 42, expected: false },
{ raw: '', expected: false },
{ raw: ' ', expected: false },
{ raw: 'a', expected: false },
{ raw: {}, expected: false },
{ raw: {
contents: []
}, expected: true },
{ raw: {
contents: [
{
name: 'general',
i18n: 'settings.settings.general',
content: [
{
type: 'bool',
name: 'Enable feature 42',
i18n: 'settings.settings.general.enableFeature42'
}
]
}
]
}, expected: true },
{ raw: {
contents: [
{
name: 'general',
i18n: 'settings.settings.general',
content: [
{
type: 'bool',
name: 'Enable feature 42',
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
name: 'copyOfGeneral',
i18n: 'settings.settings.copyOfGeneral',
content: [
{
type: 'bool',
name: 'Enable feature 42',
i18n: 'settings.settings.general.enableFeature42'
}
]
},
]
}, expected: true },
{ raw: {
contents: [
{
name: 'general',
i18n: 'settings.settings.general',
content: [
{
type: 'bool',
name: 'Enable feature 42',
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
name: 'copyOfGeneral',
i18n: 'settings.settings.copyOfGeneral',
content: [
{
type: 'bool',
name: 'Enable feature 42',
default: false,
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
name: 'aSection',
i18n: 'settings.settings.aSection',
content: [
{
type: 'bool',
name: 'Enable feature 43',
i18n: 'settings.settings.aSection.enableFeature43'
},
{
type: 'selection',
name: 'language',
i18n: 'settings.settings.aSection.language.label',
default: 'en',
options: [
{ name: 'en', i18n: 'settings.settings.aSection.language.options.en' },
{ name: 'de', i18n: 'settings.settings.aSection.language.options.de' },
]
},
{
type: 'section',
name: 'section2',
i18n: 'settings.settings.aSection.section2.label',
content: [
{
type: 'string',
name: 'string',
i18n: 'settings.settings.aSection.sections.string',
default: 'str'
}
]
},
]
},
]
}, expected: true },
{ raw: {
contents: [
{
name: 'general',
i18n: 'settings.settings.general',
content: [
{
type: 'bool',
name: 'Enable feature 42',
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
name: 'copyOfGeneral',
i18n: 'settings.settings.copyOfGeneral',
content: [
{
type: 'bool',
name: 'Enable feature 42',
default: false,
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
name: 'aSection',
i18n: 'settings.settings.aSection',
content: [
{
type: 'bool',
name: 'Enable feature 43',
i18n: 'settings.settings.aSection.enableFeature43'
},
{
type: 'selection',
name: 'language',
i18n: 'settings.settings.aSection.language.label',
default: 'en',
allowMultiple: false,
options: [
{ name: 'en', i18n: 'settings.settings.aSection.language.options.en' },
{ name: 'de', i18n: 'settings.settings.aSection.language.options.de' },
]
},
{
type: 'section',
name: 'section2',
i18n: 'settings.settings.aSection.section2.label',
content: [
{
type: 'string',
name: 'string',
i18n: 'settings.settings.aSection.sections.string',
default: 'str'
}
]
},
]
},
]
}, expected: true },
{ raw: {
contents: [
{
type: 'bool',
name: 'aStandaloneBooleanSetting',
i18n: 'settings.settings.aStandaloneBooleanSetting',
default: true
},
{
name: 'general',
i18n: 'settings.settings.general',
content: [
{
type: 'bool',
name: 'Enable feature 42',
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
name: 'copyOfGeneral',
i18n: 'settings.settings.copyOfGeneral',
content: [
{
type: 'bool',
name: 'Enable feature 42',
default: false,
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
name: 'aSection',
i18n: 'settings.settings.aSection',
content: [
{
type: 'bool',
name: 'Enable feature 43',
i18n: 'settings.settings.aSection.enableFeature43'
},
{
type: 'selection',
name: 'language',
i18n: 'settings.settings.aSection.language.label',
default: 'en',
allowMultiple: false,
options: [
{ name: 'en', i18n: 'settings.settings.aSection.language.options.en' },
{ name: 'de', i18n: 'settings.settings.aSection.language.options.de' },
]
},
{
type: 'section',
name: 'section2',
i18n: 'settings.settings.aSection.section2.label',
content: [
{
type: 'string',
name: 'string',
i18n: 'settings.settings.aSection.sections.string',
default: 'str'
}
]
},
]
},
]
}, expected: false },
{ raw: {
contents: [
{
type: 'section',
name: 'general',
i18n: 'settings.settings.general',
content: [
{
type: 'bool',
name: 'Enable feature 42',
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'copyOfGeneral',
i18n: 'settings.settings.copyOfGeneral',
content: [
{
type: 'bool',
name: 'Enable feature 42',
default: false,
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'aSection',
i18n: 'settings.settings.aSection',
content: [
{
type: 'bool',
name: 'Enable feature 43',
i18n: 'settings.settings.aSection.enableFeature43',
default: 'true'
},
{
type: 'selection',
name: 'language',
i18n: 'settings.settings.aSection.language.label',
default: 'en',
options: [
{ name: 'en', i18n: 'settings.settings.aSection.language.options.en' },
{ name: 'de', i18n: 'settings.settings.aSection.language.options.de' },
]
},
{
type: 'section',
name: 'section2',
i18n: 'settings.settings.aSection.section2.label',
content: [
{
type: 'string',
name: 'string',
i18n: 'settings.settings.aSection.sections.string',
default: 42
}
]
},
]
},
]
}, expected: false },
{ raw: {
content: [
{
type: 'section',
name: 'general',
i18n: 'settings.settings.general',
content: [
{
type: 'bool',
name: 'Enable feature 42',
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'copyOfGeneral',
i18n: 'settings.settings.copyOfGeneral',
content: [
{
type: 'bool',
name: 'Enable feature 42',
default: false,
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'aSection',
i18n: 'settings.settings.aSection',
content: [
{
type: 'bool',
name: 'Enable feature 43',
i18n: 'settings.settings.aSection.enableFeature43'
},
{
type: 'selection',
name: 'language',
i18n: 'settings.settings.aSection.language.label',
default: 'en',
options: [
{ name: 'en', i18n: 'settings.settings.aSection.language.options.en' },
{ name: 'de', i18n: 'settings.settings.aSection.language.options.de' },
]
},
{
type: 'section',
name: 'section2',
i18n: 'settings.settings.aSection.section2.label',
content: [
{
type: 'string',
name: 'string',
i18n: 'settings.settings.aSection.sections.string',
default: 'str'
}
]
},
]
},
]
}, expected: false },
{ raw: {
content: [
{
type: 'sectio',
name: 'general',
i18n: 'settings.settings.general',
content: [
{
type: 'bool',
name: 'Enable feature 42',
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'copyOfGeneral',
i18n: 'settings.settings.copyOfGeneral',
content: [
{
type: 'bool',
name: 'Enable feature 42',
default: false,
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'aSection',
i18n: 'settings.settings.aSection',
content: [
{
type: 'bool',
name: 'Enable feature 43',
i18n: 'settings.settings.aSection.enableFeature43'
},
{
type: 'selection',
name: 'language',
i18n: 'settings.settings.aSection.language.label',
default: 'en',
options: [
{ name: 'en', i18n: 'settings.settings.aSection.language.options.en' },
{ name: 'de', i18n: 'settings.settings.aSection.language.options.de' },
]
},
{
type: 'section',
name: 'section2',
i18n: 'settings.settings.aSection.section2.label',
content: [
{
type: 'string',
name: 'string',
i18n: 'settings.settings.aSection.sections.string',
default: 'str'
}
]
},
]
},
]
}, expected: false },
{ raw: {
content: [
{
type: 'sectio',
name: 'general',
i18n: 'settings.settings.general',
content: [
{
type: 'bool',
name: 'Enable feature 42',
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'copyOfGeneral',
i18n: 'settings.settings.copyOfGeneral',
content: [
{
type: 'bool',
name: 'Enable feature 42',
default: false,
i18n: 'settings.settings.general.enableFeature42'
}
]
},
{
type: 'section',
name: 'aSection',
i18n: 'settings.settings.aSection',
content: [
{
type: 'bool',
name: 'Enable feature 43',
i18n: 'settings.settings.aSection.enableFeature43'
},
{
type: 'selection',
name: 'language',
i18n: 'settings.settings.aSection.language.label',
default: 'en',
allowMultiple: 'false',
options: [
{ name: 'en', i18n: 'settings.settings.aSection.language.options.en' },
{ name: 'de', i18n: 'settings.settings.aSection.language.options.de' },
]
},
{
type: 'section',
name: 'section2',
i18n: 'settings.settings.aSection.section2.label',
content: [
{
type: 'string',
name: 'string',
i18n: 'settings.settings.aSection.sections.string',
default: 'str'
}
]
},
]
},
]
}, expected: false }
])('returns valid: $expected', ({ raw, expected }) => {
const result = validateSettingsConfig(raw);
if (!result.valid) {
console.error('Error message:', result.error);
};
expect(result.valid).toBe(expected);
});
});
describe('validateEntry', () => {
test.for([
[{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable' }],
[{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: true }],
[{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: false }],
[{ name: 'aNumber', type: 'number', i18n: 'aNumber.label' }],
[{ name: 'aNumber', type: 'number', i18n: 'aNumber.label', default: 42 }],
[{ name: 'aNumber', type: 'number', i18n: 'aNumber.label', default: 0 }],
[{ name: 'aNumber', type: 'number', i18n: 'aNumber.label', default: -42.7 }],
[{ name: 'aString', type: 'string', i18n: 'aString.label' }],
[{ name: 'aString', type: 'string', i18n: 'aString.label', default: '' }],
[{ name: 'aString', type: 'string', i18n: 'aString.label', default: 'Seekra is great!' }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', options: [
{ name: 'yes', i18n: 'selectSomething.options.yes' },
{ name: 'no', i18n: 'selectSomething.options.no' },
{ name: 'maybe', i18n: 'selectSomething.options.maybe' }
] }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', default: 'no', options: [
{ name: 'yes', i18n: 'selectSomething.options.yes' },
{ name: 'no', i18n: 'selectSomething.options.no' },
{ 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 }
] }],
[{ name: 'aSection', type: 'section', i18n: 'sections.aSection.title', content: [
{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: true },
{ name: 'enableFeature43', type: 'bool', i18n: 'feature.43.enable', default: true }
] }],
[{ name: 'aSection', type: 'section', i18n: 'sections.aSection.title', content: [
{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: true },
{ name: 'enableFeature43', type: 'bool', i18n: 'feature.43.enable', default: true },
{ name: 'aSecondSection', type: 'section', i18n: 'sections.aSecondSection.title', content: [
{ name: 'enableFeature44', type: 'bool', i18n: 'feature.44.enable', default: true },
] }
] }]
])('throws no error for the entry %s', ([ entry ]) => {
expect(() => validateEntry(entry)).not.throws(Error);
});
test.for([
[{ name: 'enableFeature42', type: 'bool', i18n: '' }],
[{ name: 'enableFeature42', type: 'thisTypeDoesNotExistAndHasAVeryLongName', i18n: 'feature.42.enable' }],
[{ name: '', type: 'thisTypeDoesNotExistAndHasAVeryLongName', i18n: 'feature.42.enable' }],
[{ name: '', type: 'thisTypeDoesNotExistAndHasAVeryLongName', i18n: '' }],
[{ name: '', type: 'bool', i18n: '' }],
[{ name: '', type: 'bool', i18n: '' }],
[{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: 42 }],
[{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: '42' }],
[{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: 'Seekra' }],
[{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: 'true' }],
[{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: 'false' }],
[{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: 'undefined' }],
[{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: 'null' }],
[{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: [] }],
[{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: {} }],
[{ name: 'enableFeature42', type: '', i18n: 'feature.42.enable', default: {} }],
[{ name: '', type: 'number', i18n: 'aNumber.label' }],
[{ name: 'aNumber', type: 'number', i18n: 'aNumber.label', default: '42' }],
[{ name: 'aNumber', type: 'number', i18n: 'aNumber.label', default: 'zero' }],
[{ name: 'aNumber', type: 'number', i18n: 'aNumber.label', default: 'undefined' }],
[{ name: 'aNumber', type: 'number', i18n: 'aNumber.label', default: 'false' }],
[{ name: 'aNumber', type: 'number', i18n: 'aNumber.label', default: true }],
[{ name: 'aNumber', type: 'number', i18n: 'aNumber.label', default: false }],
[{ name: 'aNumber', type: 'number', i18n: 'aNumber.label', default: [] }],
[{ name: 'aNumber', type: 'number', i18n: 'aNumber.label', default: {} }],
[{ name: 'aNumber', type: '', i18n: 'aNumber.label', default: {} }],
[{ name: 'aNumber', type: '', i18n: 'aNumber.label', default: 42 }],
[{ name: 'aString', type: 'string', i18n: '' }],
[{ name: 'aString', type: 'string', i18n: 'aString.label', default: 42 }],
[{ name: 'aString', type: 'string', i18n: 'aString.label', default: true }],
[{ name: 'aString', type: 'string', i18n: 'aString.label', default: false }],
[{ name: 'aString', type: 'string', i18n: 'aString.label', default: [] }],
[{ name: 'aString', type: 'string', i18n: 'aString.label', default: {} }],
[{ name: 'aString', type: 'string', i18n: '', default: {} }],
[{ 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' }
] }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', default: 'a', options: [
{ name: 'a', i18n: 'a' },
{ name: 'b', i18n: '' },
{ name: 'c' }
] }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', default: 'a', allowMultiple: 'false', options: [
{ name: 'a', i18n: 'a' },
{ name: 'b', i18n: '' },
{ name: 'c' }
] }],
[{ name: 'selectSomething', type: 'selection', i18n: 'selectSomething.title', default: 'no', options: [
{ name: 'yes', i18n: '' },
{ name: '', i18n: 'selectSomething.options.no' },
{ name: 'maybe', i18n: 'selectSomething.options.maybe' },
{ name: '', i18n: '' },
{ name: 42, i18n: 43 },
{ name: '' },
{ i18n: '' },
{}
] }],
[{ name: 'aSection', type: 'section', i18n: '', content: [] }],
[{ name: 'aSection', type: 'section', i18n: 'sections.aSection.title', content: '' }],
[{ name: 'aSection', type: 'section', i18n: 'sections.aSection.title', content: '[]' }],
[{ name: 'aSection', type: 'section', i18n: 'sections.aSection.title', content: 'a' }],
[{ name: 'aSection', type: 'section', i18n: 'sections.aSection.title', content: [
{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: true },
{ name: 'enableFeature43', type: 'bool', i18n: 'feature.42.enable', default: 0 },
{ name: 'enableFeature44', type: 'bool' }
] }],
[{ name: 'aSection', type: 'section', i18n: 'sections.aSection.title', content: [
{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: true },
{ name: 'enableFeature43', type: 'bool', i18n: 'feature.43.enable', default: true },
{ name: 'aSecondSection', type: 'section', i18n: 'sections.aSecondSection.title', content: 'Hello' },
{ name: 'aSecondSection', type: 'section', i18n: '', content: [] },
{ name: 'aSecondSection', type: 'section', i18n: 'i18n', content: [] },
{ name: 'aSecondSection', type: 'section', content: [] }
] }]
])('throws an error for the entry %s', ([ entry ]) => {
expect(() => validateEntry(entry)).throws(Error);
});
});
describe('validateSelectionOptions', () => {
test.for([
[[{ name: 'test', i18n: 'test.label' }]],
[[{ name: 'test', i18n: 'test.label' }, { name: 'test2', i18n: 'test2.label' }]],
[[{ name: 'test', i18n: 'test.label' }, { name: 'test', i18n: 'test2.label' }, { name: 'test3', i18n: 'test.label' }]]
])('throws no error for the options %s', ([ options ]) => {
expect(() => validateSelectionOptions(options)).not.throws(Error);
});
test.for([
[[{}]],
[[{ i18n: '' }]],
[[{ i18n: 'a' }]],
[[{ name: 'a' }]],
[[{ name: '' }]],
[[{ name: '', i18n: 'test.label' }]],
[[{ name: 'test', i18n: '' }]],
[[{ name: '', i18n: '' }]],
[[{ name: '', i18n: 'test.label' }, { name: 'test2', i18n: '' }]],
[[{ name: 'test', i18n: '' }, { name: '', i18n: 'test2.label' }, { name: '', i18n: ' ' }, { name: '42', i18n: '42.i18n' }]],
[[]]
])('throws an error for the options %s', ([ options ]) => {
expect(() => validateSelectionOptions(options)).throws(Error);
});
});
describe('assertType', () => {
test.for([
['bool'],
['number'],
['string'],
['selection'],
['section']
])('throws no error for the value %s', ([ value ]) => {
expect(() => assertType(value)).not.throw(Error);
});
test.for([
[''],
[' '],
[' '],
[' '],
['42'],
['0'],
['-42'],
['-42.0'],
['-0.0'],
['a'],
['ab'],
['SeekraIsGreat!'],
['Seekra is great!'],
[undefined],
[null]
])('throws an error for the value %s', ([ value ]) => {
expect(() => assertType(value)).throw(Error);
});
});
describe('assertString', () => {
test.for([
['a'],
['b'],
['ab'],
['0'],
['42'],
['null'],
['undefined'],
['()&%())']
])('throws no error for the value %s', ([ value ]) => {
expect(() => assertString(value)).not.throw(Error);
});
test.for([
[0],
[1],
[42],
[-1],
[-42],
[''],
[' '],
[' '],
[' ']
])('throws an error for the value %s', ([ value ]) => {
expect(() => assertString(value)).throws(Error);
});
});
});
+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);
};
@@ -0,0 +1,38 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { validateSettingsConfig } from './settingsValidator.js';
/**
* Loads and parses the settings configuration via dynamic import.
* @returns {Promise<import('../types/settingsConfig').SettingsConfig>}
*/
export async function loadSettingsConfig() {
let raw;
try {
raw = (await import('../settings.json')).default;
} catch (e) {
throw new Error(`[settings] Failed to load settings.json: ${e.message}`);
}
const result = validateSettingsConfig(raw);
if (!result.valid) {
throw new Error(result.error);
}
return result.config;
}
@@ -0,0 +1,134 @@
/*
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.
*/
const VALID_TYPES = ['bool', 'number', 'string', 'selection', 'section'];
export const assertString = function assertString (value, path) {
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`[settings] "${path}" must be a non-empty string`);
}
}
export const assertType = function assertType (value, path) {
if (!VALID_TYPES.includes(value)) {
throw new Error(
`[settings] "${path}" has invalid type "${value}". Must be one of: ${VALID_TYPES.join(', ')}`
);
}
}
export const validateSelectionOptions = function validateSelectionOptions (options, path) {
if (!Array.isArray(options) || options.length === 0) {
throw new Error(`[settings] "${path}.options" must be a non-empty array`);
};
options.forEach((opt, i) => {
assertString(opt.name, `${path}.options[${i}].name`);
assertString(opt.i18n, `${path}.options[${i}].i18n`);
});
}
export const validateEntry = function validateEntry (entry, path) {
assertType(entry.type, `${path}.type`);
assertString(entry.name, `${path}.name`);
assertString(entry.i18n, `${path}.i18n`);
if (entry.type === 'section') {
if (!Array.isArray(entry.content)) {
throw new Error(`[settings] "${path}.content" must be an array`);
}
entry.content.forEach((child, i) =>
validateEntry(child, `${path}.content[${i}]`)
);
return;
}
if (entry.type === 'selection') {
validateSelectionOptions(entry.options, path);
if (typeof entry.allowMultiple !== 'boolean' && entry.allowMultiple) {
throw new Error(`[settings] "${path}.allowMultiple" must be a boolean`);
}
};
if (entry.default !== undefined) {
if (entry.type === 'bool' && typeof entry.default !== 'boolean') {
throw new Error(`[settings] "${path}.default" must be a boolean`);
}
if (entry.type === 'number' && typeof entry.default !== 'number') {
throw new Error(`[settings] "${path}.default" must be a number`);
}
if (entry.type === 'string' && typeof entry.default !== 'string') {
throw new Error(`[settings] "${path}.default" must be a string`);
}
if (entry.type === 'selection') {
if (entry.allowMultiple) {
if (!Array.isArray(entry.default)) {
throw new Error(`[settings] "${path}.default" must be an array`);
}
const allOptions = entry.options.map((option) => option.name);
entry.default.forEach((defaultValue, index) => {
if (typeof defaultValue !== 'string') {
throw new Error(`[settings] "${path}.default[${index}]" must be a string`);
}
if (!allOptions.includes(defaultValue)) {
throw new Error(`[settings] option "${path}.default[${index}]" does not exist`);
}
});
} else {
if (typeof entry.default !== 'string') {
throw new Error(`[settings] "${path}.default" must be a string`);
};
if (!entry.options.map((option) => option.name).includes(entry.default)) {
throw new Error(`[settings] option "${path}.default" does not exist`);
};
}
};
}
}
export const validateFirstLevelSection = function validateFirstLevelSection (section, path) {
assertString(section.name);
assertString(section.i18n);
if (!Array.isArray(section.content)) {
throw new Error(`[settings] "${path}.content" must be an array`);
};
section.content.forEach((entry, i) =>
validateEntry(entry, `${path}.content[${i}]`)
);
};
/**
* Validates a raw settings config object.
* @param {unknown} raw
* @returns {{ valid: true, config: import('../types/settingsConfig').SettingsConfig } | { valid: false, error: string }}
*/
export function validateSettingsConfig(raw) {
try {
if (!raw || typeof raw !== 'object') {
throw new Error('[settings] Config must be an object');
}
if (!Array.isArray(raw.contents)) {
throw new Error('[settings] "contents" must be an array');
}
raw.contents.forEach((entry, i) =>
validateFirstLevelSection(entry, `contents[${i}]`)
);
return { valid: true, config: raw };
} catch (e) {
return { valid: false, error: e.message };
}
}
@@ -0,0 +1,150 @@
<!--
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 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>
<div class="settings-page-wrapper">
<header class="header">
<h1>
{{ t('preferences.settings') }}
</h1>
</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>
<style scoped>
.layout {
flex-grow: 1;
}
.settings-page-wrapper {
display: flex;
flex-direction: column;
}
.active {
background-color: var(--light-hover);
}
.header {
padding: var(--main-content-padding-y) var(--main-content-padding-x);
}
.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;
});
@@ -0,0 +1,73 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import SidebarExpandButton from './SidebarExpandButton.vue';
const props = defineProps({
'expanded': {
default: false,
required: false,
type: Boolean
}
});
const emit = defineEmits(['toggleExpanded']);
const toggleSidebar = function toggleSidebar () {
emit('toggleExpanded');
};
</script>
<template>
<nav class="sidebar" :class="{ expanded }">
<div class="sidebar-controls">
<SidebarExpandButton @click="toggleSidebar" class="sidebar-expand-button" />
</div>
<div class="sidebar-content" v-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>
+11 -2
View File
@@ -19,8 +19,17 @@ import getCurrentLanguage from './utils/currentLanguage';
export const fallbackLocale = 'en';
const SUPPORTED_LANGUAGES = [
'en'
export const LANGUAGES_RTL = [
'ar'
];
export const SUPPORTED_LANGUAGES = [
'en',
'de',
'fr',
'es',
'pt',
'it'
];
export const i18n = createI18n({
+103
View File
@@ -0,0 +1,103 @@
<!--
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 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" :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">
<slot />
</main>
</div>
</template>
<style scoped>
.layout-container {
display: grid;
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>
+65
View File
@@ -0,0 +1,65 @@
{
"loading": "Laden ...",
"search": {
"searchBar": {
"submit": "Suchen",
"placeholder": "Suchen..."
},
"error": {
"searchNotAvailable": "Die Suche ist momentan nicht verfügbar."
}
},
"error": {
"tryAgainToAnotherTime": "Bitte versuchen Sie es zu einem anderen Zeitpunkt erneut.",
"pageNotFound": "Die gesuchte Seite existiert nicht. Bitte überprüfen Sie die URL oder kehren Sie zur Suchseite zurück."
},
"links": {
"back": {
"search": "Zurück zur Suche"
}
},
"preferences": {
"settings": "Einstellungen",
"colorScheme": {
"switch": {
"light": "Zum hellen Modus wechseln",
"dark": "Zum dunklen Modus wechseln",
"auto": "Zum System-Farbschema wechseln"
}
},
"locale": {
"languages": {
"en": "English",
"de": "Deutsch",
"fr": "Français",
"es": "Español",
"it": "Italiano",
"pt": "Português"
}
}
},
"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"
}
}
}
}
+36 -1
View File
@@ -1,4 +1,5 @@
{
"loading": "Loading ...",
"search": {
"searchBar": {
"submit": "Search",
@@ -18,13 +19,47 @@
}
},
"preferences": {
"settings": "Settings",
"colorScheme": {
"switch": {
"light": "Switch to light mode",
"dark": "Switch to dark mode",
"auto": "Switch to the system color scheme"
}
},
"locale": {
"languages": {
"en": "English",
"de": "Deutsch",
"fr": "Français",
"es": "Español",
"it": "Italiano",
"pt": "Português"
}
}
},
"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}"
}
}
}
}
+65
View File
@@ -0,0 +1,65 @@
{
"loading": "Cargando ...",
"search": {
"searchBar": {
"submit": "Buscar",
"placeholder": "Buscar..."
},
"error": {
"searchNotAvailable": "La búsqueda no está disponible en este momento."
}
},
"error": {
"tryAgainToAnotherTime": "Por favor, inténtalo de nuevo más tarde.",
"pageNotFound": "La página que buscas no existe. Comprueba la URL o vuelve a la página de búsqueda."
},
"links": {
"back": {
"search": "Volver a la búsqueda"
}
},
"preferences": {
"settings": "Ajustes",
"colorScheme": {
"switch": {
"light": "Cambiar al modo claro",
"dark": "Cambiar al modo oscuro",
"auto": "Usar el esquema de color del sistema"
}
},
"locale": {
"languages": {
"en": "English",
"de": "Deutsch",
"fr": "Français",
"es": "Español",
"it": "Italiano",
"pt": "Português"
}
}
},
"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}"
}
}
}
}
+65
View File
@@ -0,0 +1,65 @@
{
"loading": "Chargement ...",
"search": {
"searchBar": {
"submit": "Rechercher",
"placeholder": "Rechercher..."
},
"error": {
"searchNotAvailable": "La recherche n'est pas disponible pour le moment."
}
},
"error": {
"tryAgainToAnotherTime": "Veuillez réessayer ultérieurement.",
"pageNotFound": "La page que vous recherchez n'existe pas. Vérifiez l'URL ou retournez à la page de recherche."
},
"links": {
"back": {
"search": "Retour à la recherche"
}
},
"preferences": {
"settings": "Paramètres",
"colorScheme": {
"switch": {
"light": "Passer en mode clair",
"dark": "Passer en mode sombre",
"auto": "Utiliser le thème système"
}
},
"locale": {
"languages": {
"en": "English",
"de": "Deutsch",
"fr": "Français",
"es": "Español",
"it": "Italiano",
"pt": "Português"
}
}
},
"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}"
}
}
}
}
+65
View File
@@ -0,0 +1,65 @@
{
"loading": "Caricamento ...",
"search": {
"searchBar": {
"submit": "Cerca",
"placeholder": "Cerca..."
},
"error": {
"searchNotAvailable": "La ricerca non è disponibile al momento."
}
},
"error": {
"tryAgainToAnotherTime": "Per favore riprova più tardi.",
"pageNotFound": "La pagina che cerchi non esiste. Controlla l'URL o torna alla pagina di ricerca."
},
"links": {
"back": {
"search": "Torna alla ricerca"
}
},
"preferences": {
"settings": "Impostazioni",
"colorScheme": {
"switch": {
"light": "Passa alla modalità chiara",
"dark": "Passa alla modalità scura",
"auto": "Usa la combinazione colori di sistema"
}
},
"locale": {
"languages": {
"en": "English",
"de": "Deutsch",
"fr": "Français",
"es": "Español",
"it": "Italiano",
"pt": "Português"
}
}
},
"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}"
}
}
}
}
+65
View File
@@ -0,0 +1,65 @@
{
"loading": "A carregar ...",
"search": {
"searchBar": {
"submit": "Pesquisar",
"placeholder": "Pesquisar..."
},
"error": {
"searchNotAvailable": "A pesquisa não está disponível no momento."
}
},
"error": {
"tryAgainToAnotherTime": "Por favor, tente novamente mais tarde.",
"pageNotFound": "A página que você procura não existe. Verifique o URL ou volte à página de pesquisa."
},
"links": {
"back": {
"search": "Voltar à pesquisa"
}
},
"preferences": {
"settings": "Configurações",
"colorScheme": {
"switch": {
"light": "Mudar para modo claro",
"dark": "Mudar para modo escuro",
"auto": "Usar esquema de cores do sistema"
}
},
"locale": {
"languages": {
"en": "English",
"de": "Deutsch",
"fr": "Français",
"es": "Español",
"it": "Italiano",
"pt": "Português"
}
}
},
"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}"
}
}
}
}
+19 -6
View File
@@ -15,16 +15,29 @@ limitations under the License.
*/
import { createApp } from 'vue'
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
import App from './App.vue'
import { i18n, loadLanguage } from './i18n';
import { i18n, loadLanguage, fallbackLocale } from './i18n';
import getCurrentLanguage from './utils/currentLanguage';
import router from './router'
import './styles/common.css'
import './styles/variables/colors.css'
import './styles/variables/dimensions.css';
await loadLanguage(getCurrentLanguage());
import './styles/common.css';
import './styles/variables/colors.css';
createApp(App)
.use(router)
.use(i18n)
.mount('#app')
(async () => {
await loadLanguage(fallbackLocale);
await loadLanguage(getCurrentLanguage());
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
createApp(App)
.use(router)
.use(i18n)
.use(pinia)
.mount('#app');
})();
+26 -5
View File
@@ -15,9 +15,11 @@ limitations under the License.
*/
import { createRouter, createWebHistory } from 'vue-router';
import { i18n } from '@/i18n';
import SearchView from '../views/SearchView.vue';
import SearchResultsView from '@/features/search/views/SearchResultsView.vue';
import SettingsView from '@/features/settings/views/SettingsView.vue';
import NotFound from '../views/NotFound.vue';
const routes = [
@@ -37,6 +39,14 @@ const routes = [
title: (route) => route.query.q
}
},
{
path: '/settings/:rest(.*)*',
name: 'settings',
component: SettingsView,
meta: {
title: () => i18n.global.t('preferences.settings')
}
},
{
path: '/:pathMatch(.*)*',
name: 'notFound',
@@ -49,18 +59,29 @@ const router = createRouter({
routes
});
// set page title
router.afterEach(to => {
export const updatePageTitle = function updatePageTitle (route) {
const title =
typeof to.meta.title === 'function'
? to.meta.title(to)
: to.meta.title;
typeof route.meta.title === 'function'
? route.meta.title(route)
: route.meta.title;
if (title) {
document.title = `${title} - Seekra`;
} else {
document.title = 'Seekra';
};
};
// remove trailing slash(es)
router.beforeEach((route) => {
if (route.path !== '/' && route.path.endsWith('/')) {
return {
path: route.path.replace(/\/+$/, ''),
query: route.query,
hash: route.hash,
replace: true
};
};
});
export default router;
+57 -1
View File
@@ -15,6 +15,8 @@ limitations under the License.
*/
body {
background-color: var(--light-bg);
color: var(--dark);
margin: 0;
font-size: 16px;
}
@@ -23,10 +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);
}
+32 -7
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
#app-wrapper {
body {
--primary-color-l-8: oklch(0.897 0.202 260);
--primary-color-l-7: oklch(0.847 0.202 260);
--primary-color-l-6: oklch(0.797 0.202 260);
@@ -75,10 +75,19 @@ limitations under the License.
--dark-bg: var(--black-l-2);
--light-bg: var(--white);
--border: var(--white-d-3);
--gray-box-shadow: oklch(0.8 0.0001 271 / 0.7);
--blue-box-shadow: oklch(0.52 0.15 268 / 0.25);
--light-hover: var(--light-d-2);
--invert: invert(0);
}
@media (prefers-color-scheme: dark) {
#app-wrapper.color-scheme-auto {
body.color-scheme-auto {
--dark-l-8: var(--white-d-8);
--dark-l-7: var(--white-d-7);
--dark-l-6: var(--white-d-6);
@@ -90,7 +99,7 @@ limitations under the License.
--dark: var(--white);
--light: var(--black);
--light-d-1: var(--black-l-1);
--light-d-1: oklch(0.10 0 0);
--light-d-2: var(--black-l-2);
--light-d-3: var(--black-l-3);
--light-d-4: var(--black-l-4);
@@ -100,11 +109,19 @@ limitations under the License.
--light-d-8: var(--black-l-8);
--dark-bg: var(--white);
--light-bg: var(--black);
--light-bg: oklch(0.18 0 0);
--border: var(--black-l-6);
--gray-box-shadow: oklch(0.25 0.0001 271 / 0.7);
--light-hover: var(--light-d-5);
--invert: invert(1);
}
}
#app-wrapper.dark {
body.dark {
--dark-l-8: var(--white-d-8);
--dark-l-7: var(--white-d-7);
--dark-l-6: var(--white-d-6);
@@ -116,7 +133,7 @@ limitations under the License.
--dark: var(--white);
--light: var(--black);
--light-d-1: var(--black-l-1);
--light-d-1: oklch(0.10 0 0);
--light-d-2: var(--black-l-2);
--light-d-3: var(--black-l-3);
--light-d-4: var(--black-l-4);
@@ -126,5 +143,13 @@ limitations under the License.
--light-d-8: var(--black-l-8);
--dark-bg: var(--white);
--light-bg: var(--black);
--light-bg: oklch(0.18 0 0);
--border: var(--black-l-6);
--gray-box-shadow: oklch(0.25 0.0001 271 / 0.7);
--light-hover: var(--light-d-5);
--invert: invert(1);
}
+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
});
};
+124
View File
@@ -0,0 +1,124 @@
/*
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 { describe, expect, test } from 'vitest';
import { ensureUnit } from '../cssDimensions';
describe('cssDimensions', () => {
test.for([
{ dimension: null, expected: '0px' },
{ dimension: undefined, expected: '0px' },
{ dimension: 0, expected: '0px' },
{ dimension: -0, expected: '0px' },
{ dimension: -1, expected: '-1px' },
{ dimension: -42, expected: '-42px' },
{ dimension: 1, expected: '1px' },
{ dimension: 42, expected: '42px' },
{ dimension: 0.0, expected: '0px' },
{ dimension: -0.0, expected: '0px' },
{ dimension: -1.0, expected: '-1px' },
{ dimension: -42.0, expected: '-42px' },
{ dimension: 1.0, expected: '1px' },
{ dimension: 42.0, expected: '42px' },
{ dimension: 0.42, expected: '0.42px' },
{ dimension: -0.42, expected: '-0.42px' },
{ dimension: -1.42, expected: '-1.42px' },
{ dimension: -42.42, expected: '-42.42px' },
{ dimension: 1.42, expected: '1.42px' },
{ dimension: 42.42, expected: '42.42px' },
{ dimension: '0', expected: '0px' },
{ dimension: '-0', expected: '-0px' },
{ dimension: '-1', expected: '-1px' },
{ dimension: '-42', expected: '-42px' },
{ dimension: '1', expected: '1px' },
{ dimension: '42', expected: '42px' },
{ dimension: '0.0', expected: '0.0px' },
{ dimension: '-0.0', expected: '-0.0px' },
{ dimension: '-1.0', expected: '-1.0px' },
{ dimension: '-42.0', expected: '-42.0px' },
{ dimension: '1.0', expected: '1.0px' },
{ dimension: '42.0', expected: '42.0px' },
{ dimension: '0.42', expected: '0.42px' },
{ dimension: '-0.42', expected: '-0.42px' },
{ dimension: '-1.42', expected: '-1.42px' },
{ dimension: '-42.42', expected: '-42.42px' },
{ dimension: '1.42', expected: '1.42px' },
{ dimension: '42.42', expected: '42.42px' },
{ dimension: '0px', expected: '0px' },
{ dimension: '-0px', expected: '-0px' },
{ dimension: '-1px', expected: '-1px' },
{ dimension: '-42px', expected: '-42px' },
{ dimension: '1px', expected: '1px' },
{ dimension: '42px', expected: '42px' },
{ dimension: '0.0px', expected: '0.0px' },
{ dimension: '-0.0px', expected: '-0.0px' },
{ dimension: '-1.0px', expected: '-1.0px' },
{ dimension: '-42.0px', expected: '-42.0px' },
{ dimension: '1.0px', expected: '1.0px' },
{ dimension: '42.0px', expected: '42.0px' },
{ dimension: '0.42px', expected: '0.42px' },
{ dimension: '-0.42px', expected: '-0.42px' },
{ dimension: '-1.42px', expected: '-1.42px' },
{ dimension: '-42.42px', expected: '-42.42px' },
{ dimension: '1.42px', expected: '1.42px' },
{ dimension: '42.42px', expected: '42.42px' },
{ dimension: '0em', expected: '0em' },
{ dimension: '-0em', expected: '-0em' },
{ dimension: '-1em', expected: '-1em' },
{ dimension: '-42em', expected: '-42em' },
{ dimension: '1em', expected: '1em' },
{ dimension: '42em', expected: '42em' },
{ dimension: '0.0em', expected: '0.0em' },
{ dimension: '-0.0em', expected: '-0.0em' },
{ dimension: '-1.0em', expected: '-1.0em' },
{ dimension: '-42.0em', expected: '-42.0em' },
{ dimension: '1.0em', expected: '1.0em' },
{ dimension: '42.0em', expected: '42.0em' },
{ dimension: '0.42em', expected: '0.42em' },
{ dimension: '-0.42em', expected: '-0.42em' },
{ dimension: '-1.42em', expected: '-1.42em' },
{ dimension: '-42.42em', expected: '-42.42em' },
{ dimension: '1.42em', expected: '1.42em' },
{ dimension: '42.42em', expected: '42.42em' },
{ dimension: 'calc(42px - 1em)', expected: 'calc(42px - 1em)' },
{ dimension: 'calc(42px)', expected: 'calc(42px)' },
{ dimension: 'calc(42vh)', expected: 'calc(42vh)' },
{ dimension: 'min(42px, 1em)', expected: 'min(42px, 1em)' },
{ dimension: 'max(42px, 5rem)', expected: 'max(42px, 5rem)' },
{ dimension: 'clamp(42vh, 23vw, 13cap)', expected: 'clamp(42vh, 23vw, 13cap)' },
{ dimension: 'calc(42px-1em)', expected: 'calc(42px-1em)' },
{ dimension: 'min(42px,1em)', expected: 'min(42px,1em)' },
{ dimension: 'max(42px,5rem)', expected: 'max(42px,5rem)' },
{ dimension: 'clamp(42vh,23vw,13cap)', expected: 'clamp(42vh,23vw,13cap)' },
{ dimension: 'clamp( 42vh,23vw,13cap )', expected: 'clamp( 42vh,23vw,13cap )' }
])('ensureUnit returns $expected with input $dimension', ({ dimension, expected }) => {
expect(ensureUnit(dimension)).toBe(expected);
});
});
@@ -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.
*/
import { describe, expect, test } from 'vitest';
import getCurrentLanguage from '../currentLanguage';
describe('currentLanguage', () => {
test.for([
{ navigatorLanguage: 'en', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'de', localStorageLanguage: null, expected: 'de'},
{ navigatorLanguage: 'fr', localStorageLanguage: null, expected: 'fr'},
{ navigatorLanguage: 'en-US', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'en-AU', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'de-DE', localStorageLanguage: null, expected: 'de'},
{ navigatorLanguage: 'fr-FR', localStorageLanguage: null, expected: 'fr'},
{ navigatorLanguage: 'en-us', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'en-au', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'de-de', localStorageLanguage: null, expected: 'de'},
{ navigatorLanguage: 'fr-fr', localStorageLanguage: null, expected: 'fr'},
{ navigatorLanguage: 'zh-Hans-CN', localStorageLanguage: null, expected: 'zh'},
{ navigatorLanguage: 'zh-Hant-TW', localStorageLanguage: null, expected: 'zh'},
{ navigatorLanguage: 'uz-Latn-UZ', localStorageLanguage: null, expected: 'uz'},
{ navigatorLanguage: 'en-US-u-ca-gregory', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'de-DE-u-co-phonebk', localStorageLanguage: null, expected: 'de'},
{ navigatorLanguage: 'zh-Hant-TW-u-co-phonebk', localStorageLanguage: null, expected: 'zh'},
{ navigatorLanguage: 'en', localStorageLanguage: 'de', expected: 'de'},
{ navigatorLanguage: 'de-DE', localStorageLanguage: 'en', expected: 'en'},
{ navigatorLanguage: 'de-de', localStorageLanguage: 'en', expected: 'en'},
{ navigatorLanguage: 'zh-Hans-CN', localStorageLanguage: 'fr', expected: 'fr'},
{ navigatorLanguage: 'en-US-u-ca-gregory', localStorageLanguage: 'zh', expected: 'zh'}
])('returns the language $expected (navigator: $navigatorLanguage; local storage: $localStorageLanguage)', ({ navigatorLanguage, localStorageLanguage, expected }) => {
Object.defineProperty(navigator, 'language', {
value: navigatorLanguage,
configurable: true
});
if (localStorageLanguage) {
localStorage.setItem('locale', localStorageLanguage);
};
expect(getCurrentLanguage()).toBe(expected);
});
});
@@ -14,13 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
form {
display: flex;
justify-content: center;
margin-top: 60px;
}
export const ensureUnit = function (d) {
if (!d) d = 0;
footer{
text-align: center;
margin-top: 20px;
}
// ensure that it is a string
if (typeof d === 'number') d = d.toString();
if (d === '') d = '0';
// if it ends with a number
if (/\d$/.test(d)) {
d += 'px';
};
return d;
};
+2
View File
@@ -15,6 +15,8 @@ limitations under the License.
*/
export default function getCurrentLanguage () {
const saved = localStorage.getItem('locale');
if (saved) return saved;
const locale = new Intl.Locale(navigator.language);
return locale.language;
};
+3 -3
View File
@@ -1,5 +1,5 @@
<!--
Copyright [yyyy] [name of copyright owner]
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.
@@ -21,7 +21,7 @@ const { t } = useI18n();
</script>
<template>
<div class="not-found">
<div class="not-found main-content-padding">
<span class="error-message">
{{ t('error.pageNotFound') }}
</span>
@@ -43,7 +43,7 @@ const { t } = useI18n();
.error-message{
margin: 0;
font-weight: 600;
font-size: 2vw;
font-size: 1.75rem;
}
#link {
align-items: center;
+5 -4
View File
@@ -19,7 +19,7 @@ import Searchbar from '../features/search/components/Searchbar.vue';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import logo from '@/assets/logo.svg';
import logo from '@/assets/images/logo.svg';
const { t } = useI18n();
const router = useRouter();
@@ -32,7 +32,7 @@ const submitSearch = function () {
</script>
<template>
<div class="search-content">
<div class="search-content main-content-padding">
<header class="global-header">
<img :src="logo" alt="Seekra" class="header-logo" />
<span class="slogan">
@@ -67,9 +67,10 @@ const submitSearch = function () {
max-width: 100%;
}
.slogan{
margin: 0;
.slogan {
margin-top: 1rem;
font-size: small;
line-height: normal;
}
.search-container {
+7
View File
@@ -21,6 +21,9 @@ import { createHtmlPlugin } from 'vite-plugin-html';
// https://vite.dev/config/
export default defineConfig({
build: {
target: 'es2020'
},
plugins: [
vue(),
createHtmlPlugin({
@@ -31,5 +34,9 @@ export default defineConfig({
alias: {
'@': path.resolve(__dirname, './src')
}
},
test: {
globals: false,
environment: 'jsdom'
}
})