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:
parent
59dff89614
commit
5062ec11c9
17
.github/ISSUE_TEMPLATE/localization-request.yml
vendored
Normal file
17
.github/ISSUE_TEMPLATE/localization-request.yml
vendored
Normal 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
|
@ -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>
|
||||
|
55
frontend/docs/docs/develop/localization.md
Normal file
55
frontend/docs/docs/develop/localization.md
Normal 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.
|
@ -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
1326
frontend/src/__generated__/locales/es.ts
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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",
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -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",
|
||||
|
7
frontend/src/types/localization.ts
Normal file
7
frontend/src/types/localization.ts
Normal 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>;
|
@ -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>;
|
||||
|
@ -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
|
||||
|
@ -139,8 +139,6 @@ export function makeAppStateService() {
|
||||
} else {
|
||||
appState.userPreferences = userPreferences;
|
||||
}
|
||||
|
||||
console.log("appState.userPreferences:", appState.userPreferences);
|
||||
}
|
||||
|
||||
@transaction()
|
||||
|
@ -3780,6 +3780,18 @@
|
||||
<x equiv-text="<a class="text-neutral-500 underline hover:text-primary" href="/docs/user-guide/org-settings/" target="_blank" rel="noopener">" id="0"/>org settings<x equiv-text="</a>" 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>
|
||||
|
Loading…
Reference in New Issue
Block a user