Просмотр исходного кода

Add language option to Dashboard

Markus-Rost 4 лет назад
Родитель
Сommit
b1518a0560

+ 10 - 2
dashboard/guilds.js

@@ -1,6 +1,7 @@
 const cheerio = require('cheerio');
 const {defaultPermissions} = require('../util/default.json');
 const Lang = require('./i18n.js');
+const allLangs = Lang.allLangs().names;
 const {settingsData, createNotice} = require('./util.js');
 
 const forms = {
@@ -21,12 +22,13 @@ const file = require('fs').readFileSync('./dashboard/index.html');
 /**
  * Let a user view settings
  * @param {import('http').ServerResponse} res - The server response
+ * @param {import('./i18n.js')} dashboardLang - The user language.
  * @param {String} state - The user state
  * @param {URL} reqURL - The used url
  * @param {String} [action] - The action the user made
  * @param {String[]} [actionArgs] - The arguments for the action
  */
-function dashboard_guilds(res, state, reqURL, action, actionArgs) {
+function dashboard_guilds(res, dashboardLang, state, reqURL, action, actionArgs) {
 	reqURL.pathname = reqURL.pathname.replace( /^(\/(?:guild\/\d+(?:\/(?:settings|verification|rcscript)(?:\/(?:\d+|new))?)?)?)(?:\/.*)?$/, '$1' );
 	var args = reqURL.pathname.split('/');
 	args = reqURL.pathname.split('/');
@@ -34,8 +36,14 @@ function dashboard_guilds(res, state, reqURL, action, actionArgs) {
 	if ( reqURL.searchParams.get('owner') && process.env.owner.split('|').includes(settings.user.id) ) {
 		args[0] = 'owner';
 	}
-	var dashboardLang = new Lang(settings.user.locale);
+	dashboardLang = new Lang(...dashboardLang.fromCookie, settings.user.locale, dashboardLang.lang);
+	res.setHeader('Content-Language', [dashboardLang.lang]);
 	var $ = cheerio.load(file);
+	$('html').attr('lang', dashboardLang.lang);
+	$('<script>').text(`
+		const selectLanguage = '${dashboardLang.get('general.language').replace( /'/g, '\\$&' )}';
+		const allLangs = ${JSON.stringify(allLangs)};
+	`).insertBefore('script#langjs');
 	$('head title').text(dashboardLang.get('general.title'));
 	$('.channel#settings div').text(dashboardLang.get('general.settings'));
 	$('.channel#verification div').text(dashboardLang.get('general.verification'));

+ 18 - 30
dashboard/i18n.js

@@ -1,27 +1,7 @@
 const {defaultSettings} = require('../util/default.json');
 const {escapeText} = require('./util.js');
-const i18n = {
-	en: require('./i18n/en.json'),
-	bn: require('./i18n/bn.json'),
-	de: require('./i18n/de.json'),
-	es: require('./i18n/es.json'),
-	fr: require('./i18n/fr.json'),
-	hi: require('./i18n/hi.json'),
-	it: require('./i18n/it.json'),
-	ja: require('./i18n/ja.json'),
-	ko: require('./i18n/ko.json'),
-	nl: require('./i18n/nl.json'),
-	pl: require('./i18n/pl.json'),
-	pt: require('./i18n/pt-br.json'),
-	'pt-br': require('./i18n/pt-br.json'),
-	ru: require('./i18n/ru.json'),
-	th: require('./i18n/th.json'),
-	tr: require('./i18n/tr.json'),
-	vi: require('./i18n/vi.json'),
-	zh: require('./i18n/zh-hans.json'),
-	'zh-hans': require('./i18n/zh-hans.json'),
-	'zh-hant': require('./i18n/zh-hant.json')
-};
+var i18n = require('./i18n/allLangs.json');
+Object.keys(i18n.allLangs.names).forEach( lang => i18n[lang] = require('./i18n/' + lang + '.json') );
 
 /**
  * A language.
@@ -30,15 +10,16 @@ const i18n = {
 class Lang {
 	/**
 	 * Creates a new language.
-	 * @param {String} [lang] - The language code.
-	 * @param {String} [namespace] - The namespace for the language.
+	 * @param {String[]} [langs] - The language code.
 	 * @constructs Lang
 	 */
-	constructor(lang = defaultSettings.lang, namespace = '') {
-		if ( !( typeof lang === 'string' && lang in i18n ) ) lang = defaultSettings.lang;
-		this.lang = lang;
-		this.namespace = namespace;
-		this.fallback = ( i18n?.[lang]?.fallback.slice() || [defaultSettings.lang] ).filter( fb => fb.trim() );
+	constructor(...langs) {
+		this.lang = ( langs.find( lang => {
+			if ( typeof lang !== 'string' ) lang = '';
+			return i18n.allLangs.map[lang.toLowerCase()]
+		} ) || defaultSettings.lang );
+		this.fallback = ( i18n?.[this.lang]?.fallback.slice() || [defaultSettings.lang] ).filter( fb => fb.trim() );
+		this.fromCookie = [];
 	}
 
 	/**
@@ -49,7 +30,6 @@ class Lang {
 	 * @returns {String}
 	 */
 	get(message = '', escaped = false, ...args) {
-		if ( this.namespace.length ) message = this.namespace + '.' + message;
 		let keys = ( message.length ? message.split('.') : [] );
 		let lang = this.lang;
 		let text = i18n?.[lang];
@@ -87,6 +67,14 @@ class Lang {
 		}
 		return ( text || '⧼' + message + ( isDebug && args.length ? ': ' + args.join(', ') : '' ) + '⧽' );
 	}
+
+	/**
+	 * Get names for all languages.
+	 * @static
+	 */
+	static allLangs() {
+		return i18n.allLangs;
+	}
 }
 
 /**

+ 82 - 0
dashboard/i18n/allLangs.json

@@ -0,0 +1,82 @@
+{
+	"allLangs": {
+		"names": {
+			"en": "English (en)",
+			"bn": "বাংলা (bn)",
+			"de": "Deutsch (de)",
+			"es": "Español (es)",
+			"hi": "हिन्दी (hi)",
+			"ko": "한국어 (ko)",
+			"pl": "Polski (pl)",
+			"pt-br": "Português do Brasil (pt-br)",
+			"ru": "Русский (ru)",
+			"tr": "Türkçe (tr)",
+			"zh-hans": "简体中文 (zh-hans)",
+			"zh-hant": "繁體中文 (zh-hant)"
+		},
+		"map": {
+			"en": "en",
+			"eng": "en",
+			"english": "en",
+			"english (en)": "en",
+			"bn": "bn",
+			"bengali": "bn",
+			"বাংলা": "bn",
+			"বাংলা (bn)": "bn",
+			"de": "de",
+			"german": "de",
+			"deutsch": "de",
+			"deutsch (de)": "de",
+			"es": "es",
+			"spanish": "es",
+			"español": "es",
+			"español (es)": "es",
+			"hi": "hi",
+			"hindi": "hi",
+			"हिन्दी": "hi",
+			"हिन्दी (hi)": "hi",
+			"ko": "ko",
+			"korean": "ko",
+			"한국어": "ko",
+			"한국어 (ko)": "ko",
+			"pl": "pl",
+			"polish": "pl",
+			"polski": "pl",
+			"polski (pl)": "pl",
+			"pt": "pt-br",
+			"portuguese": "pt-br",
+			"português": "pt-br",
+			"português (pt)": "pt-br",
+			"pt-br": "pt-br",
+			"brazilian portuguese": "pt-br",
+			"português do brasil": "pt-br",
+			"português do brasil (pt-br)": "pt-br",
+			"ru": "ru",
+			"russian": "ru",
+			"русский": "ru",
+			"русский (ru)": "ru",
+			"tr": "tr",
+			"turkish": "tr",
+			"turkçe": "tr",
+			"turkçe (tr)": "tr",
+			"zh": "zh-hans",
+			"chinese": "zh-hans",
+			"中文": "zh-hans",
+			"汉语": "zh-hans",
+			"漢語": "zh-hant",
+			"zh-hans": "zh-hans",
+			"zh-cn": "zh-hans",
+			"chinese (simplified)": "zh-hans",
+			"simplified chinese": "zh-hans",
+			"简体中文": "zh-hans",
+			"简体中文 (zh-hans)": "zh-hans",
+			"zh-hant": "zh-hant",
+			"zh-tw": "zh-hant",
+			"chinese (traditional)": "zh-hant",
+			"traditional chinese": "zh-hant",
+			"chinese (taiwan)": "zh-hant",
+			"繁體中文": "zh-hant",
+			"繁體中文 (zh-hant)": "zh-hant"
+		}
+	}
+}

+ 4 - 1
dashboard/i18n/en.json

@@ -9,6 +9,8 @@
     "general": {
         "delete": "Delete",
         "invite": "Invite Wiki-Bot",
+        "language": "Select Language",
+        "login": "Login",
         "logout": "Logout",
         "rcscript": "Recent Changes",
         "refresh": "Refresh server list",
@@ -17,7 +19,8 @@
         "settings": "Settings",
         "support": "Support Server",
         "title": "Wiki-Bot Settings",
-        "verification": "Verifications"
+        "verification": "Verifications",
+        "welcome": "<h2>Welcome on Wiki-Bot Dashboard.</h2>\n<p>Wiki-Bot is a Discord bot made to bring Discord servers and MediaWiki wikis together. It helps with linking wiki pages, verifying wiki users, informing about latest changes on the wiki and more. <a href=\"https://wiki.wikibot.de/wiki/Wiki-Bot_Wiki\" target=\"_blank\">[More information]</a></p>\n<p>Here you can change different bot settings for servers you have Manage Server permission on. To begin, you will have to authenticate your Discord account which you can do with this button:</p>"
     },
     "indexjs": {
         "invalid": {

+ 1 - 0
dashboard/index.html

@@ -11,6 +11,7 @@
 	<meta property="og:site_name" content="Wiki-Bot Settings">
 	<meta itemprop="author" content="MarkusRost">
 	<link rel="stylesheet" type="text/css" href="/src/index.css">
+	<script id="langjs" src="/src/lang.js" defer></script>
 	<script id="indexjs" src="/src/index.js" defer></script>
 </head>
 <body>

+ 36 - 7
dashboard/index.js

@@ -2,6 +2,8 @@ const http = require('http');
 const pages = require('./oauth.js');
 const dashboard = require('./guilds.js');
 const {db, settingsData} = require('./util.js');
+const Lang = require('./i18n.js');
+const allLangs = Lang.allLangs();
 
 global.isDebug = ( process.argv[2] === 'debug' );
 
@@ -90,7 +92,20 @@ const server = http.createServer((req, res) => {
 			 * @param {String[]} [actionArgs]
 			 */
 			function save_response(resURL = '/', action, ...actionArgs) {
-				return dashboard(res, state, new URL(resURL, process.env.dashboard), action, actionArgs);
+				var langCookie = ( req.headers?.cookie?.split('; ')?.filter( cookie => {
+					return cookie.split('=')[0] === 'language' && /^"[a-z\-]+"$/.test(( cookie.split('=')[1] || '' ));
+				} )?.map( cookie => cookie.replace( /^language="([a-z\-]+)"$/, '$1' ) ) || [] );
+				var dashboardLang = new Lang(...langCookie, ...( req.headers?.['accept-language']?.split(',')?.map( lang => {
+					lang = lang.split(';')[0].toLowerCase();
+					if ( allLangs.map.hasOwnProperty(lang) ) return lang;
+					lang = lang.replace( /-\w+$/, '' );
+					if ( allLangs.map.hasOwnProperty(lang) ) return lang;
+					lang = lang.replace( /-\w+$/, '' );
+					if ( allLangs.map.hasOwnProperty(lang) ) return lang;
+					return '';
+				} ) || [] ));
+				dashboardLang.fromCookie = langCookie;
+				return dashboard(res, dashboardLang, state, new URL(resURL, process.env.dashboard), action, actionArgs);
 			}
 		}
 	}
@@ -115,7 +130,21 @@ const server = http.createServer((req, res) => {
 	}
 
 	res.setHeader('Content-Type', 'text/html');
-	res.setHeader('Content-Language', ['en']);
+
+	var langCookie = ( req.headers?.cookie?.split('; ')?.filter( cookie => {
+		return cookie.split('=')[0] === 'language' && /^"[a-z\-]+"$/.test(( cookie.split('=')[1] || '' ));
+	} )?.map( cookie => cookie.replace( /^language="([a-z\-]+)"$/, '$1' ) ) || [] );
+	var dashboardLang = new Lang(...langCookie, ...( req.headers?.['accept-language']?.split(',')?.map( lang => {
+		lang = lang.split(';')[0].toLowerCase();
+		if ( allLangs.map.hasOwnProperty(lang) ) return lang;
+		lang = lang.replace( /-\w+$/, '' );
+		if ( allLangs.map.hasOwnProperty(lang) ) return lang;
+		lang = lang.replace( /-\w+$/, '' );
+		if ( allLangs.map.hasOwnProperty(lang) ) return lang;
+		return '';
+	} ) || [] ));
+	dashboardLang.fromCookie = langCookie;
+	res.setHeader('Content-Language', [dashboardLang.lang]);
 
 	var lastGuild = req.headers?.cookie?.split('; ')?.filter( cookie => {
 		return cookie.split('=')[0] === 'guild' && /^"\d+\/(?:settings|verification|rcscript)(?:\/(?:\d+|new))?"$/.test(( cookie.split('=')[1] || '' ));
@@ -129,7 +158,7 @@ const server = http.createServer((req, res) => {
 	if ( reqURL.pathname === '/login' ) {
 		let action = '';
 		if ( reqURL.searchParams.get('action') === 'failed' ) action = 'loginfail';
-		return pages.login(res, state, action);
+		return pages.login(res, dashboardLang, state, action);
 	}
 
 	if ( reqURL.pathname === '/logout' ) {
@@ -138,7 +167,7 @@ const server = http.createServer((req, res) => {
 			...( res.getHeader('Set-Cookie') || [] ),
 			'wikibot=""; HttpOnly; Path=/; Max-Age=0'
 		]);
-		return pages.login(res, state, 'logout');
+		return pages.login(res, dashboardLang, state, 'logout');
 	}
 
 	if ( !state ) {
@@ -148,7 +177,7 @@ const server = http.createServer((req, res) => {
 				res.setHeader('Set-Cookie', [`guild="${pathGuild}"; HttpOnly; Path=/`]);
 			}
 		}
-		return pages.login(res, state, ( reqURL.pathname === '/' ? '' : 'unauthorized' ));
+		return pages.login(res, dashboardLang, state, ( reqURL.pathname === '/' ? '' : 'unauthorized' ));
 	}
 
 	if ( reqURL.pathname === '/oauth' ) {
@@ -162,7 +191,7 @@ const server = http.createServer((req, res) => {
 				res.setHeader('Set-Cookie', [`guild="${pathGuild}"; HttpOnly; Path=/`]);
 			}
 		}
-		return pages.login(res, state, ( reqURL.pathname === '/' ? '' : 'unauthorized' ));
+		return pages.login(res, dashboardLang, state, ( reqURL.pathname === '/' ? '' : 'unauthorized' ));
 	}
 
 	if ( reqURL.pathname === '/refresh' ) {
@@ -181,7 +210,7 @@ const server = http.createServer((req, res) => {
 	let action = '';
 	if ( reqURL.searchParams.get('refresh') === 'success' ) action = 'refresh';
 	if ( reqURL.searchParams.get('refresh') === 'failed' ) action = 'refreshfail';
-	return dashboard(res, state, reqURL, action);
+	return dashboard(res, dashboardLang, state, reqURL, action);
 });
 
 server.listen(8080, 'localhost', () => {

+ 14 - 9
dashboard/login.html

@@ -11,23 +11,28 @@
 	<meta property="og:site_name" content="Wiki-Bot Settings">
 	<meta itemprop="author" content="MarkusRost">
 	<link rel="stylesheet" type="text/css" href="/src/index.css">
+	<script id="langjs" src="/src/lang.js" defer></script>
 </head>
 <body>
 	<div id="text">
 		<div id="notices"></div>
 		<div class="description">
-			<h2>Welcome on Wiki-Bot Dashboard.</h2>
-			<p>Wiki-Bot is a Discord bot made to bring Discord servers and MediaWiki wikis together. It helps with linking wiki pages, verifying wiki users, informing about latest changes on the wiki and more. <a href="https://wiki.wikibot.de/wiki/Wiki-Bot_Wiki" target="_blank">[More information]</a></p>
-			<p>Here you can change different bot settings for servers you have Manage Server permission on. To begin, you will have to authenticate your Discord account which you can do with this button:</p>
+			<div id="welcome">
+				<h2>Welcome on Wiki-Bot Dashboard.</h2>
+				<p>Wiki-Bot is a Discord bot made to bring Discord servers and MediaWiki wikis together. It helps with linking wiki pages, verifying wiki users, informing about latest changes on the wiki and more. <a href="https://wiki.wikibot.de/wiki/Wiki-Bot_Wiki" target="_blank">[More information]</a></p>
+				<p>Here you can change different bot settings for servers you have Manage Server permission on. To begin, you will have to authenticate your Discord account which you can do with this button:</p>
+			</div>
 			<a id="login-button">
 				<img src="https://discord.com/assets/f8389ca1a741a115313bede9ac02e2c0.svg" alt="Discord">
-				Login
+				<span>Login</span>
 			</a>
-			<h3>Enjoy a little story?</h3>
-			<p>A long time ago, when the world was still figuring out itself, a new faction was born, Gamepedia its name. The kingdom grew quickly, people wanted their own place in the kingdom, it was said the kingdom was a better place than any other kingdoms, which were ridden with very old infrastructure, the taxes enforced by their kings were too high and the kings not too merciful. On the other side, the new kingdom was blossoming, people from all over the world wanted to live there, have their own place there and to do that, they joined existing guilds of people who share their interests. The king here really cared about his most devoted citizens giving them tax exemption status and helping all of them so each of the guilds in the kingdom can prosper and spread the good word about the kingdom.</p>
-			<p>Soon enough the first bigger guilds were joining the kingdom, seeing the greatness of it they joined the kingdom along with their huge tracts. The momentum of growth became a sign of change, a change for the better future. One of the first great King's advisors was Wyn. She was passionate and very talented in all fields needed to manage the kingdom. She enthusiastically  welcomed new guilds and made sure there is nothing on their way to be a fully functioning guilds on Gamepedia.</p>
-			<p>At first the biggest guilds in the kingdom included a guild which consisted of people who devoted their lives to punching the trees with their bare fists, …</p>
-			<a href="https://wiki.wikibot.de/wiki/Story" target="_blank">[Read more]</a>
+			<div id="story">
+				<h3>Enjoy a little story?</h3>
+				<p>A long time ago, when the world was still figuring out itself, a new faction was born, Gamepedia its name. The kingdom grew quickly, people wanted their own place in the kingdom, it was said the kingdom was a better place than any other kingdoms, which were ridden with very old infrastructure, the taxes enforced by their kings were too high and the kings not too merciful. On the other side, the new kingdom was blossoming, people from all over the world wanted to live there, have their own place there and to do that, they joined existing guilds of people who share their interests. The king here really cared about his most devoted citizens giving them tax exemption status and helping all of them so each of the guilds in the kingdom can prosper and spread the good word about the kingdom.</p>
+				<p>Soon enough the first bigger guilds were joining the kingdom, seeing the greatness of it they joined the kingdom along with their huge tracts. The momentum of growth became a sign of change, a change for the better future. One of the first great King's advisors was Wyn. She was passionate and very talented in all fields needed to manage the kingdom. She enthusiastically  welcomed new guilds and made sure there is nothing on their way to be a fully functioning guilds on Gamepedia.</p>
+				<p>At first the biggest guilds in the kingdom included a guild which consisted of people who devoted their lives to punching the trees with their bare fists, …</p>
+				<a href="https://wiki.wikibot.de/wiki/Story" target="_blank">[Read more]</a>
+			</div>
 		</div>
 	</div>
 	<div class="scrollbar" id="sidebar">

+ 15 - 4
dashboard/oauth.js

@@ -1,9 +1,8 @@
 const crypto = require('crypto');
 const cheerio = require('cheerio');
-const {defaultPermissions, defaultSettings} = require('../util/default.json');
+const {defaultPermissions} = require('../util/default.json');
 const Wiki = require('../util/wiki.js');
-const Lang = require('./i18n.js');
-const dashboardLang = new Lang(defaultSettings.lang);
+const allLangs = require('./i18n.js').allLangs().names;
 const {got, settingsData, sendMsg, createNotice, hasPerm} = require('./util.js');
 
 const DiscordOauth2 = require('discord-oauth2');
@@ -18,10 +17,11 @@ const file = require('fs').readFileSync('./dashboard/login.html');
 /**
  * Let a user login
  * @param {import('http').ServerResponse} res - The server response
+ * @param {import('./i18n.js')} dashboardLang - The user language.
  * @param {String} [state] - The user state
  * @param {String} [action] - The action the user made
  */
-function dashboard_login(res, state, action) {
+function dashboard_login(res, dashboardLang, state, action) {
 	if ( state && settingsData.has(state) ) {
 		if ( !action ) {
 			res.writeHead(302, {Location: '/'});
@@ -30,6 +30,17 @@ function dashboard_login(res, state, action) {
 		settingsData.delete(state);
 	}
 	var $ = cheerio.load(file);
+	$('html').attr('lang', dashboardLang.lang);
+	$('<script>').text(`
+		const selectLanguage = '${dashboardLang.get('general.language').replace( /'/g, '\\$&' )}';
+		const allLangs = ${JSON.stringify(allLangs)};
+	`).insertBefore('script#langjs');
+	$('head title').text(dashboardLang.get('general.login') + ' – ' + dashboardLang.get('general.title'));
+	$('#login-botton span, .channel#login div').text(dashboardLang.get('general.login'));
+	$('.channel#invite-wikibot div').text(dashboardLang.get('general.invite'));
+	$('.guild#invite a').attr('alt', dashboardLang.get('general.invite'));
+	$('#support span').text(dashboardLang.get('general.support'));
+	$('#text .description #welcome').html(dashboardLang.get('general.welcome'));
 	let responseCode = 200;
 	let prompt = 'none';
 	if ( process.env.READONLY ) createNotice($, 'readonly', dashboardLang);

+ 38 - 0
dashboard/src/index.css

@@ -307,6 +307,44 @@ code {
 	font-weight: bold;
 	text-shadow: none;
 }
+#lang-selector {
+	background: #2f3136;
+	position: fixed;
+	border-top: 2px solid #202225;
+	height: 30px;
+	width: 224px;
+	left: 72px;
+	bottom: 0;
+	padding: 0 8px;
+	display: flex;
+	align-items: center;
+	color: #8e9297;
+}
+#lang-selector img {
+	padding-right: 2px;
+}
+#lang-selector:hover #lang-dropdown {
+	visibility: visible;
+}
+#lang-dropdown {
+	visibility: hidden;
+	background: #202225;
+	position: absolute;
+	width: 240px;
+	left: 0;
+	bottom: 32px;
+	overflow: overlay;
+}
+#lang-dropdown div {
+	cursor: pointer;
+	margin: 2px;
+	padding: 4px;
+	font-size: 90%;
+	border-radius: 2px;
+}
+#lang-dropdown div:hover, #lang-dropdown div.current {
+	background: #2f3136;
+}
 fieldset > div {
 	margin: 10px 0;
 }

+ 1 - 1
dashboard/src/index.js

@@ -34,7 +34,7 @@ for ( var j = 0; j < addmore.length; j++ ) {
 		clone.addEventListener( 'input', toggleOption );
 		this.before(clone);
 		toggleOption.call(clone);
-	}
+	};
 }
 
 /**

+ 27 - 0
dashboard/src/lang.js

@@ -0,0 +1,27 @@
+var currentLang = ( document.cookie.split('; ').find( cookie => {
+	return cookie.split('=')[0] === 'language' && /^"[a-z\-]+"$/.test(( cookie.split('=')[1] || '' ));
+} ) || 'en' ).replace( /^language="([a-z\-]+)"$/, '$1' );
+var channellist = document.getElementById('channellist');
+var langSelector = document.createElement('div');
+langSelector.id = 'lang-selector';
+langSelector.textContent = selectLanguage;
+var langIcon = document.createElement('img');
+langIcon.setAttribute('src', '/src/language.svg');
+langSelector.prepend(langIcon);
+var langDropdown = document.createElement('div');
+langDropdown.id = 'lang-dropdown';
+langDropdown.setAttribute('style', `max-height: ${window.innerHeight - 80}px;`);
+var langOptions = Object.keys(allLangs).map( function(lang) {
+	var langOption = document.createElement('div');
+	langOption.textContent = allLangs[lang];
+	if ( currentLang === lang ) langOption.className = 'current';
+	langOption.onclick = function() {
+		document.cookie = `language="${lang}"; Path=/; Max-Age=31536000`;
+		location.reload();
+	};
+	return langOption;
+} );
+langDropdown.append(...langOptions);
+langSelector.append(langDropdown);
+channellist.after(langSelector);
+channellist.setAttribute('style', 'bottom: 32px;');

+ 3 - 0
dashboard/src/language.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="20" height="20" viewBox="0 0 20 20">
+	<path fill="#8e9297" d="M20 18h-1.44a.61.61 0 01-.4-.12.81.81 0 01-.23-.31L17 15h-5l-1 2.54a.77.77 0 01-.22.3.59.59 0 01-.4.14H9l4.55-11.47h1.89zm-3.53-4.31L14.89 9.5a11.62 11.62 0 01-.39-1.24q-.09.37-.19.69l-.19.56-1.58 4.19zm-6.3-1.58a13.43 13.43 0 01-2.91-1.41 11.46 11.46 0 002.81-5.37H12V4H7.31a4 4 0 00-.2-.56C6.87 2.79 6.6 2 6.6 2l-1.47.5s.4.89.6 1.5H0v1.33h2.15A11.23 11.23 0 005 10.7a17.19 17.19 0 01-5 2.1q.56.82.87 1.38a23.28 23.28 0 005.22-2.51 15.64 15.64 0 003.56 1.77zM3.63 5.33h4.91a8.11 8.11 0 01-2.45 4.45 9.11 9.11 0 01-2.46-4.45z"></path>
+</svg>