Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F118238467
D2395.1775703738.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
46 KB
Referenced Files
None
Subscribers
None
D2395.1775703738.diff
View Options
diff --git a/src/.env.example b/src/.env.example
--- a/src/.env.example
+++ b/src/.env.example
@@ -6,6 +6,8 @@
APP_PUBLIC_URL=
APP_DOMAIN=kolabnow.com
APP_THEME=default
+APP_LOCALE=en
+APP_LOCALES=en,de
ASSET_URL=http://127.0.0.1:8000
diff --git a/src/.gitignore b/src/.gitignore
--- a/src/.gitignore
+++ b/src/.gitignore
@@ -21,4 +21,4 @@
yarn-error.log
composer.lock
resources/countries.php
-resources/js/ts.js
+resources/build/js/
diff --git a/src/app/Http/Controllers/ContentController.php b/src/app/Http/Controllers/ContentController.php
--- a/src/app/Http/Controllers/ContentController.php
+++ b/src/app/Http/Controllers/ContentController.php
@@ -17,14 +17,18 @@
abort(404);
}
+ $theme = \config('app.theme');
$page = str_replace('/', '.', $page);
- $file = sprintf('themes/%s/pages/%s.blade.php', \config('app.theme'), $page);
- $view = sprintf('%s.pages.%s', \config('app.theme'), $page);
+
+ $file = "themes/{$theme}/pages/{$page}.blade.php";
+ $view = "{$theme}.pages.{$page}";
if (!file_exists(resource_path($file))) {
abort(404);
}
+ self::loadLocale($theme);
+
return view($view)->with('env', \App\Utils::uiEnv());
}
@@ -57,6 +61,113 @@
// TODO: Support pages with variables, e.g. users/<user-id>
}
+ // Localization
+ if (!empty($faq)) {
+ self::loadLocale($theme_name);
+
+ foreach ($faq as $idx => $item) {
+ if (!empty($item['label'])) {
+ $faq[$idx]['title'] = \trans('theme::faq.' . $item['label']);
+ }
+ }
+ }
+
return response()->json(['status' => 'success', 'faq' => $faq]);
}
+
+ /**
+ * Returns list of enabled locales
+ *
+ * @return array List of two-letter language codes
+ */
+ public static function locales(): array
+ {
+ if ($locales = \env('APP_LOCALES')) {
+ return preg_split('/\s*,\s*/', strtolower(trim($locales)));
+ }
+
+ return ['en', 'de'];
+ }
+
+ /**
+ * Get menu definition from the theme
+ *
+ * @return array
+ */
+ public static function menu(): array
+ {
+ $theme_name = \config('app.theme');
+ $theme_file = resource_path("themes/{$theme_name}/theme.json");
+ $menu = [];
+
+ if (file_exists($theme_file)) {
+ $theme = json_decode(file_get_contents($theme_file), true);
+
+ if (json_last_error() != JSON_ERROR_NONE) {
+ \Log::error("Failed to parse $theme_file: " . json_last_error_msg());
+ } elseif (!empty($theme['menu'])) {
+ $menu = $theme['menu'];
+ }
+ }
+
+ // TODO: These 2-3 lines could become a utility function somewhere
+ $req_domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost());
+ $sys_domain = \config('app.domain');
+ $isAdmin = $req_domain == "admin.$sys_domain";
+
+ $filter = function ($item) use ($isAdmin) {
+ if ($isAdmin && empty($item['admin'])) {
+ return false;
+ }
+ if (!$isAdmin && !empty($item['admin']) && $item['admin'] === 'only') {
+ return false;
+ }
+
+ return true;
+ };
+
+ $menu = array_values(array_filter($menu, $filter));
+
+ // Load localization files for all supported languages
+ $lang_path = resource_path("themes/{$theme_name}/lang");
+ $locales = [];
+ foreach (self::locales() as $lang) {
+ $file = "{$lang_path}/{$lang}/menu.php";
+ if (file_exists($file)) {
+ $locales[$lang] = include $file;
+ }
+ }
+
+ foreach ($menu as $idx => $item) {
+ // Handle menu localization
+ if (!empty($item['label'])) {
+ $label = $item['label'];
+
+ foreach ($locales as $lang => $labels) {
+ if (!empty($labels[$label])) {
+ $item["title-{$lang}"] = $labels[$label];
+ }
+ }
+ }
+
+ // Unset properties that we don't need on the client side
+ unset($item['admin'], $item['label']);
+
+ $menu[$idx] = $item;
+ }
+
+ return $menu;
+ }
+
+ /**
+ * Register localization files from the theme.
+ *
+ * @param string $theme Theme name
+ */
+ protected static function loadLocale(string $theme): void
+ {
+ $path = resource_path(sprintf('themes/%s/lang', $theme));
+
+ \app('translator')->addNamespace('theme', $path);
+ }
}
diff --git a/src/app/Http/Kernel.php b/src/app/Http/Kernel.php
--- a/src/app/Http/Kernel.php
+++ b/src/app/Http/Kernel.php
@@ -21,6 +21,7 @@
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
\App\Http\Middleware\DevelConfig::class,
+ \App\Http\Middleware\Locale::class,
// FIXME: CORS handling added here, I didn't find a nice way
// to add this only to the API routes
// \App\Http\Middleware\Cors::class,
diff --git a/src/app/Http/Middleware/Locale.php b/src/app/Http/Middleware/Locale.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Middleware/Locale.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use Closure;
+use Illuminate\Http\Request;
+
+class Locale
+{
+ /**
+ * Handle an incoming request.
+ *
+ * @param \Illuminate\Http\Request $request
+ * @param \Closure $next
+ *
+ * @return mixed
+ */
+ public function handle(Request $request, Closure $next)
+ {
+ $langDir = resource_path('lang');
+ $enabledLanguages = \App\Http\Controllers\ContentController::locales();
+ $default = config('app.locale');
+ $lang = null;
+
+ // Try to get the language from the cookie
+ if (
+ ($cookie = $request->cookie('language'))
+ && in_array($cookie, $enabledLanguages)
+ && ($cookie == $default || file_exists("$langDir/$cookie"))
+ ) {
+ $lang = $cookie;
+ }
+
+ // If there's no cookie select try the browser languages
+ if (!$lang) {
+ $preferences = array_map(
+ function ($lang) {
+ return preg_replace('/[^a-z].*$/', '', strtolower($lang));
+ },
+ $request->getLanguages()
+ );
+
+ foreach ($preferences as $pref) {
+ if (
+ !empty($pref)
+ && in_array($pref, $enabledLanguages)
+ && ($pref == $default || file_exists("$langDir/$pref"))
+ ) {
+ $lang = $pref;
+ break;
+ }
+ }
+ }
+
+ if ($lang != $default) {
+ app()->setLocale($lang);
+ }
+
+ return $next($request);
+ }
+}
diff --git a/src/app/Utils.php b/src/app/Utils.php
--- a/src/app/Utils.php
+++ b/src/app/Utils.php
@@ -387,20 +387,8 @@
$env['paymentProvider'] = \config('services.payment_provider');
$env['stripePK'] = \config('services.stripe.public_key');
- $theme_file = resource_path("themes/{$env['app.theme']}/theme.json");
- $menu = [];
-
- if (file_exists($theme_file)) {
- $theme = json_decode(file_get_contents($theme_file), true);
-
- if (json_last_error() != JSON_ERROR_NONE) {
- \Log::error("Failed to parse $theme_file: " . json_last_error_msg());
- } elseif (!empty($theme['menu'])) {
- $menu = $theme['menu'];
- }
- }
-
- $env['menu'] = $menu;
+ $env['languages'] = \App\Http\Controllers\ContentController::locales();
+ $env['menu'] = \App\Http\Controllers\ContentController::menu();
return $env;
}
diff --git a/src/composer.json b/src/composer.json
--- a/src/composer.json
+++ b/src/composer.json
@@ -39,7 +39,7 @@
"filp/whoops": "^2.0",
"fzaninotto/faker": "^1.4",
"kirschbaum-development/mail-intercept": "^0.2.4",
- "laravel/dusk": "~5.11.0",
+ "laravel/dusk": "~6.15.0",
"mockery/mockery": "^1.0",
"nunomaduro/larastan": "^0.7",
"phpstan/phpstan": "^0.12",
diff --git a/src/config/app.php b/src/config/app.php
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -98,7 +98,7 @@
|
*/
- 'locale' => 'en',
+ 'locale' => env('APP_LOCALE', 'en'),
/*
|--------------------------------------------------------------------------
diff --git a/src/package-lock.json b/src/package-lock.json
--- a/src/package-lock.json
+++ b/src/package-lock.json
@@ -1519,7 +1519,11 @@
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
"dev": true,
- "optional": true
+ "optional": true,
+ "requires": {
+ "bindings": "^1.5.0",
+ "nan": "^2.12.1"
+ }
},
"glob-parent": {
"version": "3.1.0",
@@ -2746,6 +2750,16 @@
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
"dev": true
},
+ "bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
"bluebird": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
@@ -5512,6 +5526,13 @@
"integrity": "sha512-UssQP5ZgIOKelfsaB5CuGAL+Y+q7EmONuiwF3N5HAH0t27rvrttgi6Ra9k/+DVaY9UF6+ybxu5pOXLUdA8N7Vg==",
"dev": true
},
+ "file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "dev": true,
+ "optional": true
+ },
"fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -7864,6 +7885,13 @@
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
"dev": true
},
+ "nan": {
+ "version": "2.14.2",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz",
+ "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==",
+ "dev": true,
+ "optional": true
+ },
"nanoid": {
"version": "3.1.22",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.22.tgz",
@@ -13758,6 +13786,12 @@
"integrity": "sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==",
"dev": true
},
+ "vue-i18n": {
+ "version": "8.24.2",
+ "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-8.24.2.tgz",
+ "integrity": "sha512-+TkAPBQw4Cp2bQrSPtPNkhET7XcWYjjDt1UjWYQs+xbT41q5OAl1I3IZyhg0drjn1nlC1K0f8sLVB/nshUcF1Q==",
+ "dev": true
+ },
"vue-loader": {
"version": "15.9.6",
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-15.9.6.tgz",
diff --git a/src/package.json b/src/package.json
--- a/src/package.json
+++ b/src/package.json
@@ -34,6 +34,7 @@
"stylelint": "^13.12.0",
"stylelint-config-standard": "^20.0.0",
"vue": "^2.6.12",
+ "vue-i18n": "^8.24.1",
"vue-loader": "^15.9.6",
"vue-router": "^3.5.1",
"vue-template-compiler": "^2.6.12",
diff --git a/src/resources/build/before.php b/src/resources/build/before.php
--- a/src/resources/build/before.php
+++ b/src/resources/build/before.php
@@ -2,8 +2,39 @@
$rootDir = __DIR__ . '/../..';
+// Create build directory for js resources
+echo "Build directory...";
+
+if (!file_exists("{$rootDir}/resources/build/js")) {
+ mkdir("{$rootDir}/resources/build/js");
+}
+
+echo "OK\n";
+
// Write build timestamp to a file that is then included by the vue components
+echo "Build timestamp...";
+
file_put_contents(
- "{$rootDir}/resources/js/ts.js",
+ "{$rootDir}/resources/build/js/ts.js",
sprintf("export default new Date('%s')", date('c'))
);
+
+echo "OK\n";
+
+// Convert UI localization into vue-i18n-compatible json format
+echo "Client localization...";
+
+foreach (glob("{$rootDir}/resources/lang/*/ui.php") as $file) {
+ $content = include $file;
+
+ if (is_array($content)) {
+ preg_match('|([a-z]+)/ui\.php$|', $file, $matches);
+
+ $file = "{$rootDir}/resources/build/js/{$matches[1]}.json";
+ $opts = JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_UNICODE;
+
+ file_put_contents($file, json_encode($content, $opts));
+ }
+}
+
+echo "OK\n";
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -10,6 +10,7 @@
import MenuComponent from '../vue/Widgets/Menu'
import SupportForm from '../vue/Widgets/SupportForm'
import store from './store'
+import { loadLangAsync, i18n } from './locale'
const loader = '<div class="app-loader"><div class="spinner-border" role="status"><span class="sr-only">Loading</span></div></div>'
@@ -69,11 +70,11 @@
})
const app = new Vue({
- el: '#app',
components: {
AppComponent,
MenuComponent,
},
+ i18n,
store,
router: window.router,
data() {
@@ -401,6 +402,9 @@
}
})
+// Fetch the locale file and the start the app
+loadLangAsync().then(() => app.$mount('#app'))
+
// Add a axios request interceptor
window.axios.interceptors.request.use(
config => {
diff --git a/src/resources/js/locale.js b/src/resources/js/locale.js
new file mode 100644
--- /dev/null
+++ b/src/resources/js/locale.js
@@ -0,0 +1,66 @@
+import Vue from 'vue'
+import VueI18n from 'vue-i18n'
+
+// We do pre-load English localization as this is possible
+// the only one that is complete and used as a fallback.
+import messages from '../build/js/en.json'
+
+Vue.use(VueI18n)
+
+export const i18n = new VueI18n({
+ locale: 'en',
+ fallbackLocale: 'en',
+ messages: { en: messages },
+ silentFallbackWarn: true
+})
+
+let currentLanguage
+
+const loadedLanguages = ['en'] // our default language that is preloaded
+
+const setI18nLanguage = (lang) => {
+ i18n.locale = lang
+
+ document.querySelector('html').setAttribute('lang', lang)
+
+ // Set language for API requests
+ // Note, it's kinda redundant as we support the cookie
+ window.axios.defaults.headers.common['Accept-Language'] = lang
+
+ // Save the selected language in a cookie, so it can be used server-side
+ // after page reload. Make the cookie valid for 10 years
+ const age = 10 * 60 * 60 * 24 * 365
+ document.cookie = 'language=' + lang + '; max-age=' + age
+
+ return lang
+}
+
+export const getLang = () => {
+ if (!currentLanguage) {
+ currentLanguage = document.querySelector('html').getAttribute('lang') || 'en'
+ }
+
+ return currentLanguage
+}
+
+export const setLang = lang => {
+ currentLanguage = lang
+ loadLangAsync()
+}
+
+export function loadLangAsync() {
+ const lang = getLang()
+
+ // If the language was already loaded
+ if (loadedLanguages.includes(lang)) {
+ return Promise.resolve(setI18nLanguage(lang))
+ }
+
+ // If the language hasn't been loaded yet
+ return import(/* webpackChunkName: "locale/[request]" */ `../build/js/${lang}.json`)
+ .then(messages => {
+ i18n.setLocaleMessage(lang, messages.default)
+ loadedLanguages.push(lang)
+ return setI18nLanguage(lang)
+ })
+}
diff --git a/src/resources/lang/de/ui.php b/src/resources/lang/de/ui.php
new file mode 100644
--- /dev/null
+++ b/src/resources/lang/de/ui.php
@@ -0,0 +1,30 @@
+<?php
+
+return [
+
+ 'buttons' => [
+ 'cancel' => "Stornieren",
+ 'save' => "Speichern",
+ ],
+
+ 'menu' => [
+ 'cockpit' => "Cockpit",
+ 'login' => "Einloggen",
+ 'logout' => "Ausloggen",
+ 'signup' => "Signup",
+ 'toggle' => "Navigation umschalten",
+ ],
+
+ 'lang' => [
+ 'en' => "Englisch",
+ 'de' => "Deutsch",
+ 'fr' => "Französisch",
+ ],
+
+ 'login' => [
+ 'forgot_password' => "Passwort vergessen?",
+ 'sign_in' => "Anmelden",
+ 'webmail' => "Webmail",
+ ],
+
+];
diff --git a/src/resources/lang/en/pagination.php b/src/resources/lang/en/pagination.php
deleted file mode 100644
--- a/src/resources/lang/en/pagination.php
+++ /dev/null
@@ -1,19 +0,0 @@
-<?php
-
-return [
-
- /*
- |--------------------------------------------------------------------------
- | Pagination Language Lines
- |--------------------------------------------------------------------------
- |
- | The following language lines are used by the paginator library to build
- | the simple pagination links. You are free to change them to anything
- | you want to customize your views to better match your application.
- |
- */
-
- 'previous' => '« Previous',
- 'next' => 'Next »',
-
-];
diff --git a/src/resources/lang/en/passwords.php b/src/resources/lang/en/passwords.php
deleted file mode 100644
--- a/src/resources/lang/en/passwords.php
+++ /dev/null
@@ -1,22 +0,0 @@
-<?php
-
-return [
-
- /*
- |--------------------------------------------------------------------------
- | Password Reset Language Lines
- |--------------------------------------------------------------------------
- |
- | The following language lines are the default lines which match reasons
- | that are given by the password broker for a password update attempt
- | has failed, such as for an invalid token or invalid new password.
- |
- */
-
- 'password' => 'Passwords must be at least eight characters and match the confirmation.',
- 'reset' => 'Your password has been reset!',
- 'sent' => 'We have e-mailed your password reset link!',
- 'token' => 'This password reset token is invalid.',
- 'user' => "We can't find a user with that e-mail address.",
-
-];
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
new file mode 100644
--- /dev/null
+++ b/src/resources/lang/en/ui.php
@@ -0,0 +1,41 @@
+<?php
+
+/**
+ * This file will be converted to a Vue-i18n compatible JSON format on build time
+ *
+ * Note: The Laravel localization features do not work here. Vue-i18n rules are different
+ */
+
+return [
+
+ 'buttons' => [
+ 'cancel' => "Cancel",
+ 'save' => "Save"
+ ],
+
+ 'menu' => [
+ 'cockpit' => "Cockpit",
+ 'login' => "Login",
+ 'logout' => "Logout",
+ 'signup' => "Signup",
+ 'toggle' => "Toggle navigation"
+ ],
+
+ 'lang' => [
+ 'en' => "English",
+ 'de' => "German",
+ 'fr' => "French"
+ ],
+
+ 'login' => [
+ '2fa' => "Second factor code",
+ '2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.",
+ 'email' => "Email address",
+ 'forgot_password' => "Forgot password?",
+ 'header' => "Please sign in",
+ 'password' => "Password",
+ 'sign_in' => "Sign in",
+ 'webmail' => "Webmail"
+ ],
+
+];
diff --git a/src/resources/themes/default/lang/de/faq.php b/src/resources/themes/default/lang/de/faq.php
new file mode 100644
--- /dev/null
+++ b/src/resources/themes/default/lang/de/faq.php
@@ -0,0 +1,9 @@
+<?php
+
+return [
+
+ 'account-upgrade' => "Kann ich ein einzelnes Konto auf ein Gruppenkonto aktualisieren?",
+ 'storage' => "Wie viel Speicherplatz ist in meinem Konto enthalten?",
+ 'tos' => "Was sind Ihre Nutzungsbedingungen?",
+
+];
diff --git a/src/resources/themes/default/lang/de/menu.php b/src/resources/themes/default/lang/de/menu.php
new file mode 100644
--- /dev/null
+++ b/src/resources/themes/default/lang/de/menu.php
@@ -0,0 +1,10 @@
+<?php
+
+return [
+
+ 'blog' => "Blog",
+ 'explore' => "Erkunden",
+ 'support' => "Unterstützung",
+ 'tos' => "LdD",
+
+];
\ No newline at end of file
diff --git a/src/resources/themes/default/lang/de/support.php b/src/resources/themes/default/lang/de/support.php
new file mode 100644
--- /dev/null
+++ b/src/resources/themes/default/lang/de/support.php
@@ -0,0 +1,7 @@
+<?php
+
+return [
+
+ 'btn' => "Kontaktieren Sie Support",
+
+];
\ No newline at end of file
diff --git a/src/resources/themes/default/lang/en/faq.php b/src/resources/themes/default/lang/en/faq.php
new file mode 100644
--- /dev/null
+++ b/src/resources/themes/default/lang/en/faq.php
@@ -0,0 +1,9 @@
+<?php
+
+return [
+
+ 'account-upgrade' => "Can I upgrade an individual account to a group account?",
+ 'storage' => "How much storage comes with my account?",
+ 'tos' => "What are your terms of service?",
+
+];
diff --git a/src/resources/themes/default/lang/en/menu.php b/src/resources/themes/default/lang/en/menu.php
new file mode 100644
--- /dev/null
+++ b/src/resources/themes/default/lang/en/menu.php
@@ -0,0 +1,10 @@
+<?php
+
+return [
+
+ 'blog' => "Blog",
+ 'explore' => "Explore",
+ 'support' => "Support",
+ 'tos' => "ToS",
+
+];
\ No newline at end of file
diff --git a/src/resources/themes/default/lang/en/support.php b/src/resources/themes/default/lang/en/support.php
new file mode 100644
--- /dev/null
+++ b/src/resources/themes/default/lang/en/support.php
@@ -0,0 +1,7 @@
+<?php
+
+return [
+
+ 'btn' => "Contact Support",
+
+];
\ No newline at end of file
diff --git a/src/resources/themes/default/pages/support.blade.php b/src/resources/themes/default/pages/support.blade.php
--- a/src/resources/themes/default/pages/support.blade.php
+++ b/src/resources/themes/default/pages/support.blade.php
@@ -14,7 +14,7 @@
</p>
</div>
<div class="card-footer text-center">
- <a href="/support/contact" class="btn btn-info">Contact Support</a>
+ <a href="/support/contact" class="btn btn-info">@lang('theme::support.btn')</a>
</div>
</div>
</div>
diff --git a/src/resources/themes/default/theme.json b/src/resources/themes/default/theme.json
--- a/src/resources/themes/default/theme.json
+++ b/src/resources/themes/default/theme.json
@@ -1,23 +1,23 @@
{
"menu": [
{
- "title": "Explore",
+ "label": "explore",
"location": "https://kolabnow.com/",
"admin": true
},
{
- "title": "Blog",
+ "label": "blog",
"location": "https://blogs.kolabnow.com/",
"admin": true
},
{
- "title": "Support",
+ "label": "support",
"location": "/support",
"page": "support",
"admin": true
},
{
- "title": "ToS",
+ "label": "tos",
"location": "https://kolabnow.com/tos",
"footer": true
}
@@ -26,15 +26,15 @@
"signup": [
{
"href": "https://kolabnow.com/tos",
- "title": "What are your terms of service?"
+ "label": "tos"
},
{
"href": "https://kb.kolabnow.com/faq/can-i-upgrade-an-individual-account-to-a-group-account",
- "title": "Can I upgrade an individual account to a group account?"
+ "label": "account-upgrade"
},
{
"href": "https://kb.kolabnow.com/faq/how-much-storage-comes-with-my-account",
- "title": "How much storage comes with my account?"
+ "label": "storage"
}
]
}
diff --git a/src/resources/themes/menu.scss b/src/resources/themes/menu.scss
--- a/src/resources/themes/menu.scss
+++ b/src/resources/themes/menu.scss
@@ -3,6 +3,10 @@
padding: 0;
line-height: 85px;
+ .navbar {
+ padding-right: 0;
+ }
+
.navbar-brand {
padding: 0;
outline: 0;
@@ -39,6 +43,11 @@
background-color: $main-color;
height: 100px;
overflow: hidden;
+ padding: 0;
+
+ .navbar {
+ padding-right: 0;
+ }
.navbar-brand {
margin: 0;
@@ -56,6 +65,20 @@
}
}
+#language-selector {
+ margin: 2em 0;
+
+ .dropdown-toggle {
+ padding-left: 20px;
+ line-height: 30px;
+ font-weight: lighter;
+ }
+
+ .dropdown-item {
+ line-height: initial;
+ }
+}
+
@include media-breakpoint-up(lg) {
#header-menu {
a.menulogin {
@@ -106,6 +129,10 @@
#footer-menu {
height: 80px;
+ .navbar {
+ padding-left: 0;
+ }
+
.navbar-nav {
display: none;
}
@@ -114,6 +141,14 @@
flex-wrap: nowrap;
}
}
+
+ #language-selector {
+ margin: 0;
+
+ .dropdown-toggle:after {
+ display: none;
+ }
+ }
}
@include media-breakpoint-down(sm) {
diff --git a/src/resources/vue/Login.vue b/src/resources/vue/Login.vue
--- a/src/resources/vue/Login.vue
+++ b/src/resources/vue/Login.vue
@@ -2,40 +2,40 @@
<div class="container d-flex flex-column align-items-center justify-content-center">
<div id="logon-form" class="card col-sm-8 col-lg-6">
<div class="card-body">
- <h1 class="card-title text-center mb-3">Please sign in</h1>
+ <h1 class="card-title text-center mb-3">{{ $t('login.header') }}</h1>
<div class="card-text">
<form class="form-signin" @submit.prevent="submitLogin">
<div class="form-group">
- <label for="inputEmail" class="sr-only">Email address</label>
+ <label for="inputEmail" class="sr-only">{{ $t('login.email') }}</label>
<div class="input-group">
<span class="input-group-prepend">
<span class="input-group-text"><svg-icon icon="user"></svg-icon></span>
</span>
- <input type="email" id="inputEmail" class="form-control" placeholder="Email address" required autofocus v-model="email">
+ <input type="email" id="inputEmail" class="form-control" :placeholder="$t('login.email')" required autofocus v-model="email">
</div>
</div>
<div class="form-group">
- <label for="inputPassword" class="sr-only">Password</label>
+ <label for="inputPassword" class="sr-only">{{ $t('login.password') }}</label>
<div class="input-group">
<span class="input-group-prepend">
<span class="input-group-text"><svg-icon icon="lock"></svg-icon></span>
</span>
- <input type="password" id="inputPassword" class="form-control" placeholder="Password" required v-model="password">
+ <input type="password" id="inputPassword" class="form-control" :placeholder="$t('login.password')" required v-model="password">
</div>
</div>
<div class="form-group pt-3" v-if="!$root.isAdmin">
- <label for="secondfactor" class="sr-only">2FA</label>
+ <label for="secondfactor" class="sr-only">{{ $t('login.2fa') }}</label>
<div class="input-group">
<span class="input-group-prepend">
<span class="input-group-text"><svg-icon icon="key"></svg-icon></span>
</span>
- <input type="text" id="secondfactor" class="form-control rounded-right" placeholder="Second factor code" v-model="secondFactor">
+ <input type="text" id="secondfactor" class="form-control rounded-right" :placeholder="$t('login.2fa')" v-model="secondFactor">
</div>
- <small class="form-text text-muted">Second factor code is optional for users with no 2-Factor Authentication setup.</small>
+ <small class="form-text text-muted">{{ $t('login.2fa_desc') }}</small>
</div>
<div class="text-center">
<button class="btn btn-primary" type="submit">
- <svg-icon icon="sign-in-alt"></svg-icon> Sign in
+ <svg-icon icon="sign-in-alt"></svg-icon> {{ $t('login.sign_in') }}
</button>
</div>
</form>
@@ -43,8 +43,8 @@
</div>
</div>
<div id="logon-form-footer" class="mt-1">
- <router-link v-if="!$root.isAdmin && $root.hasRoute('password-reset')" :to="{ name: 'password-reset' }" id="forgot-password">Forgot password?</router-link>
- <a v-if="webmailURL && !$root.isAdmin" :href="webmailURL" id="webmail">Webmail</a>
+ <router-link v-if="!$root.isAdmin && $root.hasRoute('password-reset')" :to="{ name: 'password-reset' }" id="forgot-password">{{ $t('login.forgot_password') }}</router-link>
+ <a v-if="webmailURL && !$root.isAdmin" :href="webmailURL" id="webmail">{{ $t('login.webmail') }}</a>
</div>
</div>
</template>
diff --git a/src/resources/vue/Widgets/Menu.vue b/src/resources/vue/Widgets/Menu.vue
--- a/src/resources/vue/Widgets/Menu.vue
+++ b/src/resources/vue/Widgets/Menu.vue
@@ -4,13 +4,13 @@
<router-link class="navbar-brand" to="/" v-html="$root.logo(mode)"></router-link>
<button v-if="mode == 'header'" class="navbar-toggler" type="button"
data-toggle="collapse" :data-target="'#' + mode + '-menu-navbar'"
- aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation"
+ aria-controls="navbar" aria-expanded="false" :aria-label="$t('menu.toggle')"
>
<span class="navbar-toggler-icon"></span>
</button>
<div :id="mode + '-menu-navbar'" :class="'navbar' + (mode == 'header' ? ' collapse navbar-collapse' : '')">
<ul class="navbar-nav">
- <li class="nav-item" v-for="item in menu()" :key="item.index">
+ <li class="nav-item" v-for="item in menu" :key="item.index">
<a v-if="item.href" :class="'nav-link link-' + item.index" :href="item.href">{{ item.title }}</a>
<router-link v-if="item.to"
:class="'nav-link link-' + item.index"
@@ -22,16 +22,24 @@
</router-link>
</li>
<li class="nav-item" v-if="!loggedIn && !$root.isAdmin">
- <router-link class="nav-link link-signup" active-class="active" :to="{name: 'signup'}">Signup</router-link>
+ <router-link class="nav-link link-signup" active-class="active" :to="{name: 'signup'}">{{ $t('menu.signup') }}</router-link>
</li>
<li class="nav-item" v-if="loggedIn">
- <router-link class="nav-link link-dashboard" active-class="active" :to="{name: 'dashboard'}">Cockpit</router-link>
+ <router-link class="nav-link link-dashboard" active-class="active" :to="{name: 'dashboard'}">{{ $t('menu.cockpit') }}</router-link>
</li>
<li class="nav-item" v-if="loggedIn">
- <router-link class="nav-link menulogin link-logout" active-class="active" :to="{name: 'logout'}">Logout</router-link>
+ <router-link class="nav-link menulogin link-logout" active-class="active" :to="{name: 'logout'}">{{ $t('menu.logout') }}</router-link>
</li>
<li class="nav-item" v-if="!loggedIn">
- <router-link class="nav-link menulogin link-login" :to="{name: 'login'}">Login</router-link>
+ <router-link class="nav-link menulogin link-login" :to="{name: 'login'}">{{ $t('menu.login') }}</router-link>
+ </li>
+ <li v-if="languages.length > 1 && mode == 'header'" id="language-selector" class="nav-item dropdown">
+ <a href="#" class="nav-link link-lang dropdown-toggle" role="button" data-toggle="dropdown">{{ getLang().toUpperCase() }}</a>
+ <div class="dropdown-menu dropdown-menu-right">
+ <a class="dropdown-item" href="#" v-for="lang in languages" :key="lang" @click="setLang(lang)">
+ {{ lang.toUpperCase() }} - {{ $t('lang.' + lang) }}
+ </a>
+ </div>
</li>
</ul>
<div v-if="mode == 'footer'" class="footer">
@@ -44,34 +52,43 @@
</template>
<script>
- import buildDate from '../../js/ts.js'
+ import buildDate from '../../build/js/ts'
+ import { setLang, getLang } from '../../js/locale'
export default {
- data() {
- return {
- buildYear: buildDate.getFullYear()
- }
- },
props: {
mode: { type: String, default: 'header' },
footer: { type: String, default: '' }
},
+ data() {
+ return {
+ buildYear: buildDate.getFullYear(),
+ languages: window.config['languages'] || [],
+ menuList: []
+ }
+ },
computed: {
loggedIn() { return this.$store.state.isLoggedIn },
+ menu() { return this.menuList.filter(item => !item.footer || this.mode == 'footer') },
route() { return this.$route.name }
},
mounted() {
+ this.menuList = this.loadMenu()
+
// On mobile close the menu when the menu item is clicked
if (this.mode == 'header') {
$('#header-menu .navbar').on('click', function() { $(this).removeClass('show') })
}
},
methods: {
- menu() {
+ loadMenu() {
let menu = []
+ const lang = this.getLang()
const loggedIn = this.loggedIn
window.config.menu.forEach(item => {
+ item.title = item['title-' + lang] || item['title-en'] || item.title
+
if (!item.location || !item.title) {
console.error("Invalid menu entry", item)
return
@@ -79,27 +96,26 @@
// TODO: Different menu for different loggedIn state
- if (window.isAdmin && !item.admin) {
- return
- } else if (!window.isAdmin && item.admin === 'only') {
- return
+ if (item.location.match(/^https?:/)) {
+ item.href = item.location
+ } else {
+ item.to = { path: item.location }
}
- if (!item.footer || this.mode == 'footer') {
- if (item.location.match(/^https?:/)) {
- item.href = item.location
- } else {
- item.to = { path: item.location }
- }
-
- item.exact = item.location == '/'
- item.index = item.page || item.title.toLowerCase().replace(/\s+/g, '')
+ item.exact = item.location == '/'
+ item.index = item.page || item.title.toLowerCase().replace(/\s+/g, '')
- menu.push(item)
- }
+ menu.push(item)
})
return menu
+ },
+ getLang() {
+ return getLang()
+ },
+ setLang(language) {
+ setLang(language)
+ this.menuList = this.loadMenu()
}
}
}
diff --git a/src/tests/Browser/Admin/LogonTest.php b/src/tests/Browser/Admin/LogonTest.php
--- a/src/tests/Browser/Admin/LogonTest.php
+++ b/src/tests/Browser/Admin/LogonTest.php
@@ -29,7 +29,7 @@
$this->browse(function (Browser $browser) {
$browser->visit(new Home())
->with(new Menu(), function ($browser) {
- $browser->assertMenuItems(['explore', 'blog', 'support', 'login']);
+ $browser->assertMenuItems(['explore', 'blog', 'support', 'login', 'lang']);
})
->assertMissing('@second-factor-input')
->assertMissing('@forgot-password');
@@ -77,7 +77,7 @@
// Checks if we're really on Dashboard page
$browser->on(new Dashboard())
->within(new Menu(), function ($browser) {
- $browser->assertMenuItems(['explore', 'blog', 'support', 'dashboard', 'logout']);
+ $browser->assertMenuItems(['explore', 'blog', 'support', 'dashboard', 'logout', 'lang']);
})
->assertUser('jeroen@jeroen.jeroen');
@@ -108,7 +108,7 @@
// with default menu
$browser->within(new Menu(), function ($browser) {
- $browser->assertMenuItems(['explore', 'blog', 'support', 'login']);
+ $browser->assertMenuItems(['explore', 'blog', 'support', 'login', 'lang']);
});
// Success toast message
@@ -135,7 +135,7 @@
// with default menu
$browser->within(new Menu(), function ($browser) {
- $browser->assertMenuItems(['explore', 'blog', 'support', 'login']);
+ $browser->assertMenuItems(['explore', 'blog', 'support', 'login', 'lang']);
});
// Success toast message
diff --git a/src/tests/Browser/Components/Menu.php b/src/tests/Browser/Components/Menu.php
--- a/src/tests/Browser/Components/Menu.php
+++ b/src/tests/Browser/Components/Menu.php
@@ -108,6 +108,7 @@
'@list' => ".navbar-nav",
'@brand' => ".navbar-brand",
'@toggler' => ".navbar-toggler",
+ '@lang' => ".nav-link.link-lang",
];
}
}
diff --git a/src/tests/Browser/LogonTest.php b/src/tests/Browser/LogonTest.php
--- a/src/tests/Browser/LogonTest.php
+++ b/src/tests/Browser/LogonTest.php
@@ -13,7 +13,6 @@
class LogonTest extends TestCaseDusk
{
-
/**
* Test menu on logon page
*/
@@ -22,7 +21,7 @@
$this->browse(function (Browser $browser) {
$browser->visit(new Home())
->within(new Menu(), function ($browser) {
- $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login'])
+ $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login', 'lang'])
->assertSeeIn('#footer-copyright', '@ Apheleia IT AG, ' . date('Y'));
});
@@ -40,6 +39,53 @@
}
/**
+ * Test language menu, and language change
+ */
+ public function testLocales(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home())
+ // ->plainCookie('language', '')
+ ->within(new Menu(), function ($browser) {
+ $browser->assertSeeIn('@lang', 'EN')
+ ->click('@lang');
+ })
+ // Switch English -> German
+ ->whenAvailable('nav .dropdown-menu', function (Browser $browser) {
+ $browser->assertElementsCount('a', 2)
+ ->assertSeeIn('a:nth-child(1)', 'EN - English')
+ ->assertSeeIn('a:nth-child(2)', 'DE - German')
+ ->click('a:nth-child(2)');
+ })
+ ->waitUntilMissing('nav .dropdown-menu')
+ ->within(new Menu(), function ($browser) {
+ $browser->assertSeeIn('@lang', 'DE');
+ })
+ ->waitForTextIn('#header-menu .link-login', 'EINLOGGEN')
+ ->assertSeeIn('#footer-menu .link-login', 'Einloggen')
+ ->assertSeeIn('@logon-button', 'Anmelden')
+ // refresh the page to see if it uses the lang previously set
+ ->refresh()
+ ->waitForTextIn('#header-menu .link-login', 'EINLOGGEN')
+ ->assertSeeIn('#footer-menu .link-login', 'Einloggen')
+ ->assertSeeIn('@logon-button', 'Anmelden')
+ ->within(new Menu(), function ($browser) {
+ $browser->assertSeeIn('@lang', 'DE')
+ ->click('@lang');
+ })
+ // Switch German -> English
+ ->whenAvailable('nav .dropdown-menu', function (Browser $browser) {
+ $browser->click('a:nth-child(1)');
+ })
+ ->waitUntilMissing('nav .dropdown-menu')
+ ->within(new Menu(), function ($browser) {
+ $browser->assertSeeIn('@lang', 'EN');
+ })
+ ->waitForTextIn('#header-menu .link-login', 'LOGIN');
+ });
+ }
+
+ /**
* Test redirect to /login if user is unauthenticated
*/
public function testRequiredAuth(): void
@@ -86,7 +132,7 @@
->assertVisible('@links a.link-wallet')
->assertVisible('@links a.link-webmail')
->within(new Menu(), function ($browser) {
- $browser->assertMenuItems(['explore', 'blog', 'support', 'dashboard', 'logout']);
+ $browser->assertMenuItems(['explore', 'blog', 'support', 'dashboard', 'logout', 'lang']);
});
if ($browser->isDesktop()) {
@@ -138,7 +184,7 @@
// with default menu
$browser->within(new Menu(), function ($browser) {
- $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']);
+ $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login', 'lang']);
});
// Success toast message
@@ -165,7 +211,7 @@
// with default menu
$browser->within(new Menu(), function ($browser) {
- $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']);
+ $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login', 'lang']);
});
// Success toast message
diff --git a/src/tests/Browser/Meet/RoomSetupTest.php b/src/tests/Browser/Meet/RoomSetupTest.php
--- a/src/tests/Browser/Meet/RoomSetupTest.php
+++ b/src/tests/Browser/Meet/RoomSetupTest.php
@@ -36,7 +36,7 @@
$this->browse(function (Browser $browser) {
$browser->visit(new RoomPage('unknown'))
->within(new Menu(), function ($browser) {
- $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']);
+ $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login', 'lang']);
});
if ($browser->isDesktop()) {
@@ -70,7 +70,7 @@
$this->browse(function (Browser $browser) {
$browser->visit(new RoomPage('john'))
->within(new Menu(), function ($browser) {
- $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']);
+ $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login', 'lang']);
});
if ($browser->isDesktop()) {
@@ -150,7 +150,7 @@
->submitLogon('john@kolab.org', 'simple123')
->waitFor('@setup-form')
->within(new Menu(), function ($browser) {
- $browser->assertMenuItems(['explore', 'blog', 'support', 'dashboard', 'logout']);
+ $browser->assertMenuItems(['explore', 'blog', 'support', 'dashboard', 'logout', 'lang']);
});
if ($browser->isDesktop()) {
diff --git a/src/tests/Browser/Pages/Home.php b/src/tests/Browser/Pages/Home.php
--- a/src/tests/Browser/Pages/Home.php
+++ b/src/tests/Browser/Pages/Home.php
@@ -42,6 +42,7 @@
'@email-input' => '#inputEmail',
'@password-input' => '#inputPassword',
'@second-factor-input' => '#secondfactor',
+ '@logon-button' => '#logon-form button.btn-primary'
];
}
diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php
--- a/src/tests/Browser/SignupTest.php
+++ b/src/tests/Browser/SignupTest.php
@@ -105,7 +105,7 @@
->assertMissing('@step3');
$browser->within(new Menu(), function ($browser) {
- $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login'], 'signup');
+ $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login', 'lang'], 'signup');
});
$browser->waitFor('@step0 .plan-selector > .card');
@@ -174,7 +174,7 @@
});
$browser->within(new Menu(), function ($browser) {
- $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login'], 'signup');
+ $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login', 'lang'], 'signup');
});
// Submit invalid email, and first_name
diff --git a/src/webpack.mix.js b/src/webpack.mix.js
--- a/src/webpack.mix.js
+++ b/src/webpack.mix.js
@@ -10,7 +10,7 @@
|
*/
-const { exec } = require('child_process');
+const { spawn } = require('child_process');
const fs = require('fs');
const glob = require('glob');
const mix = require('laravel-mix');
@@ -24,7 +24,7 @@
})
mix.before(() => {
- exec('php resources/build/before.php')
+ spawn('php', ['resources/build/before.php'], { stdio: 'inherit' })
})
mix.js('resources/js/user.js', 'public/js').vue()
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Thu, Apr 9, 3:02 AM (19 h, 7 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18849537
Default Alt Text
D2395.1775703738.diff (46 KB)
Attached To
Mode
D2395: Localization with vue-i18n
Attached
Detach File
Event Timeline