feat: Allow users to specify language preference (#2129)

- Shows language selector on log in page
- Adds tabs to account settings
- Adds language preference selector on account settings
- Adds issue template for new language
- Updates dev docs for frontend localization

---------

Co-authored-by: SuaYoo <SuaYoo@users.noreply.github.com>
Co-authored-by: Henry Wilkinson <henry@wilkinson.graphics>
Co-authored-by: Tessa Walsh <tessa@bitarchivist.net>
This commit is contained in:
sua yoo 2024-11-11 09:30:20 -08:00 committed by GitHub
parent 59dff89614
commit 5062ec11c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1741 additions and 249 deletions

View File

@ -0,0 +1,17 @@
name: Localization Request
description: Request a new language or translation.
title: "[L10N]: "
labels: ["enhancement"]
body:
- type: textarea
attributes:
label: Language
description: Specify the language you'd like to add or translate. A list of currently supported languages can be found in our [Weblate project](https://hosted.weblate.org/engage/browsertrix/).
validations:
required: true
- type: textarea
attributes:
label: Context
description: Any background information that helps us understand the request.
validations:
required: true

View File

@ -134,44 +134,3 @@ To run tests in multiple browsers:
```sh
yarn test --browsers chromium firefox webkit
```
## Localization
Wrap text or templates in the `msg` helper to make them localizable:
```js
// import from @lit/localize:
import { msg } from "@lit/localize";
// later, in the render function:
render() {
return html`
<button>
${msg("Click me")}
</button>
`
}
```
Entire templates can be wrapped as well:
```js
render() {
return msg(html`
<p>Click the button</p>
<button>Click me</button>
`)
}
```
See: <https://lit.dev/docs/localization/overview/#message-types>
To add new languages:
1. Add [BCP 47 language tag](https://www.w3.org/International/articles/language-tags/index.en) to `targetLocales` in `lit-localize.json`
2. Run `yarn localize:extract` to generate new .xlf file in `/xliff`
3. Provide .xlf file to translation team
4. Replace .xlf file once translated
5. Run `yarn localize:build` bring translation into `src`
See: <https://lit.dev/docs/localization/overview/#extracting-messages>

View File

@ -0,0 +1,55 @@
# Localization
The Browsertrix UI supports multiple languages. Browsertrix end users can set a language preference in their account settings.
## Contributing
Translations are managed through Weblate, a web-based translation tool. Registration is free! Once registered, you can submit translations for review by Browsertrix maintainers.
**[Register for Weblate](https://hosted.weblate.org/engage/browsertrix/)**
## Adding a Language
Adding support for a new language involves a small code change. If you'd like to add a new language and would prefer that a Browsertrix maintainer make the change, submit a [**Localization Request** on GitHub](https://github.com/webrecorder/browsertrix/issues/new/choose). A Browsertrix maintainer will respond to your request on GitHub.
To add a new language directly through code change:
1. Look up the [BCP 47 language tag](https://www.w3.org/International/articles/language-tags/index.en#registry) and add it to the `targetLocales` field in `lit-localize.json`.
```js
{
// ...
"sourceLocale": "en",
"targetLocales": [
"es",
// Add your language tag here
],
}
```
2. Generate a new XLIFF file by running:
```sh
yarn localize:extract
```
This will add an `.xlf` file named after the new language tag to the `/xliff` directory.
3. Open a pull request with the changes.
4. Once the pull request is merged, manually refresh the language list in the [Weblate Browsertrix project](https://hosted.weblate.org/projects/browsertrix). Translations are managed entirely through the Weblate interface.
## Making Strings Localizable
All text should be wrapped in the `msg` helper to make them localizable:
```js
import { msg } from "@lit/localize";
// later, in the render function:
render() {
return html`
<button>
${msg("Click me")}
</button>
`
}
```
See [Lit's message types documentation](https://lit.dev/docs/localization/overview/#message-types) page for details.

View File

@ -88,6 +88,7 @@ nav:
- develop/index.md
- develop/local-dev-setup.md
- develop/frontend-dev.md
- develop/localization.md
- develop/docs.md
markdown_extensions:

1326
frontend/src/__generated__/locales/es.ts generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,28 +1,31 @@
import { localized } from "@lit/localize";
import { html, LitElement } from "lit";
import type { SlSelectEvent } from "@shoelace-style/shoelace";
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { allLocales } from "@/__generated__/locale-codes";
import { getLocale, setLocaleFromUrl } from "@/utils/localization";
import { sourceLocale } from "@/__generated__/locale-codes";
import { BtrixElement } from "@/classes/BtrixElement";
import { allLocales, type LocaleCodeEnum } from "@/types/localization";
import { getLocale, setLocale } from "@/utils/localization";
import { AppStateService } from "@/utils/state";
type LocaleCode = (typeof allLocales)[number];
type LocaleNames = {
[L in LocaleCode]: string;
[L in LocaleCodeEnum]: string;
};
@localized()
@customElement("btrix-locale-picker")
export class LocalePicker extends LitElement {
export class LocalePicker extends BtrixElement {
@state()
private localeNames: LocaleNames | undefined = {} as LocaleNames;
private readonly setLocaleName = (locale: LocaleCode) => {
private readonly setLocaleName = (locale: LocaleCodeEnum) => {
this.localeNames![locale] = new Intl.DisplayNames([locale], {
type: "language",
}).of(locale)!;
}).of(locale.toUpperCase())!;
};
async firstUpdated() {
firstUpdated() {
this.localeNames = {} as LocaleNames;
allLocales.forEach(this.setLocaleName);
}
@ -32,23 +35,24 @@ export class LocalePicker extends LitElement {
return;
}
const selectedLocale = getLocale();
const selectedLocale =
this.appState.userPreferences?.locale || sourceLocale;
return html`
<sl-dropdown
value="${selectedLocale}"
@sl-select=${this.localeChanged}
placement="top-end"
distance="4"
hoist
>
<sl-button slot="trigger" size="small" caret
>${this.localeNames[selectedLocale as LocaleCode]}</sl-button
>
<sl-button class="capitalize" slot="trigger" size="small" caret>
${this.localeNames[selectedLocale as LocaleCodeEnum]}
</sl-button>
<sl-menu>
${allLocales.map(
(locale) =>
html`<sl-menu-item
class="capitalize"
type="checkbox"
value=${locale}
?checked=${locale === selectedLocale}
@ -61,14 +65,13 @@ export class LocalePicker extends LitElement {
`;
}
async localeChanged(event: CustomEvent) {
const newLocale = event.detail.item.value as LocaleCode;
async localeChanged(event: SlSelectEvent) {
const newLocale = event.detail.item.value as LocaleCodeEnum;
AppStateService.partialUpdateUserPreferences({ locale: newLocale });
if (newLocale !== getLocale()) {
const url = new URL(window.location.href);
url.searchParams.set("locale", newLocale);
window.history.pushState(null, "", url.toString());
void setLocaleFromUrl();
void setLocale(newLocale);
}
}
}

View File

@ -1,5 +1,9 @@
import { localized, msg, str } from "@lit/localize";
import type { SlDialog, SlDrawer } from "@shoelace-style/shoelace";
import type {
SlDialog,
SlDrawer,
SlSelectEvent,
} from "@shoelace-style/shoelace";
import { nothing, render, type TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { when } from "lit/directives/when.js";
@ -26,7 +30,14 @@ import type { NavigateEventDetail } from "@/controllers/navigate";
import type { NotifyEventDetail } from "@/controllers/notify";
import { theme } from "@/theme";
import { type Auth } from "@/types/auth";
import { type LocaleCodeEnum } from "@/types/localization";
import { type AppSettings } from "@/utils/app";
import {
getLocale,
LOCALE_PARAM_NAME,
resetLocale,
setLocale,
} from "@/utils/localization";
import brandLockupColor from "~assets/brand/browsertrix-lockup-color.svg";
import "./shoelace";
@ -145,6 +156,15 @@ export class App extends LiteElement {
}
}
protected firstUpdated(): void {
if (
this.appState.userPreferences?.locale &&
this.appState.userPreferences.locale !== getLocale()
) {
void setLocale(this.appState.userPreferences.locale);
}
}
getLocationPathname() {
return window.location.pathname;
}
@ -412,7 +432,7 @@ export class App extends LiteElement {
<div class="px-7 py-2">${this.renderMenuUserInfo()}</div>
<sl-divider></sl-divider>
<sl-menu-item
@click=${() => this.navigate(ROUTES.accountSettings)}
@click=${() => this.navigate("/account/settings")}
>
<sl-icon slot="prefix" name="person-gear"></sl-icon>
${msg("Account Settings")}
@ -432,7 +452,12 @@ export class App extends LiteElement {
</sl-menu-item>
</sl-menu>
</sl-dropdown>`
: this.renderSignUpLink()}
: html`
${this.renderSignUpLink()}
<btrix-locale-picker
@sl-select=${this.onSelectLocale}
></btrix-locale-picker>
`}
</div>
${isSuperAdmin
? html`
@ -608,10 +633,6 @@ export class App extends LiteElement {
<footer
class="mx-auto box-border flex w-full max-w-screen-desktop flex-col items-center justify-between gap-4 p-3 md:flex-row"
>
<!-- <div> -->
<!-- TODO re-enable when translations are added -->
<!-- <btrix-locale-picker></btrix-locale-picker> -->
<!-- </div> -->
<div>
<a
class="flex items-center gap-2 leading-none text-neutral-400 hover:text-primary"
@ -733,6 +754,7 @@ export class App extends LiteElement {
case "accountSettings":
return html`<btrix-account-settings
class="mx-auto box-border w-full max-w-screen-desktop p-2 md:py-8"
tab=${this.viewState.params.settingsTab}
></btrix-account-settings>`;
case "usersInvite": {
@ -867,6 +889,14 @@ export class App extends LiteElement {
}
}
onSelectLocale(e: SlSelectEvent) {
const locale = e.detail.item.value as LocaleCodeEnum;
const url = new URL(window.location.href);
url.searchParams.set(LOCALE_PARAM_NAME, locale);
window.history.pushState(null, "", url.toString());
}
onLogOut(event: CustomEvent<{ redirect?: boolean } | null>) {
const detail = event.detail || {};
const redirect = detail.redirect !== false;
@ -988,6 +1018,7 @@ export class App extends LiteElement {
this.authService.logout();
this.authService = new AuthService();
AppStateService.resetAll();
void resetLocale();
}
private showDialog(content: DialogContent) {

View File

@ -1,19 +1,28 @@
import { localized, msg, str } from "@lit/localize";
import type { SlInput } from "@shoelace-style/shoelace";
import type { SlInput, SlSelectEvent } from "@shoelace-style/shoelace";
import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js";
import type { ZxcvbnResult } from "@zxcvbn-ts/core";
import { type PropertyValues } from "lit";
import { nothing, type PropertyValues } from "lit";
import { customElement, property, queryAsync, state } from "lit/decorators.js";
import { choose } from "lit/directives/choose.js";
import { when } from "lit/directives/when.js";
import debounce from "lodash/fp/debounce";
import { TailwindElement } from "@/classes/TailwindElement";
import needLogin from "@/decorators/needLogin";
import { pageHeader } from "@/layouts/pageHeader";
import { allLocales, type LocaleCodeEnum } from "@/types/localization";
import type { UnderlyingFunction } from "@/types/utils";
import { isApiError } from "@/utils/api";
import LiteElement, { html } from "@/utils/LiteElement";
import PasswordService from "@/utils/PasswordService";
import { AppStateService } from "@/utils/state";
import { tw } from "@/utils/tailwind";
enum Tab {
Profile = "profile",
Security = "security",
}
const { PASSWORD_MINLENGTH, PASSWORD_MAXLENGTH, PASSWORD_MIN_SCORE } =
PasswordService;
@ -94,11 +103,11 @@ export class RequestVerify extends TailwindElement {
@customElement("btrix-account-settings")
@needLogin
export class AccountSettings extends LiteElement {
@state()
sectionSubmitting: null | "name" | "email" | "password" = null;
@property({ type: String })
tab: string | Tab = Tab.Profile;
@state()
private isChangingPassword = false;
sectionSubmitting: null | "name" | "email" | "password" = null;
@state()
private pwStrengthResults: null | ZxcvbnResult = null;
@ -106,15 +115,17 @@ export class AccountSettings extends LiteElement {
@queryAsync('sl-input[name="password"]')
private readonly passwordInput?: Promise<SlInput | null>;
async updated(
changedProperties: PropertyValues<this> & Map<string, unknown>,
) {
if (
changedProperties.has("isChangingPassword") &&
this.isChangingPassword
) {
(await this.passwordInput)?.focus();
}
private get activeTab() {
return this.tab && Object.values(Tab).includes(this.tab as unknown as Tab)
? (this.tab as Tab)
: Tab.Profile;
}
private get tabLabels(): Record<Tab, string> {
return {
[Tab.Profile]: msg("Profile"),
[Tab.Security]: msg("Security"),
};
}
protected firstUpdated() {
@ -122,172 +133,208 @@ export class AccountSettings extends LiteElement {
}
render() {
if (!this.userInfo) return;
return html`
<btrix-document-title
title=${msg("Account Settings")}
></btrix-document-title>
<div class="mx-auto max-w-screen-sm">
<h1 class="mb-7 text-xl font-semibold leading-8">
${msg("Account Settings")}
</h1>
<form class="mb-5 rounded border" @submit=${this.onSubmitName}>
<div class="p-4">
<h2 class="mb-4 text-lg font-semibold leading-none">
${msg("Display Name")}
</h2>
<p class="mb-2">
${msg(
"Enter your full name, or another name to display in the orgs you belong to.",
)}
</p>
<sl-input
name="displayName"
value=${this.userInfo.name}
maxlength="40"
minlength="2"
required
aria-label=${msg("Display name")}
></sl-input>
</div>
<footer class="flex items-center justify-end border-t px-4 py-3">
<sl-button
type="submit"
size="small"
variant="primary"
?loading=${this.sectionSubmitting === "name"}
>${msg("Save")}</sl-button
>
</footer>
</form>
<form class="mb-5 rounded border" @submit=${this.onSubmitEmail}>
<div class="p-4">
<h2 class="mb-4 text-lg font-semibold leading-none">
${msg("Email")}
</h2>
<p class="mb-2">${msg("Update the email you use to log in.")}</p>
<sl-input
name="email"
value=${this.userInfo.email}
type="email"
aria-label=${msg("Email")}
>
<div slot="suffix">
<sl-tooltip
content=${this.userInfo.isVerified
? msg("Verified")
: msg("Needs verification")}
hoist
>
${this.userInfo.isVerified
? html`<sl-icon
class="text-success"
name="check-lg"
></sl-icon>`
: html`<sl-icon
class="text-warning"
name="exclamation-circle"
></sl-icon>`}
</sl-tooltip>
</div>
</sl-input>
</div>
<footer class="flex items-center justify-end border-t px-4 py-3">
${!this.userInfo.isVerified
? html`
<btrix-request-verify
class="mr-auto"
email=${this.userInfo.email}
></btrix-request-verify>
`
: ""}
<sl-button
type="submit"
size="small"
variant="primary"
?loading=${this.sectionSubmitting === "email"}
>${msg("Save")}</sl-button
>
</footer>
</form>
<section class="mb-5 rounded border">
${when(
this.isChangingPassword,
() => html`
<form @submit=${this.onSubmitPassword}>
<div class="p-4">
<h2 class="mb-4 text-lg font-semibold leading-none">
${msg("Password")}
</h2>
<sl-input
class="mb-3"
name="password"
label=${msg("Enter your current password")}
type="password"
autocomplete="current-password"
password-toggle
required
></sl-input>
<sl-input
name="newPassword"
label=${msg("New password")}
type="password"
autocomplete="new-password"
password-toggle
minlength="8"
required
@input=${this.onPasswordInput as UnderlyingFunction<
typeof this.onPasswordInput
>}
></sl-input>
${pageHeader(msg("Account Settings"), undefined, tw`mb-3 lg:mb-5`)}
${when(this.pwStrengthResults, this.renderPasswordStrength)}
</div>
<footer
class="flex items-center justify-end border-t px-4 py-3"
>
<p class="mr-auto text-gray-500">
${msg(
str`Choose a strong password between ${PASSWORD_MINLENGTH}-${PASSWORD_MAXLENGTH} characters.`,
)}
</p>
<sl-button
type="reset"
size="small"
variant="text"
class="mx-2"
@click=${() => (this.isChangingPassword = false)}
>
${msg("Cancel")}
</sl-button>
<sl-button
type="submit"
size="small"
variant="primary"
?loading=${this.sectionSubmitting === "password"}
?disabled=${!this.pwStrengthResults ||
this.pwStrengthResults.score < PASSWORD_MIN_SCORE}
>${msg("Save")}</sl-button
>
</footer>
</form>
`,
() => html`
<div class="flex items-center justify-between px-4 py-2.5">
<h2 class="text-lg font-semibold leading-none">
${msg("Password")}
</h2>
<sl-button
size="small"
@click=${() => (this.isChangingPassword = true)}
>${msg("Change Password")}</sl-button
>
</div>
`,
<btrix-tab-list activePanel=${this.activeTab} hideIndicator>
<header slot="header" class="flex h-7 items-end justify-between">
${choose(
this.activeTab,
[
[Tab.Profile, () => html`<h2>${msg("Display Name")}</h2>`],
[Tab.Security, () => html`<h2>${msg("Password")}</h2>`],
],
() => html`<h2>${this.tabLabels[this.activeTab]}</h2>`,
)}
</section>
</div>
</header>
${this.renderTab(Tab.Profile)} ${this.renderTab(Tab.Security)}
<btrix-tab-panel name=${Tab.Profile}>
${this.renderProfile()}
</btrix-tab-panel>
<btrix-tab-panel name=${Tab.Security}>
${this.renderSecurity()}
</btrix-tab-panel>
</btrix-tab-list>
`;
}
private renderProfile() {
if (!this.userInfo) return;
return html`
<form class="mb-5 rounded-lg border" @submit=${this.onSubmitName}>
<div class="p-4">
<p class="mb-2">
${msg(
"Enter your full name, or another name to display in the orgs you belong to.",
)}
</p>
<sl-input
name="displayName"
value=${this.userInfo.name}
maxlength="40"
minlength="2"
required
aria-label=${msg("Display name")}
></sl-input>
</div>
<footer class="flex items-center justify-end border-t px-4 py-3">
<sl-button
type="submit"
size="small"
variant="primary"
?loading=${this.sectionSubmitting === "name"}
>${msg("Save")}</sl-button
>
</footer>
</form>
<h2 class="mb-2 mt-7 text-lg font-medium">${msg("Email")}</h2>
<form class="rounded-lg border" @submit=${this.onSubmitEmail}>
<div class="p-4">
<p class="mb-2">${msg("Update the email you use to log in.")}</p>
<sl-input
name="email"
value=${this.userInfo.email}
type="email"
aria-label=${msg("Email")}
>
<div slot="suffix">
<sl-tooltip
content=${this.userInfo.isVerified
? msg("Verified")
: msg("Needs verification")}
hoist
>
${this.userInfo.isVerified
? html`<sl-icon
class="text-success"
name="check-lg"
></sl-icon>`
: html`<sl-icon
class="text-warning"
name="exclamation-circle"
></sl-icon>`}
</sl-tooltip>
</div>
</sl-input>
</div>
<footer class="flex items-center justify-end border-t px-4 py-3">
${!this.userInfo.isVerified
? html`
<btrix-request-verify
class="mr-auto"
email=${this.userInfo.email}
></btrix-request-verify>
`
: ""}
<sl-button
type="submit"
size="small"
variant="primary"
?loading=${this.sectionSubmitting === "email"}
>${msg("Save")}</sl-button
>
</footer>
</form>
${(allLocales as unknown as string[]).length > 1
? this.renderPreferences()
: nothing}
`;
}
private renderSecurity() {
return html`
<form class="rounded-lg border" @submit=${this.onSubmitPassword}>
<div class="p-4">
<sl-input
class="mb-3"
name="password"
label=${msg("Enter your current password")}
type="password"
autocomplete="off"
password-toggle
required
></sl-input>
<sl-input
name="newPassword"
label=${msg("New password")}
type="password"
autocomplete="new-password"
password-toggle
minlength="8"
required
@input=${this.onPasswordInput as UnderlyingFunction<
typeof this.onPasswordInput
>}
></sl-input>
${when(this.pwStrengthResults, this.renderPasswordStrength)}
</div>
<footer class="flex items-center justify-end border-t px-4 py-3">
<p class="mr-auto text-neutral-500">
${msg(
str`Choose a strong password between ${PASSWORD_MINLENGTH}-${PASSWORD_MAXLENGTH} characters.`,
)}
</p>
<sl-button
type="submit"
size="small"
variant="primary"
?loading=${this.sectionSubmitting === "password"}
?disabled=${!this.pwStrengthResults ||
this.pwStrengthResults.score < PASSWORD_MIN_SCORE}
>${msg("Save")}</sl-button
>
</footer>
</form>
`;
}
private renderTab(name: Tab) {
const isActive = name === this.activeTab;
return html`
<btrix-navigation-button
slot="nav"
href=${`/account/settings/${name}`}
.active=${isActive}
aria-selected=${isActive}
@click=${this.navLink}
>
${choose(name, [
[
Tab.Profile,
() => html`<sl-icon name="file-person-fill"></sl-icon>`,
],
[
Tab.Security,
() => html`<sl-icon name="shield-lock-fill"></sl-icon>`,
],
])}
${this.tabLabels[name]}
</btrix-navigation-button>
`;
}
private renderPreferences() {
return html`
<h2 class="mb-2 mt-7 text-lg font-medium">${msg("Preferences")}</h2>
<section class="mb-5 rounded-lg border">
<div class="flex items-center justify-between px-4 py-2.5">
<h3 class="font-medium">
${msg("Language")} <btrix-beta-badge></btrix-beta-badge>
</h3>
<btrix-locale-picker
@sl-select=${this.onSelectLocale}
></btrix-locale-picker>
</div>
</section>
`;
}
@ -428,8 +475,6 @@ export class AccountSettings extends LiteElement {
}),
});
this.isChangingPassword = false;
this.notify({
message: msg("Your password has been updated."),
variant: "success",
@ -453,4 +498,18 @@ export class AccountSettings extends LiteElement {
this.sectionSubmitting = null;
}
private readonly onSelectLocale = async (e: SlSelectEvent) => {
const locale = e.detail.item.value as LocaleCodeEnum;
if (locale !== this.appState.userPreferences?.locale) {
AppStateService.partialUpdateUserPreferences({ locale });
}
this.notify({
message: msg("Your language preference has been updated."),
variant: "success",
icon: "check2-circle",
});
};
}

View File

@ -8,7 +8,7 @@ export const ROUTES = {
loginWithRedirect: "/log-in?redirectUrl",
forgotPassword: "/log-in/forgot-password",
resetPassword: "/reset-password",
accountSettings: "/account/settings",
accountSettings: "/account/settings(/:settingsTab)",
orgs: "/orgs",
org: [
"/orgs/:slug",

View File

@ -0,0 +1,7 @@
import { z } from "zod";
import { allLocales } from "@/__generated__/locale-codes";
export { allLocales };
export const localeCodeEnum = z.enum(allLocales);
export type LocaleCodeEnum = z.infer<typeof localeCodeEnum>;

View File

@ -1,5 +1,6 @@
import { z } from "zod";
import { localeCodeEnum } from "./localization";
import { accessCodeSchema } from "./org";
import { WorkflowScopeType } from "./workflow";
@ -48,5 +49,6 @@ export type UserInfo = z.infer<typeof userInfoSchema>;
export const userPreferencesSchema = z.object({
newWorkflowScopeType: z.nativeEnum(WorkflowScopeType).optional(),
locale: localeCodeEnum.optional(),
});
export type UserPreferences = z.infer<typeof userPreferencesSchema>;

View File

@ -1,6 +1,11 @@
import { configureLocalization } from "@lit/localize";
import { sourceLocale, targetLocales } from "@/__generated__/locale-codes";
import {
allLocales,
sourceLocale,
targetLocales,
} from "@/__generated__/locale-codes";
import { type LocaleCodeEnum } from "@/types/localization";
export const { getLocale, setLocale } = configureLocalization({
sourceLocale,
@ -9,12 +14,29 @@ export const { getLocale, setLocale } = configureLocalization({
import(`/src/__generated__/locales/${locale}.ts`),
});
export const setLocaleFromUrl = async () => {
export const LOCALE_PARAM_NAME = "locale" as const;
export const getLocaleFromUrl = () => {
const url = new URL(window.location.href);
const locale = url.searchParams.get("locale") || sourceLocale;
const locale = url.searchParams.get(LOCALE_PARAM_NAME);
if (allLocales.includes(locale as unknown as LocaleCodeEnum)) {
return locale as LocaleCodeEnum;
}
};
export const setLocaleFromUrl = async () => {
const locale = getLocaleFromUrl();
if (!locale) return;
await setLocale(locale);
};
export const resetLocale = async () => {
await setLocale(sourceLocale);
};
/**
* Get time zone short name from locales
* @param locales List of locale codes. Omit for browser default

View File

@ -139,8 +139,6 @@ export function makeAppStateService() {
} else {
appState.userPreferences = userPreferences;
}
console.log("appState.userPreferences:", appState.userPreferences);
}
@transaction()

View File

@ -3780,6 +3780,18 @@
<x equiv-text="&lt;a class=&quot;text-neutral-500 underline hover:text-primary&quot; href=&quot;/docs/user-guide/org-settings/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;" id="0"/>org settings<x equiv-text="&lt;/a&gt;" id="1"/>
for details.</source>
</trans-unit>
<trans-unit id="sb061ff5a347a296e">
<source>Profile</source>
</trans-unit>
<trans-unit id="s01a8d45397ec133b">
<source>Security</source>
</trans-unit>
<trans-unit id="s2b5047d39b9baf3d">
<source>Preferences</source>
</trans-unit>
<trans-unit id="sfa66f095b5a35ccc">
<source>Your language preference has been updated.</source>
</trans-unit>
</body>
</file>
</xliff>