feat: I18n (#1653)

* feat: i18n

* Changing language single source of truth from TEAM to USER

* Changes according to @tommoor comments on PR

* Changed package.json for build:i18n and translation label

* Finished 1st MVP of i18n for outline

* new translation labels & Portuguese from Portugal translation

* Fixes from PR request

* Described language dropdown as an experimental feature

* Set keySeparator to false in order to cowork with html keys

* Added useTranslation to Breadcrumb

* Repositioned <strong> element

* Removed extra space from TemplatesMenu

* Fortified the test suite for i18n

* Fixed trans component problematic

* Check if selected language is available

* Update yarn.lock

* Removed unused Trans

* Removing debug variable from i18n init

* Removed debug variable

* test: update snapshots

* flow: Remove decorator usage to get proper flow typing
It's a shame, but hopefully we'll move to Typescript in the next 6 months and we can forget this whole Flow mistake ever happened

* translate: Drafts

* More translatable strings

* Mo translation strings

* translation: Search

* async translations loading

* cache translations in client

* Revert "cache translations in client"

This reverts commit 08fb61ce36384ff90a704faffe4761eccfb76da1.

* Revert localStorage cache for cache headers

* Update Crowdin configuration file

* Moved translation files to locales folder and fixed english text

* Added CONTRIBUTING File for CrowdIn

* chore: Move translations again to please CrowdIn

* fix: loading paths
chore: Add strings for editor

* fix: Improve validation on documents.import endpoint

* test: mock bull

* fix: Unknown mimetype should fallback to Markdown parsing if markdown extension (#1678)

* closes #1675

* Update CONTRIBUTING

* chore: Add link to translation portal from app UI

* refactor: Centralize language config

* fix: Ensure creation of i18n directory in build

* feat: Add language prompt

* chore: Improve contributing guidelines, add link from README

* chore: Normalize tab header casing

* chore: More string externalization

* fix: Language prompt in dark mode

Co-authored-by: André Glatzl <andreglatzl@gmail.com>
This commit is contained in:
Tom Moor
2020-11-29 20:04:58 -08:00
committed by GitHub
parent 63c73c9a51
commit 1285efc49a
85 changed files with 6432 additions and 2613 deletions

View File

@@ -1,6 +1,7 @@
// @flow
import { observer, inject } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import DocumentsStore from "stores/DocumentsStore";
import CenteredContent from "components/CenteredContent";
@@ -14,26 +15,26 @@ type Props = {
documents: DocumentsStore,
};
@observer
class Archive extends React.Component<Props> {
render() {
const { documents } = this.props;
function Archive(props: Props) {
const { t } = useTranslation();
const { documents } = props;
return (
<CenteredContent column auto>
<PageTitle title="Archive" />
<Heading>Archive</Heading>
<PaginatedDocumentList
documents={documents.archived}
fetch={documents.fetchArchived}
heading={<Subheading>Documents</Subheading>}
empty={<Empty>The document archive is empty at the moment.</Empty>}
showCollection
showTemplate
/>
</CenteredContent>
);
}
return (
<CenteredContent column auto>
<PageTitle title={t("Archive")} />
<Heading>{t("Archive")}</Heading>
<PaginatedDocumentList
documents={documents.archived}
fetch={documents.fetchArchived}
heading={<Subheading>{t("Documents")}</Subheading>}
empty={
<Empty>{t("The document archive is empty at the moment.")}</Empty>
}
showCollection
showTemplate
/>
</CenteredContent>
);
}
export default inject("documents")(Archive);
export default inject("documents")(observer(Archive));

View File

@@ -4,6 +4,7 @@ import { observer, inject } from "mobx-react";
import { NewDocumentIcon, PlusIcon, PinIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, Trans, type TFunction } from "react-i18next";
import { Redirect, Link, Switch, Route, type Match } from "react-router-dom";
import styled, { withTheme } from "styled-components";
@@ -47,6 +48,7 @@ type Props = {
policies: PoliciesStore,
match: Match,
theme: Theme,
t: TFunction,
};
@observer
@@ -64,7 +66,7 @@ class CollectionScene extends React.Component<Props> {
}
}
componentDidUpdate(prevProps) {
componentDidUpdate(prevProps: Props) {
const { id } = this.props.match.params;
if (this.collection) {
@@ -132,7 +134,7 @@ class CollectionScene extends React.Component<Props> {
};
renderActions() {
const { match, policies } = this.props;
const { match, policies, t } = this.props;
const can = policies.abilities(match.params.id || "");
return (
@@ -142,19 +144,19 @@ class CollectionScene extends React.Component<Props> {
<Action>
<InputSearch
source="collection"
placeholder="Search in collection…"
placeholder={t("Search in collection…")}
collectionId={match.params.id}
/>
</Action>
<Action>
<Tooltip
tooltip="New document"
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button onClick={this.onNewDocument} icon={<PlusIcon />}>
New doc
{t("New doc")}
</Button>
</Tooltip>
</Action>
@@ -169,7 +171,7 @@ class CollectionScene extends React.Component<Props> {
}
render() {
const { documents, theme } = this.props;
const { documents, theme, t } = this.props;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
if (!this.isFetching && !this.collection) return <Search notFound />;
@@ -179,6 +181,7 @@ class CollectionScene extends React.Component<Props> {
: [];
const hasPinnedDocuments = !!pinnedDocuments.length;
const collection = this.collection;
const collectionName = collection ? collection.name : "";
return (
<CenteredContent>
@@ -188,26 +191,28 @@ class CollectionScene extends React.Component<Props> {
{collection.isEmpty ? (
<Centered column>
<HelpText>
<strong>{collection.name}</strong> doesnt contain any
documents yet.
<Trans>
<strong>{{ collectionName }}</strong> doesnt contain any
documents yet.
</Trans>
<br />
Get started by creating a new one!
<Trans>Get started by creating a new one!</Trans>
</HelpText>
<Wrapper>
<Link to={newDocumentUrl(collection.id)}>
<Button icon={<NewDocumentIcon color={theme.buttonText} />}>
Create a document
{t("Create a document")}
</Button>
</Link>
&nbsp;&nbsp;
{collection.private && (
<Button onClick={this.onPermissions} neutral>
Manage members
{t("Manage members…")}
</Button>
)}
</Wrapper>
<Modal
title="Collection permissions"
title={t("Collection permissions")}
onRequestClose={this.handlePermissionsModalClose}
isOpen={this.permissionsModalOpen}
>
@@ -218,7 +223,7 @@ class CollectionScene extends React.Component<Props> {
/>
</Modal>
<Modal
title="Edit collection"
title={t("Edit collection")}
onRequestClose={this.handleEditModalClose}
isOpen={this.editModalOpen}
>
@@ -249,7 +254,7 @@ class CollectionScene extends React.Component<Props> {
{hasPinnedDocuments && (
<>
<Subheading>
<TinyPinIcon size={18} /> Pinned
<TinyPinIcon size={18} /> {t("Pinned")}
</Subheading>
<DocumentList documents={pinnedDocuments} showPin />
</>
@@ -257,16 +262,16 @@ class CollectionScene extends React.Component<Props> {
<Tabs>
<Tab to={collectionUrl(collection.id)} exact>
Recently updated
{t("Recently updated")}
</Tab>
<Tab to={collectionUrl(collection.id, "recent")} exact>
Recently published
{t("Recently published")}
</Tab>
<Tab to={collectionUrl(collection.id, "old")} exact>
Least recently updated
{t("Least recently updated")}
</Tab>
<Tab to={collectionUrl(collection.id, "alphabetical")} exact>
AZ
{t("AZ")}
</Tab>
</Tabs>
<Switch>
@@ -351,9 +356,11 @@ const Wrapper = styled(Flex)`
margin: 10px 0;
`;
export default inject(
"collections",
"policies",
"documents",
"ui"
)(withTheme(CollectionScene));
export default withTranslation()<CollectionScene>(
inject(
"collections",
"policies",
"documents",
"ui"
)(withTheme(CollectionScene))
);

View File

@@ -2,6 +2,7 @@
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import Button from "components/Button";
@@ -16,6 +17,7 @@ type Props = {
collection: Collection,
ui: UiStore,
onSubmit: () => void,
t: TFunction,
};
@observer
@@ -30,6 +32,7 @@ class CollectionEdit extends React.Component<Props> {
handleSubmit = async (ev: SyntheticEvent<*>) => {
ev.preventDefault();
this.isSaving = true;
const { t } = this.props;
try {
await this.props.collection.save({
@@ -40,7 +43,7 @@ class CollectionEdit extends React.Component<Props> {
private: this.private,
});
this.props.onSubmit();
this.props.ui.showToast("The collection was updated");
this.props.ui.showToast(t("The collection was updated"));
} catch (err) {
this.props.ui.showToast(err.message);
} finally {
@@ -48,7 +51,7 @@ class CollectionEdit extends React.Component<Props> {
}
};
handleDescriptionChange = (getValue) => {
handleDescriptionChange = (getValue: () => string) => {
this.description = getValue();
};
@@ -66,17 +69,20 @@ class CollectionEdit extends React.Component<Props> {
};
render() {
const { t } = this.props;
return (
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
You can edit the name and other details at any time, however doing
so often might confuse your team mates.
{t(
"You can edit the name and other details at any time, however doing so often might confuse your team mates."
)}
</HelpText>
<Flex>
<Input
type="text"
label="Name"
label={t("Name")}
onChange={this.handleNameChange}
value={this.name}
required
@@ -92,27 +98,29 @@ class CollectionEdit extends React.Component<Props> {
</Flex>
<InputRich
id={this.props.collection.id}
label="Description"
label={t("Description")}
onChange={this.handleDescriptionChange}
defaultValue={this.description || ""}
placeholder="More details about this collection…"
placeholder={t("More details about this collection…")}
minHeight={68}
maxHeight={200}
/>
<Switch
id="private"
label="Private collection"
label={t("Private collection")}
onChange={this.handlePrivateChange}
checked={this.private}
/>
<HelpText>
A private collection will only be visible to invited team members.
{t(
"A private collection will only be visible to invited team members."
)}
</HelpText>
<Button
type="submit"
disabled={this.isSaving || !this.props.collection.name}
>
{this.isSaving ? "Saving…" : "Save"}
{this.isSaving ? t("Saving…") : t("Save")}
</Button>
</form>
</Flex>
@@ -120,4 +128,4 @@ class CollectionEdit extends React.Component<Props> {
}
}
export default inject("ui")(CollectionEdit);
export default withTranslation()<CollectionEdit>(inject("ui")(CollectionEdit));

View File

@@ -3,12 +3,14 @@ import { debounce } from "lodash";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import styled from "styled-components";
import AuthStore from "stores/AuthStore";
import CollectionGroupMembershipsStore from "stores/CollectionGroupMembershipsStore";
import GroupsStore from "stores/GroupsStore";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import Group from "models/Group";
import GroupNew from "scenes/GroupNew";
import Button from "components/Button";
import Empty from "components/Empty";
@@ -26,6 +28,7 @@ type Props = {
collectionGroupMemberships: CollectionGroupMembershipsStore,
groups: GroupsStore,
onSubmit: () => void,
t: TFunction,
};
@observer
@@ -52,50 +55,56 @@ class AddGroupsToCollection extends React.Component<Props> {
});
}, 250);
handleAddGroup = (group) => {
handleAddGroup = (group: Group) => {
const { t } = this.props;
try {
this.props.collectionGroupMemberships.create({
collectionId: this.props.collection.id,
groupId: group.id,
permission: "read_write",
});
this.props.ui.showToast(`${group.name} was added to the collection`);
this.props.ui.showToast(
t("{{ groupName }} was added to the collection", {
groupName: group.name,
})
);
} catch (err) {
this.props.ui.showToast("Could not add user");
this.props.ui.showToast(t("Could not add user"));
console.error(err);
}
};
render() {
const { groups, collection, auth } = this.props;
const { groups, collection, auth, t } = this.props;
const { user, team } = auth;
if (!user || !team) return null;
return (
<Flex column>
<HelpText>
Cant find the group youre looking for?{" "}
{t("Cant find the group youre looking for?")}{" "}
<a role="button" onClick={this.handleNewGroupModalOpen}>
Create a group
{t("Create a group")}
</a>
.
</HelpText>
<Input
type="search"
placeholder="Search by group name…"
placeholder={t("Search by group name…")}
value={this.query}
onChange={this.handleFilter}
label="Search groups"
label={t("Search groups")}
labelHidden
flex
/>
<PaginatedList
empty={
this.query ? (
<Empty>No groups matching your search</Empty>
<Empty>{t("No groups matching your search")}</Empty>
) : (
<Empty>No groups left to add</Empty>
<Empty>{t("No groups left to add")}</Empty>
)
}
items={groups.notInCollection(collection.id, this.query)}
@@ -108,7 +117,7 @@ class AddGroupsToCollection extends React.Component<Props> {
renderActions={() => (
<ButtonWrap>
<Button onClick={() => this.handleAddGroup(item)} neutral>
Add
{t("Add")}
</Button>
</ButtonWrap>
)}
@@ -116,7 +125,7 @@ class AddGroupsToCollection extends React.Component<Props> {
)}
/>
<Modal
title="Create a group"
title={t("Create a group")}
onRequestClose={this.handleNewGroupModalClose}
isOpen={this.newGroupModalOpen}
>
@@ -131,9 +140,11 @@ const ButtonWrap = styled.div`
margin-left: 6px;
`;
export default inject(
"auth",
"groups",
"collectionGroupMemberships",
"ui"
)(AddGroupsToCollection);
export default withTranslation()<AddGroupsToCollection>(
inject(
"auth",
"groups",
"collectionGroupMemberships",
"ui"
)(AddGroupsToCollection)
);

View File

@@ -3,11 +3,13 @@ import { debounce } from "lodash";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import AuthStore from "stores/AuthStore";
import MembershipsStore from "stores/MembershipsStore";
import UiStore from "stores/UiStore";
import UsersStore from "stores/UsersStore";
import Collection from "models/Collection";
import User from "models/User";
import Invite from "scenes/Invite";
import Empty from "components/Empty";
import Flex from "components/Flex";
@@ -24,6 +26,7 @@ type Props = {
memberships: MembershipsStore,
users: UsersStore,
onSubmit: () => void,
t: TFunction,
};
@observer
@@ -50,40 +53,43 @@ class AddPeopleToCollection extends React.Component<Props> {
});
}, 250);
handleAddUser = (user) => {
handleAddUser = (user: User) => {
const { t } = this.props;
try {
this.props.memberships.create({
collectionId: this.props.collection.id,
userId: user.id,
permission: "read_write",
});
this.props.ui.showToast(`${user.name} was added to the collection`);
this.props.ui.showToast(
t("{{ userName }} was added to the collection", { userName: user.name })
);
} catch (err) {
this.props.ui.showToast("Could not add user");
this.props.ui.showToast(t("Could not add user"));
}
};
render() {
const { users, collection, auth } = this.props;
const { users, collection, auth, t } = this.props;
const { user, team } = auth;
if (!user || !team) return null;
return (
<Flex column>
<HelpText>
Need to add someone whos not yet on the team yet?{" "}
{t("Need to add someone whos not yet on the team yet?")}{" "}
<a role="button" onClick={this.handleInviteModalOpen}>
Invite people to {team.name}
{t("Invite people to {{ teamName }}", { teamName: team.name })}
</a>
.
</HelpText>
<Input
type="search"
placeholder="Search by name…"
placeholder={t("Search by name…")}
value={this.query}
onChange={this.handleFilter}
label="Search people"
label={t("Search people")}
autoFocus
labelHidden
flex
@@ -91,9 +97,9 @@ class AddPeopleToCollection extends React.Component<Props> {
<PaginatedList
empty={
this.query ? (
<Empty>No people matching your search</Empty>
<Empty>{t("No people matching your search")}</Empty>
) : (
<Empty>No people left to add</Empty>
<Empty>{t("No people left to add")}</Empty>
)
}
items={users.notInCollection(collection.id, this.query)}
@@ -108,7 +114,7 @@ class AddPeopleToCollection extends React.Component<Props> {
)}
/>
<Modal
title="Invite people"
title={t("Invite people")}
onRequestClose={this.handleInviteModalClose}
isOpen={this.inviteModalOpen}
>
@@ -119,9 +125,6 @@ class AddPeopleToCollection extends React.Component<Props> {
}
}
export default inject(
"auth",
"users",
"memberships",
"ui"
)(AddPeopleToCollection);
export default withTranslation()<AddPeopleToCollection>(
inject("auth", "users", "memberships", "ui")(AddPeopleToCollection)
);

View File

@@ -1,5 +1,6 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import CollectionGroupMembership from "models/CollectionGroupMembership";
import Group from "models/Group";
@@ -7,10 +8,6 @@ import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import GroupListItem from "components/GroupListItem";
import InputSelect from "components/InputSelect";
const PERMISSIONS = [
{ label: "Read only", value: "read" },
{ label: "Read & Edit", value: "read_write" },
];
type Props = {
group: Group,
collectionGroupMembership: ?CollectionGroupMembership,
@@ -24,6 +21,16 @@ const MemberListItem = ({
onUpdate,
onRemove,
}: Props) => {
const { t } = useTranslation();
const PERMISSIONS = React.useMemo(
() => [
{ label: t("Read only"), value: "read" },
{ label: t("Read & Edit"), value: "read_write" },
],
[t]
);
return (
<GroupListItem
group={group}
@@ -32,7 +39,7 @@ const MemberListItem = ({
renderActions={({ openMembersModal }) => (
<>
<Select
label="Permissions"
label={t("Permissions")}
options={PERMISSIONS}
value={
collectionGroupMembership
@@ -45,10 +52,12 @@ const MemberListItem = ({
<ButtonWrap>
<DropdownMenu>
<DropdownMenuItem onClick={openMembersModal}>
Members
{t("Members…")}
</DropdownMenuItem>
<hr />
<DropdownMenuItem onClick={onRemove}>Remove</DropdownMenuItem>
<DropdownMenuItem onClick={onRemove}>
{t("Remove")}
</DropdownMenuItem>
</DropdownMenu>
</ButtonWrap>
</>

View File

@@ -1,5 +1,6 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Membership from "models/Membership";
import User from "models/User";
@@ -12,10 +13,6 @@ import InputSelect from "components/InputSelect";
import ListItem from "components/List/Item";
import Time from "components/Time";
const PERMISSIONS = [
{ label: "Read only", value: "read" },
{ label: "Read & Edit", value: "read_write" },
];
type Props = {
user: User,
membership?: ?Membership,
@@ -33,6 +30,16 @@ const MemberListItem = ({
onAdd,
canEdit,
}: Props) => {
const { t } = useTranslation();
const PERMISSIONS = React.useMemo(
() => [
{ label: t("Read only"), value: "read" },
{ label: t("Read & Edit"), value: "read_write" },
],
[t]
);
return (
<ListItem
title={user.name}
@@ -40,13 +47,15 @@ const MemberListItem = ({
<>
{user.lastActiveAt ? (
<>
Active <Time dateTime={user.lastActiveAt} /> ago
{t("Active {{ lastActiveAt }} ago", {
lastActiveAt: <Time dateTime={user.lastActiveAt} />,
})}
</>
) : (
"Never signed in"
t("Never signed in")
)}
{user.isInvited && <Badge>Invited</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
{user.isInvited && <Badge>{t("Invited")}</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
</>
}
image={<Avatar src={user.avatarUrl} size={40} />}
@@ -54,7 +63,7 @@ const MemberListItem = ({
<Flex align="center">
{canEdit && onUpdate && (
<Select
label="Permissions"
label={t("Permissions")}
options={PERMISSIONS}
value={membership ? membership.permission : undefined}
onChange={(ev) => onUpdate(ev.target.value)}
@@ -64,12 +73,14 @@ const MemberListItem = ({
&nbsp;&nbsp;
{canEdit && onRemove && (
<DropdownMenu>
<DropdownMenuItem onClick={onRemove}>Remove</DropdownMenuItem>
<DropdownMenuItem onClick={onRemove}>
{t("Remove")}
</DropdownMenuItem>
</DropdownMenu>
)}
{canEdit && onAdd && (
<Button onClick={onAdd} neutral>
Add
{t("Add")}
</Button>
)}
</Flex>

View File

@@ -1,6 +1,7 @@
// @flow
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import User from "models/User";
import Avatar from "components/Avatar";
import Badge from "components/Badge";
@@ -15,6 +16,8 @@ type Props = {
};
const UserListItem = ({ user, onAdd, canEdit }: Props) => {
const { t } = useTranslation();
return (
<ListItem
title={user.name}
@@ -23,19 +26,21 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
<>
{user.lastActiveAt ? (
<>
Active <Time dateTime={user.lastActiveAt} /> ago
{t("Active {{ lastActiveAt }} ago", {
lastActiveAt: <Time dateTime={user.lastActiveAt} />,
})}
</>
) : (
"Never signed in"
t("Never signed in")
)}
{user.isInvited && <Badge>Invited</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
{user.isInvited && <Badge>{t("Invited")}</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
</>
}
actions={
canEdit ? (
<Button type="button" onClick={onAdd} icon={<PlusIcon />} neutral>
Add
{t("Add")}
</Button>
) : undefined
}

View File

@@ -3,6 +3,7 @@ import { intersection } from "lodash";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import CollectionsStore from "stores/CollectionsStore";
import UiStore from "stores/UiStore";
@@ -20,6 +21,7 @@ type Props = {
ui: UiStore,
collections: CollectionsStore,
onSubmit: () => void,
t: TFunction,
};
@observer
@@ -84,7 +86,7 @@ class CollectionNew extends React.Component<Props> {
this.hasOpenedIconPicker = true;
};
handleDescriptionChange = (getValue) => {
handleDescriptionChange = (getValue: () => string) => {
this.description = getValue();
};
@@ -98,17 +100,19 @@ class CollectionNew extends React.Component<Props> {
};
render() {
const { t } = this.props;
return (
<form onSubmit={this.handleSubmit}>
<HelpText>
Collections are for grouping your knowledge base. They work best when
organized around a topic or internal team Product or Engineering for
example.
{t(
"Collections are for grouping your knowledge base. They work best when organized around a topic or internal team — Product or Engineering for example."
)}
</HelpText>
<Flex>
<Input
type="text"
label="Name"
label={t("Name")}
onChange={this.handleNameChange}
value={this.name}
required
@@ -124,29 +128,33 @@ class CollectionNew extends React.Component<Props> {
/>
</Flex>
<InputRich
label="Description"
label={t("Description")}
onChange={this.handleDescriptionChange}
defaultValue={this.description || ""}
placeholder="More details about this collection…"
placeholder={t("More details about this collection…")}
minHeight={68}
maxHeight={200}
/>
<Switch
id="private"
label="Private collection"
label={t("Private collection")}
onChange={this.handlePrivateChange}
checked={this.private}
/>
<HelpText>
A private collection will only be visible to invited team members.
{t(
"A private collection will only be visible to invited team members."
)}
</HelpText>
<Button type="submit" disabled={this.isSaving || !this.name}>
{this.isSaving ? "Creating…" : "Create"}
{this.isSaving ? t("Creating…") : t("Create")}
</Button>
</form>
);
}
}
export default inject("collections", "ui")(withRouter(CollectionNew));
export default withTranslation()<CollectionNew>(
inject("collections", "ui")(withRouter(CollectionNew))
);

View File

@@ -1,81 +1,77 @@
// @flow
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Switch, Route } from "react-router-dom";
import AuthStore from "stores/AuthStore";
import DocumentsStore from "stores/DocumentsStore";
import Actions, { Action } from "components/Actions";
import CenteredContent from "components/CenteredContent";
import InputSearch from "components/InputSearch";
import LanguagePrompt from "components/LanguagePrompt";
import PageTitle from "components/PageTitle";
import Tab from "components/Tab";
import Tabs from "components/Tabs";
import PaginatedDocumentList from "../components/PaginatedDocumentList";
import useStores from "../hooks/useStores";
import NewDocumentMenu from "menus/NewDocumentMenu";
type Props = {
documents: DocumentsStore,
auth: AuthStore,
};
function Dashboard() {
const { documents, ui, auth } = useStores();
const { t } = useTranslation();
@observer
class Dashboard extends React.Component<Props> {
render() {
const { documents, auth } = this.props;
if (!auth.user || !auth.team) return null;
const user = auth.user.id;
if (!auth.user || !auth.team) return null;
const user = auth.user.id;
return (
<CenteredContent>
<PageTitle title="Home" />
<h1>Home</h1>
<Tabs>
<Tab to="/home" exact>
Recently updated
</Tab>
<Tab to="/home/recent" exact>
Recently viewed
</Tab>
<Tab to="/home/created">Created by me</Tab>
</Tabs>
<Switch>
<Route path="/home/recent">
<PaginatedDocumentList
key="recent"
documents={documents.recentlyViewed}
fetch={documents.fetchRecentlyViewed}
showCollection
/>
</Route>
<Route path="/home/created">
<PaginatedDocumentList
key="created"
documents={documents.createdByUser(user)}
fetch={documents.fetchOwned}
options={{ user }}
showCollection
/>
</Route>
<Route path="/home">
<PaginatedDocumentList
documents={documents.recentlyUpdated}
fetch={documents.fetchRecentlyUpdated}
showCollection
/>
</Route>
</Switch>
<Actions align="center" justify="flex-end">
<Action>
<InputSearch source="dashboard" />
</Action>
<Action>
<NewDocumentMenu />
</Action>
</Actions>
</CenteredContent>
);
}
return (
<CenteredContent>
<PageTitle title={t("Home")} />
{!ui.languagePromptDismissed && <LanguagePrompt />}
<h1>{t("Home")}</h1>
<Tabs>
<Tab to="/home" exact>
{t("Recently updated")}
</Tab>
<Tab to="/home/recent" exact>
{t("Recently viewed")}
</Tab>
<Tab to="/home/created">{t("Created by me")}</Tab>
</Tabs>
<Switch>
<Route path="/home/recent">
<PaginatedDocumentList
key="recent"
documents={documents.recentlyViewed}
fetch={documents.fetchRecentlyViewed}
showCollection
/>
</Route>
<Route path="/home/created">
<PaginatedDocumentList
key="created"
documents={documents.createdByUser(user)}
fetch={documents.fetchOwned}
options={{ user }}
showCollection
/>
</Route>
<Route path="/home">
<PaginatedDocumentList
documents={documents.recentlyUpdated}
fetch={documents.fetchRecentlyUpdated}
showCollection
/>
</Route>
</Switch>
<Actions align="center" justify="flex-end">
<Action>
<InputSearch source="dashboard" />
</Action>
<Action>
<NewDocumentMenu />
</Action>
</Actions>
</CenteredContent>
);
}
export default inject("documents", "auth")(Dashboard);
export default observer(Dashboard);

View File

@@ -11,6 +11,7 @@ import {
} from "outline-icons";
import { transparentize, darken } from "polished";
import * as React from "react";
import { withTranslation, Trans, type TFunction } from "react-i18next";
import { Redirect } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -55,6 +56,7 @@ type Props = {
publish?: boolean,
autosave?: boolean,
}) => void,
t: TFunction,
};
@observer
@@ -131,6 +133,7 @@ class Header extends React.Component<Props> {
publishingIsDisabled,
ui,
auth,
t,
} = this.props;
const share = shares.getByDocumentId(document.id);
@@ -153,7 +156,7 @@ class Header extends React.Component<Props> {
<Modal
isOpen={this.showShareModal}
onRequestClose={this.handleCloseShareModal}
title="Share document"
title={t("Share document")}
>
<DocumentShare
document={document}
@@ -166,7 +169,9 @@ class Header extends React.Component<Props> {
<>
<Slash />
<Tooltip
tooltip={ui.tocVisible ? "Hide contents" : "Show contents"}
tooltip={
ui.tocVisible ? t("Hide contents") : t("Show contents")
}
shortcut={`ctrl+${meta}+h`}
delay={250}
placement="bottom"
@@ -190,14 +195,15 @@ class Header extends React.Component<Props> {
{this.isScrolled && (
<Title onClick={this.handleClickTitle}>
<Fade>
{document.title} {document.isArchived && <Badge>Archived</Badge>}
{document.title}{" "}
{document.isArchived && <Badge>{t("Archived")}</Badge>}
</Fade>
</Title>
)}
<Wrapper align="center" justify="flex-end">
{isSaving && !isPublishing && (
<Action>
<Status>Saving</Status>
<Status>{t("Saving…")}</Status>
</Action>
)}
&nbsp;
@@ -217,10 +223,10 @@ class Header extends React.Component<Props> {
<Tooltip
tooltip={
isPubliclyShared ? (
<>
<Trans>
Anyone with the link <br />
can view this document
</>
</Trans>
) : (
""
)
@@ -234,7 +240,7 @@ class Header extends React.Component<Props> {
neutral
small
>
Share
{t("Share")}
</Button>
</Tooltip>
</Action>
@@ -243,7 +249,7 @@ class Header extends React.Component<Props> {
<>
<Action>
<Tooltip
tooltip="Save"
tooltip={t("Save")}
shortcut={`${meta}+enter`}
delay={500}
placement="bottom"
@@ -255,7 +261,7 @@ class Header extends React.Component<Props> {
neutral={isDraft}
small
>
{isDraft ? "Save Draft" : "Done Editing"}
{isDraft ? t("Save Draft") : t("Done Editing")}
</Button>
</Tooltip>
</Action>
@@ -264,7 +270,7 @@ class Header extends React.Component<Props> {
{canEdit && (
<Action>
<Tooltip
tooltip={`Edit ${document.noun}`}
tooltip={t("Edit {{noun}}", { noun: document.noun })}
shortcut="e"
delay={500}
placement="bottom"
@@ -275,7 +281,7 @@ class Header extends React.Component<Props> {
neutral
small
>
Edit
{t("Edit")}
</Button>
</Tooltip>
</Action>
@@ -286,13 +292,13 @@ class Header extends React.Component<Props> {
document={document}
label={
<Tooltip
tooltip="New document"
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button icon={<PlusIcon />} neutral>
New doc
{t("New doc")}
</Button>
</Tooltip>
}
@@ -307,25 +313,25 @@ class Header extends React.Component<Props> {
primary
small
>
New from template
{t("New from template")}
</Button>
</Action>
)}
{can.update && isDraft && !isRevision && (
<Action>
<Tooltip
tooltip="Publish"
tooltip={t("Publish")}
shortcut={`${meta}+shift+p`}
delay={500}
placement="bottom"
>
<Button
onClick={this.handlePublish}
title="Publish document"
title={t("Publish document")}
disabled={publishingIsDisabled}
small
>
{isPublishing ? "Publishing…" : "Publish"}
{isPublishing ? t("Publishing…") : t("Publish")}
</Button>
</Tooltip>
</Action>
@@ -425,4 +431,6 @@ const Title = styled.div`
`};
`;
export default inject("auth", "ui", "policies", "shares")(Header);
export default withTranslation()<Header>(
inject("auth", "ui", "policies", "shares")(Header)
);

View File

@@ -1,5 +1,6 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import CenteredContent from "components/CenteredContent";
import LoadingPlaceholder from "components/LoadingPlaceholder";
import PageTitle from "components/PageTitle";
@@ -11,9 +12,13 @@ type Props = {|
|};
export default function Loading({ location }: Props) {
const { t } = useTranslation();
return (
<Container column auto>
<PageTitle title={location.state ? location.state.title : "Untitled"} />
<PageTitle
title={location.state ? location.state.title : t("Untitled")}
/>
<CenteredContent>
<LoadingPlaceholder />
</CenteredContent>

View File

@@ -3,6 +3,7 @@ import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import queryString from "query-string";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { type RouterHistory } from "react-router-dom";
import styled from "styled-components";
import DocumentsStore from "stores/DocumentsStore";
@@ -25,6 +26,7 @@ type Props = {|
documents: DocumentsStore,
history: RouterHistory,
location: LocationWithState,
t: TFunction,
|};
@observer
@@ -33,7 +35,7 @@ class Drafts extends React.Component<Props> {
this.props.location.search
);
componentDidUpdate(prevProps) {
componentDidUpdate(prevProps: Props) {
if (prevProps.location.search !== this.props.location.search) {
this.handleQueryChange();
}
@@ -43,7 +45,10 @@ class Drafts extends React.Component<Props> {
this.params = new URLSearchParams(this.props.location.search);
};
handleFilterChange = (search) => {
handleFilterChange = (search: {
dateFilter?: ?string,
collectionId?: ?string,
}) => {
this.props.history.replace({
pathname: this.props.location.pathname,
search: queryString.stringify({
@@ -64,6 +69,7 @@ class Drafts extends React.Component<Props> {
}
render() {
const { t } = this.props;
const { drafts, fetchDrafts } = this.props.documents;
const isFiltered = this.collectionId || this.dateFilter;
const options = {
@@ -73,10 +79,10 @@ class Drafts extends React.Component<Props> {
return (
<CenteredContent column auto>
<PageTitle title="Drafts" />
<Heading>Drafts</Heading>
<PageTitle title={t("Drafts")} />
<Heading>{t("Drafts")}</Heading>
<Subheading>
Documents
{t("Documents")}
<Filters>
<CollectionFilter
collectionId={this.collectionId}
@@ -95,8 +101,8 @@ class Drafts extends React.Component<Props> {
empty={
<Empty>
{isFiltered
? "No documents found for your filters."
: "Youve not got any drafts at the moment."}
? t("No documents found for your filters.")
: t("Youve not got any drafts at the moment.")}
</Empty>
}
fetch={fetchDrafts}
@@ -131,4 +137,4 @@ const Filters = styled(Flex)`
}
`;
export default inject("documents")(Drafts);
export default withTranslation()<Drafts>(inject("documents")(Drafts));

View File

@@ -1,18 +1,23 @@
// @flow
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { Link } from "react-router-dom";
import CenteredContent from "components/CenteredContent";
import Empty from "components/Empty";
import PageTitle from "components/PageTitle";
const Error404 = () => {
const { t } = useTranslation();
return (
<CenteredContent>
<PageTitle title="Not Found" />
<h1>Not found</h1>
<PageTitle title={t("Not found")} />
<h1>{t("Not found")}</h1>
<Empty>
We were unable to find the page youre looking for. Go to the{" "}
<Link to="/home">homepage</Link>?
<Trans>
We were unable to find the page youre looking for. Go to the{" "}
<Link to="/home">homepage</Link>?
</Trans>
</Empty>
</CenteredContent>
);

View File

@@ -1,15 +1,18 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import CenteredContent from "components/CenteredContent";
import Empty from "components/Empty";
import PageTitle from "components/PageTitle";
const ErrorOffline = () => {
const { t } = useTranslation();
return (
<CenteredContent>
<PageTitle title="Offline" />
<h1>Offline</h1>
<Empty>We were unable to load the document while offline.</Empty>
<PageTitle title={t("Offline")} />
<h1>{t("Offline")}</h1>
<Empty>{t("We were unable to load the document while offline.")}</Empty>
</CenteredContent>
);
};

View File

@@ -1,29 +1,37 @@
// @flow
import { inject, observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import AuthStore from "stores/AuthStore";
import CenteredContent from "components/CenteredContent";
import PageTitle from "components/PageTitle";
const ErrorSuspended = observer(({ auth }: { auth: AuthStore }) => {
const ErrorSuspended = ({ auth }: { auth: AuthStore }) => {
const { t } = useTranslation();
return (
<CenteredContent>
<PageTitle title="Your account has been suspended" />
<PageTitle title={t("Your account has been suspended")} />
<h1>
<span role="img" aria-label="Warning sign">
</span>{" "}
Your account has been suspended
{t("Your account has been suspended")}
</h1>
<p>
A team admin (<strong>{auth.suspendedContactEmail}</strong>) has
suspended your account. To re-activate your account, please reach out to
them directly.
<Trans>
A team admin (
<strong>
{{ suspendedContactEmail: auth.suspendedContactEmail }}
</strong>
) has suspended your account. To re-activate your account, please
reach out to them directly.
</Trans>
</p>
</CenteredContent>
);
});
};
export default inject("auth")(ErrorSuspended);
export default inject("auth")(observer(ErrorSuspended));

View File

@@ -3,11 +3,13 @@ import { debounce } from "lodash";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import AuthStore from "stores/AuthStore";
import GroupMembershipsStore from "stores/GroupMembershipsStore";
import UiStore from "stores/UiStore";
import UsersStore from "stores/UsersStore";
import Group from "models/Group";
import User from "models/User";
import Invite from "scenes/Invite";
import Empty from "components/Empty";
import Flex from "components/Flex";
@@ -24,6 +26,7 @@ type Props = {
groupMemberships: GroupMembershipsStore,
users: UsersStore,
onSubmit: () => void,
t: TFunction,
};
@observer
@@ -50,40 +53,45 @@ class AddPeopleToGroup extends React.Component<Props> {
});
}, 250);
handleAddUser = async (user) => {
handleAddUser = async (user: User) => {
const { t } = this.props;
try {
await this.props.groupMemberships.create({
groupId: this.props.group.id,
userId: user.id,
});
this.props.ui.showToast(`${user.name} was added to the group`);
this.props.ui.showToast(
t(`{{userName}} was added to the group`, { userName: user.name })
);
} catch (err) {
this.props.ui.showToast("Could not add user");
this.props.ui.showToast(t("Could not add user"));
}
};
render() {
const { users, group, auth } = this.props;
const { users, group, auth, t } = this.props;
const { user, team } = auth;
if (!user || !team) return null;
return (
<Flex column>
<HelpText>
Add team members below to give them access to the group. Need to add
someone whos not yet on the team yet?{" "}
{t(
"Add team members below to give them access to the group. Need to add someone whos not yet on the team yet?"
)}{" "}
<a role="button" onClick={this.handleInviteModalOpen}>
Invite them to {team.name}
{t("Invite them to {{teamName}}", { teamName: team.name })}
</a>
.
</HelpText>
<Input
type="search"
placeholder="Search by name…"
placeholder={t("Search by name…")}
value={this.query}
onChange={this.handleFilter}
label="Search people"
label={t("Search people")}
labelHidden
autoFocus
flex
@@ -91,9 +99,9 @@ class AddPeopleToGroup extends React.Component<Props> {
<PaginatedList
empty={
this.query ? (
<Empty>No people matching your search</Empty>
<Empty>{t("No people matching your search")}</Empty>
) : (
<Empty>No people left to add</Empty>
<Empty>{t("No people left to add")}</Empty>
)
}
items={users.notInGroup(group.id, this.query)}
@@ -108,7 +116,7 @@ class AddPeopleToGroup extends React.Component<Props> {
)}
/>
<Modal
title="Invite people"
title={t("Invite people")}
onRequestClose={this.handleInviteModalClose}
isOpen={this.inviteModalOpen}
>
@@ -119,9 +127,6 @@ class AddPeopleToGroup extends React.Component<Props> {
}
}
export default inject(
"auth",
"users",
"groupMemberships",
"ui"
)(AddPeopleToGroup);
export default withTranslation()<AddPeopleToGroup>(
inject("auth", "users", "groupMemberships", "ui")(AddPeopleToGroup)
);

View File

@@ -3,12 +3,14 @@ import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import AuthStore from "stores/AuthStore";
import GroupMembershipsStore from "stores/GroupMembershipsStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import UsersStore from "stores/UsersStore";
import Group from "models/Group";
import User from "models/User";
import Button from "components/Button";
import Empty from "components/Empty";
import Flex from "components/Flex";
@@ -26,6 +28,7 @@ type Props = {
users: UsersStore,
policies: PoliciesStore,
groupMemberships: GroupMembershipsStore,
t: TFunction,
};
@observer
@@ -40,20 +43,24 @@ class GroupMembers extends React.Component<Props> {
this.addModalOpen = false;
};
handleRemoveUser = async (user) => {
handleRemoveUser = async (user: User) => {
const { t } = this.props;
try {
await this.props.groupMemberships.delete({
groupId: this.props.group.id,
userId: user.id,
});
this.props.ui.showToast(`${user.name} was removed from the group`);
this.props.ui.showToast(
t(`{{userName}} was removed from the group`, { userName: user.name })
);
} catch (err) {
this.props.ui.showToast("Could not remove user");
this.props.ui.showToast(t("Could not remove user"));
}
};
render() {
const { group, users, groupMemberships, policies, auth } = this.props;
const { group, users, groupMemberships, policies, t, auth } = this.props;
const { user } = auth;
if (!user) return null;
@@ -75,7 +82,7 @@ class GroupMembers extends React.Component<Props> {
icon={<PlusIcon />}
neutral
>
Add people
{t("Add people…")}
</Button>
</span>
</>
@@ -90,7 +97,7 @@ class GroupMembers extends React.Component<Props> {
items={users.inGroup(group.id)}
fetch={groupMemberships.fetchPage}
options={{ id: group.id }}
empty={<Empty>This group has no members.</Empty>}
empty={<Empty>{t("This group has no members.")}</Empty>}
renderItem={(item) => (
<GroupMemberListItem
key={item.id}
@@ -119,10 +126,6 @@ class GroupMembers extends React.Component<Props> {
}
}
export default inject(
"auth",
"users",
"policies",
"groupMemberships",
"ui"
)(GroupMembers);
export default withTranslation()<GroupMembers>(
inject("auth", "users", "policies", "groupMemberships", "ui")(GroupMembers)
);

View File

@@ -1,5 +1,6 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
@@ -7,153 +8,150 @@ import Key from "components/Key";
import { meta } from "utils/keyboard";
function KeyboardShortcuts() {
const { t } = useTranslation();
return (
<Flex column>
<HelpText>
Outline is designed to be fast and easy to use. All of your usual
keyboard shortcuts work here, and theres Markdown too.
{t(
"Outline is designed to be fast and easy to use. All of your usual keyboard shortcuts work here, and theres Markdown too."
)}
</HelpText>
<h2>Navigation</h2>
<h2>{t("Navigation")}</h2>
<List>
<Keys>
<Key>n</Key>
</Keys>
<Label>New document in current collection</Label>
<Label>{t("New document in current collection")}</Label>
<Keys>
<Key>e</Key>
</Keys>
<Label>Edit current document</Label>
<Label>{t("Edit current document")}</Label>
<Keys>
<Key>m</Key>
</Keys>
<Label>Move current document</Label>
<Label>{t("Move current document")}</Label>
<Keys>
<Key>/</Key> or <Key>t</Key>
</Keys>
<Label>Jump to search</Label>
<Label>{t("Jump to search")}</Label>
<Keys>
<Key>d</Key>
</Keys>
<Label>Jump to dashboard</Label>
<Label>{t("Jump to dashboard")}</Label>
<Keys>
<Key>{meta}</Key> + <Key>Ctrl</Key> + <Key>h</Key>
</Keys>
<Label>Table of contents</Label>
<Label>{t("Table of contents")}</Label>
<Keys>
<Key>?</Key>
</Keys>
<Label>Open this guide</Label>
<Label>{t("Open this guide")}</Label>
</List>
<h2>Editor</h2>
<h2>{t("Editor")}</h2>
<List>
<Keys>
<Key>{meta}</Key> + <Key>Enter</Key>
</Keys>
<Label>Save and exit document edit mode</Label>
<Label>{t("Save and exit document edit mode")}</Label>
<Keys>
<Key>{meta}</Key> + <Key>Shift</Key> + <Key>p</Key>
</Keys>
<Label>Publish and exit document edit mode</Label>
<Label>{t("Publish and exit document edit mode")}</Label>
<Keys>
<Key>{meta}</Key> + <Key>s</Key>
</Keys>
<Label>Save document and continue editing</Label>
<Label>{t("Save document and continue editing")}</Label>
<Keys>
<Key>{meta}</Key> + <Key>Esc</Key>
</Keys>
<Label>Cancel editing</Label>
<Label>{t("Cancel editing")}</Label>
<Keys>
<Key>{meta}</Key> + <Key>b</Key>
</Keys>
<Label>Bold</Label>
<Label>{t("Bold")}</Label>
<Keys>
<Key>{meta}</Key> + <Key>i</Key>
</Keys>
<Label>Italic</Label>
<Label>{t("Italic")}</Label>
<Keys>
<Key>{meta}</Key> + <Key>u</Key>
</Keys>
<Label>Underline</Label>
<Label>{t("Underline")}</Label>
<Keys>
<Key>{meta}</Key> + <Key>d</Key>
</Keys>
<Label>Strikethrough</Label>
<Label>{t("Strikethrough")}</Label>
<Keys>
<Key>{meta}</Key> + <Key>k</Key>
</Keys>
<Label>Link</Label>
<Label>{t("Link")}</Label>
<Keys>
<Key>{meta}</Key> + <Key>z</Key>
</Keys>
<Label>Undo</Label>
<Label>{t("Undo")}</Label>
<Keys>
<Key>{meta}</Key> + <Key>Shift</Key> + <Key>z</Key>
</Keys>
<Label>Redo</Label>
<Label>{t("Redo")}</Label>
</List>
<h2>Markdown</h2>
<h2>{t("Markdown")}</h2>
<List>
<Keys>
<Key>#</Key> <Key>Space</Key>
</Keys>
<Label>Large header</Label>
<Label>{t("Large header")}</Label>
<Keys>
<Key>##</Key> <Key>Space</Key>
</Keys>
<Label>Medium header</Label>
<Label>{t("Medium header")}</Label>
<Keys>
<Key>###</Key> <Key>Space</Key>
</Keys>
<Label>Small header</Label>
<Label>{t("Small header")}</Label>
<Keys>
<Key>1.</Key> <Key>Space</Key>
</Keys>
<Label>Numbered list</Label>
<Label>{t("Numbered list")}</Label>
<Keys>
<Key>-</Key> <Key>Space</Key>
</Keys>
<Label>Bulleted list</Label>
<Label>{t("Bulleted list")}</Label>
<Keys>
<Key>[ ]</Key> <Key>Space</Key>
</Keys>
<Label>Todo list</Label>
<Label>{t("Todo list")}</Label>
<Keys>
<Key>&gt;</Key> <Key>Space</Key>
</Keys>
<Label>Blockquote</Label>
<Label>{t("Blockquote")}</Label>
<Keys>
<Key>---</Key>
</Keys>
<Label>Horizontal divider</Label>
<Label>{t("Horizontal divider")}</Label>
<Keys>
<Key>{"```"}</Key>
</Keys>
<Label>Code block</Label>
<Label>{t("Code block")}</Label>
<Keys>
<Key>{":::"}</Key>
</Keys>
<Label>Info notice</Label>
<Label>{t("Info notice")}</Label>
<Keys>_italic_</Keys>
<Label>Italic</Label>
<Label>{t("Italic")}</Label>
<Keys>**bold**</Keys>
<Label>Bold</Label>
<Label>{t("Bold")}</Label>
<Keys>~~strikethrough~~</Keys>
<Label>Strikethrough</Label>
<Label>{t("Strikethrough")}</Label>
<Keys>{"`code`"}</Keys>
<Label>Inline code</Label>
<Label>{t("Inline code")}</Label>
<Keys>==highlight==</Keys>
<Label>highlight</Label>
<Label>{t("Highlight")}</Label>
</List>
</Flex>
);

View File

@@ -7,6 +7,7 @@ import { PlusIcon } from "outline-icons";
import queryString from "query-string";
import * as React from "react";
import ReactDOM from "react-dom";
import { withTranslation, Trans, type TFunction } from "react-i18next";
import keydown from "react-keydown";
import { withRouter, Link } from "react-router-dom";
import type { RouterHistory, Match } from "react-router-dom";
@@ -44,11 +45,12 @@ type Props = {
documents: DocumentsStore,
users: UsersStore,
notFound: ?boolean,
t: TFunction,
};
@observer
class Search extends React.Component<Props> {
firstDocument: ?React.Component<typeof DocumentPreview>;
firstDocument: ?React.Component<any>;
lastQuery: string = "";
@observable
@@ -67,7 +69,7 @@ class Search extends React.Component<Props> {
}
}
componentDidUpdate(prevProps) {
componentDidUpdate(prevProps: Props) {
if (prevProps.location.search !== this.props.location.search) {
this.handleQueryChange();
}
@@ -81,7 +83,7 @@ class Search extends React.Component<Props> {
this.props.history.goBack();
}
handleKeyDown = (ev) => {
handleKeyDown = (ev: SyntheticKeyboardEvent<>) => {
if (ev.key === "Enter") {
this.fetchResults();
return;
@@ -124,7 +126,12 @@ class Search extends React.Component<Props> {
this.fetchResultsDebounced();
};
handleFilterChange = (search) => {
handleFilterChange = (search: {
collectionId?: ?string,
userId?: ?string,
dateFilter?: ?string,
includeArchived?: ?string,
}) => {
this.props.history.replace({
pathname: this.props.location.pathname,
search: queryString.stringify({
@@ -170,7 +177,7 @@ class Search extends React.Component<Props> {
get title() {
const query = this.query;
const title = "Search";
const title = this.props.t("Search");
if (query) return `${query} ${title}`;
return title;
}
@@ -231,20 +238,19 @@ class Search extends React.Component<Props> {
trailing: true,
});
updateLocation = (query) => {
updateLocation = (query: string) => {
this.props.history.replace({
pathname: searchUrl(query),
search: this.props.location.search,
});
};
setFirstDocumentRef = (ref) => {
// $FlowFixMe
setFirstDocumentRef = (ref: any) => {
this.firstDocument = ref;
};
render() {
const { documents, notFound, location } = this.props;
const { documents, notFound, location, t } = this.props;
const results = documents.searchResults(this.query);
const showEmpty = !this.isLoading && this.query && results.length === 0;
const showShortcutTip =
@@ -256,12 +262,15 @@ class Search extends React.Component<Props> {
{this.isLoading && <LoadingIndicator />}
{notFound && (
<div>
<h1>Not Found</h1>
<Empty>We were unable to find the page youre looking for.</Empty>
<h1>{t("Not Found")}</h1>
<Empty>
{t("We were unable to find the page youre looking for.")}
</Empty>
</div>
)}
<ResultsWrapper pinToTop={this.pinToTop} column auto>
<SearchField
placeholder={t("Search…")}
onKeyDown={this.handleKeyDown}
onChange={this.updateLocation}
defaultValue={this.query}
@@ -269,8 +278,10 @@ class Search extends React.Component<Props> {
{showShortcutTip && (
<Fade>
<HelpText small>
Use the <strong>{meta}+K</strong> shortcut to search from
anywhere in Outline
<Trans>
Use the <strong>{{ meta }}+K</strong> shortcut to search from
anywhere in your knowledge base
</Trans>
</HelpText>
</Fade>
)}
@@ -304,8 +315,10 @@ class Search extends React.Component<Props> {
<Fade>
<Centered column>
<HelpText>
No documents found for your search filters. <br />
Create a new document?
<Trans>
No documents found for your search filters. <br />
Create a new document?
</Trans>
</HelpText>
<Wrapper>
{this.collectionId ? (
@@ -314,14 +327,14 @@ class Search extends React.Component<Props> {
icon={<PlusIcon />}
primary
>
New doc
{t("New doc")}
</Button>
) : (
<NewDocumentMenu />
)}
&nbsp;&nbsp;
<Button as={Link} to="/search" neutral>
Clear filters
{t("Clear filters")}
</Button>
</Wrapper>
</Centered>
@@ -414,4 +427,6 @@ const Filters = styled(Flex)`
}
`;
export default withRouter(inject("documents")(Search));
export default withTranslation()<Search>(
withRouter(inject("documents")(Search))
);

View File

@@ -8,6 +8,7 @@ import { type Theme } from "types";
type Props = {
onChange: (string) => void,
defaultValue?: string,
placeholder?: string,
theme: Theme,
};
@@ -44,7 +45,7 @@ class SearchField extends React.Component<Props> {
ref={(ref) => (this.input = ref)}
onChange={this.handleChange}
spellCheck="false"
placeholder="Search…"
placeholder={this.props.placeholder}
type="search"
autoFocus
/>

View File

@@ -2,7 +2,9 @@
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import * as React from "react";
import { Trans, withTranslation, type TFunction } from "react-i18next";
import styled from "styled-components";
import { languageOptions } from "shared/i18n";
import AuthStore from "stores/AuthStore";
import UiStore from "stores/UiStore";
@@ -10,13 +12,16 @@ import UserDelete from "scenes/UserDelete";
import Button from "components/Button";
import CenteredContent from "components/CenteredContent";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import Input, { LabelText } from "components/Input";
import InputSelect from "components/InputSelect";
import PageTitle from "components/PageTitle";
import ImageUpload from "./components/ImageUpload";
type Props = {
auth: AuthStore,
ui: UiStore,
t: TFunction,
};
@observer
@@ -27,10 +32,12 @@ class Profile extends React.Component<Props> {
@observable name: string;
@observable avatarUrl: ?string;
@observable showDeleteModal: boolean = false;
@observable language: string;
componentDidMount() {
if (this.props.auth.user) {
this.name = this.props.auth.user.name;
this.language = this.props.auth.user.language;
}
}
@@ -39,13 +46,16 @@ class Profile extends React.Component<Props> {
}
handleSubmit = async (ev: SyntheticEvent<>) => {
const { t } = this.props;
ev.preventDefault();
await this.props.auth.updateUser({
name: this.name,
avatarUrl: this.avatarUrl,
language: this.language,
});
this.props.ui.showToast("Profile saved");
this.props.ui.showToast(t("Profile saved"));
};
handleNameChange = (ev: SyntheticInputEvent<*>) => {
@@ -53,16 +63,22 @@ class Profile extends React.Component<Props> {
};
handleAvatarUpload = async (avatarUrl: string) => {
const { t } = this.props;
this.avatarUrl = avatarUrl;
await this.props.auth.updateUser({
avatarUrl: this.avatarUrl,
});
this.props.ui.showToast("Profile picture updated");
this.props.ui.showToast(t("Profile picture updated"));
};
handleAvatarError = (error: ?string) => {
this.props.ui.showToast(error || "Unable to upload new avatar");
const { t } = this.props;
this.props.ui.showToast(error || t("Unable to upload new profile picture"));
};
handleLanguageChange = (ev: SyntheticInputEvent<*>) => {
this.language = ev.target.value;
};
toggleDeleteAccount = () => {
@@ -74,16 +90,17 @@ class Profile extends React.Component<Props> {
}
render() {
const { t } = this.props;
const { user, isSaving } = this.props.auth;
if (!user) return null;
const avatarUrl = this.avatarUrl || user.avatarUrl;
return (
<CenteredContent>
<PageTitle title="Profile" />
<h1>Profile</h1>
<PageTitle title={t("Profile")} />
<h1>{t("Profile")}</h1>
<ProfilePicture column>
<LabelText>Photo</LabelText>
<LabelText>{t("Photo")}</LabelText>
<AvatarContainer>
<ImageUpload
onSuccess={this.handleAvatarUpload}
@@ -91,31 +108,55 @@ class Profile extends React.Component<Props> {
>
<Avatar src={avatarUrl} />
<Flex auto align="center" justify="center">
Upload
{t("Upload")}
</Flex>
</ImageUpload>
</AvatarContainer>
</ProfilePicture>
<form onSubmit={this.handleSubmit} ref={(ref) => (this.form = ref)}>
<Input
label="Full name"
label={t("Full name")}
autoComplete="name"
value={this.name}
onChange={this.handleNameChange}
required
short
/>
<br />
<InputSelect
label={t("Language")}
options={languageOptions}
value={this.language}
onChange={this.handleLanguageChange}
short
/>
<HelpText small>
<Trans>
Please note that translations are currently in early access.
<br />
Community contributions are accepted though our{" "}
<a
href="https://translate.getoutline.com"
target="_blank"
rel="noreferrer"
>
translation portal
</a>
</Trans>
.
</HelpText>
<Button type="submit" disabled={isSaving || !this.isValid}>
{isSaving ? "Saving…" : "Save"}
{isSaving ? t("Saving…") : t("Save")}
</Button>
</form>
<DangerZone>
<LabelText>Delete Account</LabelText>
<LabelText>{t("Delete Account")}</LabelText>
<p>
You may delete your account at any time, note that this is
unrecoverable.{" "}
<a onClick={this.toggleDeleteAccount}>Delete account</a>.
{t(
"You may delete your account at any time, note that this is unrecoverable"
)}
. <a onClick={this.toggleDeleteAccount}>{t("Delete account")}</a>.
</p>
</DangerZone>
{this.showDeleteModal && (
@@ -170,4 +211,4 @@ const Avatar = styled.img`
${avatarStyles};
`;
export default inject("auth", "ui")(Profile);
export default withTranslation()<Profile>(inject("auth", "ui")(Profile));

View File

@@ -1,9 +1,8 @@
// @flow
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { type Match } from "react-router-dom";
import DocumentsStore from "stores/DocumentsStore";
import Actions, { Action } from "components/Actions";
import CenteredContent from "components/CenteredContent";
import Empty from "components/Empty";
@@ -13,51 +12,50 @@ import PageTitle from "components/PageTitle";
import PaginatedDocumentList from "components/PaginatedDocumentList";
import Tab from "components/Tab";
import Tabs from "components/Tabs";
import useStores from "hooks/useStores";
import NewDocumentMenu from "menus/NewDocumentMenu";
type Props = {
documents: DocumentsStore,
match: Match,
};
@observer
class Starred extends React.Component<Props> {
render() {
const { fetchStarred, starred, starredAlphabetical } = this.props.documents;
const { sort } = this.props.match.params;
function Starred(props: Props) {
const { documents } = useStores();
const { t } = useTranslation();
const { fetchStarred, starred, starredAlphabetical } = documents;
const { sort } = props.match.params;
return (
<CenteredContent column auto>
<PageTitle title="Starred" />
<Heading>Starred</Heading>
<PaginatedDocumentList
heading={
<Tabs>
<Tab to="/starred" exact>
Recently Updated
</Tab>
<Tab to="/starred/alphabetical" exact>
Alphabetical
</Tab>
</Tabs>
}
empty={<Empty>Youve not starred any documents yet.</Empty>}
fetch={fetchStarred}
documents={sort === "alphabetical" ? starredAlphabetical : starred}
showCollection
/>
return (
<CenteredContent column auto>
<PageTitle title={t("Starred")} />
<Heading>{t("Starred")}</Heading>
<PaginatedDocumentList
heading={
<Tabs>
<Tab to="/starred" exact>
{t("Recently updated")}
</Tab>
<Tab to="/starred/alphabetical" exact>
{t("Alphabetical")}
</Tab>
</Tabs>
}
empty={<Empty>{t("Youve not starred any documents yet.")}</Empty>}
fetch={fetchStarred}
documents={sort === "alphabetical" ? starredAlphabetical : starred}
showCollection
/>
<Actions align="center" justify="flex-end">
<Action>
<InputSearch source="starred" />
</Action>
<Action>
<NewDocumentMenu />
</Action>
</Actions>
</CenteredContent>
);
}
<Actions align="center" justify="flex-end">
<Action>
<InputSearch source="starred" />
</Action>
<Action>
<NewDocumentMenu />
</Action>
</Actions>
</CenteredContent>
);
}
export default inject("documents")(Starred);
export default observer(Starred);

View File

@@ -1,9 +1,9 @@
// @flow
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { type Match } from "react-router-dom";
import DocumentsStore from "stores/DocumentsStore";
import Actions, { Action } from "components/Actions";
import CenteredContent from "components/CenteredContent";
import Empty from "components/Empty";
@@ -12,60 +12,54 @@ import PageTitle from "components/PageTitle";
import PaginatedDocumentList from "components/PaginatedDocumentList";
import Tab from "components/Tab";
import Tabs from "components/Tabs";
import useStores from "hooks/useStores";
import NewTemplateMenu from "menus/NewTemplateMenu";
type Props = {
documents: DocumentsStore,
match: Match,
};
@observer
class Templates extends React.Component<Props> {
render() {
const {
fetchTemplates,
templates,
templatesAlphabetical,
} = this.props.documents;
const { sort } = this.props.match.params;
function Templates(props: Props) {
const { documents } = useStores();
const { t } = useTranslation();
const { fetchTemplates, templates, templatesAlphabetical } = documents;
const { sort } = props.match.params;
return (
<CenteredContent column auto>
<PageTitle title="Templates" />
<Heading>Templates</Heading>
<PaginatedDocumentList
heading={
<Tabs>
<Tab to="/templates" exact>
Recently Updated
</Tab>
<Tab to="/templates/alphabetical" exact>
Alphabetical
</Tab>
</Tabs>
}
empty={
<Empty>
There are no templates just yet. You can create templates to help
your team create consistent and accurate documentation.
</Empty>
}
fetch={fetchTemplates}
documents={
sort === "alphabetical" ? templatesAlphabetical : templates
}
showCollection
showDraft
/>
return (
<CenteredContent column auto>
<PageTitle title={t("Templates")} />
<Heading>{t("Templates")}</Heading>
<PaginatedDocumentList
heading={
<Tabs>
<Tab to="/templates" exact>
{t("Recently updated")}
</Tab>
<Tab to="/templates/alphabetical" exact>
{t("Alphabetical")}
</Tab>
</Tabs>
}
empty={
<Empty>
{t(
"There are no templates just yet. You can create templates to help your team create consistent and accurate documentation."
)}
</Empty>
}
fetch={fetchTemplates}
documents={sort === "alphabetical" ? templatesAlphabetical : templates}
showCollection
showDraft
/>
<Actions align="center" justify="flex-end">
<Action>
<NewTemplateMenu />
</Action>
</Actions>
</CenteredContent>
);
}
<Actions align="center" justify="flex-end">
<Action>
<NewTemplateMenu />
</Action>
</Actions>
</CenteredContent>
);
}
export default inject("documents")(Templates);
export default observer(Templates);

View File

@@ -1,39 +1,34 @@
// @flow
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import DocumentsStore from "stores/DocumentsStore";
import CenteredContent from "components/CenteredContent";
import Empty from "components/Empty";
import Heading from "components/Heading";
import PageTitle from "components/PageTitle";
import PaginatedDocumentList from "components/PaginatedDocumentList";
import Subheading from "components/Subheading";
import useStores from "hooks/useStores";
type Props = {
documents: DocumentsStore,
};
function Trash() {
const { t } = useTranslation();
const { documents } = useStores();
@observer
class Trash extends React.Component<Props> {
render() {
const { documents } = this.props;
return (
<CenteredContent column auto>
<PageTitle title="Trash" />
<Heading>Trash</Heading>
<PaginatedDocumentList
documents={documents.deleted}
fetch={documents.fetchDeleted}
heading={<Subheading>Documents</Subheading>}
empty={<Empty>Trash is empty at the moment.</Empty>}
showCollection
showTemplate
/>
</CenteredContent>
);
}
return (
<CenteredContent column auto>
<PageTitle title={t("Trash")} />
<Heading>{t("Trash")}</Heading>
<PaginatedDocumentList
documents={documents.deleted}
fetch={documents.fetchDeleted}
heading={<Subheading>{t("Documents")}</Subheading>}
empty={<Empty>{t("Trash is empty at the moment.")}</Empty>}
showCollection
showTemplate
/>
</CenteredContent>
);
}
export default inject("documents")(Trash);
export default observer(Trash);

View File

@@ -3,6 +3,7 @@ import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
import { inject, observer } from "mobx-react";
import { EditIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import styled from "styled-components";
import { settings } from "shared/utils/routeHelpers";
@@ -26,61 +27,65 @@ type Props = {
onRequestClose: () => void,
};
@observer
class UserProfile extends React.Component<Props> {
render() {
const { user, auth, documents, ...rest } = this.props;
if (!user) return null;
const isCurrentUser = auth.user && auth.user.id === user.id;
function UserProfile(props: Props) {
const { t } = useTranslation();
const { user, auth, documents, ...rest } = props;
if (!user) return null;
const isCurrentUser = auth.user && auth.user.id === user.id;
return (
<Modal
title={
<Flex align="center">
<Avatar src={user.avatarUrl} size={38} />
<span>&nbsp;{user.name}</span>
</Flex>
}
{...rest}
>
<Flex column>
<Meta>
{isCurrentUser
? "You joined"
: user.lastActiveAt
? "Joined"
: "Invited"}{" "}
{distanceInWordsToNow(new Date(user.createdAt))} ago.
{user.isAdmin && (
<StyledBadge admin={user.isAdmin}>Admin</StyledBadge>
)}
{user.isSuspended && <Badge>Suspended</Badge>}
{isCurrentUser && (
<Edit>
<Button
onClick={() => this.props.history.push(settings())}
icon={<EditIcon />}
neutral
>
Edit Profile
</Button>
</Edit>
)}
</Meta>
<PaginatedDocumentList
documents={documents.createdByUser(user.id)}
fetch={documents.fetchOwned}
options={{ user: user.id }}
heading={<Subheading>Recently updated</Subheading>}
empty={
<HelpText>{user.name} hasnt updated any documents yet.</HelpText>
}
showCollection
/>
return (
<Modal
title={
<Flex align="center">
<Avatar src={user.avatarUrl} size={38} />
<span>&nbsp;{user.name}</span>
</Flex>
</Modal>
);
}
}
{...rest}
>
<Flex column>
<Meta>
{isCurrentUser
? t("You joined")
: user.lastActiveAt
? t("Joined")
: t("Invited")}{" "}
{t("{{ time }} ago.", {
time: distanceInWordsToNow(new Date(user.createdAt)),
})}
{user.isAdmin && (
<StyledBadge admin={user.isAdmin}>{t("Admin")}</StyledBadge>
)}
{user.isSuspended && <Badge>{t("Suspended")}</Badge>}
{isCurrentUser && (
<Edit>
<Button
onClick={() => this.props.history.push(settings())}
icon={<EditIcon />}
neutral
>
{t("Edit Profile")}
</Button>
</Edit>
)}
</Meta>
<PaginatedDocumentList
documents={documents.createdByUser(user.id)}
fetch={documents.fetchOwned}
options={{ user: user.id }}
heading={<Subheading>{t("Recently updated")}</Subheading>}
empty={
<HelpText>
{t("{{ userName }} hasnt updated any documents yet.", {
userName: user.name,
})}
</HelpText>
}
showCollection
/>
</Flex>
</Modal>
);
}
const Edit = styled.span`
@@ -98,4 +103,4 @@ const Meta = styled(HelpText)`
margin-top: -12px;
`;
export default inject("documents", "auth")(withRouter(UserProfile));
export default inject("documents", "auth")(withRouter(observer(UserProfile)));