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,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/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 = ''
@@ -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]" */ `../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 @@
+ [
+ '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 @@
- '« 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 @@
- '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 @@
+ [
+ '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 @@
+ "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,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 {
@@ -114,6 +128,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 @@
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 @@