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/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,17 @@ 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 = sprintf('themes/%s/pages/%s.blade.php', $theme, $page); + $view = sprintf('%s.pages.%s', $theme, $page); if (!file_exists(resource_path($file))) { abort(404); } + self::loadLocale($theme); + return view($view)->with('env', \App\Utils::uiEnv()); } @@ -57,6 +60,113 @@ // TODO: Support pages with variables, e.g. users/ } + // 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,43 @@ +getLanguages() + ); + + $default = config('app.locale'); + $lang = null; + + foreach ($preferences as $pref) { + if (!empty($pref) && ($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/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/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 = '
Loading
' @@ -69,11 +70,11 @@ }) const app = new Vue({ - el: '#app', components: { AppComponent, MenuComponent, }, + i18n, store, router: window.router, data() { @@ -371,6 +372,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,60 @@ +import Vue from 'vue' +import VueI18n from 'vue-i18n' + +// We do not pre-load English language, but it will be loaded automatically +// on page load. This means that localization files must be complete, +// because there will be no fallback localization loaded. +// import messages from '../lang-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 + window.axios.defaults.headers.common['Accept-Language'] = lang + document.querySelector('html').setAttribute('lang', lang) + + return lang +} + +export const getLang = () => { + // TODO: On init get the current user language from the browser? + if (!currentLanguage) { + currentLanguage = document.querySelector('html').getAttribute('lang') || 'en' + } + + return currentLanguage +} + +export const setLang = lang => { + // TODO: Save the selected language in the browser? + 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]" */ `../lang/${lang}/ui.json`) + .then(messages => { + i18n.setLocaleMessage(lang, messages.default) + loadedLanguages.push(lang) + return setI18nLanguage(lang) + }) +} diff --git a/src/resources/lang/de/ui.json b/src/resources/lang/de/ui.json new file mode 100644 --- /dev/null +++ b/src/resources/lang/de/ui.json @@ -0,0 +1,22 @@ +{ + "buttons": { + "cancel": "Stornieren", + "save": "Speichern" + }, + "menu": { + "cockpit": "Cockpit", + "login": "Einloggen", + "logout": "Ausloggen", + "signup": "Signup", + "toggle": "Navigation umschalten" + }, + "lang": { + "en": "Englisch", + "de": "Deutsch" + }, + "login": { + "forgot_password": "Passwort vergessen?", + "sign_in": "Anmelden", + "webmail": "Webmail" + } +} diff --git a/src/resources/lang/en/ui.json b/src/resources/lang/en/ui.json new file mode 100644 --- /dev/null +++ b/src/resources/lang/en/ui.json @@ -0,0 +1,27 @@ +{ + "buttons": { + "cancel": "Cancel", + "save": "Save" + }, + "menu": { + "cockpit": "Cockpit", + "login": "Login", + "logout": "Logout", + "signup": "Signup", + "toggle": "Toggle navigation" + }, + "lang": { + "en": "English", + "de": "German" + }, + "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 @@ + "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 @@ + "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 @@ + "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 @@ + "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 @@ + "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 @@ + "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 @@

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 @@ -56,6 +56,28 @@ } } +#language-selector { + margin: 2em 0; + height: 2em; + border-right: 1px solid #bbb; + + .nav-link { + padding-left: 0; + padding-right: 25px; + line-height: 30px; + font-weight: lighter; + + &:hover { + color: unset; + text-decoration: none; + } + } + + .dropdown-item { + line-height: initial; + } +} + @include media-breakpoint-up(lg) { #header-menu { a.menulogin { @@ -114,6 +136,12 @@ flex-wrap: nowrap; } } + + #language-selector { + border-right: 0; + margin: 0; + height: unset; + } } @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 @@
-

Please sign in

+

{{ $t('login.header') }}

@@ -43,8 +43,8 @@
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,19 @@