Wire up i18n for the React frontend (#1962)
Co-authored-by: Quentin Gliech <quenting@element.io>
This commit is contained in:
committed by
GitHub
parent
7207ebdc63
commit
af1a960c2f
10
.editorconfig
Normal file
10
.editorconfig
Normal file
@@ -0,0 +1,10 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset=utf-8
|
||||
end_of_line = lf
|
||||
|
||||
[*.{ts,tsx}]
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
@@ -54,6 +54,7 @@ enum FileType {
|
||||
Stylesheet,
|
||||
Woff,
|
||||
Woff2,
|
||||
Json,
|
||||
}
|
||||
|
||||
impl FileType {
|
||||
@@ -63,6 +64,7 @@ impl FileType {
|
||||
Some("js") => Some(Self::Script),
|
||||
Some("woff") => Some(Self::Woff),
|
||||
Some("woff2") => Some(Self::Woff2),
|
||||
Some("json") => Some(Self::Json),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -122,6 +124,9 @@ impl<'a> Asset<'a> {
|
||||
FileType::Woff | FileType::Woff2 => {
|
||||
format!(r#"<link rel="preload" href="{href}" as="font" crossorigin {integrity}/>"#,)
|
||||
}
|
||||
FileType::Json => {
|
||||
format!(r#"<link rel="preload" href="{href}" as="fetch" crossorigin {integrity}/>"#,)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,9 +145,33 @@ impl<'a> Asset<'a> {
|
||||
FileType::Script => Some(format!(
|
||||
r#"<script type="module" src="{src}" crossorigin {integrity}></script>"#
|
||||
)),
|
||||
FileType::Woff | FileType::Woff2 => None,
|
||||
FileType::Woff | FileType::Woff2 | FileType::Json => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the asset type is a script
|
||||
#[must_use]
|
||||
pub fn is_script(&self) -> bool {
|
||||
self.file_type == FileType::Script
|
||||
}
|
||||
|
||||
/// Returns `true` if the asset type is a stylesheet
|
||||
#[must_use]
|
||||
pub fn is_stylesheet(&self) -> bool {
|
||||
self.file_type == FileType::Stylesheet
|
||||
}
|
||||
|
||||
/// Returns `true` if the asset type is JSON
|
||||
#[must_use]
|
||||
pub fn is_json(&self) -> bool {
|
||||
self.file_type == FileType::Json
|
||||
}
|
||||
|
||||
/// Returns `true` if the asset type is a font
|
||||
#[must_use]
|
||||
pub fn is_font(&self) -> bool {
|
||||
self.file_type == FileType::Woff || self.file_type == FileType::Woff2
|
||||
}
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
|
||||
@@ -362,15 +362,17 @@ impl Object for IncludeAsset {
|
||||
BTreeSet::new()
|
||||
};
|
||||
|
||||
let tags: Vec<String> = preloads
|
||||
let preloads = preloads
|
||||
.iter()
|
||||
.map(|asset| asset.preload_tag(self.url_builder.assets_base().into()))
|
||||
.chain(
|
||||
assets
|
||||
.iter()
|
||||
.filter_map(|asset| asset.include_tag(self.url_builder.assets_base().into())),
|
||||
)
|
||||
.collect();
|
||||
// Only preload scripts and stylesheets for now
|
||||
.filter(|asset| asset.is_script() || asset.is_stylesheet())
|
||||
.map(|asset| asset.preload_tag(self.url_builder.assets_base().into()));
|
||||
|
||||
let assets = assets
|
||||
.iter()
|
||||
.filter_map(|asset| asset.include_tag(self.url_builder.assets_base().into()));
|
||||
|
||||
let tags: Vec<String> = preloads.chain(assets).collect();
|
||||
|
||||
Ok(Value::from_safe_string(tags.join("\n")))
|
||||
}
|
||||
|
||||
36
frontend/i18next-parser.config.ts
Normal file
36
frontend/i18next-parser.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import type { UserConfig } from "i18next-parser";
|
||||
|
||||
const config: UserConfig = {
|
||||
keySeparator: ".",
|
||||
pluralSeparator: ":",
|
||||
defaultNamespace: "frontend",
|
||||
lexers: {
|
||||
ts: [
|
||||
{
|
||||
lexer: "JavascriptLexer",
|
||||
functions: ["t", "translatedError"],
|
||||
namespaceFunctions: ["useTranslation", "withTranslation"],
|
||||
},
|
||||
],
|
||||
},
|
||||
locales: ["en"],
|
||||
output: "locales/$LOCALE.json",
|
||||
input: ["src/**/*.{ts,tsx}"],
|
||||
sort: true,
|
||||
};
|
||||
|
||||
export default config;
|
||||
140
frontend/locales/en.json
Normal file
140
frontend/locales/en.json
Normal file
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"action": {
|
||||
"cancel": "Cancel",
|
||||
"continue": "Continue",
|
||||
"save": "Save"
|
||||
},
|
||||
"common": {
|
||||
"add": "Add",
|
||||
"error": "Error",
|
||||
"loading": "Loading…",
|
||||
"next": "Next",
|
||||
"previous": "Previous"
|
||||
},
|
||||
"frontend": {
|
||||
"add_email_form": {
|
||||
"email_denied_alert": {
|
||||
"text": "The entered email is not allowed by the server policy.",
|
||||
"title": "Email denied by policy"
|
||||
},
|
||||
"email_exists_alert": {
|
||||
"text": "The entered email is already added to this account",
|
||||
"title": "Email already exists"
|
||||
},
|
||||
"email_field_label": "Add email",
|
||||
"email_invalid_alert": {
|
||||
"text": "The entered email is invalid",
|
||||
"title": "Invalid email"
|
||||
}
|
||||
},
|
||||
"app_sessions_list": {
|
||||
"error": "Failed to load app sessions",
|
||||
"heading": "Apps"
|
||||
},
|
||||
"browser_session_details": {
|
||||
"current_badge": "Current",
|
||||
"session_details_title": "Session"
|
||||
},
|
||||
"browser_sessions_overview": {
|
||||
"body:one": "{{count}} active session",
|
||||
"body:other": "{{count}} active sessions",
|
||||
"heading": "Browsers",
|
||||
"view_all_button": "View all"
|
||||
},
|
||||
"compat_session_detail": {
|
||||
"client_details_title": "Client",
|
||||
"name": "Name",
|
||||
"session_details_title": "Session"
|
||||
},
|
||||
"device_type_icon_label": {
|
||||
"desktop": "Desktop",
|
||||
"mobile": "Mobile",
|
||||
"unknown": "Unknown device type",
|
||||
"web": "Web"
|
||||
},
|
||||
"end_session_button": {
|
||||
"confirmation_modal_title": "Are you sure you want to end this session?",
|
||||
"text": "End session"
|
||||
},
|
||||
"error_boundary_title": "Something went wrong",
|
||||
"last_active": {
|
||||
"active_date": "Active {{relativeDate}}",
|
||||
"active_now": "Active now",
|
||||
"inactive_90_days": "Inactive for 90+ days"
|
||||
},
|
||||
"nav": {
|
||||
"profile": "Profile",
|
||||
"sessions": "Sessions"
|
||||
},
|
||||
"not_found_alert_title": "Not found.",
|
||||
"not_logged_in_alert": "You're not logged in.",
|
||||
"oauth2_client_detail": {
|
||||
"details_title": "Client",
|
||||
"id": "Client ID",
|
||||
"name": "Name",
|
||||
"policy": "Policy",
|
||||
"terms": "Terms of service"
|
||||
},
|
||||
"oauth2_session_detail": {
|
||||
"client_details_name": "Name",
|
||||
"client_title": "Client",
|
||||
"session_details_title": "Session"
|
||||
},
|
||||
"pagination_controls": {
|
||||
"total": "Total: {{totalCount}}"
|
||||
},
|
||||
"selectable_session": {
|
||||
"label": "Select session"
|
||||
},
|
||||
"session": {
|
||||
"current_badge": "Current",
|
||||
"finished_date": "Finished <1/>",
|
||||
"signed_in_date": "Signed in <1/>"
|
||||
},
|
||||
"session_detail": {
|
||||
"alert": {
|
||||
"button": "Go back",
|
||||
"text": "This session does not exist, or is no longer active.",
|
||||
"title": "Cannot find session: {{deviceId}}"
|
||||
}
|
||||
},
|
||||
"unknown_route": "Unknown route {{route}}",
|
||||
"unverified_email_alert": {
|
||||
"button": "Review and verify",
|
||||
"text:one": "You have {{count}} unverified email address.",
|
||||
"text:other": "You have {{count}} unverified email addresses.",
|
||||
"title": "Unverified email"
|
||||
},
|
||||
"user_email": {
|
||||
"delete_button_confirmation_modal": {
|
||||
"body": "Are you sure you want to remove this email?"
|
||||
},
|
||||
"delete_button_title": "Remove email address",
|
||||
"email": "Email",
|
||||
"make_primary_button": "Make primary",
|
||||
"primary_email": "Primary email",
|
||||
"retry_button": "Retry verification",
|
||||
"unverified": "Unverified"
|
||||
},
|
||||
"user_email_list": {
|
||||
"heading": "Emails",
|
||||
"no_primary_email_alert": "No primary email address"
|
||||
},
|
||||
"user_greeting": {
|
||||
"error": "Failed to load user"
|
||||
},
|
||||
"user_name": {
|
||||
"display_name_field_label": "Display Name"
|
||||
},
|
||||
"user_sessions_overview": {
|
||||
"heading": "Where you're signed in"
|
||||
},
|
||||
"verify_email": {
|
||||
"code_field_label": "6-digit code",
|
||||
"enter_code_prompt": "Enter the 6-digit code sent to <1>{{email}</1>",
|
||||
"heading": "Verify your email",
|
||||
"invalid_code_alert": "Invalid code",
|
||||
"unknown_email": "Unknown email"
|
||||
}
|
||||
}
|
||||
}
|
||||
1471
frontend/package-lock.json
generated
1471
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,13 +6,14 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"generate": "graphql-codegen && eslint --fix .",
|
||||
"lint": "graphql-codegen && eslint . && tsc",
|
||||
"lint": "graphql-codegen && eslint . && tsc && i18next --fail-on-warnings --fail-on-update",
|
||||
"build": "rimraf ./dist/ && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest run --coverage",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
"build-storybook": "storybook build",
|
||||
"i18n": "i18next"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
@@ -29,12 +30,16 @@
|
||||
"classnames": "^2.3.2",
|
||||
"date-fns": "^2.30.0",
|
||||
"graphql": "^16.8.1",
|
||||
"i18next": "^23.5.1",
|
||||
"i18next-browser-languagedetector": "^7.1.0",
|
||||
"i18next-http-backend": "^2.2.2",
|
||||
"jotai": "^2.4.3",
|
||||
"jotai-devtools": "^0.6.3",
|
||||
"jotai-location": "^0.5.1",
|
||||
"jotai-urql": "^0.7.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^13.3.0",
|
||||
"ua-parser-js": "^1.0.36"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -61,6 +66,7 @@
|
||||
"eslint-plugin-matrix-org": "^1.2.1",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"happy-dom": "^12.9.1",
|
||||
"i18next-parser": "^8.9.0",
|
||||
"postcss": "^8.4.31",
|
||||
"prettier": "3.0.3",
|
||||
"react-test-renderer": "^18.2.0",
|
||||
|
||||
27
frontend/src/@types/i18next.d.ts
vendored
Normal file
27
frontend/src/@types/i18next.d.ts
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import "i18next";
|
||||
import type frontend from "../../public/locales/en.json";
|
||||
|
||||
declare module "i18next" {
|
||||
interface CustomTypeOptions {
|
||||
keySeparator: ".";
|
||||
pluralSeparator: ":";
|
||||
defaultNS: "frontend";
|
||||
resources: {
|
||||
frontend: typeof frontend;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
import { H3 } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FragmentType, useFragment } from "../../gql";
|
||||
import { graphql } from "../../gql/gql";
|
||||
@@ -52,16 +53,20 @@ const FriendlyExternalLink: React.FC<{ uri?: string }> = ({ uri }) => {
|
||||
|
||||
const OAuth2ClientDetail: React.FC<Props> = ({ client }) => {
|
||||
const data = useFragment(OAUTH2_CLIENT_FRAGMENT, client);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const details = [
|
||||
{ label: "Name", value: data.clientName },
|
||||
{ label: "Client ID", value: <code>{data.clientId}</code> },
|
||||
{ label: t("frontend.oauth2_client_detail.name"), value: data.clientName },
|
||||
{
|
||||
label: "Terms of service",
|
||||
label: t("frontend.oauth2_client_detail.id"),
|
||||
value: <code>{data.clientId}</code>,
|
||||
},
|
||||
{
|
||||
label: t("frontend.oauth2_client_detail.terms"),
|
||||
value: data.tosUri && <FriendlyExternalLink uri={data.tosUri} />,
|
||||
},
|
||||
{
|
||||
label: "Policy",
|
||||
label: t("frontend.oauth2_client_detail.policy"),
|
||||
value: data.policyUri && <FriendlyExternalLink uri={data.policyUri} />,
|
||||
},
|
||||
].filter(({ value }) => !!value);
|
||||
@@ -76,7 +81,10 @@ const OAuth2ClientDetail: React.FC<Props> = ({ client }) => {
|
||||
/>
|
||||
<H3>{data.clientName}</H3>
|
||||
</header>
|
||||
<SessionDetails title="Client" details={details} />
|
||||
<SessionDetails
|
||||
title={t("frontend.oauth2_client_detail.details_title")}
|
||||
details={details}
|
||||
/>
|
||||
</BlockList>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import classNames from "classnames";
|
||||
import { ReactNode } from "react";
|
||||
import { Translation } from "react-i18next";
|
||||
|
||||
import styles from "./ConfirmationModal.module.css";
|
||||
|
||||
@@ -51,40 +52,44 @@ const ConfirmationModal: React.FC<React.PropsWithChildren<Props>> = ({
|
||||
trigger,
|
||||
title,
|
||||
}) => (
|
||||
<Root>
|
||||
<Trigger asChild>{trigger}</Trigger>
|
||||
<Portal>
|
||||
<Overlay className={styles.overlay} />
|
||||
<Content
|
||||
className={classNames(styles.content, className)}
|
||||
onEscapeKeyDown={(event): void => {
|
||||
if (onDeny) {
|
||||
onDeny();
|
||||
} else {
|
||||
// if there is no deny callback, we should prevent the escape key from closing the modal
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Title>{title}</Title>
|
||||
<Description>{children}</Description>
|
||||
<div className={styles.buttons}>
|
||||
{onDeny && (
|
||||
<Cancel asChild>
|
||||
<Button kind="tertiary" size="sm" onClick={onDeny}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Cancel>
|
||||
)}
|
||||
<Action asChild>
|
||||
<Button kind="destructive" size="sm" onClick={onConfirm}>
|
||||
Continue
|
||||
</Button>
|
||||
</Action>
|
||||
</div>
|
||||
</Content>
|
||||
</Portal>
|
||||
</Root>
|
||||
<Translation>
|
||||
{(t): ReactNode => (
|
||||
<Root>
|
||||
<Trigger asChild>{trigger}</Trigger>
|
||||
<Portal>
|
||||
<Overlay className={styles.overlay} />
|
||||
<Content
|
||||
className={classNames(styles.content, className)}
|
||||
onEscapeKeyDown={(event): void => {
|
||||
if (onDeny) {
|
||||
onDeny();
|
||||
} else {
|
||||
// if there is no deny callback, we should prevent the escape key from closing the modal
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Title>{title}</Title>
|
||||
<Description>{children}</Description>
|
||||
<div className={styles.buttons}>
|
||||
{onDeny && (
|
||||
<Cancel asChild>
|
||||
<Button kind="tertiary" size="sm" onClick={onDeny}>
|
||||
{t("action.cancel")}
|
||||
</Button>
|
||||
</Cancel>
|
||||
)}
|
||||
<Action asChild>
|
||||
<Button kind="destructive" size="sm" onClick={onConfirm}>
|
||||
{t("action.continue")}
|
||||
</Button>
|
||||
</Action>
|
||||
</div>
|
||||
</Content>
|
||||
</Portal>
|
||||
</Root>
|
||||
)}
|
||||
</Translation>
|
||||
);
|
||||
|
||||
export default ConfirmationModal;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { CombinedError } from "@urql/core";
|
||||
import { Alert } from "@vector-im/compound-web";
|
||||
import { ErrorInfo, ReactNode, PureComponent } from "react";
|
||||
import { Translation } from "react-i18next";
|
||||
|
||||
import GraphQLError from "./GraphQLError";
|
||||
|
||||
@@ -61,9 +62,13 @@ export default class ErrorBoundary extends PureComponent<Props, IState> {
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert type="critical" title="Something went wrong">
|
||||
{this.state.error.message}
|
||||
</Alert>
|
||||
<Translation>
|
||||
{(t): ReactNode => (
|
||||
<Alert type="critical" title={t("frontend.error_boundary_title")}>
|
||||
{this.state.error!.message}
|
||||
</Alert>
|
||||
)}
|
||||
</Translation>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { currentUserIdAtom } from "../atoms";
|
||||
import { isErr, unwrapErr, unwrapOk } from "../result";
|
||||
@@ -28,6 +29,8 @@ import UserGreeting from "./UserGreeting";
|
||||
const Layout: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
|
||||
const route = useAtomValue(routeAtom);
|
||||
const result = useAtomValue(currentUserIdAtom);
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isErr(result)) return <GraphQLError error={unwrapErr(result)} />;
|
||||
|
||||
// Hide the nav bar & user greeting on the verify-email page
|
||||
@@ -48,8 +51,12 @@ const Layout: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
|
||||
<UserGreeting userId={userId} />
|
||||
|
||||
<NavBar>
|
||||
<NavItem route={{ type: "profile" }}>Profile</NavItem>
|
||||
<NavItem route={{ type: "sessions-overview" }}>Sessions</NavItem>
|
||||
<NavItem route={{ type: "profile" }}>
|
||||
{t("frontend.nav.profile")}
|
||||
</NavItem>
|
||||
<NavItem route={{ type: "sessions-overview" }}>
|
||||
{t("frontend.nav.sessions")}
|
||||
</NavItem>
|
||||
</NavBar>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -21,7 +21,7 @@ exports[`LoadingScreen > render <LoadingScreen /> 1`] = `
|
||||
<span
|
||||
className="sr-only"
|
||||
>
|
||||
Loading...
|
||||
Loading…
|
||||
</span>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { Translation } from "react-i18next";
|
||||
|
||||
import styles from "./LoadingSpinner.module.css";
|
||||
|
||||
const LoadingSpinner: React.FC<{ inline?: boolean }> = ({ inline }) => (
|
||||
@@ -27,7 +30,9 @@ const LoadingSpinner: React.FC<{ inline?: boolean }> = ({ inline }) => (
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Loading...</span>
|
||||
<span className="sr-only">
|
||||
<Translation>{(t): ReactNode => t("common.loading")}</Translation>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -13,7 +13,15 @@
|
||||
// limitations under the License.
|
||||
|
||||
import { Alert } from "@vector-im/compound-web";
|
||||
import { ReactNode } from "react";
|
||||
import { Translation } from "react-i18next";
|
||||
|
||||
const NotFound: React.FC = () => <Alert type="critical" title="Not found." />;
|
||||
const NotFound: React.FC = () => (
|
||||
<Translation>
|
||||
{(t): ReactNode => (
|
||||
<Alert type="critical" title={t("frontend.not_found_alert_title")} />
|
||||
)}
|
||||
</Translation>
|
||||
);
|
||||
|
||||
export default NotFound;
|
||||
|
||||
@@ -13,9 +13,15 @@
|
||||
// limitations under the License.
|
||||
|
||||
import { Alert } from "@vector-im/compound-web";
|
||||
import { ReactNode } from "react";
|
||||
import { Translation } from "react-i18next";
|
||||
|
||||
const NotLoggedIn: React.FC = () => (
|
||||
<Alert type="critical" title="You're not logged in." />
|
||||
<Translation>
|
||||
{(t): ReactNode => (
|
||||
<Alert type="critical" title={t("frontend.not_logged_in_alert")} />
|
||||
)}
|
||||
</Translation>
|
||||
);
|
||||
|
||||
export default NotLoggedIn;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Props = {
|
||||
onNext: (() => void) | null;
|
||||
@@ -30,6 +31,8 @@ const PaginationControls: React.FC<Props> = ({
|
||||
count,
|
||||
disabled,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (autoHide && !onNext && !onPrev) {
|
||||
return null;
|
||||
}
|
||||
@@ -41,10 +44,12 @@ const PaginationControls: React.FC<Props> = ({
|
||||
disabled={disabled || !onPrev}
|
||||
onClick={(): void => onPrev?.()}
|
||||
>
|
||||
Previous
|
||||
{t("common.previous")}
|
||||
</Button>
|
||||
<div className="text-center">
|
||||
{count !== undefined ? <>Total: {count}</> : null}
|
||||
{count !== undefined ? (
|
||||
<>{t("frontend.pagination_controls.total", { totalCount: count })}</>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
kind="secondary"
|
||||
@@ -52,7 +57,7 @@ const PaginationControls: React.FC<Props> = ({
|
||||
disabled={disabled || !onNext}
|
||||
onClick={(): void => onNext?.()}
|
||||
>
|
||||
Next
|
||||
{t("common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import IconMobile from "@vector-im/compound-design-tokens/icons/mobile.svg?react
|
||||
import IconUnknown from "@vector-im/compound-design-tokens/icons/unknown.svg?react";
|
||||
import IconBrowser from "@vector-im/compound-design-tokens/icons/web-browser.svg?react";
|
||||
import { FunctionComponent, SVGProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DeviceType } from "../../utils/parseUserAgent";
|
||||
|
||||
@@ -32,17 +33,20 @@ const deviceTypeToIcon: Record<
|
||||
[DeviceType.Web]: IconBrowser,
|
||||
};
|
||||
|
||||
const deviceTypeToLabel: Record<DeviceType, string> = {
|
||||
[DeviceType.Unknown]: "Unknown device type",
|
||||
[DeviceType.Desktop]: "Desktop",
|
||||
[DeviceType.Mobile]: "Mobile",
|
||||
[DeviceType.Web]: "Web",
|
||||
};
|
||||
|
||||
const DeviceTypeIcon: React.FC<{ deviceType: DeviceType }> = ({
|
||||
deviceType,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const Icon = deviceTypeToIcon[deviceType];
|
||||
|
||||
const deviceTypeToLabel: Record<DeviceType, string> = {
|
||||
[DeviceType.Unknown]: t("frontend.device_type_icon_label.unknown"),
|
||||
[DeviceType.Desktop]: t("frontend.device_type_icon_label.desktop"),
|
||||
[DeviceType.Mobile]: t("frontend.device_type_icon_label.mobile"),
|
||||
[DeviceType.Web]: t("frontend.device_type_icon_label.web"),
|
||||
};
|
||||
|
||||
const label = deviceTypeToLabel[deviceType];
|
||||
|
||||
return <Icon className={styles.icon} aria-label={label} />;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import ConfirmationModal from "../ConfirmationModal/ConfirmationModal";
|
||||
import LoadingSpinner from "../LoadingSpinner/LoadingSpinner";
|
||||
@@ -26,6 +27,7 @@ const EndSessionButton: React.FC<{ endSession: () => Promise<void> }> = ({
|
||||
endSession,
|
||||
}) => {
|
||||
const [inProgress, setInProgress] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onConfirm = async (): Promise<void> => {
|
||||
setInProgress(true);
|
||||
@@ -45,10 +47,11 @@ const EndSessionButton: React.FC<{ endSession: () => Promise<void> }> = ({
|
||||
<ConfirmationModal
|
||||
onDeny={onDeny}
|
||||
onConfirm={onConfirm}
|
||||
title="Are you sure you want to end this session?"
|
||||
title={t("frontend.end_session_button.confirmation_modal_title")}
|
||||
trigger={
|
||||
<Button kind="destructive" size="sm" disabled={inProgress}>
|
||||
{inProgress && <LoadingSpinner inline />}End session
|
||||
{inProgress && <LoadingSpinner inline />}
|
||||
{t("frontend.end_session_button.text")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
import { differenceInSeconds, parseISO } from "date-fns";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatDate, formatReadableDate } from "../DateTime";
|
||||
|
||||
@@ -27,6 +28,8 @@ const LastActive: React.FC<{
|
||||
lastActive: Date | string;
|
||||
now?: Date | string;
|
||||
}> = ({ lastActive: lastActiveProps, now: nowProps }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const lastActive =
|
||||
typeof lastActiveProps === "string"
|
||||
? parseISO(lastActiveProps)
|
||||
@@ -42,15 +45,23 @@ const LastActive: React.FC<{
|
||||
if (differenceInSeconds(now, lastActive) <= ACTIVE_NOW_MAX_AGE) {
|
||||
return (
|
||||
<span title={formattedDate} className={styles.active}>
|
||||
Active now
|
||||
{t("frontend.last_active.active_now")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (differenceInSeconds(now, lastActive) > INACTIVE_MIN_AGE) {
|
||||
return <span title={formattedDate}>Inactive for 90+ days</span>;
|
||||
return (
|
||||
<span title={formattedDate}>
|
||||
{t("frontend.last_active.inactive_90_days")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const relativeDate = formatReadableDate(lastActive, now);
|
||||
return <span title={formattedDate}>{`Active ${relativeDate}`}</span>;
|
||||
return (
|
||||
<span title={formattedDate}>
|
||||
{t("frontend.last_active.active_date", { relativeDate })}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default LastActive;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
import { Checkbox } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import styles from "./SelectableSession.module.css";
|
||||
|
||||
@@ -29,13 +30,14 @@ const SelectableSession: React.FC<React.PropsWithChildren<Props>> = ({
|
||||
onSelect,
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={styles.selectableSession}>
|
||||
<Checkbox
|
||||
className={styles.checkbox}
|
||||
kind="primary"
|
||||
onChange={onSelect}
|
||||
aria-label="Select session"
|
||||
aria-label={t("frontend.selectable_session.label")}
|
||||
checked={isSelected}
|
||||
/>
|
||||
{children}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import { H6, Body, Badge } from "@vector-im/compound-web";
|
||||
import { ReactNode } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { DeviceType } from "../../utils/parseUserAgent";
|
||||
import Block from "../Block";
|
||||
@@ -53,20 +54,28 @@ const Session: React.FC<React.PropsWithChildren<SessionProps>> = ({
|
||||
children,
|
||||
deviceType,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Block className={styles.session}>
|
||||
<DeviceTypeIcon deviceType={deviceType || DeviceType.Unknown} />
|
||||
<div className={styles.container}>
|
||||
{isCurrent && <Badge kind="success">Current</Badge>}
|
||||
{isCurrent && (
|
||||
<Badge kind="success">{t("frontend.session.current_badge")}</Badge>
|
||||
)}
|
||||
<H6 className={styles.sessionName} title={id}>
|
||||
{name || id}
|
||||
</H6>
|
||||
<SessionMetadata weight="semibold">
|
||||
Signed in <DateTime datetime={createdAt} />
|
||||
<Trans i18nKey="frontend.session.signed_in_date">
|
||||
Signed in <DateTime datetime={createdAt} />
|
||||
</Trans>
|
||||
</SessionMetadata>
|
||||
{!!finishedAt && (
|
||||
<SessionMetadata weight="semibold" data-finished={true}>
|
||||
Finished <DateTime datetime={finishedAt} />
|
||||
<Trans i18nKey="frontend.session.finished_date">
|
||||
Finished <DateTime datetime={finishedAt} />
|
||||
</Trans>
|
||||
</SessionMetadata>
|
||||
)}
|
||||
{!!lastActiveAt && (
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import { Badge } from "@vector-im/compound-web";
|
||||
import { parseISO } from "date-fns";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FragmentType, graphql, useFragment } from "../../gql";
|
||||
import {
|
||||
@@ -57,6 +58,7 @@ type Props = {
|
||||
const BrowserSessionDetail: React.FC<Props> = ({ session }) => {
|
||||
const data = useFragment(FRAGMENT, session);
|
||||
const currentBrowserSessionId = useCurrentBrowserSessionId();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isCurrent = currentBrowserSessionId === data.id;
|
||||
const onSessionEnd = useEndBrowserSession(data.id, isCurrent);
|
||||
@@ -113,13 +115,16 @@ const BrowserSessionDetail: React.FC<Props> = ({ session }) => {
|
||||
<BlockList>
|
||||
{isCurrent && (
|
||||
<Badge className={styles.currentBadge} kind="success">
|
||||
Current
|
||||
{t("frontend.browser_session_details.current_badge")}
|
||||
</Badge>
|
||||
)}
|
||||
<SessionHeader backToRoute={{ type: "browser-session-list" }}>
|
||||
{sessionName}
|
||||
</SessionHeader>
|
||||
<SessionDetails title="Session" details={sessionDetails} />
|
||||
<SessionDetails
|
||||
title={t("frontend.browser_session_details.session_details_title")}
|
||||
details={sessionDetails}
|
||||
/>
|
||||
{!data.finishedAt && <EndSessionButton endSession={onSessionEnd} />}
|
||||
</BlockList>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import { parseISO } from "date-fns";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FragmentType, graphql, useFragment } from "../../gql";
|
||||
import BlockList from "../BlockList/BlockList";
|
||||
@@ -48,6 +49,7 @@ type Props = {
|
||||
const CompatSessionDetail: React.FC<Props> = ({ session }) => {
|
||||
const data = useFragment(FRAGMENT, session);
|
||||
const endSession = useSetAtom(endCompatSessionFamily(data.id));
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onSessionEnd = async (): Promise<void> => {
|
||||
await endSession();
|
||||
@@ -91,7 +93,7 @@ const CompatSessionDetail: React.FC<Props> = ({ session }) => {
|
||||
|
||||
if (data.ssoLogin?.redirectUri) {
|
||||
clientDetails.push({
|
||||
label: "Name",
|
||||
label: t("frontend.compat_session_detail.name"),
|
||||
value: simplifyUrl(data.ssoLogin.redirectUri),
|
||||
});
|
||||
clientDetails.push({
|
||||
@@ -113,9 +115,15 @@ const CompatSessionDetail: React.FC<Props> = ({ session }) => {
|
||||
>
|
||||
{data.deviceId || data.id}
|
||||
</SessionHeader>
|
||||
<SessionDetails title="Session" details={sessionDetails} />
|
||||
<SessionDetails
|
||||
title={t("frontend.compat_session_detail.session_details_title")}
|
||||
details={sessionDetails}
|
||||
/>
|
||||
{clientDetails.length > 0 ? (
|
||||
<SessionDetails title="Client" details={clientDetails} />
|
||||
<SessionDetails
|
||||
title={t("frontend.compat_session_detail.client_details_title")}
|
||||
details={clientDetails}
|
||||
/>
|
||||
) : null}
|
||||
{!data.finishedAt && <EndSessionButton endSession={onSessionEnd} />}
|
||||
</BlockList>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import { parseISO } from "date-fns";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FragmentType, graphql, useFragment } from "../../gql";
|
||||
import { Link } from "../../routing";
|
||||
@@ -53,6 +54,7 @@ type Props = {
|
||||
const OAuth2SessionDetail: React.FC<Props> = ({ session }) => {
|
||||
const data = useFragment(FRAGMENT, session);
|
||||
const endSession = useSetAtom(endSessionFamily(data.id));
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onSessionEnd = async (): Promise<void> => {
|
||||
await endSession();
|
||||
@@ -104,11 +106,13 @@ const OAuth2SessionDetail: React.FC<Props> = ({ session }) => {
|
||||
];
|
||||
|
||||
const clientTitle = (
|
||||
<Link route={{ type: "client", id: data.client.id }}>Client</Link>
|
||||
<Link route={{ type: "client", id: data.client.id }}>
|
||||
{t("frontend.oauth2_session_detail.client_title")}
|
||||
</Link>
|
||||
);
|
||||
const clientDetails = [
|
||||
{
|
||||
label: "Name",
|
||||
label: t("frontend.oauth2_session_detail.client_details_name"),
|
||||
value: (
|
||||
<>
|
||||
<ClientAvatar
|
||||
@@ -140,7 +144,10 @@ const OAuth2SessionDetail: React.FC<Props> = ({ session }) => {
|
||||
>
|
||||
{deviceId || data.id}
|
||||
</SessionHeader>
|
||||
<SessionDetails title="Session" details={sessionDetails} />
|
||||
<SessionDetails
|
||||
title={t("frontend.oauth2_session_detail.session_details_title")}
|
||||
details={sessionDetails}
|
||||
/>
|
||||
<SessionDetails title={clientTitle} details={clientDetails} />
|
||||
{!data.finishedAt && <EndSessionButton endSession={onSessionEnd} />}
|
||||
</BlockList>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useAtomValue } from "jotai";
|
||||
import { atomFamily } from "jotai/utils";
|
||||
import { atomWithQuery } from "jotai-urql";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { graphql } from "../../gql";
|
||||
import { Link } from "../../routing";
|
||||
@@ -59,15 +60,19 @@ const SessionDetail: React.FC<{
|
||||
[deviceId, userId],
|
||||
);
|
||||
const result = useAtomValue(sessionFamilyAtomWithProps);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const session = result.data?.session;
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<Alert type="critical" title={`Cannot find session: ${deviceId}`}>
|
||||
This session does not exist, or is no longer active.
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.session_detail.alert.title", { deviceId })}
|
||||
>
|
||||
{t("frontend.session_detail.alert.text")}
|
||||
<Link kind="button" route={{ type: "sessions-overview" }}>
|
||||
Go back
|
||||
{t("frontend.session_detail.alert.button")}
|
||||
</Link>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import { Alert } from "@vector-im/compound-web";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FragmentType, useFragment, graphql } from "../../gql";
|
||||
import { Link } from "../../routing";
|
||||
@@ -34,6 +35,7 @@ const UnverifiedEmailAlert: React.FC<{
|
||||
}> = ({ user }) => {
|
||||
const data = useFragment(UNVERIFIED_EMAILS_FRAGMENT, user);
|
||||
const [dismiss, setDismiss] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const doDismiss = (): void => setDismiss(true);
|
||||
|
||||
@@ -48,13 +50,15 @@ const UnverifiedEmailAlert: React.FC<{
|
||||
return (
|
||||
<Alert
|
||||
type="critical"
|
||||
title="Unverified email"
|
||||
title={t("frontend.unverified_email_alert.title")}
|
||||
onClose={doDismiss}
|
||||
className={styles.alert}
|
||||
>
|
||||
You have {data.unverifiedEmails.totalCount} unverified email address(es).{" "}
|
||||
{t("frontend.unverified_email_alert.text", {
|
||||
count: data.unverifiedEmails.totalCount,
|
||||
})}{" "}
|
||||
<Link kind="button" route={{ type: "profile" }}>
|
||||
Review and verify
|
||||
{t("frontend.unverified_email_alert.button")}
|
||||
</Link>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -29,9 +29,7 @@ exports[`<UnverifiedEmailAlert /> > renders a warning when there are unverified
|
||||
Unverified email
|
||||
</p>
|
||||
<p>
|
||||
You have
|
||||
2
|
||||
unverified email address(es).
|
||||
You have 2 unverified email addresses.
|
||||
|
||||
<a
|
||||
class="_linkButton_b80ad8"
|
||||
|
||||
@@ -17,7 +17,8 @@ import { Body } from "@vector-im/compound-web";
|
||||
import { atom, useSetAtom } from "jotai";
|
||||
import { atomFamily } from "jotai/utils";
|
||||
import { atomWithMutation } from "jotai-urql";
|
||||
import { useTransition, ComponentProps } from "react";
|
||||
import { useTransition, ComponentProps, ReactNode } from "react";
|
||||
import { Translation, useTranslation } from "react-i18next";
|
||||
|
||||
import { FragmentType, graphql, useFragment } from "../../gql";
|
||||
import { Link } from "../../routing";
|
||||
@@ -90,19 +91,24 @@ const DeleteButton: React.FC<{ disabled?: boolean; onClick?: () => void }> = ({
|
||||
disabled,
|
||||
onClick,
|
||||
}) => (
|
||||
<button
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={styles.userEmailDelete}
|
||||
title="Remove email address"
|
||||
>
|
||||
<IconDelete className={styles.userEmailDeleteIcon} />
|
||||
</button>
|
||||
<Translation>
|
||||
{(t): ReactNode => (
|
||||
<button
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={styles.userEmailDelete}
|
||||
title={t("frontend.user_email.delete_button_title")}
|
||||
>
|
||||
<IconDelete className={styles.userEmailDeleteIcon} />
|
||||
</button>
|
||||
)}
|
||||
</Translation>
|
||||
);
|
||||
|
||||
const DeleteButtonWithConfirmation: React.FC<
|
||||
ComponentProps<typeof DeleteButton>
|
||||
> = ({ onClick, ...rest }) => {
|
||||
const { t } = useTranslation();
|
||||
const onConfirm = (): void => {
|
||||
onClick?.();
|
||||
};
|
||||
@@ -117,7 +123,9 @@ const DeleteButtonWithConfirmation: React.FC<
|
||||
onDeny={onDeny}
|
||||
onConfirm={onConfirm}
|
||||
>
|
||||
<Body>Are you sure you want to remove this email?</Body>
|
||||
<Body>
|
||||
{t("frontend.user_email.delete_button_confirmation_modal.body")}
|
||||
</Body>
|
||||
</ConfirmationModal>
|
||||
</>
|
||||
);
|
||||
@@ -134,6 +142,7 @@ const UserEmail: React.FC<{
|
||||
const data = useFragment(FRAGMENT, email);
|
||||
const setPrimaryEmail = useSetAtom(setPrimaryEmailFamily(data.id));
|
||||
const removeEmail = useSetAtom(removeEmailFamily(data.id));
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onRemoveClick = (): void => {
|
||||
startTransition(() => {
|
||||
@@ -155,7 +164,11 @@ const UserEmail: React.FC<{
|
||||
|
||||
return (
|
||||
<div className={styles.userEmail}>
|
||||
{isPrimary ? <Body>Primary email</Body> : <Body>Email</Body>}
|
||||
{isPrimary ? (
|
||||
<Body>{t("frontend.user_email.primary_email")}</Body>
|
||||
) : (
|
||||
<Body>{t("frontend.user_email.email")}</Body>
|
||||
)}
|
||||
|
||||
<div className={styles.userEmailLine}>
|
||||
<div className={styles.userEmailField}>{data.email}</div>
|
||||
@@ -170,14 +183,17 @@ const UserEmail: React.FC<{
|
||||
disabled={pending}
|
||||
onClick={onSetPrimaryClick}
|
||||
>
|
||||
Make primary
|
||||
{t("frontend.user_email.make_primary_button")}
|
||||
</button>
|
||||
)}
|
||||
{!data.confirmedAt && (
|
||||
<div>
|
||||
<span className={styles.userEmailUnverified}>Unverified</span> |{" "}
|
||||
<span className={styles.userEmailUnverified}>
|
||||
{t("frontend.user_email.unverified")}
|
||||
</span>{" "}
|
||||
|{" "}
|
||||
<Link kind="button" route={{ type: "verify-email", id: data.id }}>
|
||||
Retry verification
|
||||
{t("frontend.user_email.retry_button")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Heading, Body, Avatar } from "@vector-im/compound-web";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { atomFamily } from "jotai/utils";
|
||||
import { atomWithQuery } from "jotai-urql";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { graphql } from "../gql";
|
||||
|
||||
@@ -48,6 +49,7 @@ export const userGreetingFamily = atomFamily((userId: string) => {
|
||||
|
||||
const UserGreeting: React.FC<{ userId: string }> = ({ userId }) => {
|
||||
const result = useAtomValue(userGreetingFamily(userId));
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (result.data?.user) {
|
||||
const user = result.data.user;
|
||||
@@ -71,7 +73,7 @@ const UserGreeting: React.FC<{ userId: string }> = ({ userId }) => {
|
||||
);
|
||||
}
|
||||
|
||||
return <>Failed to load user</>;
|
||||
return <>{t("frontend.user_greeting.error")}</>;
|
||||
};
|
||||
|
||||
export default UserGreeting;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { useAtom } from "jotai";
|
||||
import { atomWithMutation } from "jotai-urql";
|
||||
import { useRef, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { graphql } from "../../gql";
|
||||
|
||||
@@ -49,6 +50,7 @@ const AddEmailForm: React.FC<{
|
||||
const fieldRef = useRef<HTMLInputElement>(null);
|
||||
const [addEmailResult, addEmail] = useAtom(addUserEmailAtom);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
|
||||
e.preventDefault();
|
||||
@@ -87,20 +89,29 @@ const AddEmailForm: React.FC<{
|
||||
<>
|
||||
<Root ref={formRef} onSubmit={handleSubmit}>
|
||||
{emailExists && (
|
||||
<Alert type="info" title="Email already exists">
|
||||
The entered email is already added to this account
|
||||
<Alert
|
||||
type="info"
|
||||
title={t("frontend.add_email_form.email_exists_alert.title")}
|
||||
>
|
||||
{t("frontend.add_email_form.email_exists_alert.text")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{emailInvalid && (
|
||||
<Alert type="critical" title="Invalid email">
|
||||
The entered email is invalid
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.add_email_form.email_invalid_alert.title")}
|
||||
>
|
||||
{t("frontend.add_email_form.email_invalid_alert.text")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{emailDenied && (
|
||||
<Alert type="critical" title="Email denied by policy">
|
||||
The entered email is not allowed by the server policy.
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.add_email_form.email_denied_alert.title")}
|
||||
>
|
||||
{t("frontend.add_email_form.email_denied_alert.text")}
|
||||
<ul>
|
||||
{violations.map((violation, index) => (
|
||||
<li key={index}>• {violation}</li>
|
||||
@@ -110,11 +121,11 @@ const AddEmailForm: React.FC<{
|
||||
)}
|
||||
|
||||
<Field name="email" className="my-2">
|
||||
<Label>Add email</Label>
|
||||
<Label>{t("frontend.add_email_form.email_field_label")}</Label>
|
||||
<Control disabled={pending} inputMode="email" ref={fieldRef} />
|
||||
</Field>
|
||||
<Submit size="sm" disabled={pending}>
|
||||
Add
|
||||
{t("common.add")}
|
||||
</Submit>
|
||||
</Root>
|
||||
</>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { atomFamily } from "jotai/utils";
|
||||
import { atomWithQuery } from "jotai-urql";
|
||||
import { useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { graphql } from "../../gql";
|
||||
import { PageInfo } from "../../gql/graphql";
|
||||
@@ -135,6 +136,7 @@ const UserEmailList: React.FC<{
|
||||
const [primaryEmailId, refreshPrimaryEmailId] = useAtom(
|
||||
primaryEmailIdFamily(userId),
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const paginate = (pagination: Pagination): void => {
|
||||
startTransition(() => {
|
||||
@@ -159,9 +161,12 @@ const UserEmailList: React.FC<{
|
||||
|
||||
return (
|
||||
<BlockList>
|
||||
<H3>Emails</H3>
|
||||
<H3>{t("frontend.user_email_list.heading")}</H3>
|
||||
{showNoPrimaryEmailAlert && (
|
||||
<Alert type="critical" title="No primary email address" />
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.user_email_list.no_primary_email_alert")}
|
||||
/>
|
||||
)}
|
||||
{result.data?.user?.emails?.edges?.map((edge) => (
|
||||
<UserEmail
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useAtomValue, useAtom, useSetAtom, atom } from "jotai";
|
||||
import { atomFamily } from "jotai/utils";
|
||||
import { atomWithMutation } from "jotai-urql";
|
||||
import { useState, useEffect, ChangeEventHandler } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { graphql } from "../../gql";
|
||||
import LoadingSpinner from "../LoadingSpinner/LoadingSpinner";
|
||||
@@ -83,6 +84,7 @@ const UserName: React.FC<{ userId: string }> = ({ userId }) => {
|
||||
const [fieldValue, setFieldValue] = useState(displayName);
|
||||
|
||||
const userGreeting = useSetAtom(userGreetingFamily(userId));
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
setFieldValue(displayName);
|
||||
@@ -131,7 +133,7 @@ const UserName: React.FC<{ userId: string }> = ({ userId }) => {
|
||||
return (
|
||||
<Root onSubmit={onSubmit} className={styles.form}>
|
||||
<Field name="displayname">
|
||||
<Label>Display Name</Label>
|
||||
<Label>{t("frontend.user_name.display_name_field_label")}</Label>
|
||||
<Control
|
||||
value={fieldValue}
|
||||
onChange={onChange}
|
||||
@@ -140,7 +142,7 @@ const UserName: React.FC<{ userId: string }> = ({ userId }) => {
|
||||
/>
|
||||
</Field>
|
||||
{!inProgress && errorMessage && (
|
||||
<Alert type="critical" title="Error">
|
||||
<Alert type="critical" title={t("common.error")}>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
@@ -152,7 +154,8 @@ const UserName: React.FC<{ userId: string }> = ({ userId }) => {
|
||||
size="sm"
|
||||
type="submit"
|
||||
>
|
||||
{!!inProgress && <LoadingSpinner inline />}Save
|
||||
{!!inProgress && <LoadingSpinner inline />}
|
||||
{t("action.save")}
|
||||
</Button>
|
||||
</Root>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { atom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { atomFamily } from "jotai/utils";
|
||||
import { atomWithQuery } from "jotai-urql";
|
||||
import { useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { mapQueryAtom } from "../../atoms";
|
||||
import { graphql } from "../../gql";
|
||||
@@ -120,9 +121,10 @@ const AppSessionsList: React.FC<{ userId: string }> = ({ userId }) => {
|
||||
const result = useAtomValue(appSessionListFamily(userId));
|
||||
const setPagination = useSetAtom(currentPaginationAtom);
|
||||
const [prevPage, nextPage] = useAtomValue(paginationFamily(userId));
|
||||
const { t } = useTranslation();
|
||||
|
||||
const appSessions = unwrap(result);
|
||||
if (!appSessions) return <>Failed to load app sessions</>;
|
||||
if (!appSessions) return <>{t("frontend.app_sessions_list.error")}</>;
|
||||
|
||||
const paginate = (pagination: Pagination): void => {
|
||||
startTransition(() => {
|
||||
@@ -133,7 +135,7 @@ const AppSessionsList: React.FC<{ userId: string }> = ({ userId }) => {
|
||||
return (
|
||||
<BlockList>
|
||||
<header>
|
||||
<H5>Apps</H5>
|
||||
<H5>{t("frontend.app_sessions_list.heading")}</H5>
|
||||
</header>
|
||||
{appSessions.edges.map((session) => {
|
||||
const type = session.node.__typename;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
import { Body, H5 } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FragmentType, graphql, useFragment } from "../../gql";
|
||||
import { Link } from "../../routing";
|
||||
@@ -34,22 +35,20 @@ const BrowserSessionsOverview: React.FC<{
|
||||
user: FragmentType<typeof FRAGMENT>;
|
||||
}> = ({ user }) => {
|
||||
const data = useFragment(FRAGMENT, user);
|
||||
|
||||
// allow this until we get i18n
|
||||
const pluraliseSession = (count: number): string =>
|
||||
count === 1 ? "session" : "sessions";
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Block className={styles.sessionListBlock}>
|
||||
<div className={styles.sessionListBlockInfo}>
|
||||
<H5>Browsers</H5>
|
||||
<H5>{t("frontend.browser_sessions_overview.heading")}</H5>
|
||||
<Body>
|
||||
{data.browserSessions.totalCount} active{" "}
|
||||
{pluraliseSession(data.browserSessions.totalCount)}
|
||||
{t("frontend.browser_sessions_overview.body", {
|
||||
count: data.browserSessions.totalCount,
|
||||
})}
|
||||
</Body>
|
||||
</div>
|
||||
<Link kind="button" route={{ type: "browser-session-list" }}>
|
||||
View all
|
||||
{t("frontend.browser_sessions_overview.view_all_button")}
|
||||
</Link>
|
||||
</Block>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
import { H3 } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FragmentType, useFragment } from "../../gql";
|
||||
import BlockList from "../BlockList";
|
||||
@@ -24,10 +25,11 @@ const UserSessionsOverview: React.FC<{
|
||||
user: FragmentType<typeof FRAGMENT>;
|
||||
}> = ({ user }) => {
|
||||
const data = useFragment(FRAGMENT, user);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<BlockList>
|
||||
<H3>Where you're signed in</H3>
|
||||
<H3>{t("frontend.user_sessions_overview.heading")}</H3>
|
||||
<BrowserSessionsOverview user={user} />
|
||||
<AppSessionsList userId={data.id} />
|
||||
</BlockList>
|
||||
|
||||
@@ -16,10 +16,7 @@ exports[`BrowserSessionsOverview > renders with no browser sessions 1`] = `
|
||||
<p
|
||||
class="_font-body-md-regular_1jx6b_59"
|
||||
>
|
||||
0
|
||||
active
|
||||
|
||||
sessions
|
||||
0 active sessions
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
@@ -48,10 +45,7 @@ exports[`BrowserSessionsOverview > renders with sessions 1`] = `
|
||||
<p
|
||||
class="_font-body-md-regular_1jx6b_59"
|
||||
>
|
||||
2
|
||||
active
|
||||
|
||||
sessions
|
||||
2 active sessions
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useSetAtom, atom, useAtom } from "jotai";
|
||||
import { atomFamily } from "jotai/utils";
|
||||
import { atomWithMutation } from "jotai-urql";
|
||||
import { useEffect, useRef, useTransition } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { FragmentType, graphql, useFragment } from "../../gql";
|
||||
import { routeAtom, useNavigationLink } from "../../routing";
|
||||
@@ -137,6 +138,7 @@ const VerifyEmail: React.FC<{
|
||||
);
|
||||
const setRoute = useSetAtom(routeAtom);
|
||||
const fieldRef = useRef<HTMLInputElement>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onFormSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
|
||||
e.preventDefault();
|
||||
@@ -174,24 +176,32 @@ const VerifyEmail: React.FC<{
|
||||
resendVerificationEmailResult.data?.sendVerificationEmail.status === "SENT";
|
||||
const invalidCode =
|
||||
verifyEmailResult.data?.verifyEmail.status === "INVALID_CODE";
|
||||
const { email: codeEmail } = data;
|
||||
|
||||
return (
|
||||
<div className={styles.block}>
|
||||
<header className={styles.header}>
|
||||
<IconSend className={styles.icon} />
|
||||
<H1>Verify your email</H1>
|
||||
<H1>{t("frontend.verify_email.heading")}</H1>
|
||||
<Text size="lg" className={styles.tagline}>
|
||||
Enter the 6-digit code sent to{" "}
|
||||
<Text as="span" size="lg" weight="semibold">
|
||||
{data.email}
|
||||
</Text>
|
||||
<Trans i18nKey="frontend.verify_email.enter_code_prompt">
|
||||
Enter the 6-digit code sent to{" "}
|
||||
<Text as="span" size="lg" weight="semibold">
|
||||
{{ codeEmail }}
|
||||
</Text>
|
||||
</Trans>
|
||||
</Text>
|
||||
</header>
|
||||
|
||||
<Form onSubmit={onFormSubmit} className={styles.form}>
|
||||
{invalidCode && <Alert type="critical" title="Invalid code" />}
|
||||
{invalidCode && (
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.verify_email.invalid_code_alert")}
|
||||
/>
|
||||
)}
|
||||
<Field name="code" serverInvalid={invalidCode}>
|
||||
<Label>6-digit code</Label>
|
||||
<Label>{t("frontend.verify_email.code_field_label")}</Label>
|
||||
<Control
|
||||
ref={fieldRef}
|
||||
placeholder="xxxxxx"
|
||||
@@ -205,7 +215,7 @@ const VerifyEmail: React.FC<{
|
||||
disabled={pending}
|
||||
className={styles.submitButton}
|
||||
>
|
||||
Continue
|
||||
{t("action.continue")}
|
||||
</Submit>
|
||||
<Button
|
||||
kind="secondary"
|
||||
|
||||
67
frontend/src/i18n.ts
Normal file
67
frontend/src/i18n.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as i18n from "i18next";
|
||||
import { InitOptions } from "i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import I18NextHttpBackend, { HttpBackendOptions } from "i18next-http-backend";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
// This generates a map of locale names to their URL (based on import.meta.url), which looks like this:
|
||||
// {
|
||||
// "../locales/en.json": "/whatever/assets/root/locales/en-aabbcc.json",
|
||||
// ...
|
||||
// }
|
||||
const locales = import.meta.glob("../locales/*.json", {
|
||||
as: "url",
|
||||
eager: true,
|
||||
});
|
||||
|
||||
const getLocaleUrl = (name: string): string =>
|
||||
locales[`../locales/${name}.json`];
|
||||
|
||||
const supportedLngs = Object.keys(locales).map(
|
||||
(url) => url.match(/\/([^/]+)\.json$/)![1],
|
||||
);
|
||||
|
||||
i18n
|
||||
.use(I18NextHttpBackend)
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: "en",
|
||||
keySeparator: ".",
|
||||
pluralSeparator: ":",
|
||||
supportedLngs,
|
||||
interpolation: {
|
||||
escapeValue: false, // React has built-in XSS protections
|
||||
},
|
||||
backend: {
|
||||
crossDomain: true,
|
||||
loadPath(lngs: string[], _ns: string[]): string {
|
||||
return getLocaleUrl(lngs[0]);
|
||||
},
|
||||
requestOptions: {
|
||||
credentials: "same-origin",
|
||||
},
|
||||
},
|
||||
} satisfies InitOptions<HttpBackendOptions>);
|
||||
|
||||
import.meta.hot?.on("locales-update", () => {
|
||||
i18n.reloadResources().then(() => {
|
||||
i18n.changeLanguage(i18n.default.language);
|
||||
});
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -22,6 +22,7 @@ import Layout from "./components/Layout";
|
||||
import LoadingScreen from "./components/LoadingScreen";
|
||||
import LoadingSpinner from "./components/LoadingSpinner";
|
||||
import { Router } from "./routing";
|
||||
import "./i18n";
|
||||
import "./main.css";
|
||||
|
||||
createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
import { atomFamily } from "jotai/utils";
|
||||
import { atomWithQuery } from "jotai-urql";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { mapQueryAtom } from "../atoms";
|
||||
import ErrorBoundary from "../components/ErrorBoundary";
|
||||
@@ -47,10 +48,12 @@ const verifyEmailFamily = atomFamily((id: string) => {
|
||||
|
||||
const VerifyEmail: React.FC<{ id: string }> = ({ id }) => {
|
||||
const result = useAtomValue(verifyEmailFamily(id));
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isErr(result)) return <GraphQLError error={unwrapErr(result)} />;
|
||||
|
||||
const email = unwrapOk(result);
|
||||
if (email == null) return <>Unknown email</>;
|
||||
if (email == null) return <>{t("frontend.verify_email.unknown_email")}</>;
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import LoadingSpinner from "../components/LoadingSpinner";
|
||||
import BrowserSession from "../pages/BrowserSession";
|
||||
@@ -56,6 +57,7 @@ const unknownRoute = (route: never): never => {
|
||||
|
||||
const Router: React.FC = () => {
|
||||
const [route, redirecting] = useRouteWithRedirect();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (redirecting) {
|
||||
return <LoadingSpinner />;
|
||||
@@ -77,7 +79,13 @@ const Router: React.FC = () => {
|
||||
case "verify-email":
|
||||
return <VerifyEmail id={route.id} />;
|
||||
case "unknown":
|
||||
return <>Unknown route {JSON.stringify(route.segments)}</>;
|
||||
return (
|
||||
<>
|
||||
{t("frontend.unknown_route", {
|
||||
route: JSON.stringify(route.segments),
|
||||
})}
|
||||
</>
|
||||
);
|
||||
default:
|
||||
unknownRoute(route);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"DOM.Iterable",
|
||||
"ESNext"
|
||||
],
|
||||
"types": ["vite/client"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
@@ -22,6 +23,7 @@
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"locales",
|
||||
".storybook/preview.tsx"
|
||||
],
|
||||
"references": [
|
||||
|
||||
@@ -4,16 +4,19 @@
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"allowJs": true
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
".storybook/main.ts",
|
||||
"vite.config.ts",
|
||||
"vitest.global-setup.ts",
|
||||
"vitest.i18n-setup.ts",
|
||||
".eslintrc.cjs",
|
||||
"postcss.config.cjs",
|
||||
"tailwind.config.cjs",
|
||||
"tailwind.templates.config.cjs",
|
||||
"codegen.ts"
|
||||
"codegen.ts",
|
||||
"i18next-parser.config.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,12 +15,27 @@
|
||||
import { resolve } from "path";
|
||||
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { PluginOption } from "vite";
|
||||
import compression from "vite-plugin-compression";
|
||||
import codegen from "vite-plugin-graphql-codegen";
|
||||
import manifestSRI from "vite-plugin-manifest-sri";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
function i18nHotReload(): PluginOption {
|
||||
return {
|
||||
name: "i18n-hot-reload",
|
||||
handleHotUpdate({ file, server }): void {
|
||||
if (file.includes("locales") && file.endsWith(".json")) {
|
||||
console.log("Locale file updated");
|
||||
server.ws.send({
|
||||
type: "custom",
|
||||
event: "locales-update",
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
export default defineConfig((env) => ({
|
||||
base: "./",
|
||||
|
||||
@@ -113,6 +128,8 @@ export default defineConfig((env) => ({
|
||||
algorithm: "deflate",
|
||||
ext: ".zz",
|
||||
}),
|
||||
|
||||
i18nHotReload(),
|
||||
],
|
||||
|
||||
server: {
|
||||
@@ -126,6 +143,7 @@ export default defineConfig((env) => ({
|
||||
|
||||
test: {
|
||||
globalSetup: "./vitest.global-setup.ts",
|
||||
setupFiles: "./vitest.i18n-setup.ts",
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
src: ["./src/"],
|
||||
|
||||
36
frontend/vitest.i18n-setup.ts
Normal file
36
frontend/vitest.i18n-setup.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { beforeEach } from "vitest";
|
||||
|
||||
import EN from "./locales/en.json";
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.use(initReactI18next).init({
|
||||
fallbackLng: "en",
|
||||
keySeparator: ".",
|
||||
pluralSeparator: ":",
|
||||
interpolation: {
|
||||
escapeValue: false, // React has built-in XSS protections
|
||||
},
|
||||
lng: "en",
|
||||
resources: {
|
||||
en: {
|
||||
translation: EN,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,26 @@
|
||||
"readKey": "a7633943728394577700-c0f9f1df124fbdbe76b2c7dfcbfe574476d56509e0da6180e2a321dbbe056c40",
|
||||
"upload": {
|
||||
"type": "json",
|
||||
"files": "translations/en.json",
|
||||
"features": [
|
||||
"arb_metadata",
|
||||
"plural_object"
|
||||
]
|
||||
"files": [{
|
||||
"file": "file.json",
|
||||
"pattern": "translations/en.json",
|
||||
"features": [
|
||||
"arb_metadata",
|
||||
"plural_object"
|
||||
]
|
||||
}, {
|
||||
"file": "frontend.json",
|
||||
"pattern": "frontend/locales/en.json",
|
||||
"features": ["plural_postfix_dd"]
|
||||
}]
|
||||
},
|
||||
"download": {
|
||||
"files": "translations/${lang}.json"
|
||||
"files": [{
|
||||
"conditions": "equals: ${file}, file.json",
|
||||
"output": "translations/${lang}.json"
|
||||
}, {
|
||||
"conditions": "equals: ${file}, frontend.json",
|
||||
"output": "frontend/locales/${lang}.json"
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user