mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6ba98b262 | |||
| 4fc52b9f3d | |||
| 619b369295 | |||
| 6f4cdc9359 | |||
| 4fb5022324 | |||
| 70e79a5264 | |||
| da3d08566e | |||
| 44a484e456 | |||
| 75e6734388 | |||
| a6ee913211 | |||
| 525d34a1f7 | |||
| 6dde45cee9 | |||
| 3b07a53093 | |||
| b25a98c114 | |||
| 2ae30a36cd | |||
| b2e6a1a4f6 | |||
| d48a1eed35 | |||
| a55d95c7b2 | |||
| 5d2be0a199 | |||
| 8911020f34 | |||
| 3864443eae | |||
| 660e9682c5 | |||
| ca36db1ac3 | |||
| ff87da523f | |||
| 633ccc0800 | |||
| 755ceab7dd | |||
| e55290f118 |
+1
-15
@@ -10,30 +10,20 @@ DATABASE_URL=postgres://user:pass@localhost:5532/outline
|
||||
DATABASE_URL_TEST=postgres://user:pass@localhost:5532/outline-test
|
||||
REDIS_URL=redis://localhost:6479
|
||||
|
||||
# Must point to the publicly accessible URL for the installation
|
||||
URL=http://localhost:3000
|
||||
PORT=3000
|
||||
|
||||
# Optional. If using a Cloudfront distribution or similar the origin server
|
||||
# should be set to the same as URL.
|
||||
CDN_URL=
|
||||
|
||||
# enforce (auto redirect to) https in production, (optional) default is true.
|
||||
# set to false if your SSL is terminated at a loadbalancer, for example
|
||||
FORCE_HTTPS=true
|
||||
|
||||
ENABLE_UPDATES=true
|
||||
DEBUG=cache,presenters,events,emails,mailer,utils,multiplayer,server,services
|
||||
DEBUG=cache,presenters,events
|
||||
|
||||
# Third party signin credentials (at least one is required)
|
||||
SLACK_KEY=get_a_key_from_slack
|
||||
SLACK_SECRET=get_the_secret_of_above_key
|
||||
|
||||
# To configure Google auth, you'll need to create an OAuth Client ID at
|
||||
# => https://console.cloud.google.com/apis/credentials
|
||||
#
|
||||
# When configuring the Client ID, add an Authorized redirect URI:
|
||||
# https://<your Outline URL>/auth/google.callback
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
@@ -70,7 +60,3 @@ SMTP_REPLY_EMAIL=
|
||||
|
||||
# Custom logo that displays on the authentication screen, scaled to height: 60px
|
||||
# TEAM_LOGO=https://example.com/images/logo.png
|
||||
|
||||
# See translate.getoutline.com for a list of available language codes and their
|
||||
# percentage translated.
|
||||
DEFAULT_LANGUAGE=en_US
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
[options]
|
||||
emoji=true
|
||||
sharedmemory.heap_size=3221225472
|
||||
|
||||
module.system.node.resolve_dirname=node_modules
|
||||
module.system.node.resolve_dirname=app
|
||||
@@ -33,7 +32,6 @@ module.file_ext=.json
|
||||
esproposal.decorators=ignore
|
||||
esproposal.class_static_fields=enable
|
||||
esproposal.class_instance_fields=enable
|
||||
esproposal.optional_chaining=enable
|
||||
|
||||
suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe
|
||||
suppress_comment=\\(.\\|\n\\)*\\$FlowIssue
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# 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
|
||||
Vendored
+1
-2
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"javascript.validate.enable": false,
|
||||
"javascript.format.enable": false,
|
||||
"typescript.validate.enable": false,
|
||||
"typescript.format.enable": false,
|
||||
"editor.formatOnSave": true,
|
||||
"typescript.format.enable": false
|
||||
}
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.51.0
|
||||
Licensed Work: Outline 0.49.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-12-13
|
||||
Change Date: 2023-10-26
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -9,16 +9,10 @@ build:
|
||||
|
||||
test:
|
||||
docker-compose up -d redis postgres s3
|
||||
yarn sequelize db:drop --env=test
|
||||
yarn sequelize db:create --env=test
|
||||
yarn sequelize db:migrate --env=test
|
||||
yarn test
|
||||
|
||||
watch:
|
||||
docker-compose up -d redis postgres s3
|
||||
yarn sequelize db:drop --env=test
|
||||
yarn sequelize db:create --env=test
|
||||
yarn sequelize db:migrate --env=test
|
||||
yarn test:watch
|
||||
|
||||
destroy:
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield&circle-token=c0c4c2f39990e277385d5c1ae96169c409eb887a"></a>
|
||||
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat"></a>
|
||||
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg"></a>
|
||||
<a href="https://translate.getoutline.com/project/outline"><img src="https://badges.crowdin.net/outline/localized.svg"></a>
|
||||
</p>
|
||||
|
||||
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com).
|
||||
@@ -75,20 +74,6 @@ In development you can quickly get an environment running using Docker by follow
|
||||
1. Ensure that the bot token scope contains at least `users:read`
|
||||
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
|
||||
|
||||
### Upgrade
|
||||
|
||||
#### Docker
|
||||
|
||||
If you're running Outline with Docker you'll need to run migrations within the docker container after updating the image. The command will be something like:
|
||||
```
|
||||
docker run --rm outlinewiki/outline:latest yarn sequelize:migrate
|
||||
```
|
||||
#### Yarn
|
||||
|
||||
If you're running Outline by cloning this repository, run the following command to upgrade:
|
||||
```
|
||||
yarn run upgrade
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
@@ -154,16 +139,8 @@ To add new tests, write your tests with [Jest](https://facebook.github.io/jest/)
|
||||
|
||||
```shell
|
||||
# To run all tests
|
||||
make test
|
||||
yarn test
|
||||
|
||||
# To run backend tests in watch mode
|
||||
make watch
|
||||
```
|
||||
|
||||
Once the test database is created with `make test` you may individually run
|
||||
frontend and backend tests directly.
|
||||
|
||||
```shell
|
||||
# To run backend tests
|
||||
yarn test:server
|
||||
|
||||
@@ -179,7 +156,6 @@ 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
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,18 +0,0 @@
|
||||
/* 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 });
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,11 @@ export const Action = styled(Flex)`
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
|
||||
a {
|
||||
color: ${(props) => props.theme.text};
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,32 +1,18 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { observer, inject } 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 = {
|
||||
children: React.Node,
|
||||
auth: AuthStore,
|
||||
children?: React.Node,
|
||||
};
|
||||
|
||||
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 (language && i18n.language !== language) {
|
||||
// Languages are stored in en_US format in the database, however the
|
||||
// frontend translation framework (i18next) expects en-US
|
||||
i18n.changeLanguage(language.replace("_", "-"));
|
||||
}
|
||||
}, [i18n, language]);
|
||||
|
||||
const Authenticated = observer(({ auth, children }: Props) => {
|
||||
if (auth.authenticated) {
|
||||
const { user, team } = auth;
|
||||
const { hostname } = window.location;
|
||||
@@ -57,6 +43,6 @@ const Authenticated = ({ children }: Props) => {
|
||||
|
||||
auth.logout(true);
|
||||
return <Redirect to="/" />;
|
||||
};
|
||||
});
|
||||
|
||||
export default observer(Authenticated);
|
||||
export default inject("auth")(Authenticated);
|
||||
|
||||
@@ -3,17 +3,13 @@ import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import User from "models/User";
|
||||
import placeholder from "./placeholder.png";
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
src: string,
|
||||
size: number,
|
||||
icon?: React.Node,
|
||||
user?: User,
|
||||
onClick?: () => void,
|
||||
className?: string,
|
||||
|};
|
||||
};
|
||||
|
||||
@observer
|
||||
class Avatar extends React.Component<Props> {
|
||||
|
||||
@@ -4,7 +4,6 @@ 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";
|
||||
@@ -17,7 +16,6 @@ type Props = {
|
||||
isEditing: boolean,
|
||||
isCurrentUser: boolean,
|
||||
lastViewedAt: string,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -39,25 +37,20 @@ 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 && `(${t("You")})`}
|
||||
<strong>{user.name}</strong> {isCurrentUser && "(You)"}
|
||||
<br />
|
||||
{action}
|
||||
{isPresent
|
||||
? isEditing
|
||||
? "currently editing"
|
||||
: "currently viewing"
|
||||
: `viewed ${distanceInWordsToNow(new Date(lastViewedAt))} ago`}
|
||||
</Centered>
|
||||
}
|
||||
placement="bottom"
|
||||
@@ -90,4 +83,4 @@ const AvatarWrapper = styled.div`
|
||||
transition: opacity 250ms ease-in-out;
|
||||
`;
|
||||
|
||||
export default withTranslation()<AvatarWithPresence>(AvatarWithPresence);
|
||||
export default AvatarWithPresence;
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
EditIcon,
|
||||
GoToIcon,
|
||||
MoreIcon,
|
||||
PadlockIcon,
|
||||
ShapesIcon,
|
||||
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";
|
||||
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import Document from "models/Document";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import Flex from "components/Flex";
|
||||
import useStores from "hooks/useStores";
|
||||
import BreadcrumbMenu from "menus/BreadcrumbMenu";
|
||||
import BreadcrumbMenu from "./BreadcrumbMenu";
|
||||
import { collectionUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document,
|
||||
collections: CollectionsStore,
|
||||
onlyText: boolean,
|
||||
};
|
||||
|
||||
function Icon({ document }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (document.isDeleted) {
|
||||
return (
|
||||
<>
|
||||
<CategoryName to="/trash">
|
||||
<CollectionName to="/trash">
|
||||
<TrashIcon color="currentColor" />
|
||||
|
||||
<span>{t("Trash")}</span>
|
||||
</CategoryName>
|
||||
<span>Trash</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
@@ -43,11 +43,11 @@ function Icon({ document }) {
|
||||
if (document.isArchived) {
|
||||
return (
|
||||
<>
|
||||
<CategoryName to="/archive">
|
||||
<CollectionName to="/archive">
|
||||
<ArchiveIcon color="currentColor" />
|
||||
|
||||
<span>{t("Archive")}</span>
|
||||
</CategoryName>
|
||||
<span>Archive</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
@@ -55,11 +55,11 @@ function Icon({ document }) {
|
||||
if (document.isDraft) {
|
||||
return (
|
||||
<>
|
||||
<CategoryName to="/drafts">
|
||||
<CollectionName to="/drafts">
|
||||
<EditIcon color="currentColor" />
|
||||
|
||||
<span>{t("Drafts")}</span>
|
||||
</CategoryName>
|
||||
<span>Drafts</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
@@ -67,11 +67,11 @@ function Icon({ document }) {
|
||||
if (document.isTemplate) {
|
||||
return (
|
||||
<>
|
||||
<CategoryName to="/templates">
|
||||
<CollectionName to="/templates">
|
||||
<ShapesIcon color="currentColor" />
|
||||
|
||||
<span>{t("Templates")}</span>
|
||||
</CategoryName>
|
||||
<span>Templates</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
@@ -79,21 +79,20 @@ function Icon({ document }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Breadcrumb = ({ document, onlyText }: Props) => {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
|
||||
let collection = collections.get(document.collectionId);
|
||||
if (!collection) {
|
||||
if (!document.deletedAt) return <div />;
|
||||
|
||||
collection = {
|
||||
id: document.collectionId,
|
||||
name: t("Deleted Collection"),
|
||||
name: "Deleted Collection",
|
||||
color: "currentColor",
|
||||
};
|
||||
}
|
||||
|
||||
const path = collection.pathToDocument
|
||||
? collection.pathToDocument(document.id).slice(0, -1)
|
||||
? collection.pathToDocument(document).slice(0, -1)
|
||||
: [];
|
||||
|
||||
if (onlyText === true) {
|
||||
@@ -129,7 +128,7 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
|
||||
</CollectionName>
|
||||
{isNestedDocument && (
|
||||
<>
|
||||
<Slash /> <BreadcrumbMenu path={menuPath} />
|
||||
<Slash /> <BreadcrumbMenu label={<Overflow />} path={menuPath} />
|
||||
</>
|
||||
)}
|
||||
{lastPath && (
|
||||
@@ -142,12 +141,7 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const Slash = styled(GoToIcon)`
|
||||
flex-shrink: 0;
|
||||
fill: ${(props) => props.theme.divider};
|
||||
`;
|
||||
});
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
display: none;
|
||||
@@ -169,6 +163,22 @@ const SmallSlash = styled(GoToIcon)`
|
||||
opacity: 0.25;
|
||||
`;
|
||||
|
||||
export const Slash = styled(GoToIcon)`
|
||||
flex-shrink: 0;
|
||||
fill: ${(props) => props.theme.divider};
|
||||
`;
|
||||
|
||||
const Overflow = styled(MoreIcon)`
|
||||
flex-shrink: 0;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
fill: ${(props) => props.theme.divider};
|
||||
|
||||
&:active,
|
||||
&:hover {
|
||||
fill: ${(props) => props.theme.text};
|
||||
}
|
||||
`;
|
||||
|
||||
const Crumb = styled(Link)`
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 15px;
|
||||
@@ -184,21 +194,12 @@ const Crumb = styled(Link)`
|
||||
|
||||
const CollectionName = styled(Link)`
|
||||
display: flex;
|
||||
flex-shrink: 1;
|
||||
flex-shrink: 0;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const CategoryName = styled(CollectionName)`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default observer(Breadcrumb);
|
||||
export default inject("collections")(Breadcrumb);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
|
||||
|
||||
type Props = {
|
||||
label: React.Node,
|
||||
path: Array<any>,
|
||||
};
|
||||
|
||||
export default class BreadcrumbMenu extends React.Component<Props> {
|
||||
render() {
|
||||
const { path } = this.props;
|
||||
|
||||
return (
|
||||
<DropdownMenu label={this.props.label} position="center">
|
||||
{path.map((item) => (
|
||||
<DropdownMenuItem as={Link} to={item.url} key={item.id}>
|
||||
{item.title}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
+18
-42
@@ -22,13 +22,9 @@ const RealButton = styled.button`
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
${(props) =>
|
||||
!props.borderOnHover &&
|
||||
`
|
||||
svg {
|
||||
fill: ${props.iconColor || props.theme.buttonText};
|
||||
}
|
||||
`}
|
||||
svg {
|
||||
fill: ${(props) => props.iconColor || props.theme.buttonText};
|
||||
}
|
||||
|
||||
&::-moz-focus-inner {
|
||||
padding: 0;
|
||||
@@ -46,30 +42,24 @@ const RealButton = styled.button`
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$neutral &&
|
||||
props.neutral &&
|
||||
`
|
||||
background: ${props.theme.buttonNeutralBackground};
|
||||
color: ${props.theme.buttonNeutralText};
|
||||
box-shadow: ${
|
||||
props.borderOnHover
|
||||
? "none"
|
||||
: `rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.buttonNeutralBorder} 0 0 0 1px inset`
|
||||
props.borderOnHover ? "none" : "rgba(0, 0, 0, 0.07) 0px 1px 2px"
|
||||
};
|
||||
border: 1px solid ${
|
||||
props.borderOnHover ? "transparent" : props.theme.buttonNeutralBorder
|
||||
};
|
||||
|
||||
${
|
||||
props.borderOnHover
|
||||
? ""
|
||||
: `svg {
|
||||
svg {
|
||||
fill: ${props.iconColor || props.theme.buttonNeutralText};
|
||||
}`
|
||||
}
|
||||
|
||||
|
||||
&:hover {
|
||||
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
|
||||
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
|
||||
props.theme.buttonNeutralBorder
|
||||
} 0 0 0 1px inset;
|
||||
border: 1px solid ${props.theme.buttonNeutralBorder};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -81,9 +71,9 @@ const RealButton = styled.button`
|
||||
background: ${props.theme.danger};
|
||||
color: ${props.theme.white};
|
||||
|
||||
&:hover {
|
||||
background: ${darken(0.05, props.theme.danger)};
|
||||
}
|
||||
&:hover {
|
||||
background: ${darken(0.05, props.theme.danger)};
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -102,14 +92,14 @@ export const Inner = styled.span`
|
||||
line-height: ${(props) => (props.hasIcon ? 24 : 32)}px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
min-height: 30px;
|
||||
|
||||
${(props) => props.hasIcon && props.hasText && "padding-left: 4px;"};
|
||||
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
|
||||
`;
|
||||
|
||||
export type Props = {|
|
||||
type?: "button" | "submit",
|
||||
export type Props = {
|
||||
type?: string,
|
||||
value?: string,
|
||||
icon?: React.Node,
|
||||
iconColor?: string,
|
||||
@@ -117,22 +107,9 @@ export type Props = {|
|
||||
children?: React.Node,
|
||||
innerRef?: React.ElementRef<any>,
|
||||
disclosure?: boolean,
|
||||
neutral?: boolean,
|
||||
danger?: boolean,
|
||||
primary?: boolean,
|
||||
disabled?: boolean,
|
||||
fullwidth?: boolean,
|
||||
autoFocus?: boolean,
|
||||
style?: Object,
|
||||
as?: React.ComponentType<any>,
|
||||
to?: string,
|
||||
onClick?: (event: SyntheticEvent<>) => mixed,
|
||||
borderOnHover?: boolean,
|
||||
|
||||
"data-on"?: string,
|
||||
"data-event-category"?: string,
|
||||
"data-event-action"?: string,
|
||||
|};
|
||||
};
|
||||
|
||||
function Button({
|
||||
type = "text",
|
||||
@@ -141,14 +118,13 @@ function Button({
|
||||
value,
|
||||
disclosure,
|
||||
innerRef,
|
||||
neutral,
|
||||
...rest
|
||||
}: Props) {
|
||||
const hasText = children !== undefined || value !== undefined;
|
||||
const hasIcon = icon !== undefined;
|
||||
|
||||
return (
|
||||
<RealButton type={type} ref={innerRef} $neutral={neutral} {...rest}>
|
||||
<RealButton type={type} ref={innerRef} {...rest}>
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
{hasIcon && icon}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
onClick: (ev: SyntheticEvent<>) => void,
|
||||
children: React.Node,
|
||||
};
|
||||
|
||||
export default function ButtonLink(props: Props) {
|
||||
return <Button {...props} />;
|
||||
}
|
||||
|
||||
const Button = styled.button`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: ${(props) => props.theme.link};
|
||||
line-height: inherit;
|
||||
background: none;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
`;
|
||||
@@ -1,21 +1,18 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import HelpText from "components/HelpText";
|
||||
import VisuallyHidden from "components/VisuallyHidden";
|
||||
|
||||
export type Props = {|
|
||||
export type Props = {
|
||||
checked?: boolean,
|
||||
label?: string,
|
||||
labelHidden?: boolean,
|
||||
className?: string,
|
||||
name?: string,
|
||||
disabled?: boolean,
|
||||
onChange: (event: SyntheticInputEvent<HTMLInputElement>) => mixed,
|
||||
note?: string,
|
||||
short?: boolean,
|
||||
small?: boolean,
|
||||
|};
|
||||
};
|
||||
|
||||
const LabelText = styled.span`
|
||||
font-weight: 500;
|
||||
|
||||
@@ -20,9 +20,7 @@ type Props = {
|
||||
@observer
|
||||
class Collaborators extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
if (!this.props.document.isDeleted) {
|
||||
this.props.views.fetchPage({ documentId: this.props.document.id });
|
||||
}
|
||||
this.props.views.fetchPage({ documentId: this.props.document.id });
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
|
||||
const Header = styled.h3`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
letter-spacing: 0.04em;
|
||||
margin: 1em 12px 0.5em;
|
||||
`;
|
||||
|
||||
export default Header;
|
||||
@@ -1,21 +0,0 @@
|
||||
// @flow
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import NudeButton from "components/NudeButton";
|
||||
|
||||
export default function OverflowMenuButton({
|
||||
iconColor,
|
||||
className,
|
||||
...rest
|
||||
}: any) {
|
||||
return (
|
||||
<MenuButton {...rest}>
|
||||
{(props) => (
|
||||
<NudeButton className={className} {...props}>
|
||||
<MoreIcon color={iconColor} />
|
||||
</NudeButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { MenuSeparator } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
|
||||
export default function Separator(rest: {}) {
|
||||
return (
|
||||
<MenuSeparator {...rest}>
|
||||
{(props) => <HorizontalRule {...props} />}
|
||||
</MenuSeparator>
|
||||
);
|
||||
}
|
||||
|
||||
const HorizontalRule = styled.hr`
|
||||
margin: 0.5em 12px;
|
||||
`;
|
||||
@@ -1,169 +0,0 @@
|
||||
// @flow
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
useMenuState,
|
||||
MenuButton,
|
||||
MenuItem as BaseMenuItem,
|
||||
} from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import MenuItem, { MenuAnchor } from "./MenuItem";
|
||||
import Separator from "./Separator";
|
||||
import ContextMenu from ".";
|
||||
|
||||
type TMenuItem =
|
||||
| {|
|
||||
title: React.Node,
|
||||
to: string,
|
||||
visible?: boolean,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
|}
|
||||
| {|
|
||||
title: React.Node,
|
||||
onClick: (event: SyntheticEvent<>) => void | Promise<void>,
|
||||
visible?: boolean,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
|}
|
||||
| {|
|
||||
title: React.Node,
|
||||
href: string,
|
||||
visible?: boolean,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
|}
|
||||
| {|
|
||||
title: React.Node,
|
||||
visible?: boolean,
|
||||
disabled?: boolean,
|
||||
style?: Object,
|
||||
hover?: boolean,
|
||||
items: TMenuItem[],
|
||||
|}
|
||||
| {|
|
||||
type: "separator",
|
||||
visible?: boolean,
|
||||
|}
|
||||
| {|
|
||||
type: "heading",
|
||||
visible?: boolean,
|
||||
title: React.Node,
|
||||
|};
|
||||
|
||||
type Props = {|
|
||||
items: TMenuItem[],
|
||||
|};
|
||||
|
||||
const Disclosure = styled(ExpandedIcon)`
|
||||
transform: rotate(270deg);
|
||||
justify-self: flex-end;
|
||||
`;
|
||||
|
||||
const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({ modal: true });
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton ref={ref} {...menu} {...rest}>
|
||||
{(props) => (
|
||||
<MenuAnchor {...props}>
|
||||
{title} <Disclosure color="currentColor" />
|
||||
</MenuAnchor>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Submenu")}>
|
||||
<Template {...menu} items={templateItems} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function Template({ items, ...menu }: Props): React.Node {
|
||||
let filtered = items.filter((item) => item.visible !== false);
|
||||
|
||||
// this block literally just trims unneccessary separators
|
||||
filtered = filtered.reduce((acc, item, index) => {
|
||||
// trim separators from start / end
|
||||
if (item.type === "separator" && index === 0) return acc;
|
||||
if (item.type === "separator" && index === filtered.length - 1) return acc;
|
||||
|
||||
// trim double separators looking ahead / behind
|
||||
const prev = filtered[index - 1];
|
||||
if (prev && prev.type === "separator" && item.type === "separator")
|
||||
return acc;
|
||||
|
||||
// otherwise, continue
|
||||
return [...acc, item];
|
||||
}, []);
|
||||
|
||||
return filtered.map((item, index) => {
|
||||
if (item.to) {
|
||||
return (
|
||||
<MenuItem
|
||||
as={Link}
|
||||
to={item.to}
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.href) {
|
||||
return (
|
||||
<MenuItem
|
||||
href={item.href}
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
target="_blank"
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.onClick) {
|
||||
return (
|
||||
<MenuItem
|
||||
as="button"
|
||||
onClick={item.onClick}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
key={index}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.items) {
|
||||
return (
|
||||
<BaseMenuItem
|
||||
key={index}
|
||||
as={Submenu}
|
||||
templateItems={item.items}
|
||||
title={item.title}
|
||||
{...menu}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "separator") {
|
||||
return <Separator key={index} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export default React.memo<Props>(Template);
|
||||
@@ -1,77 +0,0 @@
|
||||
// @flow
|
||||
import { rgba } from "polished";
|
||||
import * as React from "react";
|
||||
import { Menu } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
|
||||
type Props = {|
|
||||
"aria-label": string,
|
||||
visible?: boolean,
|
||||
animating?: boolean,
|
||||
children: React.Node,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
|};
|
||||
|
||||
export default function ContextMenu({
|
||||
children,
|
||||
onOpen,
|
||||
onClose,
|
||||
...rest
|
||||
}: Props) {
|
||||
const previousVisible = usePrevious(rest.visible);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (rest.visible && !previousVisible) {
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
}
|
||||
if (!rest.visible && previousVisible) {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}, [onOpen, onClose, previousVisible, rest.visible]);
|
||||
|
||||
return (
|
||||
<Menu {...rest}>
|
||||
{(props) => (
|
||||
<Position {...props}>
|
||||
<Background>
|
||||
{rest.visible || rest.animating ? children : null}
|
||||
</Background>
|
||||
</Position>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
const Position = styled.div`
|
||||
position: absolute;
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
`;
|
||||
|
||||
const Background = styled.div`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: ${(props) => (props.left !== undefined ? "25%" : "75%")} 0;
|
||||
background: ${(props) => rgba(props.theme.menuBackground, 0.95)};
|
||||
border: ${(props) =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
border-radius: 2px;
|
||||
padding: 0.5em 0;
|
||||
min-width: 180px;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: 75vh;
|
||||
max-width: 276px;
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
pointer-events: all;
|
||||
font-weight: normal;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
@@ -156,7 +156,7 @@ const Wrapper = styled(Flex)`
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
min-width: ${(props) => props.theme.sidebarWidth}px;
|
||||
min-width: ${(props) => props.theme.sidebarWidth};
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: none;
|
||||
@@ -165,7 +165,7 @@ const Wrapper = styled(Flex)`
|
||||
const Sidebar = styled(Flex)`
|
||||
display: none;
|
||||
background: ${(props) => props.theme.background};
|
||||
min-width: ${(props) => props.theme.sidebarWidth}px;
|
||||
min-width: ${(props) => props.theme.sidebarWidth};
|
||||
border-left: 1px solid ${(props) => props.theme.divider};
|
||||
z-index: 1;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import format from "date-fns/format";
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
@@ -10,12 +11,11 @@ import Avatar from "components/Avatar";
|
||||
import Flex from "components/Flex";
|
||||
import Time from "components/Time";
|
||||
import RevisionMenu from "menus/RevisionMenu";
|
||||
import { type Theme } from "types";
|
||||
|
||||
import { documentHistoryUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
theme: Theme,
|
||||
theme: Object,
|
||||
showMenu: boolean,
|
||||
selected: boolean,
|
||||
document: Document,
|
||||
@@ -44,7 +44,9 @@ class RevisionListItem extends React.Component<Props> {
|
||||
<StyledRevisionMenu
|
||||
document={document}
|
||||
revision={revision}
|
||||
iconColor={selected ? theme.white : theme.textTertiary}
|
||||
label={
|
||||
<MoreIcon color={selected ? theme.white : theme.textTertiary} />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</StyledNavLink>
|
||||
|
||||
@@ -2,17 +2,12 @@
|
||||
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
|
||||
import * as React from "react";
|
||||
import Document from "models/Document";
|
||||
import DocumentListItem from "components/DocumentListItem";
|
||||
import DocumentPreview from "components/DocumentPreview";
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
documents: Document[],
|
||||
limit?: number,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
showDraft?: boolean,
|
||||
showTemplate?: boolean,
|
||||
|};
|
||||
};
|
||||
|
||||
export default function DocumentList({ limit, documents, ...rest }: Props) {
|
||||
const items = limit ? documents.splice(0, limit) : documents;
|
||||
@@ -23,7 +18,7 @@ export default function DocumentList({ limit, documents, ...rest }: Props) {
|
||||
defaultActiveChildIndex={0}
|
||||
>
|
||||
{items.map((document) => (
|
||||
<DocumentListItem key={document.id} document={document} {...rest} />
|
||||
<DocumentPreview key={document.id} document={document} {...rest} />
|
||||
))}
|
||||
</ArrowKeyNavigation>
|
||||
);
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Document from "models/Document";
|
||||
import Badge from "components/Badge";
|
||||
import Button from "components/Button";
|
||||
import DocumentMeta from "components/DocumentMeta";
|
||||
import EventBoundary from "components/EventBoundary";
|
||||
import Flex from "components/Flex";
|
||||
import Highlight from "components/Highlight";
|
||||
import StarButton, { AnimatedStar } from "components/Star";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
highlight?: ?string,
|
||||
context?: ?string,
|
||||
showNestedDocuments?: boolean,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
showDraft?: boolean,
|
||||
showTemplate?: boolean,
|
||||
|};
|
||||
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
|
||||
function replaceResultMarks(tag: string) {
|
||||
// don't use SEARCH_RESULT_REGEX here as it causes
|
||||
// an infinite loop to trigger a regex inside it's own callback
|
||||
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
|
||||
}
|
||||
|
||||
function DocumentListItem(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const {
|
||||
document,
|
||||
showNestedDocuments,
|
||||
showCollection,
|
||||
showPublished,
|
||||
showPin,
|
||||
showDraft = true,
|
||||
showTemplate,
|
||||
highlight,
|
||||
context,
|
||||
} = props;
|
||||
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar =
|
||||
!document.isDraft && !document.isArchived && !document.isTemplate;
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: document.url,
|
||||
state: { title: document.titleWithDefault },
|
||||
}}
|
||||
>
|
||||
<Content>
|
||||
<Heading>
|
||||
<Title text={document.titleWithDefault} highlight={highlight} />
|
||||
{document.isNew && document.createdBy.id !== currentUser.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip
|
||||
tooltip={t("Only visible to you")}
|
||||
delay={500}
|
||||
placement="top"
|
||||
>
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showNestedDocuments={showNestedDocuments}
|
||||
showLastViewed
|
||||
/>
|
||||
</Content>
|
||||
<Actions>
|
||||
{document.isTemplate && !document.isArchived && !document.isDeleted && (
|
||||
<>
|
||||
<Button
|
||||
as={Link}
|
||||
to={newDocumentUrl(document.collectionId, {
|
||||
templateId: document.id,
|
||||
})}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
|
||||
</>
|
||||
)}
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
showPin={showPin}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
modal={false}
|
||||
/>
|
||||
</Actions>
|
||||
</DocumentLink>
|
||||
);
|
||||
}
|
||||
|
||||
const Content = styled.div`
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const Actions = styled(EventBoundary)`
|
||||
display: none;
|
||||
align-items: center;
|
||||
margin: 8px;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
`;
|
||||
|
||||
const DocumentLink = styled(Link)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 10px -8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
max-height: 50vh;
|
||||
min-width: 100%;
|
||||
max-width: calc(100vw - 40px);
|
||||
|
||||
${Actions} {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
${AnimatedStar} {
|
||||
opacity: ${(props) => (props.$isStarred ? "1 !important" : 0)};
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
|
||||
${Actions} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${AnimatedStar} {
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$menuOpen &&
|
||||
css`
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
|
||||
${Actions} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${AnimatedStar} {
|
||||
opacity: 0.5;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Heading = styled.h3`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.25em;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
`;
|
||||
|
||||
const StarPositioner = styled(Flex)`
|
||||
margin-left: 4px;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Title = styled(Highlight)`
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const ResultContext = styled(Highlight)`
|
||||
display: block;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-size: 14px;
|
||||
margin-top: -0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
`;
|
||||
|
||||
export default observer(DocumentListItem);
|
||||
@@ -1,21 +1,20 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { inject, 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};
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const Modified = styled.span`
|
||||
@@ -23,28 +22,28 @@ const Modified = styled.span`
|
||||
font-weight: ${(props) => (props.highlight ? "600" : "400")};
|
||||
`;
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
collections: CollectionsStore,
|
||||
auth: AuthStore,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showLastViewed?: boolean,
|
||||
showNestedDocuments?: boolean,
|
||||
document: Document,
|
||||
children: React.Node,
|
||||
to?: string,
|
||||
|};
|
||||
};
|
||||
|
||||
function DocumentMeta({
|
||||
auth,
|
||||
collections,
|
||||
showPublished,
|
||||
showCollection,
|
||||
showLastViewed,
|
||||
showNestedDocuments,
|
||||
document,
|
||||
children,
|
||||
to,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { collections, auth } = useStores();
|
||||
const {
|
||||
modifiedSinceViewed,
|
||||
updatedAt,
|
||||
@@ -68,37 +67,37 @@ function DocumentMeta({
|
||||
if (deletedAt) {
|
||||
content = (
|
||||
<span>
|
||||
{t("deleted")} <Time dateTime={deletedAt} addSuffix />
|
||||
deleted <Time dateTime={deletedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (archivedAt) {
|
||||
content = (
|
||||
<span>
|
||||
{t("archived")} <Time dateTime={archivedAt} addSuffix />
|
||||
archived <Time dateTime={archivedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (createdAt === updatedAt) {
|
||||
content = (
|
||||
<span>
|
||||
{t("created")} <Time dateTime={updatedAt} addSuffix />
|
||||
created <Time dateTime={updatedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (publishedAt && (publishedAt === updatedAt || showPublished)) {
|
||||
content = (
|
||||
<span>
|
||||
{t("published")} <Time dateTime={publishedAt} addSuffix />
|
||||
published <Time dateTime={publishedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (isDraft) {
|
||||
content = (
|
||||
<span>
|
||||
{t("saved")} <Time dateTime={updatedAt} addSuffix />
|
||||
saved <Time dateTime={updatedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<Modified highlight={modifiedSinceViewed}>
|
||||
{t("updated")} <Time dateTime={updatedAt} addSuffix />
|
||||
updated <Time dateTime={updatedAt} addSuffix />
|
||||
</Modified>
|
||||
);
|
||||
}
|
||||
@@ -113,44 +112,34 @@ function DocumentMeta({
|
||||
if (!lastViewedAt) {
|
||||
return (
|
||||
<>
|
||||
• <Modified highlight>{t("Never viewed")}</Modified>
|
||||
• <Modified highlight>Never viewed</Modified>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
• {t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
|
||||
• Viewed <Time dateTime={lastViewedAt} addSuffix shorten />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const nestedDocumentsCount = collection
|
||||
? collection.getDocumentChildren(document.id).length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Container align="center" {...rest}>
|
||||
{updatedByMe ? t("You") : updatedBy.name}
|
||||
{updatedByMe ? "You" : updatedBy.name}
|
||||
{to ? <Link to={to}>{content}</Link> : content}
|
||||
{showCollection && collection && (
|
||||
<span>
|
||||
{t("in")}
|
||||
in
|
||||
<strong>
|
||||
<Breadcrumb document={document} onlyText />
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
{showNestedDocuments && nestedDocumentsCount > 0 && (
|
||||
<span>
|
||||
· {nestedDocumentsCount}{" "}
|
||||
{t("nested document", { count: nestedDocumentsCount })}
|
||||
</span>
|
||||
)}
|
||||
{timeSinceNow()}
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(DocumentMeta);
|
||||
export default inject("collections", "auth")(observer(DocumentMeta));
|
||||
|
||||
@@ -35,8 +35,6 @@ function DocumentMetaWithViews({ to, isDraft, document }: Props) {
|
||||
const Meta = styled(DocumentMeta)`
|
||||
margin: -12px 0 2em 0;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { StarredIcon, PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Link, Redirect } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import Badge from "components/Badge";
|
||||
import Button from "components/Button";
|
||||
import DocumentMeta from "components/DocumentMeta";
|
||||
import Flex from "components/Flex";
|
||||
import Highlight from "components/Highlight";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document,
|
||||
highlight?: ?string,
|
||||
context?: ?string,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
showDraft?: boolean,
|
||||
showTemplate?: boolean,
|
||||
};
|
||||
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
|
||||
@observer
|
||||
class DocumentPreview extends React.Component<Props> {
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
handleStar = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.document.star();
|
||||
};
|
||||
|
||||
handleUnstar = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.document.unstar();
|
||||
};
|
||||
|
||||
replaceResultMarks = (tag: string) => {
|
||||
// don't use SEARCH_RESULT_REGEX here as it causes
|
||||
// an infinite loop to trigger a regex inside it's own callback
|
||||
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
|
||||
};
|
||||
|
||||
handleNewFromTemplate = (event: SyntheticEvent<>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const { document } = this.props;
|
||||
|
||||
this.redirectTo = newDocumentUrl(document.collectionId, {
|
||||
templateId: document.id,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
document,
|
||||
showCollection,
|
||||
showPublished,
|
||||
showPin,
|
||||
showDraft = true,
|
||||
showTemplate,
|
||||
highlight,
|
||||
context,
|
||||
} = this.props;
|
||||
|
||||
if (this.redirectTo) {
|
||||
return <Redirect to={this.redirectTo} push />;
|
||||
}
|
||||
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
to={{
|
||||
pathname: document.url,
|
||||
state: { title: document.titleWithDefault },
|
||||
}}
|
||||
>
|
||||
<Heading>
|
||||
<Title text={document.titleWithDefault} highlight={highlight} />
|
||||
{document.isNew && <Badge yellow>New</Badge>}
|
||||
{!document.isDraft &&
|
||||
!document.isArchived &&
|
||||
!document.isTemplate && (
|
||||
<Actions>
|
||||
{document.isStarred ? (
|
||||
<StyledStar onClick={this.handleUnstar} solid />
|
||||
) : (
|
||||
<StyledStar onClick={this.handleStar} />
|
||||
)}
|
||||
</Actions>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip tooltip="Only visible to you" delay={500} placement="top">
|
||||
<Badge>Draft</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>Template</Badge>
|
||||
)}
|
||||
<SecondaryActions>
|
||||
{document.isTemplate &&
|
||||
!document.isArchived &&
|
||||
!document.isDeleted && (
|
||||
<Button
|
||||
onClick={this.handleNewFromTemplate}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
New doc
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DocumentMenu document={document} showPin={showPin} />
|
||||
</SecondaryActions>
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={this.replaceResultMarks}
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showLastViewed
|
||||
/>
|
||||
</DocumentLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const StyledStar = withTheme(styled(({ solid, theme, ...props }) => (
|
||||
<StarredIcon color={theme.text} {...props} />
|
||||
))`
|
||||
flex-shrink: 0;
|
||||
opacity: ${(props) => (props.solid ? "1 !important" : 0)};
|
||||
transition: all 100ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
`);
|
||||
|
||||
const SecondaryActions = styled(Flex)`
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
`;
|
||||
|
||||
const DocumentLink = styled(Link)`
|
||||
display: block;
|
||||
margin: 10px -8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
max-height: 50vh;
|
||||
min-width: 100%;
|
||||
max-width: calc(100vw - 40px);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
${SecondaryActions} {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
|
||||
${SecondaryActions} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${StyledStar} {
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Heading = styled.h3`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.25em;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
`;
|
||||
|
||||
const Actions = styled(Flex)`
|
||||
margin-left: 4px;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Title = styled(Highlight)`
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const ResultContext = styled(Highlight)`
|
||||
display: block;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-size: 14px;
|
||||
margin-top: -0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
`;
|
||||
|
||||
export default DocumentPreview;
|
||||
@@ -0,0 +1,3 @@
|
||||
// @flow
|
||||
import DocumentPreview from "./DocumentPreview";
|
||||
export default 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 styled, { css } from "styled-components";
|
||||
import { createGlobalStyle } from "styled-components";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
@@ -17,6 +17,8 @@ type Props = {
|
||||
children: React.Node,
|
||||
collectionId: string,
|
||||
documentId?: string,
|
||||
activeClassName?: string,
|
||||
rejectClassName?: string,
|
||||
ui: UiStore,
|
||||
documents: DocumentsStore,
|
||||
disabled: boolean,
|
||||
@@ -26,6 +28,18 @@ 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;
|
||||
@@ -60,9 +74,7 @@ class DropToImport extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(`Could not import file. ${err.message}`, {
|
||||
type: "error",
|
||||
});
|
||||
this.props.ui.showToast(`Could not import file. ${err.message}`);
|
||||
} finally {
|
||||
this.isImporting = false;
|
||||
importingLock = false;
|
||||
@@ -70,7 +82,18 @@ class DropToImport extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { documents } = this.props;
|
||||
const {
|
||||
documentId,
|
||||
collectionId,
|
||||
documents,
|
||||
disabled,
|
||||
location,
|
||||
match,
|
||||
history,
|
||||
staticContext,
|
||||
ui,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
if (this.props.disabled) return this.props.children;
|
||||
|
||||
@@ -79,45 +102,16 @@ class DropToImport extends React.Component<Props> {
|
||||
accept={documents.importFileTypes.join(", ")}
|
||||
onDropAccepted={this.onDropAccepted}
|
||||
style={EMPTY_OBJECT}
|
||||
noClick
|
||||
disableClick
|
||||
disablePreview
|
||||
multiple
|
||||
{...rest}
|
||||
>
|
||||
{({
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
isDragActive,
|
||||
isDragAccept,
|
||||
isDragReject,
|
||||
}) => (
|
||||
<DropzoneContainer
|
||||
{...getRootProps()}
|
||||
{...{ isDragActive }}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{this.isImporting && <LoadingIndicator />}
|
||||
{this.props.children}
|
||||
</DropzoneContainer>
|
||||
)}
|
||||
{this.isImporting && <LoadingIndicator />}
|
||||
{this.props.children}
|
||||
</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));
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import { rgba } from "polished";
|
||||
import * as React from "react";
|
||||
import { PortalWithState } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||
import Flex from "components/Flex";
|
||||
import NudeButton from "components/NudeButton";
|
||||
|
||||
let previousClosePortal;
|
||||
let counter = 0;
|
||||
|
||||
type Children =
|
||||
| React.Node
|
||||
| ((options: { closePortal: () => void }) => React.Node);
|
||||
|
||||
type Props = {
|
||||
label?: React.Node,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
children?: Children,
|
||||
className?: string,
|
||||
hover?: boolean,
|
||||
style?: Object,
|
||||
position?: "left" | "right" | "center",
|
||||
};
|
||||
|
||||
@observer
|
||||
class DropdownMenu extends React.Component<Props> {
|
||||
id: string = `menu${counter++}`;
|
||||
closeTimeout: TimeoutID;
|
||||
|
||||
@observable top: ?number;
|
||||
@observable bottom: ?number;
|
||||
@observable right: ?number;
|
||||
@observable left: ?number;
|
||||
@observable position: "left" | "right" | "center";
|
||||
@observable fixed: ?boolean;
|
||||
@observable bodyRect: ClientRect;
|
||||
@observable labelRect: ClientRect;
|
||||
@observable dropdownRef: { current: null | HTMLElement } = React.createRef();
|
||||
@observable menuRef: { current: null | HTMLElement } = React.createRef();
|
||||
|
||||
handleOpen = (
|
||||
openPortal: (SyntheticEvent<>) => void,
|
||||
closePortal: () => void
|
||||
) => {
|
||||
return (ev: SyntheticMouseEvent<HTMLElement>) => {
|
||||
ev.preventDefault();
|
||||
const currentTarget = ev.currentTarget;
|
||||
invariant(document.body, "why you not here");
|
||||
|
||||
if (currentTarget instanceof HTMLDivElement) {
|
||||
this.bodyRect = document.body.getBoundingClientRect();
|
||||
this.labelRect = currentTarget.getBoundingClientRect();
|
||||
this.top = this.labelRect.bottom - this.bodyRect.top;
|
||||
this.bottom = undefined;
|
||||
this.position = this.props.position || "left";
|
||||
|
||||
if (currentTarget.parentElement) {
|
||||
const triggerParentStyle = getComputedStyle(
|
||||
currentTarget.parentElement
|
||||
);
|
||||
|
||||
if (triggerParentStyle.position === "static") {
|
||||
this.fixed = true;
|
||||
this.top = this.labelRect.bottom;
|
||||
}
|
||||
}
|
||||
|
||||
this.initPosition();
|
||||
|
||||
// attempt to keep only one flyout menu open at once
|
||||
if (previousClosePortal && !this.props.hover) {
|
||||
previousClosePortal();
|
||||
}
|
||||
previousClosePortal = closePortal;
|
||||
openPortal(ev);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
initPosition() {
|
||||
if (this.position === "left") {
|
||||
this.right =
|
||||
this.bodyRect.width - this.labelRect.left - this.labelRect.width;
|
||||
} else if (this.position === "center") {
|
||||
this.left = this.labelRect.left + this.labelRect.width / 2;
|
||||
} else {
|
||||
this.left = this.labelRect.left;
|
||||
}
|
||||
}
|
||||
|
||||
onOpen = () => {
|
||||
if (typeof this.props.onOpen === "function") {
|
||||
this.props.onOpen();
|
||||
}
|
||||
this.fitOnTheScreen();
|
||||
};
|
||||
|
||||
fitOnTheScreen() {
|
||||
if (!this.dropdownRef || !this.dropdownRef.current) return;
|
||||
const el = this.dropdownRef.current;
|
||||
|
||||
const sticksOutPastBottomEdge =
|
||||
el.clientHeight + this.top > window.innerHeight;
|
||||
if (sticksOutPastBottomEdge) {
|
||||
this.top = undefined;
|
||||
this.bottom = this.fixed ? 0 : -1 * window.pageYOffset;
|
||||
} else {
|
||||
this.bottom = undefined;
|
||||
}
|
||||
|
||||
if (this.position === "left" || this.position === "right") {
|
||||
const totalWidth =
|
||||
Math.sign(this.position === "left" ? -1 : 1) * el.offsetLeft +
|
||||
el.scrollWidth;
|
||||
const isVisible = totalWidth < window.innerWidth;
|
||||
|
||||
if (!isVisible) {
|
||||
if (this.position === "right") {
|
||||
this.position = "left";
|
||||
this.left = undefined;
|
||||
} else if (this.position === "left") {
|
||||
this.position = "right";
|
||||
this.right = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.initPosition();
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
closeAfterTimeout = (closePortal: () => void) => () => {
|
||||
if (this.closeTimeout) {
|
||||
clearTimeout(this.closeTimeout);
|
||||
}
|
||||
this.closeTimeout = setTimeout(closePortal, 500);
|
||||
};
|
||||
|
||||
clearCloseTimeout = () => {
|
||||
if (this.closeTimeout) {
|
||||
clearTimeout(this.closeTimeout);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { className, hover, label, children } = this.props;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<PortalWithState
|
||||
onOpen={this.onOpen}
|
||||
onClose={this.props.onClose}
|
||||
closeOnOutsideClick
|
||||
closeOnEsc
|
||||
>
|
||||
{({ closePortal, openPortal, isOpen, portal }) => (
|
||||
<>
|
||||
<Label
|
||||
onMouseMove={hover ? this.clearCloseTimeout : undefined}
|
||||
onMouseOut={
|
||||
hover ? this.closeAfterTimeout(closePortal) : undefined
|
||||
}
|
||||
onMouseEnter={
|
||||
hover ? this.handleOpen(openPortal, closePortal) : undefined
|
||||
}
|
||||
onClick={
|
||||
hover ? undefined : this.handleOpen(openPortal, closePortal)
|
||||
}
|
||||
>
|
||||
{label || (
|
||||
<NudeButton
|
||||
id={`${this.id}button`}
|
||||
aria-label="More options"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isOpen ? "true" : "false"}
|
||||
aria-controls={this.id}
|
||||
>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
)}
|
||||
</Label>
|
||||
{portal(
|
||||
<Position
|
||||
ref={this.dropdownRef}
|
||||
position={this.position}
|
||||
fixed={this.fixed}
|
||||
top={this.top}
|
||||
bottom={this.bottom}
|
||||
left={this.left}
|
||||
right={this.right}
|
||||
>
|
||||
<Menu
|
||||
ref={this.menuRef}
|
||||
onMouseMove={hover ? this.clearCloseTimeout : undefined}
|
||||
onMouseOut={
|
||||
hover ? this.closeAfterTimeout(closePortal) : undefined
|
||||
}
|
||||
onClick={
|
||||
typeof children === "function"
|
||||
? undefined
|
||||
: (ev) => {
|
||||
ev.stopPropagation();
|
||||
closePortal();
|
||||
}
|
||||
}
|
||||
style={this.props.style}
|
||||
id={this.id}
|
||||
aria-labelledby={`${this.id}button`}
|
||||
role="menu"
|
||||
>
|
||||
{typeof children === "function"
|
||||
? children({ closePortal })
|
||||
: children}
|
||||
</Menu>
|
||||
</Position>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PortalWithState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Label = styled(Flex).attrs({
|
||||
justify: "center",
|
||||
align: "center",
|
||||
})`
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const Position = styled.div`
|
||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
||||
display: flex;
|
||||
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
|
||||
${({ right }) => (right !== undefined ? `right: ${right}px` : "")};
|
||||
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
|
||||
${({ bottom }) => (bottom !== undefined ? `bottom: ${bottom}px` : "")};
|
||||
max-height: 75%;
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
transform: ${(props) =>
|
||||
props.position === "center" ? "translateX(-50%)" : "initial"};
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
const Menu = styled.div`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: ${(props) => (props.left !== undefined ? "25%" : "75%")} 0;
|
||||
backdrop-filter: blur(10px);
|
||||
background: ${(props) => rgba(props.theme.menuBackground, 0.8)};
|
||||
border: ${(props) =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
border-radius: 2px;
|
||||
padding: 0.5em 0;
|
||||
min-width: 180px;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
pointer-events: all;
|
||||
|
||||
hr {
|
||||
margin: 0.5em 12px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Header = styled.h3`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
letter-spacing: 0.04em;
|
||||
margin: 1em 12px 0.5em;
|
||||
`;
|
||||
|
||||
export default DropdownMenu;
|
||||
+19
-33
@@ -1,62 +1,50 @@
|
||||
// @flow
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
onClick?: (SyntheticEvent<>) => void | Promise<void>,
|
||||
children?: React.Node,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
to?: string,
|
||||
href?: string,
|
||||
target?: "_blank",
|
||||
as?: string | React.ComponentType<*>,
|
||||
|};
|
||||
};
|
||||
|
||||
const MenuItem = ({
|
||||
const DropdownMenuItem = ({
|
||||
onClick,
|
||||
children,
|
||||
selected,
|
||||
disabled,
|
||||
as,
|
||||
...rest
|
||||
}: Props) => {
|
||||
return (
|
||||
<BaseMenuItem
|
||||
<MenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
role="menuitem"
|
||||
tabIndex="-1"
|
||||
{...rest}
|
||||
>
|
||||
{(props) => (
|
||||
<MenuAnchor as={onClick ? "button" : as} {...props}>
|
||||
{selected !== undefined && (
|
||||
<>
|
||||
{selected ? <CheckmarkIcon /> : <Spacer />}
|
||||
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</MenuAnchor>
|
||||
{selected !== undefined && (
|
||||
<>
|
||||
<CheckmarkIcon
|
||||
color={selected === false ? "transparent" : undefined}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
</BaseMenuItem>
|
||||
{children}
|
||||
</MenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const Spacer = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export const MenuAnchor = styled.a`
|
||||
const MenuItem = styled.a`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 6px 12px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
background: none;
|
||||
|
||||
color: ${(props) =>
|
||||
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
|
||||
justify-content: left;
|
||||
@@ -70,7 +58,6 @@ export const MenuAnchor = styled.a`
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
opacity: ${(props) => (props.disabled ? ".5" : 1)};
|
||||
}
|
||||
|
||||
@@ -79,8 +66,7 @@ export const MenuAnchor = styled.a`
|
||||
? "pointer-events: none;"
|
||||
: `
|
||||
|
||||
&:hover,
|
||||
&.focus-visible {
|
||||
&:hover {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.theme.primary};
|
||||
box-shadow: none;
|
||||
@@ -98,4 +84,4 @@ export const MenuAnchor = styled.a`
|
||||
`};
|
||||
`;
|
||||
|
||||
export default MenuItem;
|
||||
export default DropdownMenuItem;
|
||||
@@ -0,0 +1,3 @@
|
||||
// @flow
|
||||
export { default as DropdownMenu, Header } from "./DropdownMenu";
|
||||
export { default as DropdownMenuItem } from "./DropdownMenuItem";
|
||||
+61
-157
@@ -1,175 +1,87 @@
|
||||
// @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";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import embeds from "../embeds";
|
||||
import { isModKey } from "utils/keyboard";
|
||||
import isInternalUrl from "utils/isInternalUrl";
|
||||
import { uploadFile } from "utils/uploadFile";
|
||||
import { isInternalUrl } from "utils/urls";
|
||||
|
||||
const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));
|
||||
|
||||
const EMPTY_ARRAY = [];
|
||||
|
||||
export type Props = {|
|
||||
type Props = {
|
||||
id?: string,
|
||||
value?: string,
|
||||
defaultValue?: string,
|
||||
readOnly?: boolean,
|
||||
grow?: boolean,
|
||||
disableEmbeds?: boolean,
|
||||
ui?: UiStore,
|
||||
autoFocus?: boolean,
|
||||
template?: boolean,
|
||||
placeholder?: string,
|
||||
scrollTo?: string,
|
||||
readOnlyWriteCheckboxes?: boolean,
|
||||
onBlur?: (event: SyntheticEvent<>) => any,
|
||||
onFocus?: (event: SyntheticEvent<>) => any,
|
||||
onPublish?: (event: SyntheticEvent<>) => any,
|
||||
onSave?: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
|
||||
onCancel?: () => any,
|
||||
onChange?: (getValue: () => string) => any,
|
||||
onSearchLink?: (title: string) => any,
|
||||
onHoverLink?: (event: MouseEvent) => any,
|
||||
onCreateLink?: (title: string) => Promise<string>,
|
||||
onImageUploadStart?: () => any,
|
||||
onImageUploadStop?: () => any,
|
||||
|};
|
||||
};
|
||||
|
||||
type PropsWithRef = Props & {
|
||||
forwardedRef: React.Ref<any>,
|
||||
history: RouterHistory,
|
||||
};
|
||||
|
||||
function Editor(props: PropsWithRef) {
|
||||
const { id, ui, history } = props;
|
||||
const { t } = useTranslation();
|
||||
class Editor extends React.Component<PropsWithRef> {
|
||||
onUploadImage = async (file: File) => {
|
||||
const result = await uploadFile(file, { documentId: this.props.id });
|
||||
return result.url;
|
||||
};
|
||||
|
||||
const onUploadImage = React.useCallback(
|
||||
async (file: File) => {
|
||||
const result = await uploadFile(file, { documentId: id });
|
||||
return result.url;
|
||||
},
|
||||
[id]
|
||||
);
|
||||
onClickLink = (href: string, event: MouseEvent) => {
|
||||
// on page hash
|
||||
if (href[0] === "#") {
|
||||
window.location.href = href;
|
||||
return;
|
||||
}
|
||||
|
||||
const onClickLink = React.useCallback(
|
||||
(href: string, event: MouseEvent) => {
|
||||
// on page hash
|
||||
if (href[0] === "#") {
|
||||
window.location.href = href;
|
||||
return;
|
||||
}
|
||||
if (isInternalUrl(href) && !event.metaKey && !event.shiftKey) {
|
||||
// relative
|
||||
let navigateTo = href;
|
||||
|
||||
if (isInternalUrl(href) && !isModKey(event) && !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;
|
||||
}
|
||||
// probably absolute
|
||||
if (href[0] !== "/") {
|
||||
try {
|
||||
const url = new URL(href);
|
||||
navigateTo = url.pathname + url.hash;
|
||||
} catch (err) {
|
||||
navigateTo = href;
|
||||
}
|
||||
|
||||
history.push(navigateTo);
|
||||
} else if (href) {
|
||||
window.open(href, "_blank");
|
||||
}
|
||||
},
|
||||
[history]
|
||||
);
|
||||
|
||||
const onShowToast = React.useCallback(
|
||||
(message: string) => {
|
||||
if (ui) {
|
||||
ui.showToast(message);
|
||||
}
|
||||
},
|
||||
[ui]
|
||||
);
|
||||
this.props.history.push(navigateTo);
|
||||
} else if (href) {
|
||||
window.open(href, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
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]);
|
||||
onShowToast = (message: string) => {
|
||||
if (this.props.ui) {
|
||||
this.props.ui.showToast(message);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const StyledEditor = styled(RichMarkdownEditor)`
|
||||
@@ -180,34 +92,26 @@ const StyledEditor = styled(RichMarkdownEditor)`
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
}
|
||||
|
||||
& * {
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.notice-block.tip,
|
||||
.notice-block.warning {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.heading-anchor {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.heading-name {
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
position: relative;
|
||||
top: -60px;
|
||||
visibility: hidden;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.heading-name:first-child {
|
||||
& + h1,
|
||||
& + h2,
|
||||
& + h3,
|
||||
& + h4 {
|
||||
margin-top: 0;
|
||||
}
|
||||
/* pseudo element allows us to add spacing for fixed header */
|
||||
/* ref: https://stackoverflow.com/a/28824157 */
|
||||
.heading-name::before {
|
||||
content: "";
|
||||
display: ${(props) => (props.readOnly ? "block" : "none")};
|
||||
height: 72px;
|
||||
margin: -72px 0 0;
|
||||
}
|
||||
|
||||
.heading-anchor {
|
||||
margin-top: 72px !important;
|
||||
}
|
||||
|
||||
p {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// @flow
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
@@ -37,8 +36,8 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (env.SENTRY_DSN) {
|
||||
Sentry.captureException(error);
|
||||
if (window.Sentry) {
|
||||
window.Sentry.captureException(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +56,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
render() {
|
||||
if (this.error) {
|
||||
const error = this.error;
|
||||
const isReported = !!env.SENTRY_DSN && env.DEPLOYMENT === "hosted";
|
||||
const isReported = !!window.Sentry && env.DEPLOYMENT === "hosted";
|
||||
const isChunkError = this.error.message.match(/chunk/);
|
||||
|
||||
if (isChunkError) {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// @flow
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
className?: string,
|
||||
};
|
||||
|
||||
export default function EventBoundary({ children, className }: Props) {
|
||||
const handleClick = React.useCallback((event: SyntheticEvent<>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span onClick={handleClick} className={className}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,8 @@ const Heading = styled.h1`
|
||||
${(props) => (props.centered ? "text-align: center;" : "")}
|
||||
|
||||
svg {
|
||||
margin-top: 4px;
|
||||
margin-left: -6px;
|
||||
margin-right: 2px;
|
||||
align-self: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ function Highlight({
|
||||
...rest
|
||||
}: Props) {
|
||||
let regex;
|
||||
let index = 0;
|
||||
if (highlight instanceof RegExp) {
|
||||
regex = highlight;
|
||||
} else {
|
||||
@@ -30,10 +29,8 @@ function Highlight({
|
||||
return (
|
||||
<span {...rest}>
|
||||
{highlight
|
||||
? replace(text, regex, (tag) => (
|
||||
<Mark key={index++}>
|
||||
{processResult ? processResult(tag) : tag}
|
||||
</Mark>
|
||||
? replace(text, regex, (tag, index) => (
|
||||
<Mark key={index}>{processResult ? processResult(tag) : tag}</Mark>
|
||||
))
|
||||
: text}
|
||||
</span>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { fadeAndSlideIn } from "shared/styles/animations";
|
||||
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import HoverPreviewDocument from "components/HoverPreviewDocument";
|
||||
import { isInternalUrl } from "utils/urls";
|
||||
import isInternalUrl from "utils/isInternalUrl";
|
||||
|
||||
const DELAY_OPEN = 300;
|
||||
const DELAY_CLOSE = 300;
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { inject, 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, children }: Props) {
|
||||
const { documents } = useStores();
|
||||
function HoverPreviewDocument({ url, documents, children }: Props) {
|
||||
const slug = parseDocumentSlug(url);
|
||||
|
||||
documents.prefetchDocument(slug, {
|
||||
@@ -50,4 +50,4 @@ const Heading = styled.h2`
|
||||
color: ${(props) => props.theme.text};
|
||||
`;
|
||||
|
||||
export default observer(HoverPreviewDocument);
|
||||
export default inject("documents")(observer(HoverPreviewDocument));
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
CollectionIcon,
|
||||
CoinsIcon,
|
||||
@@ -20,17 +22,13 @@ import {
|
||||
VehicleIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import { DropdownMenu } from "components/DropdownMenu";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import { LabelText } from "components/Input";
|
||||
import NudeButton from "components/NudeButton";
|
||||
|
||||
const style = { width: 30, height: 30 };
|
||||
|
||||
const TwitterPicker = React.lazy(() =>
|
||||
import("react-color/lib/components/twitter/Twitter")
|
||||
);
|
||||
@@ -123,77 +121,105 @@ const colors = [
|
||||
"#2F362F",
|
||||
];
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
onOpen?: () => void,
|
||||
onChange: (color: string, icon: string) => void,
|
||||
icon: string,
|
||||
color: string,
|
||||
|};
|
||||
};
|
||||
|
||||
function IconPicker({ onOpen, icon, color, onChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
placement: "bottom-end",
|
||||
});
|
||||
const Component = icons[icon || "collection"].component;
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Label>
|
||||
<LabelText>{t("Icon")}</LabelText>
|
||||
</Label>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<Button aria-label={t("Show menu")} {...props}>
|
||||
<Component color={color} size={30} />
|
||||
</Button>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} onOpen={onOpen} aria-label={t("Choose icon")}>
|
||||
<Icons>
|
||||
{Object.keys(icons).map((name) => {
|
||||
const Component = icons[name].component;
|
||||
return (
|
||||
<MenuItem
|
||||
key={name}
|
||||
onClick={() => onChange(color, name)}
|
||||
{...menu}
|
||||
>
|
||||
{(props) => (
|
||||
<IconButton style={style} {...props}>
|
||||
<Component color={color} size={30} />
|
||||
</IconButton>
|
||||
)}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Icons>
|
||||
<Flex>
|
||||
<React.Suspense fallback={<Loading>{t("Loading")}…</Loading>}>
|
||||
<ColorPicker
|
||||
color={color}
|
||||
onChange={(color) => onChange(color.hex, icon)}
|
||||
colors={colors}
|
||||
triangle="hide"
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Flex>
|
||||
</ContextMenu>
|
||||
</Wrapper>
|
||||
);
|
||||
function preventEventBubble(event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
const Label = styled.label`
|
||||
display: block;
|
||||
`;
|
||||
@observer
|
||||
class IconPicker extends React.Component<Props> {
|
||||
@observable isOpen: boolean = false;
|
||||
node: ?HTMLElement;
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener("click", this.handleClickOutside);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("click", this.handleClickOutside);
|
||||
}
|
||||
|
||||
handleClose = () => {
|
||||
this.isOpen = false;
|
||||
};
|
||||
|
||||
handleOpen = () => {
|
||||
this.isOpen = true;
|
||||
|
||||
if (this.props.onOpen) {
|
||||
this.props.onOpen();
|
||||
}
|
||||
};
|
||||
|
||||
handleClickOutside = (ev: SyntheticMouseEvent<>) => {
|
||||
// $FlowFixMe
|
||||
if (ev.target && this.node && this.node.contains(ev.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleClose();
|
||||
};
|
||||
|
||||
render() {
|
||||
const Component = icons[this.props.icon || "collection"].component;
|
||||
|
||||
return (
|
||||
<Wrapper ref={(ref) => (this.node = ref)}>
|
||||
<label>
|
||||
<LabelText>Icon</LabelText>
|
||||
</label>
|
||||
<DropdownMenu
|
||||
onOpen={this.handleOpen}
|
||||
label={
|
||||
<LabelButton>
|
||||
<Component role="button" color={this.props.color} size={30} />
|
||||
</LabelButton>
|
||||
}
|
||||
>
|
||||
<Icons onClick={preventEventBubble}>
|
||||
{Object.keys(icons).map((name) => {
|
||||
const Component = icons[name].component;
|
||||
return (
|
||||
<IconButton
|
||||
key={name}
|
||||
onClick={() => this.props.onChange(this.props.color, name)}
|
||||
style={{ width: 30, height: 30 }}
|
||||
>
|
||||
<Component color={this.props.color} size={30} />
|
||||
</IconButton>
|
||||
);
|
||||
})}
|
||||
</Icons>
|
||||
<Flex onClick={preventEventBubble}>
|
||||
<React.Suspense fallback={<Loading>Loading…</Loading>}>
|
||||
<ColorPicker
|
||||
color={this.props.color}
|
||||
onChange={(color) =>
|
||||
this.props.onChange(color.hex, this.props.icon)
|
||||
}
|
||||
colors={colors}
|
||||
triangle="hide"
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Flex>
|
||||
</DropdownMenu>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Icons = styled.div`
|
||||
padding: 15px 9px 9px 15px;
|
||||
width: 276px;
|
||||
`;
|
||||
|
||||
const Button = styled(NudeButton)`
|
||||
const LabelButton = styled(NudeButton)`
|
||||
border: 1px solid ${(props) => props.theme.inputBorder};
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { cdnPath } from "utils/urls";
|
||||
|
||||
type Props = {|
|
||||
alt: string,
|
||||
src: string,
|
||||
title?: string,
|
||||
width?: number,
|
||||
height?: number,
|
||||
|};
|
||||
|
||||
export default function Image({ src, alt, ...rest }: Props) {
|
||||
return <img src={cdnPath(src)} alt={alt} {...rest} />;
|
||||
}
|
||||
+8
-13
@@ -2,9 +2,9 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
import VisuallyHidden from "components/VisuallyHidden";
|
||||
|
||||
const RealTextarea = styled.textarea`
|
||||
border: 0;
|
||||
@@ -33,6 +33,10 @@ const RealInput = styled.input`
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
-webkit-appearance: searchfield-cancel-button;
|
||||
}
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
@@ -75,8 +79,8 @@ export const LabelText = styled.div`
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
export type Props = {|
|
||||
type?: "text" | "email" | "checkbox" | "search",
|
||||
export type Props = {
|
||||
type?: string,
|
||||
value?: string,
|
||||
label?: string,
|
||||
className?: string,
|
||||
@@ -85,18 +89,9 @@ export type Props = {|
|
||||
short?: boolean,
|
||||
margin?: string | number,
|
||||
icon?: React.Node,
|
||||
name?: string,
|
||||
minLength?: number,
|
||||
maxLength?: number,
|
||||
autoFocus?: boolean,
|
||||
autoComplete?: boolean | string,
|
||||
readOnly?: boolean,
|
||||
required?: boolean,
|
||||
placeholder?: string,
|
||||
onChange?: (ev: SyntheticInputEvent<HTMLInputElement>) => mixed,
|
||||
onFocus?: (ev: SyntheticEvent<>) => void,
|
||||
onBlur?: (ev: SyntheticEvent<>) => void,
|
||||
|};
|
||||
};
|
||||
|
||||
@observer
|
||||
class Input extends React.Component<Props> {
|
||||
|
||||
@@ -8,13 +8,13 @@ import Editor from "components/Editor";
|
||||
import HelpText from "components/HelpText";
|
||||
import { LabelText, Outline } from "components/Input";
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
label: string,
|
||||
minHeight?: number,
|
||||
maxHeight?: number,
|
||||
readOnly?: boolean,
|
||||
ui: UiStore,
|
||||
|};
|
||||
};
|
||||
|
||||
@observer
|
||||
class InputRich extends React.Component<Props> {
|
||||
|
||||
@@ -3,24 +3,18 @@ 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";
|
||||
import Input from "./Input";
|
||||
import { type Theme } from "types";
|
||||
import { meta } from "utils/keyboard";
|
||||
import { searchUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
theme: Theme,
|
||||
theme: Object,
|
||||
source: string,
|
||||
placeholder?: string,
|
||||
label?: string,
|
||||
labelHidden?: boolean,
|
||||
collectionId?: string,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -28,8 +22,8 @@ class InputSearch extends React.Component<Props> {
|
||||
input: ?Input;
|
||||
@observable focused: boolean = false;
|
||||
|
||||
@keydown(`${meta}+f`)
|
||||
focus(ev: SyntheticEvent<>) {
|
||||
@keydown("meta+f")
|
||||
focus(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
if (this.input) {
|
||||
@@ -37,7 +31,7 @@ class InputSearch extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
handleSearchInput = (ev: SyntheticInputEvent<>) => {
|
||||
handleSearchInput = (ev) => {
|
||||
ev.preventDefault();
|
||||
this.props.history.push(
|
||||
searchUrl(ev.target.value, {
|
||||
@@ -56,8 +50,7 @@ class InputSearch extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
const { theme, placeholder = `${t("Search")}…` } = this.props;
|
||||
const { theme, placeholder = "Search…" } = this.props;
|
||||
|
||||
return (
|
||||
<InputMaxWidth
|
||||
@@ -70,8 +63,6 @@ class InputSearch extends React.Component<Props> {
|
||||
color={this.focused ? theme.inputBorderFocused : theme.inputBorder}
|
||||
/>
|
||||
}
|
||||
label={this.props.label}
|
||||
labelHidden={this.props.labelHidden}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
margin={0}
|
||||
@@ -84,6 +75,4 @@ const InputMaxWidth = styled(Input)`
|
||||
max-width: 30vw;
|
||||
`;
|
||||
|
||||
export default withTranslation()<InputSearch>(
|
||||
withTheme(withRouter(InputSearch))
|
||||
);
|
||||
export default withTheme(withRouter(InputSearch));
|
||||
|
||||
@@ -2,19 +2,17 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import VisuallyHidden from "components/VisuallyHidden";
|
||||
import { Outline, LabelText } from "./Input";
|
||||
|
||||
const Select = styled.select`
|
||||
border: 0;
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
margin: 0 12px;
|
||||
padding: 8px 12px;
|
||||
outline: none;
|
||||
background: none;
|
||||
color: ${(props) => props.theme.text};
|
||||
height: 30px;
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
@@ -22,22 +20,14 @@ 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[],
|
||||
onBlur?: () => void,
|
||||
onFocus?: () => void,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -53,19 +43,12 @@ class InputSelect extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
label,
|
||||
className,
|
||||
labelHidden,
|
||||
options,
|
||||
short,
|
||||
...rest
|
||||
} = this.props;
|
||||
const { label, className, labelHidden, options, ...rest } = this.props;
|
||||
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
|
||||
return (
|
||||
<Wrapper short={short}>
|
||||
<label>
|
||||
{label &&
|
||||
(labelHidden ? (
|
||||
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
|
||||
@@ -81,7 +64,7 @@ class InputSelect extends React.Component<Props> {
|
||||
))}
|
||||
</Select>
|
||||
</Outline>
|
||||
</Wrapper>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
label: React.Node | string,
|
||||
children: React.Node,
|
||||
|};
|
||||
};
|
||||
|
||||
const Labeled = ({ label, children, ...props }: Props) => (
|
||||
<Flex column {...props}>
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
// @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 ButtonLink from "components/ButtonLink";
|
||||
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 />
|
||||
<Link
|
||||
onClick={() => {
|
||||
auth.updateUser({
|
||||
language,
|
||||
});
|
||||
ui.setLanguagePromptDismissed();
|
||||
}}
|
||||
>
|
||||
{t("Change Language")}
|
||||
</Link>{" "}
|
||||
·{" "}
|
||||
<Link onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</Link>
|
||||
</span>
|
||||
</Flex>
|
||||
</NoticeTip>
|
||||
);
|
||||
}
|
||||
|
||||
const Link = styled(ButtonLink)`
|
||||
color: ${(props) => props.theme.almostBlack};
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const LanguageIcon = styled(Icon)`
|
||||
margin-right: 12px;
|
||||
`;
|
||||
+14
-69
@@ -1,10 +1,8 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { MenuIcon } from "outline-icons";
|
||||
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";
|
||||
@@ -15,17 +13,14 @@ import UiStore from "stores/UiStore";
|
||||
import ErrorSuspended from "scenes/ErrorSuspended";
|
||||
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
|
||||
import Analytics from "components/Analytics";
|
||||
import Button from "components/Button";
|
||||
import DocumentHistory from "components/DocumentHistory";
|
||||
import { GlobalStyles } from "components/DropToImport";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
import { LoadingIndicatorBar } from "components/LoadingIndicator";
|
||||
import Modal from "components/Modal";
|
||||
import Sidebar from "components/Sidebar";
|
||||
import SettingsSidebar from "components/Sidebar/Settings";
|
||||
import SkipNavContent from "components/SkipNavContent";
|
||||
import SkipNavLink from "components/SkipNavLink";
|
||||
import { type Theme } from "types";
|
||||
import { meta } from "utils/keyboard";
|
||||
import {
|
||||
homeUrl,
|
||||
searchUrl,
|
||||
@@ -40,9 +35,7 @@ type Props = {
|
||||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
notifications?: React.Node,
|
||||
theme: Theme,
|
||||
i18n: Object,
|
||||
t: TFunction,
|
||||
theme: Object,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -51,7 +44,7 @@ class Layout extends React.Component<Props> {
|
||||
@observable redirectTo: ?string;
|
||||
@observable keyboardShortcutsOpen: boolean = false;
|
||||
|
||||
constructor(props: Props) {
|
||||
constructor(props) {
|
||||
super();
|
||||
this.updateBackground(props);
|
||||
}
|
||||
@@ -64,16 +57,11 @@ class Layout extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
updateBackground(props: Props) {
|
||||
updateBackground(props) {
|
||||
// ensure the wider page color always matches the theme
|
||||
window.document.body.style.background = props.theme.background;
|
||||
}
|
||||
|
||||
@keydown(`${meta}+.`)
|
||||
handleToggleSidebar() {
|
||||
this.props.ui.toggleCollapsedSidebar();
|
||||
}
|
||||
|
||||
@keydown("shift+/")
|
||||
handleOpenKeyboardShortcuts() {
|
||||
if (this.props.ui.editMode) return;
|
||||
@@ -84,8 +72,8 @@ class Layout extends React.Component<Props> {
|
||||
this.keyboardShortcutsOpen = false;
|
||||
};
|
||||
|
||||
@keydown(["t", "/", `${meta}+k`])
|
||||
goToSearch(ev: SyntheticEvent<>) {
|
||||
@keydown(["t", "/", "meta+k"])
|
||||
goToSearch(ev) {
|
||||
if (this.props.ui.editMode) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
@@ -99,10 +87,9 @@ class Layout extends React.Component<Props> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { auth, t, ui } = this.props;
|
||||
const { auth, ui } = this.props;
|
||||
const { user, team } = auth;
|
||||
const showSidebar = auth.authenticated && user && team;
|
||||
const sidebarCollapsed = ui.editMode || ui.sidebarCollapsed;
|
||||
|
||||
if (auth.isSuspended) return <ErrorSuspended />;
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
@@ -116,19 +103,11 @@ class Layout extends React.Component<Props> {
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
</Helmet>
|
||||
<SkipNavLink />
|
||||
<Analytics />
|
||||
|
||||
{this.props.ui.progressBarVisible && <LoadingIndicatorBar />}
|
||||
{this.props.notifications}
|
||||
|
||||
<MobileMenuButton
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
icon={<MenuIcon />}
|
||||
iconColor="currentColor"
|
||||
neutral
|
||||
/>
|
||||
|
||||
<Container auto>
|
||||
{showSidebar && (
|
||||
<Switch>
|
||||
@@ -137,18 +116,7 @@ class Layout extends React.Component<Props> {
|
||||
</Switch>
|
||||
)}
|
||||
|
||||
<SkipNavContent />
|
||||
<Content
|
||||
auto
|
||||
justify="center"
|
||||
$isResizing={ui.sidebarIsResizing}
|
||||
$sidebarCollapsed={sidebarCollapsed}
|
||||
style={
|
||||
sidebarCollapsed
|
||||
? undefined
|
||||
: { marginLeft: `${ui.sidebarWidth}px` }
|
||||
}
|
||||
>
|
||||
<Content auto justify="center" editMode={ui.editMode}>
|
||||
{this.props.children}
|
||||
</Content>
|
||||
|
||||
@@ -162,10 +130,11 @@ class Layout extends React.Component<Props> {
|
||||
<Modal
|
||||
isOpen={this.keyboardShortcutsOpen}
|
||||
onRequestClose={this.handleCloseKeyboardShortcuts}
|
||||
title={t("Keyboard shortcuts")}
|
||||
title="Keyboard shortcuts"
|
||||
>
|
||||
<KeyboardShortcuts />
|
||||
</Modal>
|
||||
<GlobalStyles />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -179,41 +148,17 @@ const Container = styled(Flex)`
|
||||
min-height: 100%;
|
||||
`;
|
||||
|
||||
const MobileMenuButton = styled(Button)`
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: ${(props) => props.theme.depths.sidebar - 1};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: none;
|
||||
`};
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Content = styled(Flex)`
|
||||
margin: 0;
|
||||
transition: ${(props) =>
|
||||
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
|
||||
transition: margin-left 100ms ease-out;
|
||||
|
||||
@media print {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
margin-left: 0 !important;
|
||||
`}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
${(props) =>
|
||||
props.$sidebarCollapsed &&
|
||||
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
|
||||
margin-left: ${(props) => (props.editMode ? 0 : props.theme.sidebarWidth)};
|
||||
`};
|
||||
`;
|
||||
|
||||
export default withTranslation()<Layout>(
|
||||
inject("auth", "ui", "documents")(withTheme(Layout))
|
||||
);
|
||||
export default inject("auth", "ui", "documents")(withTheme(Layout));
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import format from "date-fns/format";
|
||||
import * as React from "react";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import useUserLocale from "hooks/useUserLocale";
|
||||
|
||||
const locales = {
|
||||
en: require(`date-fns/locale/en`),
|
||||
de: require(`date-fns/locale/de`),
|
||||
es: require(`date-fns/locale/es`),
|
||||
fr: require(`date-fns/locale/fr`),
|
||||
it: require(`date-fns/locale/it`),
|
||||
ko: require(`date-fns/locale/ko`),
|
||||
pt: require(`date-fns/locale/pt`),
|
||||
zh: require(`date-fns/locale/zh_cn`),
|
||||
};
|
||||
|
||||
let callbacks = [];
|
||||
|
||||
// This is a shared timer that fires every minute, used for
|
||||
// updating all Time components across the page all at once.
|
||||
setInterval(() => {
|
||||
callbacks.forEach((cb) => cb());
|
||||
}, 1000 * 60);
|
||||
|
||||
function eachMinute(fn) {
|
||||
callbacks.push(fn);
|
||||
|
||||
return () => {
|
||||
callbacks = callbacks.filter((cb) => cb !== fn);
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
dateTime: string,
|
||||
children?: React.Node,
|
||||
tooltipDelay?: number,
|
||||
addSuffix?: boolean,
|
||||
shorten?: boolean,
|
||||
};
|
||||
|
||||
function LocaleTime({
|
||||
addSuffix,
|
||||
children,
|
||||
dateTime,
|
||||
shorten,
|
||||
tooltipDelay,
|
||||
}: Props) {
|
||||
const userLocale = useUserLocale();
|
||||
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line no-unused-vars
|
||||
const callback = React.useRef();
|
||||
|
||||
React.useEffect(() => {
|
||||
callback.current = eachMinute(() => {
|
||||
setMinutesMounted((state) => ++state);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (callback.current) {
|
||||
callback.current();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
let content = distanceInWordsToNow(dateTime, {
|
||||
addSuffix,
|
||||
locale: userLocale ? locales[userLocale] : undefined,
|
||||
});
|
||||
|
||||
if (shorten) {
|
||||
content = content
|
||||
.replace("about", "")
|
||||
.replace("less than a minute ago", "just now")
|
||||
.replace("minute", "min");
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
tooltip={format(dateTime, "MMMM Do, YYYY h:mm a")}
|
||||
delay={tooltipDelay}
|
||||
placement="bottom"
|
||||
>
|
||||
<time dateTime={dateTime}>{children || content}</time>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default LocaleTime;
|
||||
@@ -5,10 +5,10 @@ import { randomInteger } from "shared/random";
|
||||
import { pulsate } from "shared/styles/animations";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
header?: boolean,
|
||||
height?: number,
|
||||
|};
|
||||
};
|
||||
|
||||
class Mask extends React.Component<Props> {
|
||||
width: number;
|
||||
@@ -23,7 +23,7 @@ class Mask extends React.Component<Props> {
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Redacted width={this.width} />;
|
||||
return <Redacted width={this.width} {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,12 @@ import Scrollable from "components/Scrollable";
|
||||
|
||||
ReactModal.setAppElement("#root");
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
children?: React.Node,
|
||||
isOpen: boolean,
|
||||
title?: string,
|
||||
onRequestClose: () => void,
|
||||
|};
|
||||
};
|
||||
|
||||
const GlobalStyles = createGlobalStyle`
|
||||
.ReactModal__Overlay {
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
// @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;
|
||||
@@ -3,17 +3,15 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Button = styled.button`
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
line-height: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
export default React.forwardRef<any, typeof Button>(
|
||||
({ size = 24, ...props }, ref) => <Button size={size} {...props} ref={ref} />
|
||||
);
|
||||
export default React.forwardRef<any, typeof Button>((props, ref) => (
|
||||
<Button {...props} ref={ref} />
|
||||
));
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import useStores from "hooks/useStores";
|
||||
import { cdnPath } from "utils/urls";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
title: string,
|
||||
favicon?: string,
|
||||
|};
|
||||
auth: AuthStore,
|
||||
};
|
||||
|
||||
const PageTitle = ({ title, favicon }: Props) => {
|
||||
const { auth } = useStores();
|
||||
const PageTitle = observer(({ auth, title, favicon }: Props) => {
|
||||
const { team } = auth;
|
||||
|
||||
return (
|
||||
@@ -22,12 +21,12 @@ const PageTitle = ({ title, favicon }: Props) => {
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
type="image/png"
|
||||
href={favicon || cdnPath("/favicon-32.png")}
|
||||
href={favicon || "/favicon-32.png"}
|
||||
sizes="32x32"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</Helmet>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default observer(PageTitle);
|
||||
export default inject("auth")(PageTitle);
|
||||
|
||||
@@ -2,22 +2,16 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import Document from "models/Document";
|
||||
import DocumentListItem from "components/DocumentListItem";
|
||||
import DocumentPreview from "components/DocumentPreview";
|
||||
import PaginatedList from "components/PaginatedList";
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
documents: Document[],
|
||||
fetch: (options: ?Object) => Promise<void>,
|
||||
options?: Object,
|
||||
heading?: React.Node,
|
||||
empty?: React.Node,
|
||||
showNestedDocuments?: boolean,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
showDraft?: boolean,
|
||||
showTemplate?: boolean,
|
||||
|};
|
||||
};
|
||||
|
||||
@observer
|
||||
class PaginatedDocumentList extends React.Component<Props> {
|
||||
@@ -32,7 +26,7 @@ class PaginatedDocumentList extends React.Component<Props> {
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item) => (
|
||||
<DocumentListItem key={item.id} document={item} {...rest} />
|
||||
<DocumentPreview key={item.id} document={item} {...rest} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @flow
|
||||
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
|
||||
import { isEqual } from "lodash";
|
||||
import { observable, action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
@@ -41,9 +40,6 @@ class PaginatedList extends React.Component<Props> {
|
||||
if (prevProps.fetch !== this.props.fetch) {
|
||||
this.fetchResults();
|
||||
}
|
||||
if (!isEqual(prevProps.options, this.props.options)) {
|
||||
this.fetchResults();
|
||||
}
|
||||
}
|
||||
|
||||
fetchResults = async () => {
|
||||
|
||||
@@ -14,7 +14,6 @@ type Props = {
|
||||
document?: ?Document,
|
||||
collection: ?Collection,
|
||||
onSuccess?: () => void,
|
||||
style?: Object,
|
||||
ref?: (?React.ElementRef<"div">) => void,
|
||||
};
|
||||
|
||||
@@ -35,20 +34,13 @@ class PathToDocument extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { result, collection, document, ref, style } = this.props;
|
||||
const { result, collection, document, ref } = this.props;
|
||||
const Component = document ? ResultWrapperLink : ResultWrapper;
|
||||
|
||||
if (!result) return <div />;
|
||||
|
||||
return (
|
||||
<Component
|
||||
ref={ref}
|
||||
onClick={this.handleClick}
|
||||
href=""
|
||||
style={style}
|
||||
role="option"
|
||||
selectable
|
||||
>
|
||||
<Component ref={ref} onClick={this.handleClick} href="" selectable>
|
||||
{collection && <CollectionIcon collection={collection} />}
|
||||
|
||||
{result.path
|
||||
@@ -80,27 +72,21 @@ const StyledGoToIcon = styled(GoToIcon)`
|
||||
const ResultWrapper = styled.div`
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
margin-left: -4px;
|
||||
user-select: none;
|
||||
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: default;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const ResultWrapperLink = styled(ResultWrapper.withComponent("a"))`
|
||||
margin: 0 -8px;
|
||||
padding: 8px 4px;
|
||||
|
||||
${DocumentTitle} {
|
||||
display: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
// @flow
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { Route } from "react-router-dom";
|
||||
import env from "env";
|
||||
|
||||
let Component = Route;
|
||||
|
||||
if (env.SENTRY_DSN) {
|
||||
Component = Sentry.withSentryRouting(Route);
|
||||
}
|
||||
|
||||
export default Component;
|
||||
@@ -1,52 +1,28 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import useWindowSize from "hooks/useWindowSize";
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
shadow?: boolean,
|
||||
topShadow?: boolean,
|
||||
bottomShadow?: boolean,
|
||||
|};
|
||||
};
|
||||
|
||||
function Scrollable({ shadow, topShadow, bottomShadow, ...rest }: Props) {
|
||||
const ref = React.useRef<?HTMLDivElement>();
|
||||
const [topShadowVisible, setTopShadow] = React.useState(false);
|
||||
const [bottomShadowVisible, setBottomShadow] = React.useState(false);
|
||||
const { height } = useWindowSize();
|
||||
@observer
|
||||
class Scrollable extends React.Component<Props> {
|
||||
@observable shadow: boolean = false;
|
||||
|
||||
const updateShadows = React.useCallback(() => {
|
||||
const c = ref.current;
|
||||
if (!c) return;
|
||||
handleScroll = (ev: SyntheticMouseEvent<HTMLDivElement>) => {
|
||||
this.shadow = !!(this.props.shadow && ev.currentTarget.scrollTop > 0);
|
||||
};
|
||||
|
||||
const scrollTop = c.scrollTop;
|
||||
const tsv = !!((shadow || topShadow) && scrollTop > 0);
|
||||
if (tsv !== topShadowVisible) {
|
||||
setTopShadow(tsv);
|
||||
}
|
||||
render() {
|
||||
const { shadow, ...rest } = this.props;
|
||||
|
||||
const wrapperHeight = c.scrollHeight - c.clientHeight;
|
||||
const bsv = !!((shadow || bottomShadow) && wrapperHeight - scrollTop !== 0);
|
||||
|
||||
if (bsv !== bottomShadowVisible) {
|
||||
setBottomShadow(bsv);
|
||||
}
|
||||
}, [shadow, topShadow, bottomShadow, topShadowVisible, bottomShadowVisible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
updateShadows();
|
||||
}, [height, updateShadows]);
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
ref={ref}
|
||||
onScroll={updateShadows}
|
||||
$topShadowVisible={topShadowVisible}
|
||||
$bottomShadowVisible={bottomShadowVisible}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Wrapper onScroll={this.handleScroll} shadow={this.shadow} {...rest} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
@@ -55,20 +31,9 @@ const Wrapper = styled.div`
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
box-shadow: ${(props) => {
|
||||
if (props.$topShadowVisible && props.$bottomShadowVisible) {
|
||||
return "0 1px inset rgba(0,0,0,.1), 0 -1px inset rgba(0,0,0,.1)";
|
||||
}
|
||||
if (props.$topShadowVisible) {
|
||||
return "0 1px inset rgba(0,0,0,.1)";
|
||||
}
|
||||
if (props.$bottomShadowVisible) {
|
||||
return "0 -1px inset rgba(0,0,0,.1)";
|
||||
}
|
||||
|
||||
return "none";
|
||||
}};
|
||||
transition: all 100ms ease-in-out;
|
||||
box-shadow: ${(props) =>
|
||||
props.shadow ? "0 1px inset rgba(0,0,0,.1)" : "none"};
|
||||
transition: all 250ms ease-in-out;
|
||||
`;
|
||||
|
||||
export default observer(Scrollable);
|
||||
export default Scrollable;
|
||||
|
||||
+157
-160
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
HomeIcon,
|
||||
@@ -9,11 +10,13 @@ import {
|
||||
ShapesIcon,
|
||||
TrashIcon,
|
||||
PlusIcon,
|
||||
SettingsIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import CollectionNew from "scenes/CollectionNew";
|
||||
import Invite from "scenes/Invite";
|
||||
import Flex from "components/Flex";
|
||||
@@ -25,179 +28,173 @@ import Collections from "./components/Collections";
|
||||
import HeaderBlock from "./components/HeaderBlock";
|
||||
import Section from "./components/Section";
|
||||
import SidebarLink from "./components/SidebarLink";
|
||||
import useStores from "hooks/useStores";
|
||||
import AccountMenu from "menus/AccountMenu";
|
||||
|
||||
function MainSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { policies, auth, documents } = useStores();
|
||||
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
|
||||
const [
|
||||
createCollectionModalOpen,
|
||||
setCreateCollectionModalOpen,
|
||||
] = React.useState(false);
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
documents: DocumentsStore,
|
||||
policies: PoliciesStore,
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
documents.fetchDrafts();
|
||||
documents.fetchTemplates();
|
||||
}, [documents]);
|
||||
@observer
|
||||
class MainSidebar extends React.Component<Props> {
|
||||
@observable inviteModalOpen = false;
|
||||
@observable createCollectionModalOpen = false;
|
||||
|
||||
const handleCreateCollectionModalOpen = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
setCreateCollectionModalOpen(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
componentDidMount() {
|
||||
this.props.documents.fetchDrafts();
|
||||
this.props.documents.fetchTemplates();
|
||||
}
|
||||
|
||||
const handleCreateCollectionModalClose = React.useCallback(() => {
|
||||
setCreateCollectionModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleInviteModalOpen = React.useCallback((ev: SyntheticEvent<>) => {
|
||||
handleCreateCollectionModalOpen = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
setInviteModalOpen(true);
|
||||
}, []);
|
||||
this.createCollectionModalOpen = true;
|
||||
};
|
||||
|
||||
const handleInviteModalClose = React.useCallback(() => {
|
||||
setInviteModalOpen(false);
|
||||
}, []);
|
||||
handleCreateCollectionModalClose = (ev: SyntheticEvent<>) => {
|
||||
this.createCollectionModalOpen = false;
|
||||
};
|
||||
|
||||
const { user, team } = auth;
|
||||
if (!user || !team) return null;
|
||||
handleInviteModalOpen = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.inviteModalOpen = true;
|
||||
};
|
||||
|
||||
const can = policies.abilities(team.id);
|
||||
handleInviteModalClose = () => {
|
||||
this.inviteModalOpen = false;
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<AccountMenu>
|
||||
{(props) => (
|
||||
<HeaderBlock
|
||||
{...props}
|
||||
subheading={user.name}
|
||||
teamName={team.name}
|
||||
logoUrl={team.avatarUrl}
|
||||
showDisclosure
|
||||
/>
|
||||
)}
|
||||
</AccountMenu>
|
||||
<Flex auto column>
|
||||
<Scrollable shadow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to="/home"
|
||||
icon={<HomeIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
render() {
|
||||
const { auth, documents, policies } = this.props;
|
||||
const { user, team } = auth;
|
||||
if (!user || !team) return null;
|
||||
|
||||
const draftDocumentsCount = documents.drafts.length;
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<AccountMenu
|
||||
label={
|
||||
<HeaderBlock
|
||||
subheading={user.name}
|
||||
teamName={team.name}
|
||||
logoUrl={team.avatarUrl}
|
||||
showDisclosure
|
||||
/>
|
||||
<SidebarLink
|
||||
to={{
|
||||
pathname: "/search",
|
||||
state: { fromMenu: true },
|
||||
}}
|
||||
icon={<SearchIcon color="currentColor" />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/starred"
|
||||
icon={<StarredIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Starred")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/templates"
|
||||
icon={<ShapesIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Templates")}
|
||||
active={documents.active ? documents.active.template : undefined}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/drafts"
|
||||
icon={<EditIcon color="currentColor" />}
|
||||
label={
|
||||
<Drafts align="center">
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 && (
|
||||
<Bubble count={documents.totalDrafts} />
|
||||
)}
|
||||
</Drafts>
|
||||
}
|
||||
active={
|
||||
documents.active
|
||||
? !documents.active.publishedAt &&
|
||||
!documents.active.isDeleted &&
|
||||
!documents.active.isTemplate
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<Collections onCreateCollection={handleCreateCollectionModalOpen} />
|
||||
</Section>
|
||||
</Scrollable>
|
||||
<Secondary>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to="/archive"
|
||||
icon={<ArchiveIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
active={
|
||||
documents.active
|
||||
? documents.active.isArchived && !documents.active.isDeleted
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/trash"
|
||||
icon={<TrashIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Trash")}
|
||||
active={documents.active ? documents.active.isDeleted : undefined}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings"
|
||||
icon={<SettingsIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Settings")}
|
||||
/>
|
||||
{can.invite && (
|
||||
}
|
||||
/>
|
||||
<Flex auto column>
|
||||
<Scrollable shadow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to="/settings/people"
|
||||
onClick={handleInviteModalOpen}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label={`${t("Invite people")}…`}
|
||||
to="/home"
|
||||
icon={<HomeIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label="Home"
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Secondary>
|
||||
</Flex>
|
||||
<Modal
|
||||
title={t("Invite people")}
|
||||
onRequestClose={handleInviteModalClose}
|
||||
isOpen={inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={handleInviteModalClose} />
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Create a collection")}
|
||||
onRequestClose={handleCreateCollectionModalClose}
|
||||
isOpen={createCollectionModalOpen}
|
||||
>
|
||||
<CollectionNew onSubmit={handleCreateCollectionModalClose} />
|
||||
</Modal>
|
||||
</Sidebar>
|
||||
);
|
||||
<SidebarLink
|
||||
to={{
|
||||
pathname: "/search",
|
||||
state: { fromMenu: true },
|
||||
}}
|
||||
icon={<SearchIcon color="currentColor" />}
|
||||
label="Search"
|
||||
exact={false}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/starred"
|
||||
icon={<StarredIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label="Starred"
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/templates"
|
||||
icon={<ShapesIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label="Templates"
|
||||
active={
|
||||
documents.active ? documents.active.template : undefined
|
||||
}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/drafts"
|
||||
icon={<EditIcon color="currentColor" />}
|
||||
label={
|
||||
<Drafts align="center">
|
||||
Drafts
|
||||
{draftDocumentsCount > 0 && (
|
||||
<Bubble count={draftDocumentsCount} />
|
||||
)}
|
||||
</Drafts>
|
||||
}
|
||||
active={
|
||||
documents.active
|
||||
? !documents.active.publishedAt &&
|
||||
!documents.active.isDeleted &&
|
||||
!documents.active.isTemplate
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<Collections
|
||||
onCreateCollection={this.handleCreateCollectionModalOpen}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to="/archive"
|
||||
icon={<ArchiveIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label="Archive"
|
||||
active={
|
||||
documents.active
|
||||
? documents.active.isArchived && !documents.active.isDeleted
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/trash"
|
||||
icon={<TrashIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label="Trash"
|
||||
active={
|
||||
documents.active ? documents.active.isDeleted : undefined
|
||||
}
|
||||
/>
|
||||
{can.invite && (
|
||||
<SidebarLink
|
||||
to="/settings/people"
|
||||
onClick={this.handleInviteModalOpen}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label="Invite people…"
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Scrollable>
|
||||
</Flex>
|
||||
<Modal
|
||||
title="Invite people"
|
||||
onRequestClose={this.handleInviteModalClose}
|
||||
isOpen={this.inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={this.handleInviteModalClose} />
|
||||
</Modal>
|
||||
<Modal
|
||||
title="Create a collection"
|
||||
onRequestClose={this.handleCreateCollectionModalClose}
|
||||
isOpen={this.createCollectionModalOpen}
|
||||
>
|
||||
<CollectionNew onSubmit={this.handleCreateCollectionModalClose} />
|
||||
</Modal>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Secondary = styled.div`
|
||||
overflow-x: hidden;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Drafts = styled(Flex)`
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export default observer(MainSidebar);
|
||||
export default inject("documents", "policies", "auth")(MainSidebar);
|
||||
|
||||
+116
-108
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import {
|
||||
DocumentIcon,
|
||||
EmailIcon,
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
ExpandedIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import type { RouterHistory } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import Flex from "components/Flex";
|
||||
import Scrollable from "components/Scrollable";
|
||||
|
||||
@@ -28,123 +29,130 @@ import Version from "./components/Version";
|
||||
import SlackIcon from "./icons/Slack";
|
||||
import ZapierIcon from "./icons/Zapier";
|
||||
import env from "env";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
const isHosted = env.DEPLOYMENT === "hosted";
|
||||
|
||||
function SettingsSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const team = useCurrentTeam();
|
||||
const { policies } = useStores();
|
||||
const can = policies.abilities(team.id);
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
policies: PoliciesStore,
|
||||
auth: AuthStore,
|
||||
};
|
||||
|
||||
const returnToDashboard = React.useCallback(() => {
|
||||
history.push("/home");
|
||||
}, [history]);
|
||||
@observer
|
||||
class SettingsSidebar extends React.Component<Props> {
|
||||
returnToDashboard = () => {
|
||||
this.props.history.push("/home");
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<HeaderBlock
|
||||
subheading={
|
||||
<ReturnToApp align="center">
|
||||
<BackIcon color="currentColor" /> {t("Return to App")}
|
||||
</ReturnToApp>
|
||||
}
|
||||
teamName={team.name}
|
||||
logoUrl={team.avatarUrl}
|
||||
onClick={returnToDashboard}
|
||||
/>
|
||||
render() {
|
||||
const { policies, auth } = this.props;
|
||||
const { team } = auth;
|
||||
if (!team) return null;
|
||||
|
||||
<Flex auto column>
|
||||
<Scrollable topShadow>
|
||||
<Section>
|
||||
<Header>{t("Account")}</Header>
|
||||
<SidebarLink
|
||||
to="/settings"
|
||||
icon={<ProfileIcon color="currentColor" />}
|
||||
label={t("Profile")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/notifications"
|
||||
icon={<EmailIcon color="currentColor" />}
|
||||
label={t("Notifications")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/tokens"
|
||||
icon={<CodeIcon color="currentColor" />}
|
||||
label={t("API Tokens")}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<Header>{t("Team")}</Header>
|
||||
{can.update && (
|
||||
<SidebarLink
|
||||
to="/settings/details"
|
||||
icon={<TeamIcon color="currentColor" />}
|
||||
label={t("Details")}
|
||||
/>
|
||||
)}
|
||||
{can.update && (
|
||||
<SidebarLink
|
||||
to="/settings/security"
|
||||
icon={<PadlockIcon color="currentColor" />}
|
||||
label={t("Security")}
|
||||
/>
|
||||
)}
|
||||
<SidebarLink
|
||||
to="/settings/people"
|
||||
icon={<UserIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("People")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/groups"
|
||||
icon={<GroupIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Groups")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/shares"
|
||||
icon={<LinkIcon color="currentColor" />}
|
||||
label={t("Share Links")}
|
||||
/>
|
||||
{can.export && (
|
||||
<SidebarLink
|
||||
to="/settings/export"
|
||||
icon={<DocumentIcon color="currentColor" />}
|
||||
label={t("Export Data")}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
{can.update && (
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<HeaderBlock
|
||||
subheading={
|
||||
<ReturnToApp align="center">
|
||||
<BackIcon color="currentColor" /> Return to App
|
||||
</ReturnToApp>
|
||||
}
|
||||
teamName={team.name}
|
||||
logoUrl={team.avatarUrl}
|
||||
onClick={this.returnToDashboard}
|
||||
/>
|
||||
|
||||
<Flex auto column>
|
||||
<Scrollable shadow>
|
||||
<Section>
|
||||
<Header>{t("Integrations")}</Header>
|
||||
<Header>Account</Header>
|
||||
<SidebarLink
|
||||
to="/settings/integrations/slack"
|
||||
icon={<SlackIcon color="currentColor" />}
|
||||
label="Slack"
|
||||
to="/settings"
|
||||
icon={<ProfileIcon color="currentColor" />}
|
||||
label="Profile"
|
||||
/>
|
||||
{isHosted && (
|
||||
<SidebarLink
|
||||
to="/settings/notifications"
|
||||
icon={<EmailIcon color="currentColor" />}
|
||||
label="Notifications"
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/tokens"
|
||||
icon={<CodeIcon color="currentColor" />}
|
||||
label="API Tokens"
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<Header>Team</Header>
|
||||
{can.update && (
|
||||
<SidebarLink
|
||||
to="/settings/integrations/zapier"
|
||||
icon={<ZapierIcon color="currentColor" />}
|
||||
label="Zapier"
|
||||
to="/settings/details"
|
||||
icon={<TeamIcon color="currentColor" />}
|
||||
label="Details"
|
||||
/>
|
||||
)}
|
||||
{can.update && (
|
||||
<SidebarLink
|
||||
to="/settings/security"
|
||||
icon={<PadlockIcon color="currentColor" />}
|
||||
label="Security"
|
||||
/>
|
||||
)}
|
||||
<SidebarLink
|
||||
to="/settings/people"
|
||||
icon={<UserIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label="People"
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/groups"
|
||||
icon={<GroupIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label="Groups"
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/shares"
|
||||
icon={<LinkIcon color="currentColor" />}
|
||||
label="Share Links"
|
||||
/>
|
||||
{can.export && (
|
||||
<SidebarLink
|
||||
to="/settings/export"
|
||||
icon={<DocumentIcon color="currentColor" />}
|
||||
label="Export Data"
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{can.update && !isHosted && (
|
||||
<Section>
|
||||
<Header>{t("Installation")}</Header>
|
||||
<Version />
|
||||
</Section>
|
||||
)}
|
||||
</Scrollable>
|
||||
</Flex>
|
||||
</Sidebar>
|
||||
);
|
||||
{can.update && (
|
||||
<Section>
|
||||
<Header>Integrations</Header>
|
||||
<SidebarLink
|
||||
to="/settings/integrations/slack"
|
||||
icon={<SlackIcon color="currentColor" />}
|
||||
label="Slack"
|
||||
/>
|
||||
{isHosted && (
|
||||
<SidebarLink
|
||||
to="/settings/integrations/zapier"
|
||||
icon={<ZapierIcon color="currentColor" />}
|
||||
label="Zapier"
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{can.update && !isHosted && (
|
||||
<Section>
|
||||
<Header>Installation</Header>
|
||||
<Version />
|
||||
</Section>
|
||||
)}
|
||||
</Scrollable>
|
||||
</Flex>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const BackIcon = styled(ExpandedIcon)`
|
||||
@@ -156,4 +164,4 @@ const ReturnToApp = styled(Flex)`
|
||||
height: 16px;
|
||||
`;
|
||||
|
||||
export default observer(SettingsSidebar);
|
||||
export default inject("auth", "policies")(SettingsSidebar);
|
||||
|
||||
@@ -1,171 +1,50 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon, MenuIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Portal } from "react-portal";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import type { Location } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import CollapseToggle, {
|
||||
Button as CollapseButton,
|
||||
} from "./components/CollapseToggle";
|
||||
import ResizeBorder from "./components/ResizeBorder";
|
||||
import ResizeHandle from "./components/ResizeHandle";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
let firstRender = true;
|
||||
let BOUNCE_ANIMATION_MS = 250;
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
location: Location,
|
||||
};
|
||||
|
||||
const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [isAnimating, setAnimating] = React.useState(false);
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
const isSmallerThanMinimum = width < minWidth;
|
||||
|
||||
const handleDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
// suppresses text selection
|
||||
event.preventDefault();
|
||||
|
||||
// this is simple because the sidebar is always against the left edge
|
||||
const width = Math.min(event.pageX - offset, maxWidth);
|
||||
setWidth(width);
|
||||
},
|
||||
[offset, maxWidth, setWidth]
|
||||
);
|
||||
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
setResizing(false);
|
||||
|
||||
if (isSmallerThanMinimum) {
|
||||
setWidth(minWidth);
|
||||
setAnimating(true);
|
||||
} else {
|
||||
setWidth(width);
|
||||
}
|
||||
}, [isSmallerThanMinimum, minWidth, width, setWidth]);
|
||||
|
||||
const handleStartDrag = React.useCallback(
|
||||
(event) => {
|
||||
setOffset(event.pageX - width);
|
||||
setResizing(true);
|
||||
setAnimating(false);
|
||||
},
|
||||
[width]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isAnimating) {
|
||||
setTimeout(() => setAnimating(false), BOUNCE_ANIMATION_MS);
|
||||
}
|
||||
}, [isAnimating]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isResizing) {
|
||||
document.addEventListener("mousemove", handleDrag);
|
||||
document.addEventListener("mouseup", handleStopDrag);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleDrag);
|
||||
document.removeEventListener("mouseup", handleStopDrag);
|
||||
};
|
||||
}, [isResizing, handleDrag, handleStopDrag]);
|
||||
|
||||
return { isAnimating, isSmallerThanMinimum, isResizing, handleStartDrag };
|
||||
};
|
||||
|
||||
function Sidebar({ location, children }: Props) {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
const previousLocation = usePrevious(location);
|
||||
|
||||
const width = ui.sidebarWidth;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
const collapsed = ui.editMode || ui.sidebarCollapsed;
|
||||
|
||||
const {
|
||||
isAnimating,
|
||||
isSmallerThanMinimum,
|
||||
isResizing,
|
||||
handleStartDrag,
|
||||
} = useResize({
|
||||
width,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
setWidth: ui.setSidebarWidth,
|
||||
});
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
ui.setSidebarWidth(theme.sidebarWidth);
|
||||
}, [ui, theme.sidebarWidth]);
|
||||
|
||||
React.useEffect(() => {
|
||||
ui.setSidebarResizing(isResizing);
|
||||
}, [ui, isResizing]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (location !== previousLocation) {
|
||||
ui.hideMobileSidebar();
|
||||
}
|
||||
}, [ui, location, previousLocation]);
|
||||
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
width: `${width}px`,
|
||||
left:
|
||||
collapsed && !ui.mobileSidebarVisible
|
||||
? `${-width + theme.sidebarCollapsedWidth}px`
|
||||
: 0,
|
||||
}),
|
||||
[width, collapsed, theme.sidebarCollapsedWidth, ui.mobileSidebarVisible]
|
||||
);
|
||||
}, [ui, location]);
|
||||
|
||||
const content = (
|
||||
<Container
|
||||
style={style}
|
||||
$sidebarWidth={ui.sidebarWidth}
|
||||
$isAnimating={isAnimating}
|
||||
$isSmallerThanMinimum={isSmallerThanMinimum}
|
||||
$mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
$collapsed={collapsed}
|
||||
editMode={ui.editMode}
|
||||
mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
column
|
||||
>
|
||||
{!isResizing && (
|
||||
<CollapseToggle
|
||||
collapsed={ui.sidebarCollapsed}
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
/>
|
||||
)}
|
||||
{ui.mobileSidebarVisible && (
|
||||
<Portal>
|
||||
<Fade>
|
||||
<Background onClick={ui.toggleMobileSidebar} />
|
||||
</Fade>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
<Toggle
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
>
|
||||
{ui.mobileSidebarVisible ? (
|
||||
<CloseIcon size={32} />
|
||||
) : (
|
||||
<MenuIcon size={32} />
|
||||
)}
|
||||
</Toggle>
|
||||
{children}
|
||||
{!ui.sidebarCollapsed && (
|
||||
<ResizeBorder
|
||||
onMouseDown={handleStartDrag}
|
||||
onDoubleClick={handleReset}
|
||||
$isResizing={isResizing}
|
||||
>
|
||||
<ResizeHandle aria-label={t("Resize sidebar")} />
|
||||
</ResizeBorder>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -178,66 +57,58 @@ function Sidebar({ location, children }: Props) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const Background = styled.a`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
cursor: default;
|
||||
z-index: ${(props) => props.theme.depths.sidebar - 1};
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
transition: box-shadow, 100ms, ease-in-out, margin-left 100ms ease-out,
|
||||
left 100ms ease-out,
|
||||
${(props) => props.theme.backgroundTransition}
|
||||
${(props) =>
|
||||
props.$isAnimating ? `,width ${BOUNCE_ANIMATION_MS}ms ease-out` : ""};
|
||||
margin-left: ${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")};
|
||||
transition: left 100ms ease-out,
|
||||
${(props) => props.theme.backgroundTransition};
|
||||
margin-left: ${(props) => (props.mobileSidebarVisible ? 0 : "-100%")};
|
||||
z-index: ${(props) => props.theme.depths.sidebar};
|
||||
max-width: 70%;
|
||||
min-width: 280px;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: "";
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
position: absolute;
|
||||
top: -50vh;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 50vh;
|
||||
}
|
||||
|
||||
&:after {
|
||||
top: auto;
|
||||
bottom: -50vh;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
left: ${(props) => (props.editMode ? `-${props.theme.sidebarWidth}` : 0)};
|
||||
width: ${(props) => props.theme.sidebarWidth};
|
||||
margin: 0;
|
||||
z-index: 3;
|
||||
min-width: 0;
|
||||
`};
|
||||
`;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
left: 0 !important;
|
||||
box-shadow: ${(props) =>
|
||||
props.$collapsed
|
||||
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
|
||||
: props.$isSmallerThanMinimum
|
||||
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
|
||||
: "none"};
|
||||
const Toggle = styled.a`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: ${(props) => (props.mobileSidebarVisible ? "auto" : 0)};
|
||||
right: ${(props) => (props.mobileSidebarVisible ? 0 : "auto")};
|
||||
z-index: 1;
|
||||
margin: 12px;
|
||||
|
||||
& ${CollapseButton} {
|
||||
opacity: .75;
|
||||
}
|
||||
|
||||
& ${CollapseButton}:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:hover):not(:focus-within) > div {
|
||||
opacity: ${(props) => (props.$collapsed ? "0" : "1")};
|
||||
transition: opacity 100ms ease-in-out;
|
||||
}
|
||||
${breakpoint("tablet")`
|
||||
display: none;
|
||||
`};
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
// @flow
|
||||
import { NextIcon, BackIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import { meta } from "utils/keyboard";
|
||||
|
||||
type Props = {|
|
||||
collapsed: boolean,
|
||||
onClick?: (event: SyntheticEvent<>) => void,
|
||||
|};
|
||||
|
||||
function CollapseToggle({ collapsed, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
tooltip={collapsed ? t("Expand") : t("Collapse")}
|
||||
shortcut={`${meta}+.`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button {...rest} tabIndex="-1" aria-hidden>
|
||||
{collapsed ? (
|
||||
<NextIcon color="currentColor" />
|
||||
) : (
|
||||
<BackIcon color="currentColor" />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export const Button = styled.button`
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
right: 8px;
|
||||
border: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
z-index: 1;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
background: transparent;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.white};
|
||||
background: ${(props) => props.theme.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
export default CollapseToggle;
|
||||
@@ -1,147 +1,119 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { observable } from "mobx";
|
||||
import { observer, Observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import styled from "styled-components";
|
||||
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 { SidebarDnDContext } from "./Collections";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Draggable from "./Draggable";
|
||||
import Droppable from "./Droppable";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import useStores from "hooks/useStores";
|
||||
import CollectionMenu from "menus/CollectionMenu";
|
||||
import CollectionSortMenu from "menus/CollectionSortMenu";
|
||||
|
||||
type Props = {|
|
||||
collection: Collection,
|
||||
ui: UiStore,
|
||||
canUpdate: boolean,
|
||||
documents: DocumentsStore,
|
||||
activeDocument: ?Document,
|
||||
prefetchDocument: (id: string) => Promise<void>,
|
||||
|};
|
||||
|
||||
function CollectionLink({
|
||||
collection,
|
||||
activeDocument,
|
||||
prefetchDocument,
|
||||
canUpdate,
|
||||
ui,
|
||||
}: Props) {
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
@observer
|
||||
class CollectionLink extends React.Component<Props> {
|
||||
@observable menuOpen = false;
|
||||
|
||||
const handleTitleChange = React.useCallback(
|
||||
async (name: string) => {
|
||||
await collection.save({ name });
|
||||
},
|
||||
[collection]
|
||||
);
|
||||
handleTitleChange = async (name: string) => {
|
||||
await this.props.collection.save({ name });
|
||||
};
|
||||
|
||||
const { documents, policies } = useStores();
|
||||
const expanded = collection.id === ui.activeCollectionId;
|
||||
const manualSort = collection.sort.field === "index";
|
||||
const can = policies.abilities(collection.id);
|
||||
render() {
|
||||
const {
|
||||
collection,
|
||||
documents,
|
||||
activeDocument,
|
||||
prefetchDocument,
|
||||
canUpdate,
|
||||
ui,
|
||||
} = this.props;
|
||||
const expanded = collection.id === ui.activeCollectionId;
|
||||
|
||||
// Drop to re-parent
|
||||
const [{ isOver, canDrop }, drop] = useDrop({
|
||||
accept: "document",
|
||||
drop: (item, monitor) => {
|
||||
if (monitor.didDrop()) return;
|
||||
if (!collection) return;
|
||||
documents.move(item.id, collection.id);
|
||||
},
|
||||
canDrop: (item, monitor) => {
|
||||
return policies.abilities(collection.id).update;
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: !!monitor.isOver({ shallow: true }),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Drop to reorder
|
||||
const [{ isOverReorder }, dropToReorder] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item, monitor) => {
|
||||
if (!collection) return;
|
||||
documents.move(item.id, collection.id, undefined, 0);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: !!monitor.isOver(),
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={drop} style={{ position: "relative" }}>
|
||||
<DropToImport key={collection.id} collectionId={collection.id}>
|
||||
<SidebarLinkWithPadding
|
||||
key={collection.id}
|
||||
to={collection.url}
|
||||
icon={
|
||||
<CollectionIcon collection={collection} expanded={expanded} />
|
||||
}
|
||||
iconColor={collection.color}
|
||||
expanded={expanded}
|
||||
showActions={menuOpen || expanded}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={collection.name}
|
||||
onSubmit={handleTitleChange}
|
||||
canUpdate={canUpdate}
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
menu={
|
||||
<>
|
||||
{can.update && (
|
||||
<CollectionSortMenuWithMargin
|
||||
collection={collection}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</DropToImport>
|
||||
{expanded && manualSort && (
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded &&
|
||||
collection.documents.map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={collection}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
canUpdate={canUpdate}
|
||||
depth={1.5}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<DropToImport
|
||||
key={collection.id}
|
||||
collectionId={collection.id}
|
||||
activeClassName="activeDropZone"
|
||||
>
|
||||
<SidebarDnDContext.Consumer>
|
||||
{({ draggingDocumentId, isDragging }) => (
|
||||
<Droppable collectionId={collection.id}>
|
||||
{(provided, snapshot) => (
|
||||
<SidebarLink
|
||||
key={collection.id}
|
||||
to={collection.url}
|
||||
icon={
|
||||
<CollectionIcon
|
||||
collection={collection}
|
||||
expanded={expanded}
|
||||
/>
|
||||
}
|
||||
iconColor={collection.color}
|
||||
expanded={
|
||||
isDragging ? expanded || snapshot.isDraggingOver : 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>
|
||||
<Observer>
|
||||
{() =>
|
||||
collection.documents.map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
index={index}
|
||||
documents={documents}
|
||||
collection={collection}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
canUpdate={canUpdate}
|
||||
depth={1.5}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</Observer>
|
||||
</Flex>
|
||||
</SidebarLink>
|
||||
)}
|
||||
</Droppable>
|
||||
)}
|
||||
</SidebarDnDContext.Consumer>
|
||||
</DropToImport>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const SidebarLinkWithPadding = styled(SidebarLink)`
|
||||
padding-right: 60px;
|
||||
`;
|
||||
|
||||
const CollectionSortMenuWithMargin = styled(CollectionSortMenu)`
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
export default observer(CollectionLink);
|
||||
export default CollectionLink;
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import type { DropResult, BeforeCapture } from "react-beautiful-dnd";
|
||||
import { DragDropContext } from "react-beautiful-dnd";
|
||||
import keydown from "react-keydown";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
|
||||
@@ -16,8 +17,28 @@ import CollectionLink from "./CollectionLink";
|
||||
import CollectionsLoading from "./CollectionsLoading";
|
||||
import Header from "./Header";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import {
|
||||
DROPPABLE_COLLECTION_SUFFIX,
|
||||
DROPPABLE_DOCUMENT_SUFFIX,
|
||||
DROPPABLE_DOCUMENT_SEPARATOR,
|
||||
} from "utils/dnd";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type SidebarDnDContextObject = {
|
||||
isDragging: boolean,
|
||||
draggingDocumentId?: string,
|
||||
};
|
||||
|
||||
const initialSidebarDnDContextValue: SidebarDnDContextObject = {
|
||||
isDragging: false,
|
||||
draggingDocumentId: undefined,
|
||||
};
|
||||
|
||||
//$FlowFixMe
|
||||
export const SidebarDnDContext = React.createContext(
|
||||
initialSidebarDnDContextValue
|
||||
);
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
policies: PoliciesStore,
|
||||
@@ -25,11 +46,18 @@ type Props = {
|
||||
documents: DocumentsStore,
|
||||
onCreateCollection: () => void,
|
||||
ui: UiStore,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
type State = {
|
||||
draggingDocumentId?: string,
|
||||
isDragging: boolean,
|
||||
};
|
||||
|
||||
@observer
|
||||
class Collections extends React.Component<Props> {
|
||||
class Collections extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
isDragging: false,
|
||||
};
|
||||
isPreloaded: boolean = !!this.props.collections.orderedData.length;
|
||||
|
||||
componentDidMount() {
|
||||
@@ -53,34 +81,152 @@ class Collections extends React.Component<Props> {
|
||||
this.props.history.push(newDocumentUrl(activeCollectionId));
|
||||
}
|
||||
|
||||
getDroppableIdParts(droppableId: string) {
|
||||
let collection, parentDocumentId;
|
||||
const { collections } = this.props;
|
||||
|
||||
if (droppableId.indexOf(DROPPABLE_COLLECTION_SUFFIX) === 0) {
|
||||
collection = collections.get(
|
||||
droppableId.substring(DROPPABLE_COLLECTION_SUFFIX.length)
|
||||
);
|
||||
} else if (
|
||||
droppableId.indexOf(DROPPABLE_DOCUMENT_SUFFIX) === 0 &&
|
||||
droppableId.indexOf(DROPPABLE_DOCUMENT_SEPARATOR)
|
||||
) {
|
||||
const [documentId, collectionId] = droppableId
|
||||
.substring(DROPPABLE_DOCUMENT_SUFFIX.length)
|
||||
.split(DROPPABLE_DOCUMENT_SEPARATOR);
|
||||
|
||||
parentDocumentId = documentId;
|
||||
|
||||
collection = collections.get(collectionId);
|
||||
}
|
||||
|
||||
return {
|
||||
collection,
|
||||
parentDocumentId,
|
||||
};
|
||||
}
|
||||
|
||||
handleBeforeCapture = (before: BeforeCapture) => {
|
||||
this.setState({
|
||||
isDragging: true,
|
||||
draggingDocumentId: before.draggableId,
|
||||
});
|
||||
};
|
||||
|
||||
reorder = (result: DropResult) => {
|
||||
this.setState({
|
||||
isDragging: false,
|
||||
draggingDocumentId: undefined,
|
||||
});
|
||||
|
||||
// Bail out early if result doesn't have a destination or combine data
|
||||
if (!result.destination && !result.combine) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bail out early if no changes
|
||||
if (
|
||||
(result.destination &&
|
||||
result.destination.droppableId === result.source.droppableId &&
|
||||
result.destination.index === result.source.index) ||
|
||||
(result.combine && result.combine.draggableId === result.draggableId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { documents } = this.props;
|
||||
const document = documents.get(result.draggableId);
|
||||
let collection,
|
||||
parentDocumentId,
|
||||
index = 0;
|
||||
|
||||
// Bail out if document doesn't exist
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.destination) {
|
||||
index = result.destination.index;
|
||||
const droppableId = result.destination.droppableId;
|
||||
const parts = this.getDroppableIdParts(droppableId);
|
||||
|
||||
collection = parts.collection;
|
||||
parentDocumentId = parts.parentDocumentId;
|
||||
} else if (result.combine) {
|
||||
const { draggableId, droppableId } = result.combine;
|
||||
const parts = this.getDroppableIdParts(droppableId);
|
||||
|
||||
collection = parts.collection;
|
||||
parentDocumentId = draggableId;
|
||||
}
|
||||
|
||||
// Bail out if collection doesn't exist
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parentDocumentId) {
|
||||
// Bail out if moving document to itself
|
||||
if (parentDocumentId === document.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentDocument = documents.get(parentDocumentId);
|
||||
|
||||
// Bail out if parent document doesn't exist
|
||||
if (!parentDocument) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
documents.move(document, collection.id, parentDocumentId, index);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collections, ui, policies, documents, t } = this.props;
|
||||
const { collections, ui, policies, documents } = this.props;
|
||||
const { draggingDocumentId, isDragging } = this.state;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{collections.orderedData.map((collection) => (
|
||||
<CollectionLink
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
canUpdate={policies.abilities(collection.id).update}
|
||||
ui={ui}
|
||||
/>
|
||||
))}
|
||||
<React.Fragment>
|
||||
<DragDropContext
|
||||
onBeforeCapture={this.handleBeforeCapture}
|
||||
onDragEnd={this.reorder}
|
||||
>
|
||||
<SidebarDnDContext.Provider
|
||||
value={{
|
||||
draggingDocumentId,
|
||||
isDragging,
|
||||
}}
|
||||
>
|
||||
{collections.orderedData.map((collection) => (
|
||||
<CollectionLink
|
||||
key={collection.id}
|
||||
documents={documents}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
canUpdate={policies.abilities(collection.id).update}
|
||||
ui={ui}
|
||||
/>
|
||||
))}
|
||||
<div id="sidebar-collections-portal" />
|
||||
</SidebarDnDContext.Provider>
|
||||
</DragDropContext>
|
||||
<SidebarLink
|
||||
to="/collections"
|
||||
onClick={this.props.onCreateCollection}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label={`${t("New collection")}…`}
|
||||
label="New collection…"
|
||||
exact
|
||||
/>
|
||||
</>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<Header>{t("Collections")}</Header>
|
||||
<Header>Collections</Header>
|
||||
{collections.isLoaded ? (
|
||||
this.isPreloaded ? (
|
||||
content
|
||||
@@ -95,6 +241,9 @@ class Collections extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation()<Collections>(
|
||||
inject("collections", "ui", "documents", "policies")(withRouter(Collections))
|
||||
);
|
||||
export default inject(
|
||||
"collections",
|
||||
"ui",
|
||||
"documents",
|
||||
"policies"
|
||||
)(withRouter(Collections));
|
||||
|
||||
@@ -1,291 +1,186 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import { observable } from "mobx";
|
||||
import { observer, Observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
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 DropCursor from "./DropCursor";
|
||||
import Flex from "components/Flex";
|
||||
import { SidebarDnDContext } from "./Collections";
|
||||
import Draggable from "./Draggable";
|
||||
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,
|
||||
collection: Collection,
|
||||
canUpdate: boolean,
|
||||
collection?: Collection,
|
||||
collection: Collection,
|
||||
index?: number,
|
||||
activeDocument: ?Document,
|
||||
activeDocumentRef?: (?HTMLElement) => void,
|
||||
prefetchDocument: (documentId: string) => Promise<void>,
|
||||
depth: number,
|
||||
index: number,
|
||||
parentId?: string,
|
||||
isDropDisabled?: boolean,
|
||||
|};
|
||||
|
||||
function DocumentLink({
|
||||
node,
|
||||
canUpdate,
|
||||
collection,
|
||||
activeDocument,
|
||||
prefetchDocument,
|
||||
depth,
|
||||
index,
|
||||
parentId,
|
||||
}: Props) {
|
||||
const { documents, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
@observer
|
||||
class DocumentLink extends React.Component<Props> {
|
||||
@observable menuOpen = false;
|
||||
|
||||
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);
|
||||
componentDidMount() {
|
||||
if (this.isActiveDocument() && this.hasChildDocuments()) {
|
||||
this.props.documents.fetchChildDocuments(this.props.node.id);
|
||||
}
|
||||
}, [fetchChildDocuments, node, hasChildDocuments, isActiveDocument]);
|
||||
}
|
||||
|
||||
const pathToNode = React.useMemo(
|
||||
() =>
|
||||
collection && collection.pathToDocument(node.id).map((entry) => entry.id),
|
||||
[collection, node]
|
||||
);
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (prevProps.activeDocument !== this.props.activeDocument) {
|
||||
if (this.isActiveDocument() && this.hasChildDocuments()) {
|
||||
this.props.documents.fetchChildDocuments(this.props.node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const showChildren = React.useMemo(() => {
|
||||
return !!(
|
||||
hasChildDocuments &&
|
||||
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,
|
||||
index,
|
||||
isDropDisabled,
|
||||
canUpdate,
|
||||
} = this.props;
|
||||
|
||||
const showChildren = !!(
|
||||
activeDocument &&
|
||||
collection &&
|
||||
(collection
|
||||
.pathToDocument(activeDocument.id)
|
||||
.pathToDocument(activeDocument)
|
||||
.map((entry) => entry.id)
|
||||
.includes(node.id) ||
|
||||
isActiveDocument)
|
||||
this.isActiveDocument())
|
||||
);
|
||||
}, [hasChildDocuments, activeDocument, isActiveDocument, node, collection]);
|
||||
const document = documents.get(node.id);
|
||||
const title = node.title || "Untitled";
|
||||
|
||||
const [expanded, setExpanded] = React.useState(showChildren);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (showChildren) {
|
||||
setExpanded(showChildren);
|
||||
let hideDisclosure;
|
||||
if (!this.hasChildDocuments()) {
|
||||
hideDisclosure = true;
|
||||
}
|
||||
}, [showChildren]);
|
||||
|
||||
// when the last child document is removed,
|
||||
// also close the local folder state to closed
|
||||
React.useEffect(() => {
|
||||
if (expanded && !hasChildDocuments) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [expanded, hasChildDocuments]);
|
||||
return (
|
||||
<>
|
||||
{/* <DropToImport documentId={node.id} activeClassName="activeDropZone"> */}
|
||||
<SidebarDnDContext.Consumer>
|
||||
{({ draggingDocumentId, isDragging }) => {
|
||||
const disableChildDrops =
|
||||
isDropDisabled || draggingDocumentId === node.id;
|
||||
|
||||
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);
|
||||
const isMoving = documents.movingDocumentId === node.id;
|
||||
const manualSort = collection?.sort.field === "index";
|
||||
|
||||
// Draggable
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
item: { type: "document", ...node, depth, active: isActiveDocument },
|
||||
collect: (monitor) => ({
|
||||
isDragging: !!monitor.isDragging(),
|
||||
}),
|
||||
canDrag: (monitor) => {
|
||||
return policies.abilities(node.id).move;
|
||||
},
|
||||
});
|
||||
|
||||
const hoverExpanding = React.useRef(null);
|
||||
|
||||
// We set a timeout when the user first starts hovering over the document link,
|
||||
// to trigger expansion of children. Clear this timeout when they stop hovering.
|
||||
const resetHoverExpanding = React.useCallback(() => {
|
||||
if (hoverExpanding.current) {
|
||||
clearTimeout(hoverExpanding.current);
|
||||
hoverExpanding.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Drop to re-parent
|
||||
const [{ isOverReparent, canDropToReparent }, dropToReparent] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item, monitor) => {
|
||||
if (monitor.didDrop()) return;
|
||||
if (!collection) return;
|
||||
documents.move(item.id, collection.id, node.id);
|
||||
},
|
||||
|
||||
canDrop: (item, monitor) =>
|
||||
pathToNode && !pathToNode.includes(monitor.getItem().id),
|
||||
|
||||
hover: (item, monitor) => {
|
||||
// Enables expansion of document children when hovering over the document
|
||||
// for more than half a second.
|
||||
if (
|
||||
hasChildDocuments &&
|
||||
monitor.canDrop() &&
|
||||
monitor.isOver({ shallow: true })
|
||||
) {
|
||||
if (!hoverExpanding.current) {
|
||||
hoverExpanding.current = setTimeout(() => {
|
||||
hoverExpanding.current = null;
|
||||
if (monitor.isOver({ shallow: true })) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
collect: (monitor) => ({
|
||||
isOverReparent: !!monitor.isOver({ shallow: true }),
|
||||
canDropToReparent: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Drop to reorder
|
||||
const [{ isOverReorder }, dropToReorder] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item, monitor) => {
|
||||
if (!collection) return;
|
||||
if (item.id === node.id) return;
|
||||
|
||||
if (expanded) {
|
||||
documents.move(item.id, collection.id, node.id, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
documents.move(item.id, collection.id, parentId, index + 1);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: !!monitor.isOver(),
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ position: "relative" }} onDragLeave={resetHoverExpanding}>
|
||||
<Draggable
|
||||
key={node.id}
|
||||
ref={drag}
|
||||
$isDragging={isDragging}
|
||||
$isMoving={isMoving}
|
||||
>
|
||||
<div ref={dropToReparent}>
|
||||
<DropToImport documentId={node.id} activeClassName="activeDropZone">
|
||||
return (
|
||||
<SidebarLink
|
||||
onMouseEnter={handleMouseEnter}
|
||||
to={{
|
||||
pathname: node.url,
|
||||
state: { title: node.title },
|
||||
}}
|
||||
expanded={showChildren ? true : undefined}
|
||||
hideDisclosure={hideDisclosure}
|
||||
label={
|
||||
<>
|
||||
{hasChildDocuments && (
|
||||
<Disclosure
|
||||
expanded={expanded && !isDragging}
|
||||
onClick={handleDisclosureClick}
|
||||
/>
|
||||
)}
|
||||
<EditableTitle
|
||||
title={node.title || t("Untitled")}
|
||||
onSubmit={handleTitleChange}
|
||||
canUpdate={canUpdate}
|
||||
/>
|
||||
</>
|
||||
<EditableTitle
|
||||
title={title}
|
||||
onSubmit={this.handleTitleChange}
|
||||
canUpdate={canUpdate}
|
||||
/>
|
||||
}
|
||||
isActiveDrop={isOverReparent && canDropToReparent}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
showActions={menuOpen}
|
||||
menuOpen={this.menuOpen}
|
||||
menu={
|
||||
document && !isMoving ? (
|
||||
document ? (
|
||||
<Fade>
|
||||
<DocumentMenu
|
||||
position="right"
|
||||
document={document}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
onOpen={() => (this.menuOpen = true)}
|
||||
onClose={() => (this.menuOpen = false)}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</DropToImport>
|
||||
</div>
|
||||
</Draggable>
|
||||
{manualSort && (
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
)}
|
||||
</div>
|
||||
{expanded && !isDragging && (
|
||||
<>
|
||||
{node.children.map((childNode, index) => (
|
||||
<ObservedDocumentLink
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
node={childNode}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
depth={depth + 1}
|
||||
canUpdate={canUpdate}
|
||||
index={index}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
index={index}
|
||||
draggableId={node.id}
|
||||
>
|
||||
{this.hasChildDocuments() && !disableChildDrops && (
|
||||
<Observer>
|
||||
{() =>
|
||||
node.children.map((childNode, index) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
index={index}
|
||||
collection={collection}
|
||||
node={childNode}
|
||||
documents={documents}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
canUpdate={canUpdate}
|
||||
depth={depth + 1}
|
||||
isDropDisabled={disableChildDrops}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</Observer>
|
||||
)}
|
||||
</SidebarLink>
|
||||
);
|
||||
}}
|
||||
</SidebarDnDContext.Consumer>
|
||||
{/* </DropToImport> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Draggable = styled("div")`
|
||||
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)};
|
||||
pointer-events: ${(props) => (props.$isMoving ? "none" : "all")};
|
||||
`;
|
||||
const DocumentChildren = styled(Flex)``;
|
||||
|
||||
const Disclosure = styled(CollapsedIcon)`
|
||||
position: absolute;
|
||||
left: -24px;
|
||||
|
||||
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
|
||||
`;
|
||||
|
||||
const ObservedDocumentLink = observer(DocumentLink);
|
||||
export default ObservedDocumentLink;
|
||||
export default DocumentLink;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { Draggable as DnDDraggable } from "react-beautiful-dnd";
|
||||
import type {
|
||||
DraggableProvided,
|
||||
DraggableStateSnapshot,
|
||||
} from "react-beautiful-dnd";
|
||||
import ReactDOM from "react-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
|
||||
type Props = {
|
||||
draggableId: string,
|
||||
index: number,
|
||||
children: React.Node,
|
||||
};
|
||||
|
||||
type InnerProps = {
|
||||
provided: DraggableProvided,
|
||||
snapshot: DraggableStateSnapshot,
|
||||
children: React.Node,
|
||||
};
|
||||
|
||||
class Inner extends React.Component<InnerProps> {
|
||||
render() {
|
||||
const { provided, snapshot, children } = this.props;
|
||||
|
||||
const child = (
|
||||
<DropContainer
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
isDraggingOver={Boolean(snapshot.combineTargetFor)}
|
||||
>
|
||||
{children}
|
||||
</DropContainer>
|
||||
);
|
||||
|
||||
const portalContainer = document.getElementById(
|
||||
"sidebar-collections-portal"
|
||||
);
|
||||
|
||||
if (snapshot.isDragging && portalContainer) {
|
||||
return ReactDOM.createPortal(child, portalContainer);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
class Draggable extends React.Component<Props> {
|
||||
render() {
|
||||
const { draggableId, index, children } = this.props;
|
||||
|
||||
return (
|
||||
<DnDDraggable draggableId={draggableId} index={index}>
|
||||
{(provided, snapshot) => children}
|
||||
</DnDDraggable>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const DropContainer = styled.div((props) => ({
|
||||
backgroundColor: props.isDraggingOver
|
||||
? props.theme.sidebarDroppableBackground
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
export default withTheme(Draggable);
|
||||
@@ -1,42 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import { type Theme } from "types";
|
||||
|
||||
function DropCursor({
|
||||
isActiveDrop,
|
||||
innerRef,
|
||||
theme,
|
||||
}: {
|
||||
isActiveDrop: boolean,
|
||||
innerRef: React.Ref<any>,
|
||||
theme: Theme,
|
||||
}) {
|
||||
return <Cursor isOver={isActiveDrop} ref={innerRef} />;
|
||||
}
|
||||
|
||||
// transparent hover zone with a thin visible band vertically centered
|
||||
const Cursor = styled("div")`
|
||||
opacity: ${(props) => (props.isOver ? 1 : 0)};
|
||||
transition: opacity 150ms;
|
||||
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
bottom: -7px;
|
||||
background: transparent;
|
||||
|
||||
::after {
|
||||
background: ${(props) => props.theme.slateDark};
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
content: "";
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
export default withTheme(DropCursor);
|
||||
@@ -0,0 +1,63 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { Droppable as DnDDroppable } from "react-beautiful-dnd";
|
||||
import type {
|
||||
DroppableProvided,
|
||||
DroppableStateSnapshot,
|
||||
} from "react-beautiful-dnd";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import {
|
||||
DROPPABLE_COLLECTION_SUFFIX,
|
||||
DROPPABLE_DOCUMENT_SUFFIX,
|
||||
DROPPABLE_DOCUMENT_SEPARATOR,
|
||||
} from "utils/dnd";
|
||||
|
||||
type Props = {
|
||||
collectionId: string,
|
||||
documentId?: string,
|
||||
isDropDisabled?: boolean,
|
||||
children(
|
||||
provided: DroppableProvided,
|
||||
snapshot: DroppableStateSnapshot
|
||||
): React.Node,
|
||||
};
|
||||
|
||||
class Droppable extends React.Component<Props> {
|
||||
static defaultProps = {
|
||||
isDropDisabled: false,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collectionId, documentId, isDropDisabled, children } = this.props;
|
||||
let droppableId;
|
||||
|
||||
if (documentId) {
|
||||
droppableId = `${DROPPABLE_DOCUMENT_SUFFIX}${documentId}${DROPPABLE_DOCUMENT_SEPARATOR}${collectionId}`;
|
||||
} else {
|
||||
droppableId = `${DROPPABLE_COLLECTION_SUFFIX}${collectionId}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<DnDDroppable droppableId={droppableId} isDropDisabled={isDropDisabled}>
|
||||
{(provided, snapshot) => (
|
||||
<DropContainer
|
||||
isDraggingOver={snapshot.isDraggingOver}
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
{children(provided, snapshot)}
|
||||
{provided.placeholder}
|
||||
</DropContainer>
|
||||
)}
|
||||
</DnDDroppable>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const DropContainer = styled.div((props) => ({
|
||||
backgroundColor: props.isDraggingOver
|
||||
? props.theme.sidebarDroppableBackground
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
export default withTheme(Droppable);
|
||||
@@ -9,7 +9,7 @@ type Props = {|
|
||||
canUpdate: boolean,
|
||||
|};
|
||||
|
||||
function EditableTitle({ title, onSubmit, canUpdate }: Props) {
|
||||
function EditableTitle({ title, onSubmit, canUpdate, ...rest }: Props) {
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [originalValue, setOriginalValue] = React.useState(title);
|
||||
const [value, setValue] = React.useState(title);
|
||||
@@ -52,9 +52,7 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
|
||||
setOriginalValue(value);
|
||||
} catch (error) {
|
||||
setValue(originalValue);
|
||||
ui.showToast(error.message, {
|
||||
type: "error",
|
||||
});
|
||||
ui.showToast(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -84,7 +82,6 @@ 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;
|
||||
|
||||
@@ -5,35 +5,33 @@ import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
import TeamLogo from "components/TeamLogo";
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
teamName: string,
|
||||
subheading: React.Node,
|
||||
showDisclosure?: boolean,
|
||||
onClick: (event: SyntheticEvent<>) => void,
|
||||
logoUrl: string,
|
||||
|};
|
||||
};
|
||||
|
||||
const HeaderBlock = React.forwardRef<Props, any>(
|
||||
({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => (
|
||||
<Wrapper>
|
||||
<Header justify="flex-start" align="center" ref={ref} {...rest}>
|
||||
<TeamLogo
|
||||
alt={`${teamName} logo`}
|
||||
src={logoUrl}
|
||||
width={38}
|
||||
height={38}
|
||||
/>
|
||||
<Flex align="flex-start" column>
|
||||
<TeamName showDisclosure>
|
||||
{teamName}{" "}
|
||||
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
|
||||
</TeamName>
|
||||
<Subheading>{subheading}</Subheading>
|
||||
</Flex>
|
||||
</Header>
|
||||
</Wrapper>
|
||||
)
|
||||
);
|
||||
function HeaderBlock({
|
||||
showDisclosure,
|
||||
teamName,
|
||||
subheading,
|
||||
logoUrl,
|
||||
...rest
|
||||
}: Props) {
|
||||
return (
|
||||
<Header justify="flex-start" align="center" {...rest}>
|
||||
<TeamLogo alt={`${teamName} logo`} src={logoUrl} size="38px" />
|
||||
<Flex align="flex-start" column>
|
||||
<TeamName showDisclosure>
|
||||
{teamName}{" "}
|
||||
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
|
||||
</TeamName>
|
||||
<Subheading>{subheading}</Subheading>
|
||||
</Flex>
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledExpandedIcon = styled(ExpandedIcon)`
|
||||
position: absolute;
|
||||
@@ -46,7 +44,6 @@ const Subheading = styled.div`
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
`;
|
||||
|
||||
@@ -56,20 +53,16 @@ const TeamName = styled.div`
|
||||
padding-right: 24px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Header = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
flex-shrink: 0;
|
||||
padding: 16px 24px;
|
||||
position: relative;
|
||||
background: none;
|
||||
line-height: inherit;
|
||||
border: 0;
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
// @flow
|
||||
// ref: https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/NavLink.js
|
||||
|
||||
// This file is pulled almost 100% from react-router with the addition of one
|
||||
// thing, automatic scroll to the active link. It's worth the copy paste because
|
||||
// it avoids recalculating the link match again.
|
||||
import { createLocation } from "history";
|
||||
import * as React from "react";
|
||||
import {
|
||||
__RouterContext as RouterContext,
|
||||
matchPath,
|
||||
type Location,
|
||||
} from "react-router";
|
||||
import { Link } from "react-router-dom";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
|
||||
const resolveToLocation = (to, currentLocation) =>
|
||||
typeof to === "function" ? to(currentLocation) : to;
|
||||
|
||||
const normalizeToLocation = (to, currentLocation) => {
|
||||
return typeof to === "string"
|
||||
? createLocation(to, null, null, currentLocation)
|
||||
: to;
|
||||
};
|
||||
|
||||
const joinClassnames = (...classnames) => {
|
||||
return classnames.filter((i) => i).join(" ");
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
activeClassName?: String,
|
||||
activeStyle?: Object,
|
||||
className?: string,
|
||||
exact?: boolean,
|
||||
isActive?: any,
|
||||
location?: Location,
|
||||
strict?: boolean,
|
||||
style?: Object,
|
||||
to: string,
|
||||
|};
|
||||
|
||||
/**
|
||||
* A <Link> wrapper that knows if it's "active" or not.
|
||||
*/
|
||||
const NavLink = ({
|
||||
"aria-current": ariaCurrent = "page",
|
||||
activeClassName = "active",
|
||||
activeStyle,
|
||||
className: classNameProp,
|
||||
exact,
|
||||
isActive: isActiveProp,
|
||||
location: locationProp,
|
||||
strict,
|
||||
style: styleProp,
|
||||
to,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const linkRef = React.useRef();
|
||||
const context = React.useContext(RouterContext);
|
||||
const currentLocation = locationProp || context.location;
|
||||
const toLocation = normalizeToLocation(
|
||||
resolveToLocation(to, currentLocation),
|
||||
currentLocation
|
||||
);
|
||||
const { pathname: path } = toLocation;
|
||||
// Regex taken from: https://github.com/pillarjs/path-to-regexp/blob/master/index.js#L202
|
||||
const escapedPath = path && path.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1");
|
||||
|
||||
const match = escapedPath
|
||||
? matchPath(currentLocation.pathname, {
|
||||
path: escapedPath,
|
||||
exact,
|
||||
strict,
|
||||
})
|
||||
: null;
|
||||
const isActive = !!(isActiveProp
|
||||
? isActiveProp(match, currentLocation)
|
||||
: match);
|
||||
|
||||
const className = isActive
|
||||
? joinClassnames(classNameProp, activeClassName)
|
||||
: classNameProp;
|
||||
const style = isActive ? { ...styleProp, ...activeStyle } : styleProp;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isActive && linkRef.current) {
|
||||
scrollIntoView(linkRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "instant",
|
||||
});
|
||||
}
|
||||
}, [linkRef, isActive]);
|
||||
|
||||
const props = {
|
||||
"aria-current": (isActive && ariaCurrent) || null,
|
||||
className,
|
||||
style,
|
||||
to: toLocation,
|
||||
...rest,
|
||||
};
|
||||
|
||||
return <Link ref={linkRef} {...props} />;
|
||||
};
|
||||
|
||||
export default NavLink;
|
||||
@@ -1,28 +0,0 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
import ResizeHandle from "./ResizeHandle";
|
||||
|
||||
const ResizeBorder = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: -6px;
|
||||
width: 12px;
|
||||
cursor: ew-resize;
|
||||
|
||||
${(props) =>
|
||||
props.$isResizing &&
|
||||
`
|
||||
${ResizeHandle} {
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
|
||||
&:hover {
|
||||
${ResizeHandle} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default ResizeBorder;
|
||||
@@ -1,39 +0,0 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
const ResizeHandle = styled.button`
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
transform: translateY(-50%);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
height: 40px;
|
||||
right: -10px;
|
||||
width: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -24px;
|
||||
bottom: -24px;
|
||||
left: -12px;
|
||||
right: -12px;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: ${(props) => props.theme.sidebarText};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
pointer-events: all;
|
||||
cursor: ew-resize;
|
||||
`}
|
||||
`;
|
||||
|
||||
export default ResizeHandle;
|
||||
@@ -5,9 +5,7 @@ import Flex from "components/Flex";
|
||||
const Section = styled(Flex)`
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
margin: 20px 8px;
|
||||
min-width: ${(props) => props.theme.sidebarMinWidth}px;
|
||||
flex-shrink: 0;
|
||||
margin: 24px 8px;
|
||||
`;
|
||||
|
||||
export default Section;
|
||||
|
||||
@@ -1,30 +1,28 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
|
||||
import { Draggable as DnDDraggable } from "react-beautiful-dnd";
|
||||
import { withRouter, NavLink } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import EventBoundary from "components/EventBoundary";
|
||||
import NavLink from "./NavLink";
|
||||
import { type Theme } from "types";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
type Props = {
|
||||
to?: string | Object,
|
||||
href?: string | Object,
|
||||
innerRef?: (?HTMLElement) => void,
|
||||
onClick?: (SyntheticEvent<>) => void,
|
||||
onMouseEnter?: (SyntheticEvent<>) => void,
|
||||
className?: string,
|
||||
children?: React.Node,
|
||||
icon?: React.Node,
|
||||
expanded?: boolean,
|
||||
label?: React.Node,
|
||||
menu?: React.Node,
|
||||
showActions?: boolean,
|
||||
menuOpen?: boolean,
|
||||
hideDisclosure?: boolean,
|
||||
draggableId?: string,
|
||||
index?: number,
|
||||
iconColor?: string,
|
||||
active?: boolean,
|
||||
isActiveDrop?: boolean,
|
||||
history: RouterHistory,
|
||||
match: Match,
|
||||
theme: Theme,
|
||||
theme: Object,
|
||||
exact?: boolean,
|
||||
depth?: number,
|
||||
};
|
||||
@@ -33,55 +31,94 @@ function SidebarLink({
|
||||
icon,
|
||||
children,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
to,
|
||||
label,
|
||||
active,
|
||||
isActiveDrop,
|
||||
menu,
|
||||
showActions,
|
||||
menuOpen,
|
||||
hideDisclosure,
|
||||
draggableId,
|
||||
index,
|
||||
theme,
|
||||
exact,
|
||||
href,
|
||||
depth,
|
||||
history,
|
||||
match,
|
||||
className,
|
||||
...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 = {
|
||||
fontWeight: 600,
|
||||
color: theme.text,
|
||||
background: theme.sidebarItemBackground,
|
||||
fontWeight: 600,
|
||||
...style,
|
||||
};
|
||||
|
||||
const activeDropStyle = {
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
$isActiveDrop={isActiveDrop}
|
||||
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
|
||||
const linkElement = (props) => (
|
||||
<StyledNavLink
|
||||
activeStyle={activeStyle}
|
||||
style={active ? activeStyle : style}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
exact={exact !== false}
|
||||
to={to}
|
||||
as={to ? undefined : href ? "a" : "div"}
|
||||
href={href}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label>{label}</Label>
|
||||
{menu && <Actions showActions={showActions}>{menu}</Actions>}
|
||||
</Link>
|
||||
<Label onClick={handleExpand}>
|
||||
{showDisclosure && (
|
||||
<Disclosure expanded={expanded} onClick={handleClick} />
|
||||
)}
|
||||
{label}
|
||||
</Label>
|
||||
{menu && <Action menuOpen={menuOpen}>{menu}</Action>}
|
||||
</StyledNavLink>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{draggableId ? (
|
||||
<DnDDraggable draggableId={draggableId} index={index}>
|
||||
{(provided, snapshot) =>
|
||||
linkElement({
|
||||
innerRef: provided.innerRef,
|
||||
...provided.draggableProps,
|
||||
...provided.dragHandleProps,
|
||||
})
|
||||
}
|
||||
</DnDDraggable>
|
||||
) : (
|
||||
linkElement()
|
||||
)}
|
||||
{expanded && children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,54 +127,39 @@ const IconWrapper = styled.span`
|
||||
margin-left: -4px;
|
||||
margin-right: 4px;
|
||||
height: 24px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Actions = styled(EventBoundary)`
|
||||
display: ${(props) => (props.showActions ? "inline-flex" : "none")};
|
||||
const Action = styled.span`
|
||||
display: ${(props) => (props.menuOpen ? "inline" : "none")};
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
transition: opacity 50ms;
|
||||
|
||||
svg {
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
fill: currentColor;
|
||||
opacity: 0.5;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Link = styled(NavLink)`
|
||||
const StyledNavLink = styled(NavLink)`
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 6px 16px;
|
||||
padding: 4px 16px;
|
||||
border-radius: 4px;
|
||||
transition: background 50ms, color 50ms;
|
||||
background: ${(props) =>
|
||||
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
|
||||
color: ${(props) =>
|
||||
props.$isActiveDrop ? props.theme.white : props.theme.sidebarText};
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
svg {
|
||||
${(props) => (props.$isActiveDrop ? `fill: ${props.theme.white};` : "")}
|
||||
transition: fill 50ms;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) =>
|
||||
props.$isActiveDrop ? props.theme.white : props.theme.text};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
@@ -145,20 +167,15 @@ const Link = styled(NavLink)`
|
||||
background: ${(props) => props.theme.black05};
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
> ${Actions} {
|
||||
display: inline-flex;
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
}
|
||||
&:hover {
|
||||
> ${Action} {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px 16px;
|
||||
`}
|
||||
const Wrapper = styled(Flex)`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Label = styled.div`
|
||||
@@ -168,4 +185,15 @@ const Label = styled.div`
|
||||
line-height: 1.6;
|
||||
`;
|
||||
|
||||
export default withRouter(withTheme(SidebarLink));
|
||||
const Disclosure = styled(CollapsedIcon)`
|
||||
position: absolute;
|
||||
left: -24px;
|
||||
|
||||
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
|
||||
`;
|
||||
|
||||
const ChildrenWrapper = styled.div(({ expanded }) => ({
|
||||
display: expanded ? "block" : "none",
|
||||
}));
|
||||
|
||||
export default withRouter(withTheme(observer(SidebarLink)));
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
export const id = "skip-nav";
|
||||
|
||||
export default function SkipNavContent() {
|
||||
return <div id={id} />;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { id } from "components/SkipNavContent";
|
||||
|
||||
export default function SkipNavLink() {
|
||||
return <Anchor href={`#${id}`}>Skip navigation</Anchor>;
|
||||
}
|
||||
|
||||
const Anchor = styled.a`
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
&:focus {
|
||||
padding: 1rem;
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
background: ${(props) => props.theme.background};
|
||||
color: ${(props) => props.theme.text};
|
||||
outline-color: ${(props) => props.theme.primary};
|
||||
z-index: ${(props) => props.theme.depths.popover};
|
||||
width: auto;
|
||||
height: auto;
|
||||
clip: auto;
|
||||
}
|
||||
`;
|
||||
@@ -110,9 +110,7 @@ class SocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on("unauthorized", (err) => {
|
||||
this.socket.authenticated = false;
|
||||
ui.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
ui.showToast(err.message);
|
||||
throw err;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// @flow
|
||||
import { StarredIcon, UnstarredIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import NudeButton from "./NudeButton";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
size?: number,
|
||||
|};
|
||||
|
||||
function Star({ size, document, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const handleClick = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (document.isStarred) {
|
||||
document.unstar();
|
||||
} else {
|
||||
document.star();
|
||||
}
|
||||
},
|
||||
[document]
|
||||
);
|
||||
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
size={size}
|
||||
aria-label={document.isStarred ? t("Unstar") : t("Star")}
|
||||
{...rest}
|
||||
>
|
||||
{document.isStarred ? (
|
||||
<AnimatedStar size={size} color="currentColor" />
|
||||
) : (
|
||||
<AnimatedStar size={size} color="currentColor" as={UnstarredIcon} />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const Button = styled(NudeButton)`
|
||||
color: ${(props) => props.theme.text};
|
||||
`;
|
||||
|
||||
export const AnimatedStar = styled(StarredIcon)`
|
||||
flex-shrink: 0;
|
||||
transition: all 100ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
`;
|
||||
|
||||
export default Star;
|
||||
@@ -11,11 +11,12 @@ const H3 = styled.h3`
|
||||
margin-top: 22px;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Underline = styled("span")`
|
||||
margin-top: -1px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
|
||||
@@ -3,15 +3,12 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { LabelText } from "components/Input";
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
width?: number,
|
||||
height?: number,
|
||||
label?: string,
|
||||
checked?: boolean,
|
||||
disabled?: boolean,
|
||||
onChange: (event: SyntheticInputEvent<HTMLInputElement>) => mixed,
|
||||
id?: string,
|
||||
|};
|
||||
};
|
||||
|
||||
function Switch({ width = 38, height = 20, label, ...props }: Props) {
|
||||
const component = (
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
import * as React from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import { type Theme } from "types";
|
||||
|
||||
type Props = {
|
||||
theme: Theme,
|
||||
theme: Object,
|
||||
};
|
||||
|
||||
const StyledNavLink = styled(NavLink)`
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
const TeamLogo = styled.img`
|
||||
width: ${(props) =>
|
||||
props.width ? `${props.width}px` : props.size || "auto"};
|
||||
height: ${(props) =>
|
||||
props.height ? `${props.height}px` : props.size || "38px"};
|
||||
width: ${(props) => props.size || "auto"};
|
||||
height: ${(props) => props.size || "38px"};
|
||||
border-radius: 4px;
|
||||
background: ${(props) => props.theme.background};
|
||||
border: 1px solid ${(props) => props.theme.divider};
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default TeamLogo;
|
||||
|
||||
+52
-19
@@ -1,8 +1,24 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import format from "date-fns/format";
|
||||
import * as React from "react";
|
||||
import Tooltip from "components/Tooltip";
|
||||
|
||||
const LocaleTime = React.lazy(() => import("components/LocaleTime"));
|
||||
let callbacks = [];
|
||||
|
||||
// This is a shared timer that fires every minute, used for
|
||||
// updating all Time components across the page all at once.
|
||||
setInterval(() => {
|
||||
callbacks.forEach((cb) => cb());
|
||||
}, 1000 * 60);
|
||||
|
||||
function eachMinute(fn) {
|
||||
callbacks.push(fn);
|
||||
|
||||
return () => {
|
||||
callbacks = callbacks.filter((cb) => cb !== fn);
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
dateTime: string,
|
||||
@@ -12,27 +28,44 @@ type Props = {
|
||||
shorten?: boolean,
|
||||
};
|
||||
|
||||
function Time(props: Props) {
|
||||
let content = distanceInWordsToNow(props.dateTime, {
|
||||
addSuffix: props.addSuffix,
|
||||
});
|
||||
class Time extends React.Component<Props> {
|
||||
removeEachMinuteCallback: () => void;
|
||||
|
||||
if (props.shorten) {
|
||||
content = content
|
||||
.replace("about", "")
|
||||
.replace("less than a minute ago", "just now")
|
||||
.replace("minute", "min");
|
||||
componentDidMount() {
|
||||
this.removeEachMinuteCallback = eachMinute(() => {
|
||||
this.forceUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<time dateTime={props.dateTime}>{props.children || content}</time>
|
||||
}
|
||||
>
|
||||
<LocaleTime {...props} />
|
||||
</React.Suspense>
|
||||
);
|
||||
componentWillUnmount() {
|
||||
this.removeEachMinuteCallback();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { shorten, addSuffix } = this.props;
|
||||
let content = distanceInWordsToNow(this.props.dateTime, {
|
||||
addSuffix,
|
||||
});
|
||||
|
||||
if (shorten) {
|
||||
content = content
|
||||
.replace("about", "")
|
||||
.replace("less than a minute ago", "just now")
|
||||
.replace("minute", "min");
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
tooltip={format(this.props.dateTime, "MMMM Do, YYYY h:mm a")}
|
||||
delay={this.props.tooltipDelay}
|
||||
placement="bottom"
|
||||
>
|
||||
<time dateTime={this.props.dateTime}>
|
||||
{this.props.children || content}
|
||||
</time>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Time;
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import UiStore from "../../stores/UiStore";
|
||||
import Toast from "./components/Toast";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
function Toasts() {
|
||||
const { ui } = useStores();
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
};
|
||||
@observer
|
||||
class Toasts extends React.Component<Props> {
|
||||
render() {
|
||||
const { ui } = this.props;
|
||||
|
||||
return (
|
||||
<List>
|
||||
{ui.orderedToasts.map((toast) => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
toast={toast}
|
||||
onRequestClose={() => ui.removeToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
return (
|
||||
<List>
|
||||
{ui.orderedToasts.map((toast) => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
toast={toast}
|
||||
onRequestClose={() => ui.removeToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const List = styled.ol`
|
||||
@@ -31,4 +37,4 @@ const List = styled.ol`
|
||||
z-index: ${(props) => props.theme.depths.toasts};
|
||||
`;
|
||||
|
||||
export default observer(Toasts);
|
||||
export default inject("ui")(Toasts);
|
||||
|
||||
@@ -1,61 +1,58 @@
|
||||
// @flow
|
||||
import { CheckboxIcon, InfoIcon, WarningIcon } from "outline-icons";
|
||||
import { darken } from "polished";
|
||||
import * as React from "react";
|
||||
import styled, { css } from "styled-components";
|
||||
import { fadeAndScaleIn, pulse } from "shared/styles/animations";
|
||||
import styled from "styled-components";
|
||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||
import type { Toast as TToast } from "types";
|
||||
|
||||
type Props = {
|
||||
onRequestClose: () => void,
|
||||
closeAfterMs?: number,
|
||||
closeAfterMs: number,
|
||||
toast: TToast,
|
||||
};
|
||||
|
||||
function Toast({ closeAfterMs = 3000, onRequestClose, toast }: Props) {
|
||||
const timeout = React.useRef();
|
||||
const [pulse, setPulse] = React.useState(false);
|
||||
const { action, type = "info", reoccurring } = toast;
|
||||
class Toast extends React.Component<Props> {
|
||||
timeout: TimeoutID;
|
||||
|
||||
React.useEffect(() => {
|
||||
timeout.current = setTimeout(onRequestClose, toast.timeout || closeAfterMs);
|
||||
static defaultProps = {
|
||||
closeAfterMs: 3000,
|
||||
};
|
||||
|
||||
return () => clearTimeout(timeout.current);
|
||||
}, [onRequestClose, toast, closeAfterMs]);
|
||||
componentDidMount() {
|
||||
this.timeout = setTimeout(
|
||||
this.props.onRequestClose,
|
||||
this.props.toast.timeout || this.props.closeAfterMs
|
||||
);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (reoccurring) {
|
||||
setPulse(reoccurring);
|
||||
componentWillUnmount() {
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
|
||||
// must match animation time in css below vvv
|
||||
setTimeout(() => setPulse(false), 250);
|
||||
}
|
||||
}, [reoccurring]);
|
||||
render() {
|
||||
const { toast, onRequestClose } = this.props;
|
||||
const { action } = toast;
|
||||
const message =
|
||||
typeof toast.message === "string"
|
||||
? toast.message
|
||||
: toast.message.toString();
|
||||
|
||||
const message =
|
||||
typeof toast.message === "string"
|
||||
? toast.message
|
||||
: toast.message.toString();
|
||||
|
||||
return (
|
||||
<ListItem $pulse={pulse}>
|
||||
<Container
|
||||
onClick={action ? undefined : onRequestClose}
|
||||
type={toast.type || "success"}
|
||||
>
|
||||
{type === "info" && <InfoIcon color="currentColor" />}
|
||||
{type === "success" && <CheckboxIcon checked color="currentColor" />}
|
||||
{type === "warning" ||
|
||||
(type === "error" && <WarningIcon color="currentColor" />)}
|
||||
<Message>{message}</Message>
|
||||
{action && (
|
||||
<Action type={toast.type || "success"} onClick={action.onClick}>
|
||||
{action.text}
|
||||
</Action>
|
||||
)}
|
||||
</Container>
|
||||
</ListItem>
|
||||
);
|
||||
return (
|
||||
<li>
|
||||
<Container
|
||||
onClick={action ? undefined : onRequestClose}
|
||||
type={toast.type || "success"}
|
||||
>
|
||||
<Message>{message}</Message>
|
||||
{action && (
|
||||
<Action type={toast.type || "success"} onClick={action.onClick}>
|
||||
{action.text}
|
||||
</Action>
|
||||
)}
|
||||
</Container>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Action = styled.span`
|
||||
@@ -74,20 +71,11 @@ const Action = styled.span`
|
||||
}
|
||||
`;
|
||||
|
||||
const ListItem = styled.li`
|
||||
${(props) =>
|
||||
props.$pulse &&
|
||||
css`
|
||||
animation: ${pulse} 250ms;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Container = styled.div`
|
||||
display: inline-flex;
|
||||
display: inline-block;
|
||||
align-items: center;
|
||||
animation: ${fadeAndScaleIn} 100ms ease;
|
||||
margin: 8px 0;
|
||||
padding: 0 12px;
|
||||
color: ${(props) => props.theme.toastText};
|
||||
background: ${(props) => props.theme.toastBackground};
|
||||
font-size: 15px;
|
||||
@@ -101,8 +89,7 @@ const Container = styled.div`
|
||||
|
||||
const Message = styled.div`
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
padding: 10px 4px;
|
||||
padding: 10px 12px;
|
||||
`;
|
||||
|
||||
export default Toast;
|
||||
|
||||
@@ -3,14 +3,14 @@ import Tippy from "@tippy.js/react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
tooltip: React.Node,
|
||||
shortcut?: React.Node,
|
||||
placement?: "top" | "bottom" | "left" | "right",
|
||||
children: React.Node,
|
||||
delay?: number,
|
||||
className?: string,
|
||||
|};
|
||||
};
|
||||
|
||||
class Tooltip extends React.Component<Props> {
|
||||
render() {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
|
||||
const VisuallyHidden = styled("span")`
|
||||
position: absolute !important;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
`;
|
||||
|
||||
export default VisuallyHidden;
|
||||
@@ -1,6 +1,5 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import Image from "components/Image";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
const URL_REGEX = new RegExp("^https?://docs.google.com/document/(.*)$");
|
||||
@@ -21,7 +20,7 @@ export default class GoogleDocs extends React.Component<Props> {
|
||||
{...this.props}
|
||||
src={this.props.attrs.href.replace("/edit", "/preview")}
|
||||
icon={
|
||||
<Image
|
||||
<img
|
||||
src="/images/google-docs.png"
|
||||
alt="Google Docs Icon"
|
||||
width={16}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import Image from "components/Image";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
const URL_REGEX = new RegExp(
|
||||
"^https://docs.google.com/drawings/d/(.*)/(edit|preview)(.*)$"
|
||||
);
|
||||
|
||||
type Props = {|
|
||||
attrs: {|
|
||||
href: string,
|
||||
matches: string[],
|
||||
|},
|
||||
|};
|
||||
|
||||
export default class GoogleDrawings extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={this.props.attrs.href.replace("/edit", "/preview")}
|
||||
icon={
|
||||
<Image
|
||||
src="/images/google-drawings.png"
|
||||
alt="Google Drawings"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
}
|
||||
canonicalUrl={this.props.attrs.href.replace("/preview", "/edit")}
|
||||
title="Google Drawings"
|
||||
border
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import GoogleDrawings from "./GoogleDrawings";
|
||||
|
||||
describe("GoogleDrawings", () => {
|
||||
const match = GoogleDrawings.ENABLED[0];
|
||||
test("to be enabled on share link", () => {
|
||||
expect(
|
||||
"https://docs.google.com/drawings/d/1zDLtJ4HSCnjGCGSoCgqGe3F8p6o7R8Vjk8MDR6dKf-U/edit".match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
"https://docs.google.com/drawings/d/1zDLtJ4HSCnjGCGSoCgqGe3F8p6o7R8Vjk8MDR6dKf-U/edit?usp=sharing".match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("to not be enabled elsewhere", () => {
|
||||
expect(
|
||||
"https://docs.google.com/drawings/d/e/2PACX-1vRtzIzEWN6svSrIYZq-kq2XZEN6WaOFXHbPKRLXNOFRlxLIdJg0Vo6RfretGqs9SzD-fUazLeS594Kw/pub?w=960&h=720".match(
|
||||
match
|
||||
)
|
||||
).toBe(null);
|
||||
expect("https://docs.google.com/drawings".match(match)).toBe(null);
|
||||
expect("https://docs.google.com".match(match)).toBe(null);
|
||||
expect("https://www.google.com".match(match)).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import Image from "components/Image";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
const URL_REGEX = new RegExp(
|
||||
"^https?://drive.google.com/file/d/(.*)/(preview|view).?usp=sharing$"
|
||||
);
|
||||
|
||||
type Props = {|
|
||||
attrs: {|
|
||||
href: string,
|
||||
matches: string[],
|
||||
|},
|
||||
|};
|
||||
|
||||
export default class GoogleDrive extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Frame
|
||||
src={this.props.attrs.href.replace("/view", "/preview")}
|
||||
icon={
|
||||
<Image
|
||||
src="/images/google-drive.png"
|
||||
alt="Google Drive Icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
}
|
||||
title="Google Drive Embed"
|
||||
canonicalUrl={this.props.attrs.href}
|
||||
border
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import GoogleDrive from "./GoogleDrive";
|
||||
|
||||
describe("GoogleDrive", () => {
|
||||
const match = GoogleDrive.ENABLED[0];
|
||||
test("to be enabled on share link", () => {
|
||||
expect(
|
||||
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/view?usp=sharing".match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/preview?usp=sharing".match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("to not be enabled elsewhere", () => {
|
||||
expect(
|
||||
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/view".match(
|
||||
match
|
||||
)
|
||||
).toBe(null);
|
||||
expect(
|
||||
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/preview".match(
|
||||
match
|
||||
)
|
||||
).toBe(null);
|
||||
expect(
|
||||
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/view?usp=restricted".match(
|
||||
match
|
||||
)
|
||||
).toBe(null);
|
||||
expect("https://drive.google.com/file".match(match)).toBe(null);
|
||||
expect("https://drive.google.com".match(match)).toBe(null);
|
||||
expect("https://www.google.com".match(match)).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import Image from "components/Image";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
const URL_REGEX = new RegExp("^https?://docs.google.com/spreadsheets/d/(.*)$");
|
||||
@@ -21,7 +20,7 @@ export default class GoogleSlides extends React.Component<Props> {
|
||||
{...this.props}
|
||||
src={this.props.attrs.href.replace("/edit", "/preview")}
|
||||
icon={
|
||||
<Image
|
||||
<img
|
||||
src="/images/google-sheets.png"
|
||||
alt="Google Sheets Icon"
|
||||
width={16}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import Image from "components/Image";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
const URL_REGEX = new RegExp("^https?://docs.google.com/presentation/d/(.*)$");
|
||||
@@ -23,7 +22,7 @@ export default class GoogleSlides extends React.Component<Props> {
|
||||
.replace("/edit", "/preview")
|
||||
.replace("/pub", "/embed")}
|
||||
icon={
|
||||
<Image
|
||||
<img
|
||||
src="/images/google-slides.png"
|
||||
alt="Google Slides Icon"
|
||||
width={16}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user