(core) User language switcher
Summary: New language selector on the Account page for logged-in users. New icon for switching language for an anonymous user. For anonymous users, language is stored in a cookie grist_user_locale. Language is stored in user settings for authenticated users and takes precedence over what is stored in the cookie. Test Plan: New tests Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D3766
@ -6,18 +6,7 @@ import {G} from 'grainjs/dist/cjs/lib/browserGlobals';
|
|||||||
export async function setupLocale() {
|
export async function setupLocale() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const supportedLngs = getGristConfig().supportedLngs ?? ['en'];
|
const supportedLngs = getGristConfig().supportedLngs ?? ['en'];
|
||||||
let lng = window.navigator.language || 'en';
|
const lng = detectCurrentLang();
|
||||||
// If user agent language is not in the list of supported languages, use the default one.
|
|
||||||
lng = lng.replace(/-/g, '_');
|
|
||||||
if (!supportedLngs.includes(lng)) {
|
|
||||||
// Test if server supports general language.
|
|
||||||
if (lng.includes("_") && supportedLngs.includes(lng.split("_")[0])) {
|
|
||||||
lng = lng.split("_")[0]!;
|
|
||||||
} else {
|
|
||||||
lng = 'en';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ns = getGristConfig().namespaces ?? ['client'];
|
const ns = getGristConfig().namespaces ?? ['client'];
|
||||||
// Initialize localization plugin
|
// Initialize localization plugin
|
||||||
try {
|
try {
|
||||||
@ -25,8 +14,6 @@ export async function setupLocale() {
|
|||||||
i18next.init({
|
i18next.init({
|
||||||
// By default we use english language.
|
// By default we use english language.
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
// Fallback from en-US, en-GB, etc to en.
|
|
||||||
nonExplicitSupportedLngs: true,
|
|
||||||
// We will load resources ourselves.
|
// We will load resources ourselves.
|
||||||
initImmediate: false,
|
initImmediate: false,
|
||||||
// Read language from navigator object.
|
// Read language from navigator object.
|
||||||
@ -38,8 +25,7 @@ export async function setupLocale() {
|
|||||||
// for now just import all what server offers.
|
// for now just import all what server offers.
|
||||||
// We can fallback to client namespace for any addons.
|
// We can fallback to client namespace for any addons.
|
||||||
fallbackNS: 'client',
|
fallbackNS: 'client',
|
||||||
ns,
|
ns
|
||||||
supportedLngs
|
|
||||||
}).catch((err: any) => {
|
}).catch((err: any) => {
|
||||||
// This should not happen, the promise should be resolved synchronously, without
|
// This should not happen, the promise should be resolved synchronously, without
|
||||||
// any errors reported.
|
// any errors reported.
|
||||||
@ -51,14 +37,14 @@ export async function setupLocale() {
|
|||||||
const loadPath = `${document.baseURI}locales/{{lng}}.{{ns}}.json`;
|
const loadPath = `${document.baseURI}locales/{{lng}}.{{ns}}.json`;
|
||||||
const pathsToLoad: Promise<any>[] = [];
|
const pathsToLoad: Promise<any>[] = [];
|
||||||
async function load(lang: string, n: string) {
|
async function load(lang: string, n: string) {
|
||||||
const resourceUrl = loadPath.replace('{{lng}}', lang).replace('{{ns}}', n);
|
const resourceUrl = loadPath.replace('{{lng}}', lang.replace("-", "_")).replace('{{ns}}', n);
|
||||||
const response = await fetch(resourceUrl);
|
const response = await fetch(resourceUrl);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to load ${resourceUrl}`);
|
throw new Error(`Failed to load ${resourceUrl}`);
|
||||||
}
|
}
|
||||||
i18next.addResourceBundle(lang, n, await response.json());
|
i18next.addResourceBundle(lang, n, await response.json());
|
||||||
}
|
}
|
||||||
for (const lang of languages) {
|
for (const lang of languages.filter((l) => supportedLngs.includes(l))) {
|
||||||
for (const n of ns) {
|
for (const n of ns) {
|
||||||
pathsToLoad.push(load(lang, n));
|
pathsToLoad.push(load(lang, n));
|
||||||
}
|
}
|
||||||
@ -70,6 +56,25 @@ export async function setupLocale() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function detectCurrentLang() {
|
||||||
|
const { userLocale, supportedLngs } = getGristConfig();
|
||||||
|
const detected = userLocale
|
||||||
|
|| document.cookie.match(/grist_user_locale=([^;]+)/)?.[1]
|
||||||
|
|| window.navigator.language
|
||||||
|
|| 'en';
|
||||||
|
const supportedList = supportedLngs ?? ['en'];
|
||||||
|
// If we have this language in the list (or more general version) mark it as selected.
|
||||||
|
// Compare languages in lower case, as navigator.language can return en-US, en-us (for older Safari).
|
||||||
|
const selected = supportedList.find(supported => supported.toLowerCase() === detected.toLowerCase()) ??
|
||||||
|
supportedList.find(supported => supported === detected.split(/[-_]/)[0]) ?? 'en';
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAnonymousLocale(lng: string) {
|
||||||
|
document.cookie = lng ? `grist_user_locale=${lng}; path=/; max-age=31536000`
|
||||||
|
: 'grist_user_locale=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the translation of the given key using the given options.
|
* Resolves the translation of the given key using the given options.
|
||||||
*/
|
*/
|
||||||
|
@ -63,6 +63,7 @@ export interface IEditableMember {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
picture?: string|null;
|
picture?: string|null;
|
||||||
|
locale?: string|null;
|
||||||
access: Observable<roles.Role|null>;
|
access: Observable<roles.Role|null>;
|
||||||
parentAccess: roles.BasicRole|null;
|
parentAccess: roles.BasicRole|null;
|
||||||
inheritedAccess: Computed<roles.BasicRole|null>;
|
inheritedAccess: Computed<roles.BasicRole|null>;
|
||||||
|
@ -13,10 +13,12 @@ import {transientInput} from 'app/client/ui/transientInput';
|
|||||||
import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
|
import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
|
||||||
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
||||||
import {cssLink} from 'app/client/ui2018/links';
|
import {cssLink} from 'app/client/ui2018/links';
|
||||||
|
import {select} from 'app/client/ui2018/menus';
|
||||||
import {getGristConfig} from 'app/common/urlUtils';
|
import {getGristConfig} from 'app/common/urlUtils';
|
||||||
import {FullUser} from 'app/common/UserAPI';
|
import {FullUser} from 'app/common/UserAPI';
|
||||||
|
import {detectCurrentLang, makeT} from 'app/client/lib/localization';
|
||||||
|
import {translateLocale} from 'app/client/ui/LanguageMenu';
|
||||||
import {Computed, Disposable, dom, domComputed, makeTestId, Observable, styled} from 'grainjs';
|
import {Computed, Disposable, dom, domComputed, makeTestId, Observable, styled} from 'grainjs';
|
||||||
import {makeT} from 'app/client/lib/localization';
|
|
||||||
|
|
||||||
const testId = makeTestId('test-account-page-');
|
const testId = makeTestId('test-account-page-');
|
||||||
const t = makeT('AccountPage');
|
const t = makeT('AccountPage');
|
||||||
@ -56,6 +58,22 @@ export class AccountPage extends Disposable {
|
|||||||
|
|
||||||
private _buildContentMain() {
|
private _buildContentMain() {
|
||||||
const {enableCustomCss} = getGristConfig();
|
const {enableCustomCss} = getGristConfig();
|
||||||
|
const supportedLngs = getGristConfig().supportedLngs ?? ['en'];
|
||||||
|
const languageOptions = supportedLngs
|
||||||
|
.map((lng) => ({value: lng, label: translateLocale(lng)!}))
|
||||||
|
.sort((a, b) => a.value.localeCompare(b.value));
|
||||||
|
|
||||||
|
const userLocale = Computed.create(this, use => {
|
||||||
|
const selected = detectCurrentLang();
|
||||||
|
if (!supportedLngs.includes(selected)) { return 'en'; }
|
||||||
|
return selected;
|
||||||
|
});
|
||||||
|
userLocale.onWrite(async value => {
|
||||||
|
await this._appModel.api.updateUserLocale(value || null);
|
||||||
|
// Reload the page to apply the new locale.
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
return domComputed(this._userObs, (user) => user && (
|
return domComputed(this._userObs, (user) => user && (
|
||||||
css.container(css.accountPage(
|
css.container(css.accountPage(
|
||||||
css.header(t("Account settings")),
|
css.header(t("Account settings")),
|
||||||
@ -73,7 +91,7 @@ export class AccountPage extends Disposable {
|
|||||||
save: (val) => this._isNameValid.get() && this._updateUserName(val),
|
save: (val) => this._isNameValid.get() && this._updateUserName(val),
|
||||||
close: () => { this._isEditingName.set(false); this._nameEdit.set(''); },
|
close: () => { this._isEditingName.set(false); this._nameEdit.set(''); },
|
||||||
},
|
},
|
||||||
{ size: '5' }, // Lower size so that input can shrink below ~152px.
|
{size: '5'}, // Lower size so that input can shrink below ~152px.
|
||||||
dom.on('input', (_ev, el) => this._nameEdit.set(el.value)),
|
dom.on('input', (_ev, el) => this._nameEdit.set(el.value)),
|
||||||
css.flexGrow.cls(''),
|
css.flexGrow.cls(''),
|
||||||
),
|
),
|
||||||
@ -92,7 +110,7 @@ export class AccountPage extends Disposable {
|
|||||||
testId('username'),
|
testId('username'),
|
||||||
),
|
),
|
||||||
// show warning for invalid name but not for the empty string
|
// show warning for invalid name but not for the empty string
|
||||||
dom.maybe(use => use(this._nameEdit) && !use(this._isNameValid), cssWarnings),
|
dom.maybe(use => use(this._nameEdit) && !use(this._isNameValid), this._buildNameWarningsDom.bind(this)),
|
||||||
css.header(t("Password & Security")),
|
css.header(t("Password & Security")),
|
||||||
css.dataRow(
|
css.dataRow(
|
||||||
css.inlineSubHeader(t("Login Method")),
|
css.inlineSubHeader(t("Login Method")),
|
||||||
@ -123,6 +141,15 @@ export class AccountPage extends Disposable {
|
|||||||
enableCustomCss ? null : [
|
enableCustomCss ? null : [
|
||||||
css.header(t("Theme")),
|
css.header(t("Theme")),
|
||||||
dom.create(ThemeConfig, this._appModel),
|
dom.create(ThemeConfig, this._appModel),
|
||||||
|
css.subHeader(t("Language ")),
|
||||||
|
css.dataRow({ style: 'width: 300px'},
|
||||||
|
select(userLocale, languageOptions, {
|
||||||
|
renderOptionArgs: () => {
|
||||||
|
return dom.cls(cssFirstUpper.className);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
testId('language'),
|
||||||
|
)
|
||||||
],
|
],
|
||||||
css.header(t("API")),
|
css.header(t("API")),
|
||||||
css.dataRow(css.inlineSubHeader(t("API Key")), css.content(
|
css.dataRow(css.inlineSubHeader(t("API Key")), css.content(
|
||||||
@ -131,7 +158,7 @@ export class AccountPage extends Disposable {
|
|||||||
onCreate: () => this._createApiKey(),
|
onCreate: () => this._createApiKey(),
|
||||||
onDelete: () => this._deleteApiKey(),
|
onDelete: () => this._deleteApiKey(),
|
||||||
anonymous: false,
|
anonymous: false,
|
||||||
inputArgs: [{ size: '5' }], // Lower size so that input can shrink below ~152px.
|
inputArgs: [{size: '5'}], // Lower size so that input can shrink below ~152px.
|
||||||
})
|
})
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
@ -141,7 +168,7 @@ export class AccountPage extends Disposable {
|
|||||||
|
|
||||||
private _buildHeaderMain() {
|
private _buildHeaderMain() {
|
||||||
return dom.frag(
|
return dom.frag(
|
||||||
cssBreadcrumbs({ style: 'margin-left: 16px;' },
|
cssBreadcrumbs({style: 'margin-left: 16px;'},
|
||||||
cssLink(
|
cssLink(
|
||||||
urlState().setLinkUrl({}),
|
urlState().setLinkUrl({}),
|
||||||
'Home',
|
'Home',
|
||||||
@ -194,6 +221,16 @@ export class AccountPage extends Disposable {
|
|||||||
private _showChangePasswordDialog() {
|
private _showChangePasswordDialog() {
|
||||||
return buildChangePasswordDialog();
|
return buildChangePasswordDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds dom to show marning messages to the user.
|
||||||
|
*/
|
||||||
|
private _buildNameWarningsDom() {
|
||||||
|
return cssWarnings(
|
||||||
|
t("Names only allow letters, numbers and certain special characters"),
|
||||||
|
testId('username-warning'),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -211,16 +248,14 @@ export function checkName(name: string): boolean {
|
|||||||
return VALID_NAME_REGEXP.test(name);
|
return VALID_NAME_REGEXP.test(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds dom to show marning messages to the user.
|
|
||||||
*/
|
|
||||||
function buildNameWarningsDom() {
|
|
||||||
return css.warning(
|
|
||||||
t("Names only allow letters, numbers and certain special characters"),
|
|
||||||
testId('username-warning'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cssWarnings = styled(buildNameWarningsDom, `
|
|
||||||
|
const cssWarnings = styled(css.warning, `
|
||||||
margin: -8px 0 0 110px;
|
margin: -8px 0 0 110px;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const cssFirstUpper = styled('div', `
|
||||||
|
& > div::first-letter {
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
@ -98,6 +98,7 @@ export const dataRow = styled('div', `
|
|||||||
margin: 8px 0px;
|
margin: 8px 0px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
|
gap: 2px;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
export const betaTag = styled('span', `
|
export const betaTag = styled('span', `
|
||||||
|
@ -86,7 +86,7 @@ export class AccountWidget extends Disposable {
|
|||||||
cssEmail(user.email, testId('usermenu-email'))
|
cssEmail(user.email, testId('usermenu-email'))
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
menuItemLink(urlState().setLinkUrl({account: 'account'}), t("Profile Settings")),
|
menuItemLink(urlState().setLinkUrl({account: 'account'}), t("Profile Settings"), testId('dm-account-settings')),
|
||||||
|
|
||||||
documentSettingsItem,
|
documentSettingsItem,
|
||||||
|
|
||||||
|
107
app/client/ui/LanguageMenu.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import {detectCurrentLang, makeT, setAnonymousLocale} from 'app/client/lib/localization';
|
||||||
|
import {AppModel} from 'app/client/models/AppModel';
|
||||||
|
import {hoverTooltip} from 'app/client/ui/tooltips';
|
||||||
|
import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
|
||||||
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
|
import {menu, menuItem} from 'app/client/ui2018/menus';
|
||||||
|
import {getCountryCode} from 'app/common/Locales';
|
||||||
|
import {getGristConfig} from 'app/common/urlUtils';
|
||||||
|
import {dom, makeTestId, styled} from 'grainjs';
|
||||||
|
|
||||||
|
const testId = makeTestId('test-language-');
|
||||||
|
const t = makeT('LanguageMenu');
|
||||||
|
|
||||||
|
export function buildLanguageMenu(appModel: AppModel) {
|
||||||
|
// Get the list of languages from the config, or default to English.
|
||||||
|
const languages = getGristConfig().supportedLngs ?? ['en'];
|
||||||
|
// Get the current language (from user's preference, cookie or browser)
|
||||||
|
const userLanguage = detectCurrentLang();
|
||||||
|
|
||||||
|
if (appModel.currentValidUser) {
|
||||||
|
// For logged in users, we don't need to show the menu (they have a preference in their profile).
|
||||||
|
// But for tests we will show a hidden indicator.
|
||||||
|
return dom('input', {type: 'hidden'}, (testId(`current-` + userLanguage)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// When we switch language, we need to reload the page to get the new translations.
|
||||||
|
// This button is only for anonymous users, so we don't need to save the preference or wait for anything.
|
||||||
|
const changeLanguage = (lng: string) => {
|
||||||
|
setAnonymousLocale(lng);
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
// Try to convert locale setting to the emoji flag, fallback to plain flag icon.
|
||||||
|
const emojiFlag = buildEmoji(userLanguage);
|
||||||
|
return cssHoverCircle(
|
||||||
|
// Margin is common for all hover buttons on TopBar.
|
||||||
|
{style: `margin: 5px;`},
|
||||||
|
// Flag or emoji flag if we have it.
|
||||||
|
emojiFlag ?? cssTopBarBtn('Flag'),
|
||||||
|
// Expose for test the current language use.
|
||||||
|
testId(`current-` + userLanguage),
|
||||||
|
menu(
|
||||||
|
// Convert the list of languages we support to menu items.
|
||||||
|
() => languages.map((lng) => menuItem(() => changeLanguage(lng), [
|
||||||
|
// Try to convert the locale to nice name, fallback to locale itself.
|
||||||
|
cssFirstUpper(translateLocale(lng) ?? lng),
|
||||||
|
// If this is current language, mark it with a tick (by default we mark en).
|
||||||
|
userLanguage === lng ? cssWrapper(icon('Tick'), testId('selected')) : null,
|
||||||
|
testId(`lang-` + lng),
|
||||||
|
])),
|
||||||
|
{
|
||||||
|
placement: 'bottom-end',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
hoverTooltip(t('Language'), {key: 'topBarBtnTooltip'}),
|
||||||
|
testId('button'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unfortunately, Windows doesn't support emoji flags, so we need to use SVG icons.
|
||||||
|
function buildEmoji(locale: string) {
|
||||||
|
const countryCode = getCountryCode(locale);
|
||||||
|
if (!countryCode) { return null; }
|
||||||
|
return [
|
||||||
|
cssSvgIcon({
|
||||||
|
style: `background-image: url("icons/locales/${countryCode}.svg")`
|
||||||
|
}),
|
||||||
|
dom.cls(cssSvgIconWrapper.className)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translateLocale(locale: string) {
|
||||||
|
try {
|
||||||
|
locale = locale.replace("_", "-");
|
||||||
|
// This API might not be available in all browsers.
|
||||||
|
const languageNames = new Intl.DisplayNames([locale], {type: 'language'});
|
||||||
|
return languageNames.of(locale) || null;
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cssWrapper = styled('div', `
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-block;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssSvgIconWrapper = styled('div', `
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssSvgIcon = styled('div', `
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-color: transparent;
|
||||||
|
background-size: contain;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssFirstUpper = styled('span', `
|
||||||
|
&::first-letter {
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
`);
|
@ -11,6 +11,7 @@ import {manageTeamUsersApp} from 'app/client/ui/OpenUserManager';
|
|||||||
import {buildShareMenuButton} from 'app/client/ui/ShareMenu';
|
import {buildShareMenuButton} from 'app/client/ui/ShareMenu';
|
||||||
import {hoverTooltip} from 'app/client/ui/tooltips';
|
import {hoverTooltip} from 'app/client/ui/tooltips';
|
||||||
import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
|
import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
|
||||||
|
import {buildLanguageMenu} from 'app/client/ui/LanguageMenu';
|
||||||
import {docBreadcrumbs} from 'app/client/ui2018/breadcrumbs';
|
import {docBreadcrumbs} from 'app/client/ui2018/breadcrumbs';
|
||||||
import {basicButton} from 'app/client/ui2018/buttons';
|
import {basicButton} from 'app/client/ui2018/buttons';
|
||||||
import {cssHideForNarrowScreen, testId, theme} from 'app/client/ui2018/cssVars';
|
import {cssHideForNarrowScreen, testId, theme} from 'app/client/ui2018/cssVars';
|
||||||
@ -38,6 +39,7 @@ export function createTopBarHome(appModel: AppModel) {
|
|||||||
null
|
null
|
||||||
),
|
),
|
||||||
|
|
||||||
|
buildLanguageMenu(appModel),
|
||||||
buildNotifyMenuButton(appModel.notifier, appModel),
|
buildNotifyMenuButton(appModel.notifier, appModel),
|
||||||
dom('div', dom.create(AccountWidget, appModel)),
|
dom('div', dom.create(AccountWidget, appModel)),
|
||||||
];
|
];
|
||||||
|
@ -622,7 +622,8 @@ function getFullUser(member: IEditableMember): FullUser {
|
|||||||
id: member.id,
|
id: member.id,
|
||||||
name: member.name,
|
name: member.name,
|
||||||
email: member.email,
|
email: member.email,
|
||||||
picture: member.picture
|
picture: member.picture,
|
||||||
|
locale: member.locale
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,6 +61,7 @@ export type IconName = "ChartArea" |
|
|||||||
"Filter" |
|
"Filter" |
|
||||||
"FilterSimple" |
|
"FilterSimple" |
|
||||||
"Fireworks" |
|
"Fireworks" |
|
||||||
|
"Flag" |
|
||||||
"Folder" |
|
"Folder" |
|
||||||
"FontBold" |
|
"FontBold" |
|
||||||
"FontItalic" |
|
"FontItalic" |
|
||||||
@ -195,6 +196,7 @@ export const IconList: IconName[] = ["ChartArea",
|
|||||||
"Filter",
|
"Filter",
|
||||||
"FilterSimple",
|
"FilterSimple",
|
||||||
"Fireworks",
|
"Fireworks",
|
||||||
|
"Flag",
|
||||||
"Folder",
|
"Folder",
|
||||||
"FontBold",
|
"FontBold",
|
||||||
"FontItalic",
|
"FontItalic",
|
||||||
|
@ -330,10 +330,10 @@ export function saveModal(createFunc: (ctl: IModalControl, owner: MultiHolder) =
|
|||||||
* See saveModal() for error handling notes that here apply to the onConfirm callback.
|
* See saveModal() for error handling notes that here apply to the onConfirm callback.
|
||||||
*/
|
*/
|
||||||
export function confirmModal(
|
export function confirmModal(
|
||||||
title: string,
|
title: DomElementArg,
|
||||||
btnText: string,
|
btnText: DomElementArg,
|
||||||
onConfirm: () => Promise<void>,
|
onConfirm: () => Promise<void>,
|
||||||
explanation?: Element|string,
|
explanation?: DomElementArg,
|
||||||
{hideCancel, extraButtons}: {hideCancel?: boolean, extraButtons?: DomContents} = {},
|
{hideCancel, extraButtons}: {hideCancel?: boolean, extraButtons?: DomContents} = {},
|
||||||
): void {
|
): void {
|
||||||
return saveModal((ctl, owner): ISaveModalOptions => ({
|
return saveModal((ctl, owner): ISaveModalOptions => ({
|
||||||
|
@ -68,3 +68,17 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currencies = [...currencies].sort((a, b) => nativeCompare(a.code, b.code));
|
currencies = [...currencies].sort((a, b) => nativeCompare(a.code, b.code));
|
||||||
|
|
||||||
|
|
||||||
|
export function getCountryCode(locale: string) {
|
||||||
|
// We have some defaults defined.
|
||||||
|
if (locale === 'en') { return 'US'; }
|
||||||
|
let countryCode = locale.split(/[-_]/)[1];
|
||||||
|
if (countryCode) { return countryCode.toUpperCase(); }
|
||||||
|
countryCode = locale.toUpperCase();
|
||||||
|
// Test if we can use language as a country code.
|
||||||
|
if (localeCodes.map(code => code.split(/[-_]/)[1]).includes(countryCode)) {
|
||||||
|
return countryCode;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
@ -6,6 +6,7 @@ export interface UserProfile {
|
|||||||
anonymous?: boolean; // when present, asserts whether user is anonymous (not authorized).
|
anonymous?: boolean; // when present, asserts whether user is anonymous (not authorized).
|
||||||
connectId?: string|null, // used by GristConnect to identify user in external provider.
|
connectId?: string|null, // used by GristConnect to identify user in external provider.
|
||||||
loginMethod?: 'Google'|'Email + Password'|'External';
|
loginMethod?: 'Google'|'Email + Password'|'External';
|
||||||
|
locale?: string|null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// User profile including user id and user ref. All information in it should
|
// User profile including user id and user ref. All information in it should
|
||||||
|
@ -31,6 +31,8 @@ export interface UserPrefs extends Prefs {
|
|||||||
behavioralPrompts?: BehavioralPromptPrefs;
|
behavioralPrompts?: BehavioralPromptPrefs;
|
||||||
// Welcome popups a user has dismissed.
|
// Welcome popups a user has dismissed.
|
||||||
dismissedWelcomePopups?: DismissedReminder[];
|
dismissedWelcomePopups?: DismissedReminder[];
|
||||||
|
// Localization support.
|
||||||
|
locale?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A collection of preferences related to a combination of user and org.
|
// A collection of preferences related to a combination of user and org.
|
||||||
|
@ -141,6 +141,8 @@ export interface UserOptions {
|
|||||||
// Whether user is a consultant. Consultant users can be added to sites
|
// Whether user is a consultant. Consultant users can be added to sites
|
||||||
// without being counted for billing. Defaults to false if unset.
|
// without being counted for billing. Defaults to false if unset.
|
||||||
isConsultant?: boolean;
|
isConsultant?: boolean;
|
||||||
|
// Locale selected by the user. Defaults to 'en' if unset.
|
||||||
|
locale?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PermissionDelta {
|
export interface PermissionDelta {
|
||||||
@ -331,6 +333,7 @@ export interface UserAPI {
|
|||||||
moveDoc(docId: string, workspaceId: number): Promise<void>;
|
moveDoc(docId: string, workspaceId: number): Promise<void>;
|
||||||
getUserProfile(): Promise<FullUser>;
|
getUserProfile(): Promise<FullUser>;
|
||||||
updateUserName(name: string): Promise<void>;
|
updateUserName(name: string): Promise<void>;
|
||||||
|
updateUserLocale(locale: string|null): Promise<void>;
|
||||||
updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void>;
|
updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void>;
|
||||||
updateIsConsultant(userId: number, isConsultant: boolean): Promise<void>;
|
updateIsConsultant(userId: number, isConsultant: boolean): Promise<void>;
|
||||||
getWorker(key: string): Promise<string>;
|
getWorker(key: string): Promise<string>;
|
||||||
@ -632,6 +635,13 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async updateUserLocale(locale: string|null): Promise<void> {
|
||||||
|
await this.request(`${this._url}/api/profile/user/locale`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({locale})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void> {
|
public async updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void> {
|
||||||
await this.request(`${this._url}/api/profile/allowGoogleLogin`, {
|
await this.request(`${this._url}/api/profile/allowGoogleLogin`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
@ -585,6 +585,9 @@ export interface GristLoadConfig {
|
|||||||
|
|
||||||
// Email address of the support user.
|
// Email address of the support user.
|
||||||
supportEmail?: string;
|
supportEmail?: string;
|
||||||
|
|
||||||
|
// Current user locale, read from the user options;
|
||||||
|
userLocale?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HideableUiElements = StringUnion("helpCenter", "billing", "templates", "multiSite", "multiAccounts");
|
export const HideableUiElements = StringUnion("helpCenter", "billing", "templates", "multiSite", "multiAccounts");
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import * as express from 'express';
|
import * as express from 'express';
|
||||||
import {EntityManager} from 'typeorm';
|
import {EntityManager} from 'typeorm';
|
||||||
|
import * as cookie from 'cookie';
|
||||||
|
import {Request} from 'express';
|
||||||
|
|
||||||
import {ApiError} from 'app/common/ApiError';
|
import {ApiError} from 'app/common/ApiError';
|
||||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||||
@ -13,10 +15,10 @@ import log from 'app/server/lib/log';
|
|||||||
import {addPermit, clearSessionCacheIfNeeded, getDocScope, getScope, integerParam,
|
import {addPermit, clearSessionCacheIfNeeded, getDocScope, getScope, integerParam,
|
||||||
isParameterOn, sendOkReply, sendReply, stringParam} from 'app/server/lib/requestUtils';
|
isParameterOn, sendOkReply, sendReply, stringParam} from 'app/server/lib/requestUtils';
|
||||||
import {IWidgetRepository} from 'app/server/lib/WidgetRepository';
|
import {IWidgetRepository} from 'app/server/lib/WidgetRepository';
|
||||||
import {Request} from 'express';
|
|
||||||
|
|
||||||
import {User} from './entity/User';
|
import {User} from './entity/User';
|
||||||
import {HomeDBManager, QueryResult, Scope} from './lib/HomeDBManager';
|
import {HomeDBManager, QueryResult, Scope} from './lib/HomeDBManager';
|
||||||
|
import {getCookieDomain} from 'app/server/lib/gristSessions';
|
||||||
|
|
||||||
// Special public organization that contains examples and templates.
|
// Special public organization that contains examples and templates.
|
||||||
export const TEMPLATES_ORG_DOMAIN = process.env.GRIST_ID_PREFIX ?
|
export const TEMPLATES_ORG_DOMAIN = process.env.GRIST_ID_PREFIX ?
|
||||||
@ -371,6 +373,23 @@ export class ApiServer {
|
|||||||
res.sendStatus(200);
|
res.sendStatus(200);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// POST /api/profile/user/locale
|
||||||
|
// Body params: string
|
||||||
|
// Update users profile.
|
||||||
|
this._app.post('/api/profile/user/locale', expressWrap(async (req, res) => {
|
||||||
|
const userId = getAuthorizedUserId(req);
|
||||||
|
await this._dbManager.updateUserOptions(userId, {locale: req.body.locale || null});
|
||||||
|
res.append('Set-Cookie', cookie.serialize('grist_user_locale', req.body.locale || '', {
|
||||||
|
httpOnly: false, // make available to client-side scripts
|
||||||
|
domain: getCookieDomain(req),
|
||||||
|
path: '/',
|
||||||
|
secure: true,
|
||||||
|
maxAge: req.body.locale ? 31536000 : 0,
|
||||||
|
sameSite: 'None', // there is no security concern to expose this information.
|
||||||
|
}));
|
||||||
|
res.sendStatus(200);
|
||||||
|
}));
|
||||||
|
|
||||||
// POST /api/profile/allowGoogleLogin
|
// POST /api/profile/allowGoogleLogin
|
||||||
// Update user's preference for allowing Google login.
|
// Update user's preference for allowing Google login.
|
||||||
this._app.post('/api/profile/allowGoogleLogin', expressWrap(async (req, res) => {
|
this._app.post('/api/profile/allowGoogleLogin', expressWrap(async (req, res) => {
|
||||||
|
@ -490,6 +490,7 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
name: user.name,
|
name: user.name,
|
||||||
picture: user.picture,
|
picture: user.picture,
|
||||||
ref: user.ref,
|
ref: user.ref,
|
||||||
|
locale: user.options?.locale
|
||||||
};
|
};
|
||||||
if (this.getAnonymousUserId() === user.id) {
|
if (this.getAnonymousUserId() === user.id) {
|
||||||
result.anonymous = true;
|
result.anonymous = true;
|
||||||
@ -2663,7 +2664,8 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
email: login.displayEmail,
|
email: login.displayEmail,
|
||||||
name: login.user.name,
|
name: login.user.name,
|
||||||
picture: login.user.picture,
|
picture: login.user.picture,
|
||||||
anonymous: login.user.id === this.getAnonymousUserId()
|
anonymous: login.user.id === this.getAnonymousUserId(),
|
||||||
|
locale: login.user.options?.locale
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return profiles.map(profile => completedProfiles[normalizeEmail(profile.email)])
|
return profiles.map(profile => completedProfiles[normalizeEmail(profile.email)])
|
||||||
|
@ -373,6 +373,14 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer
|
|||||||
mreq.users = [dbManager.makeFullUser(anon)];
|
mreq.users = [dbManager.makeFullUser(anon)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mreq.userId) {
|
||||||
|
if (mreq.user?.options?.locale) {
|
||||||
|
mreq.language = mreq.user.options.locale;
|
||||||
|
// This is a synchronous call (as it was configured with initImmediate: false).
|
||||||
|
mreq.i18n.changeLanguage(mreq.language).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
customHostSession,
|
customHostSession,
|
||||||
method: mreq.method,
|
method: mreq.method,
|
||||||
|
@ -122,7 +122,7 @@ export function getDocSessionUser(docSession: OptDocSession): FullUser|null {
|
|||||||
const user = getUser(docSession.req);
|
const user = getUser(docSession.req);
|
||||||
const email = user.loginEmail;
|
const email = user.loginEmail;
|
||||||
if (email) {
|
if (email) {
|
||||||
return {id: user.id, name: user.name, email, ref: user.ref};
|
return {id: user.id, name: user.name, email, ref: user.ref, locale: user.options?.locale};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (docSession.client) {
|
if (docSession.client) {
|
||||||
|
@ -59,7 +59,7 @@ import {startTestingHooks} from 'app/server/lib/TestingHooks';
|
|||||||
import {getTestLoginSystem} from 'app/server/lib/TestLogin';
|
import {getTestLoginSystem} from 'app/server/lib/TestLogin';
|
||||||
import {addUploadRoute} from 'app/server/lib/uploads';
|
import {addUploadRoute} from 'app/server/lib/uploads';
|
||||||
import {buildWidgetRepository, IWidgetRepository} from 'app/server/lib/WidgetRepository';
|
import {buildWidgetRepository, IWidgetRepository} from 'app/server/lib/WidgetRepository';
|
||||||
import {readLoadedLngs, readLoadedNamespaces, setupLocale} from 'app/server/localization';
|
import {setupLocale} from 'app/server/localization';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import * as bodyParser from 'body-parser';
|
import * as bodyParser from 'body-parser';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
@ -1278,10 +1278,7 @@ export class FlexServer implements GristServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getGristConfig(): GristLoadConfig {
|
public getGristConfig(): GristLoadConfig {
|
||||||
return makeGristConfig(this.getDefaultHomeUrl(), {
|
return makeGristConfig(this.getDefaultHomeUrl(), {}, this._defaultBaseDomain);
|
||||||
supportedLngs: readLoadedLngs(this.i18Instance),
|
|
||||||
namespaces: readLoadedNamespaces(this.i18Instance),
|
|
||||||
}, this._defaultBaseDomain);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -65,6 +65,7 @@ export function makeGristConfig(homeUrl: string|null, extra: Partial<GristLoadCo
|
|||||||
namespaces: readLoadedNamespaces(req?.i18n),
|
namespaces: readLoadedNamespaces(req?.i18n),
|
||||||
featureComments: process.env.COMMENTS === "true",
|
featureComments: process.env.COMMENTS === "true",
|
||||||
supportEmail: SUPPORT_EMAIL,
|
supportEmail: SUPPORT_EMAIL,
|
||||||
|
userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale,
|
||||||
...extra,
|
...extra,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -111,7 +112,10 @@ export function makeSendAppPage(opts: {
|
|||||||
const customHeadHtmlSnippet = server?.create.getExtraHeadHtml?.() ?? "";
|
const customHeadHtmlSnippet = server?.create.getExtraHeadHtml?.() ?? "";
|
||||||
const warning = testLogin ? "<div class=\"dev_warning\">Authentication is not enforced</div>" : "";
|
const warning = testLogin ? "<div class=\"dev_warning\">Authentication is not enforced</div>" : "";
|
||||||
// Preload all languages that will be used or are requested by client.
|
// Preload all languages that will be used or are requested by client.
|
||||||
const preloads = req.languages.map((lng) =>
|
const preloads = req.languages
|
||||||
|
.filter(lng => (readLoadedLngs(req.i18n)).includes(lng))
|
||||||
|
.map(lng => lng.replace('-', '_'))
|
||||||
|
.map((lng) =>
|
||||||
readLoadedNamespaces(req.i18n).map((ns) =>
|
readLoadedNamespaces(req.i18n).map((ns) =>
|
||||||
`<link rel="preload" href="locales/${lng}.${ns}.json" as="fetch" type="application/json" crossorigin>`
|
`<link rel="preload" href="locales/${lng}.${ns}.json" as="fetch" type="application/json" crossorigin>`
|
||||||
).join("\n")
|
).join("\n")
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import {lstatSync, readdirSync} from 'fs';
|
import {lstatSync, readdirSync, readFileSync} from 'fs';
|
||||||
import {createInstance, i18n} from 'i18next';
|
import {createInstance, i18n} from 'i18next';
|
||||||
import i18fsBackend from 'i18next-fs-backend';
|
|
||||||
import {LanguageDetector} from 'i18next-http-middleware';
|
import {LanguageDetector} from 'i18next-http-middleware';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
@ -10,42 +9,41 @@ export function setupLocale(appRoot: string): i18n {
|
|||||||
// By default locales are located in the appRoot folder, unless the environment variable
|
// By default locales are located in the appRoot folder, unless the environment variable
|
||||||
// GRIST_LOCALES_DIR is set.
|
// GRIST_LOCALES_DIR is set.
|
||||||
const localeDir = process.env.GRIST_LOCALES_DIR || path.join(appRoot, 'static', 'locales');
|
const localeDir = process.env.GRIST_LOCALES_DIR || path.join(appRoot, 'static', 'locales');
|
||||||
|
const preload: [string, string, string][] = [];
|
||||||
const supportedNamespaces: Set<string> = new Set();
|
const supportedNamespaces: Set<string> = new Set();
|
||||||
const supportedLngs: Set<string> = new Set(readdirSync(localeDir).map((fileName) => {
|
const supportedLngs: Set<string> = new Set();
|
||||||
|
|
||||||
|
for(const fileName of readdirSync(localeDir)) {
|
||||||
const fullPath = path.join(localeDir, fileName);
|
const fullPath = path.join(localeDir, fileName);
|
||||||
const isDirectory = lstatSync(fullPath).isDirectory();
|
const isDirectory = lstatSync(fullPath).isDirectory();
|
||||||
if (isDirectory) {
|
if (isDirectory) {
|
||||||
return "";
|
continue;
|
||||||
}
|
}
|
||||||
const baseName = path.basename(fileName, '.json');
|
const baseName = path.basename(fileName, '.json');
|
||||||
const lang = baseName.split('.')[0];
|
const lang = baseName.split('.')[0]?.replace(/_/g, '-');
|
||||||
const namespace = baseName.split('.')[1];
|
const namespace = baseName.split('.')[1];
|
||||||
if (!lang || !namespace) {
|
if (!lang || !namespace) {
|
||||||
throw new Error("Unrecognized resource file " + fileName);
|
throw new Error("Unrecognized resource file " + fileName);
|
||||||
}
|
}
|
||||||
supportedNamespaces.add(namespace);
|
supportedNamespaces.add(namespace);
|
||||||
return lang;
|
preload.push([lang, namespace, fullPath]);
|
||||||
}).filter((lang) => lang));
|
supportedLngs.add(lang);
|
||||||
|
}
|
||||||
|
|
||||||
if (!supportedLngs.has('en') || !supportedNamespaces.has('server')) {
|
if (!supportedLngs.has('en') || !supportedNamespaces.has('server')) {
|
||||||
throw new Error("Missing server English language file");
|
throw new Error("Missing server English language file");
|
||||||
}
|
}
|
||||||
// Initialize localization filesystem plugin that will read the locale files from the localeDir.
|
|
||||||
instance.use(i18fsBackend);
|
|
||||||
// Initialize localization language detector plugin that will read the language from the request.
|
// Initialize localization language detector plugin that will read the language from the request.
|
||||||
instance.use(LanguageDetector);
|
instance.use(LanguageDetector);
|
||||||
|
|
||||||
let errorDuringLoad: Error | undefined;
|
let errorDuringLoad: Error | undefined;
|
||||||
instance.init({
|
instance.init({
|
||||||
// Load all files synchronously.
|
|
||||||
initImmediate: false,
|
|
||||||
preload: [...supportedLngs],
|
|
||||||
supportedLngs: [...supportedLngs],
|
|
||||||
defaultNS: 'server',
|
defaultNS: 'server',
|
||||||
ns: [...supportedNamespaces],
|
ns: [...supportedNamespaces],
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
backend: {
|
detection: {
|
||||||
loadPath: `${localeDir}/{{lng}}.{{ns}}.json`
|
lookupCookie: 'grist_user_locale'
|
||||||
},
|
}
|
||||||
}, (err: any) => {
|
}, (err: any) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
errorDuringLoad = err;
|
errorDuringLoad = err;
|
||||||
@ -56,14 +54,19 @@ export function setupLocale(appRoot: string): i18n {
|
|||||||
console.error("i18next failed unexpectedly", err);
|
console.error("i18next failed unexpectedly", err);
|
||||||
});
|
});
|
||||||
if (errorDuringLoad) {
|
if (errorDuringLoad) {
|
||||||
|
console.error('i18next failed to load', errorDuringLoad);
|
||||||
throw errorDuringLoad;
|
throw errorDuringLoad;
|
||||||
}
|
}
|
||||||
|
// Load all files synchronously.
|
||||||
|
for(const [lng, ns, fullPath] of preload) {
|
||||||
|
instance.addResourceBundle(lng, ns, JSON.parse(readFileSync(fullPath, 'utf8')));
|
||||||
|
}
|
||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readLoadedLngs(instance?: i18n): readonly string[] {
|
export function readLoadedLngs(instance?: i18n): readonly string[] {
|
||||||
if (!instance) { return []; }
|
if (!instance) { return []; }
|
||||||
return instance?.options.preload || ['en'];
|
return Object.keys(instance?.services.resourceStore.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readLoadedNamespaces(instance?: i18n): readonly string[] {
|
export function readLoadedNamespaces(instance?: i18n): readonly string[] {
|
||||||
|
@ -144,7 +144,6 @@
|
|||||||
"https-proxy-agent": "5.0.1",
|
"https-proxy-agent": "5.0.1",
|
||||||
"i18n-iso-countries": "6.1.0",
|
"i18n-iso-countries": "6.1.0",
|
||||||
"i18next": "21.9.1",
|
"i18next": "21.9.1",
|
||||||
"i18next-fs-backend": "1.1.5",
|
|
||||||
"i18next-http-middleware": "3.2.1",
|
"i18next-http-middleware": "3.2.1",
|
||||||
"image-size": "0.6.3",
|
"image-size": "0.6.3",
|
||||||
"jquery": "3.5.0",
|
"jquery": "3.5.0",
|
||||||
|
@ -62,6 +62,7 @@
|
|||||||
--icon-Filter: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNSw0IEwxLjUsNCBDMS4yMjQsNCAxLDMuNzc2IDEsMy41IEMxLDMuMjI0IDEuMjI0LDMgMS41LDMgTDIuNSwzIEMyLjc3NiwzIDMsMy4yMjQgMywzLjUgQzMsMy43NzYgMi43NzYsNCAyLjUsNCBaIE00LjUsNyBMMS41LDcgQzEuMjI0LDcgMSw2Ljc3NiAxLDYuNSBDMSw2LjIyNCAxLjIyNCw2IDEuNSw2IEw0LjUsNiBDNC43NzYsNiA1LDYuMjI0IDUsNi41IEM1LDYuNzc2IDQuNzc2LDcgNC41LDcgWiBNNi41LDEwIEwxLjUsMTAgQzEuMjI0LDEwIDEsOS43NzYgMSw5LjUgQzEsOS4yMjQgMS4yMjQsOSAxLjUsOSBMNi41LDkgQzYuNzc2LDkgNyw5LjIyNCA3LDkuNSBDNyw5Ljc3NiA2Ljc3NiwxMCA2LjUsMTAgWiBNOC41LDEzIEwxLjUsMTMgQzEuMjI0LDEzIDEsMTIuNzc2IDEsMTIuNSBDMSwxMi4yMjQgMS4yMjQsMTIgMS41LDEyIEw4LjUsMTIgQzguNzc2LDEyIDksMTIuMjI0IDksMTIuNSBDOSwxMi43NzYgOC43NzYsMTMgOC41LDEzIFogTTE0Ljk3Mjg4OCwyLjI4NDkwODg1IEMxNS4wMjY1MzgyLDIuNDU3MzYxNzggMTQuOTk2ODg2NCwyLjY1NTk2NzI1IDE0Ljg5Nzg5MzIsMi43ODc4MDY4MyBMMTEuNjgxMzA2OCw3LjA3NjY2OTY1IEMxMS42MTE3MzQ3LDcuMTY5NTg4MDkgMTEuNTM4MDA5LDcuNDA2ODA3MDIgMTEuNTM4MDA5LDcuNTM4MTg1MDggTDExLjUzODAwOSwxMy41Mzc4ODU2IEMxMS41MzgwMDksMTMuNzI0NjQ1NSAxMS40NTM3ODQsMTMuODkyOTQ0OCAxMS4zMjQzMzE0LDEzLjk2NDE3MiBDMTEuMjgxNTI2NiwxMy45ODgwMTcgMTEuMjM2NTI5NywxMy45OTk0MDEgMTEuMTkxOTk0MywxMy45OTk0MDEgQzExLjEwMTg4NTEsMTMuOTk5NDAxIDExLjAxMzI3NTgsMTMuOTUyNDgwMyAxMC45NDcxNjUsMTMuODY0MTc3IEw5LjU2MjY0NDg2LDEyLjAxODExNTMgQzkuNDk3Njg3NzksMTEuOTMxNjU4MSA5LjQ2MTIyODc2LDExLjgxNDI3OTMgOS40NjEyMjg3NiwxMS42OTE4MjM5IEw5LjQ2MTIyODc2LDcuNTM4MTg1MDggQzkuNDYxMjI4NzYsNy40MDY4MDcwMiA5LjM4NzYxODQ0LDcuMTY5NTg4MDkgOS4zMTc5MzA5Miw3LjA3Njk3NzMzIEw2LjEwMTM0NDUyLDIuNzg3OTYwNjcgQzYuMDAyMzUxMzMsMi42NTU5NjcyNSA1Ljk3MjgxNDksMi40NTc1MTU2MiA2LjAyNjM0OTY4LDIuMjg0OTA4ODUgQzYuMDc5ODg0NDUsMi4xMTIzMDIwOSA2LjIwNjEwNjU0LDIgNi4zNDYwNTg0NSwyIEwxNC42NTMxNzkzLDIgQzE0Ljc5MzI0NjYsMiAxNC45MTk0Njg2LDIuMTEyMzAyMDkgMTQuOTcyODg4LDIuMjg0OTA4ODUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
|
--icon-Filter: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNSw0IEwxLjUsNCBDMS4yMjQsNCAxLDMuNzc2IDEsMy41IEMxLDMuMjI0IDEuMjI0LDMgMS41LDMgTDIuNSwzIEMyLjc3NiwzIDMsMy4yMjQgMywzLjUgQzMsMy43NzYgMi43NzYsNCAyLjUsNCBaIE00LjUsNyBMMS41LDcgQzEuMjI0LDcgMSw2Ljc3NiAxLDYuNSBDMSw2LjIyNCAxLjIyNCw2IDEuNSw2IEw0LjUsNiBDNC43NzYsNiA1LDYuMjI0IDUsNi41IEM1LDYuNzc2IDQuNzc2LDcgNC41LDcgWiBNNi41LDEwIEwxLjUsMTAgQzEuMjI0LDEwIDEsOS43NzYgMSw5LjUgQzEsOS4yMjQgMS4yMjQsOSAxLjUsOSBMNi41LDkgQzYuNzc2LDkgNyw5LjIyNCA3LDkuNSBDNyw5Ljc3NiA2Ljc3NiwxMCA2LjUsMTAgWiBNOC41LDEzIEwxLjUsMTMgQzEuMjI0LDEzIDEsMTIuNzc2IDEsMTIuNSBDMSwxMi4yMjQgMS4yMjQsMTIgMS41LDEyIEw4LjUsMTIgQzguNzc2LDEyIDksMTIuMjI0IDksMTIuNSBDOSwxMi43NzYgOC43NzYsMTMgOC41LDEzIFogTTE0Ljk3Mjg4OCwyLjI4NDkwODg1IEMxNS4wMjY1MzgyLDIuNDU3MzYxNzggMTQuOTk2ODg2NCwyLjY1NTk2NzI1IDE0Ljg5Nzg5MzIsMi43ODc4MDY4MyBMMTEuNjgxMzA2OCw3LjA3NjY2OTY1IEMxMS42MTE3MzQ3LDcuMTY5NTg4MDkgMTEuNTM4MDA5LDcuNDA2ODA3MDIgMTEuNTM4MDA5LDcuNTM4MTg1MDggTDExLjUzODAwOSwxMy41Mzc4ODU2IEMxMS41MzgwMDksMTMuNzI0NjQ1NSAxMS40NTM3ODQsMTMuODkyOTQ0OCAxMS4zMjQzMzE0LDEzLjk2NDE3MiBDMTEuMjgxNTI2NiwxMy45ODgwMTcgMTEuMjM2NTI5NywxMy45OTk0MDEgMTEuMTkxOTk0MywxMy45OTk0MDEgQzExLjEwMTg4NTEsMTMuOTk5NDAxIDExLjAxMzI3NTgsMTMuOTUyNDgwMyAxMC45NDcxNjUsMTMuODY0MTc3IEw5LjU2MjY0NDg2LDEyLjAxODExNTMgQzkuNDk3Njg3NzksMTEuOTMxNjU4MSA5LjQ2MTIyODc2LDExLjgxNDI3OTMgOS40NjEyMjg3NiwxMS42OTE4MjM5IEw5LjQ2MTIyODc2LDcuNTM4MTg1MDggQzkuNDYxMjI4NzYsNy40MDY4MDcwMiA5LjM4NzYxODQ0LDcuMTY5NTg4MDkgOS4zMTc5MzA5Miw3LjA3Njk3NzMzIEw2LjEwMTM0NDUyLDIuNzg3OTYwNjcgQzYuMDAyMzUxMzMsMi42NTU5NjcyNSA1Ljk3MjgxNDksMi40NTc1MTU2MiA2LjAyNjM0OTY4LDIuMjg0OTA4ODUgQzYuMDc5ODg0NDUsMi4xMTIzMDIwOSA2LjIwNjEwNjU0LDIgNi4zNDYwNTg0NSwyIEwxNC42NTMxNzkzLDIgQzE0Ljc5MzI0NjYsMiAxNC45MTk0Njg2LDIuMTEyMzAyMDkgMTQuOTcyODg4LDIuMjg0OTA4ODUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
|
||||||
--icon-FilterSimple: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTkuOTUwODMzMzMsMC4yMiBDOS44NzgyODY2NywwLjA4NDQ5NzcxMDggOS43MzcwMzQsLTYuMTY5Njc5NTFlLTA1IDkuNTgzMzMzMzMsLTQuNjI1OTMyOGUtMTcgTDAuNDE2NjY2NjY3LC00LjYyNTkzMjhlLTE3IEMwLjI2MzEwMzQ1NCw3LjkzMjczNDM1ZS0wNSAwLjEyMjAzMTQsMC4wODQ2MTg0ODc1IDAuMDQ5NTQ5NDAxMiwwLjIxOTk5OTUyOSBDLTAuMDIyOTMyNTk3OCwwLjM1NTM4MDU3MSAtMC4wMTUwNzQwNTE3LDAuNTE5NjU2MDYyIDAuMDcsMC42NDc1IEwzLjMzMzMzMzMzLDUuNTQyNSBMMy4zMzMzMzMzMyw5LjU4MzMzMzMzIEMzLjMzMzMzMzMzLDkuODEzNDUyIDMuNTE5ODgxMzYsMTAgMy43NSwxMCBMNi4yNSwxMCBDNi40ODAxMTg2NCwxMCA2LjY2NjY2NjY3LDkuODEzNDUyIDYuNjY2NjY2NjcsOS41ODMzMzMzMyBMNi42NjY2NjY2Nyw1LjU0MjUgTDkuOTMsMC42NDc1IEMxMC4wMTUxOTU2LDAuNTE5NzI3OTkxIDEwLjAyMzIwMTMsMC4zNTU0NTA1NDcgOS45NTA4MzMzMywwLjIyIFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDMgMykiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
--icon-FilterSimple: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTkuOTUwODMzMzMsMC4yMiBDOS44NzgyODY2NywwLjA4NDQ5NzcxMDggOS43MzcwMzQsLTYuMTY5Njc5NTFlLTA1IDkuNTgzMzMzMzMsLTQuNjI1OTMyOGUtMTcgTDAuNDE2NjY2NjY3LC00LjYyNTkzMjhlLTE3IEMwLjI2MzEwMzQ1NCw3LjkzMjczNDM1ZS0wNSAwLjEyMjAzMTQsMC4wODQ2MTg0ODc1IDAuMDQ5NTQ5NDAxMiwwLjIxOTk5OTUyOSBDLTAuMDIyOTMyNTk3OCwwLjM1NTM4MDU3MSAtMC4wMTUwNzQwNTE3LDAuNTE5NjU2MDYyIDAuMDcsMC42NDc1IEwzLjMzMzMzMzMzLDUuNTQyNSBMMy4zMzMzMzMzMyw5LjU4MzMzMzMzIEMzLjMzMzMzMzMzLDkuODEzNDUyIDMuNTE5ODgxMzYsMTAgMy43NSwxMCBMNi4yNSwxMCBDNi40ODAxMTg2NCwxMCA2LjY2NjY2NjY3LDkuODEzNDUyIDYuNjY2NjY2NjcsOS41ODMzMzMzMyBMNi42NjY2NjY2Nyw1LjU0MjUgTDkuOTMsMC42NDc1IEMxMC4wMTUxOTU2LDAuNTE5NzI3OTkxIDEwLjAyMzIwMTMsMC4zNTU0NTA1NDcgOS45NTA4MzMzMywwLjIyIFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDMgMykiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
||||||
--icon-Fireworks: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTguNjY2NDcgMTUuMzMzM0g3LjMzMzE0QzcuMzMzMTQgOS44NDc5NyA0LjQ0NzggOC42MzA2MyA0LjQxODQ3IDguNjE5M0wzLjc5OTggOC4zNzEzIDQuMjk1MTQgNy4xMzMzIDQuOTE0NDcgNy4zODA2M0M1LjAyNTE0IDcuNDI1OTcgNi45NTE4IDguMjM1MyA3Ljk5OTggMTEuMTE0IDkuMDQ3OCA4LjIzNTk3IDEwLjk3NDUgNy40MjY2MyAxMS4wODUxIDcuMzgwNjNMMTEuNzA0NSA3LjEzMzMgMTIuMTk5OCA4LjM3MTMgMTEuNTg0NSA4LjYxNzNDMTEuNDM5OCA4LjY3OTk3IDguNjY2NDcgOS45NDE5NyA4LjY2NjQ3IDE1LjMzMzN6TTEwLjQ1MzEgMS43OTA1NEw4Ljc1NzcyIDEuNTQ0NTQgNy45OTk3Mi4wMDg1NDQ5MiA3LjI0MTcyIDEuNTQ0NTQgNS41NDYzOSAxLjc5MDU0IDYuNzczMDUgMi45ODY1NCA2LjQ4MzcyIDQuNjc1MjEgNy45OTk3MiAzLjg3Nzg4IDkuNTE1NzIgNC42NzUyMSA5LjIyNjM5IDIuOTg2NTQgMTAuNDUzMSAxLjc5MDU0ek01LjI5OTE3IDEyLjM1OEwzLjY5ODUgMTIuMTI1MyAyLjk4MzE3IDEwLjY3NTMgMi4yNjcxNyAxMi4xMjUzLjY2NjUwNCAxMi4zNTggMS44MjQ1IDEzLjQ4NjYgMS41NTExNyAxNS4wODA2IDIuOTgzMTcgMTQuMzI4NiA0LjQxNDUgMTUuMDgwNiA0LjE0MTE3IDEzLjQ4NjYgNS4yOTkxNyAxMi4zNTh6TTE1LjMzMzMgMTIuMzU4TDEzLjczMjcgMTIuMTI1MyAxMy4wMTY3IDEwLjY3NTMgMTIuMzAxMyAxMi4xMjUzIDEwLjcwMDcgMTIuMzU4IDExLjg1ODcgMTMuNDg2NiAxMS41ODUzIDE1LjA4MDYgMTMuMDE2NyAxNC4zMjg2IDE0LjQ0ODcgMTUuMDgwNiAxNC4xNzUzIDEzLjQ4NjYgMTUuMzMzMyAxMi4zNTh6IiBmaWxsPSIjZmZmIi8+PHBhdGggZD0iTTguNjY2MzQgNy41MzMxM1Y1LjM0MThINy4zMzMwMVY3LjUyMDQ2QzcuNTc2MjEgNy43NjgxMSA3LjgwMjUgOC4wMzE4MSA4LjAxMDM0IDguMzA5OCA4LjIxMjIgOC4wMzcxNyA4LjQzMTMyIDcuNzc3NzUgOC42NjYzNCA3LjUzMzEzek0xLjY2NjUgNy4zMzMyNUMyLjIxODc5IDcuMzMzMjUgMi42NjY1IDYuODg1NTQgMi42NjY1IDYuMzMzMjUgMi42NjY1IDUuNzgwOTcgMi4yMTg3OSA1LjMzMzI1IDEuNjY2NSA1LjMzMzI1IDEuMTE0MjIgNS4zMzMyNS42NjY1MDQgNS43ODA5Ny42NjY1MDQgNi4zMzMyNS42NjY1MDQgNi44ODU1NCAxLjExNDIyIDcuMzMzMjUgMS42NjY1IDcuMzMzMjV6TTE0LjMzMyA3LjMzMzI1QzE0Ljg4NTMgNy4zMzMyNSAxNS4zMzMgNi44ODU1NCAxNS4zMzMgNi4zMzMyNSAxNS4zMzMgNS43ODA5NyAxNC44ODUzIDUuMzMzMjUgMTQuMzMzIDUuMzMzMjUgMTMuNzgwNyA1LjMzMzI1IDEzLjMzMyA1Ljc4MDk3IDEzLjMzMyA2LjMzMzI1IDEzLjMzMyA2Ljg4NTU0IDEzLjc4MDcgNy4zMzMyNSAxNC4zMzMgNy4zMzMyNXoiIGZpbGw9IiNmZmYiLz48L3N2Zz4=');
|
--icon-Fireworks: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTguNjY2NDcgMTUuMzMzM0g3LjMzMzE0QzcuMzMzMTQgOS44NDc5NyA0LjQ0NzggOC42MzA2MyA0LjQxODQ3IDguNjE5M0wzLjc5OTggOC4zNzEzIDQuMjk1MTQgNy4xMzMzIDQuOTE0NDcgNy4zODA2M0M1LjAyNTE0IDcuNDI1OTcgNi45NTE4IDguMjM1MyA3Ljk5OTggMTEuMTE0IDkuMDQ3OCA4LjIzNTk3IDEwLjk3NDUgNy40MjY2MyAxMS4wODUxIDcuMzgwNjNMMTEuNzA0NSA3LjEzMzMgMTIuMTk5OCA4LjM3MTMgMTEuNTg0NSA4LjYxNzNDMTEuNDM5OCA4LjY3OTk3IDguNjY2NDcgOS45NDE5NyA4LjY2NjQ3IDE1LjMzMzN6TTEwLjQ1MzEgMS43OTA1NEw4Ljc1NzcyIDEuNTQ0NTQgNy45OTk3Mi4wMDg1NDQ5MiA3LjI0MTcyIDEuNTQ0NTQgNS41NDYzOSAxLjc5MDU0IDYuNzczMDUgMi45ODY1NCA2LjQ4MzcyIDQuNjc1MjEgNy45OTk3MiAzLjg3Nzg4IDkuNTE1NzIgNC42NzUyMSA5LjIyNjM5IDIuOTg2NTQgMTAuNDUzMSAxLjc5MDU0ek01LjI5OTE3IDEyLjM1OEwzLjY5ODUgMTIuMTI1MyAyLjk4MzE3IDEwLjY3NTMgMi4yNjcxNyAxMi4xMjUzLjY2NjUwNCAxMi4zNTggMS44MjQ1IDEzLjQ4NjYgMS41NTExNyAxNS4wODA2IDIuOTgzMTcgMTQuMzI4NiA0LjQxNDUgMTUuMDgwNiA0LjE0MTE3IDEzLjQ4NjYgNS4yOTkxNyAxMi4zNTh6TTE1LjMzMzMgMTIuMzU4TDEzLjczMjcgMTIuMTI1MyAxMy4wMTY3IDEwLjY3NTMgMTIuMzAxMyAxMi4xMjUzIDEwLjcwMDcgMTIuMzU4IDExLjg1ODcgMTMuNDg2NiAxMS41ODUzIDE1LjA4MDYgMTMuMDE2NyAxNC4zMjg2IDE0LjQ0ODcgMTUuMDgwNiAxNC4xNzUzIDEzLjQ4NjYgMTUuMzMzMyAxMi4zNTh6IiBmaWxsPSIjZmZmIi8+PHBhdGggZD0iTTguNjY2MzQgNy41MzMxM1Y1LjM0MThINy4zMzMwMVY3LjUyMDQ2QzcuNTc2MjEgNy43NjgxMSA3LjgwMjUgOC4wMzE4MSA4LjAxMDM0IDguMzA5OCA4LjIxMjIgOC4wMzcxNyA4LjQzMTMyIDcuNzc3NzUgOC42NjYzNCA3LjUzMzEzek0xLjY2NjUgNy4zMzMyNUMyLjIxODc5IDcuMzMzMjUgMi42NjY1IDYuODg1NTQgMi42NjY1IDYuMzMzMjUgMi42NjY1IDUuNzgwOTcgMi4yMTg3OSA1LjMzMzI1IDEuNjY2NSA1LjMzMzI1IDEuMTE0MjIgNS4zMzMyNS42NjY1MDQgNS43ODA5Ny42NjY1MDQgNi4zMzMyNS42NjY1MDQgNi44ODU1NCAxLjExNDIyIDcuMzMzMjUgMS42NjY1IDcuMzMzMjV6TTE0LjMzMyA3LjMzMzI1QzE0Ljg4NTMgNy4zMzMyNSAxNS4zMzMgNi44ODU1NCAxNS4zMzMgNi4zMzMyNSAxNS4zMzMgNS43ODA5NyAxNC44ODUzIDUuMzMzMjUgMTQuMzMzIDUuMzMzMjUgMTMuNzgwNyA1LjMzMzI1IDEzLjMzMyA1Ljc4MDk3IDEzLjMzMyA2LjMzMzI1IDEzLjMzMyA2Ljg4NTU0IDEzLjc4MDcgNy4zMzMyNSAxNC4zMzMgNy4zMzMyNXoiIGZpbGw9IiNmZmYiLz48L3N2Zz4=');
|
||||||
|
--icon-Flag: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0iZmVhdGhlciBmZWF0aGVyLWZsYWciPjxwYXRoIGQ9Ik00IDE1czEtMSA0LTEgNSAyIDggMiA0LTEgNC0xVjNzLTEgMS00IDEtNS0yLTgtMi00IDEtNCAxek00IDIyTDQgMTUiLz48L3N2Zz4=');
|
||||||
--icon-Folder: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMsNSBMMTMsNSBDMTMuNTUyMjg0Nyw1IDE0LDUuNDQ3NzE1MjUgMTQsNiBMMTQsMTMgQzE0LDEzLjU1MjI4NDcgMTMuNTUyMjg0NywxNCAxMywxNCBMMywxNCBDMi40NDc3MTUyNSwxNCAyLDEzLjU1MjI4NDcgMiwxMyBMMiw2IEMyLDUuNDQ3NzE1MjUgMi40NDc3MTUyNSw1IDMsNSBaIE00LDIgTDEyLDIgTDEyLDQgTDQsNCBMNCwyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
--icon-Folder: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMsNSBMMTMsNSBDMTMuNTUyMjg0Nyw1IDE0LDUuNDQ3NzE1MjUgMTQsNiBMMTQsMTMgQzE0LDEzLjU1MjI4NDcgMTMuNTUyMjg0NywxNCAxMywxNCBMMywxNCBDMi40NDc3MTUyNSwxNCAyLDEzLjU1MjI4NDcgMiwxMyBMMiw2IEMyLDUuNDQ3NzE1MjUgMi40NDc3MTUyNSw1IDMsNSBaIE00LDIgTDEyLDIgTDEyLDQgTDQsNCBMNCwyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
||||||
--icon-FontBold: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTTMuMTk1MDk4OCAxLjgzNTQxOTloNS42OTAzODE4Yy43NTQ1OTUyIDAgMS40NzgyNzI0LjI5OTc1NjcgMi4wMTE4NTM0LjgzMzMzNzUuNTMzNTgxLjUzMzU4MDguODMzMzM3IDEuMjU3MjU4Mi44MzMzMzcgMi4wMTE4NTM1IDAgLjc1NDU5NTItLjI5OTc1NiAxLjQ3ODI3MjctLjgzMzMzNyAyLjAxMTg1MzVDMTAuMzYzNzUzIDcuMjI2MDQ1MSA5LjY0MDA3NTggNy41MjU4MDE4IDguODg1NDgwNiA3LjUyNTgwMThINS4wOTE4OTI3TTUuMDkxODkyNyA3LjUyNTgwMThoNS4yMTYxODMzYy44ODAzNTIgMCAxLjcyNDY1My4zNDk3MTgyIDIuMzQ3MTY5Ljk3MjIyMDcuNjIyNTAyLjYyMjUxNTIuOTcyMjIgMS40NjY4MTYxLjk3MjIyIDIuMzQ3MTY4NXYwYzAgLjg4MDM1My0uMzQ5NzE4IDEuNzI0NjU0LS45NzIyMiAyLjM0NzIxOS0uNjIyNTE2LjYyMjQwMi0xLjQ2NjgxNy45NzIxNy0yLjM0NzE2OS45NzIxN0gzLjE5NTA5ODhNNS4wOTE4OTI3IDEuODM1NDE5OVYxNC4xNjQ1OCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDMwMDAwIiBzdHJva2Utd2lkdGg9IjEuMjY1IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=');
|
--icon-FontBold: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTTMuMTk1MDk4OCAxLjgzNTQxOTloNS42OTAzODE4Yy43NTQ1OTUyIDAgMS40NzgyNzI0LjI5OTc1NjcgMi4wMTE4NTM0LjgzMzMzNzUuNTMzNTgxLjUzMzU4MDguODMzMzM3IDEuMjU3MjU4Mi44MzMzMzcgMi4wMTE4NTM1IDAgLjc1NDU5NTItLjI5OTc1NiAxLjQ3ODI3MjctLjgzMzMzNyAyLjAxMTg1MzVDMTAuMzYzNzUzIDcuMjI2MDQ1MSA5LjY0MDA3NTggNy41MjU4MDE4IDguODg1NDgwNiA3LjUyNTgwMThINS4wOTE4OTI3TTUuMDkxODkyNyA3LjUyNTgwMThoNS4yMTYxODMzYy44ODAzNTIgMCAxLjcyNDY1My4zNDk3MTgyIDIuMzQ3MTY5Ljk3MjIyMDcuNjIyNTAyLjYyMjUxNTIuOTcyMjIgMS40NjY4MTYxLjk3MjIyIDIuMzQ3MTY4NXYwYzAgLjg4MDM1My0uMzQ5NzE4IDEuNzI0NjU0LS45NzIyMiAyLjM0NzIxOS0uNjIyNTE2LjYyMjQwMi0xLjQ2NjgxNy45NzIxNy0yLjM0NzE2OS45NzIxN0gzLjE5NTA5ODhNNS4wOTE4OTI3IDEuODM1NDE5OVYxNC4xNjQ1OCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDMwMDAwIiBzdHJva2Utd2lkdGg9IjEuMjY1IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=');
|
||||||
--icon-FontItalic: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDEyVjEySDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PGcgdHJhbnNmb3JtPSJtYXRyaXgoMS4xMzU2IDAgMCAxLjEzNTYgLjk4MyAxLjM5KSIgY2xpcC1wYXRoPSJ1cmwoI2EpIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGZpbGw9Im5vbmUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0ibSA0Ljg3NSwwLjM3NSBoIDQuNSIgc3Ryb2tlPSIjMDAwIi8+PHBhdGggZD0ibSAyLjYyNSwxMS42MjUgaCA0LjUiIHN0cm9rZT0iIzA2MDAwMCIvPjxwYXRoIGQ9Im0gNy4xMjUsMC4zNzUgLTIuMjUsMTEuMjUiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLW9wYWNpdHk9Ii45OSIvPjwvZz48L3N2Zz4=');
|
--icon-FontItalic: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDEyVjEySDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PGcgdHJhbnNmb3JtPSJtYXRyaXgoMS4xMzU2IDAgMCAxLjEzNTYgLjk4MyAxLjM5KSIgY2xpcC1wYXRoPSJ1cmwoI2EpIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGZpbGw9Im5vbmUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0ibSA0Ljg3NSwwLjM3NSBoIDQuNSIgc3Ryb2tlPSIjMDAwIi8+PHBhdGggZD0ibSAyLjYyNSwxMS42MjUgaCA0LjUiIHN0cm9rZT0iIzA2MDAwMCIvPjxwYXRoIGQ9Im0gNy4xMjUsMC4zNzUgLTIuMjUsMTEuMjUiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLW9wYWNpdHk9Ii45OSIvPjwvZz48L3N2Zz4=');
|
||||||
|
39
static/icons/locales/BR.svg
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<svg width="16" height="12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<mask id="a" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
|
||||||
|
<path fill="#fff" d="M0 0h16v12H0z"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#a)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0v12h16V0H0z" fill="#093"/>
|
||||||
|
<mask id="b" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0v12h16V0H0z" fill="#fff"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#b)">
|
||||||
|
<g filter="url(#BR_-_Brazil__filter0_d)" fill-rule="evenodd" clip-rule="evenodd">
|
||||||
|
<path d="M7.963 1.852l6.101 4.252-6.184 3.982L1.904 6.02l6.06-4.169z" fill="#FFD221"/>
|
||||||
|
<path d="M7.963 1.852l6.101 4.252-6.184 3.982L1.904 6.02l6.06-4.169z" fill="url(#BR_-_Brazil__paint0_linear)"/>
|
||||||
|
</g>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 8.6a2.5 2.5 0 100-5 2.5 2.5 0 000 5z" fill="#2E42A5"/>
|
||||||
|
<mask id="c" maskUnits="userSpaceOnUse" x="5" y="3" width="6" height="6">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 8.6a2.5 2.5 0 100-5 2.5 2.5 0 000 5z" fill="#fff"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#c)" fill="#F7FCFF">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.19 7.285l-.112.059.022-.125-.09-.088.124-.018L7.19 7l.056.113.125.018-.09.088.02.125-.111-.059zm1 0l-.112.059.022-.125-.09-.088.124-.018L8.19 7l.056.113.125.018-.09.088.02.125-.111-.059zm0 .6l-.112.059.022-.125-.09-.088.124-.018.056-.113.056.113.125.018-.09.088.02.125-.111-.059zm-.5-2.1l-.112.059.022-.125-.09-.088.124-.018.056-.113.056.113.125.018-.09.088.02.125-.111-.059zm0 1l-.112.059.022-.125-.09-.088.124-.018.056-.113.056.113.125.018-.09.088.02.125-.111-.059zm-.7-.5l-.112.059.022-.125-.09-.088.124-.018L6.99 6l.056.113.125.018-.09.088.02.125-.11-.059zm-.7.4l-.112.059.022-.125-.09-.088.124-.018.056-.113.056.113.125.018-.09.088.02.125-.111-.059zm2.3-1.7l-.112.059.022-.125-.09-.088.124-.018.056-.113.056.113.125.018-.09.088.02.125-.111-.059z"/>
|
||||||
|
<path d="M4.962 5.499l.076-.998c2.399.181 4.292.97 5.656 2.373l-.717.697C8.795 6.355 7.131 5.662 4.962 5.5z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="BR_-_Brazil__paint0_linear" x1="16" y1="12" x2="16" y2="0" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#FFC600"/>
|
||||||
|
<stop offset="1" stop-color="#FFDE42"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="BR_-_Brazil__filter0_d" x="1.904" y="1.852" width="12.16" height="8.234" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
||||||
|
<feOffset/>
|
||||||
|
<feColorMatrix values="0 0 0 0 0.0313726 0 0 0 0 0.368627 0 0 0 0 0 0 0 0 0.28 0"/>
|
||||||
|
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
||||||
|
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.9 KiB |
10
static/icons/locales/DE.svg
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<svg width="16" height="12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<mask id="a" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
|
||||||
|
<rect width="16" height="12" rx="-1" fill="#fff"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#a)" fill-rule="evenodd" clip-rule="evenodd">
|
||||||
|
<path d="M0 8h16v4H0V8z" fill="#FFD018"/>
|
||||||
|
<path d="M0 4h16v4H0V4z" fill="#E31D1C"/>
|
||||||
|
<path d="M0 0h16v4H0V0z" fill="#272727"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 432 B |
46
static/icons/locales/ES.svg
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<svg width="16" height="12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<mask id="a" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
|
||||||
|
<path fill="#fff" d="M0 0h16v12H0z"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#a)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0v12h16V0H0z" fill="#FFB400"/>
|
||||||
|
<mask id="b" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0v12h16V0H0z" fill="#fff"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#b)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0v3h16V0H0zm0 9v3h16V9H0z" fill="#C51918"/>
|
||||||
|
<path fill="#F1F9FF" d="M2.504 5.136h.56v2.912h-.56z"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.288 4.632H2.28v.28h.168v.224h.672v-.224h.168v-.28zM3.12 8.216h.168v.28H2.28v-.28h.168v-.224h.672v.224z" fill="#C88A02"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.784 4.688c.122 0 .134-.046.206-.114.056-.054.186-.12.186-.194 0-.17-.175-.308-.392-.308-.216 0-.392.138-.392.308 0 .083.09.138.157.194.072.058.124.114.235.114z" fill="#AD1619"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.28 8.496h1.008v.448s-.126-.056-.252-.056-.252.056-.252.056-.126-.056-.252-.056-.252.056-.252.056v-.448z" fill="#005BBF"/>
|
||||||
|
<path fill="#F1F9FF" d="M7.992 5.136h.56v2.912h-.56z"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.776 4.632H7.768v.28h.168v.224h.672v-.224h.168v-.28zm-.168 3.584h.168v.28H7.768v-.28h.168v-.224h.672v.224z" fill="#C88A02"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.272 4.688c.122 0 .134-.046.206-.114.056-.054.186-.12.186-.194 0-.17-.175-.308-.392-.308-.216 0-.392.138-.392.308 0 .083.09.138.157.194.072.058.124.114.235.114z" fill="#AD1619"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.768 8.496h1.008v.448s-.126-.056-.252-.056-.252.056-.252.056-.126-.056-.252-.056-.252.056-.252.056v-.448z" fill="#005BBF"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.494 7.84c.101-.122.157-.234.157-.352a.316.316 0 00-.06-.192l.006-.003s.11-.048.15-.067c.072-.034.135-.07.197-.116.04-.028.092-.06.173-.103l.096-.051.096-.053a.62.62 0 00.183-.144.268.268 0 00-.061-.399.728.728 0 00-.301-.096l-.197-.03a4.931 4.931 0 01-.177-.03c.345-.057.836-.036 1.052.076l.206-.398c-.44-.228-1.445-.204-1.82.054-.275.19-.238.476.048.6.12.05.276.085.564.131a1.431 1.431 0 00-.126.081.799.799 0 01-.127.075 6.71 6.71 0 01-.125.055l-.017.008c-.233.106-.346.252-.312.517l.018.143.033.01.344.284zm-.288-.37v.002-.002zm6.147.018c0 .118.056.23.157.352l.344-.284.033-.01.018-.143c.034-.265-.079-.411-.312-.517l-.016-.008a6.704 6.704 0 01-.125-.055.8.8 0 01-.128-.075 1.431 1.431 0 00-.126-.08c.289-.047.445-.081.564-.133.286-.123.323-.41.048-.6-.375-.257-1.379-.28-1.82-.053l.206.398c.216-.112.708-.133 1.052-.075l-.177.029-.196.03a.728.728 0 00-.301.096.268.268 0 00-.062.4.605.605 0 00.183.143l.096.053.096.05c.081.044.134.076.173.104.062.045.126.082.198.116.039.02.15.068.15.067l.006.003a.316.316 0 00-.061.192z" fill="#AD1619"/>
|
||||||
|
<path d="M2.962 6.2l.165.034v.247c-.176.14-.623.377-.623.377V6.2h.458zm5.195 0l-.165.034v.247c.176.14.623.377.623.377V6.2h-.458z" fill="#F1F9FF"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.388 3.603v-.082a1.53 1.53 0 00-.905-.31 1.806 1.806 0 00-.918-.156v-.001c-.465-.046-.934.157-.934.157-.473 0-.905.31-.905.31v.082l.565.567s.159.546 1.272.418v.001s.737-.02.79-.037l.063-.02c.144-.042.31-.09.407-.362l.565-.567zm-1.825-.519z" fill="#AD1619"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.76 3.7l.038-.042.513.483c.097-.026.626-.16 1.216-.168h.045a5.281 5.281 0 011.232.172s-.006.053-.021.116l.056-.11.006-.006.517-.487.038.041-.514.483c-.075.138-.112.23-.112.267 0 .058-.15.092-.444.128-.23.027-.5.046-.722.048h-.056a7.222 7.222 0 01-.722-.048c-.294-.036-.444-.07-.444-.128a.118.118 0 00-.004-.027.44.44 0 01-.064-.154 2.84 2.84 0 00-.044-.086L3.76 3.7zm2.867.75c.039.019.07 0 .095-.035a.18.18 0 00-.004.027.541.541 0 01-.098.032 3.068 3.068 0 01-.296.047c-.238.029-.52.047-.744.049a7.095 7.095 0 01-.744-.049 3.067 3.067 0 01-.296-.047.633.633 0 01-.073-.02l.006-.003c.122-.058.93-.111 1.077-.12.145.009.954.062 1.077.12zm.154-.01l-.001.001z" fill="#C88A02"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.556 2.674a.168.168 0 100-.336.168.168 0 000 .336z" fill="#005BBF"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.493 2.038h.117v.074h.076v.117H5.61v.233h.076v.117h-.27v-.117h.077v-.233h-.077v-.117h.077v-.074z" fill="#C88A02"/>
|
||||||
|
<path fill="#C88A02" d="M5.472 2.672h.224V3.4h-.224z"/>
|
||||||
|
<path d="M3.854 3.648l-.308-.011c.145-.839.86-1.25 2.002-1.25 1.144 0 1.856.413 1.99 1.255l-.415.043c-.066-.41-.752-.78-1.569-.78-.818 0-1.629.33-1.7.743z" fill="#C88A02"/>
|
||||||
|
<path opacity=".3" fill-rule="evenodd" clip-rule="evenodd" d="M3.736 4.632h3.64v3.27S7.106 9 5.556 9s-1.82-1.127-1.82-1.127V4.632z" fill="#E1E5E8"/>
|
||||||
|
<mask id="c" maskUnits="userSpaceOnUse" x="3" y="4" width="5" height="5">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.736 4.632h3.64v3.27S7.106 9 5.556 9s-1.82-1.127-1.82-1.127V4.632z" fill="#fff"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#c)">
|
||||||
|
<path fill="#FFC034" d="M3.736 6.648h1.848v2.184H3.736z"/>
|
||||||
|
<path fill="#AD1619" d="M3.736 4.576h1.848v2.128H3.736z"/>
|
||||||
|
<path fill="#AD1619" d="M5.528 6.592h1.848V8.72H5.528z"/>
|
||||||
|
<path fill="#F1F9FF" d="M5.528 4.632h1.96v2.072h-1.96z"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.741 8.721s-1.186.094-1.186-.671c0 0-.011.671-1.25.671v.604h2.436v-.604z" fill="#F1F9FF"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.556 7.096c.232 0 .42-.2.42-.448 0-.247-.188-.448-.42-.448-.232 0-.42.2-.42.448 0 .247.188.448.42.448z" fill="#005BBF" stroke="#AD1619" stroke-width=".583"/>
|
||||||
|
</g>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.5 6a.5.5 0 100-1 .5.5 0 000 1z" fill="#C88A02"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.5 6a.5.5 0 100-1 .5.5 0 000 1z" fill="#C37C9C"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.5 8a.5.5 0 100-1 .5.5 0 000 1z" fill="#FFC034"/>
|
||||||
|
<path d="M4.5 8a.5.5 0 100-1 .5.5 0 000 1z" fill="#AD1619"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 6.1 KiB |
10
static/icons/locales/FR.svg
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<svg width="16" height="12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<mask id="a" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
|
||||||
|
<path fill="#fff" d="M0 0h16v12H0z"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#a)" fill-rule="evenodd" clip-rule="evenodd">
|
||||||
|
<path d="M11 0h5v12h-5V0z" fill="#F50100"/>
|
||||||
|
<path d="M0 0h6v12H0V0z" fill="#2E42A5"/>
|
||||||
|
<path d="M5 0h6v12H5V0z" fill="#F7FCFF"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 421 B |
21
static/icons/locales/LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2021 Yummygum (https://github.com/Yummygum/flagpack-core)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
14
static/icons/locales/NO.svg
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<svg width="16" height="12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<mask id="a" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
|
||||||
|
<path fill="#fff" d="M0 0h16v12H0z"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#a)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0v12h16V0H0z" fill="#E31D1C"/>
|
||||||
|
<mask id="b" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0v12h16V0H0z" fill="#fff"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#b)">
|
||||||
|
<path d="M5-.5h-.5v5h-5v3h5v5h3v-5h9v-3h-9v-5H5z" fill="#2E42A5" stroke="#F7FCFF"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 628 B |
11
static/icons/locales/US.svg
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<svg width="16" height="12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<mask id="a" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
|
||||||
|
<path fill="#fff" d="M0 0h16v12H0z"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#a)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0h16v12H0V0z" fill="#E31D1C"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 1v1h16V1H0zm0 2v1h16V3H0zm0 3V5h16v1H0zm0 1v1h16V7H0zm0 3V9h16v1H0zm0 2v-1h16v1H0z" fill="#F7FCFF"/>
|
||||||
|
<path fill="#2E42A5" d="M0 0h9v7H0z"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.04 2.174l.53-.37.411.297h-.233l.471.416-.159.584h-.249l-.242-.536-.206.536H.748l.471.416-.179.657.53-.37.411.297h-.233l.471.416-.159.584h-.249l-.242-.536-.206.536H.748l.471.416-.179.657.53-.37.513.37-.16-.657.412-.416h-.19l.425-.296.411.296h-.233l.471.416-.179.657.53-.37.513.37-.16-.657.412-.416h-.19l.425-.296.411.296h-.233l.471.416-.179.657.53-.37.513.37-.16-.657.412-.416h-.19l.425-.296.411.296h-.233l.471.416-.179.657.53-.37.513.37-.16-.657.412-.416h-.524l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.19l.425-.296.513.369-.16-.657.412-.416h-.524l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.19l.425-.296.513.369-.16-.657.412-.416h-.524L7.569.565l-.206.536h-.615l.471.416-.159.584h-.249l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.524L5.569.565l-.206.536h-.615l.471.416-.159.584h-.249l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.524L3.569.565l-.206.536h-.615l.471.416-.159.584h-.249l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.524L1.569.565l-.206.536H.748l.471.416-.179.657zM7.06 4.1l.159-.584-.47-.416h.232l-.411-.296-.425.296h.19l-.412.416.142.584h.298l.206-.536.242.536h.249zm-1.079 0l-.411-.296-.425.296h.19l-.412.416.142.584h.298l.206-.536.242.536h.249l.159-.584-.47-.416h.232zm-1.762.416L4.06 5.1h-.249l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.19l.425-.296.411.296h-.233l.471.416zm.144-.416h-.298l-.142-.584.412-.416h-.19l.425-.296.411.296h-.233l.471.416-.159.584h-.249l-.242-.536-.206.536zm-1.303 0l.159-.584-.47-.416h.232l-.411-.296-.425.296h.19l-.412.416.142.584h.298l.206-.536.242.536h.249zm3.159-1.584L6.06 3.1h-.249l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.19l.425-.296.411.296h-.233l.471.416zM3.981 2.1l-.411-.296-.425.296h.19l-.412.416.142.584h.298l.206-.536.242.536h.249l.159-.584-.47-.416h.232z" fill="#F7FCFF"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.3 KiB |
1
static/ui-icons/UI/Flag.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-flag"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"></path><line x1="4" y1="22" x2="4" y2="15"></line></svg>
|
After Width: | Height: | Size: 334 B |
256
test/nbrowser/LanguageSettings.ts
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
import {assert, createDriver, driver, WebDriver} from 'mocha-webdriver';
|
||||||
|
import * as gu from 'test/nbrowser/gristUtils';
|
||||||
|
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
|
||||||
|
|
||||||
|
describe("LanguageSettings", function() {
|
||||||
|
this.timeout('50s');
|
||||||
|
const cleanup = setupTestSuite();
|
||||||
|
|
||||||
|
before(async function() {
|
||||||
|
if (server.isExternalServer()) {
|
||||||
|
this.skip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List of languages that chrome supports https://developer.chrome.com/docs/webstore/i18n/#localeTable
|
||||||
|
const locales = [ // [language to set in the browser, country code detected, language name detected]
|
||||||
|
['fr', 'FR', 'Français'],
|
||||||
|
['te', 'US', 'English'], // Telugu is not supported yet, so Grist should fallback to English (US).
|
||||||
|
['en', 'US', 'English'], // This is a default language for Grist.
|
||||||
|
['pt-BR', 'BR', 'Português (Brasil)']
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [locale, countryCode, language] of locales) {
|
||||||
|
describe(`correctly detects browser language ${locale}`, () => {
|
||||||
|
// Change the language to the one we want to test.
|
||||||
|
withLang(locale);
|
||||||
|
before(async function() {
|
||||||
|
const session = await gu.session().personalSite.anon.login();
|
||||||
|
await session.loadRelPath("/");
|
||||||
|
await gu.waitForDocMenuToLoad();
|
||||||
|
});
|
||||||
|
it("shows correct language from browser settings", async () => {
|
||||||
|
// Find the button to switch the language.
|
||||||
|
const button = await langButton();
|
||||||
|
assert.isTrue(await button.isDisplayed());
|
||||||
|
// Make sure correct flag is shown.
|
||||||
|
const flag = await button.find("div").getCssValue("background-image");
|
||||||
|
assert.isTrue(flag.endsWith(countryCode + '.svg")'), `Flag is ${flag} search for ${countryCode}`);
|
||||||
|
// Make sure we see the all languages in the menu.
|
||||||
|
await button.click();
|
||||||
|
const menu = await gu.currentDriver().findWait(".grist-floating-menu", 100);
|
||||||
|
const allLangues = (await menu.findAll("li", e => e.getText())).map(l => l.toLowerCase());
|
||||||
|
for (const [, , language] of locales) {
|
||||||
|
assert.include(allLangues, language.toLowerCase());
|
||||||
|
}
|
||||||
|
// Make sure that this language is selected.
|
||||||
|
assert.equal(await selectedLang(), language.toLowerCase());
|
||||||
|
// Smoke test that we see the correct language.
|
||||||
|
const welcomeText = await gu.currentDriver().find(".test-welcome-title").getText();
|
||||||
|
if (locale === 'en') {
|
||||||
|
assert.equal(welcomeText, "Welcome to Grist!");
|
||||||
|
} else if (locale === 'fr') {
|
||||||
|
assert.equal(welcomeText, "Bienvenue sur Grist !");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("for Anonymous", function() {
|
||||||
|
before(async function() {
|
||||||
|
const session = await gu.session().personalSite.anon.login();
|
||||||
|
await session.loadRelPath("/");
|
||||||
|
await gu.waitForDocMenuToLoad();
|
||||||
|
});
|
||||||
|
it("allows anonymous user to switch a language", async () => {
|
||||||
|
await langButton().click();
|
||||||
|
// By default we have English (US) selected.
|
||||||
|
assert.equal(await selectedLang(), "english");
|
||||||
|
// Change to French.
|
||||||
|
await gu.currentDriver().find(".test-language-lang-fr").click();
|
||||||
|
// We will be reloaded, so wait until we see the new language.
|
||||||
|
await waitForLangButton("fr");
|
||||||
|
// Now we have a cookie with the language selected, so reloading the page should keep it.
|
||||||
|
await gu.currentDriver().navigate().refresh();
|
||||||
|
await gu.waitForDocMenuToLoad();
|
||||||
|
await waitForLangButton("fr");
|
||||||
|
assert.equal(await languageInCookie(), "fr");
|
||||||
|
// Switch to German.
|
||||||
|
await langButton().click();
|
||||||
|
await gu.currentDriver().find(".test-language-lang-de").click();
|
||||||
|
await waitForLangButton("de");
|
||||||
|
// Make sure we see new cookie.
|
||||||
|
assert.equal(await languageInCookie(), "de");
|
||||||
|
// Remove the cookie and reload.
|
||||||
|
await clearCookie();
|
||||||
|
await gu.currentDriver().navigate().refresh();
|
||||||
|
await gu.waitForDocMenuToLoad();
|
||||||
|
// Make sure we see the default language.
|
||||||
|
await waitForLangButton("en");
|
||||||
|
// Test if changing the cookie is reflected in the UI. This cookie is available for javascript.
|
||||||
|
await setCookie("fr");
|
||||||
|
await gu.currentDriver().navigate().refresh();
|
||||||
|
await gu.waitForDocMenuToLoad();
|
||||||
|
await waitForLangButton("fr");
|
||||||
|
assert.equal(await languageInCookie(), "fr");
|
||||||
|
// Go back to English.
|
||||||
|
await clearCookie();
|
||||||
|
await gu.currentDriver().navigate().refresh();
|
||||||
|
await gu.waitForDocMenuToLoad();
|
||||||
|
});
|
||||||
|
it("when user is logged in the language is still taken from the cookie", async () => {
|
||||||
|
await langButton().click();
|
||||||
|
// By default we have English (US) selected ()
|
||||||
|
assert.equal(await selectedLang(), "english");
|
||||||
|
|
||||||
|
// Now login to the account.
|
||||||
|
const user = await gu.session().personalSite.user('user1').login();
|
||||||
|
await user.loadRelPath("/");
|
||||||
|
await gu.waitForDocMenuToLoad();
|
||||||
|
// Language should still be english.
|
||||||
|
await waitForHiddenButton("en");
|
||||||
|
// And we should not have a cookie.
|
||||||
|
assert.isNull(await languageInCookie());
|
||||||
|
|
||||||
|
// Go back to anonymous.
|
||||||
|
const anonym = await gu.session().personalSite.anon.login();
|
||||||
|
await anonym.loadRelPath("/");
|
||||||
|
await gu.waitForDocMenuToLoad();
|
||||||
|
|
||||||
|
// Change language to french.
|
||||||
|
await langButton().click();
|
||||||
|
await driver.find(".test-language-lang-fr").click();
|
||||||
|
await waitForLangButton("fr");
|
||||||
|
|
||||||
|
// Login as user.
|
||||||
|
await user.login();
|
||||||
|
await anonym.loadRelPath("/");
|
||||||
|
await gu.waitForDocMenuToLoad();
|
||||||
|
|
||||||
|
// Language should still be french.
|
||||||
|
await waitForHiddenButton("fr");
|
||||||
|
// But now we should have a cookie (cookie is reused).
|
||||||
|
assert.equal(await languageInCookie(), 'fr');
|
||||||
|
await clearCookie();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("for logged in user with nb-NO", function() {
|
||||||
|
withLang("de");
|
||||||
|
let session: gu.Session;
|
||||||
|
before(async function() {
|
||||||
|
session = await gu.session().login();
|
||||||
|
await session.loadRelPath("/");
|
||||||
|
await gu.waitForDocMenuToLoad();
|
||||||
|
});
|
||||||
|
after(async function() {
|
||||||
|
await clearCookie();
|
||||||
|
const api = session.createHomeApi();
|
||||||
|
await api.updateUserLocale(null);
|
||||||
|
});
|
||||||
|
it("profile page detects correct language", async () => {
|
||||||
|
const driver = gu.currentDriver();
|
||||||
|
// Make sure we don't have a cookie yet.
|
||||||
|
assert.isNull(await languageInCookie());
|
||||||
|
// Or a saved setting.
|
||||||
|
let gristConfig: any = await driver.executeScript("return window.gristConfig");
|
||||||
|
assert.isNull(gristConfig.userLocale);
|
||||||
|
await gu.openProfileSettingsPage();
|
||||||
|
// Make sure we see the correct language.
|
||||||
|
assert.equal(await languageMenu().getText(), "Deutsch");
|
||||||
|
// Make sure we see hidden indicator.
|
||||||
|
await waitForHiddenButton("de");
|
||||||
|
// Change language to nb-.NO
|
||||||
|
await languageMenu().click();
|
||||||
|
await driver.findContentWait('.test-select-menu li', 'Norsk bokmål (Norge)', 100).click();
|
||||||
|
// This is api call and we will be reloaded, so wait for the hidden indicator.
|
||||||
|
await waitForHiddenButton("nb-NO");
|
||||||
|
// Now we should have a cookie.
|
||||||
|
assert.equal(await languageInCookie(), "nb-NO");
|
||||||
|
// And the gristConfig should have this language.
|
||||||
|
gristConfig = await driver.executeScript("return window.gristConfig");
|
||||||
|
assert.equal(gristConfig.userLocale, "nb-NO");
|
||||||
|
// If we remove the cookie, we should still use the gristConfig.
|
||||||
|
await clearCookie();
|
||||||
|
await driver.navigate().refresh();
|
||||||
|
await waitForHiddenButton("nb-NO");
|
||||||
|
// If we set a different cookie, we should still use the saved setting.
|
||||||
|
await setCookie("de");
|
||||||
|
await driver.navigate().refresh();
|
||||||
|
await waitForHiddenButton("nb-NO");
|
||||||
|
// Make sure this works on the document, by adding a new doc and smoke testing the Add New button.
|
||||||
|
await session.tempNewDoc(cleanup, "Test");
|
||||||
|
assert.equal(await driver.findWait(".test-dp-add-new", 3000).getText(), "Legg til ny");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
function languageMenu() {
|
||||||
|
return gu.currentDriver().find('.test-account-page-language .test-select-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearCookie() {
|
||||||
|
await gu.currentDriver().executeScript(
|
||||||
|
"document.cookie = 'grist_user_locale=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setCookie(locale: string) {
|
||||||
|
await gu.currentDriver().executeScript(
|
||||||
|
`document.cookie = 'grist_user_locale=${locale}; expires=Thu, 01 Jan 2970 00:00:00 UTC; path=/;';`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForLangButton(locale: string) {
|
||||||
|
await gu.waitToPass(async () =>
|
||||||
|
assert.isTrue(await gu.currentDriver().findWait(`.test-language-current-${locale}`, 1000).isDisplayed()), 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForHiddenButton(locale: string) {
|
||||||
|
await gu.waitToPass(async () =>
|
||||||
|
assert.isTrue(await gu.currentDriver().findWait(`input.test-language-current-${locale}`, 1000).isPresent()), 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function languageInCookie(): Promise<string | null> {
|
||||||
|
const cookie2: string = await gu.currentDriver().executeScript("return document.cookie");
|
||||||
|
return cookie2.match(/grist_user_locale=([^;]+)/)?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withLang(locale: string) {
|
||||||
|
let customDriver: WebDriver;
|
||||||
|
let oldLanguage: string | undefined;
|
||||||
|
before(async function() {
|
||||||
|
// On Mac we can't change the language, so skip the test.
|
||||||
|
if (await gu.isMac()) { return this.skip(); }
|
||||||
|
oldLanguage = process.env.LANGUAGE;
|
||||||
|
// How to run chrome with a different language:
|
||||||
|
// https://developer.chrome.com/docs/extensions/reference/i18n/#how-to-set-browsers-locale
|
||||||
|
process.env.LANGUAGE = locale;
|
||||||
|
customDriver = await createDriver({
|
||||||
|
extraArgs: [
|
||||||
|
'lang=' + locale,
|
||||||
|
...(process.env.MOCHA_WEBDRIVER_HEADLESS ? [`headless=chrome`] : [])
|
||||||
|
]
|
||||||
|
});
|
||||||
|
server.setDriver(customDriver);
|
||||||
|
gu.setDriver(customDriver);
|
||||||
|
const session = await gu.session().personalSite.anon.login();
|
||||||
|
await session.loadRelPath("/");
|
||||||
|
await gu.waitForDocMenuToLoad();
|
||||||
|
});
|
||||||
|
after(async function() {
|
||||||
|
if (await gu.isMac()) { return this.skip(); }
|
||||||
|
gu.setDriver();
|
||||||
|
server.setDriver();
|
||||||
|
await customDriver.quit();
|
||||||
|
process.env.LANGUAGE = oldLanguage;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function langButton() {
|
||||||
|
return gu.currentDriver().findWait(".test-language-button", 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectedLang() {
|
||||||
|
const menu = gu.currentDriver().findWait(".grist-floating-menu", 100);
|
||||||
|
return (await menu.find(".test-language-selected").findClosest("li").getText()).toLowerCase();
|
||||||
|
}
|
@ -41,7 +41,7 @@ describe("Localization", function() {
|
|||||||
const namespaces: Set<string> = new Set();
|
const namespaces: Set<string> = new Set();
|
||||||
for (const file of fs.readdirSync(localeDirectory)) {
|
for (const file of fs.readdirSync(localeDirectory)) {
|
||||||
if (file.endsWith(".json")) {
|
if (file.endsWith(".json")) {
|
||||||
const lang = file.split('.')[0];
|
const lang = file.split('.')[0]?.replace(/_/g, '-');
|
||||||
const ns = file.split('.')[1];
|
const ns = file.split('.')[1];
|
||||||
langs.add(lang);
|
langs.add(lang);
|
||||||
namespaces.add(ns);
|
namespaces.add(ns);
|
||||||
|
@ -35,6 +35,8 @@ namespace gristUtils {
|
|||||||
// Allow overriding the global 'driver' to use in gristUtil.
|
// Allow overriding the global 'driver' to use in gristUtil.
|
||||||
let driver: WebDriver;
|
let driver: WebDriver;
|
||||||
|
|
||||||
|
export function currentDriver() { return driver; }
|
||||||
|
|
||||||
// Substitute a custom driver to use with gristUtils functions. Omit argument to restore to default.
|
// Substitute a custom driver to use with gristUtils functions. Omit argument to restore to default.
|
||||||
export function setDriver(customDriver: WebDriver = driverOrig) { driver = customDriver; }
|
export function setDriver(customDriver: WebDriver = driverOrig) { driver = customDriver; }
|
||||||
|
|
||||||
@ -2470,7 +2472,7 @@ export async function openAccountMenu() {
|
|||||||
|
|
||||||
export async function openProfileSettingsPage() {
|
export async function openProfileSettingsPage() {
|
||||||
await openAccountMenu();
|
await openAccountMenu();
|
||||||
await driver.findContent('.grist-floating-menu a', 'Profile Settings').click();
|
await driver.find('.grist-floating-menu .test-dm-account-settings').click();
|
||||||
await driver.findWait('.test-account-page-login-method', 5000);
|
await driver.findWait('.test-account-page-login-method', 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4509,11 +4509,6 @@ i18n-iso-countries@6.1.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
diacritics "1.3.0"
|
diacritics "1.3.0"
|
||||||
|
|
||||||
i18next-fs-backend@1.1.5:
|
|
||||||
version "1.1.5"
|
|
||||||
resolved "https://registry.npmjs.org/i18next-fs-backend/-/i18next-fs-backend-1.1.5.tgz"
|
|
||||||
integrity sha512-raTel3EfshiUXxR0gvmIoqp75jhkj8+7R1LjB006VZKPTFBbXyx6TlUVhb8Z9+7ahgpFbcQg1QWVOdf/iNzI5A==
|
|
||||||
|
|
||||||
i18next-http-middleware@3.2.1:
|
i18next-http-middleware@3.2.1:
|
||||||
version "3.2.1"
|
version "3.2.1"
|
||||||
resolved "https://registry.npmjs.org/i18next-http-middleware/-/i18next-http-middleware-3.2.1.tgz"
|
resolved "https://registry.npmjs.org/i18next-http-middleware/-/i18next-http-middleware-3.2.1.tgz"
|
||||||
|