mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
27 Commits
yjs
...
dropzone-version
| Author | SHA1 | Date | |
|---|---|---|---|
| 418627f901 | |||
| 6153f392ec | |||
| db404b63fe | |||
| c1ea8fa6a4 | |||
| 03175b38ad | |||
| 474fbf07e6 | |||
| fe62048890 | |||
| 1851477290 | |||
| bde6f4b3c4 | |||
| 283b479689 | |||
| 183f06c2d1 | |||
| 21fff8d172 | |||
| 18e56aff65 | |||
| a97523a652 | |||
| 2316512a19 | |||
| 1285efc49a | |||
| 63c73c9a51 | |||
| 1b7fe0f7da | |||
| 6eda1cc0d3 | |||
| ac349b40f5 | |||
| 8bddc1b338 | |||
| 56d5f048f9 | |||
| 273d9c4680 | |||
| 44ca447185 | |||
| 6b511e4251 | |||
| de6ee91d96 | |||
| 18fac781a9 |
@@ -60,3 +60,5 @@ SMTP_REPLY_EMAIL=
|
||||
|
||||
# Custom logo that displays on the authentication screen, scaled to height: 60px
|
||||
# TEAM_LOGO=https://example.com/images/logo.png
|
||||
|
||||
DEFAULT_LANGUAGE=en_US
|
||||
@@ -0,0 +1,10 @@
|
||||
# Set to true to add reviewers to pull requests
|
||||
addReviewers: true
|
||||
|
||||
# A list of reviewers to be added to pull requests (GitHub user name)
|
||||
reviewers:
|
||||
- tommoor
|
||||
|
||||
# A list of keywords to be skipped the process that add reviewers if pull requests include it
|
||||
skipKeywords:
|
||||
- wip
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.49.0
|
||||
Licensed Work: Outline 0.50.0
|
||||
The Licensed Work is (c) 2020 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2023-10-26
|
||||
Change Date: 2023-11-14
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@ However, before working on a pull request please let the core team know by creat
|
||||
|
||||
If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
|
||||
|
||||
* [Translation](TRANSLATION.md) into other languages
|
||||
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
|
||||
* Performance improvements, both on server and frontend
|
||||
* Developer happiness and documentation
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Translation
|
||||
|
||||
Outline is localized through community contributions. The text in Outline's user interface is in American English by default, we're very thankful for all help that the community provides bringing the app to different languages.
|
||||
|
||||
## Externalizing strings
|
||||
|
||||
Before a string can be translated, it must be externalized. This is the process where English strings in the source code are wrapped in a function that retrieves the translated string for the user’s language.
|
||||
|
||||
For externalization we use [react-i18next](https://react.i18next.com/), this provides the hooks [useTranslation](https://react.i18next.com/latest/usetranslation-hook) and the [Trans](https://react.i18next.com/latest/trans-component) component for wrapping English text.
|
||||
|
||||
PR's are accepted for wrapping English strings in the codebase that were not previously externalized.
|
||||
|
||||
## Translating strings
|
||||
|
||||
To manage the translation process we use [CrowdIn](https://translate.getoutline.com/), it keeps track of which strings in which languages still need translating, synchronizes with the codebase automatically, and provides a great editor interface.
|
||||
|
||||
You'll need to create a free account to use CrowdIn. Once you have joined, you can provide translations by following these steps:
|
||||
|
||||
1. Select the language for which you want to contribute (or vote for) a translation (below the language you can see the progress of the translation)
|
||||

|
||||
|
||||
2. Please choose the translation.json file from your desired language
|
||||
|
||||
3. Once a file is selected, all the strings associated with the version are displayed on the left side. To display the untranslated strings first, select the filter icon next to the search bar and select “All, Untranslated First”.The red square next to an English string shows that a string has not been translated yet. To provide a translation, select a string on the left side, provide a translation in the target language in the text box in the right side (singular and plural) and press the save button. As soon as a translation has been provided by another user (green square next to string), you can also vote on a translation provided by another user. The translation with the most votes is used unless a different translation has been approved by a proof reader. 
|
||||
|
||||
## Proofreading
|
||||
|
||||
Once a translation has been provided, a proof reader can approve the translation and mark it for use in Outline.
|
||||
|
||||
If you are interested in becoming a proof reader, please contact one of the project managers in the Outline CrowdIn project or contact [@tommoor](https://github.com/tommoor). Similarly, if your language is not listed in the list of CrowdIn languages, please contact our project managers or [send us an email](https://www.getoutline.com/contact) so we can add your language.
|
||||
|
||||
## Release
|
||||
|
||||
Updated translations are automatically PR'd against the codebase by a bot and will be merged regularly so that new translations appear in the next release of Outline.
|
||||
@@ -0,0 +1,18 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
export default class Queue {
|
||||
name;
|
||||
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
process = (fn) => {
|
||||
console.log(`Registered function ${this.name}`);
|
||||
this.processFn = fn;
|
||||
};
|
||||
|
||||
add = (data) => {
|
||||
console.log(`Running ${this.name}`);
|
||||
return this.processFn({ data });
|
||||
};
|
||||
}
|
||||
@@ -1,18 +1,30 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import { isCustomSubdomain } from "shared/utils/domains";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
import useStores from "../hooks/useStores";
|
||||
import env from "env";
|
||||
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
children?: React.Node,
|
||||
children: React.Node,
|
||||
};
|
||||
|
||||
const Authenticated = observer(({ auth, children }: Props) => {
|
||||
const Authenticated = ({ children }: Props) => {
|
||||
const { auth } = useStores();
|
||||
const { i18n } = useTranslation();
|
||||
const language = auth.user && auth.user.language;
|
||||
|
||||
// Watching for language changes here as this is the earliest point we have
|
||||
// the user available and means we can start loading translations faster
|
||||
React.useEffect(() => {
|
||||
if (i18n.language !== language) {
|
||||
i18n.changeLanguage(language);
|
||||
}
|
||||
}, [i18n, language]);
|
||||
|
||||
if (auth.authenticated) {
|
||||
const { user, team } = auth;
|
||||
const { hostname } = window.location;
|
||||
@@ -43,6 +55,6 @@ const Authenticated = observer(({ auth, children }: Props) => {
|
||||
|
||||
auth.logout(true);
|
||||
return <Redirect to="/" />;
|
||||
});
|
||||
};
|
||||
|
||||
export default inject("auth")(Authenticated);
|
||||
export default observer(Authenticated);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import User from "models/User";
|
||||
import UserProfile from "scenes/UserProfile";
|
||||
@@ -16,6 +17,7 @@ type Props = {
|
||||
isEditing: boolean,
|
||||
isCurrentUser: boolean,
|
||||
lastViewedAt: string,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -37,20 +39,25 @@ class AvatarWithPresence extends React.Component<Props> {
|
||||
isPresent,
|
||||
isEditing,
|
||||
isCurrentUser,
|
||||
t,
|
||||
} = this.props;
|
||||
|
||||
const action = isPresent
|
||||
? isEditing
|
||||
? t("currently editing")
|
||||
: t("currently viewing")
|
||||
: t("viewed {{ timeAgo }} ago", {
|
||||
timeAgo: distanceInWordsToNow(new Date(lastViewedAt)),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
tooltip={
|
||||
<Centered>
|
||||
<strong>{user.name}</strong> {isCurrentUser && "(You)"}
|
||||
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
|
||||
<br />
|
||||
{isPresent
|
||||
? isEditing
|
||||
? "currently editing"
|
||||
: "currently viewing"
|
||||
: `viewed ${distanceInWordsToNow(new Date(lastViewedAt))} ago`}
|
||||
{action}
|
||||
</Centered>
|
||||
}
|
||||
placement="bottom"
|
||||
@@ -83,4 +90,4 @@ const AvatarWrapper = styled.div`
|
||||
transition: opacity 250ms ease-in-out;
|
||||
`;
|
||||
|
||||
export default AvatarWithPresence;
|
||||
export default withTranslation()<AvatarWithPresence>(AvatarWithPresence);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
EditIcon,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
TrashIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
@@ -19,6 +20,7 @@ import Document from "models/Document";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import Flex from "components/Flex";
|
||||
import BreadcrumbMenu from "./BreadcrumbMenu";
|
||||
import useStores from "hooks/useStores";
|
||||
import { collectionUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
@@ -28,13 +30,15 @@ type Props = {
|
||||
};
|
||||
|
||||
function Icon({ document }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (document.isDeleted) {
|
||||
return (
|
||||
<>
|
||||
<CollectionName to="/trash">
|
||||
<TrashIcon color="currentColor" />
|
||||
|
||||
<span>Trash</span>
|
||||
<span>{t("Trash")}</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
@@ -46,7 +50,7 @@ function Icon({ document }) {
|
||||
<CollectionName to="/archive">
|
||||
<ArchiveIcon color="currentColor" />
|
||||
|
||||
<span>Archive</span>
|
||||
<span>{t("Archive")}</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
@@ -58,7 +62,7 @@ function Icon({ document }) {
|
||||
<CollectionName to="/drafts">
|
||||
<EditIcon color="currentColor" />
|
||||
|
||||
<span>Drafts</span>
|
||||
<span>{t("Drafts")}</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
@@ -70,7 +74,7 @@ function Icon({ document }) {
|
||||
<CollectionName to="/templates">
|
||||
<ShapesIcon color="currentColor" />
|
||||
|
||||
<span>Templates</span>
|
||||
<span>{t("Templates")}</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
@@ -79,14 +83,17 @@ function Icon({ document }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
|
||||
const Breadcrumb = ({ document, onlyText }: Props) => {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
let collection = collections.get(document.collectionId);
|
||||
if (!collection) {
|
||||
if (!document.deletedAt) return <div />;
|
||||
|
||||
collection = {
|
||||
id: document.collectionId,
|
||||
name: "Deleted Collection",
|
||||
name: t("Deleted Collection"),
|
||||
color: "currentColor",
|
||||
};
|
||||
}
|
||||
@@ -141,7 +148,7 @@ const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
display: none;
|
||||
@@ -202,4 +209,4 @@ const CollectionName = styled(Link)`
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export default inject("collections")(Breadcrumb);
|
||||
export default observer(Breadcrumb);
|
||||
|
||||
@@ -20,7 +20,9 @@ type Props = {
|
||||
@observer
|
||||
class Collaborators extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
this.props.views.fetchPage({ documentId: this.props.document.id });
|
||||
if (!this.props.document.isDeleted) {
|
||||
this.props.views.fetchPage({ documentId: this.props.document.id });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// @flow
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import Document from "models/Document";
|
||||
import Breadcrumb from "components/Breadcrumb";
|
||||
import Flex from "components/Flex";
|
||||
import Time from "components/Time";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
const Container = styled(Flex)`
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
@@ -23,8 +23,6 @@ const Modified = styled.span`
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
collections: CollectionsStore,
|
||||
auth: AuthStore,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showLastViewed?: boolean,
|
||||
@@ -34,8 +32,6 @@ type Props = {
|
||||
};
|
||||
|
||||
function DocumentMeta({
|
||||
auth,
|
||||
collections,
|
||||
showPublished,
|
||||
showCollection,
|
||||
showLastViewed,
|
||||
@@ -44,6 +40,8 @@ function DocumentMeta({
|
||||
to,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { collections, auth } = useStores();
|
||||
const {
|
||||
modifiedSinceViewed,
|
||||
updatedAt,
|
||||
@@ -67,37 +65,37 @@ function DocumentMeta({
|
||||
if (deletedAt) {
|
||||
content = (
|
||||
<span>
|
||||
deleted <Time dateTime={deletedAt} addSuffix />
|
||||
{t("deleted")} <Time dateTime={deletedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (archivedAt) {
|
||||
content = (
|
||||
<span>
|
||||
archived <Time dateTime={archivedAt} addSuffix />
|
||||
{t("archived")} <Time dateTime={archivedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (createdAt === updatedAt) {
|
||||
content = (
|
||||
<span>
|
||||
created <Time dateTime={updatedAt} addSuffix />
|
||||
{t("created")} <Time dateTime={updatedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (publishedAt && (publishedAt === updatedAt || showPublished)) {
|
||||
content = (
|
||||
<span>
|
||||
published <Time dateTime={publishedAt} addSuffix />
|
||||
{t("published")} <Time dateTime={publishedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (isDraft) {
|
||||
content = (
|
||||
<span>
|
||||
saved <Time dateTime={updatedAt} addSuffix />
|
||||
{t("saved")} <Time dateTime={updatedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<Modified highlight={modifiedSinceViewed}>
|
||||
updated <Time dateTime={updatedAt} addSuffix />
|
||||
{t("updated")} <Time dateTime={updatedAt} addSuffix />
|
||||
</Modified>
|
||||
);
|
||||
}
|
||||
@@ -112,25 +110,25 @@ function DocumentMeta({
|
||||
if (!lastViewedAt) {
|
||||
return (
|
||||
<>
|
||||
• <Modified highlight>Never viewed</Modified>
|
||||
• <Modified highlight>{t("Never viewed")}</Modified>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
• Viewed <Time dateTime={lastViewedAt} addSuffix shorten />
|
||||
• {t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container align="center" {...rest}>
|
||||
{updatedByMe ? "You" : updatedBy.name}
|
||||
{updatedByMe ? t("You") : updatedBy.name}
|
||||
{to ? <Link to={to}>{content}</Link> : content}
|
||||
{showCollection && collection && (
|
||||
<span>
|
||||
in
|
||||
{t("in")}
|
||||
<strong>
|
||||
<Breadcrumb document={document} onlyText />
|
||||
</strong>
|
||||
@@ -142,4 +140,4 @@ function DocumentMeta({
|
||||
);
|
||||
}
|
||||
|
||||
export default inject("collections", "auth")(observer(DocumentMeta));
|
||||
export default observer(DocumentMeta);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { StarredIcon, PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { Link, Redirect } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import Document from "models/Document";
|
||||
@@ -25,6 +26,7 @@ type Props = {
|
||||
showPin?: boolean,
|
||||
showDraft?: boolean,
|
||||
showTemplate?: boolean,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
@@ -72,6 +74,7 @@ class DocumentPreview extends React.Component<Props> {
|
||||
showTemplate,
|
||||
highlight,
|
||||
context,
|
||||
t,
|
||||
} = this.props;
|
||||
|
||||
if (this.redirectTo) {
|
||||
@@ -91,7 +94,7 @@ class DocumentPreview extends React.Component<Props> {
|
||||
>
|
||||
<Heading>
|
||||
<Title text={document.titleWithDefault} highlight={highlight} />
|
||||
{document.isNew && <Badge yellow>New</Badge>}
|
||||
{document.isNew && <Badge yellow>{t("New")}</Badge>}
|
||||
{!document.isDraft &&
|
||||
!document.isArchived &&
|
||||
!document.isTemplate && (
|
||||
@@ -104,12 +107,16 @@ class DocumentPreview extends React.Component<Props> {
|
||||
</Actions>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip tooltip="Only visible to you" delay={500} placement="top">
|
||||
<Badge>Draft</Badge>
|
||||
<Tooltip
|
||||
tooltip={t("Only visible to you")}
|
||||
delay={500}
|
||||
placement="top"
|
||||
>
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>Template</Badge>
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
<SecondaryActions>
|
||||
{document.isTemplate &&
|
||||
@@ -120,7 +127,7 @@ class DocumentPreview extends React.Component<Props> {
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
New doc
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -237,4 +244,4 @@ const ResultContext = styled(Highlight)`
|
||||
margin-bottom: 0.25em;
|
||||
`;
|
||||
|
||||
export default DocumentPreview;
|
||||
export default withTranslation()<DocumentPreview>(DocumentPreview);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
|
||||
import { createGlobalStyle } from "styled-components";
|
||||
import styled, { css } from "styled-components";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
@@ -17,8 +17,6 @@ type Props = {
|
||||
children: React.Node,
|
||||
collectionId: string,
|
||||
documentId?: string,
|
||||
activeClassName?: string,
|
||||
rejectClassName?: string,
|
||||
ui: UiStore,
|
||||
documents: DocumentsStore,
|
||||
disabled: boolean,
|
||||
@@ -28,18 +26,6 @@ type Props = {
|
||||
staticContext: Object,
|
||||
};
|
||||
|
||||
export const GlobalStyles = createGlobalStyle`
|
||||
.activeDropZone {
|
||||
border-radius: 4px;
|
||||
background: ${(props) => props.theme.slateDark};
|
||||
svg { fill: ${(props) => props.theme.white}; }
|
||||
}
|
||||
|
||||
.activeDropZone a {
|
||||
color: ${(props) => props.theme.white} !important;
|
||||
}
|
||||
`;
|
||||
|
||||
@observer
|
||||
class DropToImport extends React.Component<Props> {
|
||||
@observable isImporting: boolean = false;
|
||||
@@ -82,17 +68,7 @@ class DropToImport extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
documentId,
|
||||
collectionId,
|
||||
documents,
|
||||
disabled,
|
||||
location,
|
||||
match,
|
||||
history,
|
||||
staticContext,
|
||||
...rest
|
||||
} = this.props;
|
||||
const { documents } = this.props;
|
||||
|
||||
if (this.props.disabled) return this.props.children;
|
||||
|
||||
@@ -101,16 +77,41 @@ class DropToImport extends React.Component<Props> {
|
||||
accept={documents.importFileTypes.join(", ")}
|
||||
onDropAccepted={this.onDropAccepted}
|
||||
style={EMPTY_OBJECT}
|
||||
disableClick
|
||||
disablePreview
|
||||
noClick
|
||||
multiple
|
||||
{...rest}
|
||||
>
|
||||
{this.isImporting && <LoadingIndicator />}
|
||||
{this.props.children}
|
||||
{({
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
isDragActive,
|
||||
isDragAccept,
|
||||
isDragReject,
|
||||
}) => (
|
||||
<DropzoneContainer {...getRootProps()} {...{ isDragActive }}>
|
||||
<input {...getInputProps()} />
|
||||
{this.isImporting && <LoadingIndicator />}
|
||||
{this.props.children}
|
||||
</DropzoneContainer>
|
||||
)}
|
||||
</Dropzone>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const DropzoneContainer = styled("div")`
|
||||
border-radius: 4px;
|
||||
|
||||
${({ isDragActive, theme }) =>
|
||||
isDragActive &&
|
||||
css`
|
||||
background: ${theme.slateDark};
|
||||
a {
|
||||
color: ${theme.white} !important;
|
||||
}
|
||||
svg {
|
||||
fill: ${theme.white};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export default inject("documents", "ui")(withRouter(DropToImport));
|
||||
|
||||
@@ -5,6 +5,7 @@ import { observer } from "mobx-react";
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import { rgba } from "polished";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { PortalWithState } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||
@@ -27,6 +28,7 @@ type Props = {|
|
||||
hover?: boolean,
|
||||
style?: Object,
|
||||
position?: "left" | "right" | "center",
|
||||
t: TFunction,
|
||||
|};
|
||||
|
||||
@observer
|
||||
@@ -150,7 +152,7 @@ class DropdownMenu extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { className, hover, label, children } = this.props;
|
||||
const { className, hover, label, children, t } = this.props;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
@@ -177,7 +179,7 @@ class DropdownMenu extends React.Component<Props> {
|
||||
{label || (
|
||||
<NudeButton
|
||||
id={`${this.id}button`}
|
||||
aria-label="More options"
|
||||
aria-label={t("More options")}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isOpen ? "true" : "false"}
|
||||
aria-controls={this.id}
|
||||
@@ -284,4 +286,4 @@ export const Header = styled.h3`
|
||||
margin: 1em 12px 0.5em;
|
||||
`;
|
||||
|
||||
export default DropdownMenu;
|
||||
export default withTranslation()<DropdownMenu>(DropdownMenu);
|
||||
|
||||
+122
-48
@@ -1,6 +1,7 @@
|
||||
// @flow
|
||||
import { lighten } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import UiStore from "stores/UiStore";
|
||||
@@ -28,60 +29,129 @@ type PropsWithRef = Props & {
|
||||
history: RouterHistory,
|
||||
};
|
||||
|
||||
class Editor extends React.Component<PropsWithRef> {
|
||||
onUploadImage = async (file: File) => {
|
||||
const result = await uploadFile(file, { documentId: this.props.id });
|
||||
return result.url;
|
||||
};
|
||||
function Editor(props: PropsWithRef) {
|
||||
const { id, ui, history } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
onClickLink = (href: string, event: MouseEvent) => {
|
||||
// on page hash
|
||||
if (href[0] === "#") {
|
||||
window.location.href = href;
|
||||
return;
|
||||
}
|
||||
const onUploadImage = React.useCallback(
|
||||
async (file: File) => {
|
||||
const result = await uploadFile(file, { documentId: id });
|
||||
return result.url;
|
||||
},
|
||||
[id]
|
||||
);
|
||||
|
||||
if (isInternalUrl(href) && !event.metaKey && !event.shiftKey) {
|
||||
// relative
|
||||
let navigateTo = href;
|
||||
|
||||
// probably absolute
|
||||
if (href[0] !== "/") {
|
||||
try {
|
||||
const url = new URL(href);
|
||||
navigateTo = url.pathname + url.hash;
|
||||
} catch (err) {
|
||||
navigateTo = href;
|
||||
}
|
||||
const onClickLink = React.useCallback(
|
||||
(href: string, event: MouseEvent) => {
|
||||
// on page hash
|
||||
if (href[0] === "#") {
|
||||
window.location.href = href;
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.history.push(navigateTo);
|
||||
} else if (href) {
|
||||
window.open(href, "_blank");
|
||||
}
|
||||
};
|
||||
if (isInternalUrl(href) && !event.metaKey && !event.shiftKey) {
|
||||
// relative
|
||||
let navigateTo = href;
|
||||
|
||||
onShowToast = (message: string) => {
|
||||
if (this.props.ui) {
|
||||
this.props.ui.showToast(message);
|
||||
}
|
||||
};
|
||||
// probably absolute
|
||||
if (href[0] !== "/") {
|
||||
try {
|
||||
const url = new URL(href);
|
||||
navigateTo = url.pathname + url.hash;
|
||||
} catch (err) {
|
||||
navigateTo = href;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ErrorBoundary reloadOnChunkMissing>
|
||||
<StyledEditor
|
||||
ref={this.props.forwardedRef}
|
||||
uploadImage={this.onUploadImage}
|
||||
onClickLink={this.onClickLink}
|
||||
onShowToast={this.onShowToast}
|
||||
embeds={this.props.disableEmbeds ? EMPTY_ARRAY : embeds}
|
||||
tooltip={EditorTooltip}
|
||||
{...this.props}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
history.push(navigateTo);
|
||||
} else if (href) {
|
||||
window.open(href, "_blank");
|
||||
}
|
||||
},
|
||||
[history]
|
||||
);
|
||||
|
||||
const onShowToast = React.useCallback(
|
||||
(message: string) => {
|
||||
if (ui) {
|
||||
ui.showToast(message);
|
||||
}
|
||||
},
|
||||
[ui]
|
||||
);
|
||||
|
||||
const dictionary = React.useMemo(() => {
|
||||
return {
|
||||
addColumnAfter: t("Insert column after"),
|
||||
addColumnBefore: t("Insert column before"),
|
||||
addRowAfter: t("Insert row after"),
|
||||
addRowBefore: t("Insert row before"),
|
||||
alignCenter: t("Align center"),
|
||||
alignLeft: t("Align left"),
|
||||
alignRight: t("Align right"),
|
||||
bulletList: t("Bulleted list"),
|
||||
checkboxList: t("Todo list"),
|
||||
codeBlock: t("Code block"),
|
||||
codeCopied: t("Copied to clipboard"),
|
||||
codeInline: t("Code"),
|
||||
createLink: t("Create link"),
|
||||
createLinkError: t("Sorry, an error occurred creating the link"),
|
||||
createNewDoc: t("Create a new doc"),
|
||||
deleteColumn: t("Delete column"),
|
||||
deleteRow: t("Delete row"),
|
||||
deleteTable: t("Delete table"),
|
||||
em: t("Italic"),
|
||||
embedInvalidLink: t("Sorry, that link won’t work for this embed type"),
|
||||
findOrCreateDoc: t("Find or create a doc…"),
|
||||
h1: t("Big heading"),
|
||||
h2: t("Medium heading"),
|
||||
h3: t("Small heading"),
|
||||
heading: t("Heading"),
|
||||
hr: t("Divider"),
|
||||
image: t("Image"),
|
||||
imageUploadError: t("Sorry, an error occurred uploading the image"),
|
||||
info: t("Info"),
|
||||
infoNotice: t("Info notice"),
|
||||
link: t("Link"),
|
||||
linkCopied: t("Link copied to clipboard"),
|
||||
mark: t("Highlight"),
|
||||
newLineEmpty: t("Type '/' to insert…"),
|
||||
newLineWithSlash: t("Keep typing to filter…"),
|
||||
noResults: t("No results"),
|
||||
openLink: t("Open link"),
|
||||
orderedList: t("Ordered list"),
|
||||
pasteLink: t("Paste a link…"),
|
||||
pasteLinkWithTitle: (service: string) =>
|
||||
t("Paste a {{service}} link…", { service }),
|
||||
placeholder: t("Placeholder"),
|
||||
quote: t("Quote"),
|
||||
removeLink: t("Remove link"),
|
||||
searchOrPasteLink: t("Search or paste a link…"),
|
||||
strikethrough: t("Strikethrough"),
|
||||
strong: t("Bold"),
|
||||
subheading: t("Subheading"),
|
||||
table: t("Table"),
|
||||
tip: t("Tip"),
|
||||
tipNotice: t("Tip notice"),
|
||||
warning: t("Warning"),
|
||||
warningNotice: t("Warning notice"),
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary reloadOnChunkMissing>
|
||||
<StyledEditor
|
||||
ref={props.forwardedRef}
|
||||
uploadImage={onUploadImage}
|
||||
onClickLink={onClickLink}
|
||||
onShowToast={onShowToast}
|
||||
embeds={props.disableEmbeds ? EMPTY_ARRAY : embeds}
|
||||
tooltip={EditorTooltip}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledEditor = styled(RichMarkdownEditor)`
|
||||
@@ -92,6 +162,10 @@ const StyledEditor = styled(RichMarkdownEditor)`
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
}
|
||||
|
||||
& * {
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.notice-block.tip,
|
||||
.notice-block.warning {
|
||||
font-weight: 500;
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
// @flow
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import DocumentMetaWithViews from "components/DocumentMetaWithViews";
|
||||
import Editor from "components/Editor";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
documents: DocumentsStore,
|
||||
children: (React.Node) => React.Node,
|
||||
};
|
||||
|
||||
function HoverPreviewDocument({ url, documents, children }: Props) {
|
||||
function HoverPreviewDocument({ url, children }: Props) {
|
||||
const { documents } = useStores();
|
||||
const slug = parseDocumentSlug(url);
|
||||
|
||||
documents.prefetchDocument(slug, {
|
||||
@@ -50,4 +50,4 @@ const Heading = styled.h2`
|
||||
color: ${(props) => props.theme.text};
|
||||
`;
|
||||
|
||||
export default inject("documents")(observer(HoverPreviewDocument));
|
||||
export default observer(HoverPreviewDocument);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
VehicleIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { DropdownMenu } from "components/DropdownMenu";
|
||||
import Flex from "components/Flex";
|
||||
@@ -126,6 +127,7 @@ type Props = {
|
||||
onChange: (color: string, icon: string) => void,
|
||||
icon: string,
|
||||
color: string,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
function preventEventBubble(event) {
|
||||
@@ -167,12 +169,13 @@ class IconPicker extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
const Component = icons[this.props.icon || "collection"].component;
|
||||
|
||||
return (
|
||||
<Wrapper ref={(ref) => (this.node = ref)}>
|
||||
<label>
|
||||
<LabelText>Icon</LabelText>
|
||||
<LabelText>{t("Icon")}</LabelText>
|
||||
</label>
|
||||
<DropdownMenu
|
||||
onOpen={this.handleOpen}
|
||||
@@ -197,7 +200,7 @@ class IconPicker extends React.Component<Props> {
|
||||
})}
|
||||
</Icons>
|
||||
<Flex onClick={preventEventBubble}>
|
||||
<React.Suspense fallback={<Loading>Loading…</Loading>}>
|
||||
<React.Suspense fallback={<Loading>{t("Loading…")}</Loading>}>
|
||||
<ColorPicker
|
||||
color={this.props.color}
|
||||
onChange={(color) =>
|
||||
@@ -246,4 +249,4 @@ const Wrapper = styled("div")`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export default IconPicker;
|
||||
export default withTranslation()<IconPicker>(IconPicker);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import keydown from "react-keydown";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
@@ -16,6 +17,7 @@ type Props = {
|
||||
source: string,
|
||||
placeholder?: string,
|
||||
collectionId?: string,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -24,7 +26,7 @@ class InputSearch extends React.Component<Props> {
|
||||
@observable focused: boolean = false;
|
||||
|
||||
@keydown("meta+f")
|
||||
focus(ev) {
|
||||
focus(ev: SyntheticEvent<>) {
|
||||
ev.preventDefault();
|
||||
|
||||
if (this.input) {
|
||||
@@ -32,7 +34,7 @@ class InputSearch extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
handleSearchInput = (ev) => {
|
||||
handleSearchInput = (ev: SyntheticInputEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.props.history.push(
|
||||
searchUrl(ev.target.value, {
|
||||
@@ -51,7 +53,8 @@ class InputSearch extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { theme, placeholder = "Search…" } = this.props;
|
||||
const { t } = this.props;
|
||||
const { theme, placeholder = t("Search…") } = this.props;
|
||||
|
||||
return (
|
||||
<InputMaxWidth
|
||||
@@ -76,4 +79,6 @@ const InputMaxWidth = styled(Input)`
|
||||
max-width: 30vw;
|
||||
`;
|
||||
|
||||
export default withTheme(withRouter(InputSearch));
|
||||
export default withTranslation()<InputSearch>(
|
||||
withTheme(withRouter(InputSearch))
|
||||
);
|
||||
|
||||
@@ -20,11 +20,17 @@ const Select = styled.select`
|
||||
}
|
||||
`;
|
||||
|
||||
const Wrapper = styled.label`
|
||||
display: block;
|
||||
max-width: ${(props) => (props.short ? "350px" : "100%")};
|
||||
`;
|
||||
|
||||
type Option = { label: string, value: string };
|
||||
|
||||
export type Props = {
|
||||
value?: string,
|
||||
label?: string,
|
||||
short?: boolean,
|
||||
className?: string,
|
||||
labelHidden?: boolean,
|
||||
options: Option[],
|
||||
@@ -43,12 +49,19 @@ class InputSelect extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { label, className, labelHidden, options, ...rest } = this.props;
|
||||
const {
|
||||
label,
|
||||
className,
|
||||
labelHidden,
|
||||
options,
|
||||
short,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
|
||||
return (
|
||||
<label>
|
||||
<Wrapper short={short}>
|
||||
{label &&
|
||||
(labelHidden ? (
|
||||
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
|
||||
@@ -64,7 +77,7 @@ class InputSelect extends React.Component<Props> {
|
||||
))}
|
||||
</Select>
|
||||
</Outline>
|
||||
</label>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// @flow
|
||||
import { find } from "lodash";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { languages, languageOptions } from "shared/i18n";
|
||||
import Flex from "components/Flex";
|
||||
import NoticeTip from "components/NoticeTip";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import useStores from "hooks/useStores";
|
||||
import { detectLanguage } from "utils/language";
|
||||
|
||||
function Icon(props) {
|
||||
return (
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M21 18H16L14 16V6C14 4.89543 14.8954 4 16 4H28C29.1046 4 30 4.89543 30 6V16C30 17.1046 29.1046 18 28 18H27L25.4142 19.5858C24.6332 20.3668 23.3668 20.3668 22.5858 19.5858L21 18ZM16 15.1716V6H28V16H27H26.1716L25.5858 16.5858L24 18.1716L22.4142 16.5858L21.8284 16H21H16.8284L16 15.1716Z"
|
||||
fill="#2B2F35"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M16 13H4C2.89543 13 2 13.8954 2 15V25C2 26.1046 2.89543 27 4 27H5L6.58579 28.5858C7.36684 29.3668 8.63316 29.3668 9.41421 28.5858L11 27H16C17.1046 27 18 26.1046 18 25V15C18 13.8954 17.1046 13 16 13ZM9 17L6 16.9681C6 16.9681 5 17.016 5 18C5 18.984 6 19 6 19H8.5H10C10 19 9.57627 20.1885 8.38983 21.0831C7.20339 21.9777 5.7197 23 5.7197 23C5.7197 23 4.99153 23.6054 5.5 24.5C6.00847 25.3946 7 24.8403 7 24.8403L9.74576 22.8722L11.9492 24.6614C11.9492 24.6614 12.6271 25.3771 13.3051 24.4825C13.9831 23.5879 13.3051 23.0512 13.3051 23.0512L11.1017 21.262C11.1017 21.262 11.5 21 12 20L12.5 19H14C14 19 15 19.0319 15 18C15 16.9681 14 16.9681 14 16.9681L11 17V16C11 16 11.0169 15 10 15C8.98305 15 9 16 9 16V17Z"
|
||||
fill="#2B2F35"
|
||||
/>
|
||||
<path
|
||||
d="M23.6672 12.5221L23.5526 12.1816H23.1934H20.8818H20.5215L20.4075 12.5235L20.082 13.5H19.2196L21.2292 8.10156H21.8774L21.5587 9.06116L20.7633 11.4562L20.5449 12.1138H21.2378H22.8374H23.5327L23.3114 11.4546L22.5072 9.05959L22.1855 8.10156H22.768L24.7887 13.5H23.9964L23.6672 12.5221Z"
|
||||
fill="#2B2F35"
|
||||
stroke="#2B2F35"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LanguagePrompt() {
|
||||
const { auth, ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const language = detectLanguage();
|
||||
|
||||
if (language === "en_US" || language === user.language) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!languages.includes(language)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const option = find(languageOptions, (o) => o.value === language);
|
||||
const optionLabel = option ? option.label : "";
|
||||
|
||||
return (
|
||||
<NoticeTip>
|
||||
<Flex align="center">
|
||||
<LanguageIcon />
|
||||
<span>
|
||||
<Trans>
|
||||
Outline is available in your language {{ optionLabel }}, would you
|
||||
like to change?
|
||||
</Trans>
|
||||
<br />
|
||||
<a
|
||||
onClick={() => {
|
||||
auth.updateUser({
|
||||
language,
|
||||
});
|
||||
ui.setLanguagePromptDismissed();
|
||||
}}
|
||||
>
|
||||
{t("Change Language")}
|
||||
</a>{" "}
|
||||
· <a onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</a>
|
||||
</span>
|
||||
</Flex>
|
||||
</NoticeTip>
|
||||
);
|
||||
}
|
||||
|
||||
const LanguageIcon = styled(Icon)`
|
||||
margin-right: 12px;
|
||||
`;
|
||||
@@ -3,6 +3,7 @@ import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import keydown from "react-keydown";
|
||||
import { Switch, Route, Redirect } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
@@ -14,7 +15,6 @@ import ErrorSuspended from "scenes/ErrorSuspended";
|
||||
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
|
||||
import Analytics from "components/Analytics";
|
||||
import DocumentHistory from "components/DocumentHistory";
|
||||
import { GlobalStyles } from "components/DropToImport";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
import { LoadingIndicatorBar } from "components/LoadingIndicator";
|
||||
@@ -37,6 +37,8 @@ type Props = {
|
||||
ui: UiStore,
|
||||
notifications?: React.Node,
|
||||
theme: Theme,
|
||||
i18n: Object,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -45,7 +47,7 @@ class Layout extends React.Component<Props> {
|
||||
@observable redirectTo: ?string;
|
||||
@observable keyboardShortcutsOpen: boolean = false;
|
||||
|
||||
constructor(props) {
|
||||
constructor(props: Props) {
|
||||
super();
|
||||
this.updateBackground(props);
|
||||
}
|
||||
@@ -58,7 +60,7 @@ class Layout extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
updateBackground(props) {
|
||||
updateBackground(props: Props) {
|
||||
// ensure the wider page color always matches the theme
|
||||
window.document.body.style.background = props.theme.background;
|
||||
}
|
||||
@@ -74,7 +76,7 @@ class Layout extends React.Component<Props> {
|
||||
};
|
||||
|
||||
@keydown(["t", "/", "meta+k"])
|
||||
goToSearch(ev) {
|
||||
goToSearch(ev: SyntheticEvent<>) {
|
||||
if (this.props.ui.editMode) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
@@ -88,7 +90,7 @@ class Layout extends React.Component<Props> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { auth, ui } = this.props;
|
||||
const { auth, t, ui } = this.props;
|
||||
const { user, team } = auth;
|
||||
const showSidebar = auth.authenticated && user && team;
|
||||
|
||||
@@ -131,11 +133,10 @@ class Layout extends React.Component<Props> {
|
||||
<Modal
|
||||
isOpen={this.keyboardShortcutsOpen}
|
||||
onRequestClose={this.handleCloseKeyboardShortcuts}
|
||||
title="Keyboard shortcuts"
|
||||
title={t("Keyboard shortcuts")}
|
||||
>
|
||||
<KeyboardShortcuts />
|
||||
</Modal>
|
||||
<GlobalStyles />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -162,4 +163,6 @@ const Content = styled(Flex)`
|
||||
`};
|
||||
`;
|
||||
|
||||
export default inject("auth", "ui", "documents")(withTheme(Layout));
|
||||
export default withTranslation()<Layout>(
|
||||
inject("auth", "ui", "documents")(withTheme(Layout))
|
||||
);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
|
||||
const Notice = styled.p`
|
||||
background: ${(props) => props.theme.brand.marine};
|
||||
color: ${(props) => props.theme.almostBlack};
|
||||
padding: 10px 12px;
|
||||
margin-top: 24px;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
|
||||
a {
|
||||
color: ${(props) => props.theme.almostBlack};
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
export default Notice;
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
PlusIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
|
||||
import AuthStore from "stores/AuthStore";
|
||||
@@ -34,6 +35,7 @@ type Props = {
|
||||
auth: AuthStore,
|
||||
documents: DocumentsStore,
|
||||
policies: PoliciesStore,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -65,7 +67,7 @@ class MainSidebar extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { auth, documents, policies } = this.props;
|
||||
const { auth, documents, policies, t } = this.props;
|
||||
const { user, team } = auth;
|
||||
if (!user || !team) return null;
|
||||
|
||||
@@ -90,7 +92,7 @@ class MainSidebar extends React.Component<Props> {
|
||||
to="/home"
|
||||
icon={<HomeIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label="Home"
|
||||
label={t("Home")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to={{
|
||||
@@ -98,20 +100,20 @@ class MainSidebar extends React.Component<Props> {
|
||||
state: { fromMenu: true },
|
||||
}}
|
||||
icon={<SearchIcon color="currentColor" />}
|
||||
label="Search"
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/starred"
|
||||
icon={<StarredIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label="Starred"
|
||||
label={t("Starred")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/templates"
|
||||
icon={<ShapesIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label="Templates"
|
||||
label={t("Templates")}
|
||||
active={
|
||||
documents.active ? documents.active.template : undefined
|
||||
}
|
||||
@@ -121,7 +123,7 @@ class MainSidebar extends React.Component<Props> {
|
||||
icon={<EditIcon color="currentColor" />}
|
||||
label={
|
||||
<Drafts align="center">
|
||||
Drafts
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 && (
|
||||
<Bubble count={documents.totalDrafts} />
|
||||
)}
|
||||
@@ -146,7 +148,7 @@ class MainSidebar extends React.Component<Props> {
|
||||
to="/archive"
|
||||
icon={<ArchiveIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label="Archive"
|
||||
label={t("Archive")}
|
||||
active={
|
||||
documents.active
|
||||
? documents.active.isArchived && !documents.active.isDeleted
|
||||
@@ -157,7 +159,7 @@ class MainSidebar extends React.Component<Props> {
|
||||
to="/trash"
|
||||
icon={<TrashIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label="Trash"
|
||||
label={t("Trash")}
|
||||
active={
|
||||
documents.active ? documents.active.isDeleted : undefined
|
||||
}
|
||||
@@ -167,21 +169,21 @@ class MainSidebar extends React.Component<Props> {
|
||||
to="/settings/people"
|
||||
onClick={this.handleInviteModalOpen}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label="Invite people…"
|
||||
label={t("Invite people…")}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Scrollable>
|
||||
</Flex>
|
||||
<Modal
|
||||
title="Invite people"
|
||||
title={t("Invite people")}
|
||||
onRequestClose={this.handleInviteModalClose}
|
||||
isOpen={this.inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={this.handleInviteModalClose} />
|
||||
</Modal>
|
||||
<Modal
|
||||
title="Create a collection"
|
||||
title={t("Create a collection")}
|
||||
onRequestClose={this.handleCreateCollectionModalClose}
|
||||
isOpen={this.createCollectionModalOpen}
|
||||
>
|
||||
@@ -196,4 +198,6 @@ const Drafts = styled(Flex)`
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export default inject("documents", "policies", "auth")(MainSidebar);
|
||||
export default withTranslation()<MainSidebar>(
|
||||
inject("documents", "policies", "auth")(MainSidebar)
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ExpandedIcon,
|
||||
} from "outline-icons";
|
||||
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 AuthStore from "stores/AuthStore";
|
||||
@@ -36,6 +37,7 @@ type Props = {
|
||||
history: RouterHistory,
|
||||
policies: PoliciesStore,
|
||||
auth: AuthStore,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -45,7 +47,7 @@ class SettingsSidebar extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { policies, auth } = this.props;
|
||||
const { policies, t, auth } = this.props;
|
||||
const { team } = auth;
|
||||
if (!team) return null;
|
||||
|
||||
@@ -56,7 +58,7 @@ class SettingsSidebar extends React.Component<Props> {
|
||||
<HeaderBlock
|
||||
subheading={
|
||||
<ReturnToApp align="center">
|
||||
<BackIcon color="currentColor" /> Return to App
|
||||
<BackIcon color="currentColor" /> {t("Return to App")}
|
||||
</ReturnToApp>
|
||||
}
|
||||
teamName={team.name}
|
||||
@@ -71,17 +73,17 @@ class SettingsSidebar extends React.Component<Props> {
|
||||
<SidebarLink
|
||||
to="/settings"
|
||||
icon={<ProfileIcon color="currentColor" />}
|
||||
label="Profile"
|
||||
label={t("Profile")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/notifications"
|
||||
icon={<EmailIcon color="currentColor" />}
|
||||
label="Notifications"
|
||||
label={t("Notifications")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/tokens"
|
||||
icon={<CodeIcon color="currentColor" />}
|
||||
label="API Tokens"
|
||||
label={t("API Tokens")}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
@@ -90,44 +92,44 @@ class SettingsSidebar extends React.Component<Props> {
|
||||
<SidebarLink
|
||||
to="/settings/details"
|
||||
icon={<TeamIcon color="currentColor" />}
|
||||
label="Details"
|
||||
label={t("Details")}
|
||||
/>
|
||||
)}
|
||||
{can.update && (
|
||||
<SidebarLink
|
||||
to="/settings/security"
|
||||
icon={<PadlockIcon color="currentColor" />}
|
||||
label="Security"
|
||||
label={t("Security")}
|
||||
/>
|
||||
)}
|
||||
<SidebarLink
|
||||
to="/settings/people"
|
||||
icon={<UserIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label="People"
|
||||
label={t("People")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/groups"
|
||||
icon={<GroupIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label="Groups"
|
||||
label={t("Groups")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/shares"
|
||||
icon={<LinkIcon color="currentColor" />}
|
||||
label="Share Links"
|
||||
label={t("Share Links")}
|
||||
/>
|
||||
{can.export && (
|
||||
<SidebarLink
|
||||
to="/settings/export"
|
||||
icon={<DocumentIcon color="currentColor" />}
|
||||
label="Export Data"
|
||||
label={t("Export Data")}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
{can.update && (
|
||||
<Section>
|
||||
<Header>Integrations</Header>
|
||||
<Header>{t("Integrations")}</Header>
|
||||
<SidebarLink
|
||||
to="/settings/integrations/slack"
|
||||
icon={<SlackIcon color="currentColor" />}
|
||||
@@ -144,7 +146,7 @@ class SettingsSidebar extends React.Component<Props> {
|
||||
)}
|
||||
{can.update && !isHosted && (
|
||||
<Section>
|
||||
<Header>Installation</Header>
|
||||
<Header>{t("Installation")}</Header>
|
||||
<Version />
|
||||
</Section>
|
||||
)}
|
||||
@@ -164,4 +166,6 @@ const ReturnToApp = styled(Flex)`
|
||||
height: 16px;
|
||||
`;
|
||||
|
||||
export default inject("auth", "policies")(SettingsSidebar);
|
||||
export default withTranslation()<SettingsSidebar>(
|
||||
inject("auth", "policies")(SettingsSidebar)
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ function Sidebar({ location, children }: Props) {
|
||||
if (location !== previousLocation) {
|
||||
ui.hideMobileSidebar();
|
||||
}
|
||||
}, [ui, location]);
|
||||
}, [ui, location, previousLocation]);
|
||||
|
||||
const content = (
|
||||
<Container
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Collection from "models/Collection";
|
||||
import Document from "models/Document";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import DropToImport from "components/DropToImport";
|
||||
import Flex from "components/Flex";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
@@ -18,7 +16,6 @@ type Props = {|
|
||||
collection: Collection,
|
||||
ui: UiStore,
|
||||
canUpdate: boolean,
|
||||
documents: DocumentsStore,
|
||||
activeDocument: ?Document,
|
||||
prefetchDocument: (id: string) => Promise<void>,
|
||||
|};
|
||||
@@ -34,61 +31,58 @@ class CollectionLink extends React.Component<Props> {
|
||||
render() {
|
||||
const {
|
||||
collection,
|
||||
documents,
|
||||
activeDocument,
|
||||
prefetchDocument,
|
||||
canUpdate,
|
||||
ui,
|
||||
} = this.props;
|
||||
|
||||
const expanded = collection.id === ui.activeCollectionId;
|
||||
|
||||
return (
|
||||
<DropToImport
|
||||
key={collection.id}
|
||||
collectionId={collection.id}
|
||||
activeClassName="activeDropZone"
|
||||
>
|
||||
<SidebarLink
|
||||
key={collection.id}
|
||||
to={collection.url}
|
||||
icon={<CollectionIcon collection={collection} expanded={expanded} />}
|
||||
iconColor={collection.color}
|
||||
expanded={expanded}
|
||||
hideDisclosure
|
||||
menuOpen={this.menuOpen}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={collection.name}
|
||||
onSubmit={this.handleTitleChange}
|
||||
canUpdate={canUpdate}
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
menu={
|
||||
<CollectionMenu
|
||||
position="right"
|
||||
collection={collection}
|
||||
onOpen={() => (this.menuOpen = true)}
|
||||
onClose={() => (this.menuOpen = false)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Flex column>
|
||||
{collection.documents.map((node) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
documents={documents}
|
||||
collection={collection}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
<>
|
||||
<DropToImport key={collection.id} collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
key={collection.id}
|
||||
to={collection.url}
|
||||
icon={
|
||||
<CollectionIcon collection={collection} expanded={expanded} />
|
||||
}
|
||||
iconColor={collection.color}
|
||||
expanded={expanded}
|
||||
menuOpen={this.menuOpen}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={collection.name}
|
||||
onSubmit={this.handleTitleChange}
|
||||
canUpdate={canUpdate}
|
||||
depth={1.5}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</SidebarLink>
|
||||
</DropToImport>
|
||||
}
|
||||
exact={false}
|
||||
menu={
|
||||
<CollectionMenu
|
||||
position="right"
|
||||
collection={collection}
|
||||
onOpen={() => (this.menuOpen = true)}
|
||||
onClose={() => (this.menuOpen = false)}
|
||||
/>
|
||||
}
|
||||
></SidebarLink>
|
||||
</DropToImport>
|
||||
|
||||
{expanded &&
|
||||
collection.documents.map((node) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={collection}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
canUpdate={canUpdate}
|
||||
depth={1.5}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import keydown from "react-keydown";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
|
||||
@@ -24,6 +25,7 @@ type Props = {
|
||||
documents: DocumentsStore,
|
||||
onCreateCollection: () => void,
|
||||
ui: UiStore,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -52,14 +54,13 @@ class Collections extends React.Component<Props> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { collections, ui, policies, documents } = this.props;
|
||||
const { collections, ui, policies, documents, t } = this.props;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{collections.orderedData.map((collection) => (
|
||||
<CollectionLink
|
||||
key={collection.id}
|
||||
documents={documents}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
@@ -71,7 +72,7 @@ class Collections extends React.Component<Props> {
|
||||
to="/collections"
|
||||
onClick={this.props.onCreateCollection}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label="New collection…"
|
||||
label={t("New collection…")}
|
||||
exact
|
||||
/>
|
||||
</>
|
||||
@@ -79,7 +80,7 @@ class Collections extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<Header>Collections</Header>
|
||||
<Header>{t("Collections")}</Header>
|
||||
{collections.isLoaded ? (
|
||||
this.isPreloaded ? (
|
||||
content
|
||||
@@ -94,9 +95,6 @@ class Collections extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default inject(
|
||||
"collections",
|
||||
"ui",
|
||||
"documents",
|
||||
"policies"
|
||||
)(withRouter(Collections));
|
||||
export default withTranslation()<Collections>(
|
||||
inject("collections", "ui", "documents", "policies")(withRouter(Collections))
|
||||
);
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import Collection from "models/Collection";
|
||||
import Document from "models/Document";
|
||||
import DropToImport from "components/DropToImport";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import useStores from "hooks/useStores";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
import { type NavigationNode } from "types";
|
||||
|
||||
type Props = {|
|
||||
node: NavigationNode,
|
||||
documents: DocumentsStore,
|
||||
canUpdate: boolean,
|
||||
collection?: Collection,
|
||||
activeDocument: ?Document,
|
||||
@@ -25,139 +24,151 @@ type Props = {|
|
||||
depth: number,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class DocumentLink extends React.Component<Props> {
|
||||
@observable menuOpen = false;
|
||||
function DocumentLink({
|
||||
node,
|
||||
collection,
|
||||
activeDocument,
|
||||
activeDocumentRef,
|
||||
prefetchDocument,
|
||||
depth,
|
||||
canUpdate,
|
||||
}: Props) {
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
componentDidMount() {
|
||||
if (this.isActiveDocument() && this.hasChildDocuments()) {
|
||||
this.props.documents.fetchChildDocuments(this.props.node.id);
|
||||
const isActiveDocument = activeDocument && activeDocument.id === node.id;
|
||||
const hasChildDocuments = !!node.children.length;
|
||||
|
||||
const document = documents.get(node.id);
|
||||
const { fetchChildDocuments } = documents;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isActiveDocument && hasChildDocuments) {
|
||||
fetchChildDocuments(node.id);
|
||||
}
|
||||
}
|
||||
}, [fetchChildDocuments, node, hasChildDocuments, isActiveDocument]);
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (prevProps.activeDocument !== this.props.activeDocument) {
|
||||
if (this.isActiveDocument() && this.hasChildDocuments()) {
|
||||
this.props.documents.fetchChildDocuments(this.props.node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseEnter = (ev: SyntheticEvent<>) => {
|
||||
const { node, prefetchDocument } = this.props;
|
||||
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
prefetchDocument(node.id);
|
||||
};
|
||||
|
||||
handleTitleChange = async (title: string) => {
|
||||
const document = this.props.documents.get(this.props.node.id);
|
||||
if (!document) return;
|
||||
|
||||
await this.props.documents.update({
|
||||
id: document.id,
|
||||
lastRevision: document.revision,
|
||||
text: document.text,
|
||||
title,
|
||||
});
|
||||
};
|
||||
|
||||
isActiveDocument = () => {
|
||||
return (
|
||||
this.props.activeDocument &&
|
||||
this.props.activeDocument.id === this.props.node.id
|
||||
);
|
||||
};
|
||||
|
||||
hasChildDocuments = () => {
|
||||
return !!this.props.node.children.length;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
node,
|
||||
documents,
|
||||
collection,
|
||||
activeDocument,
|
||||
activeDocumentRef,
|
||||
prefetchDocument,
|
||||
depth,
|
||||
canUpdate,
|
||||
} = this.props;
|
||||
|
||||
const showChildren = !!(
|
||||
const showChildren = React.useMemo(() => {
|
||||
return !!(
|
||||
hasChildDocuments &&
|
||||
activeDocument &&
|
||||
collection &&
|
||||
(collection
|
||||
.pathToDocument(activeDocument)
|
||||
.map((entry) => entry.id)
|
||||
.includes(node.id) ||
|
||||
this.isActiveDocument())
|
||||
isActiveDocument)
|
||||
);
|
||||
const document = documents.get(node.id);
|
||||
const title = node.title || "Untitled";
|
||||
}, [hasChildDocuments, activeDocument, isActiveDocument, node, collection]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
column
|
||||
key={node.id}
|
||||
ref={this.isActiveDocument() ? activeDocumentRef : undefined}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
>
|
||||
<DropToImport documentId={node.id} activeClassName="activeDropZone">
|
||||
<SidebarLink
|
||||
to={{
|
||||
pathname: node.url,
|
||||
state: { title: node.title },
|
||||
}}
|
||||
expanded={showChildren ? true : undefined}
|
||||
label={
|
||||
const [expanded, setExpanded] = React.useState(showChildren);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (showChildren) {
|
||||
setExpanded(showChildren);
|
||||
}
|
||||
}, [showChildren]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
},
|
||||
[expanded]
|
||||
);
|
||||
|
||||
const handleMouseEnter = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
prefetchDocument(node.id);
|
||||
},
|
||||
[prefetchDocument, node]
|
||||
);
|
||||
|
||||
const handleTitleChange = React.useCallback(
|
||||
async (title: string) => {
|
||||
if (!document) return;
|
||||
|
||||
await documents.update({
|
||||
id: document.id,
|
||||
lastRevision: document.revision,
|
||||
text: document.text,
|
||||
title,
|
||||
});
|
||||
},
|
||||
[documents, document]
|
||||
);
|
||||
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<React.Fragment key={node.id}>
|
||||
<DropToImport documentId={node.id} activeClassName="activeDropZone">
|
||||
<SidebarLink
|
||||
innerRef={isActiveDocument ? activeDocumentRef : undefined}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
to={{
|
||||
pathname: node.url,
|
||||
state: { title: node.title },
|
||||
}}
|
||||
label={
|
||||
<>
|
||||
{hasChildDocuments && (
|
||||
<Disclosure
|
||||
expanded={expanded}
|
||||
onClick={handleDisclosureClick}
|
||||
/>
|
||||
)}
|
||||
<EditableTitle
|
||||
title={title}
|
||||
onSubmit={this.handleTitleChange}
|
||||
title={node.title || t("Untitled")}
|
||||
onSubmit={handleTitleChange}
|
||||
canUpdate={canUpdate}
|
||||
/>
|
||||
}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
menuOpen={this.menuOpen}
|
||||
menu={
|
||||
document ? (
|
||||
<Fade>
|
||||
<DocumentMenu
|
||||
position="right"
|
||||
document={document}
|
||||
onOpen={() => (this.menuOpen = true)}
|
||||
onClose={() => (this.menuOpen = false)}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{this.hasChildDocuments() && (
|
||||
<DocumentChildren column>
|
||||
{node.children.map((childNode) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
node={childNode}
|
||||
documents={documents}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
depth={depth + 1}
|
||||
canUpdate={canUpdate}
|
||||
/>
|
||||
))}
|
||||
</DocumentChildren>
|
||||
)}
|
||||
</SidebarLink>
|
||||
</DropToImport>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
</>
|
||||
}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
menuOpen={menuOpen}
|
||||
menu={
|
||||
document ? (
|
||||
<Fade>
|
||||
<DocumentMenu
|
||||
position="right"
|
||||
document={document}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined
|
||||
}
|
||||
></SidebarLink>
|
||||
</DropToImport>
|
||||
|
||||
{expanded && (
|
||||
<>
|
||||
{node.children.map((childNode) => (
|
||||
<ObservedDocumentLink
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
node={childNode}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
depth={depth + 1}
|
||||
canUpdate={canUpdate}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const DocumentChildren = styled(Flex)``;
|
||||
const Disclosure = styled(CollapsedIcon)`
|
||||
position: absolute;
|
||||
left: -24px;
|
||||
|
||||
export default DocumentLink;
|
||||
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
|
||||
`;
|
||||
|
||||
const ObservedDocumentLink = observer(DocumentLink);
|
||||
export default ObservedDocumentLink;
|
||||
|
||||
@@ -82,6 +82,7 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
|
||||
|
||||
const Input = styled.input`
|
||||
margin-left: -4px;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
background: ${(props) => props.theme.background};
|
||||
width: calc(100% - 10px);
|
||||
border-radius: 3px;
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withRouter, NavLink } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
import { type Theme } from "types";
|
||||
|
||||
type Props = {
|
||||
to?: string | Object,
|
||||
href?: string | Object,
|
||||
innerRef?: (?HTMLElement) => void,
|
||||
onClick?: (SyntheticEvent<>) => void,
|
||||
onMouseEnter?: (SyntheticEvent<>) => void,
|
||||
children?: React.Node,
|
||||
icon?: React.Node,
|
||||
expanded?: boolean,
|
||||
label?: React.Node,
|
||||
menu?: React.Node,
|
||||
menuOpen?: boolean,
|
||||
hideDisclosure?: boolean,
|
||||
iconColor?: string,
|
||||
active?: boolean,
|
||||
theme: Theme,
|
||||
@@ -29,75 +26,48 @@ function SidebarLink({
|
||||
icon,
|
||||
children,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
to,
|
||||
label,
|
||||
active,
|
||||
menu,
|
||||
menuOpen,
|
||||
hideDisclosure,
|
||||
theme,
|
||||
exact,
|
||||
href,
|
||||
innerRef,
|
||||
depth,
|
||||
...rest
|
||||
}: Props) {
|
||||
const [expanded, setExpanded] = React.useState(rest.expanded);
|
||||
|
||||
const style = React.useMemo(() => {
|
||||
return {
|
||||
paddingLeft: `${(depth || 0) * 16 + 16}px`,
|
||||
};
|
||||
}, [depth]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (rest.expanded !== undefined) {
|
||||
setExpanded(rest.expanded);
|
||||
}
|
||||
}, [rest.expanded]);
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
},
|
||||
[expanded]
|
||||
);
|
||||
|
||||
const handleExpand = React.useCallback(() => {
|
||||
setExpanded(true);
|
||||
}, []);
|
||||
|
||||
const showDisclosure = !!children && !hideDisclosure;
|
||||
const activeStyle = {
|
||||
color: theme.text,
|
||||
background: theme.sidebarItemBackground,
|
||||
fontWeight: 600,
|
||||
background: theme.sidebarItemBackground,
|
||||
...style,
|
||||
};
|
||||
|
||||
return (
|
||||
<Wrapper column>
|
||||
<StyledNavLink
|
||||
activeStyle={activeStyle}
|
||||
style={active ? activeStyle : style}
|
||||
onClick={onClick}
|
||||
exact={exact !== false}
|
||||
to={to}
|
||||
as={to ? undefined : href ? "a" : "div"}
|
||||
href={href}
|
||||
>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label onClick={handleExpand}>
|
||||
{showDisclosure && (
|
||||
<Disclosure expanded={expanded} onClick={handleClick} />
|
||||
)}
|
||||
{label}
|
||||
</Label>
|
||||
{menu && <Action menuOpen={menuOpen}>{menu}</Action>}
|
||||
</StyledNavLink>
|
||||
{expanded && children}
|
||||
</Wrapper>
|
||||
<StyledNavLink
|
||||
activeStyle={activeStyle}
|
||||
style={active ? activeStyle : style}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
exact={exact !== false}
|
||||
to={to}
|
||||
as={to ? undefined : href ? "a" : "div"}
|
||||
href={href}
|
||||
ref={innerRef}
|
||||
>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label>{label}</Label>
|
||||
{menu && <Action menuOpen={menuOpen}>{menu}</Action>}
|
||||
</StyledNavLink>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,10 +123,6 @@ const StyledNavLink = styled(NavLink)`
|
||||
}
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Label = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -164,11 +130,4 @@ const Label = styled.div`
|
||||
line-height: 1.6;
|
||||
`;
|
||||
|
||||
const Disclosure = styled(CollapsedIcon)`
|
||||
position: absolute;
|
||||
left: -24px;
|
||||
|
||||
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
|
||||
`;
|
||||
|
||||
export default withRouter(withTheme(observer(SidebarLink)));
|
||||
export default withRouter(withTheme(SidebarLink));
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import useStores from "./useStores";
|
||||
|
||||
export default function useCurrentUser() {
|
||||
const { auth } = useStores();
|
||||
invariant(auth.user, "user required");
|
||||
return auth.user;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import * as React from "react";
|
||||
import { render } from "react-dom";
|
||||
import { BrowserRouter as Router } from "react-router-dom";
|
||||
|
||||
import { initI18n } from "shared/i18n";
|
||||
import stores from "stores";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import ScrollToTop from "components/ScrollToTop";
|
||||
@@ -14,6 +15,8 @@ import Toasts from "components/Toasts";
|
||||
import Routes from "./routes";
|
||||
import env from "env";
|
||||
|
||||
initI18n();
|
||||
|
||||
const element = document.getElementById("root");
|
||||
|
||||
if (element) {
|
||||
|
||||
+18
-14
@@ -3,6 +3,7 @@ import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { SunIcon, MoonIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
@@ -23,6 +24,7 @@ type Props = {
|
||||
label: React.Node,
|
||||
ui: UiStore,
|
||||
auth: AuthStore,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -42,14 +44,14 @@ class AccountMenu extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { ui } = this.props;
|
||||
const { ui, t } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={this.keyboardShortcutsOpen}
|
||||
onRequestClose={this.handleCloseKeyboardShortcuts}
|
||||
title="Keyboard shortcuts"
|
||||
title={t("Keyboard shortcuts")}
|
||||
>
|
||||
<KeyboardShortcuts />
|
||||
</Modal>
|
||||
@@ -58,23 +60,23 @@ class AccountMenu extends React.Component<Props> {
|
||||
label={this.props.label}
|
||||
>
|
||||
<DropdownMenuItem as={Link} to={settings()}>
|
||||
Settings
|
||||
{t("Settings")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.handleOpenKeyboardShortcuts}>
|
||||
Keyboard shortcuts
|
||||
{t("Keyboard shortcuts")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem href={developers()} target="_blank">
|
||||
API documentation
|
||||
{t("API documentation")}
|
||||
</DropdownMenuItem>
|
||||
<hr />
|
||||
<DropdownMenuItem href={changelog()} target="_blank">
|
||||
Changelog
|
||||
{t("Changelog")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem href={mailToUrl()} target="_blank">
|
||||
Send us feedback
|
||||
{t("Send us feedback")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem href={githubIssuesUrl()} target="_blank">
|
||||
Report a bug
|
||||
{t("Report a bug")}
|
||||
</DropdownMenuItem>
|
||||
<hr />
|
||||
<DropdownMenu
|
||||
@@ -87,7 +89,7 @@ class AccountMenu extends React.Component<Props> {
|
||||
label={
|
||||
<DropdownMenuItem>
|
||||
<ChangeTheme justify="space-between">
|
||||
Appearance
|
||||
{t("Appearance")}
|
||||
{ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />}
|
||||
</ChangeTheme>
|
||||
</DropdownMenuItem>
|
||||
@@ -98,24 +100,24 @@ class AccountMenu extends React.Component<Props> {
|
||||
onClick={() => ui.setTheme("system")}
|
||||
selected={ui.theme === "system"}
|
||||
>
|
||||
System
|
||||
{t("System")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => ui.setTheme("light")}
|
||||
selected={ui.theme === "light"}
|
||||
>
|
||||
Light
|
||||
{t("Light")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => ui.setTheme("dark")}
|
||||
selected={ui.theme === "dark"}
|
||||
>
|
||||
Dark
|
||||
{t("Dark")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
<hr />
|
||||
<DropdownMenuItem onClick={this.handleLogout}>
|
||||
Log out
|
||||
{t("Log out")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
@@ -127,4 +129,6 @@ const ChangeTheme = styled(Flex)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export default inject("ui", "auth")(AccountMenu);
|
||||
export default withTranslation()<AccountMenu>(
|
||||
inject("ui", "auth")(AccountMenu)
|
||||
);
|
||||
|
||||
+16
-15
@@ -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 { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
@@ -27,6 +28,7 @@ type Props = {
|
||||
history: RouterHistory,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -112,6 +114,7 @@ class CollectionMenu extends React.Component<Props> {
|
||||
position,
|
||||
onOpen,
|
||||
onClose,
|
||||
t,
|
||||
} = this.props;
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
@@ -128,7 +131,7 @@ class CollectionMenu extends React.Component<Props> {
|
||||
</VisuallyHidden>
|
||||
|
||||
<Modal
|
||||
title="Collection permissions"
|
||||
title={t("Collection permissions")}
|
||||
onRequestClose={this.handleMembersModalClose}
|
||||
isOpen={this.showCollectionMembers}
|
||||
>
|
||||
@@ -143,12 +146,12 @@ class CollectionMenu extends React.Component<Props> {
|
||||
<DropdownMenuItems
|
||||
items={[
|
||||
{
|
||||
title: "New document",
|
||||
title: t("New document"),
|
||||
visible: !!(collection && can.update),
|
||||
onClick: this.onNewDocument,
|
||||
},
|
||||
{
|
||||
title: "Import document",
|
||||
title: t("Import document"),
|
||||
visible: !!(collection && can.update),
|
||||
onClick: this.onImportDocument,
|
||||
},
|
||||
@@ -156,22 +159,22 @@ class CollectionMenu extends React.Component<Props> {
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: "Edit…",
|
||||
title: t("Edit…"),
|
||||
visible: !!(collection && can.update),
|
||||
onClick: this.handleEditCollectionOpen,
|
||||
},
|
||||
{
|
||||
title: "Permissions…",
|
||||
title: t("Permissions…"),
|
||||
visible: !!(collection && can.update),
|
||||
onClick: this.handleMembersModalOpen,
|
||||
},
|
||||
{
|
||||
title: "Export…",
|
||||
title: t("Export…"),
|
||||
visible: !!(collection && can.export),
|
||||
onClick: this.handleExportCollectionOpen,
|
||||
},
|
||||
{
|
||||
title: "Delete…",
|
||||
title: t("Delete…"),
|
||||
visible: !!(collection && can.delete),
|
||||
onClick: this.handleDeleteCollectionOpen,
|
||||
},
|
||||
@@ -179,7 +182,7 @@ class CollectionMenu extends React.Component<Props> {
|
||||
/>
|
||||
</DropdownMenu>
|
||||
<Modal
|
||||
title="Edit collection"
|
||||
title={t("Edit collection")}
|
||||
isOpen={this.showCollectionEdit}
|
||||
onRequestClose={this.handleEditCollectionClose}
|
||||
>
|
||||
@@ -189,7 +192,7 @@ class CollectionMenu extends React.Component<Props> {
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title="Delete collection"
|
||||
title={t("Delete collection")}
|
||||
isOpen={this.showCollectionDelete}
|
||||
onRequestClose={this.handleDeleteCollectionClose}
|
||||
>
|
||||
@@ -199,7 +202,7 @@ class CollectionMenu extends React.Component<Props> {
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title="Export collection"
|
||||
title={t("Export collection")}
|
||||
isOpen={this.showCollectionExport}
|
||||
onRequestClose={this.handleExportCollectionClose}
|
||||
>
|
||||
@@ -213,8 +216,6 @@ class CollectionMenu extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default inject(
|
||||
"ui",
|
||||
"documents",
|
||||
"policies"
|
||||
)(withRouter(CollectionMenu));
|
||||
export default withTranslation()<CollectionMenu>(
|
||||
inject("ui", "documents", "policies")(withRouter(CollectionMenu))
|
||||
);
|
||||
|
||||
+41
-30
@@ -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 { Redirect } from "react-router-dom";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import CollectionStore from "stores/CollectionsStore";
|
||||
@@ -38,6 +39,7 @@ type Props = {
|
||||
label?: React.Node,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -83,7 +85,8 @@ class DocumentMenu extends React.Component<Props> {
|
||||
|
||||
// when duplicating, go straight to the duplicated document content
|
||||
this.redirectTo = duped.url;
|
||||
this.props.ui.showToast("Document duplicated");
|
||||
const { t } = this.props;
|
||||
this.props.ui.showToast(t("Document duplicated"));
|
||||
};
|
||||
|
||||
handleOpenTemplateModal = () => {
|
||||
@@ -100,7 +103,8 @@ class DocumentMenu extends React.Component<Props> {
|
||||
|
||||
handleArchive = async (ev: SyntheticEvent<>) => {
|
||||
await this.props.document.archive();
|
||||
this.props.ui.showToast("Document archived");
|
||||
const { t } = this.props;
|
||||
this.props.ui.showToast(t("Document archived"));
|
||||
};
|
||||
|
||||
handleRestore = async (
|
||||
@@ -108,12 +112,14 @@ class DocumentMenu extends React.Component<Props> {
|
||||
options?: { collectionId: string }
|
||||
) => {
|
||||
await this.props.document.restore(options);
|
||||
this.props.ui.showToast("Document restored");
|
||||
const { t } = this.props;
|
||||
this.props.ui.showToast(t("Document restored"));
|
||||
};
|
||||
|
||||
handleUnpublish = async (ev: SyntheticEvent<>) => {
|
||||
await this.props.document.unpublish();
|
||||
this.props.ui.showToast("Document unpublished");
|
||||
const { t } = this.props;
|
||||
this.props.ui.showToast(t("Document unpublished"));
|
||||
};
|
||||
|
||||
handlePin = (ev: SyntheticEvent<>) => {
|
||||
@@ -164,6 +170,7 @@ class DocumentMenu extends React.Component<Props> {
|
||||
label,
|
||||
onOpen,
|
||||
onClose,
|
||||
t,
|
||||
} = this.props;
|
||||
|
||||
const can = policies.abilities(document.id);
|
||||
@@ -183,17 +190,17 @@ class DocumentMenu extends React.Component<Props> {
|
||||
<DropdownMenuItems
|
||||
items={[
|
||||
{
|
||||
title: "Restore",
|
||||
title: t("Restore"),
|
||||
visible: !!can.unarchive,
|
||||
onClick: this.handleRestore,
|
||||
},
|
||||
{
|
||||
title: "Restore",
|
||||
title: t("Restore"),
|
||||
visible: !!(collection && can.restore),
|
||||
onClick: this.handleRestore,
|
||||
},
|
||||
{
|
||||
title: "Restore…",
|
||||
title: t("Restore…"),
|
||||
visible: !collection && !!can.restore,
|
||||
style: {
|
||||
left: -170,
|
||||
@@ -204,7 +211,7 @@ class DocumentMenu extends React.Component<Props> {
|
||||
items: [
|
||||
{
|
||||
type: "heading",
|
||||
title: "Choose a collection",
|
||||
title: t("Choose a collection"),
|
||||
},
|
||||
...collections.orderedData.map((collection) => {
|
||||
const can = policies.abilities(collection.id);
|
||||
@@ -224,37 +231,37 @@ class DocumentMenu extends React.Component<Props> {
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Unpin",
|
||||
title: t("Unpin"),
|
||||
onClick: this.handleUnpin,
|
||||
visible: !!(showPin && document.pinned && can.unpin),
|
||||
},
|
||||
{
|
||||
title: "Pin to collection",
|
||||
title: t("Pin to collection"),
|
||||
onClick: this.handlePin,
|
||||
visible: !!(showPin && !document.pinned && can.pin),
|
||||
},
|
||||
{
|
||||
title: "Unstar",
|
||||
title: t("Unstar"),
|
||||
onClick: this.handleUnstar,
|
||||
visible: document.isStarred && !!can.unstar,
|
||||
},
|
||||
{
|
||||
title: "Star",
|
||||
title: t("Star"),
|
||||
onClick: this.handleStar,
|
||||
visible: !document.isStarred && !!can.star,
|
||||
},
|
||||
{
|
||||
title: "Share link…",
|
||||
title: t("Share link…"),
|
||||
onClick: this.handleShareLink,
|
||||
visible: canShareDocuments,
|
||||
},
|
||||
{
|
||||
title: "Enable embeds",
|
||||
title: t("Enable embeds"),
|
||||
onClick: document.enableEmbeds,
|
||||
visible: !!showToggleEmbeds && document.embedsDisabled,
|
||||
},
|
||||
{
|
||||
title: "Disable embeds",
|
||||
title: t("Disable embeds"),
|
||||
onClick: document.disableEmbeds,
|
||||
visible: !!showToggleEmbeds && !document.embedsDisabled,
|
||||
},
|
||||
@@ -262,42 +269,42 @@ class DocumentMenu extends React.Component<Props> {
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: "New nested document",
|
||||
title: t("New nested document"),
|
||||
onClick: this.handleNewChild,
|
||||
visible: !!can.createChildDocument,
|
||||
},
|
||||
{
|
||||
title: "Create template…",
|
||||
title: t("Create template…"),
|
||||
onClick: this.handleOpenTemplateModal,
|
||||
visible: !!can.update && !document.isTemplate,
|
||||
},
|
||||
{
|
||||
title: "Edit",
|
||||
title: t("Edit"),
|
||||
onClick: this.handleEdit,
|
||||
visible: !!can.update,
|
||||
},
|
||||
{
|
||||
title: "Duplicate",
|
||||
title: t("Duplicate"),
|
||||
onClick: this.handleDuplicate,
|
||||
visible: !!can.update,
|
||||
},
|
||||
{
|
||||
title: "Unpublish",
|
||||
title: t("Unpublish"),
|
||||
onClick: this.handleUnpublish,
|
||||
visible: !!can.unpublish,
|
||||
},
|
||||
{
|
||||
title: "Archive",
|
||||
title: t("Archive"),
|
||||
onClick: this.handleArchive,
|
||||
visible: !!can.archive,
|
||||
},
|
||||
{
|
||||
title: "Delete…",
|
||||
title: t("Delete…"),
|
||||
onClick: this.handleDelete,
|
||||
visible: !!can.delete,
|
||||
},
|
||||
{
|
||||
title: "Move…",
|
||||
title: t("Move…"),
|
||||
onClick: this.handleMove,
|
||||
visible: !!can.move,
|
||||
},
|
||||
@@ -305,17 +312,17 @@ class DocumentMenu extends React.Component<Props> {
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: "History",
|
||||
title: t("History"),
|
||||
onClick: this.handleDocumentHistory,
|
||||
visible: canViewHistory,
|
||||
},
|
||||
{
|
||||
title: "Download",
|
||||
title: t("Download"),
|
||||
onClick: this.handleExport,
|
||||
visible: !!can.download,
|
||||
},
|
||||
{
|
||||
title: "Print",
|
||||
title: t("Print"),
|
||||
onClick: window.print,
|
||||
visible: !!showPrint,
|
||||
},
|
||||
@@ -323,7 +330,9 @@ class DocumentMenu extends React.Component<Props> {
|
||||
/>
|
||||
</DropdownMenu>
|
||||
<Modal
|
||||
title={`Delete ${this.props.document.noun}`}
|
||||
title={t("Delete {{ documentName }}", {
|
||||
documentName: this.props.document.noun,
|
||||
})}
|
||||
onRequestClose={this.handleCloseDeleteModal}
|
||||
isOpen={this.showDeleteModal}
|
||||
>
|
||||
@@ -333,7 +342,7 @@ class DocumentMenu extends React.Component<Props> {
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title="Create template"
|
||||
title={t("Create template")}
|
||||
onRequestClose={this.handleCloseTemplateModal}
|
||||
isOpen={this.showTemplateModal}
|
||||
>
|
||||
@@ -343,7 +352,7 @@ class DocumentMenu extends React.Component<Props> {
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title="Share document"
|
||||
title={t("Share document")}
|
||||
onRequestClose={this.handleCloseShareModal}
|
||||
isOpen={this.showShareModal}
|
||||
>
|
||||
@@ -357,4 +366,6 @@ class DocumentMenu extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default inject("ui", "auth", "collections", "policies")(DocumentMenu);
|
||||
export default withTranslation()<DocumentMenu>(
|
||||
inject("ui", "auth", "collections", "policies")(DocumentMenu)
|
||||
);
|
||||
|
||||
+11
-7
@@ -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 { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
@@ -20,6 +21,7 @@ type Props = {
|
||||
onMembers: () => void,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -46,13 +48,13 @@ class GroupMenu extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { policies, group, onOpen, onClose } = this.props;
|
||||
const { policies, group, onOpen, onClose, t } = this.props;
|
||||
const can = policies.abilities(group.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title="Edit group"
|
||||
title={t("Edit group")}
|
||||
onRequestClose={this.handleEditModalClose}
|
||||
isOpen={this.editModalOpen}
|
||||
>
|
||||
@@ -63,7 +65,7 @@ class GroupMenu extends React.Component<Props> {
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="Delete group"
|
||||
title={t("Delete group")}
|
||||
onRequestClose={this.handleDeleteModalClose}
|
||||
isOpen={this.deleteModalOpen}
|
||||
>
|
||||
@@ -76,7 +78,7 @@ class GroupMenu extends React.Component<Props> {
|
||||
<DropdownMenuItems
|
||||
items={[
|
||||
{
|
||||
title: "Members…",
|
||||
title: t("Members…"),
|
||||
onClick: this.props.onMembers,
|
||||
visible: !!(group && can.read),
|
||||
},
|
||||
@@ -84,12 +86,12 @@ class GroupMenu extends React.Component<Props> {
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: "Edit…",
|
||||
title: t("Edit…"),
|
||||
onClick: this.onEdit,
|
||||
visible: !!(group && can.update),
|
||||
},
|
||||
{
|
||||
title: "Delete…",
|
||||
title: t("Delete…"),
|
||||
onClick: this.onDelete,
|
||||
visible: !!(group && can.delete),
|
||||
},
|
||||
@@ -101,4 +103,6 @@ class GroupMenu extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default inject("policies")(withRouter(GroupMenu));
|
||||
export default withTranslation()<GroupMenu>(
|
||||
inject("policies")(withRouter(GroupMenu))
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { Redirect } from "react-router-dom";
|
||||
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
@@ -14,6 +15,7 @@ type Props = {
|
||||
label?: React.Node,
|
||||
document: Document,
|
||||
collections: CollectionsStore,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -39,7 +41,7 @@ class NewChildDocumentMenu extends React.Component<Props> {
|
||||
render() {
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
const { label, document, collections } = this.props;
|
||||
const { label, document, collections, t } = this.props;
|
||||
const collection = collections.get(document.collectionId);
|
||||
|
||||
return (
|
||||
@@ -49,14 +51,16 @@ class NewChildDocumentMenu extends React.Component<Props> {
|
||||
{
|
||||
title: (
|
||||
<span>
|
||||
New document in{" "}
|
||||
<strong>{collection ? collection.name : "collection"}</strong>
|
||||
{t("New document in")}{" "}
|
||||
<strong>
|
||||
{collection ? collection.name : t("collection")}
|
||||
</strong>
|
||||
</span>
|
||||
),
|
||||
onClick: this.handleNewDocument,
|
||||
},
|
||||
{
|
||||
title: "New nested document",
|
||||
title: t("New nested document"),
|
||||
onClick: this.handleNewChild,
|
||||
},
|
||||
]}
|
||||
@@ -66,4 +70,6 @@ class NewChildDocumentMenu extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default inject("collections")(NewChildDocumentMenu);
|
||||
export default withTranslation()<NewChildDocumentMenu>(
|
||||
inject("collections")(NewChildDocumentMenu)
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { Redirect } from "react-router-dom";
|
||||
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
@@ -19,6 +20,7 @@ type Props = {
|
||||
documents: DocumentsStore,
|
||||
collections: CollectionsStore,
|
||||
policies: PoliciesStore,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -29,7 +31,14 @@ class NewDocumentMenu extends React.Component<Props> {
|
||||
this.redirectTo = undefined;
|
||||
}
|
||||
|
||||
handleNewDocument = (collectionId: string, options) => {
|
||||
handleNewDocument = (
|
||||
collectionId: string,
|
||||
options?: {
|
||||
parentDocumentId?: string,
|
||||
template?: boolean,
|
||||
templateId?: string,
|
||||
}
|
||||
) => {
|
||||
this.redirectTo = newDocumentUrl(collectionId, options);
|
||||
};
|
||||
|
||||
@@ -44,7 +53,7 @@ class NewDocumentMenu extends React.Component<Props> {
|
||||
render() {
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
const { collections, documents, policies, label, ...rest } = this.props;
|
||||
const { collections, documents, policies, label, t, ...rest } = this.props;
|
||||
const singleCollection = collections.orderedData.length === 1;
|
||||
|
||||
return (
|
||||
@@ -52,14 +61,15 @@ class NewDocumentMenu extends React.Component<Props> {
|
||||
label={
|
||||
label || (
|
||||
<Button icon={<PlusIcon />} small>
|
||||
New doc{singleCollection ? "" : "…"}
|
||||
{t("New doc")}
|
||||
{singleCollection ? "" : "…"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
onOpen={this.onOpen}
|
||||
{...rest}
|
||||
>
|
||||
<Header>Choose a collection</Header>
|
||||
<Header>{t("Choose a collection")}</Header>
|
||||
<DropdownMenuItems
|
||||
items={collections.orderedData.map((collection) => ({
|
||||
onClick: () => this.handleNewDocument(collection.id),
|
||||
@@ -77,4 +87,6 @@ class NewDocumentMenu extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default inject("collections", "documents", "policies")(NewDocumentMenu);
|
||||
export default withTranslation()<NewDocumentMenu>(
|
||||
inject("collections", "documents", "policies")(NewDocumentMenu)
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { Redirect } from "react-router-dom";
|
||||
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
@@ -17,6 +18,7 @@ type Props = {
|
||||
label?: React.Node,
|
||||
collections: CollectionsStore,
|
||||
policies: PoliciesStore,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -36,20 +38,20 @@ class NewTemplateMenu extends React.Component<Props> {
|
||||
render() {
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
const { collections, policies, label, ...rest } = this.props;
|
||||
const { collections, policies, label, t, ...rest } = this.props;
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
label={
|
||||
label || (
|
||||
<Button icon={<PlusIcon />} small>
|
||||
New template…
|
||||
{t("New template…")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
{...rest}
|
||||
>
|
||||
<Header>Choose a collection</Header>
|
||||
<Header>{t("Choose a collection")}</Header>
|
||||
<DropdownMenuItems
|
||||
items={collections.orderedData.map((collection) => ({
|
||||
onClick: () => this.handleNewDocument(collection.id),
|
||||
@@ -67,4 +69,6 @@ class NewTemplateMenu extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default inject("collections", "policies")(NewTemplateMenu);
|
||||
export default withTranslation()<NewTemplateMenu>(
|
||||
inject("collections", "policies")(NewTemplateMenu)
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @flow
|
||||
import { inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
|
||||
import UiStore from "stores/UiStore";
|
||||
@@ -19,22 +20,25 @@ type Props = {
|
||||
className?: string,
|
||||
label: React.Node,
|
||||
ui: UiStore,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
class RevisionMenu extends React.Component<Props> {
|
||||
handleRestore = async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
await this.props.document.restore({ revisionId: this.props.revision.id });
|
||||
this.props.ui.showToast("Document restored");
|
||||
const { t } = this.props;
|
||||
this.props.ui.showToast(t("Document restored"));
|
||||
this.props.history.push(this.props.document.url);
|
||||
};
|
||||
|
||||
handleCopy = () => {
|
||||
this.props.ui.showToast("Link copied");
|
||||
const { t } = this.props;
|
||||
this.props.ui.showToast(t("Link copied"));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { className, label, onOpen, onClose } = this.props;
|
||||
const { className, label, onOpen, onClose, t } = this.props;
|
||||
const url = `${window.location.origin}${documentHistoryUrl(
|
||||
this.props.document,
|
||||
this.props.revision.id
|
||||
@@ -48,15 +52,17 @@ class RevisionMenu extends React.Component<Props> {
|
||||
label={label}
|
||||
>
|
||||
<DropdownMenuItem onClick={this.handleRestore}>
|
||||
Restore version
|
||||
{t("Restore version")}
|
||||
</DropdownMenuItem>
|
||||
<hr />
|
||||
<CopyToClipboard text={url} onCopy={this.handleCopy}>
|
||||
<DropdownMenuItem>Copy link</DropdownMenuItem>
|
||||
<DropdownMenuItem>{t("Copy link")}</DropdownMenuItem>
|
||||
</CopyToClipboard>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(inject("ui")(RevisionMenu));
|
||||
export default withTranslation()<RevisionMenu>(
|
||||
withRouter(inject("ui")(RevisionMenu))
|
||||
);
|
||||
|
||||
+11
-7
@@ -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 { Redirect } from "react-router-dom";
|
||||
|
||||
import SharesStore from "stores/SharesStore";
|
||||
@@ -16,6 +17,7 @@ type Props = {
|
||||
shares: SharesStore,
|
||||
ui: UiStore,
|
||||
share: Share,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -36,36 +38,38 @@ class ShareMenu extends React.Component<Props> {
|
||||
|
||||
try {
|
||||
await this.props.shares.revoke(this.props.share);
|
||||
this.props.ui.showToast("Share link revoked");
|
||||
const { t } = this.props;
|
||||
this.props.ui.showToast(t("Share link revoked"));
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
handleCopy = () => {
|
||||
this.props.ui.showToast("Share link copied");
|
||||
const { t } = this.props;
|
||||
this.props.ui.showToast(t("Share link copied"));
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
const { share, onOpen, onClose } = this.props;
|
||||
const { share, onOpen, onClose, t } = this.props;
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpen={onOpen} onClose={onClose}>
|
||||
<CopyToClipboard text={share.url} onCopy={this.handleCopy}>
|
||||
<DropdownMenuItem>Copy link</DropdownMenuItem>
|
||||
<DropdownMenuItem>{t("Copy link")}</DropdownMenuItem>
|
||||
</CopyToClipboard>
|
||||
<DropdownMenuItem onClick={this.handleGoToDocument}>
|
||||
Go to document
|
||||
{t("Go to document")}
|
||||
</DropdownMenuItem>
|
||||
<hr />
|
||||
<DropdownMenuItem onClick={this.handleRevoke}>
|
||||
Revoke link
|
||||
{t("Revoke link")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default inject("shares", "ui")(ShareMenu);
|
||||
export default withTranslation()<ShareMenu>(inject("shares", "ui")(ShareMenu));
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import Document from "models/Document";
|
||||
@@ -11,12 +12,13 @@ import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
|
||||
type Props = {
|
||||
document: Document,
|
||||
documents: DocumentsStore,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
class TemplatesMenu extends React.Component<Props> {
|
||||
render() {
|
||||
const { documents, document, ...rest } = this.props;
|
||||
const { documents, document, t, ...rest } = this.props;
|
||||
const templates = documents.templatesInCollection(document.collectionId);
|
||||
|
||||
if (!templates.length) {
|
||||
@@ -28,7 +30,7 @@ class TemplatesMenu extends React.Component<Props> {
|
||||
position="left"
|
||||
label={
|
||||
<Button disclosure neutral>
|
||||
Templates
|
||||
{t("Templates")}
|
||||
</Button>
|
||||
}
|
||||
{...rest}
|
||||
@@ -42,7 +44,9 @@ class TemplatesMenu extends React.Component<Props> {
|
||||
<div>
|
||||
<strong>{template.titleWithDefault}</strong>
|
||||
<br />
|
||||
<Author>By {template.createdBy.name}</Author>
|
||||
<Author>
|
||||
{t("By {{ author }}", { author: template.createdBy.name })}
|
||||
</Author>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
@@ -55,4 +59,6 @@ const Author = styled.div`
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
export default inject("documents")(TemplatesMenu);
|
||||
export default withTranslation()<TemplatesMenu>(
|
||||
inject("documents")(TemplatesMenu)
|
||||
);
|
||||
|
||||
+30
-13
@@ -2,6 +2,7 @@
|
||||
import { inject, observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import UsersStore from "stores/UsersStore";
|
||||
import User from "models/User";
|
||||
import { DropdownMenu } from "components/DropdownMenu";
|
||||
@@ -10,16 +11,20 @@ import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
|
||||
type Props = {
|
||||
user: User,
|
||||
users: UsersStore,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
class UserMenu extends React.Component<Props> {
|
||||
handlePromote = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { user, users } = this.props;
|
||||
const { user, users, t } = this.props;
|
||||
if (
|
||||
!window.confirm(
|
||||
`Are you want to make ${user.name} an admin? Admins can modify team and billing information.`
|
||||
t(
|
||||
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.",
|
||||
{ userName: user.name }
|
||||
)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
@@ -29,8 +34,14 @@ class UserMenu extends React.Component<Props> {
|
||||
|
||||
handleDemote = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { user, users } = this.props;
|
||||
if (!window.confirm(`Are you want to make ${user.name} a member?`)) {
|
||||
const { user, users, t } = this.props;
|
||||
if (
|
||||
!window.confirm(
|
||||
t("Are you sure you want to make {{ userName }} a member?", {
|
||||
userName: user.name,
|
||||
})
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
users.demote(user);
|
||||
@@ -38,10 +49,12 @@ class UserMenu extends React.Component<Props> {
|
||||
|
||||
handleSuspend = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { user, users } = this.props;
|
||||
const { user, users, t } = this.props;
|
||||
if (
|
||||
!window.confirm(
|
||||
"Are you want to suspend this account? Suspended users will be prevented from logging in."
|
||||
t(
|
||||
"Are you sure you want to suspend this account? Suspended users will be prevented from logging in."
|
||||
)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
@@ -62,19 +75,23 @@ class UserMenu extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { user } = this.props;
|
||||
const { user, t } = this.props;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuItems
|
||||
items={[
|
||||
{
|
||||
title: `Make ${user.name} a member…`,
|
||||
title: t("Make {{ userName }} a member…", {
|
||||
userName: user.name,
|
||||
}),
|
||||
onClick: this.handleDemote,
|
||||
visible: user.isAdmin,
|
||||
},
|
||||
{
|
||||
title: `Make ${user.name} an admin…`,
|
||||
title: t("Make {{ userName }} an admin…", {
|
||||
userName: user.name,
|
||||
}),
|
||||
onClick: this.handlePromote,
|
||||
visible: !user.isAdmin && !user.isSuspended,
|
||||
},
|
||||
@@ -82,17 +99,17 @@ class UserMenu extends React.Component<Props> {
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: "Revoke invite…",
|
||||
title: t("Revoke invite…"),
|
||||
onClick: this.handleRevoke,
|
||||
visible: user.isInvited,
|
||||
},
|
||||
{
|
||||
title: "Reactivate account",
|
||||
title: t("Activate account"),
|
||||
onClick: this.handleActivate,
|
||||
visible: !user.isInvited && user.isSuspended,
|
||||
},
|
||||
{
|
||||
title: "Suspend account",
|
||||
title: t("Suspend account…"),
|
||||
onClick: this.handleSuspend,
|
||||
visible: !user.isInvited && !user.isSuspended,
|
||||
},
|
||||
@@ -103,4 +120,4 @@ class UserMenu extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default inject("users")(UserMenu);
|
||||
export default withTranslation()<UserMenu>(inject("users")(UserMenu));
|
||||
|
||||
@@ -206,6 +206,11 @@ export default class Document extends BaseModel {
|
||||
|
||||
@action
|
||||
view = () => {
|
||||
// we don't record views for documents in the trash
|
||||
if (this.isDeleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.store.rootStore.views.create({ documentId: this.id });
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ class User extends BaseModel {
|
||||
lastActiveAt: string;
|
||||
isSuspended: boolean;
|
||||
createdAt: string;
|
||||
language: string;
|
||||
|
||||
@computed
|
||||
get isInvited(): boolean {
|
||||
|
||||
+21
-20
@@ -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));
|
||||
|
||||
+31
-24
@@ -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> doesn’t contain any
|
||||
documents yet.
|
||||
<Trans>
|
||||
<strong>{{ collectionName }}</strong> doesn’t 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>
|
||||
|
||||
{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>
|
||||
A–Z
|
||||
{t("A–Z")}
|
||||
</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))
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
Can’t find the group you’re looking for?{" "}
|
||||
{t("Can’t find the group you’re 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)
|
||||
);
|
||||
|
||||
@@ -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 who’s not yet on the team yet?{" "}
|
||||
{t("Need to add someone who’s 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)
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
{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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+19
-11
@@ -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))
|
||||
);
|
||||
|
||||
+60
-64
@@ -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);
|
||||
|
||||
@@ -54,7 +54,7 @@ class DocumentEditor extends React.Component<Props> {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (event.metaKey) {
|
||||
this.props.onSave({ publish: true, done: true });
|
||||
this.props.onSave({ done: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,11 @@ class DocumentEditor extends React.Component<Props> {
|
||||
this.focusAtStart();
|
||||
return;
|
||||
}
|
||||
if (event.key === "p" && event.metaKey && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
this.props.onSave({ publish: true, done: true });
|
||||
return;
|
||||
}
|
||||
if (event.key === "s" && event.metaKey) {
|
||||
event.preventDefault();
|
||||
this.props.onSave({});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
+14
-8
@@ -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."
|
||||
: "You’ve not got any drafts at the moment."}
|
||||
? t("No documents found for your filters.")
|
||||
: t("You’ve 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));
|
||||
|
||||
@@ -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 you’re looking for. Go to the{" "}
|
||||
<Link to="/home">homepage</Link>?
|
||||
<Trans>
|
||||
We were unable to find the page you’re looking for. Go to the{" "}
|
||||
<Link to="/home">homepage</Link>?
|
||||
</Trans>
|
||||
</Empty>
|
||||
</CenteredContent>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 who’s not yet on the team yet?{" "}
|
||||
{t(
|
||||
"Add team members below to give them access to the group. Need to add someone who’s 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)
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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 there’s Markdown too.
|
||||
{t(
|
||||
"Outline is designed to be fast and easy to use. All of your usual keyboard shortcuts work here, and there’s 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>></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>
|
||||
);
|
||||
|
||||
+33
-18
@@ -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 you’re looking for.</Empty>
|
||||
<h1>{t("Not Found")}</h1>
|
||||
<Empty>
|
||||
{t("We were unable to find the page you’re 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 />
|
||||
)}
|
||||
|
||||
<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))
|
||||
);
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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));
|
||||
|
||||
+39
-41
@@ -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>You’ve 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("You’ve 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);
|
||||
|
||||
+43
-49
@@ -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);
|
||||
|
||||
+21
-26
@@ -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);
|
||||
|
||||
+59
-54
@@ -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> {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} hasn’t updated any documents yet.</HelpText>
|
||||
}
|
||||
showCollection
|
||||
/>
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<Flex align="center">
|
||||
<Avatar src={user.avatarUrl} size={38} />
|
||||
<span> {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 }} hasn’t 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)));
|
||||
|
||||
@@ -211,6 +211,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
const { data } = res;
|
||||
runInAction("DocumentsStore#fetchBacklinks", () => {
|
||||
data.forEach(this.add);
|
||||
this.addPolicies(res.policies);
|
||||
this.backlinks.set(
|
||||
documentId,
|
||||
data.map((doc) => doc.id)
|
||||
@@ -236,6 +237,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
const { data } = res;
|
||||
runInAction("DocumentsStore#fetchChildDocuments", () => {
|
||||
data.forEach(this.add);
|
||||
this.addPolicies(res.policies);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import { sortBy, filter, find } from "lodash";
|
||||
import { sortBy, filter, find, isUndefined } from "lodash";
|
||||
import { action, computed } from "mobx";
|
||||
import Share from "models/Share";
|
||||
import BaseStore from "./BaseStore";
|
||||
@@ -47,6 +47,8 @@ export default class SharesStore extends BaseStore<Share> {
|
||||
|
||||
try {
|
||||
const res = await client.post(`/${this.modelName}s.info`, { documentId });
|
||||
if (isUndefined(res)) return;
|
||||
|
||||
invariant(res && res.data, "Data should be available");
|
||||
|
||||
this.addPolicies(res.policies);
|
||||
|
||||
@@ -9,6 +9,9 @@ import type { Toast } from "types";
|
||||
const UI_STORE = "UI_STORE";
|
||||
|
||||
class UiStore {
|
||||
// has the user seen the prompt to change the UI language and actioned it
|
||||
@observable languagePromptDismissed: boolean;
|
||||
|
||||
// theme represents the users UI preference (defaults to system)
|
||||
@observable theme: "light" | "dark" | "system";
|
||||
|
||||
@@ -47,6 +50,7 @@ class UiStore {
|
||||
}
|
||||
|
||||
// persisted keys
|
||||
this.languagePromptDismissed = data.languagePromptDismissed;
|
||||
this.tocVisible = data.tocVisible;
|
||||
this.theme = data.theme || "system";
|
||||
|
||||
@@ -68,6 +72,11 @@ class UiStore {
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
setLanguagePromptDismissed = () => {
|
||||
this.languagePromptDismissed = true;
|
||||
};
|
||||
|
||||
@action
|
||||
setActiveDocument = (document: Document): void => {
|
||||
this.activeDocumentId = document.id;
|
||||
@@ -181,6 +190,7 @@ class UiStore {
|
||||
get asJson(): string {
|
||||
return JSON.stringify({
|
||||
tocVisible: this.tocVisible,
|
||||
languagePromptDismissed: this.languagePromptDismissed,
|
||||
theme: this.theme,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -110,6 +110,8 @@ class ApiClient {
|
||||
|
||||
download(blob, trim(fileName, '"'));
|
||||
return;
|
||||
} else if (success && response.status === 204) {
|
||||
return;
|
||||
} else if (success) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// @flow
|
||||
|
||||
export function detectLanguage() {
|
||||
const [ln, r] = navigator.language.split("-");
|
||||
const region = (r || ln).toUpperCase();
|
||||
return `${ln}_${region}`;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
commit_message: "fix: New %language% translations from Crowdin [ci skip]"
|
||||
append_commit_message: false
|
||||
files:
|
||||
- source: /shared/i18n/locales/en_US/translation.json
|
||||
translation: /shared/i18n/locales/%locale_with_underscore%/translation.json
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
// @flow
|
||||
declare module "react-i18next" {
|
||||
declare type TFunction = (key?: ?string, data?: ?Object) => string;
|
||||
|
||||
declare type TranslatorProps = {|
|
||||
t: TFunction,
|
||||
i18nLoadedAt: Date,
|
||||
i18n: Object,
|
||||
|};
|
||||
|
||||
declare type TranslatorPropsVoid = {
|
||||
t: TFunction | void,
|
||||
i18nLoadedAt: Date | void,
|
||||
i18n: Object | void,
|
||||
};
|
||||
|
||||
declare type Translator<P: {}, Component: React$ComponentType<P>> = (
|
||||
WrappedComponent: Component
|
||||
) => React$Element<
|
||||
$Diff<React$ElementConfig<Component>, TranslatorPropsVoid>
|
||||
>;
|
||||
|
||||
declare type TranslateOptions = $Shape<{
|
||||
wait: boolean,
|
||||
nsMode: "default" | "fallback",
|
||||
bindi18n: false | string,
|
||||
bindStore: false | string,
|
||||
withRef: boolean,
|
||||
translateFuncName: string,
|
||||
i18n: Object,
|
||||
usePureComponent: boolean,
|
||||
}>;
|
||||
|
||||
declare type UseTranslationResponse = {
|
||||
t: TFunction,
|
||||
i18n: Object,
|
||||
ready: boolean,
|
||||
};
|
||||
|
||||
declare type Namespace =
|
||||
| string
|
||||
| Array<string>
|
||||
| (($Diff<P, TranslatorPropsVoid>) => string | Array<string>);
|
||||
|
||||
declare function useTranslation(
|
||||
ns?: Namespace,
|
||||
options?: TranslateOptions
|
||||
): UseTranslationResponse;
|
||||
|
||||
declare function withTranslation(
|
||||
ns?: Namespace,
|
||||
options?: {
|
||||
withRef?: boolean,
|
||||
}
|
||||
): <P>(component: React.ComponentType<P>) => Translator<P, Component>;
|
||||
|
||||
declare type I18nProps = {
|
||||
i18n?: Object,
|
||||
ns?: string | Array<string>,
|
||||
children: (t: TFunction, { i18n: Object, t: TFunction }) => React$Node,
|
||||
initialI18nStore?: Object,
|
||||
initialLanguage?: string,
|
||||
};
|
||||
declare var I18n: React$ComponentType<I18nProps>;
|
||||
|
||||
declare type InterpolateProps = {
|
||||
className?: string,
|
||||
dangerouslySetInnerHTMLPartElement?: string,
|
||||
i18n?: Object,
|
||||
i18nKey?: string,
|
||||
options?: Object,
|
||||
parent?: string,
|
||||
style?: Object,
|
||||
t?: TFunction,
|
||||
useDangerouslySetInnerHTML?: boolean,
|
||||
};
|
||||
declare var Interpolate: React$ComponentType<InterpolateProps>;
|
||||
|
||||
declare type TransProps = {
|
||||
count?: number,
|
||||
parent?: string,
|
||||
i18n?: Object,
|
||||
i18nKey?: string,
|
||||
t?: TFunction,
|
||||
};
|
||||
declare var Trans: React$ComponentType<TransProps>;
|
||||
|
||||
declare type ProviderProps = { i18n: Object, children: React$Element<*> };
|
||||
declare var I18nextProvider: React$ComponentType<ProviderProps>;
|
||||
|
||||
declare type NamespacesProps = {
|
||||
components: Array<React$ComponentType<*>>,
|
||||
i18n: { loadNamespaces: Function },
|
||||
};
|
||||
declare function loadNamespaces(props: NamespacesProps): Promise<void>;
|
||||
|
||||
declare var initReactI18next: {
|
||||
type: "3rdParty",
|
||||
init: (instance: Object) => void,
|
||||
};
|
||||
|
||||
declare function setDefaults(options: TranslateOptions): void;
|
||||
|
||||
declare function getDefaults(): TranslateOptions;
|
||||
|
||||
declare function getI18n(): Object;
|
||||
|
||||
declare function setI18n(instance: Object): void;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// @flow
|
||||
module.exports = {
|
||||
contextSeparator: "_",
|
||||
// Key separator used in your translation keys
|
||||
|
||||
createOldCatalogs: false,
|
||||
// Save the \_old files
|
||||
|
||||
defaultNamespace: "translation",
|
||||
// Default namespace used in your i18next config
|
||||
|
||||
defaultValue: "",
|
||||
// Default value to give to empty keys
|
||||
|
||||
indentation: 2,
|
||||
// Indentation of the catalog files
|
||||
|
||||
keepRemoved: false,
|
||||
// Keep keys from the catalog that are no longer in code
|
||||
|
||||
keySeparator: false,
|
||||
// Key separator used in your translation keys
|
||||
// If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance.
|
||||
|
||||
// see below for more details
|
||||
lexers: {
|
||||
hbs: ["HandlebarsLexer"],
|
||||
handlebars: ["HandlebarsLexer"],
|
||||
|
||||
htm: ["HTMLLexer"],
|
||||
html: ["HTMLLexer"],
|
||||
|
||||
mjs: ["JavascriptLexer"],
|
||||
js: ["JsxLexer"], // if you're writing jsx inside .js files, change this to JsxLexer
|
||||
ts: ["JavascriptLexer"],
|
||||
jsx: ["JsxLexer"],
|
||||
tsx: ["JsxLexer"],
|
||||
|
||||
default: ["JavascriptLexer"],
|
||||
},
|
||||
|
||||
lineEnding: "auto",
|
||||
// Control the line ending. See options at https://github.com/ryanve/eol
|
||||
|
||||
namespaceSeparator: ":",
|
||||
// Namespace separator used in your translation keys
|
||||
// If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance.
|
||||
|
||||
output: "shared/i18n/locales/en_US/translation.json",
|
||||
// Supports $LOCALE and $NAMESPACE injection
|
||||
// Supports JSON (.json) and YAML (.yml) file formats
|
||||
// Where to write the locale files relative to process.cwd()
|
||||
|
||||
input: undefined,
|
||||
// An array of globs that describe where to look for source files
|
||||
// relative to the location of the configuration file
|
||||
|
||||
sort: false,
|
||||
// Whether or not to sort the catalog
|
||||
|
||||
skipDefaultValues: false,
|
||||
// Whether to ignore default values.
|
||||
|
||||
useKeysAsDefaultValue: true,
|
||||
// Whether to use the keys as the default value; ex. "Hello": "Hello", "World": "World"
|
||||
// This option takes precedence over the `defaultValue` and `skipDefaultValues` options
|
||||
|
||||
verbose: false,
|
||||
// Display info about the parsing including some stats
|
||||
|
||||
failOnWarnings: false,
|
||||
// Exit with an exit code of 1 on warnings
|
||||
|
||||
customValueTemplate: null,
|
||||
// If you wish to customize the value output the value as an object, you can set your own format.
|
||||
// ${defaultValue} is the default value you set in your translation function.
|
||||
// Any other custom property will be automatically extracted.
|
||||
//
|
||||
// Example:
|
||||
// {
|
||||
// message: "${defaultValue}",
|
||||
// description: "${maxLength}", // t('my-key', {maxLength: 150})
|
||||
// }
|
||||
};
|
||||
+11
-6
@@ -5,11 +5,12 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"clean": "rimraf build",
|
||||
"build:i18n": "i18next 'app/**/*.js' 'server/**/*.js' && mkdir -p ./build/shared/i18n && cp -R ./shared/i18n/locales ./build/shared/i18n",
|
||||
"build:server": "babel -d ./build/server ./server && babel -d ./build/shared ./shared && cp package.json ./build && ln -sf \"$(pwd)/webpack.config.dev.js\" ./build",
|
||||
"build:webpack": "webpack --config webpack.config.prod.js",
|
||||
"build": "yarn clean && yarn build:webpack && yarn build:server",
|
||||
"build": "yarn clean && yarn build:webpack && yarn build:i18n && yarn build:server",
|
||||
"start": "node ./build/server/index.js",
|
||||
"dev": "nodemon --exec \"yarn build:server && node build/server/index.js\" -e js --ignore build/ --ignore app/",
|
||||
"dev": "nodemon --exec \"yarn build:server && yarn build:i18n && node build/server/index.js\" -e js --ignore build/ --ignore app/",
|
||||
"lint": "eslint app server shared",
|
||||
"flow": "flow",
|
||||
"deploy": "git push heroku master",
|
||||
@@ -92,6 +93,8 @@
|
||||
"fs-extra": "^4.0.2",
|
||||
"google-auth-library": "^5.5.1",
|
||||
"http-errors": "1.4.0",
|
||||
"i18next": "^19.8.3",
|
||||
"i18next-http-backend": "^1.0.21",
|
||||
"immutable": "^3.8.2",
|
||||
"imports-loader": "0.6.5",
|
||||
"invariant": "^2.2.2",
|
||||
@@ -123,7 +126,7 @@
|
||||
"nodemailer": "^4.4.0",
|
||||
"outline-icons": "^1.21.0",
|
||||
"oy-vey": "^0.10.0",
|
||||
"pg": "^8.3.0",
|
||||
"pg": "^8.5.1",
|
||||
"pg-hstore": "^2.3.3",
|
||||
"polished": "3.6.5",
|
||||
"query-string": "^4.3.4",
|
||||
@@ -135,8 +138,9 @@
|
||||
"react-avatar-editor": "^10.3.0",
|
||||
"react-color": "^2.17.3",
|
||||
"react-dom": "^16.8.6",
|
||||
"react-dropzone": "4.2.1",
|
||||
"react-dropzone": "^11.2.4",
|
||||
"react-helmet": "^5.2.0",
|
||||
"react-i18next": "^11.7.3",
|
||||
"react-keydown": "^1.7.3",
|
||||
"react-modal": "^3.1.2",
|
||||
"react-portal": "^4.0.0",
|
||||
@@ -144,7 +148,7 @@
|
||||
"react-virtualized-auto-sizer": "^1.0.2",
|
||||
"react-waypoint": "^9.0.2",
|
||||
"react-window": "^1.8.6",
|
||||
"rich-markdown-editor": "^11.0.6",
|
||||
"rich-markdown-editor": "^11.0.7",
|
||||
"semver": "^7.3.2",
|
||||
"sequelize": "^6.3.4",
|
||||
"sequelize-cli": "^6.2.0",
|
||||
@@ -183,6 +187,7 @@
|
||||
"fetch-test-server": "^1.1.0",
|
||||
"flow-bin": "^0.104.0",
|
||||
"html-webpack-plugin": "3.2.0",
|
||||
"i18next-parser": "^3.3.0",
|
||||
"jest-cli": "^26.0.0",
|
||||
"koa-webpack-dev-middleware": "^1.4.5",
|
||||
"koa-webpack-hot-middleware": "^1.0.3",
|
||||
@@ -200,4 +205,4 @@
|
||||
"js-yaml": "^3.13.1"
|
||||
},
|
||||
"version": "0.50.0"
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ Object {
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
"isAdmin": false,
|
||||
"isSuspended": false,
|
||||
"language": null,
|
||||
"lastActiveAt": null,
|
||||
"name": "User 1",
|
||||
},
|
||||
@@ -44,6 +45,7 @@ Object {
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
"isAdmin": false,
|
||||
"isSuspended": false,
|
||||
"language": null,
|
||||
"lastActiveAt": null,
|
||||
"name": "User 1",
|
||||
},
|
||||
@@ -79,6 +81,7 @@ Object {
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
"isAdmin": true,
|
||||
"isSuspended": false,
|
||||
"language": null,
|
||||
"lastActiveAt": null,
|
||||
"name": "User 1",
|
||||
},
|
||||
@@ -123,6 +126,7 @@ Object {
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
"isAdmin": false,
|
||||
"isSuspended": true,
|
||||
"language": null,
|
||||
"lastActiveAt": null,
|
||||
"name": "User 1",
|
||||
},
|
||||
|
||||
+16
-1
@@ -4,7 +4,11 @@ import Sequelize from "sequelize";
|
||||
import { subtractDate } from "../../shared/utils/date";
|
||||
import documentImporter from "../commands/documentImporter";
|
||||
import documentMover from "../commands/documentMover";
|
||||
import { NotFoundError, InvalidRequestError } from "../errors";
|
||||
import {
|
||||
NotFoundError,
|
||||
InvalidRequestError,
|
||||
AuthorizationError,
|
||||
} from "../errors";
|
||||
import auth from "../middlewares/authentication";
|
||||
import {
|
||||
Backlink,
|
||||
@@ -17,6 +21,7 @@ import {
|
||||
Star,
|
||||
User,
|
||||
View,
|
||||
Team,
|
||||
} from "../models";
|
||||
import policy from "../policies";
|
||||
import {
|
||||
@@ -454,6 +459,11 @@ async function loadDocument({ id, shareId, user }) {
|
||||
if (!share.published) {
|
||||
authorize(user, "read", document);
|
||||
}
|
||||
|
||||
const team = await Team.findByPk(document.teamId);
|
||||
if (!team.sharing) {
|
||||
throw new AuthorizationError();
|
||||
}
|
||||
} else {
|
||||
document = await Document.findByPk(id, {
|
||||
userId: user ? user.id : undefined,
|
||||
@@ -815,7 +825,12 @@ router.post("documents.unstar", auth(), async (ctx) => {
|
||||
|
||||
router.post("documents.create", auth(), createDocumentFromContext);
|
||||
router.post("documents.import", auth(), async (ctx) => {
|
||||
if (!ctx.is("multipart/form-data")) {
|
||||
throw new InvalidRequestError("Request type must be multipart/form-data");
|
||||
}
|
||||
|
||||
const file: any = Object.values(ctx.request.files)[0];
|
||||
ctx.assertPresent(file, "file is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
authorize(user, "create", Document);
|
||||
|
||||
@@ -95,6 +95,23 @@ describe("#documents.info", () => {
|
||||
expect(body.data.updatedBy).toEqual(undefined);
|
||||
});
|
||||
|
||||
it("should not return document from shareId if sharing is disabled for team", async () => {
|
||||
const { document, team, user } = await seed();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
team.sharing = false;
|
||||
await team.save();
|
||||
|
||||
const res = await server.post("/api/documents.info", {
|
||||
body: { shareId: share.id },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should not return document from revoked shareId", async () => {
|
||||
const { document, user } = await seed();
|
||||
const share = await buildShare({
|
||||
@@ -1524,6 +1541,18 @@ describe("#documents.unstar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("#documents.import", () => {
|
||||
it("should error if no file is passed", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/documents.import", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#documents.create", () => {
|
||||
it("should create as a new document", async () => {
|
||||
const { user, collection } = await seed();
|
||||
|
||||
@@ -30,7 +30,7 @@ router.post("shares.info", auth(), async (ctx) => {
|
||||
},
|
||||
});
|
||||
if (!share || !share.document) {
|
||||
throw new NotFoundError();
|
||||
return (ctx.response.status = 204);
|
||||
}
|
||||
|
||||
authorize(user, "read", share);
|
||||
|
||||
@@ -281,7 +281,7 @@ describe("#shares.info", () => {
|
||||
const res = await server.post("/api/shares.info", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
});
|
||||
expect(res.status).toEqual(404);
|
||||
expect(res.status).toEqual(204);
|
||||
});
|
||||
|
||||
it("should not find revoked share", async () => {
|
||||
@@ -295,7 +295,7 @@ describe("#shares.info", () => {
|
||||
const res = await server.post("/api/shares.info", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
});
|
||||
expect(res.status).toEqual(404);
|
||||
expect(res.status).toEqual(204);
|
||||
});
|
||||
|
||||
it("should not find share for deleted document", async () => {
|
||||
@@ -309,7 +309,7 @@ describe("#shares.info", () => {
|
||||
const res = await server.post("/api/shares.info", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
});
|
||||
expect(res.status).toEqual(404);
|
||||
expect(res.status).toEqual(204);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
|
||||
+5
-11
@@ -1,6 +1,7 @@
|
||||
// @flow
|
||||
import Router from "koa-router";
|
||||
import userInviter from "../commands/userInviter";
|
||||
import userSuspender from "../commands/userSuspender";
|
||||
import auth from "../middlewares/authentication";
|
||||
import { Event, User, Team } from "../models";
|
||||
import policy from "../policies";
|
||||
@@ -62,10 +63,11 @@ router.post("users.info", auth(), async (ctx) => {
|
||||
|
||||
router.post("users.update", auth(), async (ctx) => {
|
||||
const { user } = ctx.state;
|
||||
const { name, avatarUrl } = ctx.body;
|
||||
const { name, avatarUrl, language } = ctx.body;
|
||||
|
||||
if (name) user.name = name;
|
||||
if (avatarUrl) user.avatarUrl = avatarUrl;
|
||||
if (language) user.language = language;
|
||||
|
||||
await user.save();
|
||||
|
||||
@@ -135,23 +137,15 @@ router.post("users.demote", auth(), async (ctx) => {
|
||||
});
|
||||
|
||||
router.post("users.suspend", auth(), async (ctx) => {
|
||||
const admin = ctx.state.user;
|
||||
const userId = ctx.body.id;
|
||||
const teamId = ctx.state.user.teamId;
|
||||
ctx.assertPresent(userId, "id is required");
|
||||
|
||||
const user = await User.findByPk(userId);
|
||||
authorize(ctx.state.user, "suspend", user);
|
||||
|
||||
const team = await Team.findByPk(teamId);
|
||||
await team.suspendUser(user, admin);
|
||||
|
||||
await Event.create({
|
||||
name: "users.suspend",
|
||||
await userSuspender({
|
||||
user,
|
||||
actorId: ctx.state.user.id,
|
||||
userId,
|
||||
teamId,
|
||||
data: { name: user.name },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import File from "formidable/lib/file";
|
||||
import { strikethrough, tables } from "joplin-turndown-plugin-gfm";
|
||||
import mammoth from "mammoth";
|
||||
@@ -139,7 +140,16 @@ export default async function documentImporter({
|
||||
file: File,
|
||||
ip: string,
|
||||
}): Promise<{ text: string, title: string }> {
|
||||
const fileInfo = importMapping.filter((item) => item.type === file.type)[0];
|
||||
const fileInfo = importMapping.filter((item) => {
|
||||
if (item.type === file.type) {
|
||||
return true;
|
||||
}
|
||||
if (item.type === "text/markdown" && path.extname(file.name) === ".md") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})[0];
|
||||
|
||||
if (!fileInfo) {
|
||||
throw new InvalidRequestError(`File type ${file.type} not supported`);
|
||||
}
|
||||
|
||||
@@ -94,9 +94,28 @@ describe("documentImporter", () => {
|
||||
expect(response.title).toEqual("Heading 1");
|
||||
});
|
||||
|
||||
it("should error with unknown file type", async () => {
|
||||
it("should fallback to extension if mimetype unknown", async () => {
|
||||
const user = await buildUser();
|
||||
const name = "markdown.md";
|
||||
const file = new File({
|
||||
name,
|
||||
type: "application/octet-stream",
|
||||
path: path.resolve(__dirname, "..", "test", "fixtures", name),
|
||||
});
|
||||
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
file,
|
||||
ip,
|
||||
});
|
||||
|
||||
expect(response.text).toContain("This is a test paragraph");
|
||||
expect(response.title).toEqual("Heading 1");
|
||||
});
|
||||
|
||||
it("should error with unknown file type", async () => {
|
||||
const user = await buildUser();
|
||||
const name = "files.zip";
|
||||
const file = new File({
|
||||
name,
|
||||
type: "executable/zip",
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { uniqBy } from "lodash";
|
||||
import mailer from "../mailer";
|
||||
import { User, Event, Team } from "../models";
|
||||
import { sequelize } from "../sequelize";
|
||||
|
||||
type Invite = { name: string, email: string };
|
||||
|
||||
@@ -47,48 +46,33 @@ export default async function userInviter({
|
||||
let users = [];
|
||||
|
||||
// send and record remaining invites
|
||||
await Promise.all(
|
||||
filteredInvites.map(async (invite) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
const newUser = await User.create(
|
||||
{
|
||||
teamId: user.teamId,
|
||||
name: invite.name,
|
||||
email: invite.email,
|
||||
service: null,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
users.push(newUser);
|
||||
await Event.create(
|
||||
{
|
||||
name: "users.invite",
|
||||
actorId: user.id,
|
||||
teamId: user.teamId,
|
||||
data: {
|
||||
email: invite.email,
|
||||
name: invite.name,
|
||||
},
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await mailer.invite({
|
||||
to: invite.email,
|
||||
name: invite.name,
|
||||
actorName: user.name,
|
||||
actorEmail: user.email,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
});
|
||||
await transaction.commit();
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
);
|
||||
for (const invite of filteredInvites) {
|
||||
const newUser = await User.create({
|
||||
teamId: user.teamId,
|
||||
name: invite.name,
|
||||
email: invite.email,
|
||||
service: null,
|
||||
});
|
||||
users.push(newUser);
|
||||
await Event.create({
|
||||
name: "users.invite",
|
||||
actorId: user.id,
|
||||
teamId: user.teamId,
|
||||
data: {
|
||||
email: invite.email,
|
||||
name: invite.name,
|
||||
},
|
||||
ip,
|
||||
});
|
||||
await mailer.invite({
|
||||
to: invite.email,
|
||||
name: invite.name,
|
||||
actorName: user.name,
|
||||
actorEmail: user.email,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
});
|
||||
}
|
||||
|
||||
return { sent: filteredInvites, users };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// @flow
|
||||
import { Transaction } from "sequelize";
|
||||
import { ValidationError } from "../errors";
|
||||
import { User, Event, GroupUser } from "../models";
|
||||
import { sequelize } from "../sequelize";
|
||||
|
||||
export default async function userSuspender({
|
||||
user,
|
||||
actorId,
|
||||
ip,
|
||||
}: {
|
||||
user: User,
|
||||
actorId: string,
|
||||
ip: string,
|
||||
}): Promise<void> {
|
||||
if (user.id === actorId) {
|
||||
throw new ValidationError("Unable to suspend the current user");
|
||||
}
|
||||
|
||||
await sequelize.transaction(async (transaction: Transaction) => {
|
||||
await user.update(
|
||||
{
|
||||
suspendedById: actorId,
|
||||
suspendedAt: new Date(),
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await GroupUser.destroy({ where: { userId: user.id }, transaction });
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "users.suspend",
|
||||
actorId,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
data: { name: user.name },
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import { GroupUser } from "../models";
|
||||
import { buildGroup, buildUser } from "../test/factories";
|
||||
import { flushdb } from "../test/support";
|
||||
import userSuspender from "./userSuspender";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
describe("userSuspender", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should not suspend self", async () => {
|
||||
const user = await buildUser();
|
||||
let error;
|
||||
|
||||
try {
|
||||
await userSuspender({
|
||||
actorId: user.id,
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
expect(error.message).toEqual("Unable to suspend the current user");
|
||||
});
|
||||
|
||||
it("should suspend the user", async () => {
|
||||
const admin = await buildUser({ isAdmin: true });
|
||||
const user = await buildUser({ teamId: admin.teamId });
|
||||
await userSuspender({
|
||||
actorId: admin.id,
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
expect(user.suspendedAt).toBeTruthy();
|
||||
expect(user.suspendedById).toEqual(admin.id);
|
||||
});
|
||||
|
||||
it("should remove group memberships", async () => {
|
||||
const admin = await buildUser({ isAdmin: true });
|
||||
const user = await buildUser({ teamId: admin.teamId });
|
||||
const group = await buildGroup({ teamId: user.teamId });
|
||||
|
||||
await group.addUser(user, { through: { createdById: user.id } });
|
||||
|
||||
await userSuspender({
|
||||
actorId: admin.id,
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
expect(user.suspendedAt).toBeTruthy();
|
||||
expect(user.suspendedById).toEqual(admin.id);
|
||||
expect(await GroupUser.count()).toEqual(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn('users', 'language', {
|
||||
type: Sequelize.STRING,
|
||||
defaultValue: process.env.DEFAULT_LANGUAGE,
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn('users', 'language');
|
||||
}
|
||||
};
|
||||
@@ -205,15 +205,6 @@ Team.prototype.removeAdmin = async function (user: User) {
|
||||
}
|
||||
};
|
||||
|
||||
Team.prototype.suspendUser = async function (user: User, admin: User) {
|
||||
if (user.id === admin.id)
|
||||
throw new ValidationError("Unable to suspend the current user");
|
||||
return user.update({
|
||||
suspendedById: admin.id,
|
||||
suspendedAt: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
Team.prototype.activateUser = async function (user: User, admin: User) {
|
||||
return user.update({
|
||||
suspendedById: null,
|
||||
|
||||
@@ -4,6 +4,7 @@ import addMinutes from "date-fns/add_minutes";
|
||||
import subMinutes from "date-fns/sub_minutes";
|
||||
import JWT from "jsonwebtoken";
|
||||
import uuid from "uuid";
|
||||
import { languages } from "../../shared/i18n";
|
||||
import { ValidationError } from "../errors";
|
||||
import { sendEmail } from "../mailer";
|
||||
import { DataTypes, sequelize, encryptedFields } from "../sequelize";
|
||||
@@ -36,6 +37,13 @@ const User = sequelize.define(
|
||||
lastSigninEmailSentAt: DataTypes.DATE,
|
||||
suspendedAt: DataTypes.DATE,
|
||||
suspendedById: DataTypes.UUID,
|
||||
language: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: process.env.DEFAULT_LANGUAGE,
|
||||
validate: {
|
||||
isIn: [languages],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
paranoid: true,
|
||||
|
||||
@@ -7,6 +7,7 @@ Object {
|
||||
"id": "123",
|
||||
"isAdmin": undefined,
|
||||
"isSuspended": undefined,
|
||||
"language": undefined,
|
||||
"lastActiveAt": undefined,
|
||||
"name": "Test User",
|
||||
}
|
||||
@@ -19,6 +20,7 @@ Object {
|
||||
"id": "123",
|
||||
"isAdmin": undefined,
|
||||
"isSuspended": undefined,
|
||||
"language": undefined,
|
||||
"lastActiveAt": undefined,
|
||||
"name": "Test User",
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ type UserPresentation = {
|
||||
email?: string,
|
||||
isAdmin: boolean,
|
||||
isSuspended: boolean,
|
||||
language: string,
|
||||
};
|
||||
|
||||
export default (user: User, options: Options = {}): ?UserPresentation => {
|
||||
@@ -23,6 +24,7 @@ export default (user: User, options: Options = {}): ?UserPresentation => {
|
||||
userData.isAdmin = user.isAdmin;
|
||||
userData.isSuspended = user.isSuspended;
|
||||
userData.avatarUrl = user.avatarUrl;
|
||||
userData.language = user.language;
|
||||
|
||||
if (options.includeDetails) {
|
||||
userData.email = user.email;
|
||||
|
||||
@@ -6,7 +6,9 @@ import Koa from "koa";
|
||||
import Router from "koa-router";
|
||||
import sendfile from "koa-sendfile";
|
||||
import serve from "koa-static";
|
||||
import { languages } from "../shared/i18n";
|
||||
import environment from "./env";
|
||||
import { NotFoundError } from "./errors";
|
||||
import apexRedirect from "./middlewares/apexRedirect";
|
||||
import { opensearchResponse } from "./utils/opensearch";
|
||||
import { robotsResponse } from "./utils/robots";
|
||||
@@ -72,6 +74,25 @@ if (process.env.NODE_ENV === "production") {
|
||||
});
|
||||
}
|
||||
|
||||
router.get("/locales/:lng.json", async (ctx) => {
|
||||
let { lng } = ctx.params;
|
||||
|
||||
if (!languages.includes(lng)) {
|
||||
throw new NotFoundError();
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
ctx.set({
|
||||
"Cache-Control": `max-age=${7 * 24 * 60 * 60}`,
|
||||
});
|
||||
}
|
||||
|
||||
await sendfile(
|
||||
ctx,
|
||||
path.join(__dirname, "../shared/i18n/locales", lng, "translation.json")
|
||||
);
|
||||
});
|
||||
|
||||
router.get("/robots.txt", (ctx) => {
|
||||
ctx.body = robotsResponse(ctx);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// @flow
|
||||
import i18n from "i18next";
|
||||
import backend from "i18next-http-backend";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
export const initI18n = () => {
|
||||
const lng =
|
||||
"DEFAULT_LANGUAGE" in process.env ? process.env.DEFAULT_LANGUAGE : "en_US";
|
||||
|
||||
i18n
|
||||
.use(backend)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
backend: {
|
||||
// this must match the path defined in routes. It's the path that the
|
||||
// frontend UI code will hit to load missing translations.
|
||||
loadPath: "/locales/{{lng}}.json",
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
lng,
|
||||
fallbackLng: lng,
|
||||
debug: process.env.NODE_ENV !== "production",
|
||||
keySeparator: false,
|
||||
});
|
||||
|
||||
return i18n;
|
||||
};
|
||||
|
||||
export const languageOptions = [
|
||||
{ label: "English (US)", value: "en_US" },
|
||||
{ label: "Deutsch (Deutschland)", value: "de_DE" },
|
||||
{ label: "Español (España)", value: "es_ES" },
|
||||
{ label: "Français (France)", value: "fr_FR" },
|
||||
{ label: "Português (Portugal)", value: "pt_PT" },
|
||||
];
|
||||
|
||||
export const languages: string[] = languageOptions.map((i) => i.value);
|
||||
@@ -0,0 +1,98 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import i18n from "i18next";
|
||||
import de_DE from "./locales/de_DE/translation.json";
|
||||
import en_US from "./locales/en_US/translation.json";
|
||||
import pt_PT from "./locales/pt_PT/translation.json";
|
||||
import { initI18n } from ".";
|
||||
|
||||
describe("i18n process.env is unset", () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.DEFAULT_LANGUAGE;
|
||||
initI18n()
|
||||
.addResources("en_US", "translation", en_US)
|
||||
.addResources("de_DE", "translation", de_DE)
|
||||
.addResources("pt_PT", "translation", pt_PT);
|
||||
});
|
||||
|
||||
it("translation of key should match", () =>
|
||||
expect(i18n.t("Saving…")).toBe("Saving…"));
|
||||
|
||||
it("translation if changed to de_DE", () => {
|
||||
i18n.changeLanguage("de_DE");
|
||||
expect(i18n.t("Saving…")).toBe("Speichert…");
|
||||
});
|
||||
|
||||
it("translation if changed to pt_PT", () => {
|
||||
i18n.changeLanguage("pt_PT");
|
||||
expect(i18n.t("Saving…")).toBe("A guardar…");
|
||||
});
|
||||
});
|
||||
|
||||
describe("i18n process.env is en_US", () => {
|
||||
beforeEach(() => {
|
||||
process.env.DEFAULT_LANGUAGE = "en_US";
|
||||
initI18n()
|
||||
.addResources("en_US", "translation", en_US)
|
||||
.addResources("de_DE", "translation", de_DE)
|
||||
.addResources("pt_PT", "translation", pt_PT);
|
||||
});
|
||||
|
||||
it("translation of key should match", () =>
|
||||
expect(i18n.t("Saving…")).toBe("Saving…"));
|
||||
|
||||
it("translation if changed to de_DE", () => {
|
||||
i18n.changeLanguage("de_DE");
|
||||
expect(i18n.t("Saving…")).toBe("Speichert…");
|
||||
});
|
||||
|
||||
it("translation if changed to pt_PT", () => {
|
||||
i18n.changeLanguage("pt_PT");
|
||||
expect(i18n.t("Saving…")).toBe("A guardar…");
|
||||
});
|
||||
});
|
||||
|
||||
describe("i18n process.env is de_DE", () => {
|
||||
beforeEach(() => {
|
||||
process.env.DEFAULT_LANGUAGE = "de_DE";
|
||||
initI18n()
|
||||
.addResources("en_US", "translation", en_US)
|
||||
.addResources("de_DE", "translation", de_DE)
|
||||
.addResources("pt_PT", "translation", pt_PT);
|
||||
});
|
||||
|
||||
it("translation of key should match", () =>
|
||||
expect(i18n.t("Saving…")).toBe("Speichert…"));
|
||||
|
||||
it("translation if changed to en_US", () => {
|
||||
i18n.changeLanguage("en_US");
|
||||
expect(i18n.t("Saving…")).toBe("Saving…");
|
||||
});
|
||||
|
||||
it("translation if changed to pt_PT", () => {
|
||||
i18n.changeLanguage("pt_PT");
|
||||
expect(i18n.t("Saving…")).toBe("A guardar…");
|
||||
});
|
||||
});
|
||||
|
||||
describe("i18n process.env is pt_PT", () => {
|
||||
beforeEach(() => {
|
||||
process.env.DEFAULT_LANGUAGE = "pt_PT";
|
||||
initI18n()
|
||||
.addResources("en_US", "translation", en_US)
|
||||
.addResources("de_DE", "translation", de_DE)
|
||||
.addResources("pt_PT", "translation", pt_PT);
|
||||
});
|
||||
|
||||
it("translation of key should match", () =>
|
||||
expect(i18n.t("Saving…")).toBe("A guardar…"));
|
||||
|
||||
it("translation if changed to en_US", () => {
|
||||
i18n.changeLanguage("en_US");
|
||||
expect(i18n.t("Saving…")).toBe("Saving…");
|
||||
});
|
||||
|
||||
it("translation if changed to de_DE", () => {
|
||||
i18n.changeLanguage("de_DE");
|
||||
expect(i18n.t("Saving…")).toBe("Speichert…");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user