diff --git a/.env.sample b/.env.sample
index e4c505d0..1b41ae5f 100644
--- a/.env.sample
+++ b/.env.sample
@@ -1,26 +1,40 @@
-# Copy this file to .env, remove this comment and change the keys. For development
-# with docker this should mostly work out of the box other than setting the Slack
-# keys (for auth) and the SECRET_KEY.
-#
-# Please use `openssl rand -hex 32` to create SECRET_KEY
+# 👋 Welcome, we're glad you're setting up an installation of Outline. Copy this
+# file to .env or set the variables in your local environment manually. For
+# development with docker this should mostly work out of the box other than
+# setting the Slack keys and the SECRET_KEY.
+
+
+
+
+# –––––––––––––––– REQUIRED ––––––––––––––––
+
+# Generate a unique random key, you can use `openssl rand -hex 32` in terminal
+# DO NOT LEAVE UNSET
SECRET_KEY=generate_a_new_key
+
+# Generate a unique random key, you can use `openssl rand -hex 32` in terminal
+# DO NOT LEAVE UNSET
UTILS_SECRET=generate_a_new_key
+# For production point these at your databases, in development the default
+# should work out of the box.
DATABASE_URL=postgres://user:pass@localhost:5532/outline
DATABASE_URL_TEST=postgres://user:pass@localhost:5532/outline-test
REDIS_URL=redis://localhost:6479
+# URL should point to the fully qualified, publicly accessible URL. If using a
+# proxy the port in URL and PORT may be different.
URL=http://localhost:3000
PORT=3000
-# 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
+# Third party signin credentials, at least one of EITHER Google OR Slack is
+# required for a working installation or you'll have no sign-in options.
-ENABLE_UPDATES=true
-DEBUG=cache,presenters,events,emails,mailer,utils,multiplayer,server,services
-
-# Third party signin credentials (at least one is required)
+# To configure Slack auth, you'll need to create an Application at
+# => https://api.slack.com/apps
+#
+# When configuring the Client ID, add a redirect URL under "OAuth & Permissions":
+# https:///auth/slack.callback
SLACK_KEY=get_a_key_from_slack
SLACK_SECRET=get_the_secret_of_above_key
@@ -28,22 +42,59 @@ SLACK_SECRET=get_the_secret_of_above_key
# => https://console.cloud.google.com/apis/credentials
#
# When configuring the Client ID, add an Authorized redirect URI:
-# https:///auth/google.callback
+# https:///auth/google.callback
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
-# Comma separated list of domains to be allowed (optional)
-# If not set, all Google apps domains are allowed by default
+
+
+
+# –––––––––––––––– OPTIONAL ––––––––––––––––
+
+# If using a Cloudfront/Cloudflare distribution or similar it can be set below.
+# This will cause paths to javascript, stylesheets, and images to be updated to
+# the hostname defined in CDN_URL. In your CDN configuration the origin server
+# should be set to the same as URL.
+CDN_URL=
+
+# Auto-redirect to https in production. The default is true but you may set to
+# false if you can be sure that SSL is terminated at an external loadbalancer.
+FORCE_HTTPS=true
+
+# Have the installation check for updates by sending anonymized statistics to
+# the maintainers
+ENABLE_UPDATES=true
+
+# You may enable or disable debugging categories to increase the noisiness of
+# logs. The default is a good balance
+DEBUG=cache,presenters,events,emails,mailer,utils,multiplayer,server,services
+
+# Comma separated list of domains to be allowed to signin to the wiki. If not
+# set, all domains are allowed by default when using Google OAuth to signin
GOOGLE_ALLOWED_DOMAINS=
-# Third party credentials (optional)
-SLACK_VERIFICATION_TOKEN=PLxk6OlXXXXXVj3YYYY
+# For a complete Slack integration with search and posting to channels the
+# following configs are also needed, some more details
+# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
+#
+SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
+
+# Optionally enable google analytics to track pageviews in the knowledge base
GOOGLE_ANALYTICS_ID=
+
+# Optionally enable Sentry (sentry.io) to track errors and performance
SENTRY_DSN=
-# AWS credentials (optional in development)
+# To support uploading of images for avatars and document attachments an
+# s3-compatible storage must be provided. AWS S3 is recommended for redundency
+# however if you want to keep all file storage local an alternative such as
+# minio (https://github.com/minio/minio) can be used.
+
+# A more detailed guide on setting up S3 is available here:
+# => https://wiki.generaloutline.com/share/125de1cc-9ff6-424b-8415-0d58c809a40f
+#
AWS_ACCESS_KEY_ID=get_a_key_from_aws
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
AWS_REGION=xx-xxxx-x
@@ -51,11 +102,10 @@ AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
AWS_S3_UPLOAD_MAX_SIZE=26214400
AWS_S3_FORCE_PATH_STYLE=true
-# uploaded s3 objects permission level, default is private
-# set to "public-read" to allow public access
AWS_S3_ACL=private
-# Emails configuration (optional)
+# To support sending outgoing transactional emails such as "document updated" or
+# "you've been invited" you'll need to provide authentication for an SMTP server
SMTP_HOST=
SMTP_PORT=
SMTP_USERNAME=
@@ -66,4 +116,6 @@ SMTP_REPLY_EMAIL=
# Custom logo that displays on the authentication screen, scaled to height: 60px
# TEAM_LOGO=https://example.com/images/logo.png
+# The default interface language. See translate.getoutline.com for a list of
+# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
\ No newline at end of file
diff --git a/.flowconfig b/.flowconfig
index 23473ecb..0a37806b 100644
--- a/.flowconfig
+++ b/.flowconfig
@@ -18,6 +18,7 @@
[options]
emoji=true
+sharedmemory.heap_size=3221225472
module.system.node.resolve_dirname=node_modules
module.system.node.resolve_dirname=app
@@ -32,6 +33,7 @@ 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
diff --git a/.vscode/settings.json b/.vscode/settings.json
index f5852ab5..041e71a9 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,6 +1,7 @@
{
"javascript.validate.enable": false,
+ "javascript.format.enable": false,
"typescript.validate.enable": false,
+ "typescript.format.enable": false,
"editor.formatOnSave": true,
- "typescript.format.enable": false
}
\ No newline at end of file
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
new file mode 100644
index 00000000..b996d572
--- /dev/null
+++ b/ARCHITECTURE.md
@@ -0,0 +1,66 @@
+
+# Architecture
+
+Outline is composed of a backend and frontend codebase in this monorepo. As both are written in Javascript, they share some code where possible. We utilize the latest ES6 language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier formatting and ESLint are enforced by CI.
+
+## Frontend
+
+Outline's frontend is a React application compiled with [Webpack](https://webpack.js.org/). It uses [MobX](https://mobx.js.org/) for state management and [Styled Components](https://www.styled-components.com/) for component styles. Unless global, state logic and styles are always co-located with React components together with their subcomponents to make the component tree easier to manage.
+
+> Important Note: The Outline editor is built on [Prosemirror](https://github.com/prosemirror) and managed in a separate open source repository to encourage re-use: [rich-markdown-editor](https://github.com/outline/rich-markdown-editor).
+
+```
+app
+├── components - React components reusable across scenes
+├── embeds - Embed definitions that represent rich interactive embeds in the editor
+├── hooks - Reusable React hooks
+├── menus - Context menus, often appear in multiple places in the UI
+├── models - State models using MobX observables
+├── routes - Route definitions, note that chunks are async loaded with suspense
+├── scenes - A scene represents a full-page view that contains several components
+├── stores - Collections of models and associated fetch logic
+├── types - Flow types
+└── utils - Utility methods specific to the frontend
+```
+
+## Backend
+
+The API server is driven by [Koa](http://koajs.com/), it uses [Sequelize](http://docs.sequelizejs.com/) as the ORM and Redis with [Bull](https://github.com/OptimalBits/bull) for queues and async event management. Authorization logic
+is contained in [cancan](https://www.npmjs.com/package/cancan) policies under the "policies" directory.
+
+Interested in more documentation on the API routes? Check out the [API documentation](https://getoutline.com/developers).
+
+```
+server
+├── api - All API routes are contained within here
+│ └── middlewares - Koa middlewares specific to the API
+├── auth - OAuth routes for Slack and Google, plus email authentication routes
+├── commands - We are gradually moving to the command pattern for new write logic
+├── config - Database configuration
+├── emails - Transactional email templates
+│ └── components - Shared React components for email templates
+├── middlewares - Koa middlewares
+├── migrations - Database migrations
+├── models - Sequelize models
+├── onboarding - Markdown templates for onboarding documents
+├── policies - Authorization logic based on cancan
+├── presenters - JSON presenters for database models, the interface between backend -> frontend
+├── services - Service definitions are triggered for events and perform async jobs
+├── static - Static assets
+├── test - Test helpers and fixtures, tests themselves are colocated
+└── utils - Utility methods specific to the backend
+```
+
+## Shared
+
+Where logic is shared between the client and server it is placed in this directory. This is generally
+small utilities.
+
+```
+shared
+├── i18n - Internationalization confiuration
+│ └── locales - Language specific translation files
+├── styles - Styles, colors and other global aesthetics
+├── utils - Shared utility methods
+└── constants - Shared constants
+```
\ No newline at end of file
diff --git a/README.md b/README.md
index 40e04a04..33481fab 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ This is the source code that runs [**Outline**](https://www.getoutline.com) and
If you'd like to run your own copy of Outline or contribute to development then this is the place for you.
-## Installation
+# Installation
Outline requires the following dependencies:
@@ -31,33 +31,58 @@ Outline requires the following dependencies:
- Slack or Google developer application for authentication
-### Production
+## Self-Hosted Production
-For a manual self-hosted production installation these are the suggested steps:
+### Docker
-1. Clone this repo and install dependencies with `yarn install`
-1. Build the source code with `yarn build`
-1. Using the `.env.sample` as a reference, set the required variables in your production environment. The following are required as a minimum:
- 1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
- 1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
- 1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
- 1. `DATABASE_URL` (run your own local copy of Postgres, or use a cloud service)
- 1. `REDIS_URL` (run your own local copy of Redis, or use a cloud service)
- 1. `URL` (the public facing URL of your installation)
- 1. `AWS_` (all of the keys beginning with AWS)
-1. Migrate database schema with `yarn sequelize:migrate`. Production assumes an SSL connection, if
-Postgres is on the same machine and is not SSL you can migrate with `yarn sequelize:migrate --env=production-ssl-disabled`.
-1. Start the service with any daemon tools you prefer. Take PM2 for example, `NODE_ENV=production pm2 start ./build/server/index.js --name outline `
+For a manual self-hosted production installation these are the recommended steps:
+
+1. First setup Redis and Postgres servers, this is outside the scope of the guide.
+1. Download the latest official Docker image, new releases are available around the middle of every month:
+
+ `docker pull outlinewiki/outline`
+1. Using the [.env.sample](.env.sample) as a reference, set the required variables in your production environment. You can export the environment variables directly, or create a `.env` file and pass it to the docker image like so:
+
+ `docker run --env-file=.env outlinewiki/outline`
+1. Setup the database with `yarn sequelize:migrate`. Production assumes an SSL connection to the database by default, if
+Postgres is on the same machine and is not SSL you can migrate with `yarn sequelize:migrate --env=production-ssl-disabled`, for example:
+
+ `docker run --rm outlinewiki/outline yarn sequelize:migrate`
+1. Start the container:
+
+ `docker run outlinewiki/outline`
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
> Port number can be changed using the `PORT` environment variable
-1. (Optional) You can add an `nginx` reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc.
+1. (Optional) You can add an `nginx` or other reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc.
+
+### Terraform
+
+Alternatively a community member maintains a script to deploy Outline on Google Cloud Platform with [Terraform & Ansible](https://github.com/rjsgn/outline-terraform-ansible).
+
+### Upgrading
+
+#### 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:
+
+```shell
+docker run --rm outlinewiki/outline:latest yarn sequelize:migrate
+```
+
+#### Git
+
+If you're running Outline by cloning this repository, run the following command to upgrade:
+
+```shell
+yarn run upgrade
+```
-### Development
+## Local Development
-In development you can quickly get an environment running using Docker by following these steps:
+For contributing features and fixes you can quickly get an environment running using Docker by following these steps:
1. Install these dependencies if you don't already have them
1. [Docker for Desktop](https://www.docker.com)
@@ -76,9 +101,28 @@ In development you can quickly get an environment running using Docker by follow
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
-## Development
+# Contributing
-### Server
+Outline is built and maintained by a small team – we'd love your help to fix bugs and add features!
+
+Before submitting a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and we'd also love to hear from you in the [Discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher liklihood of code being accepted.
+
+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
+* Bugs and other issues listed on GitHub
+
+
+## Architecture
+
+If you're interested in contributing or learning more about the Outline codebase
+please refer to the [architecture document](ARCHITECTURE.md) first for a high level overview of how the application is put together.
+
+
+## Debugging
Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging output, the following categories are available:
@@ -86,52 +130,6 @@ Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging o
DEBUG=sql,cache,presenters,events,logistics,emails,mailer
```
-## Migrations
-
-Sequelize is used to create and run migrations, for example:
-
-```
-yarn sequelize migration:generate --name my-migration
-yarn sequelize db:migrate
-```
-
-Or to run migrations on test database:
-
-```
-yarn sequelize db:migrate --env test
-```
-
-## Structure
-
-Outline is composed of separate backend and frontend application which are both driven by the same Node process. As both are written in Javascript, they share some code but are mostly separate. We utilize the latest language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier and ESLint are enforced by CI.
-
-### Frontend
-
-Outline's frontend is a React application compiled with [Webpack](https://webpack.js.org/). It uses [Mobx](https://mobx.js.org/) for state management and [Styled Components](https://www.styled-components.com/) for component styles. Unless global, state logic and styles are always co-located with React components together with their subcomponents to make the component tree easier to manage.
-
-The editor itself is built on [Prosemirror](https://github.com/prosemirror) and hosted in a separate repository to encourage reuse: [rich-markdown-editor](https://github.com/outline/rich-markdown-editor)
-
-- `app/` - Frontend React application
-- `app/scenes` - Full page views
-- `app/components` - Reusable React components
-- `app/stores` - Global state stores
-- `app/models` - State models
-- `app/types` - Flow types for non-models
-
-### Backend
-
-Backend is driven by [Koa](http://koajs.com/) (API, web server), [Sequelize](http://docs.sequelizejs.com/) (database) and React for public pages and emails.
-
-- `server/api` - API endpoints
-- `server/commands` - Domain logic, currently being refactored from /models
-- `server/emails` - React rendered email templates
-- `server/models` - Database models
-- `server/policies` - Authorization logic
-- `server/presenters` - API responses for database models
-- `server/test` - Test helps and support
-- `server/utils` - Utility methods
-- `shared` - Code shared between frontend and backend applications
-
## Tests
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
@@ -157,20 +155,21 @@ yarn test:server
yarn test:app
```
-## Contributing
+## Migrations
-Outline is built and maintained by a small team – we'd love your help to fix bugs and add features!
+Sequelize is used to create and run migrations, for example:
-However, before working on a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and we'd also love to hear from you in the [Discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and will hopefully help to get your contributions integrated faster!
+```
+yarn sequelize migration:generate --name my-migration
+yarn sequelize db:migrate
+```
-If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
+Or to run migrations on test database:
-* [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
-* Bugs and other issues listed on GitHub
+```
+yarn sequelize db:migrate --env test
+```
## License
-Outline is [BSL 1.1 licensed](https://github.com/outline/outline/blob/master/LICENSE).
+Outline is [BSL 1.1 licensed](LICENSE).
diff --git a/app/components/Actions.js b/app/components/Actions.js
index 2e3fbf41..77ebe565 100644
--- a/app/components/Actions.js
+++ b/app/components/Actions.js
@@ -11,11 +11,6 @@ export const Action = styled(Flex)`
font-size: 15px;
flex-shrink: 0;
- a {
- color: ${(props) => props.theme.text};
- height: 24px;
- }
-
&:empty {
display: none;
}
diff --git a/app/components/Authenticated.js b/app/components/Authenticated.js
index 505c47a3..86b43ef5 100644
--- a/app/components/Authenticated.js
+++ b/app/components/Authenticated.js
@@ -20,8 +20,10 @@ const Authenticated = ({ children }: Props) => {
// Watching for language changes here as this is the earliest point we have
// the user available and means we can start loading translations faster
React.useEffect(() => {
- if (i18n.language !== language) {
- i18n.changeLanguage(language);
+ 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]);
diff --git a/app/components/Avatar/Avatar.js b/app/components/Avatar/Avatar.js
index 46b0e594..38cfe682 100644
--- a/app/components/Avatar/Avatar.js
+++ b/app/components/Avatar/Avatar.js
@@ -3,13 +3,17 @@ 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 {
diff --git a/app/components/Breadcrumb.js b/app/components/Breadcrumb.js
index 33f12809..421866a7 100644
--- a/app/components/Breadcrumb.js
+++ b/app/components/Breadcrumb.js
@@ -4,7 +4,6 @@ import {
ArchiveIcon,
EditIcon,
GoToIcon,
- MoreIcon,
PadlockIcon,
ShapesIcon,
TrashIcon,
@@ -14,20 +13,18 @@ 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 BreadcrumbMenu from "./BreadcrumbMenu";
import useStores from "hooks/useStores";
+import BreadcrumbMenu from "menus/BreadcrumbMenu";
import { collectionUrl } from "utils/routeHelpers";
-type Props = {
+type Props = {|
document: Document,
- collections: CollectionsStore,
+ children?: React.Node,
onlyText: boolean,
-};
+|};
function Icon({ document }) {
const { t } = useTranslation();
@@ -35,11 +32,11 @@ function Icon({ document }) {
if (document.isDeleted) {
return (
<>
-
+
{t("Trash")}
-
+
>
);
@@ -47,11 +44,11 @@ function Icon({ document }) {
if (document.isArchived) {
return (
<>
-
+
{t("Archive")}
-
+
>
);
@@ -59,11 +56,11 @@ function Icon({ document }) {
if (document.isDraft) {
return (
<>
-
+
{t("Drafts")}
-
+
>
);
@@ -71,11 +68,11 @@ function Icon({ document }) {
if (document.isTemplate) {
return (
<>
-
+
{t("Templates")}
-
+
>
);
@@ -83,14 +80,16 @@ function Icon({ document }) {
return null;
}
-const Breadcrumb = ({ document, onlyText }: Props) => {
+const Breadcrumb = ({ document, children, onlyText }: Props) => {
const { collections } = useStores();
const { t } = useTranslation();
+ if (!collections.isLoaded) {
+ return ;
+ }
+
let collection = collections.get(document.collectionId);
if (!collection) {
- if (!document.deletedAt) return ;
-
collection = {
id: document.collectionId,
name: t("Deleted Collection"),
@@ -135,7 +134,7 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
{isNestedDocument && (
<>
- } path={menuPath} />
+
>
)}
{lastPath && (
@@ -146,10 +145,16 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
>
)}
+ {children}
);
};
+export const Slash = styled(GoToIcon)`
+ flex-shrink: 0;
+ fill: ${(props) => props.theme.divider};
+`;
+
const Wrapper = styled(Flex)`
display: none;
@@ -170,22 +175,6 @@ 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;
@@ -201,12 +190,21 @@ const Crumb = styled(Link)`
const CollectionName = styled(Link)`
display: flex;
- flex-shrink: 0;
+ flex-shrink: 1;
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);
diff --git a/app/components/BreadcrumbMenu.js b/app/components/BreadcrumbMenu.js
deleted file mode 100644
index fa7df3f4..00000000
--- a/app/components/BreadcrumbMenu.js
+++ /dev/null
@@ -1,22 +0,0 @@
-// @flow
-import * as React from "react";
-import { DropdownMenu } from "components/DropdownMenu";
-import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
-
-type Props = {
- label: React.Node,
- path: Array,
-};
-
-export default function BreadcrumbMenu({ label, path }: Props) {
- return (
-
- ({
- title: item.title,
- to: item.url,
- }))}
- />
-
- );
-}
diff --git a/app/components/Button.js b/app/components/Button.js
index d10333ad..aa15e984 100644
--- a/app/components/Button.js
+++ b/app/components/Button.js
@@ -22,9 +22,13 @@ const RealButton = styled.button`
cursor: pointer;
user-select: none;
- svg {
- fill: ${(props) => props.iconColor || props.theme.buttonText};
- }
+ ${(props) =>
+ !props.borderOnHover &&
+ `
+ svg {
+ fill: ${props.iconColor || props.theme.buttonText};
+ }
+ `}
&::-moz-focus-inner {
padding: 0;
@@ -42,24 +46,30 @@ 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"
- };
- border: 1px solid ${
- props.borderOnHover ? "transparent" : props.theme.buttonNeutralBorder
+ props.borderOnHover
+ ? "none"
+ : `rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.buttonNeutralBorder} 0 0 0 1px inset`
};
- svg {
+ ${
+ props.borderOnHover
+ ? ""
+ : `svg {
fill: ${props.iconColor || props.theme.buttonNeutralText};
+ }`
}
+
&:hover {
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
- border: 1px solid ${props.theme.buttonNeutralBorder};
+ box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
+ props.theme.buttonNeutralBorder
+ } 0 0 0 1px inset;
}
&:disabled {
@@ -71,9 +81,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)};
+ }
`};
`;
@@ -92,14 +102,14 @@ export const Inner = styled.span`
line-height: ${(props) => (props.hasIcon ? 24 : 32)}px;
justify-content: center;
align-items: center;
- min-height: 30px;
+ min-height: 32px;
${(props) => props.hasIcon && props.hasText && "padding-left: 4px;"};
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
`;
-export type Props = {
- type?: string,
+export type Props = {|
+ type?: "button" | "submit",
value?: string,
icon?: React.Node,
iconColor?: string,
@@ -107,9 +117,22 @@ export type Props = {
children?: React.Node,
innerRef?: React.ElementRef,
disclosure?: boolean,
+ neutral?: boolean,
+ danger?: boolean,
+ primary?: boolean,
+ disabled?: boolean,
fullwidth?: boolean,
+ autoFocus?: boolean,
+ style?: Object,
+ as?: React.ComponentType,
+ to?: string,
+ onClick?: (event: SyntheticEvent<>) => mixed,
borderOnHover?: boolean,
-};
+
+ "data-on"?: string,
+ "data-event-category"?: string,
+ "data-event-action"?: string,
+|};
function Button({
type = "text",
@@ -118,13 +141,14 @@ function Button({
value,
disclosure,
innerRef,
+ neutral,
...rest
}: Props) {
const hasText = children !== undefined || value !== undefined;
const hasIcon = icon !== undefined;
return (
-
+
{hasIcon && icon}
{hasText && }
diff --git a/app/components/ButtonLink.js b/app/components/ButtonLink.js
new file mode 100644
index 00000000..b42e1efd
--- /dev/null
+++ b/app/components/ButtonLink.js
@@ -0,0 +1,23 @@
+// @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 ;
+}
+
+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;
+`;
diff --git a/app/components/Checkbox.js b/app/components/Checkbox.js
index 85efc12a..73068677 100644
--- a/app/components/Checkbox.js
+++ b/app/components/Checkbox.js
@@ -1,18 +1,21 @@
// @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) => mixed,
note?: string,
short?: boolean,
small?: boolean,
-};
+|};
const LabelText = styled.span`
font-weight: 500;
diff --git a/app/components/ContextMenu/Header.js b/app/components/ContextMenu/Header.js
new file mode 100644
index 00000000..775991eb
--- /dev/null
+++ b/app/components/ContextMenu/Header.js
@@ -0,0 +1,13 @@
+// @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;
diff --git a/app/components/DropdownMenu/DropdownMenuItem.js b/app/components/ContextMenu/MenuItem.js
similarity index 62%
rename from app/components/DropdownMenu/DropdownMenuItem.js
rename to app/components/ContextMenu/MenuItem.js
index d455ffda..c46e25f2 100644
--- a/app/components/DropdownMenu/DropdownMenuItem.js
+++ b/app/components/ContextMenu/MenuItem.js
@@ -1,50 +1,62 @@
// @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,
children?: React.Node,
selected?: boolean,
disabled?: boolean,
-};
+ to?: string,
+ href?: string,
+ target?: "_blank",
+ as?: string | React.ComponentType<*>,
+|};
-const DropdownMenuItem = ({
+const MenuItem = ({
onClick,
children,
selected,
disabled,
+ as,
...rest
}: Props) => {
return (
-
+
);
};
-const MenuItem = styled.a`
+const Spacer = styled.div`
+ width: 24px;
+ height: 24px;
+`;
+
+export const MenuAnchor = 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;
@@ -58,6 +70,7 @@ const MenuItem = styled.a`
}
svg {
+ flex-shrink: 0;
opacity: ${(props) => (props.disabled ? ".5" : 1)};
}
@@ -66,7 +79,8 @@ const MenuItem = styled.a`
? "pointer-events: none;"
: `
- &:hover {
+ &:hover,
+ &.focus-visible {
color: ${props.theme.white};
background: ${props.theme.primary};
box-shadow: none;
@@ -84,4 +98,4 @@ const MenuItem = styled.a`
`};
`;
-export default DropdownMenuItem;
+export default MenuItem;
diff --git a/app/components/ContextMenu/OverflowMenuButton.js b/app/components/ContextMenu/OverflowMenuButton.js
new file mode 100644
index 00000000..419b63b5
--- /dev/null
+++ b/app/components/ContextMenu/OverflowMenuButton.js
@@ -0,0 +1,21 @@
+// @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 (
+
+ {(props) => (
+
+
+
+ )}
+
+ );
+}
diff --git a/app/components/ContextMenu/Separator.js b/app/components/ContextMenu/Separator.js
new file mode 100644
index 00000000..5dbc29c5
--- /dev/null
+++ b/app/components/ContextMenu/Separator.js
@@ -0,0 +1,16 @@
+// @flow
+import * as React from "react";
+import { MenuSeparator } from "reakit/Menu";
+import styled from "styled-components";
+
+export default function Separator(rest: {}) {
+ return (
+
+ {(props) => }
+
+ );
+}
+
+const HorizontalRule = styled.hr`
+ margin: 0.5em 12px;
+`;
diff --git a/app/components/DropdownMenu/DropdownMenuItems.js b/app/components/ContextMenu/Template.js
similarity index 55%
rename from app/components/DropdownMenu/DropdownMenuItems.js
rename to app/components/ContextMenu/Template.js
index b8f33cd2..d1ab6b70 100644
--- a/app/components/DropdownMenu/DropdownMenuItems.js
+++ b/app/components/ContextMenu/Template.js
@@ -1,26 +1,38 @@
// @flow
+import { ExpandedIcon } from "outline-icons";
import * as React from "react";
+import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
-import DropdownMenu from "./DropdownMenu";
-import DropdownMenuItem from "./DropdownMenuItem";
+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 MenuItem =
+type TMenuItem =
| {|
title: React.Node,
to: string,
visible?: boolean,
+ selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
onClick: (event: SyntheticEvent<>) => void | Promise,
visible?: boolean,
+ selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
href: string,
visible?: boolean,
+ selected?: boolean,
disabled?: boolean,
|}
| {|
@@ -29,7 +41,7 @@ type MenuItem =
disabled?: boolean,
style?: Object,
hover?: boolean,
- items: MenuItem[],
+ items: TMenuItem[],
|}
| {|
type: "separator",
@@ -42,10 +54,35 @@ type MenuItem =
|};
type Props = {|
- items: MenuItem[],
+ items: TMenuItem[],
|};
-export default function DropdownMenuItems({ items }: Props): React.Node {
+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 (
+ <>
+
+ {(props) => (
+
+ {title}
+
+ )}
+
+
+
+
+ >
+ );
+});
+
+function Template({ items, ...menu }: Props): React.Node {
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unneccessary separators
@@ -66,63 +103,67 @@ export default function DropdownMenuItems({ items }: Props): React.Node {
return filtered.map((item, index) => {
if (item.to) {
return (
-
{item.title}
-
+
);
}
if (item.href) {
return (
-
{item.title}
-
+
);
}
if (item.onClick) {
return (
-
{item.title}
-
+
);
}
if (item.items) {
return (
-
- {item.title}
-
- }
- hover={item.hover}
+
-
-
+ as={Submenu}
+ templateItems={item.items}
+ title={item.title}
+ {...menu}
+ />
);
}
if (item.type === "separator") {
- return
;
+ return ;
}
return null;
});
}
+
+export default React.memo(Template);
diff --git a/app/components/ContextMenu/index.js b/app/components/ContextMenu/index.js
new file mode 100644
index 00000000..88f700c5
--- /dev/null
+++ b/app/components/ContextMenu/index.js
@@ -0,0 +1,77 @@
+// @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 (
+
+ );
+}
+
+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;
+ }
+`;
diff --git a/app/components/DocumentHistory/DocumentHistory.js b/app/components/DocumentHistory/DocumentHistory.js
index 03f488f9..1f1e6e24 100644
--- a/app/components/DocumentHistory/DocumentHistory.js
+++ b/app/components/DocumentHistory/DocumentHistory.js
@@ -156,7 +156,7 @@ const Wrapper = styled(Flex)`
top: 0;
right: 0;
z-index: 1;
- min-width: ${(props) => props.theme.sidebarWidth};
+ min-width: ${(props) => props.theme.sidebarWidth}px;
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};
+ min-width: ${(props) => props.theme.sidebarWidth}px;
border-left: 1px solid ${(props) => props.theme.divider};
z-index: 1;
diff --git a/app/components/DocumentHistory/components/Revision.js b/app/components/DocumentHistory/components/Revision.js
index dbb228a4..2c29cb2e 100644
--- a/app/components/DocumentHistory/components/Revision.js
+++ b/app/components/DocumentHistory/components/Revision.js
@@ -1,6 +1,5 @@
// @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";
@@ -45,9 +44,7 @@ class RevisionListItem extends React.Component {
- }
+ iconColor={selected ? theme.white : theme.textTertiary}
/>
)}
diff --git a/app/components/DocumentList.js b/app/components/DocumentList.js
index 00d00956..00ec5945 100644
--- a/app/components/DocumentList.js
+++ b/app/components/DocumentList.js
@@ -2,12 +2,17 @@
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import * as React from "react";
import Document from "models/Document";
-import DocumentPreview from "components/DocumentPreview";
+import DocumentListItem from "components/DocumentListItem";
-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;
@@ -18,7 +23,7 @@ export default function DocumentList({ limit, documents, ...rest }: Props) {
defaultActiveChildIndex={0}
>
{items.map((document) => (
-
+
))}
);
diff --git a/app/components/DocumentListItem.js b/app/components/DocumentListItem.js
new file mode 100644
index 00000000..78fe61d5
--- /dev/null
+++ b/app/components/DocumentListItem.js
@@ -0,0 +1,243 @@
+// @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>/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>/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 (
+
+
+
+
+ {document.isNew && document.createdBy.id !== currentUser.id && (
+ {t("New")}
+ )}
+ {canStar && (
+
+
+
+ )}
+ {document.isDraft && showDraft && (
+
+ {t("Draft")}
+
+ )}
+ {document.isTemplate && showTemplate && (
+ {t("Template")}
+ )}
+
+
+ {!queryIsInTitle && (
+
+ )}
+
+
+
+ {document.isTemplate && !document.isArchived && !document.isDeleted && (
+ <>
+ }
+ neutral
+ >
+ {t("New doc")}
+
+
+ >
+ )}
+ setMenuOpen(true)}
+ onClose={() => setMenuOpen(false)}
+ modal={false}
+ />
+
+
+ );
+}
+
+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);
diff --git a/app/components/DocumentMeta.js b/app/components/DocumentMeta.js
index 52c42ea4..093e0a2c 100644
--- a/app/components/DocumentMeta.js
+++ b/app/components/DocumentMeta.js
@@ -15,6 +15,7 @@ const Container = styled(Flex)`
font-size: 13px;
white-space: nowrap;
overflow: hidden;
+ min-width: 0;
`;
const Modified = styled.span`
@@ -22,19 +23,21 @@ const Modified = styled.span`
font-weight: ${(props) => (props.highlight ? "600" : "400")};
`;
-type Props = {
+type Props = {|
showCollection?: boolean,
showPublished?: boolean,
showLastViewed?: boolean,
+ showNestedDocuments?: boolean,
document: Document,
children: React.Node,
to?: string,
-};
+|};
function DocumentMeta({
showPublished,
showCollection,
showLastViewed,
+ showNestedDocuments,
document,
children,
to,
@@ -122,6 +125,10 @@ function DocumentMeta({
);
};
+ const nestedDocumentsCount = collection
+ ? collection.getDocumentChildren(document.id).length
+ : 0;
+
return (
{updatedByMe ? t("You") : updatedBy.name}
@@ -134,6 +141,12 @@ function DocumentMeta({
)}
+ {showNestedDocuments && nestedDocumentsCount > 0 && (
+
+ · {nestedDocumentsCount}{" "}
+ {t("nested document", { count: nestedDocumentsCount })}
+
+ )}
{timeSinceNow()}
{children}
diff --git a/app/components/DocumentMetaWithViews.js b/app/components/DocumentMetaWithViews.js
index 877acca7..dc43a53b 100644
--- a/app/components/DocumentMetaWithViews.js
+++ b/app/components/DocumentMetaWithViews.js
@@ -35,6 +35,8 @@ 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;
diff --git a/app/components/DocumentPreview/DocumentPreview.js b/app/components/DocumentPreview/DocumentPreview.js
deleted file mode 100644
index 6ae35efd..00000000
--- a/app/components/DocumentPreview/DocumentPreview.js
+++ /dev/null
@@ -1,247 +0,0 @@
-// @flow
-import { observable } from "mobx";
-import { observer } from "mobx-react";
-import { StarredIcon, PlusIcon } from "outline-icons";
-import * as React from "react";
-import { withTranslation, type TFunction } from "react-i18next";
-import { Link, Redirect } from "react-router-dom";
-import styled, { withTheme } from "styled-components";
-import Document from "models/Document";
-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 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,
- t: TFunction,
-};
-
-const SEARCH_RESULT_REGEX = /]*>(.*?)<\/b>/gi;
-
-@observer
-class DocumentPreview extends React.Component {
- @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>/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,
- t,
- } = this.props;
-
- if (this.redirectTo) {
- return ;
- }
-
- const queryIsInTitle =
- !!highlight &&
- !!document.title.toLowerCase().includes(highlight.toLowerCase());
-
- return (
-
-
-
- {document.isNew && {t("New")}}
- {!document.isDraft &&
- !document.isArchived &&
- !document.isTemplate && (
-
- {document.isStarred ? (
-
- ) : (
-
- )}
-
- )}
- {document.isDraft && showDraft && (
-
- {t("Draft")}
-
- )}
- {document.isTemplate && showTemplate && (
- {t("Template")}
- )}
-
- {document.isTemplate &&
- !document.isArchived &&
- !document.isDeleted && (
- }
- neutral
- >
- {t("New doc")}
-
- )}
-
-
-
-
-
-
-
- {!queryIsInTitle && (
-
- )}
-
-
- );
- }
-}
-
-const StyledStar = withTheme(styled(({ solid, theme, ...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 withTranslation()(DocumentPreview);
diff --git a/app/components/DocumentPreview/index.js b/app/components/DocumentPreview/index.js
deleted file mode 100644
index 5b0f0d9d..00000000
--- a/app/components/DocumentPreview/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-// @flow
-import DocumentPreview from "./DocumentPreview";
-export default DocumentPreview;
diff --git a/app/components/DropToImport.js b/app/components/DropToImport.js
index f5dc68e6..5442ffb7 100644
--- a/app/components/DropToImport.js
+++ b/app/components/DropToImport.js
@@ -60,7 +60,9 @@ class DropToImport extends React.Component {
}
}
} catch (err) {
- this.props.ui.showToast(`Could not import file. ${err.message}`);
+ this.props.ui.showToast(`Could not import file. ${err.message}`, {
+ type: "error",
+ });
} finally {
this.isImporting = false;
importingLock = false;
@@ -87,7 +89,11 @@ class DropToImport extends React.Component {
isDragAccept,
isDragReject,
}) => (
-
+
{this.isImporting && }
{this.props.children}
diff --git a/app/components/DropdownMenu/DropdownMenu.js b/app/components/DropdownMenu/DropdownMenu.js
deleted file mode 100644
index bb5b8eaf..00000000
--- a/app/components/DropdownMenu/DropdownMenu.js
+++ /dev/null
@@ -1,289 +0,0 @@
-// @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 { withTranslation, type TFunction } from "react-i18next";
-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",
- t: TFunction,
-|};
-
-@observer
-class DropdownMenu extends React.Component {
- 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) => {
- 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, t } = this.props;
-
- return (
-
-
- {({ closePortal, openPortal, isOpen, portal }) => (
- <>
-
- {portal(
-
-
-
- )}
- >
- )}
-
-
- );
- }
-}
-
-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 withTranslation()(DropdownMenu);
diff --git a/app/components/DropdownMenu/index.js b/app/components/DropdownMenu/index.js
deleted file mode 100644
index 38b1b84e..00000000
--- a/app/components/DropdownMenu/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-// @flow
-export { default as DropdownMenu, Header } from "./DropdownMenu";
-export { default as DropdownMenuItem } from "./DropdownMenuItem";
diff --git a/app/components/Editor.js b/app/components/Editor.js
index a683c0dd..103d8777 100644
--- a/app/components/Editor.js
+++ b/app/components/Editor.js
@@ -8,21 +8,39 @@ import UiStore from "stores/UiStore";
import ErrorBoundary from "components/ErrorBoundary";
import Tooltip from "components/Tooltip";
import embeds from "../embeds";
-import isInternalUrl from "utils/isInternalUrl";
+import { isModKey } from "utils/keyboard";
import { uploadFile } from "utils/uploadFile";
+import { isInternalUrl } from "utils/urls";
const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));
const EMPTY_ARRAY = [];
-type Props = {
+export 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,
+ onImageUploadStart?: () => any,
+ onImageUploadStop?: () => any,
+|};
type PropsWithRef = Props & {
forwardedRef: React.Ref,
@@ -49,7 +67,7 @@ function Editor(props: PropsWithRef) {
return;
}
- if (isInternalUrl(href) && !event.metaKey && !event.shiftKey) {
+ if (isInternalUrl(href) && !isModKey(event) && !event.shiftKey) {
// relative
let navigateTo = href;
@@ -171,17 +189,16 @@ const StyledEditor = styled(RichMarkdownEditor)`
font-weight: 500;
}
- .heading-name {
- pointer-events: none;
+ .heading-anchor {
+ box-sizing: border-box;
}
- /* 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-name {
+ pointer-events: none;
+ display: block;
+ position: relative;
+ top: -60px;
+ visibility: hidden;
}
.heading-name:first-child {
diff --git a/app/components/ErrorBoundary.js b/app/components/ErrorBoundary.js
index e578a4e9..d7b07f45 100644
--- a/app/components/ErrorBoundary.js
+++ b/app/components/ErrorBoundary.js
@@ -1,4 +1,5 @@
// @flow
+import * as Sentry from "@sentry/react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
@@ -36,8 +37,8 @@ class ErrorBoundary extends React.Component {
return;
}
- if (window.Sentry) {
- window.Sentry.captureException(error);
+ if (env.SENTRY_DSN) {
+ Sentry.captureException(error);
}
}
@@ -56,7 +57,7 @@ class ErrorBoundary extends React.Component {
render() {
if (this.error) {
const error = this.error;
- const isReported = !!window.Sentry && env.DEPLOYMENT === "hosted";
+ const isReported = !!env.SENTRY_DSN && env.DEPLOYMENT === "hosted";
const isChunkError = this.error.message.match(/chunk/);
if (isChunkError) {
diff --git a/app/components/EventBoundary.js b/app/components/EventBoundary.js
index 16b64809..bbe28f46 100644
--- a/app/components/EventBoundary.js
+++ b/app/components/EventBoundary.js
@@ -4,13 +4,18 @@ import * as React from "react";
type Props = {
children: React.Node,
+ className?: string,
};
-export default function EventBoundary({ children }: Props) {
+export default function EventBoundary({ children, className }: Props) {
const handleClick = React.useCallback((event: SyntheticEvent<>) => {
event.preventDefault();
event.stopPropagation();
}, []);
- return {children};
+ return (
+
+ {children}
+
+ );
}
diff --git a/app/components/Heading.js b/app/components/Heading.js
index 763c5e38..f625d43e 100644
--- a/app/components/Heading.js
+++ b/app/components/Heading.js
@@ -7,8 +7,11 @@ 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;
}
`;
diff --git a/app/components/HoverPreview.js b/app/components/HoverPreview.js
index ba73224a..39b8a3d8 100644
--- a/app/components/HoverPreview.js
+++ b/app/components/HoverPreview.js
@@ -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/isInternalUrl";
+import { isInternalUrl } from "utils/urls";
const DELAY_OPEN = 300;
const DELAY_CLOSE = 300;
diff --git a/app/components/IconPicker.js b/app/components/IconPicker.js
index 279c9233..2b6b0940 100644
--- a/app/components/IconPicker.js
+++ b/app/components/IconPicker.js
@@ -1,6 +1,4 @@
// @flow
-import { observable } from "mobx";
-import { observer } from "mobx-react";
import {
CollectionIcon,
CoinsIcon,
@@ -22,14 +20,17 @@ import {
VehicleIcon,
} from "outline-icons";
import * as React from "react";
-import { withTranslation, type TFunction } from "react-i18next";
+import { useTranslation } from "react-i18next";
+import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
import styled from "styled-components";
-import { DropdownMenu } from "components/DropdownMenu";
+import ContextMenu from "components/ContextMenu";
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")
);
@@ -122,107 +123,77 @@ const colors = [
"#2F362F",
];
-type Props = {
+type Props = {|
onOpen?: () => void,
onChange: (color: string, icon: string) => void,
icon: string,
color: string,
- t: TFunction,
-};
+|};
-function preventEventBubble(event) {
- event.stopPropagation();
+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 (
+
+
+
+ {(props) => (
+
+ )}
+
+
+
+ {Object.keys(icons).map((name) => {
+ const Component = icons[name].component;
+ return (
+
+ );
+ })}
+
+
+ {t("Loading")}…}>
+ onChange(color.hex, icon)}
+ colors={colors}
+ triangle="hide"
+ />
+
+
+
+
+ );
}
-@observer
-class IconPicker extends React.Component {
- @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 { t } = this.props;
- const Component = icons[this.props.icon || "collection"].component;
-
- return (
- (this.node = ref)}>
-
-
-
-
- }
- >
-
- {Object.keys(icons).map((name) => {
- const Component = icons[name].component;
- return (
- this.props.onChange(this.props.color, name)}
- style={{ width: 30, height: 30 }}
- >
-
-
- );
- })}
-
-
- {t("Loading")}…}>
-
- this.props.onChange(color.hex, this.props.icon)
- }
- colors={colors}
- triangle="hide"
- />
-
-
-
-
- );
- }
-}
+const Label = styled.label`
+ display: block;
+`;
const Icons = styled.div`
padding: 15px 9px 9px 15px;
width: 276px;
`;
-const LabelButton = styled(NudeButton)`
+const Button = styled(NudeButton)`
border: 1px solid ${(props) => props.theme.inputBorder};
width: 32px;
height: 32px;
@@ -249,4 +220,4 @@ const Wrapper = styled("div")`
position: relative;
`;
-export default withTranslation()(IconPicker);
+export default IconPicker;
diff --git a/app/components/Image.js b/app/components/Image.js
new file mode 100644
index 00000000..a8bf88a8
--- /dev/null
+++ b/app/components/Image.js
@@ -0,0 +1,15 @@
+// @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 ;
+}
diff --git a/app/components/Input.js b/app/components/Input.js
index 655ae46a..855f59b4 100644
--- a/app/components/Input.js
+++ b/app/components/Input.js
@@ -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;
@@ -75,8 +75,8 @@ export const LabelText = styled.div`
display: inline-block;
`;
-export type Props = {
- type?: string,
+export type Props = {|
+ type?: "text" | "email" | "checkbox" | "search",
value?: string,
label?: string,
className?: string,
@@ -85,9 +85,18 @@ 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) => mixed,
onFocus?: (ev: SyntheticEvent<>) => void,
onBlur?: (ev: SyntheticEvent<>) => void,
-};
+|};
@observer
class Input extends React.Component {
diff --git a/app/components/InputRich.js b/app/components/InputRich.js
index d9dd68d7..5238537b 100644
--- a/app/components/InputRich.js
+++ b/app/components/InputRich.js
@@ -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 {
diff --git a/app/components/InputSearch.js b/app/components/InputSearch.js
index 8cb37273..c5db89b7 100644
--- a/app/components/InputSearch.js
+++ b/app/components/InputSearch.js
@@ -9,6 +9,7 @@ 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 = {
@@ -16,6 +17,8 @@ type Props = {
theme: Theme,
source: string,
placeholder?: string,
+ label?: string,
+ labelHidden?: boolean,
collectionId?: string,
t: TFunction,
};
@@ -25,7 +28,7 @@ class InputSearch extends React.Component {
input: ?Input;
@observable focused: boolean = false;
- @keydown("meta+f")
+ @keydown(`${meta}+f`)
focus(ev: SyntheticEvent<>) {
ev.preventDefault();
@@ -67,6 +70,8 @@ class InputSearch extends React.Component {
color={this.focused ? theme.inputBorderFocused : theme.inputBorder}
/>
}
+ label={this.props.label}
+ labelHidden={this.props.labelHidden}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
margin={0}
diff --git a/app/components/InputSelect.js b/app/components/InputSelect.js
index e9e6cdc5..2187b968 100644
--- a/app/components/InputSelect.js
+++ b/app/components/InputSelect.js
@@ -2,17 +2,19 @@
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: 8px 12px;
+ padding: 4px 0;
+ margin: 0 12px;
outline: none;
background: none;
color: ${(props) => props.theme.text};
+ height: 30px;
&:disabled,
&::placeholder {
@@ -34,6 +36,8 @@ export type Props = {
className?: string,
labelHidden?: boolean,
options: Option[],
+ onBlur?: () => void,
+ onFocus?: () => void,
};
@observer
diff --git a/app/components/Labeled.js b/app/components/Labeled.js
index 07905f96..41b57dec 100644
--- a/app/components/Labeled.js
+++ b/app/components/Labeled.js
@@ -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) => (
diff --git a/app/components/LanguagePrompt.js b/app/components/LanguagePrompt.js
index 140064f9..46e61f64 100644
--- a/app/components/LanguagePrompt.js
+++ b/app/components/LanguagePrompt.js
@@ -4,6 +4,7 @@ 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";
@@ -68,7 +69,7 @@ export default function LanguagePrompt() {
like to change?
- {
auth.updateUser({
language,
@@ -77,14 +78,24 @@ export default function LanguagePrompt() {
}}
>
{t("Change Language")}
- {" "}
- · {t("Dismiss")}
+ {" "}
+ ·{" "}
+ {t("Dismiss")}
);
}
+const Link = styled(ButtonLink)`
+ color: ${(props) => props.theme.almostBlack};
+ font-weight: 500;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`;
+
const LanguageIcon = styled(Icon)`
margin-right: 12px;
`;
diff --git a/app/components/Layout.js b/app/components/Layout.js
index 6e8dab13..4c96b26a 100644
--- a/app/components/Layout.js
+++ b/app/components/Layout.js
@@ -1,6 +1,7 @@
// @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";
@@ -14,14 +15,17 @@ 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 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,
@@ -65,7 +69,7 @@ class Layout extends React.Component {
window.document.body.style.background = props.theme.background;
}
- @keydown("meta+.")
+ @keydown(`${meta}+.`)
handleToggleSidebar() {
this.props.ui.toggleCollapsedSidebar();
}
@@ -80,7 +84,7 @@ class Layout extends React.Component {
this.keyboardShortcutsOpen = false;
};
- @keydown(["t", "/", "meta+k"])
+ @keydown(["t", "/", `${meta}+k`])
goToSearch(ev: SyntheticEvent<>) {
if (this.props.ui.editMode) return;
ev.preventDefault();
@@ -98,6 +102,7 @@ class Layout extends React.Component {
const { auth, t, ui } = this.props;
const { user, team } = auth;
const showSidebar = auth.authenticated && user && team;
+ const sidebarCollapsed = ui.editMode || ui.sidebarCollapsed;
if (auth.isSuspended) return ;
if (this.redirectTo) return ;
@@ -111,11 +116,19 @@ class Layout extends React.Component {
content="width=device-width, initial-scale=1.0"
/>
+
{this.props.ui.progressBarVisible && }
{this.props.notifications}
+ }
+ iconColor="currentColor"
+ neutral
+ />
+
{showSidebar && (
@@ -124,10 +137,17 @@ class Layout extends React.Component {
)}
+
{this.props.children}
@@ -159,19 +179,38 @@ 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: margin-left 100ms ease-out;
+ transition: ${(props) =>
+ props.$isResizing ? "none" : `margin-left 100ms ease-out`};
@media print {
margin: 0;
}
+ ${breakpoint("mobile", "tablet")`
+ margin-left: 0 !important;
+ `}
+
${breakpoint("tablet")`
- margin-left: ${(props) =>
- props.sidebarCollapsed
- ? props.theme.sidebarCollapsedWidth
- : props.theme.sidebarWidth};
+ ${(props) =>
+ props.$sidebarCollapsed &&
+ `margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
`};
`;
diff --git a/app/components/LocaleTime.js b/app/components/LocaleTime.js
new file mode 100644
index 00000000..71e4242c
--- /dev/null
+++ b/app/components/LocaleTime.js
@@ -0,0 +1,89 @@
+// @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 (
+
+
+
+ );
+}
+
+export default LocaleTime;
diff --git a/app/components/Mask.js b/app/components/Mask.js
index d581ea3b..eeeabe86 100644
--- a/app/components/Mask.js
+++ b/app/components/Mask.js
@@ -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 {
width: number;
@@ -23,7 +23,7 @@ class Mask extends React.Component {
}
render() {
- return ;
+ return ;
}
}
diff --git a/app/components/Modal.js b/app/components/Modal.js
index 8dbf6197..11032040 100644
--- a/app/components/Modal.js
+++ b/app/components/Modal.js
@@ -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 {
diff --git a/app/components/NudeButton.js b/app/components/NudeButton.js
index 5d902110..f42fcdf9 100644
--- a/app/components/NudeButton.js
+++ b/app/components/NudeButton.js
@@ -3,15 +3,17 @@ import * as React from "react";
import styled from "styled-components";
const Button = styled.button`
- width: 24px;
- height: 24px;
+ width: ${(props) => props.size}px;
+ height: ${(props) => props.size}px;
background: none;
border-radius: 4px;
line-height: 0;
border: 0;
padding: 0;
+ cursor: pointer;
+ user-select: none;
`;
-export default React.forwardRef((props, ref) => (
-
-));
+export default React.forwardRef(
+ ({ size = 24, ...props }, ref) =>
+);
diff --git a/app/components/PageTitle.js b/app/components/PageTitle.js
index a91ef21b..537d8690 100644
--- a/app/components/PageTitle.js
+++ b/app/components/PageTitle.js
@@ -1,16 +1,17 @@
// @flow
-import { observer, inject } from "mobx-react";
+import { observer } from "mobx-react";
import * as React from "react";
import { Helmet } from "react-helmet";
-import AuthStore from "stores/AuthStore";
+import useStores from "hooks/useStores";
+import { cdnPath } from "utils/urls";
-type Props = {
+type Props = {|
title: string,
favicon?: string,
- auth: AuthStore,
-};
+|};
-const PageTitle = observer(({ auth, title, favicon }: Props) => {
+const PageTitle = ({ title, favicon }: Props) => {
+ const { auth } = useStores();
const { team } = auth;
return (
@@ -21,12 +22,12 @@ const PageTitle = observer(({ auth, title, favicon }: Props) => {
);
-});
+};
-export default inject("auth")(PageTitle);
+export default observer(PageTitle);
diff --git a/app/components/PaginatedDocumentList.js b/app/components/PaginatedDocumentList.js
index 8d2188e8..f6fabd9a 100644
--- a/app/components/PaginatedDocumentList.js
+++ b/app/components/PaginatedDocumentList.js
@@ -2,16 +2,22 @@
import { observer } from "mobx-react";
import * as React from "react";
import Document from "models/Document";
-import DocumentPreview from "components/DocumentPreview";
+import DocumentListItem from "components/DocumentListItem";
import PaginatedList from "components/PaginatedList";
-type Props = {
+type Props = {|
documents: Document[],
fetch: (options: ?Object) => Promise,
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 {
@@ -26,7 +32,7 @@ class PaginatedDocumentList extends React.Component {
fetch={fetch}
options={options}
renderItem={(item) => (
-
+
)}
/>
);
diff --git a/app/components/Popover.js b/app/components/Popover.js
deleted file mode 100644
index ea008f11..00000000
--- a/app/components/Popover.js
+++ /dev/null
@@ -1,66 +0,0 @@
-// @flow
-import BoundlessPopover from "boundless-popover";
-import * as React from "react";
-import styled, { keyframes } from "styled-components";
-
-const fadeIn = keyframes`
- from {
- opacity: 0;
- }
-
- 50% {
- opacity: 1;
- }
-`;
-
-const StyledPopover = styled(BoundlessPopover)`
- animation: ${fadeIn} 150ms ease-in-out;
- display: flex;
- flex-direction: column;
-
- line-height: 0;
- position: absolute;
- top: 0;
- left: 0;
- z-index: ${(props) => props.theme.depths.popover};
-
- svg {
- height: 16px;
- width: 16px;
- position: absolute;
-
- polygon:first-child {
- fill: rgba(0, 0, 0, 0.075);
- }
- polygon {
- fill: #fff;
- }
- }
-`;
-
-const Dialog = styled.div`
- outline: none;
- background: #fff;
- box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 8px 16px rgba(0, 0, 0, 0.1),
- 0 2px 4px rgba(0, 0, 0, 0.1);
- border-radius: 4px;
- line-height: 1.5;
- padding: 16px;
- margin-top: 14px;
- min-width: 200px;
- min-height: 150px;
-`;
-
-export const Preset = BoundlessPopover.preset;
-
-export default function Popover(props: Object) {
- return (
-
- );
-}
diff --git a/app/components/ProfiledRoute.js b/app/components/ProfiledRoute.js
new file mode 100644
index 00000000..2eef5bce
--- /dev/null
+++ b/app/components/ProfiledRoute.js
@@ -0,0 +1,12 @@
+// @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;
diff --git a/app/components/Scrollable.js b/app/components/Scrollable.js
index 179bee86..762301a6 100644
--- a/app/components/Scrollable.js
+++ b/app/components/Scrollable.js
@@ -1,28 +1,52 @@
// @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,
+|};
-@observer
-class Scrollable extends React.Component {
- @observable shadow: boolean = false;
+function Scrollable({ shadow, topShadow, bottomShadow, ...rest }: Props) {
+ const ref = React.useRef();
+ const [topShadowVisible, setTopShadow] = React.useState(false);
+ const [bottomShadowVisible, setBottomShadow] = React.useState(false);
+ const { height } = useWindowSize();
- handleScroll = (ev: SyntheticMouseEvent) => {
- this.shadow = !!(this.props.shadow && ev.currentTarget.scrollTop > 0);
- };
+ const updateShadows = React.useCallback(() => {
+ const c = ref.current;
+ if (!c) return;
- render() {
- const { shadow, ...rest } = this.props;
+ const scrollTop = c.scrollTop;
+ const tsv = !!((shadow || topShadow) && scrollTop > 0);
+ if (tsv !== topShadowVisible) {
+ setTopShadow(tsv);
+ }
- return (
-
- );
- }
+ 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 (
+
+ );
}
const Wrapper = styled.div`
@@ -31,9 +55,20 @@ const Wrapper = styled.div`
overflow-x: hidden;
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
- box-shadow: ${(props) =>
- props.shadow ? "0 1px inset rgba(0,0,0,.1)" : "none"};
- transition: all 250ms ease-in-out;
+ 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;
`;
-export default Scrollable;
+export default observer(Scrollable);
diff --git a/app/components/Sidebar/Main.js b/app/components/Sidebar/Main.js
index 872ba576..fdbd2e35 100644
--- a/app/components/Sidebar/Main.js
+++ b/app/components/Sidebar/Main.js
@@ -1,6 +1,5 @@
// @flow
-import { observable } from "mobx";
-import { observer, inject } from "mobx-react";
+import { observer } from "mobx-react";
import {
ArchiveIcon,
HomeIcon,
@@ -10,14 +9,11 @@ import {
ShapesIcon,
TrashIcon,
PlusIcon,
+ SettingsIcon,
} from "outline-icons";
import * as React from "react";
-import { withTranslation, type TFunction } from "react-i18next";
+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";
@@ -29,175 +25,179 @@ 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";
-type Props = {
- auth: AuthStore,
- documents: DocumentsStore,
- policies: PoliciesStore,
- t: TFunction,
-};
+function MainSidebar() {
+ const { t } = useTranslation();
+ const { policies, auth, documents } = useStores();
+ const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
+ const [
+ createCollectionModalOpen,
+ setCreateCollectionModalOpen,
+ ] = React.useState(false);
-@observer
-class MainSidebar extends React.Component {
- @observable inviteModalOpen = false;
- @observable createCollectionModalOpen = false;
+ React.useEffect(() => {
+ documents.fetchDrafts();
+ documents.fetchTemplates();
+ }, [documents]);
- componentDidMount() {
- this.props.documents.fetchDrafts();
- this.props.documents.fetchTemplates();
- }
+ const handleCreateCollectionModalOpen = React.useCallback(
+ (ev: SyntheticEvent<>) => {
+ ev.preventDefault();
+ setCreateCollectionModalOpen(true);
+ },
+ []
+ );
- handleCreateCollectionModalOpen = (ev: SyntheticEvent<>) => {
+ const handleCreateCollectionModalClose = React.useCallback(() => {
+ setCreateCollectionModalOpen(false);
+ }, []);
+
+ const handleInviteModalOpen = React.useCallback((ev: SyntheticEvent<>) => {
ev.preventDefault();
- this.createCollectionModalOpen = true;
- };
+ setInviteModalOpen(true);
+ }, []);
- handleCreateCollectionModalClose = (ev: SyntheticEvent<>) => {
- this.createCollectionModalOpen = false;
- };
+ const handleInviteModalClose = React.useCallback(() => {
+ setInviteModalOpen(false);
+ }, []);
- handleInviteModalOpen = (ev: SyntheticEvent<>) => {
- ev.preventDefault();
- this.inviteModalOpen = true;
- };
+ const { user, team } = auth;
+ if (!user || !team) return null;
- handleInviteModalClose = () => {
- this.inviteModalOpen = false;
- };
+ const can = policies.abilities(team.id);
- render() {
- const { auth, documents, policies, t } = this.props;
- const { user, team } = auth;
- if (!user || !team) return null;
-
- const can = policies.abilities(team.id);
-
- return (
-
-
+
+ {(props) => (
+
+ )}
+
+
+
+
+ }
+ exact={false}
+ label={t("Home")}
/>
- }
- />
-
-
-
+ }
+ label={t("Search")}
+ exact={false}
+ />
+ }
+ exact={false}
+ label={t("Starred")}
+ />
+ }
+ exact={false}
+ label={t("Templates")}
+ active={documents.active ? documents.active.template : undefined}
+ />
+ }
+ label={
+
+ {t("Drafts")}
+ {documents.totalDrafts > 0 && (
+
+ )}
+
+ }
+ active={
+ documents.active
+ ? !documents.active.publishedAt &&
+ !documents.active.isDeleted &&
+ !documents.active.isTemplate
+ : undefined
+ }
+ />
+
+
+
+
+
+ }
+ exact={false}
+ label={t("Archive")}
+ active={
+ documents.active
+ ? documents.active.isArchived && !documents.active.isDeleted
+ : undefined
+ }
+ />
+ }
+ exact={false}
+ label={t("Trash")}
+ active={documents.active ? documents.active.isDeleted : undefined}
+ />
+ }
+ exact={false}
+ label={t("Settings")}
+ />
+ {can.invite && (
}
- exact={false}
- label={t("Home")}
+ to="/settings/people"
+ onClick={handleInviteModalOpen}
+ icon={}
+ label={`${t("Invite people")}…`}
/>
- }
- label={t("Search")}
- exact={false}
- />
- }
- exact={false}
- label={t("Starred")}
- />
- }
- exact={false}
- label={t("Templates")}
- active={
- documents.active ? documents.active.template : undefined
- }
- />
- }
- label={
-
- {t("Drafts")}
- {documents.totalDrafts > 0 && (
-
- )}
-
- }
- active={
- documents.active
- ? !documents.active.publishedAt &&
- !documents.active.isDeleted &&
- !documents.active.isTemplate
- : undefined
- }
- />
-
-
-
- }
- exact={false}
- label={t("Archive")}
- active={
- documents.active
- ? documents.active.isArchived && !documents.active.isDeleted
- : undefined
- }
- />
- }
- exact={false}
- label={t("Trash")}
- active={
- documents.active ? documents.active.isDeleted : undefined
- }
- />
- {can.invite && (
- }
- label={t("Invite people…")}
- />
- )}
-
-
-
-
-
-
-
-
-
-
- );
- }
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
}
+const Secondary = styled.div`
+ overflow-x: hidden;
+ flex-shrink: 0;
+`;
+
const Drafts = styled(Flex)`
height: 24px;
`;
-export default withTranslation()(
- inject("documents", "policies", "auth")(MainSidebar)
-);
+export default observer(MainSidebar);
diff --git a/app/components/Sidebar/Settings.js b/app/components/Sidebar/Settings.js
index 164e42ef..2a16d92f 100644
--- a/app/components/Sidebar/Settings.js
+++ b/app/components/Sidebar/Settings.js
@@ -1,5 +1,5 @@
// @flow
-import { observer, inject } from "mobx-react";
+import { observer } from "mobx-react";
import {
DocumentIcon,
EmailIcon,
@@ -13,11 +13,9 @@ import {
ExpandedIcon,
} from "outline-icons";
import * as React from "react";
-import { withTranslation, type TFunction } from "react-i18next";
-import type { RouterHistory } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { useHistory } 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";
@@ -30,131 +28,123 @@ 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";
-type Props = {
- history: RouterHistory,
- policies: PoliciesStore,
- auth: AuthStore,
- t: TFunction,
-};
+function SettingsSidebar() {
+ const { t } = useTranslation();
+ const history = useHistory();
+ const team = useCurrentTeam();
+ const { policies } = useStores();
+ const can = policies.abilities(team.id);
-@observer
-class SettingsSidebar extends React.Component {
- returnToDashboard = () => {
- this.props.history.push("/home");
- };
+ const returnToDashboard = React.useCallback(() => {
+ history.push("/home");
+ }, [history]);
- render() {
- const { policies, t, auth } = this.props;
- const { team } = auth;
- if (!team) return null;
+ return (
+
+
+ {t("Return to App")}
+
+ }
+ teamName={team.name}
+ logoUrl={team.avatarUrl}
+ onClick={returnToDashboard}
+ />
- const can = policies.abilities(team.id);
-
- return (
-
-
- {t("Return to App")}
-
- }
- teamName={team.name}
- logoUrl={team.avatarUrl}
- onClick={this.returnToDashboard}
- />
-
-
-
-
-
- }
- label={t("Profile")}
- />
- }
- label={t("Notifications")}
- />
- }
- label={t("API Tokens")}
- />
-
-
-
- {can.update && (
- }
- label={t("Details")}
- />
- )}
- {can.update && (
- }
- label={t("Security")}
- />
- )}
- }
- exact={false}
- label={t("People")}
- />
- }
- exact={false}
- label={t("Groups")}
- />
- }
- label={t("Share Links")}
- />
- {can.export && (
- }
- label={t("Import / Export")}
- />
- )}
-
+
+
+
+
+ }
+ label={t("Profile")}
+ />
+ }
+ label={t("Notifications")}
+ />
+ }
+ label={t("API Tokens")}
+ />
+
+
+
{can.update && (
-
-
+ }
+ label={t("Details")}
+ />
+ )}
+ {can.update && (
+ }
+ label={t("Security")}
+ />
+ )}
+ }
+ exact={false}
+ label={t("People")}
+ />
+ }
+ exact={false}
+ label={t("Groups")}
+ />
+ }
+ label={t("Share Links")}
+ />
+ {can.export && (
+ }
+ label={t("Import / Export")}
+ />
+ )}
+
+ {can.update && (
+
+
+ }
+ label="Slack"
+ />
+ {isHosted && (
}
- label="Slack"
+ to="/settings/integrations/zapier"
+ icon={}
+ label="Zapier"
/>
- {isHosted && (
- }
- label="Zapier"
- />
- )}
-
- )}
- {can.update && !isHosted && (
-
- )}
-
-
-
- );
- }
+ )}
+
+ )}
+ {can.update && !isHosted && (
+
+ )}
+
+
+
+ );
}
const BackIcon = styled(ExpandedIcon)`
@@ -166,6 +156,4 @@ const ReturnToApp = styled(Flex)`
height: 16px;
`;
-export default withTranslation()(
- inject("auth", "policies")(SettingsSidebar)
-);
+export default observer(SettingsSidebar);
diff --git a/app/components/Sidebar/Sidebar.js b/app/components/Sidebar/Sidebar.js
index d4ceaccc..79e4d3d0 100644
--- a/app/components/Sidebar/Sidebar.js
+++ b/app/components/Sidebar/Sidebar.js
@@ -1,55 +1,171 @@
// @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 from "styled-components";
+import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Fade from "components/Fade";
import Flex from "components/Flex";
-import CollapseToggle, { Button } from "./components/CollapseToggle";
+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]
+ );
+
const content = (
-
-
- {ui.mobileSidebarVisible ? (
-
- ) : (
-
- )}
-
+ {!isResizing && (
+
+ )}
+ {ui.mobileSidebarVisible && (
+
+
+
+
+
+ )}
+
{children}
+ {!ui.sidebarCollapsed && (
+
+
+
+ )}
);
@@ -62,82 +178,67 @@ 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, left 100ms ease-out,
- ${(props) => props.theme.backgroundTransition};
- margin-left: ${(props) => (props.mobileSidebarVisible ? 0 : "-100%")};
+ 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%")};
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.collapsed
- ? `calc(-${props.theme.sidebarWidth} + ${props.theme.sidebarCollapsedWidth})`
- : 0};
- width: ${(props) => props.theme.sidebarWidth};
margin: 0;
z-index: 3;
+ min-width: 0;
&:hover,
&:focus-within {
- left: 0;
+ left: 0 !important;
box-shadow: ${(props) =>
- props.collapsed ? "rgba(0, 0, 0, 0.2) 1px 0 4px" : "none"};
+ props.$collapsed
+ ? "rgba(0, 0, 0, 0.2) 1px 0 4px"
+ : props.$isSmallerThanMinimum
+ ? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
+ : "none"};
- & ${Button} {
+ & ${CollapseButton} {
opacity: .75;
}
- & ${Button}:hover {
+ & ${CollapseButton}:hover {
opacity: 1;
}
}
&:not(:hover):not(:focus-within) > div {
- opacity: ${(props) => (props.collapsed ? "0" : "1")};
+ opacity: ${(props) => (props.$collapsed ? "0" : "1")};
transition: opacity 100ms ease-in-out;
}
`};
`;
-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;
-
- ${breakpoint("tablet")`
- display: none;
- `};
-`;
-
export default withRouter(observer(Sidebar));
diff --git a/app/components/Sidebar/components/CollapseToggle.js b/app/components/Sidebar/components/CollapseToggle.js
index 8de701be..d7dd88a8 100644
--- a/app/components/Sidebar/components/CollapseToggle.js
+++ b/app/components/Sidebar/components/CollapseToggle.js
@@ -8,7 +8,7 @@ import { meta } from "utils/keyboard";
type Props = {|
collapsed: boolean,
- onClick?: () => void,
+ onClick?: (event: SyntheticEvent<>) => void,
|};
function CollapseToggle({ collapsed, ...rest }: Props) {
@@ -21,7 +21,7 @@ function CollapseToggle({ collapsed, ...rest }: Props) {
delay={500}
placement="bottom"
>
-
- )
- }
- onOpen={this.onOpen}
- {...rest}
- >
+ return (
+ <>
+
+ {(props) => (
+ } {...props} small>
+ {`${t("New doc")}…`}
+
+ )}
+
+
{t("Choose a collection")}
- ({
- onClick: () => this.handleNewDocument(collection.id),
+ to: newDocumentUrl(collection.id),
disabled: !policies.abilities(collection.id).update,
title: (
- <>
+
- {collection.name}
- >
+ {collection.name}
+
),
}))}
/>
-
- );
- }
+
+ >
+ );
}
-export default withTranslation()(
- inject("collections", "documents", "policies")(NewDocumentMenu)
-);
+const CollectionName = styled.div`
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+`;
+
+export default observer(NewDocumentMenu);
diff --git a/app/menus/NewTemplateMenu.js b/app/menus/NewTemplateMenu.js
index a63e31c7..610fce1f 100644
--- a/app/menus/NewTemplateMenu.js
+++ b/app/menus/NewTemplateMenu.js
@@ -1,74 +1,59 @@
// @flow
-import { observable } from "mobx";
-import { inject, observer } from "mobx-react";
+import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
-import { withTranslation, type TFunction } from "react-i18next";
-import { Redirect } from "react-router-dom";
-
-import CollectionsStore from "stores/CollectionsStore";
-import PoliciesStore from "stores/PoliciesStore";
+import { useTranslation } from "react-i18next";
+import { useMenuState, MenuButton } from "reakit/Menu";
+import styled from "styled-components";
import Button from "components/Button";
import CollectionIcon from "components/CollectionIcon";
-import { DropdownMenu, Header } from "components/DropdownMenu";
-import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
+import ContextMenu from "components/ContextMenu";
+import Header from "components/ContextMenu/Header";
+import Template from "components/ContextMenu/Template";
+import Flex from "components/Flex";
+import useStores from "hooks/useStores";
import { newDocumentUrl } from "utils/routeHelpers";
-type Props = {
- label?: React.Node,
- collections: CollectionsStore,
- policies: PoliciesStore,
- t: TFunction,
-};
+function NewTemplateMenu() {
+ const menu = useMenuState();
+ const { t } = useTranslation();
+ const { collections, policies } = useStores();
-@observer
-class NewTemplateMenu extends React.Component {
- @observable redirectTo: ?string;
-
- componentDidUpdate() {
- this.redirectTo = undefined;
- }
-
- handleNewDocument = (collectionId: string) => {
- this.redirectTo = newDocumentUrl(collectionId, {
- template: true,
- });
- };
-
- render() {
- if (this.redirectTo) return ;
-
- const { collections, policies, label, t, ...rest } = this.props;
-
- return (
- } small>
- {t("New template")}…
-
- )
- }
- {...rest}
- >
+ return (
+ <>
+
+ {(props) => (
+ } {...props} small>
+ {t("New template")}…
+
+ )}
+
+
{t("Choose a collection")}
- ({
- onClick: () => this.handleNewDocument(collection.id),
+ to: newDocumentUrl(collection.id, {
+ template: true,
+ }),
disabled: !policies.abilities(collection.id).update,
title: (
- <>
+
- {collection.name}
- >
+ {collection.name}
+
),
}))}
/>
-
- );
- }
+
+ >
+ );
}
-export default withTranslation()(
- inject("collections", "policies")(NewTemplateMenu)
-);
+const CollectionName = styled.div`
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+`;
+
+export default observer(NewTemplateMenu);
diff --git a/app/menus/RevisionMenu.js b/app/menus/RevisionMenu.js
index 59311281..bc587295 100644
--- a/app/menus/RevisionMenu.js
+++ b/app/menus/RevisionMenu.js
@@ -1,68 +1,70 @@
// @flow
-import { inject } from "mobx-react";
+import { observer } from "mobx-react";
import * as React from "react";
-import { withTranslation, type TFunction } from "react-i18next";
-import { withRouter, type RouterHistory } from "react-router-dom";
-
-import UiStore from "stores/UiStore";
+import { useTranslation } from "react-i18next";
+import { useHistory } from "react-router-dom";
+import { useMenuState } from "reakit/Menu";
import Document from "models/Document";
import Revision from "models/Revision";
+import ContextMenu from "components/ContextMenu";
+import MenuItem from "components/ContextMenu/MenuItem";
+import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
+import Separator from "components/ContextMenu/Separator";
import CopyToClipboard from "components/CopyToClipboard";
-import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
+import useStores from "hooks/useStores";
import { documentHistoryUrl } from "utils/routeHelpers";
-type Props = {
- onOpen?: () => void,
- onClose: () => void,
- history: RouterHistory,
+type Props = {|
document: Document,
revision: Revision,
+ iconColor?: string,
className?: string,
- label: React.Node,
- ui: UiStore,
- t: TFunction,
-};
+|};
-class RevisionMenu extends React.Component {
- handleRestore = async (ev: SyntheticEvent<>) => {
- ev.preventDefault();
- await this.props.document.restore({ revisionId: this.props.revision.id });
- const { t } = this.props;
- this.props.ui.showToast(t("Document restored"));
- this.props.history.push(this.props.document.url);
- };
+function RevisionMenu({ document, revision, className, iconColor }: Props) {
+ const { ui } = useStores();
+ const menu = useMenuState({ modal: true });
+ const { t } = useTranslation();
+ const history = useHistory();
- handleCopy = () => {
- const { t } = this.props;
- this.props.ui.showToast(t("Link copied"));
- };
+ const handleRestore = React.useCallback(
+ async (ev: SyntheticEvent<>) => {
+ ev.preventDefault();
+ await document.restore({ revisionId: revision.id });
+ ui.showToast(t("Document restored"), { type: "success" });
+ history.push(document.url);
+ },
+ [history, ui, t, document, revision]
+ );
- render() {
- const { className, label, onOpen, onClose, t } = this.props;
- const url = `${window.location.origin}${documentHistoryUrl(
- this.props.document,
- this.props.revision.id
- )}`;
+ const handleCopy = React.useCallback(() => {
+ ui.showToast(t("Link copied"), { type: "info" });
+ }, [ui, t]);
- return (
-
+
-
+ iconColor={iconColor}
+ aria-label={t("Show menu")}
+ {...menu}
+ />
+
+
-
-
- {t("Copy link")}
+
+
+
+
-
- );
- }
+
+ >
+ );
}
-export default withTranslation()(
- withRouter(inject("ui")(RevisionMenu))
-);
+export default observer(RevisionMenu);
diff --git a/app/menus/ShareMenu.js b/app/menus/ShareMenu.js
index 6c3404a0..e5cb11b8 100644
--- a/app/menus/ShareMenu.js
+++ b/app/menus/ShareMenu.js
@@ -1,75 +1,69 @@
// @flow
-import { observable } from "mobx";
-import { inject, observer } from "mobx-react";
+import { observer } from "mobx-react";
import * as React from "react";
-import { withTranslation, type TFunction } from "react-i18next";
-import { Redirect } from "react-router-dom";
-
-import SharesStore from "stores/SharesStore";
-import UiStore from "stores/UiStore";
+import { useTranslation } from "react-i18next";
+import { useHistory } from "react-router-dom";
+import { useMenuState } from "reakit/Menu";
import Share from "models/Share";
+import ContextMenu from "components/ContextMenu";
+import MenuItem from "components/ContextMenu/MenuItem";
+import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import CopyToClipboard from "components/CopyToClipboard";
-import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
+import useStores from "hooks/useStores";
type Props = {
- onOpen?: () => void,
- onClose: () => void,
- shares: SharesStore,
- ui: UiStore,
share: Share,
- t: TFunction,
};
-@observer
-class ShareMenu extends React.Component {
- @observable redirectTo: ?string;
+function ShareMenu({ share }: Props) {
+ const menu = useMenuState({ modal: true });
+ const { ui, shares } = useStores();
+ const { t } = useTranslation();
+ const history = useHistory();
- componentDidUpdate() {
- this.redirectTo = undefined;
- }
+ const handleGoToDocument = React.useCallback(
+ (ev: SyntheticEvent<>) => {
+ ev.preventDefault();
+ history.push(share.documentUrl);
+ },
+ [history, share]
+ );
- handleGoToDocument = (ev: SyntheticEvent<>) => {
- ev.preventDefault();
- this.redirectTo = this.props.share.documentUrl;
- };
+ const handleRevoke = React.useCallback(
+ async (ev: SyntheticEvent<>) => {
+ ev.preventDefault();
- handleRevoke = async (ev: SyntheticEvent<>) => {
- ev.preventDefault();
+ try {
+ await shares.revoke(share);
+ ui.showToast(t("Share link revoked"), { type: "info" });
+ } catch (err) {
+ ui.showToast(err.message, { type: "error" });
+ }
+ },
+ [t, shares, share, ui]
+ );
- try {
- await this.props.shares.revoke(this.props.share);
- const { t } = this.props;
- this.props.ui.showToast(t("Share link revoked"));
- } catch (err) {
- this.props.ui.showToast(err.message);
- }
- };
+ const handleCopy = React.useCallback(() => {
+ ui.showToast(t("Share link copied"), { type: "info" });
+ }, [t, ui]);
- handleCopy = () => {
- const { t } = this.props;
- this.props.ui.showToast(t("Share link copied"));
- };
-
- render() {
- if (this.redirectTo) return ;
-
- const { share, onOpen, onClose, t } = this.props;
-
- return (
-
-
- {t("Copy link")}
+ return (
+ <>
+
+
+
+
-
+
+
-
+
-
- );
- }
+
+
+ >
+ );
}
-export default withTranslation()(inject("shares", "ui")(ShareMenu));
+export default observer(ShareMenu);
diff --git a/app/menus/TemplatesMenu.js b/app/menus/TemplatesMenu.js
index fe899358..33066475 100644
--- a/app/menus/TemplatesMenu.js
+++ b/app/menus/TemplatesMenu.js
@@ -1,42 +1,42 @@
// @flow
-import { observer, inject } from "mobx-react";
+import { observer } from "mobx-react";
import { DocumentIcon } from "outline-icons";
import * as React from "react";
-import { withTranslation, type TFunction } from "react-i18next";
+import { useTranslation } from "react-i18next";
+import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
-import DocumentsStore from "stores/DocumentsStore";
import Document from "models/Document";
import Button from "components/Button";
-import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
+import ContextMenu from "components/ContextMenu";
+import MenuItem from "components/ContextMenu/MenuItem";
+import useStores from "hooks/useStores";
-type Props = {
+type Props = {|
document: Document,
- documents: DocumentsStore,
- t: TFunction,
-};
+|};
-@observer
-class TemplatesMenu extends React.Component {
- render() {
- const { documents, document, t, ...rest } = this.props;
- const templates = documents.templatesInCollection(document.collectionId);
+function TemplatesMenu({ document }: Props) {
+ const menu = useMenuState({ modal: true });
+ const { documents } = useStores();
+ const { t } = useTranslation();
+ const templates = documents.templatesInCollection(document.collectionId);
- if (!templates.length) {
- return null;
- }
+ if (!templates.length) {
+ return null;
+ }
- return (
-
+ return (
+ <>
+
+ {(props) => (
+
{t("Templates")}
- }
- {...rest}
- >
+ )}
+
+
{templates.map((template) => (
- document.updateFromTemplate(template)}
>
@@ -48,17 +48,15 @@ class TemplatesMenu extends React.Component {
{t("By {{ author }}", { author: template.createdBy.name })}
-
+
))}
-
- );
- }
+
+ >
+ );
}
const Author = styled.div`
font-size: 13px;
`;
-export default withTranslation()(
- inject("documents")(TemplatesMenu)
-);
+export default observer(TemplatesMenu);
diff --git a/app/menus/UserMenu.js b/app/menus/UserMenu.js
index 901499c1..94b01aeb 100644
--- a/app/menus/UserMenu.js
+++ b/app/menus/UserMenu.js
@@ -1,98 +1,110 @@
// @flow
-import { inject, observer } from "mobx-react";
+import { observer } from "mobx-react";
import * as React from "react";
-
-import { withTranslation, type TFunction } from "react-i18next";
-import UsersStore from "stores/UsersStore";
+import { useTranslation } from "react-i18next";
+import { useMenuState } from "reakit/Menu";
import User from "models/User";
-import { DropdownMenu } from "components/DropdownMenu";
-import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
+import ContextMenu from "components/ContextMenu";
+import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
+import Template from "components/ContextMenu/Template";
+import useStores from "hooks/useStores";
-type Props = {
+type Props = {|
user: User,
- users: UsersStore,
- t: TFunction,
-};
+|};
-@observer
-class UserMenu extends React.Component {
- handlePromote = (ev: SyntheticEvent<>) => {
- ev.preventDefault();
- const { user, users, t } = this.props;
- if (
- !window.confirm(
- t(
- "Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.",
- { userName: user.name }
+function UserMenu({ user }: Props) {
+ const { users } = useStores();
+ const { t } = useTranslation();
+ const menu = useMenuState({ modal: true });
+
+ const handlePromote = React.useCallback(
+ (ev: SyntheticEvent<>) => {
+ ev.preventDefault();
+ if (
+ !window.confirm(
+ t(
+ "Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.",
+ { userName: user.name }
+ )
)
- )
- ) {
- return;
- }
- users.promote(user);
- };
+ ) {
+ return;
+ }
+ users.promote(user);
+ },
+ [users, user, t]
+ );
- handleDemote = (ev: SyntheticEvent<>) => {
- ev.preventDefault();
- const { user, users, t } = this.props;
- if (
- !window.confirm(
- t("Are you sure you want to make {{ userName }} a member?", {
- userName: user.name,
- })
- )
- ) {
- return;
- }
- users.demote(user);
- };
-
- handleSuspend = (ev: SyntheticEvent<>) => {
- ev.preventDefault();
- const { user, users, t } = this.props;
- if (
- !window.confirm(
- t(
- "Are you sure you want to suspend this account? Suspended users will be prevented from logging in."
+ const handleDemote = React.useCallback(
+ (ev: SyntheticEvent<>) => {
+ ev.preventDefault();
+ if (
+ !window.confirm(
+ t("Are you sure you want to make {{ userName }} a member?", {
+ userName: user.name,
+ })
)
- )
- ) {
- return;
- }
- users.suspend(user);
- };
+ ) {
+ return;
+ }
+ users.demote(user);
+ },
+ [users, user, t]
+ );
- handleRevoke = (ev: SyntheticEvent<>) => {
- ev.preventDefault();
- const { user, users } = this.props;
- users.delete(user, { confirmation: true });
- };
+ const handleSuspend = React.useCallback(
+ (ev: SyntheticEvent<>) => {
+ ev.preventDefault();
+ if (
+ !window.confirm(
+ t(
+ "Are you sure you want to suspend this account? Suspended users will be prevented from logging in."
+ )
+ )
+ ) {
+ return;
+ }
+ users.suspend(user);
+ },
+ [users, user, t]
+ );
- handleActivate = (ev: SyntheticEvent<>) => {
- ev.preventDefault();
- const { user, users } = this.props;
- users.activate(user);
- };
+ const handleRevoke = React.useCallback(
+ (ev: SyntheticEvent<>) => {
+ ev.preventDefault();
+ users.delete(user, { confirmation: true });
+ },
+ [users, user]
+ );
- render() {
- const { user, t } = this.props;
+ const handleActivate = React.useCallback(
+ (ev: SyntheticEvent<>) => {
+ ev.preventDefault();
+ users.activate(user);
+ },
+ [users, user]
+ );
- return (
-
-
+
+
+ {
},
{
title: `${t("Revoke invite")}…`,
- onClick: this.handleRevoke,
+ onClick: handleRevoke,
visible: user.isInvited,
},
{
title: t("Activate account"),
- onClick: this.handleActivate,
+ onClick: handleActivate,
visible: !user.isInvited && user.isSuspended,
},
{
title: `${t("Suspend account")}…`,
- onClick: this.handleSuspend,
+ onClick: handleSuspend,
visible: !user.isInvited && !user.isSuspended,
},
]}
/>
-
- );
- }
+
+ >
+ );
}
-export default withTranslation()(inject("users")(UserMenu));
+export default observer(UserMenu);
diff --git a/app/models/Collection.js b/app/models/Collection.js
index 0a6c0a2a..388a0668 100644
--- a/app/models/Collection.js
+++ b/app/models/Collection.js
@@ -1,5 +1,5 @@
// @flow
-import { pick } from "lodash";
+import { pick, trim } from "lodash";
import { action, computed, observable } from "mobx";
import BaseModel from "models/BaseModel";
import Document from "models/Document";
@@ -20,6 +20,7 @@ export default class Collection extends BaseModel {
createdAt: ?string;
updatedAt: ?string;
deletedAt: ?string;
+ sort: { field: string, direction: "asc" | "desc" };
url: string;
@computed
@@ -45,6 +46,11 @@ export default class Collection extends BaseModel {
return results;
}
+ @computed
+ get hasDescription(): boolean {
+ return !!trim(this.description, "\\").trim();
+ }
+
@action
updateDocument(document: Document) {
const travelDocuments = (documentList, path) =>
@@ -108,6 +114,7 @@ export default class Collection extends BaseModel {
"description",
"icon",
"private",
+ "sort",
]);
};
diff --git a/app/models/Document.js b/app/models/Document.js
index d20ffc0c..bebb34c8 100644
--- a/app/models/Document.js
+++ b/app/models/Document.js
@@ -142,7 +142,7 @@ export default class Document extends BaseModel {
};
@action
- updateFromJson = (data) => {
+ updateFromJson = (data: Object) => {
set(this, data);
};
@@ -150,7 +150,7 @@ export default class Document extends BaseModel {
return this.store.archive(this);
};
- restore = (options) => {
+ restore = (options: { revisionId?: string, collectionId?: string }) => {
return this.store.restore(this, options);
};
@@ -233,7 +233,7 @@ export default class Document extends BaseModel {
};
@action
- save = async (options: SaveOptions) => {
+ save = async (options: SaveOptions = {}) => {
if (this.isSaving) return this;
const isCreating = !this.id;
@@ -246,7 +246,9 @@ export default class Document extends BaseModel {
collectionId: this.collectionId,
title: this.title,
text: this.text,
- ...options,
+ publish: options.publish,
+ done: options.done,
+ autosave: options.autosave,
});
}
@@ -257,7 +259,9 @@ export default class Document extends BaseModel {
text: this.text,
templateId: this.templateId,
lastRevision: options.lastRevision,
- ...options,
+ publish: options.publish,
+ done: options.done,
+ autosave: options.autosave,
});
}
diff --git a/app/routes/authenticated.js b/app/routes/authenticated.js
index 5ee755df..7a1ef175 100644
--- a/app/routes/authenticated.js
+++ b/app/routes/authenticated.js
@@ -1,6 +1,6 @@
// @flow
import * as React from "react";
-import { Switch, Route, Redirect, type Match } from "react-router-dom";
+import { Switch, Redirect, type Match } from "react-router-dom";
import Archive from "scenes/Archive";
import Collection from "scenes/Collection";
import Dashboard from "scenes/Dashboard";
@@ -16,6 +16,7 @@ import Trash from "scenes/Trash";
import CenteredContent from "components/CenteredContent";
import Layout from "components/Layout";
import LoadingPlaceholder from "components/LoadingPlaceholder";
+import Route from "components/ProfiledRoute";
import SocketProvider from "components/SocketProvider";
import { matchDocumentSlug as slug } from "utils/routeHelpers";
diff --git a/app/routes/index.js b/app/routes/index.js
index 2be6870f..420de6cb 100644
--- a/app/routes/index.js
+++ b/app/routes/index.js
@@ -1,8 +1,9 @@
// @flow
import * as React from "react";
-import { Switch, Route } from "react-router-dom";
+import { Switch } from "react-router-dom";
import DelayedMount from "components/DelayedMount";
import FullscreenLoading from "components/FullscreenLoading";
+import Route from "components/ProfiledRoute";
const Authenticated = React.lazy(() => import("components/Authenticated"));
const AuthenticatedRoutes = React.lazy(() => import("./authenticated"));
diff --git a/app/routes/settings.js b/app/routes/settings.js
index 71ea32fb..483a5e2e 100644
--- a/app/routes/settings.js
+++ b/app/routes/settings.js
@@ -1,6 +1,6 @@
// @flow
import * as React from "react";
-import { Switch, Route } from "react-router-dom";
+import { Switch } from "react-router-dom";
import Settings from "scenes/Settings";
import Details from "scenes/Settings/Details";
import Groups from "scenes/Settings/Groups";
@@ -12,6 +12,7 @@ import Shares from "scenes/Settings/Shares";
import Slack from "scenes/Settings/Slack";
import Tokens from "scenes/Settings/Tokens";
import Zapier from "scenes/Settings/Zapier";
+import Route from "components/ProfiledRoute";
export default function SettingsRoutes() {
return (
diff --git a/app/scenes/Collection.js b/app/scenes/Collection.js
index 27f086a1..f1bacb8b 100644
--- a/app/scenes/Collection.js
+++ b/app/scenes/Collection.js
@@ -2,7 +2,7 @@
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
-import { NewDocumentIcon, PlusIcon, PinIcon } from "outline-icons";
+import { NewDocumentIcon, PlusIcon, PinIcon, MoreIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, Trans, type TFunction } from "react-i18next";
import { Redirect, Link, Switch, Route, type Match } from "react-router-dom";
@@ -145,6 +145,8 @@ class CollectionScene extends React.Component {
@@ -164,7 +166,20 @@ class CollectionScene extends React.Component {
>
)}
-
+ (
+ }
+ {...props}
+ borderOnHover
+ neutral
+ small
+ />
+ )}
+ />
);
@@ -179,9 +194,10 @@ class CollectionScene extends React.Component {
const pinnedDocuments = this.collection
? documents.pinnedInCollection(this.collection.id)
: [];
- const hasPinnedDocuments = !!pinnedDocuments.length;
const collection = this.collection;
const collectionName = collection ? collection.name : "";
+ const hasPinnedDocuments = !!pinnedDocuments.length;
+ const hasDescription = collection ? collection.hasDescription : false;
return (
@@ -191,10 +207,12 @@ class CollectionScene extends React.Component {
{collection.isEmpty ? (
-
- {{ collectionName }} doesn’t contain any
- documents yet.
-
+ }}
+ />
Get started by creating a new one!
@@ -240,7 +258,7 @@ class CollectionScene extends React.Component {
{collection.name}
- {collection.description && (
+ {hasDescription && (
Loading…
}>
{
+ {t("Documents")}
+
+
{t("Recently updated")}
-
+
{t("Recently published")}
@@ -298,8 +319,11 @@ class CollectionScene extends React.Component {
/>
+
+
+
{
showPin
/>
-
+
{
showPin
/>
+
+
+
>
)}
diff --git a/app/scenes/CollectionDelete.js b/app/scenes/CollectionDelete.js
index 44c26294..13e93ea3 100644
--- a/app/scenes/CollectionDelete.js
+++ b/app/scenes/CollectionDelete.js
@@ -32,7 +32,7 @@ class CollectionDelete extends React.Component {
this.props.history.push(homeUrl());
this.props.onSubmit();
} catch (err) {
- this.props.ui.showToast(err.message);
+ this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isDeleting = false;
}
diff --git a/app/scenes/CollectionEdit.js b/app/scenes/CollectionEdit.js
index 9feb3a17..5efa21f6 100644
--- a/app/scenes/CollectionEdit.js
+++ b/app/scenes/CollectionEdit.js
@@ -2,7 +2,7 @@
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
-import { withTranslation, type TFunction } from "react-i18next";
+import { withTranslation, Trans, type TFunction } from "react-i18next";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import Button from "components/Button";
@@ -11,6 +11,7 @@ import HelpText from "components/HelpText";
import IconPicker from "components/IconPicker";
import Input from "components/Input";
import InputRich from "components/InputRich";
+import InputSelect from "components/InputSelect";
import Switch from "components/Switch";
type Props = {
@@ -27,6 +28,8 @@ class CollectionEdit extends React.Component {
@observable icon: string = this.props.collection.icon;
@observable color: string = this.props.collection.color || "#4E5C6E";
@observable private: boolean = this.props.collection.private;
+ @observable sort: { field: string, direction: "asc" | "desc" } = this.props
+ .collection.sort;
@observable isSaving: boolean;
handleSubmit = async (ev: SyntheticEvent<*>) => {
@@ -41,16 +44,27 @@ class CollectionEdit extends React.Component {
icon: this.icon,
color: this.color,
private: this.private,
+ sort: this.sort,
});
this.props.onSubmit();
- this.props.ui.showToast(t("The collection was updated"));
+ this.props.ui.showToast(t("The collection was updated"), {
+ type: "success",
+ });
} catch (err) {
- this.props.ui.showToast(err.message);
+ this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
}
};
+ handleSortChange = (ev: SyntheticInputEvent) => {
+ const [field, direction] = ev.target.value.split(".");
+
+ if (direction === "asc" || direction === "desc") {
+ this.sort = { field, direction };
+ }
+ };
+
handleDescriptionChange = (getValue: () => string) => {
this.description = getValue();
};
@@ -75,9 +89,10 @@ class CollectionEdit extends React.Component {
- {t("Delete Account")}
-
- {t(
- "You may delete your account at any time, note that this is unrecoverable"
- )}
- . {t("Delete account")}.
-
+ {t("Delete Account")}
+
+
+ You may delete your account at any time, note that this is
+ unrecoverable
+
+
+
+ {t("Delete account")}…
+
{this.showDeleteModal && (
@@ -168,10 +174,7 @@ class Profile extends React.Component {
}
const DangerZone = styled.div`
- background: ${(props) => props.theme.background};
- transition: ${(props) => props.theme.backgroundTransition};
- position: absolute;
- bottom: 16px;
+ margin-top: 60px;
`;
const ProfilePicture = styled(Flex)`
diff --git a/app/scenes/Settings/Security.js b/app/scenes/Settings/Security.js
index ac0c8779..0e663825 100644
--- a/app/scenes/Settings/Security.js
+++ b/app/scenes/Settings/Security.js
@@ -56,7 +56,7 @@ class Security extends React.Component {
};
showSuccessMessage = debounce(() => {
- this.props.ui.showToast("Settings saved");
+ this.props.ui.showToast("Settings saved", { type: "success" });
}, 500);
render() {
diff --git a/app/scenes/Settings/Zapier.js b/app/scenes/Settings/Zapier.js
index 15fc4fb3..076d1d84 100644
--- a/app/scenes/Settings/Zapier.js
+++ b/app/scenes/Settings/Zapier.js
@@ -17,10 +17,9 @@ function Zapier() {
+ (window.location.href = "https://zapier.com/apps/outline")
+ }
>
Open Zapier →
diff --git a/app/scenes/Settings/components/ImageUpload.js b/app/scenes/Settings/components/ImageUpload.js
index 5f824bbb..c4d39971 100644
--- a/app/scenes/Settings/components/ImageUpload.js
+++ b/app/scenes/Settings/components/ImageUpload.js
@@ -10,6 +10,7 @@ import Button from "components/Button";
import Flex from "components/Flex";
import LoadingIndicator from "components/LoadingIndicator";
import Modal from "components/Modal";
+import { compressImage } from "utils/compressImage";
import { uploadFile, dataUrlToBlob } from "utils/uploadFile";
const EMPTY_OBJECT = {};
@@ -53,7 +54,11 @@ class ImageUpload extends React.Component {
const canvas = this.avatarEditorRef.getImage();
const imageBlob = dataUrlToBlob(canvas.toDataURL());
try {
- const attachment = await uploadFile(imageBlob, {
+ const compressed = await compressImage(imageBlob, {
+ maxHeight: 512,
+ maxWidth: 512,
+ });
+ const attachment = await uploadFile(compressed, {
name: this.file.name,
public: true,
});
diff --git a/app/scenes/Starred.js b/app/scenes/Starred.js
index 6b9477d3..a41f14c0 100644
--- a/app/scenes/Starred.js
+++ b/app/scenes/Starred.js
@@ -48,7 +48,11 @@ function Starred(props: Props) {
-
+
diff --git a/app/scenes/UserDelete.js b/app/scenes/UserDelete.js
index 89ba2265..3aa54486 100644
--- a/app/scenes/UserDelete.js
+++ b/app/scenes/UserDelete.js
@@ -27,17 +27,17 @@ class UserDelete extends React.Component {
await this.props.auth.deleteUser();
this.props.auth.logout();
} catch (error) {
- this.props.ui.showToast(error.message);
+ this.props.ui.showToast(error.message, { type: "error" });
} finally {
this.isDeleting = false;
}
};
render() {
- const { auth, ...rest } = this.props;
+ const { onRequestClose } = this.props;
return (
-
+