Author SHA1 Message Date
jakob.scheid 3ad946dd7e style: remove unnecessary blank lines whitespace characters 2026-08-07 16:59:37 +02:00
jakob.scheid 203d222dc9 style: remove unnecessary semicolons after blocks 2026-08-07 16:58:19 +02:00
jakob.scheid 636f3c4be8 refactor(i18n): fix typo in language switch 2026-08-07 16:58:19 +02:00
jakob.scheid 977560ae7c style: ensure semicolons at line ends 2026-08-07 16:58:15 +02:00
jakob.scheid 05cf1df307 test: use describe in all test files 2026-08-07 16:44:31 +02:00
jakob.scheid c48b610e7a style: ensure newlines at the end of each file 2026-08-07 12:12:40 +02:00
jakob.scheidandGitea f4f8993e87 Merge pull request 'Feat(i18n): make language a setting' (#185) from feature/make-language-a-setting into main
Deploy on dev / Deploy on dev (push) Successful in 39s
Reviewed-on: #185
Reviewed-by: Jakob Gregory
2026-08-07 11:44:32 +02:00
jakob.scheid 31588b2f1c refactor(i18n): move locale updater to language switch button 2026-08-07 02:10:12 +02:00
jakob.scheid e964c2635f feat(i18n): add default language error handling
Set the default language to the fallback language if the navigator
language is not supported.
2026-08-07 01:04:19 +02:00
jakob.scheid 0fe98bb03d feat(i18n): set default language setting to navigator language 2026-08-07 00:52:58 +02:00
jakob.scheid 134fdabffc feat(i18n): load language on startup 2026-08-07 00:51:00 +02:00
jakob.scheid 788bea0016 refactor(i18n): move currentLanguage utility to correct location
Moved src/utils/currentLanguage.js to
src/features/i18n/utils/navigatorLanguage.js and updated it to only
parse the navigator language.
2026-08-07 00:50:13 +02:00
jakob.scheid b571a68e3b feat(i18n): use settings to set and get language 2026-08-07 00:33:17 +02:00
jakob.scheid d5f6108f47 feat(i18n): add language settings 2026-08-07 00:33:14 +02:00
jakob.scheid 3dd9eded41 feature(settings)!: make settings configuration a JavaScript module
The settings configuration is now a JavaScript module because dynamic
configuration is only possible in this way and dynamic settings
configuration is needed for the language as a setting.
2026-08-06 23:52:29 +02:00
jakob.scheidandGitea 8f44609702 Merge pull request 'Feat(legal): add legal notice' (#170) from feature/legal-notice into main
Deploy on dev / Deploy on dev (push) Successful in 34s
Reviewed-on: #170
Reviewed-by: Jakob Gregory
2026-08-06 23:43:56 +02:00
jakob.scheid 5dc3ddd549 feat(style): add style for JavaScript disabled
Add inline stylesheet which is used if JavaScript is disabled.
2026-08-06 23:43:34 +02:00
jakob.scheid f739676a1a feat(legal): add <noscript> containing legal notice link
Added a <noscript> with a link to the legal notice and a note to enable
JavaScript to use Seekra. This ensures that also users with JavaScript
disabled can access legal content.
2026-08-06 23:43:33 +02:00
jakob.scheid f73e2edae7 test(legal): add test for the legal notice view
Added tests for the legal notice view (LegalNoticeView.vue).
2026-08-06 23:43:33 +02:00
jakob.scheid f7358ceb34 test(legal): add tests for getLegalNotice 2026-08-06 23:43:33 +02:00
jakob.scheid f8d2086433 feat(legal): add legal notice loading indicator 2026-08-06 23:43:32 +02:00
jakob.scheid 7613122658 feat(legal): add legal notice error handling
The legal notice view now shows an error box if the legal notice could
not be loaded.
2026-08-06 23:43:32 +02:00
jakob.scheid b27c89945f fix(style): set link color to --dark variable
This change was necessary due to the dynamically loaded legal notice.
Links would have the default color without this fix.
2026-08-06 23:43:31 +02:00
jakob.scheid d9ed16f3de feat(legal): load and display legal notice 2026-08-06 23:43:31 +02:00
jakob.scheid 4c39fadd9a feat(legal): add legal notice link in the footer
Added a link to the legal notice in the footer and revised the layout of
the last footer segment: The segment is now a grid container and the
legal links (currently only the legal notice link) are displayed at the
end. The copyright notice remains centered. On small screens, the legal
links are displayed centered below the copyright notice.
2026-08-06 23:43:30 +02:00
jakob.scheid dccdab0d4a feat(legal): add route for the legal notice 2026-08-06 23:43:30 +02:00
jakob.scheidandGitea f6cf1e9777 Merge pull request 'Fix(settings): selection reactivity' (#184) from fix/selection-setting-reactivity into main
Deploy on dev / Deploy on dev (push) Successful in 38s
Reviewed-on: #184
Reviewed-by: Jakob Gregory
2026-08-06 23:41:53 +02:00
jakob.scheid 53bd67b7a0 test(settings): add tests for selection reactivity 2026-08-06 23:24:09 +02:00
jakob.scheid 1f51a0b3ae fix(settings): make selection reactive
Selection now uses computed with getter and setter.
2026-08-06 23:23:21 +02:00
jakob.scheidandGitea bcb0c05d74 Merge pull request 'Feat(color-scheme): make color scheme a setting' (#182) from feature/make-color-scheme-a-setting into main
Deploy on dev / Deploy on dev (push) Successful in 35s
Reviewed-on: #182
Reviewed-by: Jakob Gregory
2026-08-06 23:06:13 +02:00
jakob.scheid 069b75b3c5 test(color-scheme): add tests for color scheme button 2026-08-06 20:56:46 +02:00
jakob.scheid 589be2a5bc refactor(color-scheme): remove unused composable
Removed the unused composable 'useColorScheme.js'.
2026-08-06 20:26:04 +02:00
jakob.scheid 287f02505e feat(color-scheme): make color scheme a setting
The color scheme now uses settings to store the currect color scheme.
2026-08-06 20:23:57 +02:00
jakob.scheid ad6f59a85c fix(settings): fix useSettings getSetting store path 2026-08-06 20:23:50 +02:00
jakob.scheid f9a53a8911 feat(settings)!: make settings module import static
Migrated the settings to a static settings configuration import.
2026-08-06 19:38:52 +02:00
jakob.scheid f485a2d476 feat(color-scheme): add color scheme settings
Added an appearance settings section containing a color scheme setting
and translations for that.
2026-08-06 17:38:05 +02:00
jakob.scheidandGitea dde56c42ae Merge pull request 'Feat(settings): function to set settings' (#179) from feature/set-setting-function into main
Deploy on dev / Deploy on dev (push) Successful in 35s
Reviewed-on: #179
Reviewed-by: Jakob Gregory
2026-08-06 17:22:49 +02:00
jakob.scheid 15697144b1 feat(settings): add function to set settings
Added function setSetting, which sets a setting, to useSettings
composable.
2026-08-06 15:33:55 +02:00
jakob.scheidandGitea 396c025ec4 Merge pull request 'Fix(color-scheme): fix system color scheme initial loading' (#177) from fix/system-color-scheme-initial into main
Deploy on dev / Deploy on dev (push) Successful in 34s
Reviewed-on: #177
Reviewed-by: Jakob Gregory
2026-08-06 15:27:14 +02:00
jakob.scheid 30f6b82416 fix(color-scheme): set color scheme dummy value to null
Set the initial value of the color scheme ref to null to make setting it
to the actual value trigger a change if the color scheme is auto.
2026-08-06 15:06:30 +02:00
jakob.scheidandGitea 7b09cd74cb Merge pull request 'Feat(settings): function to get the value of a setting' (#168) from feature/setting-value-function into main
Deploy on dev / Deploy on dev (push) Successful in 38s
Reviewed-on: #168
Reviewed-by: Jakob Gregory
2026-08-06 12:50:51 +02:00
jakob.scheidandGitea fab7d7e901 test(settings): add test cases for empty setting value return value 2026-08-06 12:50:33 +02:00
jakob.scheidandGitea 04c148a3b1 feat(settings): make setting value function synchronous
Added a store to cache the settings configuration. The getSetting
function from useSettings is now synchronous and uses this cache.
The settings cache is set when loading the settings initially
(asnychronously).
2026-08-06 12:50:33 +02:00
jakob.scheidandGitea ff6ab7a78c test(settings): add tests for useSettings composable
Added some tests for the getSetting function from the useSettings
composable.
2026-08-06 12:50:33 +02:00
jakob.scheidandGitea 294fdab550 feat(settings): add composable for settings values
Added the composable useSettings that provides the function getSetting.
This function returns the set value of the setting, or otherwise the
default value. If there is no default value, it returns undefined.
2026-08-06 12:50:33 +02:00
jakob.scheidandGitea 26eec92f42 Merge pull request 'Fix(color-scheme): fix initial color scheme loading' (#171) from fix/initial-color-scheme into main
Deploy on dev / Deploy on dev (push) Successful in 35s
Reviewed-on: #171
Reviewed-by: Jakob Gregory
2026-08-06 12:50:01 +02:00
jakob.scheidandGitea a01abe67e1 fix(color-scheme): load color scheme initially
The color scheme is now loaded and set after registering the watcher to
ensure that the callback is also called initially.
2026-08-06 12:48:55 +02:00
jakob.scheidandGitea 3eaf81a465 Merge pull request 'Feat(footer): align footer segment items vertically centered' (#172) from fix/footer-segments-not-vertically-centered into main
Deploy on dev / Deploy on dev (push) Successful in 35s
Reviewed-on: #172
Reviewed-by: Jakob Gregory
2026-08-06 12:48:34 +02:00
jakob.scheidandGitea 2addd779a7 feat(footer): align footer segment items vertically centered 2026-08-06 12:48:18 +02:00
jakob.scheidandGitea 6afbf3efed Merge pull request 'Fix(settings): switch setting default value as store value fallback' (#176) from fix/switch-setting-default-value into main
Deploy on dev / Deploy on dev (push) Failing after 38s
Reviewed-on: #176
Reviewed-by: Jakob Gregory
2026-08-06 12:48:12 +02:00
jakob.scheidandGitea dca7f2176b test(settings): add tests for switch
Added test cases for the switch to test the default value functionality.
2026-08-06 12:48:00 +02:00
jakob.scheidandGitea 60d3296816 fix(settings): use switch default value as store value fallback
The switch now uses the default value as a fallback for the stored value
and updates the switch state when stored value changes.
2026-08-06 12:48:00 +02:00
jakob.scheidandGitea b0642cc826 Merge pull request 'Fix(sidebar): hide sidebar instead of removing it when it is not expanded' (#174) from fix/settings-view into main
Deploy on dev / Deploy on dev (push) Successful in 35s
Reviewed-on: #174
Reviewed-by: Jakob Gregory
2026-08-06 12:47:40 +02:00
jakob.scheidandGitea 32555d9acb fix(sidebar): hide sidebar instead of removing it
Previously, the sidebar was fully removed from DOM. This could cause
unwanted behavior, such as tests which test its content failing. Now, it
is just hidden. This does not affect tests any more.
2026-08-05 20:46:35 +02:00
jakob.scheidandGitea d1e95582a8 Merge pull request 'Fix(sidebar): hide sidebar border on small screens' (#173) from fix/sidebar-border-on-small-screens into main
Deploy on dev / Deploy on dev (push) Successful in 40s
Reviewed-on: #173
Reviewed-by: Jakob Gregory
2026-08-05 20:44:38 +02:00
jakob.scheid e5f27041d2 fix(sidebar): hide border on small screens 2026-08-05 19:33:34 +02:00
jakob.scheidandGitea baa924a2b2 Merge pull request 'Feat(fonts): Add font' (#162) from feature/fonts into main
Deploy on dev / Deploy on dev (push) Successful in 34s
Reviewed-on: #162
Reviewed-by: Jakob Gregory
2026-08-04 19:41:49 +02:00
84 changed files with 1514 additions and 1255 deletions
+1 -1
View File
@@ -42,4 +42,4 @@ jobs:
printf "Update current build pointer ... " printf "Update current build pointer ... "
echo "$BUILD_ID" | mc pipe "minio/$BUCKET/current" echo "$BUILD_ID" | mc pipe "minio/$BUCKET/current"
echo "done" echo "done"
+1 -1
View File
@@ -1,3 +1,3 @@
.vscode/ .vscode/
node_modules/ node_modules/
dist/ dist/
+1 -1
View File
@@ -5,4 +5,4 @@ This software is provided "as is", without warranty of any kind.
This software uses the Vue.js framework. This software uses the Vue.js framework.
This software makes use of various open-source components. This software makes use of various open-source components.
See THIRD_PARTY_LICENSES for details. See THIRD_PARTY_LICENSES for details.
+1 -1
View File
@@ -8,4 +8,4 @@ Please execute the setup script in `scripts/` for your platform when cloning the
## Legal ## Legal
After each update to the dependencies, please run the script `scripts/generate-license-files.js` to regenerate the third-party license files. After each update to the dependencies, please run the script `scripts/generate-license-files.js` to regenerate the third-party license files.
+1 -1
View File
@@ -13,4 +13,4 @@
- Internationalization and some major languages - Internationalization and some major languages
- Color scheme - Color scheme
- Search results view with error message which indicates that the search is not available - Search results view with error message which indicates that the search is not available
- Settings renderer - Settings renderer
+34
View File
@@ -21,8 +21,42 @@ limitations under the License.
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0158D2" /> <meta name="theme-color" content="#0158D2" />
<title>Seekra</title> <title>Seekra</title>
<noscript>
<style>
body {
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
a {
color: #000;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>
</noscript>
</head> </head>
<body> <body>
<noscript>
<h1>
Seekra
</h1>
<p>
You need to enable JavaScript to use Seekra.
</p>
<section>
<h2>Legal Content</h2>
<ul>
<li>
<a href="https://assets.seekra.jcloud-services.ddns.net/2026-08-04-76a067e/legal/legal_notice.html">
Legal Notice
</a>
</li>
</ul>
</section>
</noscript>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
+1 -1
View File
@@ -4,4 +4,4 @@
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
} }
} }
+1 -1
View File
@@ -55,4 +55,4 @@ await generateLicenseFile(
'./THIRD_PARTY_LICENSES_DEV' './THIRD_PARTY_LICENSES_DEV'
); );
await fs.rename(packageJsonBackup, packageJson); await fs.rename(packageJsonBackup, packageJson);
+1 -1
View File
@@ -14,4 +14,4 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
git config --local commit.template .gitmessage git config --local commit.template .gitmessage
+1 -1
View File
@@ -14,4 +14,4 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
git config --local commit.template .gitmessage git config --local commit.template .gitmessage
+19 -10
View File
@@ -18,36 +18,45 @@ limitations under the License.
import Navbar from './features/nav/components/Navbar.vue'; import Navbar from './features/nav/components/Navbar.vue';
import Footer from './features/footer/components/Footer.vue'; import Footer from './features/footer/components/Footer.vue';
import { LANGUAGE_PATH } from './config/featureSettings.js';
import { updatePageTitle } from './router'; import { updatePageTitle } from './router';
import { useColorScheme } from './features/colorScheme/composables/useColorScheme'; import { useSettings } from './features/settings/composables/useSettings.js';
import { ref, provide, watch, watchEffect } from 'vue'; import { setLocale } from './i18n';
import { computed, onMounted, watch, watchEffect } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
const route = useRoute(); const route = useRoute();
const { getColorScheme, updateColorScheme } = useColorScheme(); const { getSetting } = useSettings();
const colorScheme = ref(getColorScheme()); const colorScheme = computed(() => getSetting(['appearance', 'colorScheme']));
provide('colorScheme', colorScheme);
watch(colorScheme, (newValue) => { const updateColorScheme = function updateColorScheme (newColorScheme) {
updateColorScheme(newValue);
document.body.style.setProperty(colorScheme, { document.body.style.setProperty(colorScheme, {
auto: 'normal', auto: 'normal',
dark: 'dark', dark: 'dark',
light: 'light' light: 'light'
}); });
if (newValue === 'dark') { if (newColorScheme === 'dark') {
document.body.classList.add('dark'); document.body.classList.add('dark');
} else { } else {
document.body.classList.remove('dark'); document.body.classList.remove('dark');
} }
if (newValue === 'auto') { if (newColorScheme === 'auto') {
document.body.classList.add('color-scheme-auto'); document.body.classList.add('color-scheme-auto');
} else { } else {
document.body.classList.remove('color-scheme-auto'); document.body.classList.remove('color-scheme-auto');
} }
};
watch(colorScheme, (newColorScheme) => {
updateColorScheme(newColorScheme);
}); });
updateColorScheme(colorScheme.value);
watchEffect(() => updatePageTitle(route)); watchEffect(() => updatePageTitle(route));
onMounted(() => {
setLocale(getSetting(LANGUAGE_PATH));
});
</script> </script>
<template> <template>
@@ -81,4 +90,4 @@ watchEffect(() => updatePageTitle(route));
--main-content-padding-x: 15px; --main-content-padding-x: 15px;
} }
} }
</style> </style>
+1 -1
View File
@@ -23,4 +23,4 @@ limitations under the License.
stroke-linejoin="round" stroke-linejoin="round"
d="M5 12 l6 8 l8 -16" d="M5 12 l6 8 l8 -16"
/> />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 832 B

After

Width:  |  Height:  |  Size: 833 B

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

Before

Width:  |  Height:  |  Size: 830 B

After

Width:  |  Height:  |  Size: 831 B

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

Before

Width:  |  Height:  |  Size: 833 B

After

Width:  |  Height:  |  Size: 834 B

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

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

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

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

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

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

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

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

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

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

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

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

+17
View File
@@ -0,0 +1,17 @@
/*
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 LANGUAGE_PATH = ['general', 'language'];
@@ -17,12 +17,14 @@ limitations under the License.
<script setup> <script setup>
import Icon from '@/features/icons/components/Icon.vue'; import Icon from '@/features/icons/components/Icon.vue';
import { inject } from 'vue'; import { useSettings } from '@/features/settings/composables/useSettings';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
const { getSetting, setSetting } = useSettings();
const colorScheme = inject('colorScheme'); const colorScheme = computed(() => getSetting(['appearance', 'colorScheme']));
const colorSchemeNextMapper = { const colorSchemeNextMapper = {
'light': 'dark', 'light': 'dark',
@@ -39,11 +41,15 @@ const colorSchemeIconMapper = {
const getTooltipTranslation = function (colorScheme) { const getTooltipTranslation = function (colorScheme) {
return t(`preferences.colorScheme.switch.${colorSchemeNextMapper[colorScheme]}`); return t(`preferences.colorScheme.switch.${colorSchemeNextMapper[colorScheme]}`);
}; };
const setColorScheme = function setColorScheme (newColorScheme) {
setSetting(['appearance', 'colorScheme'], newColorScheme);
};
</script> </script>
<template> <template>
<button class="color-scheme-button" <button class="color-scheme-button"
@click="colorScheme = colorSchemeNextMapper[colorScheme]" @click="setColorScheme(colorSchemeNextMapper[colorScheme])"
:aria-label="getTooltipTranslation(colorScheme)" :aria-label="getTooltipTranslation(colorScheme)"
:title="getTooltipTranslation(colorScheme)" :title="getTooltipTranslation(colorScheme)"
> >
@@ -72,4 +78,4 @@ const getTooltipTranslation = function (colorScheme) {
.color-scheme-button:hover { .color-scheme-button:hover {
background: var(--light-hover); background: var(--light-hover);
} }
</style> </style>
@@ -0,0 +1,47 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import ColorSchemeButton from '../ColorSchemeButton.vue';
import { useSettings } from '@/features/settings/composables/useSettings.js';
import { mountComponent } from '@/test-utils/mountComponent';
import { describe, expect, test } from 'vitest';
const { getSetting } = useSettings();
const colorSchemeSettingPath = ['appearance', 'colorScheme'];
const getWrapper = function getWrapper () {
return mountComponent(ColorSchemeButton);
};
describe('ColorSchemeButton', () => {
test('sets color scheme setting', async () => {
const wrapper = getWrapper();
expect(getSetting(colorSchemeSettingPath)).toBe('auto');
const colorSchemeButton = wrapper.find('button');
await colorSchemeButton.trigger('click');
expect(getSetting(colorSchemeSettingPath)).toBe('light');
await colorSchemeButton.trigger('click');
expect(getSetting(colorSchemeSettingPath)).toBe('dark');
await colorSchemeButton.trigger('click');
expect(getSetting(colorSchemeSettingPath)).toBe('auto');
});
});
@@ -1,39 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export const useColorScheme = function () {
const getColorScheme = function () {
let colorScheme = localStorage.getItem('colorScheme') || 'auto';
if (!(colorScheme === 'dark' || colorScheme === 'light' || colorScheme === 'auto')) {
colorScheme = 'auto';
};
return colorScheme;
};
const updateColorScheme = function (newScheme) {
let actualNewScheme = newScheme;
if (!(actualNewScheme === 'dark' || actualNewScheme === 'light' || actualNewScheme === 'auto')) {
actualNewScheme = 'auto';
};
localStorage.setItem('colorScheme', actualNewScheme);
};
return {
getColorScheme,
updateColorScheme
};
};
+5 -4
View File
@@ -21,13 +21,13 @@ import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
const startYear = 2026 const startYear = 2026;
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear();
const copyrightPeriod = const copyrightPeriod =
startYear === currentYear startYear === currentYear
? `${currentYear}` ? `${currentYear}`
: `${startYear}-${currentYear}` : `${startYear}-${currentYear}`;
</script> </script>
@@ -66,6 +66,7 @@ const copyrightPeriod =
.footer-segment { .footer-segment {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center;
gap: 32px; gap: 32px;
padding: var(--padding-y); padding: var(--padding-y);
background-color: var(--light-bg); background-color: var(--light-bg);
@@ -102,4 +103,4 @@ const copyrightPeriod =
margin-top: 0.8em; margin-top: 0.8em;
} }
} }
</style> </style>
@@ -17,29 +17,31 @@ limitations under the License.
<script setup> <script setup>
import Icon from '@/features/icons/components/Icon.vue'; import Icon from '@/features/icons/components/Icon.vue';
import { ref } from 'vue'; import { LANGUAGE_PATH } from '@/config/featureSettings';
import { useSettings } from '@/features/settings/composables/useSettings';
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { loadLanguage, LANGUAGES_RTL, SUPPORTED_LANGUAGES } from '@/i18n'; import { setLocale, SUPPORTED_LANGUAGES } from '@/i18n';
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const { setSetting, getSetting } = useSettings();
const isOpen = ref(false); const isOpen = ref(false);
const languageDropdown = ref(null); const languageDropdown = ref(null);
async function selectLanguage(code) { const selectLanguage = function selectLanguage (code) {
await loadLanguage(code); setSetting(LANGUAGE_PATH, code);
localStorage.setItem('locale', code);
document.documentElement.lang = code;
document.documentElement.dir = LANGUAGES_RTL.includes(code) ? 'rtl' : 'ltr';
close(); close();
}; };
watch(() => getSetting(LANGUAGE_PATH), setLocale);
const close = function () { const close = function () {
document.removeEventListener('click', closeWrapperOnClickOutsite); document.removeEventListener('click', closeWrapperOnClickOutside);
isOpen.value = false; isOpen.value = false;
}; };
const closeWrapperOnClickOutsite = function (e) { const closeWrapperOnClickOutside = function (e) {
if (languageDropdown.value) { if (languageDropdown.value) {
if (!languageDropdown.value.contains(e.target)) { if (!languageDropdown.value.contains(e.target)) {
close(); close();
@@ -51,7 +53,7 @@ const open = function () {
if (!isOpen.value) { if (!isOpen.value) {
isOpen.value = true; isOpen.value = true;
setTimeout(() => { setTimeout(() => {
document.addEventListener('click', closeWrapperOnClickOutsite); document.addEventListener('click', closeWrapperOnClickOutside);
}, 0); }, 0);
}; };
}; };
@@ -161,4 +163,4 @@ const open = function () {
.flag { .flag {
font-size: 1.1rem; font-size: 1.1rem;
} }
</style> </style>
@@ -14,84 +14,57 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { expect, test, describe, vi, beforeEach } from 'vitest'; import { LANGUAGE_PATH } from '@/config/featureSettings.js';
import { mount } from '@vue/test-utils'; import { useSettings } from '@/features/settings/composables/useSettings.js';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { expect, test, describe } from 'vitest';
import LanguageSwitchButton from '../LanguageSwitchButton.vue'; import LanguageSwitchButton from '../LanguageSwitchButton.vue';
import { loadLanguage } from '@/i18n';
vi.mock('@/i18n', () => ({ const getWrapper = function getWrapper () {
loadLanguage: vi.fn(() => Promise.resolve()), return mountComponent(LanguageSwitchButton);
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', () => { describe('LanguageSwitchButton', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
document.documentElement.lang = '';
document.documentElement.dir = '';
});
test('renders correctly with initial state closed', () => { test('renders correctly with initial state closed', () => {
const wrapper = mount(LanguageSwitchButton); const wrapper = getWrapper();
expect(wrapper.find('.language-button').exists()).toBe(true); expect(wrapper.find('.language-button').exists()).toBe(true);
expect(wrapper.find('.language-dropdown').exists()).toBe(false); expect(wrapper.find('.language-dropdown').exists()).toBe(false);
expect(wrapper.find('.language-button').attributes('aria-expanded')).toBe('false'); expect(wrapper.find('.language-button').attributes('aria-expanded')).toBe('false');
}); });
test('opens the dropdown when language button is clicked', async () => { test('opens the dropdown when language button is clicked', async () => {
const wrapper = mount(LanguageSwitchButton); const wrapper = getWrapper();
const button = wrapper.find('.language-button'); const button = wrapper.find('.language-button');
await button.trigger('click'); await button.trigger('click');
expect(wrapper.find('.language-dropdown').exists()).toBe(true); expect(wrapper.find('.language-dropdown').exists()).toBe(true);
expect(button.attributes('aria-expanded')).toBe('true'); expect(button.attributes('aria-expanded')).toBe('true');
}); });
const languageTestCases = [ test('sets the language when clicking it', async () => {
{ code: 'en', expectedDir: 'ltr' }, const { getSetting } = useSettings();
{ code: 'de', expectedDir: 'ltr' },
{ code: 'ar', expectedDir: 'rtl' } const wrapper = getWrapper();
];
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'); 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); const options = wrapper.findAll('.language-dropdown li');
expect(localStorage.getItem('locale')).toBe(code);
expect(document.documentElement.lang).toBe(code); const enOption = options.find(opt => opt.text().includes('en'));
expect(document.documentElement.dir).toBe(expectedDir); await enOption.trigger('click');
expect(getSetting(LANGUAGE_PATH)).toBe('en');
expect(wrapper.find('.language-dropdown').exists()).toBe(false);
const deOption = options.find(opt => opt.text().includes('de'));
await deOption.trigger('click');
expect(getSetting(LANGUAGE_PATH)).toBe('de');
expect(wrapper.find('.language-dropdown').exists()).toBe(false); expect(wrapper.find('.language-dropdown').exists()).toBe(false);
}); });
test('closes the dropdown when clicking outside the component', async () => { test('closes the dropdown when clicking outside the component', async () => {
const wrapper = mount(LanguageSwitchButton, { const wrapper = getWrapper();
attachTo: document.body
});
await wrapper.find('.language-button').trigger('click'); await wrapper.find('.language-button').trigger('click');
expect(wrapper.find('.language-dropdown').exists()).toBe(true); expect(wrapper.find('.language-dropdown').exists()).toBe(true);
@@ -100,10 +73,10 @@ describe('LanguageSwitchButton', () => {
const externalDiv = document.createElement('div'); const externalDiv = document.createElement('div');
document.body.appendChild(externalDiv); document.body.appendChild(externalDiv);
const clickEvent = new MouseEvent('click', { bubbles: true }); const clickEvent = new MouseEvent('click', { bubbles: true });
externalDiv.dispatchEvent(clickEvent); externalDiv.dispatchEvent(clickEvent);
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(wrapper.find('.language-dropdown').exists()).toBe(false); expect(wrapper.find('.language-dropdown').exists()).toBe(false);
@@ -111,4 +84,4 @@ describe('LanguageSwitchButton', () => {
wrapper.unmount(); wrapper.unmount();
externalDiv.remove(); externalDiv.remove();
}); });
}); });
@@ -0,0 +1,51 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { getNavigatorLanguage } from '../navigatorLanguage';
import { describe, expect, test } from 'vitest';
describe('navigatorLanguage', () => {
test.for([
{ navigatorLanguage: 'en', expected: 'en'},
{ navigatorLanguage: 'de', expected: 'de'},
{ navigatorLanguage: 'fr', expected: 'fr'},
{ navigatorLanguage: 'en-US', expected: 'en'},
{ navigatorLanguage: 'en-AU', expected: 'en'},
{ navigatorLanguage: 'de-DE', expected: 'de'},
{ navigatorLanguage: 'fr-FR', expected: 'fr'},
{ navigatorLanguage: 'en-us', expected: 'en'},
{ navigatorLanguage: 'en-au', expected: 'en'},
{ navigatorLanguage: 'de-de', expected: 'de'},
{ navigatorLanguage: 'fr-fr', expected: 'fr'},
{ navigatorLanguage: 'zh-Hans-CN', expected: 'zh'},
{ navigatorLanguage: 'zh-Hant-TW', expected: 'zh'},
{ navigatorLanguage: 'uz-Latn-UZ', expected: 'uz'},
{ navigatorLanguage: 'en-US-u-ca-gregory', expected: 'en'},
{ navigatorLanguage: 'de-DE-u-co-phonebk', expected: 'de'},
{ navigatorLanguage: 'zh-Hant-TW-u-co-phonebk', expected: 'zh'}
])('returns language $expected with navigator language $navigatorLanguage', ({ navigatorLanguage, expected }) => {
Object.defineProperty(navigator, 'language', {
value: navigatorLanguage,
configurable: true
});
expect(getNavigatorLanguage()).toBe(expected);
});
});
@@ -14,9 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export default function getCurrentLanguage () { export const getNavigatorLanguage = function getNavigatorLanguage () {
const saved = localStorage.getItem('locale');
if (saved) return saved;
const locale = new Intl.Locale(navigator.language); const locale = new Intl.Locale(navigator.language);
return locale.language; return locale.language;
}; };
+5 -5
View File
@@ -27,16 +27,16 @@ const props = defineProps({
type: [Number, String], type: [Number, String],
default: 24 default: 24
} }
}) });
const icons = import.meta.glob('@/assets/icons/*.svg', { const icons = import.meta.glob('@/assets/icons/*.svg', {
eager: true, eager: true,
import: 'default' import: 'default'
}) });
const Icon = computed(() => icons[`/src/assets/icons/${props.name}.svg`]) const Icon = computed(() => icons[`/src/assets/icons/${props.name}.svg`]);
const size = computed(() => ensureUnit(props.size)) const size = computed(() => ensureUnit(props.size));
</script> </script>
<template> <template>
@@ -51,4 +51,4 @@ const size = computed(() => ensureUnit(props.size))
align-items: center; align-items: center;
filter: var(--invert); filter: var(--invert);
} }
</style> </style>
+1 -1
View File
@@ -18,5 +18,5 @@ export class FetchError extends Error {
constructor (message) { constructor (message) {
super(message); super(message);
this.name = 'FetchError'; this.name = 'FetchError';
}; }
}; };
@@ -40,4 +40,4 @@ watch(() => route.query.q, q => {
v-model="searchQueryModel" v-model="searchQueryModel"
auto-submit auto-submit
/> />
</template> </template>
+5 -5
View File
@@ -24,8 +24,8 @@ import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter() const router = useRouter();
const props = defineProps(['autoSubmit']) const props = defineProps(['autoSubmit']);
const submitSearch = function () { const submitSearch = function () {
if (props.autoSubmit !== undefined) { if (props.autoSubmit !== undefined) {
@@ -33,8 +33,8 @@ const submitSearch = function () {
name: 'searchResults', name: 'searchResults',
query: { q: searchQuery.value } query: { q: searchQuery.value }
}); });
}; }
} };
</script> </script>
<template> <template>
@@ -114,4 +114,4 @@ const submitSearch = function () {
.search-form { .search-form {
width: 100%; width: 100%;
} }
</style> </style>
@@ -94,4 +94,4 @@ const { t } = useI18n();
transform: scale(1); transform: scale(1);
} }
} }
</style> </style>
@@ -54,10 +54,9 @@ const props = defineProps({
const store = useSettingsStore(); const store = useSettingsStore();
const optionType = computed(() => props.setting.allowMultiple ? 'checkbox' : 'radio'); const optionType = computed(() => props.setting.allowMultiple ? 'checkbox' : 'radio');
const selected = ref(normalizeSelectedValue(store.get(props.path) ?? props.setting.default)); const selected = computed({
get: () => normalizeSelectedValue(store.get(props.path) ?? props.setting.default),
watch(selected, (newValue) => { set: (value) => store.set(props.path, value)
store.set(props.path, newValue);
}); });
const labelId = useId(); const labelId = useId();
@@ -123,4 +122,4 @@ const labelId = useId();
.option-input:checked + .option-check-mark-wrapper .option-check-mark { .option-input:checked + .option-check-mark-wrapper .option-check-mark {
visibility: visible; visibility: visible;
} }
</style> </style>
@@ -16,4 +16,4 @@
.settings-button:hover { .settings-button:hover {
background-color: var(--light-hover); background-color: var(--light-hover);
} }
@@ -200,7 +200,7 @@ const apply = function apply () {
opacity: 1; opacity: 1;
} }
} }
@keyframes done-button-disappear { @keyframes done-button-disappear {
0% { 0% {
opacity: 1; opacity: 1;
@@ -212,4 +212,4 @@ const apply = function apply () {
} }
} }
} }
</style> </style>
@@ -90,4 +90,4 @@ const props = defineProps({
.section-heading[aria-level="4"] { .section-heading[aria-level="4"] {
--size: 1.4em; --size: 1.4em;
} }
</style> </style>
@@ -58,4 +58,4 @@ const followLink = function followLink () {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
</style> </style>
+12 -4
View File
@@ -16,7 +16,7 @@ limitations under the License.
<script setup> <script setup>
import { useSettingsStore } from '../stores/settingsStore'; import { useSettingsStore } from '../stores/settingsStore';
import { useId } from 'vue'; import { ref, useId, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
@@ -35,8 +35,16 @@ const switchId = useId();
const store = useSettingsStore(); const store = useSettingsStore();
const enabled = ref(null);
watch(store, (newStore) => {
enabled.value = newStore.get(props.path);
});
// set value after registering watcher to avoid immediate value change
enabled.value = store.get(props.path) ?? props.setting.default;
const toggle = function toggle () { const toggle = function toggle () {
store.set(props.path, !store.get(props.path)); enabled.value = !enabled.value;
store.set(props.path, enabled.value);
}; };
</script> </script>
@@ -47,7 +55,7 @@ const toggle = function toggle () {
</label> </label>
<div <div
class="switch-wrapper" class="switch-wrapper"
:class="{ enabled: store.get(props.path) }" :class="{ enabled }"
:title="t('settings.switch.title')" :title="t('settings.switch.title')"
:aria-label="t('settings.switch.ariaLabel')" :aria-label="t('settings.switch.ariaLabel')"
:aria-labelledby="labelId" :aria-labelledby="labelId"
@@ -98,4 +106,4 @@ const toggle = function toggle () {
.switch-wrapper.enabled .switch-point { .switch-wrapper.enabled .switch-point {
margin-left: var(--point-size); margin-left: var(--point-size);
} }
</style> </style>
@@ -90,6 +90,32 @@ describe('Selection', () => {
expect(inputsChecked).toStrictEqual([false, true, false]); expect(inputsChecked).toStrictEqual([false, true, false]);
}); });
test('reacts on store change', async () => {
const wrapper = getWrapper({
options: exampleOptions,
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.set('selection', 'o1');
}
}
});
const settings = useSettingsStore();
{
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([false, true, false]);
}
{
settings.set('selection', 'o2');
await nextTick();
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([false, false, true]);
}
});
test('shows multiple values from store', () => { test('shows multiple values from store', () => {
const wrapper = getWrapper({ const wrapper = getWrapper({
options: exampleOptions, options: exampleOptions,
@@ -211,7 +237,7 @@ describe('Selection', () => {
const inputO2 = inputs[2]; const inputO2 = inputs[2];
await check(inputO0); await check(inputO0);
expectSelectionValue(['o0']) expectSelectionValue(['o0']);
await uncheck(inputO0); await uncheck(inputO0);
expectSelectionValue([]); expectSelectionValue([]);
@@ -264,4 +290,4 @@ describe('Selection', () => {
const label = wrapper.find('.label'); const label = wrapper.find('.label');
expect(label.text()).toBe(translation); expect(label.text()).toBe(translation);
}); });
}); });
@@ -32,7 +32,7 @@ const getWrapper = function getWrapper({ type = 'string', i18n = 'input', defaul
path: 'input' path: 'input'
} }
}, {}, [], piniaOptions); }, {}, [], piniaOptions);
} };
describe('SettingsInput', () => { describe('SettingsInput', () => {
test('uses correct input type', () => { test('uses correct input type', () => {
@@ -173,15 +173,15 @@ describe('SettingsInput', () => {
await form.trigger('submit'); await form.trigger('submit');
expect(form.classes('done-button-shown')).toBeFalsy(); expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue('a'); await inputElement.setValue('a');
expect(form.classes('done-button-shown')).toBeTruthy(); expect(form.classes('done-button-shown')).toBeTruthy();
const store = useSettingsStore(); const store = useSettingsStore();
store.set('input', 'a'); store.set('input', 'a');
await nextTick(); await nextTick();
expect(form.classes('done-button-shown')).toBeFalsy(); expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue(''); await inputElement.setValue('');
store.set('input', ''); store.set('input', '');
await nextTick(); await nextTick();
@@ -207,4 +207,4 @@ describe('SettingsInput', () => {
const label = wrapper.find('label'); const label = wrapper.find('label');
expect(label.text()).toBe(translation); expect(label.text()).toBe(translation);
}); });
}); });
@@ -157,4 +157,4 @@ describe('SettingsPage', () => {
} }
}); });
}); });
}); });
@@ -56,4 +56,4 @@ describe('SettingsPageLink', () => {
expect(goToSettingsPage).toHaveBeenCalledWith(target); expect(goToSettingsPage).toHaveBeenCalledWith(target);
}); });
}); });
@@ -20,17 +20,23 @@ import { useSettingsStore } from '../../stores/settingsStore.js';
import { expect, describe, test } from 'vitest'; import { expect, describe, test } from 'vitest';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
const getWrapper = function getWrapper ({ i18n = 'switch1', translations = {} } = {}) { const getWrapper = function getWrapper ({
i18n = 'switch1',
defaultValue = undefined,
translations = {},
piniaOptions = {}
} = {}) {
return mountComponent(Switch, { return mountComponent(Switch, {
attrs: { attrs: {
setting: { setting: {
type: 'bool', type: 'bool',
name: 'switch', name: 'switch',
i18n i18n,
default: defaultValue
}, },
path: 'switch' path: 'switch'
} }
}, translations); }, translations, [], piniaOptions);
}; };
describe('Switch', () => { describe('Switch', () => {
@@ -53,31 +59,67 @@ describe('Switch', () => {
expect(switchElementClasses2).not.toContain('enabled'); expect(switchElementClasses2).not.toContain('enabled');
}); });
test('shows default value', () => {
const wrapper = getWrapper({
defaultValue: true
});
const switchElement = wrapper.find('.switch-wrapper');
expect(switchElement.classes('enabled')).toBeTruthy();
});
test('shows stored value instead of default value', () => {
const wrapper = getWrapper({
defaultValue: true,
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.set('switch', false);
}
}
});
const switchElement = wrapper.find('.switch-wrapper');
expect(switchElement.classes('enabled')).toBeFalsy();
});
test('reacts on store change', async () => {
const wrapper = getWrapper();
const store = useSettingsStore();
const switchElement = wrapper.find('.switch-wrapper');
expect(switchElement.classes('enabled')).toBeFalsy();
store.set('switch', true);
await nextTick();
expect(switchElement.classes('enabled')).toBeTruthy();
});
test('toggles value in store', async () => { test('toggles value in store', async () => {
const wrapper = getWrapper(); const wrapper = getWrapper();
const switchElement = wrapper.find('.switch-wrapper'); const switchElement = wrapper.find('.switch-wrapper');
const store = useSettingsStore(); const store = useSettingsStore();
expect(store.get('switch')).toBeUndefined(); expect(store.get('switch')).toBeUndefined();
switchElement.trigger('click'); switchElement.trigger('click');
await nextTick(); await nextTick();
expect(store.get('switch')).toBe(true); expect(store.get('switch')).toBe(true);
switchElement.trigger('click'); switchElement.trigger('click');
await nextTick(); await nextTick();
expect(store.get('switch')).toBe(false); expect(store.get('switch')).toBe(false);
}); });
test('shows correct translation', () => { test('shows correct translation', () => {
const translation = 'Switch'; const translation = 'Switch';
const wrapper = getWrapper({ const wrapper = getWrapper({
translations: { switch1: translation } translations: { switch1: translation }
}) });
const label = wrapper.find('label'); const label = wrapper.find('label');
expect(label.text()).toBe(translation); expect(label.text()).toBe(translation);
}); });
}); });
@@ -0,0 +1,91 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useSettings } from '../useSettings';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { beforeEach, describe, expect, test, vi } from 'vitest';
const settingDefaultValue = vi.hoisted(() => 42);
const settingPath = ['test', 'number'];
vi.mock('../../config.js', () => ({
default: {
contents: [
{
name: 'test',
i18n: '',
content: [
{
type: 'number',
i18n: '',
name: 'number',
default: settingDefaultValue
},
{
type: 'bool',
i18n: '',
name: 'bool'
}
]
}
]
}
}));
describe('useSettings', () => {
describe('getSetting', () => {
test('returns stored value', () => {
const { getSetting, setSetting } = useSettings();
const key = ['setting'];
const value = 42;
setSetting(key, value);
expect(getSetting(key)).toBe(value);
});
test('returns default value', () => {
const { getSetting, setSetting } = useSettings();
expect(getSetting(settingPath)).toBe(settingDefaultValue);
});
test('returns stored value instead of default value', () => {
const { getSetting, setSetting } = useSettings();
const value = 43;
expect(getSetting(settingPath)).toBe(settingDefaultValue);
setSetting(settingPath, value);
expect(getSetting(settingPath)).toBe(value);
});
test('returns undefined if there is no value', () => {
const { getSetting } = useSettings();
expect(getSetting(['test', 'bool'])).toBeUndefined();
});
});
});
beforeEach(() => {
setActivePinia(
createTestingPinia({
createSpy: vi.fn,
stubActions: false
})
);
});
@@ -37,4 +37,4 @@ describe('useSettingsPage', () => {
}); });
}); });
}); });
}); });
@@ -0,0 +1,52 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import settings from '../config.js';
import { useSettingsStore } from '../stores/settingsStore';
import { getSettingRecursively } from '../utils/getSetting';
/**
* Provides access to the stored settings.
*/
export const useSettings = function useSettings () {
/**
* Returns the value of a specific setting.
* @param {string[]} key - The settings key.
* @return The value of the setting.
*/
const getSetting = function getSetting (key) {
const settingsStore = useSettingsStore();
return (
settingsStore.get(key.join('.')) ??
getSettingRecursively(
key, settings.contents ?? []
)?.default
);
};
/**
* Sets the value of a specific setting.
* @param {string[]} key - The setting key.
* @param value - The new value for the setting.
*/
const setSetting = function setSetting (key, value) {
const settingsStore = useSettingsStore();
settingsStore.set(key.join('.'), value);
};
return { getSetting, setSetting };
};
@@ -1,48 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
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,
};
}
@@ -30,4 +30,4 @@ export const useSettingsPage = function useSettingsPage () {
}; };
return { goToSettingsPage }; return { goToSettingsPage };
}; };
+75
View File
@@ -0,0 +1,75 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { getNavigatorLanguage } from '../i18n/utils/navigatorLanguage';
import { fallbackLocale, SUPPORTED_LANGUAGES } from '@/i18n';
let defaultLanguage;
{
const navigatorLanguage = getNavigatorLanguage();
if (SUPPORTED_LANGUAGES.includes(navigatorLanguage)) {
defaultLanguage = navigatorLanguage;
} else {
defaultLanguage = fallbackLocale;
}
}
export default {
contents: [
{
name: 'general',
i18n: 'settings.settings.general.title',
content: [
{
name: 'language',
type: 'selection',
i18n: 'settings.settings.general.settings.language.title',
default: defaultLanguage,
options: SUPPORTED_LANGUAGES.map((language) => ({
name: language,
i18n: `preferences.locale.languages.${language}`
}))
}
]
},
{
name: 'appearance',
i18n: 'settings.settings.appearance.title',
content: [
{
name: 'colorScheme',
type: 'selection',
i18n: 'settings.settings.appearance.contents.colorScheme.title',
default: 'auto',
options: [
{
name: 'auto',
i18n: 'settings.settings.appearance.contents.colorScheme.options.auto'
},
{
name: 'light',
i18n: 'settings.settings.appearance.contents.colorScheme.options.light'
},
{
name: 'dark',
i18n: 'settings.settings.appearance.contents.colorScheme.options.dark'
}
]
}
]
}
]
};
-3
View File
@@ -1,3 +0,0 @@
{
"contents": []
}
@@ -39,4 +39,4 @@ export const useSettingsStore = defineStore('settings', () => {
return { set, get, remove, clear, settings }; return { set, get, remove, clear, settings };
}, { }, {
persist: true persist: true
}); });
@@ -62,4 +62,4 @@ limitations under the License.
* @typedef {BoolSettingConfig | NumberSettingConfig | StringSettingConfig | SelectionSettingConfig | SectionSettingConfig} SettingConfigEntry * @typedef {BoolSettingConfig | NumberSettingConfig | StringSettingConfig | SelectionSettingConfig | SectionSettingConfig} SettingConfigEntry
* @typedef {{ contents: SettingConfigEntry[] }} FirstLevelSettingConfigEntry * @typedef {{ contents: SettingConfigEntry[] }} FirstLevelSettingConfigEntry
* @typedef {{ contents: FirstLevelSettingConfigEntry[] }} SettingsConfig * @typedef {{ contents: FirstLevelSettingConfigEntry[] }} SettingsConfig
*/ */
@@ -15,512 +15,514 @@ limitations under the License.
*/ */
import { getSetting, getSettingRecursively } from '../getSetting.js'; import { getSetting, getSettingRecursively } from '../getSetting.js';
import { test, expect } from 'vitest'; import { describe, test, expect } from 'vitest';
test.for([ describe('getSetting', () => {
({ settings: [], name: '', expected: undefined }), test.for([
({ settings: [], name: 'a', expected: undefined }), ({ settings: [], name: '', expected: undefined }),
({ settings: [ ({ settings: [], name: 'a', expected: undefined }),
{ ({ settings: [
{
type: 'bool',
name: 'a',
i18n: 'a'
}
], name: 'a', expected: {
type: 'bool', type: 'bool',
name: 'a', name: 'a',
i18n: 'a' i18n: 'a'
} } })
], name: 'a', expected: { ])('returns $expected', ({ settings, name, expected }) => {
type: 'bool', expect(getSetting(name, settings)).toStrictEqual(expected);
name: 'a', });
i18n: 'a'
} })
])('returns $expected', ({ settings, name, expected }) => {
expect(getSetting(name, settings)).toStrictEqual(expected);
});
const veryNestedSettingsList = [ const veryNestedSettingsList = [
{ {
type: 'bool', type: 'bool',
name: '1a', name: '1a',
i18n: '1a' i18n: '1a'
}, },
{ {
type: 'section', type: 'section',
name: '1b', name: '1b',
i18n: '1b', i18n: '1b',
content: [ 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', type: 'bool',
name: '2a', name: '1a',
i18n: '2a' i18n: '1a'
}
], path: ['1a'], expected: {
type: 'bool',
name: '1a',
i18n: '1a'
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
}, },
{ {
type: 'section', type: 'section',
name: '2b', name: '1b',
i18n: '2b', i18n: '1b',
content: [ content: [
{ {
type: 'bool', type: 'bool',
name: '3a', name: '2a',
i18n: '3a' 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', type: 'section',
name: '3b', name: '2b',
i18n: '3b', i18n: '2b',
content: [ content: [
{ {
type: 'bool', type: 'string',
name: '4a', name: '3a',
i18n: '4a' i18n: '3a'
},
{
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'
}
]
}
]
}
]
} }
] ]
} }
] ]
} }
] ], path: ['1b', '2b', '3a'], expected: {
} type: 'string',
]; name: '3a',
i18n: '3a'
test.for([ } }),
({ settings: [], path: ['a'], expected: undefined }), ({ settings: [
({ 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', type: 'bool',
name: '2a', name: '1a',
i18n: '2a' i18n: '1a'
}, },
{ {
type: 'section', type: 'section',
name: '2b', name: '1b',
i18n: '2b', i18n: '1b',
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: [ content: [
{ {
type: 'bool', type: 'bool',
name: '7a', name: '2a',
i18n: '7a' i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
} }
] ]
} }
] ], path: ['1b'], expected: {
} }), type: 'section',
({ settings: veryNestedSettingsList, path: ['1b', '2b', '3b', '4ba', '5b', '6b', '7a'], expected: undefined }) name: '1b',
])('returns $expected', ({ settings, path, expected }) => { i18n: '1b',
expect(getSettingRecursively(path, settings)).toStrictEqual(expected); 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);
});
});
@@ -59,5 +59,5 @@ describe('settingsPage', () => {
])('returns $expected', async ({ path, expected }) => { ])('returns $expected', async ({ path, expected }) => {
expect(getSettingsPagePathSegments(path)).toStrictEqual(expected); expect(getSettingsPagePathSegments(path)).toStrictEqual(expected);
}); });
}) });
}) });
@@ -20,18 +20,10 @@ import { validateSettingsConfig, validateEntry, validateSelectionOptions, assert
describe('settingsValidator', () => { describe('settingsValidator', () => {
describe('validateSettingsConfig', () => { describe('validateSettingsConfig', () => {
test.for([ test.for([
{ raw: false, expected: false }, { settings: {
{ 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: [] contents: []
}, expected: true }, } },
{ raw: { { settings: {
contents: [ contents: [
{ {
name: 'general', name: 'general',
@@ -45,8 +37,8 @@ describe('settingsValidator', () => {
] ]
} }
] ]
}, expected: true }, } },
{ raw: { { settings: {
contents: [ contents: [
{ {
name: 'general', name: 'general',
@@ -71,8 +63,8 @@ describe('settingsValidator', () => {
] ]
}, },
] ]
}, expected: true }, } },
{ raw: { { settings: {
contents: [ contents: [
{ {
name: 'general', name: 'general',
@@ -128,12 +120,12 @@ describe('settingsValidator', () => {
default: 'str' default: 'str'
} }
] ]
}, }
] ]
}, }
] ]
}, expected: true }, } },
{ raw: { { settings: {
contents: [ contents: [
{ {
name: 'general', name: 'general',
@@ -190,12 +182,283 @@ describe('settingsValidator', () => {
default: 'str' default: 'str'
} }
] ]
}, }
]
}
]
} }
])('does not throw an error and returns input', ({ settings }) => {
validateSettingsConfig(settings);
});
test.for([
{ settings: false },
{ settings: true },
{ settings: 0 },
{ settings: 42 },
{ settings: '' },
{ settings: ' ' },
{ settings: 'a' },
{ settings: {} },
{ settings: {
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: true }, } },
{ raw: { { settings: {
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'
}
]
}
]
}
]
} },
{ settings: {
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'
}
]
}
]
}
]
} },
{ settings: {
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
}
]
}
]
}
]
} },
{ settings: {
contents: [ contents: [
{ {
type: 'bool', type: 'bool',
@@ -258,275 +521,13 @@ describe('settingsValidator', () => {
default: 'str' default: 'str'
} }
] ]
}, }
] ]
}, }
] ]
}, expected: false }, } }
{ raw: { ])('throws error', ({ settings }) => {
contents: [ expect(() => validateSettingsConfig(settings)).toThrow(Error);
{
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);
}); });
}); });
@@ -535,12 +536,12 @@ describe('settingsValidator', () => {
[{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable' }], [{ 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: true }],
[{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: false }], [{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: false }],
[{ name: 'aNumber', type: 'number', i18n: 'aNumber.label' }], [{ name: 'aNumber', type: 'number', i18n: 'aNumber.label' }],
[{ name: 'aNumber', type: 'number', i18n: 'aNumber.label', default: 42 }], [{ 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: 0 }],
[{ name: 'aNumber', type: 'number', i18n: 'aNumber.label', default: -42.7 }], [{ name: 'aNumber', type: 'number', i18n: 'aNumber.label', default: -42.7 }],
[{ name: 'aString', type: 'string', i18n: 'aString.label' }], [{ name: 'aString', type: 'string', i18n: 'aString.label' }],
[{ name: 'aString', type: 'string', i18n: 'aString.label', default: '' }], [{ name: 'aString', type: 'string', i18n: 'aString.label', default: '' }],
[{ name: 'aString', type: 'string', i18n: 'aString.label', default: 'Seekra is great!' }], [{ name: 'aString', type: 'string', i18n: 'aString.label', default: 'Seekra is great!' }],
@@ -617,7 +618,7 @@ describe('settingsValidator', () => {
[{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: [] }], [{ name: 'enableFeature42', type: 'bool', i18n: 'feature.42.enable', default: [] }],
[{ 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: 'enableFeature42', type: '', i18n: 'feature.42.enable', default: {} }],
[{ name: '', type: 'number', i18n: 'aNumber.label' }], [{ name: '', type: 'number', i18n: 'aNumber.label' }],
[{ name: 'aNumber', type: 'number', i18n: 'aNumber.label', default: '42' }], [{ 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: 'zero' }],
@@ -629,7 +630,7 @@ describe('settingsValidator', () => {
[{ 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: {} }],
[{ name: 'aNumber', type: '', i18n: 'aNumber.label', default: 42 }], [{ name: 'aNumber', type: '', i18n: 'aNumber.label', default: 42 }],
[{ name: 'aString', type: 'string', i18n: '' }], [{ name: 'aString', type: 'string', i18n: '' }],
[{ name: 'aString', type: 'string', i18n: 'aString.label', default: 42 }], [{ 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: true }],
@@ -809,4 +810,4 @@ describe('settingsValidator', () => {
expect(() => assertString(value)).throws(Error); expect(() => assertString(value)).throws(Error);
}); });
}); });
}); });
+3 -3
View File
@@ -31,12 +31,12 @@ export const getSettingRecursively = function getSettingRecursively (sectionPath
return oneSettingsLevel(cursor.slice(1, cursor.length), setting); return oneSettingsLevel(cursor.slice(1, cursor.length), setting);
} else { } else {
return setting; return setting;
}; }
} else if (cursor.length <= 1) { } else if (cursor.length <= 1) {
return setting; return setting;
} else { } else {
return; return;
}; }
}; };
return oneSettingsLevel(sectionPath, { content: settings }); return oneSettingsLevel(sectionPath, { content: settings });
}; };
+1 -1
View File
@@ -28,4 +28,4 @@ export const getSettingsPagePathSegments = function getSettingsPagePathSegments
(segment) => segment.replace(/[^a-zA-Z0-9-]/g, '') (segment) => segment.replace(/[^a-zA-Z0-9-]/g, '')
) )
.filter(Boolean); .filter(Boolean);
}; };
@@ -1,38 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
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;
}
@@ -20,7 +20,7 @@ export const assertString = function assertString (value, path) {
if (typeof value !== 'string' || value.trim() === '') { if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`[settings] "${path}" must be a non-empty string`); throw new Error(`[settings] "${path}" must be a non-empty string`);
} }
} };
export const assertType = function assertType (value, path) { export const assertType = function assertType (value, path) {
if (!VALID_TYPES.includes(value)) { if (!VALID_TYPES.includes(value)) {
@@ -28,21 +28,21 @@ export const assertType = function assertType (value, path) {
`[settings] "${path}" has invalid type "${value}". Must be one of: ${VALID_TYPES.join(', ')}` `[settings] "${path}" has invalid type "${value}". Must be one of: ${VALID_TYPES.join(', ')}`
); );
} }
} };
export const validateSelectionOptions = function validateSelectionOptions (options, path) { export const validateSelectionOptions = function validateSelectionOptions (options, path) {
if (!Array.isArray(options) || options.length === 0) { if (!Array.isArray(options) || options.length === 0) {
throw new Error(`[settings] "${path}.options" must be a non-empty array`); throw new Error(`[settings] "${path}.options" must be a non-empty array`);
}; }
options.forEach((opt, i) => { options.forEach((opt, i) => {
assertString(opt.name, `${path}.options[${i}].name`); assertString(opt.name, `${path}.options[${i}].name`);
assertString(opt.i18n, `${path}.options[${i}].i18n`); assertString(opt.i18n, `${path}.options[${i}].i18n`);
}); });
} };
export const validateEntry = function validateEntry (entry, path) { export const validateEntry = function validateEntry (entry, path) {
assertType(entry.type, `${path}.type`); assertType(entry.type, `${path}.type`);
assertString(entry.name, `${path}.name`); assertString(entry.name, `${path}.name`);
assertString(entry.i18n, `${path}.i18n`); assertString(entry.i18n, `${path}.i18n`);
@@ -55,13 +55,13 @@ export const validateEntry = function validateEntry (entry, path) {
); );
return; return;
} }
if (entry.type === 'selection') { if (entry.type === 'selection') {
validateSelectionOptions(entry.options, path); validateSelectionOptions(entry.options, path);
if (typeof entry.allowMultiple !== 'boolean' && entry.allowMultiple) { if (typeof entry.allowMultiple !== 'boolean' && entry.allowMultiple) {
throw new Error(`[settings] "${path}.allowMultiple" must be a boolean`); throw new Error(`[settings] "${path}.allowMultiple" must be a boolean`);
} }
}; }
if (entry.default !== undefined) { if (entry.default !== undefined) {
if (entry.type === 'bool' && typeof entry.default !== 'boolean') { if (entry.type === 'bool' && typeof entry.default !== 'boolean') {
throw new Error(`[settings] "${path}.default" must be a boolean`); throw new Error(`[settings] "${path}.default" must be a boolean`);
@@ -89,14 +89,14 @@ export const validateEntry = function validateEntry (entry, path) {
} else { } else {
if (typeof entry.default !== 'string') { if (typeof entry.default !== 'string') {
throw new Error(`[settings] "${path}.default" must be a string`); throw new Error(`[settings] "${path}.default" must be a string`);
}; }
if (!entry.options.map((option) => option.name).includes(entry.default)) { if (!entry.options.map((option) => option.name).includes(entry.default)) {
throw new Error(`[settings] option "${path}.default" does not exist`); throw new Error(`[settings] option "${path}.default" does not exist`);
}; }
} }
}; }
} }
} };
export const validateFirstLevelSection = function validateFirstLevelSection (section, path) { export const validateFirstLevelSection = function validateFirstLevelSection (section, path) {
assertString(section.name); assertString(section.name);
@@ -104,7 +104,7 @@ export const validateFirstLevelSection = function validateFirstLevelSection (sec
if (!Array.isArray(section.content)) { if (!Array.isArray(section.content)) {
throw new Error(`[settings] "${path}.content" must be an array`); throw new Error(`[settings] "${path}.content" must be an array`);
}; }
section.content.forEach((entry, i) => section.content.forEach((entry, i) =>
validateEntry(entry, `${path}.content[${i}]`) validateEntry(entry, `${path}.content[${i}]`)
@@ -117,18 +117,13 @@ export const validateFirstLevelSection = function validateFirstLevelSection (sec
* @returns {{ valid: true, config: import('../types/settingsConfig').SettingsConfig } | { valid: false, error: string }} * @returns {{ valid: true, config: import('../types/settingsConfig').SettingsConfig } | { valid: false, error: string }}
*/ */
export function validateSettingsConfig(raw) { export function validateSettingsConfig(raw) {
try { if (!raw || typeof raw !== 'object') {
if (!raw || typeof raw !== 'object') { throw new Error('[settings] Config must be an object');
throw new Error('[settings] Config must be an object');
}
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 };
} }
} if (!Array.isArray(raw.contents)) {
throw new Error('[settings] "contents" must be an array');
}
raw.contents.forEach((entry, i) =>
validateFirstLevelSection(entry, `contents[${i}]`)
);
};
+18 -33
View File
@@ -18,9 +18,9 @@ limitations under the License.
import LeftSidebarLayout from '@/layouts/LeftSidebarLayout.vue'; import LeftSidebarLayout from '@/layouts/LeftSidebarLayout.vue';
import SettingsPage from '../components/SettingsPage.vue'; import SettingsPage from '../components/SettingsPage.vue';
import settingsConfiguration from '../config.js';
import { getSettingRecursively } from '../utils/getSetting.js'; import { getSettingRecursively } from '../utils/getSetting.js';
import { loadSettingsConfig } from '../utils/settingsParser'; import { computed, onMounted, ref, watch } from 'vue';
import { onMounted, ref, watchEffect } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
@@ -28,39 +28,27 @@ const { t } = useI18n();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const settingsLoaded = ref(false) const settings = settingsConfiguration.contents;
const settings = ref([]);
const activeSectionContent = ref({});
const activeSection = ref(null);
watchEffect(() => { const activePath = computed(() => {
const segments = route.path const segments = route.path
.split('/') .split('/')
.filter(Boolean); .filter(Boolean);
activeSection.value = segments.slice(1, segments.length).join('.'); return segments.slice(1, segments.length);
}); });
const updateSettings = function updateSettings () { const activeSection = ref(null);
if (activeSection.value) { const updateSettings = async function updateSettings () {
const setting = getSettingRecursively(activeSection.value.split('.'), settings.value); const setting = getSettingRecursively(activePath.value, settings);
if (!setting) { if (setting) {
router.push('/settings'); activeSection.value = setting;
} else {
activeSectionContent.value = setting;
};
} else { } else {
activeSectionContent.value = { content: [] }; await router.push('/settings');
}; }
}; };
watch(route, (newRoute) => updateSettings(newRoute));
onMounted(async () => { onMounted(updateSettings);
settings.value = (await loadSettingsConfig()).contents;
watchEffect(() => {
updateSettings();
});
settingsLoaded.value = true;
});
const toggleSidebarIfSmallScreen = function toggleSidebarIfSmallScreen (toggleSidebarFunction) { const toggleSidebarIfSmallScreen = function toggleSidebarIfSmallScreen (toggleSidebarFunction) {
if (matchMedia('(max-width: 48rem)').matches) toggleSidebarFunction(); if (matchMedia('(max-width: 48rem)').matches) toggleSidebarFunction();
@@ -81,7 +69,7 @@ const toggleSidebarIfSmallScreen = function toggleSidebarIfSmallScreen (toggleSi
<RouterLink <RouterLink
:to="`/settings/${section.name}`" :to="`/settings/${section.name}`"
class="button button-link link sidebar-section" class="button button-link link sidebar-section"
:class="{ active: activeSection === section.name }" :class="{ active: activeSection && activeSection.name === section.name }"
@click="toggleSidebarIfSmallScreen(toggleSidebar)" @click="toggleSidebarIfSmallScreen(toggleSidebar)"
> >
{{ t(section.i18n) }} {{ t(section.i18n) }}
@@ -89,12 +77,9 @@ const toggleSidebarIfSmallScreen = function toggleSidebarIfSmallScreen (toggleSi
</li> </li>
</ul> </ul>
</template> </template>
<div v-if="!settingsLoaded" class="main-content--loading"> <div v-if="activeSection">
{{ t('loading') }}
</div>
<div v-else-if="activeSection" class="main-content--loaded">
<div class="settings-main-content"> <div class="settings-main-content">
<SettingsPage :setting="activeSectionContent" :path="activeSection" /> <SettingsPage :setting="activeSection" :path="activePath" />
</div> </div>
</div> </div>
<div v-else class="main-content--no-section"> <div v-else class="main-content--no-section">
@@ -152,4 +137,4 @@ const toggleSidebarIfSmallScreen = function toggleSidebarIfSmallScreen (toggleSi
padding: 0; padding: 0;
} }
} }
</style> </style>
@@ -16,8 +16,8 @@ limitations under the License.
import SettingsView from '../SettingsView.vue'; import SettingsView from '../SettingsView.vue';
import { mountComponent } from '@/test-utils/mountComponent.js'; import { mountComponent } from '@/test-utils/mountComponent.js';
import { flushPromises } from '@vue/test-utils';
import { beforeEach, describe, expect, test, vi } from 'vitest'; import { beforeEach, describe, expect, test, vi } from 'vitest';
import { nextTick } from 'vue';
const settingsHoisted = vi.hoisted(() => [ const settingsHoisted = vi.hoisted(() => [
{ {
@@ -197,10 +197,8 @@ const settings = settingsHoisted.values();
const originalCurrentSection = 's0'; const originalCurrentSection = 's0';
let currentSection = originalCurrentSection; let currentSection = originalCurrentSection;
vi.mock('../../utils/settingsParser', () => ({ vi.mock('../../config.js', () => ({
loadSettingsConfig: vi.fn().mockResolvedValue({ default: { contents: settingsHoisted }
contents: settingsHoisted
})
})); }));
vi.mock('vue-router', async (importOriginal) => { vi.mock('vue-router', async (importOriginal) => {
@@ -235,9 +233,7 @@ describe('SettingsView', () => {
describe('sidebar', () => { describe('sidebar', () => {
test('shows all top-level sections', async () => { test('shows all top-level sections', async () => {
const wrapper = getWrapper(); const wrapper = getWrapper();
await nextTick();
await flushPromises();
await wrapper.vm.$nextTick();
const sidebarItems = wrapper.findAll('.sidebar-sections-list > li'); const sidebarItems = wrapper.findAll('.sidebar-sections-list > li');
settings.forEach((setting, index) => { settings.forEach((setting, index) => {
@@ -254,47 +250,27 @@ describe('SettingsView', () => {
describe('main', () => { describe('main', () => {
describe('correct element', () => { 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 () => { test('shows that no section is selected', async () => {
currentSection = ''; currentSection = '';
const wrapper = getWrapper(); const wrapper = getWrapper();
await nextTick();
await flushPromises(); const mainContent = wrapper.find('.settings-main-content');
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'); const mainContentNoSection = wrapper.find('.main-content--no-section');
expect(mainContentLoading.exists()).toBe(false); expect(mainContent.exists()).toBe(false);
expect(mainContentLoaded.exists()).toBe(false);
expect(mainContentNoSection.exists()).toBe(true); expect(mainContentNoSection.exists()).toBe(true);
}); });
test('shows main content', async () => { test('shows main content', async () => {
const wrapper = getWrapper(); const wrapper = getWrapper();
await nextTick();
await flushPromises(); const mainContent = wrapper.find('.settings-main-content');
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'); const mainContentNoSection = wrapper.find('.main-content--no-section');
expect(mainContentLoading.exists()).toBe(false); expect(mainContent.exists()).toBe(true);
expect(mainContentLoaded.exists()).toBe(true);
expect(mainContentNoSection.exists()).toBe(false); expect(mainContentNoSection.exists()).toBe(false);
}); });
}); });
@@ -303,4 +279,4 @@ describe('SettingsView', () => {
beforeEach(() => { beforeEach(() => {
currentSection = originalCurrentSection; currentSection = originalCurrentSection;
}); });
+6 -2
View File
@@ -36,7 +36,7 @@ const toggleSidebar = function toggleSidebar () {
<div class="sidebar-controls"> <div class="sidebar-controls">
<SidebarExpandButton @click="toggleSidebar" class="sidebar-expand-button" /> <SidebarExpandButton @click="toggleSidebar" class="sidebar-expand-button" />
</div> </div>
<div class="sidebar-content" v-if="props.expanded"> <div class="sidebar-content" v-show="props.expanded">
<slot /> <slot />
</div> </div>
</nav> </nav>
@@ -61,6 +61,10 @@ const toggleSidebar = function toggleSidebar () {
} }
@media (max-width: 48rem) { @media (max-width: 48rem) {
.sidebar {
border: none;
}
.sidebar:not(.expanded) .sidebar-expand-button { .sidebar:not(.expanded) .sidebar-expand-button {
display: none; display: none;
} }
@@ -70,4 +74,4 @@ const toggleSidebar = function toggleSidebar () {
padding: 20px; padding: 20px;
padding-top: 0; padding-top: 0;
} }
</style> </style>
@@ -38,4 +38,4 @@ const click = function click (event) {
padding: 4px; padding: 4px;
border-radius: 10px; border-radius: 10px;
} }
</style> </style>
+9 -4
View File
@@ -15,7 +15,6 @@ limitations under the License.
*/ */
import { createI18n } from 'vue-i18n'; import { createI18n } from 'vue-i18n';
import getCurrentLanguage from './utils/currentLanguage';
export const fallbackLocale = 'en'; export const fallbackLocale = 'en';
@@ -34,7 +33,7 @@ export const SUPPORTED_LANGUAGES = [
export const i18n = createI18n({ export const i18n = createI18n({
legacy: false, legacy: false,
locale: getCurrentLanguage(), locale: fallbackLocale,
fallbackLocale: fallbackLocale, fallbackLocale: fallbackLocale,
messages: {} messages: {}
}); });
@@ -49,7 +48,7 @@ export async function loadLanguage (locale) {
if (loadedLanguages.has(locale)) { if (loadedLanguages.has(locale)) {
i18n.global.locale.value = locale; i18n.global.locale.value = locale;
return; return;
}; }
const messages = (await import(`./locales/${locale}.json`)).default; const messages = (await import(`./locales/${locale}.json`)).default;
@@ -57,4 +56,10 @@ export async function loadLanguage (locale) {
i18n.global.locale.value = locale; i18n.global.locale.value = locale;
loadedLanguages.add(locale); loadedLanguages.add(locale);
}; };
export const setLocale = async function setLocale (locale) {
await loadLanguage(locale);
document.documentElement.lang = locale;
document.documentElement.dir = LANGUAGES_RTL.includes(locale) ? 'rtl' : 'ltr';
};
+2 -2
View File
@@ -80,7 +80,7 @@ const toggleSidebar = function toggleSidebar () {
.layout-container.expanded { .layout-container.expanded {
grid-template-columns: 1fr 0; grid-template-columns: 1fr 0;
} }
.layout-container:not(.expanded) .sidebar-expand-button-container { .layout-container:not(.expanded) .sidebar-expand-button-container {
justify-content: end; justify-content: end;
display: block; display: block;
@@ -100,4 +100,4 @@ const toggleSidebar = function toggleSidebar () {
grid-area: main-content; grid-area: main-content;
padding: 20px; padding: 20px;
} }
</style> </style>
+24 -1
View File
@@ -60,6 +60,29 @@
"option": { "option": {
"ariaLabel": "Wählt die Option {option} aus" "ariaLabel": "Wählt die Option {option} aus"
} }
},
"settings": {
"appearance": {
"title": "Erscheinungsbild",
"contents": {
"colorScheme": {
"title": "Farbschema",
"options": {
"auto": "An Systemfarbschema anpassen",
"light": "Hell",
"dark": "Dunkel"
}
}
}
},
"general": {
"title": "Allgemein",
"settings": {
"language": {
"title": "Sprache"
}
}
}
} }
}, },
"legal": { "legal": {
@@ -68,4 +91,4 @@
"loadError": "Das Impressum konnte nicht geladen werden." "loadError": "Das Impressum konnte nicht geladen werden."
} }
} }
} }
+24 -1
View File
@@ -60,6 +60,29 @@
"option": { "option": {
"ariaLabel": "Selects option {option}" "ariaLabel": "Selects option {option}"
} }
},
"settings": {
"appearance": {
"title": "Appearance",
"contents": {
"colorScheme": {
"title": "Color Scheme",
"options": {
"auto": "Adapt to system color scheme",
"light": "Light",
"dark": "Dark"
}
}
}
},
"general": {
"title": "General",
"settings": {
"language": {
"title": "Language"
}
}
}
} }
}, },
"legal": { "legal": {
@@ -68,4 +91,4 @@
"loadError": "The legal notice could not be loaded." "loadError": "The legal notice could not be loaded."
} }
} }
} }
+1 -1
View File
@@ -62,4 +62,4 @@
} }
} }
} }
} }
+1 -1
View File
@@ -62,4 +62,4 @@
} }
} }
} }
} }
+1 -1
View File
@@ -62,4 +62,4 @@
} }
} }
} }
} }
+1 -1
View File
@@ -62,4 +62,4 @@
} }
} }
} }
} }
+12 -9
View File
@@ -15,21 +15,23 @@ limitations under the License.
*/ */
import { getAssetsUrl } from './config/env'; import { getAssetsUrl } from './config/env';
import { createApp } from 'vue' import { createApp } from 'vue';
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'; import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
import App from './App.vue' import App from './App.vue';
import { i18n, loadLanguage, fallbackLocale } from './i18n'; import { i18n, loadLanguage, fallbackLocale } from './i18n';
import getCurrentLanguage from './utils/currentLanguage'; import router from './router';
import router from './router' import './styles/common.css';
import './styles/common.css' import './styles/variables/colors.css';
import './styles/variables/colors.css'
import './styles/variables/dimensions.css'; import './styles/variables/dimensions.css';
import rawFonts from './styles/fonts.json'; import rawFonts from './styles/fonts.json';
import './styles/common.css'; import './styles/common.css';
import './styles/variables/colors.css'; import './styles/variables/colors.css';
import settings from './features/settings/config.js';
import { validateSettingsConfig } from './features/settings/utils/settingsValidator';
(async () => { (async () => {
const fonts = rawFonts.map((font) => new FontFace( const fonts = rawFonts.map((font) => new FontFace(
font.family ?? '', font.family ?? '',
@@ -50,11 +52,12 @@ import './styles/variables/colors.css';
fonts.forEach((font) => document.fonts.add(font)); fonts.forEach((font) => document.fonts.add(font));
await loadLanguage(fallbackLocale); await loadLanguage(fallbackLocale);
await loadLanguage(getCurrentLanguage());
const pinia = createPinia(); const pinia = createPinia();
pinia.use(piniaPluginPersistedstate); pinia.use(piniaPluginPersistedstate);
validateSettingsConfig(settings);
createApp(App) createApp(App)
.use(router) .use(router)
.use(i18n) .use(i18n)
+3 -3
View File
@@ -78,7 +78,7 @@ export const updatePageTitle = function updatePageTitle (route) {
document.title = `${title} - Seekra`; document.title = `${title} - Seekra`;
} else { } else {
document.title = 'Seekra'; document.title = 'Seekra';
}; }
}; };
// remove trailing slash(es) // remove trailing slash(es)
@@ -90,7 +90,7 @@ router.beforeEach((route) => {
hash: route.hash, hash: route.hash,
replace: true replace: true
}; };
}; }
}); });
export default router; export default router;
+1 -1
View File
@@ -155,4 +155,4 @@ body.dark {
--light-hover: var(--light-d-5); --light-hover: var(--light-d-5);
--invert: invert(1); --invert: invert(1);
} }
+1 -1
View File
@@ -1,4 +1,4 @@
:root { :root {
--input-padding: 0.7em; --input-padding: 0.7em;
--input-height: 1em; --input-height: 1em;
} }
+1 -1
View File
@@ -59,4 +59,4 @@ export const mountComponent = function mountComponent (component, options = {},
}, },
...options ...options
}); });
}; };
+1 -1
View File
@@ -121,4 +121,4 @@ describe('cssDimensions', () => {
])('ensureUnit returns $expected with input $dimension', ({ dimension, expected }) => { ])('ensureUnit returns $expected with input $dimension', ({ dimension, expected }) => {
expect(ensureUnit(dimension)).toBe(expected); expect(ensureUnit(dimension)).toBe(expected);
}); });
}); });
@@ -1,61 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { 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);
});
});
+2 -2
View File
@@ -25,7 +25,7 @@ export const ensureUnit = function (d) {
// if it ends with a number // if it ends with a number
if (/\d$/.test(d)) { if (/\d$/.test(d)) {
d += 'px'; d += 'px';
}; }
return d; return d;
}; };
+1 -1
View File
@@ -53,4 +53,4 @@ const { t } = useI18n();
padding: 10px 20px; padding: 10px 20px;
text-decoration: none; text-decoration: none;
} }
</style> </style>
+2 -2
View File
@@ -39,7 +39,7 @@ const submitSearch = function () {
{{ t('slogan') }} {{ t('slogan') }}
</span> </span>
</header> </header>
<div class="search-container"> <div class="search-container">
<Searchbar v-model="searchQuery" ref="searchbar" class="search-bar" auto-submit /> <Searchbar v-model="searchQuery" ref="searchbar" class="search-bar" auto-submit />
</div> </div>
@@ -83,4 +83,4 @@ const submitSearch = function () {
width: 100%; width: 100%;
} }
} }
</style> </style>
+3 -3
View File
@@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { defineConfig } from 'vite' import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue';
import path from 'path'; import path from 'path';
import { createHtmlPlugin } from 'vite-plugin-html'; import { createHtmlPlugin } from 'vite-plugin-html';
@@ -39,4 +39,4 @@ export default defineConfig({
globals: false, globals: false,
environment: 'jsdom' environment: 'jsdom'
} }
}) });