mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f01499c9c | |||
| b382467a04 | |||
| 7091176c33 | |||
| 07425f4243 | |||
| b5dcb1b3fe | |||
| ae57cdea2a | |||
| 8599b60a6c | |||
| e00a437f2f | |||
| 626c94ecea | |||
| 889186e510 | |||
| 4166257283 | |||
| 6a7d7af767 | |||
| 46912f8ddb | |||
| 877c01f723 | |||
| 97158b1337 | |||
| 8d8bde4b8b | |||
| 059fca27b3 | |||
| 9f6ba798c8 | |||
| 349e971a8a | |||
| 9af9d3a008 | |||
| bb5443452b | |||
| b3353f20d5 | |||
| 200f25c4b2 | |||
| f1296cc8e3 | |||
| ad8c08497c | |||
| 7891a8ee8b | |||
| 56c4acc18f | |||
| 1b972070d7 | |||
| 138336639d | |||
| 8ea746dbe8 | |||
| 46bcc2e2ae | |||
| b93002ad93 | |||
| a427d77076 | |||
| eff56b758c | |||
| ffc270b567 | |||
| d86b7babb9 | |||
| ec57951087 | |||
| 2385f41a98 | |||
| bdb684a4be | |||
| 5d6f68d399 | |||
| dc967be4fc | |||
| d530edcc2f | |||
| 1393d1950e | |||
| 0aa72036d7 | |||
| f50b88716b | |||
| e90c02bec7 | |||
| 504b11576a | |||
| bac7a364d0 | |||
| ed2a42ac27 | |||
| ab7b16bbb9 | |||
| d8eefc1972 | |||
| b188a8ff30 | |||
| e1c7b07af9 | |||
| eaadeb26e5 | |||
| 0c301fcf0c | |||
| a3e95023dc | |||
| e08b17561e | |||
| ac79a4c4cc | |||
| e085553306 | |||
| 38bd1d5585 | |||
| cd7cbab5ac | |||
| 2195787e7d | |||
| 04f942141f | |||
| d0f1fd533a | |||
| a1e885f057 | |||
| 2ad9f69f7f | |||
| 65bca35bbf | |||
| a96993fda9 | |||
| 9fc03b6ece | |||
| 100360adb3 | |||
| d277d80323 | |||
| c79cfbd30d | |||
| e66611e771 | |||
| 903e83a618 | |||
| 4ef4ef963a | |||
| 51c6a19dc3 | |||
| bbf434e2f4 | |||
| 5b7018058d | |||
| fae54c7957 | |||
| fabfa6a491 | |||
| c5f9412ac0 | |||
| f4c871bb62 | |||
| df233c95a9 | |||
| 568e271738 | |||
| 9efed11a3e | |||
| c30132e558 | |||
| b152a5595e | |||
| 887e341e48 | |||
| ae2f1b47e7 | |||
| 86d9a14c5c | |||
| 6a8a83610f | |||
| 54bf7a9dea | |||
| 43ed7d0343 | |||
| a81a18b173 | |||
| f18a2a048d | |||
| 7e922d8716 | |||
| 4b603460cb | |||
| 32a298054d | |||
| ca2459361e | |||
| e49f3ab9fb | |||
| e9338df057 | |||
| 2629d6db23 | |||
| b017590033 | |||
| 7d244dfa1f | |||
| 2a225d81d2 | |||
| 41df5c74be | |||
| ef026b34fa | |||
| 1dbcc12648 | |||
| 2611376b21 | |||
| a1b3cfc7de | |||
| 5a478ec127 | |||
| c0325fcaf3 | |||
| df472ac391 | |||
| 097359bf7c | |||
| 3739bb7c55 | |||
| cc90c8de1c | |||
| ac6c48817c | |||
| 8e3534dcbc | |||
| cada91a135 | |||
| e2d7d34f30 | |||
| 0d88a1dfda | |||
| df5a2e45c5 | |||
| 1a7a48674b | |||
| e23474fa1c | |||
| 37fa13d841 | |||
| 6d88c02869 | |||
| a2fb3bb9f8 | |||
| 41be18e938 | |||
| caee7afde2 | |||
| d79933887d | |||
| 2787e56de3 | |||
| b932457fd3 | |||
| ea5d2ea9e0 | |||
| 6e9b4e8363 | |||
| 012e6b320e | |||
| c8cd7fcf4a | |||
| 7021c2a9e5 | |||
| 799e639439 | |||
| ba2552f69f | |||
| a51af98d43 | |||
| ad7400a4f5 | |||
| 087ccdd825 | |||
| 938f6ba8c5 | |||
| 7f5a7d7df7 | |||
| b98e4bb1ff | |||
| 5012104a10 |
+74
-29
@@ -1,31 +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
|
||||
|
||||
# Must point to the publicly accessible URL for the installation
|
||||
# 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
|
||||
|
||||
# Optional. If using a Cloudfront distribution or similar the origin server
|
||||
# should be set to the same as URL.
|
||||
CDN_URL=
|
||||
# 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.
|
||||
|
||||
# enforce (auto redirect to) https in production, (optional) default is true.
|
||||
# set to false if your SSL is terminated at a loadbalancer, for example
|
||||
FORCE_HTTPS=true
|
||||
|
||||
ENABLE_UPDATES=true
|
||||
DEBUG=cache,presenters,events,emails,mailer,utils,multiplayer,server,services
|
||||
|
||||
# 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://<URL>/auth/slack.callback
|
||||
SLACK_KEY=get_a_key_from_slack
|
||||
SLACK_SECRET=get_the_secret_of_above_key
|
||||
|
||||
@@ -33,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://<your Outline URL>/auth/google.callback
|
||||
# https://<URL>/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
|
||||
GOOGLE_ALLOWED_DOMAINS=
|
||||
|
||||
# Third party credentials (optional)
|
||||
SLACK_VERIFICATION_TOKEN=PLxk6OlXXXXXVj3YYYY
|
||||
|
||||
|
||||
# –––––––––––––––– 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
|
||||
ALLOWED_DOMAINS=
|
||||
|
||||
# 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
|
||||
@@ -56,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=
|
||||
@@ -71,6 +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
|
||||
|
||||
# See translate.getoutline.com for a list of available language codes and their
|
||||
# percentage translated.
|
||||
# The default interface language. See translate.getoutline.com for a list of
|
||||
# available language codes and their rough percentage translated.
|
||||
DEFAULT_LANGUAGE=en_US
|
||||
@@ -1,7 +1,6 @@
|
||||
dist
|
||||
build
|
||||
node_modules/*
|
||||
server/scripts
|
||||
.env
|
||||
.log
|
||||
npm-debug.log
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
|
||||
# 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 - Authentication logic
|
||||
│ └── providers - Authentication providers export passport.js strategies and config
|
||||
├── 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
|
||||
```
|
||||
@@ -6,7 +6,7 @@
|
||||
<p align="center">
|
||||
<i>An open, extensible, wiki for your team built using React and Node.js.<br/>Try out Outline using our hosted version at <a href="https://www.getoutline.com">www.getoutline.com</a>.</i>
|
||||
<br/>
|
||||
<img src="https://user-images.githubusercontent.com/380914/78513257-153ae080-775f-11ea-9b49-1e1939451a3e.png" alt="Outline" width="800" />
|
||||
<img width="1640" alt="screenshot" src="https://user-images.githubusercontent.com/380914/110356468-26374600-7fef-11eb-9f6a-f2cc2c8c6590.png">
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield&circle-token=c0c4c2f39990e277385d5c1ae96169c409eb887a"></a>
|
||||
@@ -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 db: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 db:migrate --env=production-ssl-disabled`, for example:
|
||||
|
||||
`docker run --rm outlinewiki/outline yarn db: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 db: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)
|
||||
@@ -75,77 +100,36 @@ In development you can quickly get an environment running using Docker by follow
|
||||
1. Ensure that the bot token scope contains at least `users:read`
|
||||
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
|
||||
|
||||
### Upgrade
|
||||
|
||||
#### Docker
|
||||
# Contributing
|
||||
|
||||
If you're running Outline with Docker you'll need to run migrations within the docker container after updating the image. The command will be something like:
|
||||
```
|
||||
docker run --rm outlinewiki/outline:latest yarn sequelize:migrate
|
||||
```
|
||||
#### Yarn
|
||||
Outline is built and maintained by a small team – we'd love your help to fix bugs and add features!
|
||||
|
||||
If you're running Outline by cloning this repository, run the following command to upgrade:
|
||||
```
|
||||
yarn run upgrade
|
||||
```
|
||||
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.
|
||||
|
||||
## Development
|
||||
If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
|
||||
|
||||
### Server
|
||||
* [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:
|
||||
|
||||
```
|
||||
DEBUG=sql,cache,presenters,events,logistics,emails,mailer
|
||||
DEBUG=sql,cache,presenters,events,importer,exporter,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.
|
||||
@@ -171,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).
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
"postdeploy": "yarn sequelize db:migrate"
|
||||
},
|
||||
"env": {
|
||||
"NODE_ENV": {
|
||||
"value": "production",
|
||||
"required": true
|
||||
},
|
||||
"SECRET_KEY": {
|
||||
"description": "A secret key",
|
||||
"generator": "secret",
|
||||
@@ -51,7 +55,7 @@
|
||||
"description": "",
|
||||
"required": false
|
||||
},
|
||||
"GOOGLE_ALLOWED_DOMAINS": {
|
||||
"ALLOWED_DOMAINS": {
|
||||
"description": "Comma separated list of domains to be allowed (optional). If not set, all Google apps domains are allowed by default",
|
||||
"required": false
|
||||
},
|
||||
|
||||
@@ -29,6 +29,11 @@ export default class Analytics extends React.Component<Props> {
|
||||
script.src = "https://www.google-analytics.com/analytics.js";
|
||||
script.async = true;
|
||||
|
||||
// Track PWA install event
|
||||
window.addEventListener("appinstalled", () => {
|
||||
ga("send", "event", "pwa", "install");
|
||||
});
|
||||
|
||||
if (document.body) {
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
export default function Arrow() {
|
||||
return (
|
||||
<svg
|
||||
width="13"
|
||||
height="30"
|
||||
viewBox="0 0 13 30"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import SlackLogo from "../SlackLogo";
|
||||
import GoogleLogo from "./GoogleLogo";
|
||||
|
||||
type Props = {|
|
||||
providerName: string,
|
||||
|};
|
||||
|
||||
export default function AuthLogo({ providerName }: Props) {
|
||||
switch (providerName) {
|
||||
case "slack":
|
||||
return <SlackLogo size={16} />;
|
||||
case "google":
|
||||
return <GoogleLogo size={16} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,17 @@ import styled from "styled-components";
|
||||
|
||||
const Badge = styled.span`
|
||||
margin-left: 10px;
|
||||
padding: 2px 6px 3px;
|
||||
padding: 1px 5px 2px;
|
||||
background-color: ${({ yellow, primary, theme }) =>
|
||||
yellow ? theme.yellow : primary ? theme.primary : theme.textTertiary};
|
||||
yellow ? theme.yellow : primary ? theme.primary : "transparent"};
|
||||
color: ${({ primary, yellow, theme }) =>
|
||||
primary ? theme.white : yellow ? theme.almostBlack : theme.background};
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
primary ? theme.white : yellow ? theme.almostBlack : theme.textTertiary};
|
||||
border: 1px solid
|
||||
${({ primary, yellow, theme }) =>
|
||||
primary || yellow ? "transparent" : theme.textTertiary};
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Document from "models/Document";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import Flex from "components/Flex";
|
||||
@@ -20,10 +19,11 @@ import useStores from "hooks/useStores";
|
||||
import BreadcrumbMenu from "menus/BreadcrumbMenu";
|
||||
import { collectionUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
document: Document,
|
||||
children?: React.Node,
|
||||
onlyText: boolean,
|
||||
};
|
||||
|};
|
||||
|
||||
function Icon({ document }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -79,10 +79,14 @@ 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) {
|
||||
collection = {
|
||||
@@ -120,7 +124,7 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
|
||||
const menuPath = isNestedDocument ? path.slice(0, -1) : [];
|
||||
|
||||
return (
|
||||
<Wrapper justify="flex-start" align="center">
|
||||
<Flex justify="flex-start" align="center">
|
||||
<Icon document={document} />
|
||||
<CollectionName to={collectionUrl(collection.id)}>
|
||||
<CollectionIcon collection={collection} expanded />
|
||||
@@ -140,7 +144,8 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
|
||||
</Crumb>
|
||||
</>
|
||||
)}
|
||||
</Wrapper>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -149,24 +154,19 @@ export const Slash = styled(GoToIcon)`
|
||||
fill: ${(props) => props.theme.divider};
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
display: none;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
`;
|
||||
|
||||
const SmallPadlockIcon = styled(PadlockIcon)`
|
||||
display: inline-block;
|
||||
vertical-align: sub;
|
||||
`;
|
||||
|
||||
const SmallSlash = styled(GoToIcon)`
|
||||
width: 15px;
|
||||
height: 10px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.25;
|
||||
|
||||
fill: ${(props) => props.theme.slate};
|
||||
opacity: 0.5;
|
||||
`;
|
||||
|
||||
const Crumb = styled(Link)`
|
||||
|
||||
@@ -3,11 +3,15 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { bounceIn } from "shared/styles/animations";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
count: number,
|
||||
};
|
||||
|};
|
||||
|
||||
const Bubble = ({ count }: Props) => {
|
||||
if (!count) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Count>{count}</Count>;
|
||||
};
|
||||
|
||||
@@ -3,17 +3,18 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
children?: React.Node,
|
||||
};
|
||||
withStickyHeader?: boolean,
|
||||
|};
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
padding: 60px 20px;
|
||||
padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 60px;
|
||||
padding: ${(props) => (props.withStickyHeader ? "4px 60px" : "60px")};
|
||||
`};
|
||||
`;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import HelpText from "components/HelpText";
|
||||
|
||||
export type Props = {|
|
||||
checked?: boolean,
|
||||
label?: string,
|
||||
label?: React.Node,
|
||||
labelHidden?: boolean,
|
||||
className?: string,
|
||||
name?: string,
|
||||
@@ -26,6 +26,7 @@ const LabelText = styled.span`
|
||||
const Wrapper = styled.div`
|
||||
padding-bottom: 8px;
|
||||
${(props) => (props.small ? "font-size: 14px" : "")};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Label = styled.label`
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import { sortBy, keyBy } from "lodash";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { MAX_AVATAR_DISPLAY } from "shared/constants";
|
||||
|
||||
import DocumentPresenceStore from "stores/DocumentPresenceStore";
|
||||
import ViewsStore from "stores/ViewsStore";
|
||||
import Document from "models/Document";
|
||||
@@ -51,7 +52,7 @@ class Collaborators extends React.Component<Props> {
|
||||
const overflow = documentViews.length - mostRecentViewers.length;
|
||||
|
||||
return (
|
||||
<Facepile
|
||||
<FacepileHiddenOnMobile
|
||||
users={mostRecentViewers.map((v) => v.user)}
|
||||
overflow={overflow}
|
||||
renderAvatar={(user) => {
|
||||
@@ -75,4 +76,10 @@ class Collaborators extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
const FacepileHiddenOnMobile = styled(Facepile)`
|
||||
${breakpoint("mobile", "tablet")`
|
||||
display: none;
|
||||
`};
|
||||
`;
|
||||
|
||||
export default inject("views", "presence")(Collaborators);
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Collection from "models/Collection";
|
||||
import Arrow from "components/Arrow";
|
||||
import ButtonLink from "components/ButtonLink";
|
||||
import Editor from "components/Editor";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
import NudeButton from "components/NudeButton";
|
||||
import useDebouncedCallback from "hooks/useDebouncedCallback";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
collection: Collection,
|
||||
|};
|
||||
|
||||
function CollectionDescription({ collection }: Props) {
|
||||
const { collections, ui, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [isExpanded, setExpanded] = React.useState(false);
|
||||
const [isEditing, setEditing] = React.useState(false);
|
||||
const [isDirty, setDirty] = React.useState(false);
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
const handleStartEditing = React.useCallback(() => {
|
||||
setEditing(true);
|
||||
}, []);
|
||||
|
||||
const handleStopEditing = React.useCallback(() => {
|
||||
setEditing(false);
|
||||
}, []);
|
||||
|
||||
const handleClickDisclosure = React.useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (isExpanded && document.activeElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
setExpanded(!isExpanded);
|
||||
},
|
||||
[isExpanded]
|
||||
);
|
||||
|
||||
const handleSave = useDebouncedCallback(async (getValue) => {
|
||||
try {
|
||||
await collection.save({
|
||||
description: getValue(),
|
||||
});
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
ui.showToast(
|
||||
t("Sorry, an error occurred saving the collection", {
|
||||
type: "error",
|
||||
})
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(getValue) => {
|
||||
setDirty(true);
|
||||
handleSave(getValue);
|
||||
},
|
||||
[handleSave]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setEditing(false);
|
||||
}, [collection.id]);
|
||||
|
||||
const placeholder = `${t("Add a description")}…`;
|
||||
const key = isEditing || isDirty ? "draft" : collection.updatedAt;
|
||||
|
||||
return (
|
||||
<MaxHeight data-editing={isEditing} data-expanded={isExpanded}>
|
||||
<Input
|
||||
$isEditable={can.update}
|
||||
data-editing={isEditing}
|
||||
data-expanded={isExpanded}
|
||||
>
|
||||
<span onClick={can.update ? handleStartEditing : undefined}>
|
||||
{collections.isSaving && <LoadingIndicator />}
|
||||
{collection.hasDescription || isEditing || isDirty ? (
|
||||
<React.Suspense fallback={<Placeholder>Loading…</Placeholder>}>
|
||||
<Editor
|
||||
key={key}
|
||||
defaultValue={collection.description || ""}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
readOnly={!isEditing}
|
||||
autoFocus={isEditing}
|
||||
onBlur={handleStopEditing}
|
||||
maxLength={1000}
|
||||
disableEmbeds
|
||||
readOnlyWriteCheckboxes
|
||||
grow
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
can.update && <Placeholder>{placeholder}</Placeholder>
|
||||
)}
|
||||
</span>
|
||||
</Input>
|
||||
{!isEditing && (
|
||||
<Disclosure
|
||||
onClick={handleClickDisclosure}
|
||||
aria-label={isExpanded ? t("Collapse") : t("Expand")}
|
||||
size={30}
|
||||
>
|
||||
<Arrow />
|
||||
</Disclosure>
|
||||
)}
|
||||
</MaxHeight>
|
||||
);
|
||||
}
|
||||
|
||||
const Disclosure = styled(NudeButton)`
|
||||
opacity: 0;
|
||||
color: ${(props) => props.theme.divider};
|
||||
position: absolute;
|
||||
top: calc(25vh - 50px);
|
||||
left: 50%;
|
||||
z-index: 1;
|
||||
transform: rotate(-90deg) translateX(-50%);
|
||||
transition: opacity 100ms ease-in-out;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
}
|
||||
`;
|
||||
|
||||
const Placeholder = styled(ButtonLink)`
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
cursor: text;
|
||||
min-height: 27px;
|
||||
`;
|
||||
|
||||
const MaxHeight = styled.div`
|
||||
position: relative;
|
||||
max-height: 25vh;
|
||||
overflow: hidden;
|
||||
margin: -12px -8px -8px;
|
||||
padding: 8px;
|
||||
|
||||
&[data-editing="true"],
|
||||
&[data-expanded="true"] {
|
||||
max-height: initial;
|
||||
overflow: initial;
|
||||
|
||||
${Disclosure} {
|
||||
top: initial;
|
||||
bottom: 0;
|
||||
transform: rotate(90deg) translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover ${Disclosure} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Input = styled.div`
|
||||
margin: -8px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: calc(25vh - 50px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
${(props) => transparentize(1, props.theme.background)} 0%,
|
||||
${(props) => props.theme.background} 100%
|
||||
);
|
||||
}
|
||||
|
||||
&[data-editing="true"],
|
||||
&[data-expanded="true"] {
|
||||
&:after {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-editing="true"] {
|
||||
background: ${(props) => props.theme.secondaryBackground};
|
||||
}
|
||||
|
||||
.block-menu-trigger,
|
||||
.heading-anchor {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(CollectionDescription);
|
||||
@@ -1,20 +1,21 @@
|
||||
// @flow
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { PrivateCollectionIcon, CollectionIcon } from "outline-icons";
|
||||
import { observer } from "mobx-react";
|
||||
import { CollectionIcon } from "outline-icons";
|
||||
import { getLuminance } from "polished";
|
||||
import * as React from "react";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Collection from "models/Collection";
|
||||
import { icons } from "components/IconPicker";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
collection: Collection,
|
||||
expanded?: boolean,
|
||||
size?: number,
|
||||
ui: UiStore,
|
||||
};
|
||||
|
||||
function ResolvedCollectionIcon({ collection, expanded, size, ui }: Props) {
|
||||
function ResolvedCollectionIcon({ collection, expanded, size }: Props) {
|
||||
const { ui } = useStores();
|
||||
|
||||
// If the chosen icon color is very dark then we invert it in dark mode
|
||||
// otherwise it will be impossible to see against the dark background.
|
||||
const color =
|
||||
@@ -33,13 +34,7 @@ function ResolvedCollectionIcon({ collection, expanded, size, ui }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
if (collection.private) {
|
||||
return (
|
||||
<PrivateCollectionIcon color={color} expanded={expanded} size={size} />
|
||||
);
|
||||
}
|
||||
|
||||
return <CollectionIcon color={color} expanded={expanded} size={size} />;
|
||||
}
|
||||
|
||||
export default inject("ui")(observer(ResolvedCollectionIcon));
|
||||
export default observer(ResolvedCollectionIcon);
|
||||
|
||||
@@ -13,6 +13,7 @@ type Props = {|
|
||||
href?: string,
|
||||
target?: "_blank",
|
||||
as?: string | React.ComponentType<*>,
|
||||
hide?: () => void,
|
||||
|};
|
||||
|
||||
const MenuItem = ({
|
||||
@@ -21,16 +22,34 @@ const MenuItem = ({
|
||||
selected,
|
||||
disabled,
|
||||
as,
|
||||
hide,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const handleClick = React.useCallback(
|
||||
(ev) => {
|
||||
if (onClick) {
|
||||
onClick(ev);
|
||||
}
|
||||
if (hide) {
|
||||
hide();
|
||||
}
|
||||
},
|
||||
[hide, onClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseMenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
hide={hide}
|
||||
{...rest}
|
||||
>
|
||||
{(props) => (
|
||||
<MenuAnchor as={onClick ? "button" : as} {...props}>
|
||||
<MenuAnchor
|
||||
{...props}
|
||||
as={onClick ? "button" : as}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<>
|
||||
{selected ? <CheckmarkIcon /> : <Spacer />}
|
||||
@@ -66,7 +85,7 @@ export const MenuAnchor = styled.a`
|
||||
user-select: none;
|
||||
|
||||
svg:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
svg {
|
||||
|
||||
@@ -59,7 +59,8 @@ type Props = {|
|
||||
|
||||
const Disclosure = styled(ExpandedIcon)`
|
||||
transform: rotate(270deg);
|
||||
justify-self: flex-end;
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
`;
|
||||
|
||||
const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function ContextMenu({
|
||||
}, [onOpen, onClose, previousVisible, rest.visible]);
|
||||
|
||||
return (
|
||||
<Menu {...rest}>
|
||||
<Menu hideOnClickOutside preventBodyScroll {...rest}>
|
||||
{(props) => (
|
||||
<Position {...props}>
|
||||
<Background>
|
||||
@@ -60,8 +60,8 @@ const Background = styled.div`
|
||||
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;
|
||||
border-radius: 6px;
|
||||
padding: 6px 0;
|
||||
min-width: 180px;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -163,8 +163,11 @@ const DocumentLink = styled(Link)`
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
max-height: 50vh;
|
||||
min-width: 100%;
|
||||
max-width: calc(100vw - 40px);
|
||||
width: calc(100vw - 8px);
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: auto;
|
||||
`};
|
||||
|
||||
${Actions} {
|
||||
opacity: 0;
|
||||
|
||||
@@ -18,6 +18,11 @@ const Container = styled(Flex)`
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const Viewed = styled.span`
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Modified = styled.span`
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-weight: ${(props) => (props.highlight ? "600" : "400")};
|
||||
@@ -112,16 +117,16 @@ function DocumentMeta({
|
||||
}
|
||||
if (!lastViewedAt) {
|
||||
return (
|
||||
<>
|
||||
<Viewed>
|
||||
• <Modified highlight>{t("Never viewed")}</Modified>
|
||||
</>
|
||||
</Viewed>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
<Viewed>
|
||||
• {t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
|
||||
</span>
|
||||
</Viewed>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -27,13 +27,16 @@ export type Props = {|
|
||||
autoFocus?: boolean,
|
||||
template?: boolean,
|
||||
placeholder?: string,
|
||||
maxLength?: number,
|
||||
scrollTo?: string,
|
||||
handleDOMEvents?: Object,
|
||||
readOnlyWriteCheckboxes?: boolean,
|
||||
onBlur?: (event: SyntheticEvent<>) => any,
|
||||
onFocus?: (event: SyntheticEvent<>) => any,
|
||||
onPublish?: (event: SyntheticEvent<>) => any,
|
||||
onSave?: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
|
||||
onCancel?: () => any,
|
||||
onDoubleClick?: () => any,
|
||||
onChange?: (getValue: () => string) => any,
|
||||
onSearchLink?: (title: string) => any,
|
||||
onHoverLink?: (event: MouseEvent) => any,
|
||||
@@ -177,7 +180,7 @@ const StyledEditor = styled(RichMarkdownEditor)`
|
||||
justify-content: start;
|
||||
|
||||
> div {
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
& * {
|
||||
|
||||
@@ -107,7 +107,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
}
|
||||
|
||||
const Pre = styled.pre`
|
||||
background: ${(props) => props.theme.smoke};
|
||||
background: ${(props) => props.theme.secondaryBackground};
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
+11
-5
@@ -16,7 +16,7 @@ type AlignValues =
|
||||
| "flex-start"
|
||||
| "flex-end";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
column?: ?boolean,
|
||||
shrink?: ?boolean,
|
||||
align?: AlignValues,
|
||||
@@ -24,12 +24,18 @@ type Props = {
|
||||
auto?: ?boolean,
|
||||
className?: string,
|
||||
children?: React.Node,
|
||||
};
|
||||
role?: string,
|
||||
|};
|
||||
|
||||
const Flex = (props: Props) => {
|
||||
const Flex = React.forwardRef<Props, HTMLDivElement>((props: Props, ref) => {
|
||||
const { children, ...restProps } = props;
|
||||
return <Container {...restProps}>{children}</Container>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Container ref={ref} {...restProps}>
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// @flow
|
||||
import { throttle } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
type Props = {|
|
||||
breadcrumb?: React.Node,
|
||||
title: React.Node,
|
||||
actions?: React.Node,
|
||||
|};
|
||||
|
||||
function Header({ breadcrumb, title, actions }: Props) {
|
||||
const [isScrolled, setScrolled] = React.useState(false);
|
||||
|
||||
const handleScroll = React.useCallback(
|
||||
throttle(() => setScrolled(window.scrollY > 75), 50),
|
||||
[]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
const handleClickTitle = React.useCallback(() => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Wrapper align="center" isCompact={isScrolled} shrink={false}>
|
||||
{breadcrumb ? <Breadcrumbs>{breadcrumb}</Breadcrumbs> : null}
|
||||
{isScrolled ? (
|
||||
<Title align="center" justify="flex-start" onClick={handleClickTitle}>
|
||||
<Fade>{title}</Fade>
|
||||
</Title>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{actions && (
|
||||
<Actions align="center" justify="flex-end">
|
||||
{actions}
|
||||
</Actions>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Breadcrumbs = styled("div")`
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
align-items: center;
|
||||
padding-right: 8px;
|
||||
|
||||
/* Don't show breadcrumbs on mobile */
|
||||
display: none;
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Actions = styled(Flex)`
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
min-width: auto;
|
||||
padding-left: 8px;
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: ${(props) => transparentize(0.2, props.theme.background)};
|
||||
padding: 12px;
|
||||
transition: all 100ms ease-out;
|
||||
transform: translate3d(0, 0, 0);
|
||||
backdrop-filter: blur(20px);
|
||||
min-height: 56px;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
justify-content: flex-start;
|
||||
${breakpoint("tablet")`
|
||||
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
|
||||
justify-content: "center";
|
||||
`};
|
||||
`;
|
||||
|
||||
const Title = styled("div")`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
|
||||
/* on mobile, there's always a floating menu button in the top left
|
||||
add some padding here to offset
|
||||
*/
|
||||
padding-left: 40px;
|
||||
${breakpoint("tablet")`
|
||||
padding-left: 0;
|
||||
`};
|
||||
|
||||
svg {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
@media (display-mode: standalone) {
|
||||
overflow: hidden;
|
||||
flex-grow: 0 !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(Header);
|
||||
+12
-4
@@ -4,6 +4,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
const RealTextarea = styled.textarea`
|
||||
@@ -33,10 +34,15 @@ const RealInput = styled.input`
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
font-size: 16px;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
flex: ${(props) => (props.flex ? "1" : "0")};
|
||||
width: ${(props) => (props.short ? "49%" : "auto")};
|
||||
max-width: ${(props) => (props.short ? "350px" : "100%")};
|
||||
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : "0")};
|
||||
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : "initial")};
|
||||
@@ -50,7 +56,6 @@ const IconWrapper = styled.span`
|
||||
`;
|
||||
|
||||
export const Outline = styled(Flex)`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
margin: ${(props) =>
|
||||
props.margin !== undefined ? props.margin : "0 0 16px"};
|
||||
@@ -59,7 +64,7 @@ export const Outline = styled(Flex)`
|
||||
border-style: solid;
|
||||
border-color: ${(props) =>
|
||||
props.hasError
|
||||
? "red"
|
||||
? props.theme.danger
|
||||
: props.focused
|
||||
? props.theme.inputBorderFocused
|
||||
: props.theme.inputBorder};
|
||||
@@ -76,7 +81,7 @@ export const LabelText = styled.div`
|
||||
`;
|
||||
|
||||
export type Props = {|
|
||||
type?: "text" | "email" | "checkbox" | "search",
|
||||
type?: "text" | "email" | "checkbox" | "search" | "textarea",
|
||||
value?: string,
|
||||
label?: string,
|
||||
className?: string,
|
||||
@@ -92,8 +97,11 @@ export type Props = {|
|
||||
autoComplete?: boolean | string,
|
||||
readOnly?: boolean,
|
||||
required?: boolean,
|
||||
disabled?: boolean,
|
||||
placeholder?: string,
|
||||
onChange?: (ev: SyntheticInputEvent<HTMLInputElement>) => mixed,
|
||||
onChange?: (
|
||||
ev: SyntheticInputEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => mixed,
|
||||
onFocus?: (ev: SyntheticEvent<>) => void,
|
||||
onBlur?: (ev: SyntheticEvent<>) => void,
|
||||
|};
|
||||
|
||||
+25
-23
@@ -6,15 +6,21 @@ import * as React from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import keydown from "react-keydown";
|
||||
import { Switch, Route, Redirect } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import {
|
||||
Switch,
|
||||
Route,
|
||||
Redirect,
|
||||
withRouter,
|
||||
type RouterHistory,
|
||||
} from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
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";
|
||||
@@ -24,12 +30,12 @@ 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,
|
||||
matchDocumentSlug as slug,
|
||||
newDocumentUrl,
|
||||
} from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
@@ -39,8 +45,9 @@ type Props = {
|
||||
title?: ?React.Node,
|
||||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
history: RouterHistory,
|
||||
policies: PoliciesStore,
|
||||
notifications?: React.Node,
|
||||
theme: Theme,
|
||||
i18n: Object,
|
||||
t: TFunction,
|
||||
};
|
||||
@@ -51,24 +58,12 @@ class Layout extends React.Component<Props> {
|
||||
@observable redirectTo: ?string;
|
||||
@observable keyboardShortcutsOpen: boolean = false;
|
||||
|
||||
constructor(props: Props) {
|
||||
super();
|
||||
this.updateBackground(props);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.updateBackground(this.props);
|
||||
|
||||
if (this.redirectTo) {
|
||||
this.redirectTo = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
updateBackground(props: Props) {
|
||||
// ensure the wider page color always matches the theme
|
||||
window.document.body.style.background = props.theme.background;
|
||||
}
|
||||
|
||||
@keydown(`${meta}+.`)
|
||||
handleToggleSidebar() {
|
||||
this.props.ui.toggleCollapsedSidebar();
|
||||
@@ -76,7 +71,6 @@ class Layout extends React.Component<Props> {
|
||||
|
||||
@keydown("shift+/")
|
||||
handleOpenKeyboardShortcuts() {
|
||||
if (this.props.ui.editMode) return;
|
||||
this.keyboardShortcutsOpen = true;
|
||||
}
|
||||
|
||||
@@ -86,7 +80,6 @@ class Layout extends React.Component<Props> {
|
||||
|
||||
@keydown(["t", "/", `${meta}+k`])
|
||||
goToSearch(ev: SyntheticEvent<>) {
|
||||
if (this.props.ui.editMode) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.redirectTo = searchUrl();
|
||||
@@ -94,15 +87,25 @@ class Layout extends React.Component<Props> {
|
||||
|
||||
@keydown("d")
|
||||
goToDashboard() {
|
||||
if (this.props.ui.editMode) return;
|
||||
this.redirectTo = homeUrl();
|
||||
}
|
||||
|
||||
@keydown("n")
|
||||
goToNewDocument() {
|
||||
const { activeCollectionId } = this.props.ui;
|
||||
if (!activeCollectionId) return;
|
||||
|
||||
const can = this.props.policies.abilities(activeCollectionId);
|
||||
if (!can.update) return;
|
||||
|
||||
this.props.history.push(newDocumentUrl(activeCollectionId));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { auth, t, ui } = this.props;
|
||||
const { user, team } = auth;
|
||||
const showSidebar = auth.authenticated && user && team;
|
||||
const sidebarCollapsed = ui.editMode || ui.sidebarCollapsed;
|
||||
const sidebarCollapsed = ui.isEditing || ui.sidebarCollapsed;
|
||||
|
||||
if (auth.isSuspended) return <ErrorSuspended />;
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
@@ -117,7 +120,6 @@ class Layout extends React.Component<Props> {
|
||||
/>
|
||||
</Helmet>
|
||||
<SkipNavLink />
|
||||
<Analytics />
|
||||
|
||||
{this.props.ui.progressBarVisible && <LoadingIndicatorBar />}
|
||||
{this.props.notifications}
|
||||
@@ -215,5 +217,5 @@ const Content = styled(Flex)`
|
||||
`;
|
||||
|
||||
export default withTranslation()<Layout>(
|
||||
inject("auth", "ui", "documents")(withTheme(Layout))
|
||||
inject("auth", "ui", "documents", "policies")(withRouter(Layout))
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ const locales = {
|
||||
ko: require(`date-fns/locale/ko`),
|
||||
pt: require(`date-fns/locale/pt`),
|
||||
zh: require(`date-fns/locale/zh_cn`),
|
||||
ru: require(`date-fns/locale/ru`),
|
||||
};
|
||||
|
||||
let callbacks = [];
|
||||
|
||||
+106
-81
@@ -3,15 +3,18 @@ import { observer } from "mobx-react";
|
||||
import { CloseIcon, BackIcon } from "outline-icons";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import ReactModal from "react-modal";
|
||||
import styled, { createGlobalStyle } from "styled-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||
import Flex from "components/Flex";
|
||||
import NudeButton from "components/NudeButton";
|
||||
import Scrollable from "components/Scrollable";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
import useUnmount from "hooks/useUnmount";
|
||||
|
||||
ReactModal.setAppElement("#root");
|
||||
let openModals = 0;
|
||||
|
||||
type Props = {|
|
||||
children?: React.Node,
|
||||
@@ -20,44 +23,6 @@ type Props = {|
|
||||
onRequestClose: () => void,
|
||||
|};
|
||||
|
||||
const GlobalStyles = createGlobalStyle`
|
||||
.ReactModal__Overlay {
|
||||
background-color: ${(props) =>
|
||||
transparentize(0.25, props.theme.background)} !important;
|
||||
z-index: ${(props) => props.theme.depths.modalOverlay};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
.ReactModalPortal + .ReactModalPortal,
|
||||
.ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
|
||||
.ReactModal__Overlay {
|
||||
margin-left: 12px;
|
||||
box-shadow: 0 -2px 10px ${(props) => props.theme.shadow};
|
||||
border-radius: 8px 0 0 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal,
|
||||
.ReactModalPortal + .ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
|
||||
.ReactModal__Overlay {
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + .ReactModalPortal,
|
||||
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
|
||||
.ReactModal__Overlay {
|
||||
margin-left: 36px;
|
||||
}
|
||||
}
|
||||
`};
|
||||
|
||||
.ReactModal__Body--open {
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
|
||||
const Modal = ({
|
||||
children,
|
||||
isOpen,
|
||||
@@ -65,35 +30,112 @@ const Modal = ({
|
||||
onRequestClose,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const dialog = useDialogState({ animated: 250 });
|
||||
const [depth, setDepth] = React.useState(0);
|
||||
const wasOpen = usePrevious(isOpen);
|
||||
const { t } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!wasOpen && isOpen) {
|
||||
setDepth(openModals++);
|
||||
dialog.show();
|
||||
}
|
||||
if (wasOpen && !isOpen) {
|
||||
setDepth(openModals--);
|
||||
dialog.hide();
|
||||
}
|
||||
}, [dialog, wasOpen, isOpen]);
|
||||
|
||||
useUnmount(() => {
|
||||
if (isOpen) {
|
||||
openModals--;
|
||||
}
|
||||
});
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalStyles />
|
||||
<StyledModal
|
||||
contentLabel={title}
|
||||
onRequestClose={onRequestClose}
|
||||
isOpen={isOpen}
|
||||
{...rest}
|
||||
>
|
||||
<Content>
|
||||
<Centered onClick={(ev) => ev.stopPropagation()} column>
|
||||
{title && <h1>{title}</h1>}
|
||||
{children}
|
||||
</Centered>
|
||||
</Content>
|
||||
<Back onClick={onRequestClose}>
|
||||
<BackIcon size={32} color="currentColor" />
|
||||
<Text>Back</Text>
|
||||
</Back>
|
||||
<Close onClick={onRequestClose}>
|
||||
<CloseIcon size={32} color="currentColor" />
|
||||
</Close>
|
||||
</StyledModal>
|
||||
</>
|
||||
<DialogBackdrop {...dialog}>
|
||||
{(props) => (
|
||||
<Backdrop {...props}>
|
||||
<Dialog
|
||||
{...dialog}
|
||||
aria-label={title}
|
||||
preventBodyScrollhideOnEsc
|
||||
hide={onRequestClose}
|
||||
>
|
||||
{(props) => (
|
||||
<Scene
|
||||
$nested={!!depth}
|
||||
style={{ marginLeft: `${depth * 12}px` }}
|
||||
{...props}
|
||||
>
|
||||
<Content>
|
||||
<Centered onClick={(ev) => ev.stopPropagation()} column>
|
||||
{title && <h1>{title}</h1>}
|
||||
{children}
|
||||
</Centered>
|
||||
</Content>
|
||||
<Back onClick={onRequestClose}>
|
||||
<BackIcon size={32} color="currentColor" />
|
||||
<Text>{t("Back")}</Text>
|
||||
</Back>
|
||||
<Close onClick={onRequestClose}>
|
||||
<CloseIcon size={32} color="currentColor" />
|
||||
</Close>
|
||||
</Scene>
|
||||
)}
|
||||
</Dialog>
|
||||
</Backdrop>
|
||||
)}
|
||||
</DialogBackdrop>
|
||||
);
|
||||
};
|
||||
|
||||
const Backdrop = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: ${(props) =>
|
||||
transparentize(0.25, props.theme.background)} !important;
|
||||
z-index: ${(props) => props.theme.depths.modalOverlay};
|
||||
transition: opacity 50ms ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
&[data-enter] {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Scene = styled.div`
|
||||
animation: ${fadeAndScaleIn} 250ms ease;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: ${(props) => props.theme.depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
outline: none;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
${(props) =>
|
||||
props.$nested &&
|
||||
`
|
||||
box-shadow: 0 -2px 10px ${props.theme.shadow};
|
||||
border-radius: 8px 0 0 8px;
|
||||
overflow: hidden;
|
||||
`}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Content = styled(Scrollable)`
|
||||
width: 100%;
|
||||
padding: 8vh 2rem 2rem;
|
||||
@@ -110,23 +152,6 @@ const Centered = styled(Flex)`
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
const StyledModal = styled(ReactModal)`
|
||||
animation: ${fadeAndScaleIn} 250ms ease;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: ${(props) => props.theme.depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
const Text = styled.span`
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function AlertNotice({ children }: { children: React.Node }) {
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ position: "relative", top: "2px" }}
|
||||
style={{ position: "relative", top: "2px", marginRight: "4px" }}
|
||||
>
|
||||
<path
|
||||
d="M15.6676 11.5372L10.0155 1.14735C9.10744 -0.381434 6.89378 -0.383465 5.98447 1.14735L0.332715 11.5372C-0.595598 13.0994 0.528309 15.0776 2.34778 15.0776H13.652C15.47 15.0776 16.5959 13.101 15.6676 11.5372ZM8 13.2026C7.48319 13.2026 7.0625 12.7819 7.0625 12.2651C7.0625 11.7483 7.48319 11.3276 8 11.3276C8.51681 11.3276 8.9375 11.7483 8.9375 12.2651C8.9375 12.7819 8.51681 13.2026 8 13.2026ZM8.9375 9.45257C8.9375 9.96938 8.51681 10.3901 8 10.3901C7.48319 10.3901 7.0625 9.96938 7.0625 9.45257V4.76507C7.0625 4.24826 7.48319 3.82757 8 3.82757C8.51681 3.82757 8.9375 4.24826 8.9375 4.76507V9.45257Z"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { useTheme } from "styled-components";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
export default function PageTheme() {
|
||||
const { ui } = useStores();
|
||||
const theme = useTheme();
|
||||
|
||||
React.useEffect(() => {
|
||||
// wider page background beyond the React root
|
||||
if (document.body) {
|
||||
document.body.style.background = theme.background;
|
||||
}
|
||||
|
||||
// theme-color adjusts the title bar color for desktop PWA
|
||||
const themeElement = document.querySelector('meta[name="theme-color"]');
|
||||
if (themeElement) {
|
||||
themeElement.setAttribute("content", theme.background);
|
||||
}
|
||||
|
||||
// status bar color for iOS PWA
|
||||
const statusElement = document.querySelector(
|
||||
'meta[name="apple-mobile-web-app-status-bar-style"]'
|
||||
);
|
||||
if (statusElement) {
|
||||
statusElement.setAttribute(
|
||||
"content",
|
||||
ui.resolvedTheme === "dark" ? "black-translucent" : "default"
|
||||
);
|
||||
}
|
||||
|
||||
// user-agent controls and scrollbars
|
||||
const csElement = document.querySelector('meta[name="color-scheme"]');
|
||||
if (csElement) {
|
||||
csElement.setAttribute("content", ui.resolvedTheme);
|
||||
}
|
||||
}, [theme, ui.resolvedTheme]);
|
||||
|
||||
return 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 (
|
||||
<StyledPopover
|
||||
dialogComponent={Dialog}
|
||||
closeOnOutsideScroll
|
||||
closeOnOutsideFocus
|
||||
closeOnEscKey
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import Header from "components/Header";
|
||||
import PageTitle from "components/PageTitle";
|
||||
|
||||
type Props = {|
|
||||
icon?: React.Node,
|
||||
title: React.Node,
|
||||
textTitle?: string,
|
||||
children: React.Node,
|
||||
breadcrumb?: React.Node,
|
||||
actions?: React.Node,
|
||||
|};
|
||||
|
||||
function Scene({
|
||||
title,
|
||||
icon,
|
||||
textTitle,
|
||||
actions,
|
||||
breadcrumb,
|
||||
children,
|
||||
}: Props) {
|
||||
return (
|
||||
<FillWidth>
|
||||
<PageTitle title={textTitle || title} />
|
||||
<Header
|
||||
title={
|
||||
icon ? (
|
||||
<>
|
||||
{icon} {title}
|
||||
</>
|
||||
) : (
|
||||
title
|
||||
)
|
||||
}
|
||||
actions={actions}
|
||||
breadcrumb={breadcrumb}
|
||||
/>
|
||||
<CenteredContent withStickyHeader>{children}</CenteredContent>
|
||||
</FillWidth>
|
||||
);
|
||||
}
|
||||
|
||||
const FillWidth = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export default Scene;
|
||||
@@ -8,9 +8,10 @@ type Props = {|
|
||||
shadow?: boolean,
|
||||
topShadow?: boolean,
|
||||
bottomShadow?: boolean,
|
||||
flex?: boolean,
|
||||
|};
|
||||
|
||||
function Scrollable({ shadow, topShadow, bottomShadow, ...rest }: Props) {
|
||||
function Scrollable({ shadow, topShadow, bottomShadow, flex, ...rest }: Props) {
|
||||
const ref = React.useRef<?HTMLDivElement>();
|
||||
const [topShadowVisible, setTopShadow] = React.useState(false);
|
||||
const [bottomShadowVisible, setBottomShadow] = React.useState(false);
|
||||
@@ -42,6 +43,7 @@ function Scrollable({ shadow, topShadow, bottomShadow, ...rest }: Props) {
|
||||
<Wrapper
|
||||
ref={ref}
|
||||
onScroll={updateShadows}
|
||||
$flex={flex}
|
||||
$topShadowVisible={topShadowVisible}
|
||||
$bottomShadowVisible={bottomShadowVisible}
|
||||
{...rest}
|
||||
@@ -50,6 +52,8 @@ function Scrollable({ shadow, topShadow, bottomShadow, ...rest }: Props) {
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: ${(props) => (props.$flex ? "flex" : "block")};
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
+132
-123
@@ -12,19 +12,21 @@ import {
|
||||
SettingsIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import CollectionNew from "scenes/CollectionNew";
|
||||
import Invite from "scenes/Invite";
|
||||
import Bubble from "components/Bubble";
|
||||
import Flex from "components/Flex";
|
||||
import Modal from "components/Modal";
|
||||
import Scrollable from "components/Scrollable";
|
||||
import Sidebar from "./Sidebar";
|
||||
import Bubble from "./components/Bubble";
|
||||
import Collections from "./components/Collections";
|
||||
import HeaderBlock from "./components/HeaderBlock";
|
||||
import Section from "./components/Section";
|
||||
import SidebarLink from "./components/SidebarLink";
|
||||
import TeamButton from "./components/TeamButton";
|
||||
import useStores from "hooks/useStores";
|
||||
import AccountMenu from "menus/AccountMenu";
|
||||
|
||||
@@ -63,139 +65,146 @@ function MainSidebar() {
|
||||
setInviteModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const [dndArea, setDndArea] = React.useState();
|
||||
const handleSidebarRef = React.useCallback((node) => setDndArea(node), []);
|
||||
const html5Options = React.useMemo(() => ({ rootElement: dndArea }), [
|
||||
dndArea,
|
||||
]);
|
||||
|
||||
const { user, team } = auth;
|
||||
if (!user || !team) return null;
|
||||
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<AccountMenu>
|
||||
{(props) => (
|
||||
<HeaderBlock
|
||||
{...props}
|
||||
subheading={user.name}
|
||||
teamName={team.name}
|
||||
logoUrl={team.avatarUrl}
|
||||
showDisclosure
|
||||
/>
|
||||
)}
|
||||
</AccountMenu>
|
||||
<Flex auto column>
|
||||
<Scrollable shadow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to="/home"
|
||||
icon={<HomeIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to={{
|
||||
pathname: "/search",
|
||||
state: { fromMenu: true },
|
||||
}}
|
||||
icon={<SearchIcon color="currentColor" />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/starred"
|
||||
icon={<StarredIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Starred")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/templates"
|
||||
icon={<ShapesIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Templates")}
|
||||
active={documents.active ? documents.active.template : undefined}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/drafts"
|
||||
icon={<EditIcon color="currentColor" />}
|
||||
label={
|
||||
<Drafts align="center">
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 && (
|
||||
<Bubble count={documents.totalDrafts} />
|
||||
)}
|
||||
</Drafts>
|
||||
}
|
||||
active={
|
||||
documents.active
|
||||
? !documents.active.publishedAt &&
|
||||
!documents.active.isDeleted &&
|
||||
!documents.active.isTemplate
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<Collections onCreateCollection={handleCreateCollectionModalOpen} />
|
||||
</Section>
|
||||
</Scrollable>
|
||||
<Secondary>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to="/archive"
|
||||
icon={<ArchiveIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
active={
|
||||
documents.active
|
||||
? documents.active.isArchived && !documents.active.isDeleted
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/trash"
|
||||
icon={<TrashIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Trash")}
|
||||
active={documents.active ? documents.active.isDeleted : undefined}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings"
|
||||
icon={<SettingsIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Settings")}
|
||||
/>
|
||||
{can.invite && (
|
||||
<SidebarLink
|
||||
to="/settings/people"
|
||||
onClick={handleInviteModalOpen}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label={`${t("Invite people")}…`}
|
||||
<Sidebar ref={handleSidebarRef}>
|
||||
{dndArea && (
|
||||
<DndProvider backend={HTML5Backend} options={html5Options}>
|
||||
<AccountMenu>
|
||||
{(props) => (
|
||||
<TeamButton
|
||||
{...props}
|
||||
subheading={user.name}
|
||||
teamName={team.name}
|
||||
logoUrl={team.avatarUrl}
|
||||
showDisclosure
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Secondary>
|
||||
</Flex>
|
||||
<Modal
|
||||
title={t("Invite people")}
|
||||
onRequestClose={handleInviteModalClose}
|
||||
isOpen={inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={handleInviteModalClose} />
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Create a collection")}
|
||||
onRequestClose={handleCreateCollectionModalClose}
|
||||
isOpen={createCollectionModalOpen}
|
||||
>
|
||||
<CollectionNew onSubmit={handleCreateCollectionModalClose} />
|
||||
</Modal>
|
||||
</AccountMenu>
|
||||
<Scrollable flex topShadow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to="/home"
|
||||
icon={<HomeIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to={{
|
||||
pathname: "/search",
|
||||
state: { fromMenu: true },
|
||||
}}
|
||||
icon={<SearchIcon color="currentColor" />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/starred"
|
||||
icon={<StarredIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Starred")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/templates"
|
||||
icon={<ShapesIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Templates")}
|
||||
active={
|
||||
documents.active ? documents.active.template : undefined
|
||||
}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/drafts"
|
||||
icon={<EditIcon color="currentColor" />}
|
||||
label={
|
||||
<Drafts align="center">
|
||||
{t("Drafts")}
|
||||
<Bubble count={documents.totalDrafts} />
|
||||
</Drafts>
|
||||
}
|
||||
active={
|
||||
documents.active
|
||||
? !documents.active.publishedAt &&
|
||||
!documents.active.isDeleted &&
|
||||
!documents.active.isTemplate
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
<Section auto>
|
||||
<Collections
|
||||
onCreateCollection={handleCreateCollectionModalOpen}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to="/archive"
|
||||
icon={<ArchiveIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
active={
|
||||
documents.active
|
||||
? documents.active.isArchived && !documents.active.isDeleted
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/trash"
|
||||
icon={<TrashIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Trash")}
|
||||
active={
|
||||
documents.active ? documents.active.isDeleted : undefined
|
||||
}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings"
|
||||
icon={<SettingsIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Settings")}
|
||||
/>
|
||||
{can.inviteUser && (
|
||||
<SidebarLink
|
||||
to="/settings/people"
|
||||
onClick={handleInviteModalOpen}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label={`${t("Invite people")}…`}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Scrollable>
|
||||
{can.inviteUser && (
|
||||
<Modal
|
||||
title={t("Invite people")}
|
||||
onRequestClose={handleInviteModalClose}
|
||||
isOpen={inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={handleInviteModalClose} />
|
||||
</Modal>
|
||||
)}
|
||||
<Modal
|
||||
title={t("Create a collection")}
|
||||
onRequestClose={handleCreateCollectionModalClose}
|
||||
isOpen={createCollectionModalOpen}
|
||||
>
|
||||
<CollectionNew onSubmit={handleCreateCollectionModalClose} />
|
||||
</Modal>
|
||||
</DndProvider>
|
||||
)}
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const Secondary = styled.div`
|
||||
overflow-x: hidden;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Drafts = styled(Flex)`
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
@@ -21,9 +21,9 @@ import Scrollable from "components/Scrollable";
|
||||
|
||||
import Sidebar from "./Sidebar";
|
||||
import Header from "./components/Header";
|
||||
import HeaderBlock from "./components/HeaderBlock";
|
||||
import Section from "./components/Section";
|
||||
import SidebarLink from "./components/SidebarLink";
|
||||
import TeamButton from "./components/TeamButton";
|
||||
import Version from "./components/Version";
|
||||
import SlackIcon from "./icons/Slack";
|
||||
import ZapierIcon from "./icons/Zapier";
|
||||
@@ -46,7 +46,7 @@ function SettingsSidebar() {
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<HeaderBlock
|
||||
<TeamButton
|
||||
subheading={
|
||||
<ReturnToApp align="center">
|
||||
<BackIcon color="currentColor" /> {t("Return to App")}
|
||||
@@ -112,9 +112,9 @@ function SettingsSidebar() {
|
||||
/>
|
||||
{can.export && (
|
||||
<SidebarLink
|
||||
to="/settings/export"
|
||||
to="/settings/import-export"
|
||||
icon={<DocumentIcon color="currentColor" />}
|
||||
label={t("Export Data")}
|
||||
label={`${t("Import")} / ${t("Export")}`}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
+183
-151
@@ -3,180 +3,205 @@ import { observer } from "mobx-react";
|
||||
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 { useLocation } from "react-router-dom";
|
||||
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 as CollapseButton,
|
||||
} from "./components/CollapseToggle";
|
||||
import ResizeBorder from "./components/ResizeBorder";
|
||||
import ResizeHandle from "./components/ResizeHandle";
|
||||
import Toggle, { ToggleButton, Positioner } from "./components/Toggle";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
let firstRender = true;
|
||||
let BOUNCE_ANIMATION_MS = 250;
|
||||
let ANIMATION_MS = 250;
|
||||
let isFirstRender = true;
|
||||
|
||||
type Props = {
|
||||
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 Sidebar = React.forwardRef<Props, HTMLButtonElement>(
|
||||
({ children }: Props, ref) => {
|
||||
const [isCollapsing, setCollapsing] = React.useState(false);
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
const location = useLocation();
|
||||
const previousLocation = usePrevious(location);
|
||||
|
||||
const handleDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
// suppresses text selection
|
||||
event.preventDefault();
|
||||
const width = ui.sidebarWidth;
|
||||
const collapsed = ui.isEditing || ui.sidebarCollapsed;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
const setWidth = ui.setSidebarWidth;
|
||||
|
||||
// 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 [offset, setOffset] = React.useState(0);
|
||||
const [isAnimating, setAnimating] = React.useState(false);
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
const isSmallerThanMinimum = width < minWidth;
|
||||
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
setResizing(false);
|
||||
const handleDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
// suppresses text selection
|
||||
event.preventDefault();
|
||||
|
||||
if (isSmallerThanMinimum) {
|
||||
setWidth(minWidth);
|
||||
setAnimating(true);
|
||||
} else {
|
||||
setWidth(width);
|
||||
}
|
||||
}, [isSmallerThanMinimum, minWidth, width, setWidth]);
|
||||
// this is simple because the sidebar is always against the left edge
|
||||
const width = Math.min(event.pageX - offset, maxWidth);
|
||||
const isSmallerThanCollapsePoint = width < minWidth / 2;
|
||||
|
||||
const handleStartDrag = React.useCallback(
|
||||
(event) => {
|
||||
setOffset(event.pageX - width);
|
||||
setResizing(true);
|
||||
setAnimating(false);
|
||||
},
|
||||
[width]
|
||||
);
|
||||
if (isSmallerThanCollapsePoint) {
|
||||
setWidth(theme.sidebarCollapsedWidth);
|
||||
} else {
|
||||
setWidth(width);
|
||||
}
|
||||
},
|
||||
[theme, offset, minWidth, maxWidth, setWidth]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isAnimating) {
|
||||
setTimeout(() => setAnimating(false), BOUNCE_ANIMATION_MS);
|
||||
}
|
||||
}, [isAnimating]);
|
||||
const handleStopDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
setResizing(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isResizing) {
|
||||
document.addEventListener("mousemove", handleDrag);
|
||||
document.addEventListener("mouseup", handleStopDrag);
|
||||
}
|
||||
if (document.activeElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleDrag);
|
||||
document.removeEventListener("mouseup", handleStopDrag);
|
||||
};
|
||||
}, [isResizing, handleDrag, handleStopDrag]);
|
||||
if (isSmallerThanMinimum) {
|
||||
const isSmallerThanCollapsePoint = width < minWidth / 2;
|
||||
|
||||
return { isAnimating, isSmallerThanMinimum, isResizing, handleStartDrag };
|
||||
};
|
||||
if (isSmallerThanCollapsePoint) {
|
||||
setAnimating(false);
|
||||
setCollapsing(true);
|
||||
ui.collapseSidebar();
|
||||
} else {
|
||||
setWidth(minWidth);
|
||||
setAnimating(true);
|
||||
}
|
||||
} else {
|
||||
setWidth(width);
|
||||
}
|
||||
},
|
||||
[ui, isSmallerThanMinimum, minWidth, width, setWidth]
|
||||
);
|
||||
|
||||
function Sidebar({ location, children }: Props) {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
const previousLocation = usePrevious(location);
|
||||
const handleMouseDown = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
setOffset(event.pageX - width);
|
||||
setResizing(true);
|
||||
setAnimating(false);
|
||||
},
|
||||
[width]
|
||||
);
|
||||
|
||||
const width = ui.sidebarWidth;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
const collapsed = ui.editMode || ui.sidebarCollapsed;
|
||||
React.useEffect(() => {
|
||||
if (isAnimating) {
|
||||
setTimeout(() => setAnimating(false), ANIMATION_MS);
|
||||
}
|
||||
}, [isAnimating]);
|
||||
|
||||
const {
|
||||
isAnimating,
|
||||
isSmallerThanMinimum,
|
||||
isResizing,
|
||||
handleStartDrag,
|
||||
} = useResize({
|
||||
width,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
setWidth: ui.setSidebarWidth,
|
||||
});
|
||||
React.useEffect(() => {
|
||||
if (isCollapsing) {
|
||||
setTimeout(() => {
|
||||
setWidth(minWidth);
|
||||
setCollapsing(false);
|
||||
}, ANIMATION_MS);
|
||||
}
|
||||
}, [setWidth, minWidth, isCollapsing]);
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
ui.setSidebarWidth(theme.sidebarWidth);
|
||||
}, [ui, theme.sidebarWidth]);
|
||||
React.useEffect(() => {
|
||||
if (isResizing) {
|
||||
document.addEventListener("mousemove", handleDrag);
|
||||
document.addEventListener("mouseup", handleStopDrag);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
ui.setSidebarResizing(isResizing);
|
||||
}, [ui, isResizing]);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleDrag);
|
||||
document.removeEventListener("mouseup", handleStopDrag);
|
||||
};
|
||||
}, [isResizing, handleDrag, handleStopDrag]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (location !== previousLocation) {
|
||||
ui.hideMobileSidebar();
|
||||
}
|
||||
}, [ui, location, previousLocation]);
|
||||
const handleReset = React.useCallback(() => {
|
||||
ui.setSidebarWidth(theme.sidebarWidth);
|
||||
}, [ui, theme.sidebarWidth]);
|
||||
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
width: `${width}px`,
|
||||
left:
|
||||
collapsed && !ui.mobileSidebarVisible
|
||||
? `${-width + theme.sidebarCollapsedWidth}px`
|
||||
: 0,
|
||||
}),
|
||||
[width, collapsed, theme.sidebarCollapsedWidth, ui.mobileSidebarVisible]
|
||||
);
|
||||
React.useEffect(() => {
|
||||
ui.setSidebarResizing(isResizing);
|
||||
}, [ui, isResizing]);
|
||||
|
||||
const content = (
|
||||
<Container
|
||||
style={style}
|
||||
$sidebarWidth={ui.sidebarWidth}
|
||||
$isAnimating={isAnimating}
|
||||
$isSmallerThanMinimum={isSmallerThanMinimum}
|
||||
$mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
$collapsed={collapsed}
|
||||
column
|
||||
>
|
||||
{!isResizing && (
|
||||
<CollapseToggle
|
||||
collapsed={ui.sidebarCollapsed}
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
/>
|
||||
)}
|
||||
{ui.mobileSidebarVisible && (
|
||||
<Portal>
|
||||
<Fade>
|
||||
<Background onClick={ui.toggleMobileSidebar} />
|
||||
</Fade>
|
||||
</Portal>
|
||||
)}
|
||||
React.useEffect(() => {
|
||||
if (location !== previousLocation) {
|
||||
isFirstRender = false;
|
||||
ui.hideMobileSidebar();
|
||||
}
|
||||
}, [ui, location, previousLocation]);
|
||||
|
||||
{children}
|
||||
{!ui.sidebarCollapsed && (
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
width: `${width}px`,
|
||||
}),
|
||||
[width]
|
||||
);
|
||||
|
||||
const toggleStyle = React.useMemo(
|
||||
() => ({
|
||||
right: "auto",
|
||||
marginLeft: `${collapsed ? theme.sidebarCollapsedWidth : width}px`,
|
||||
}),
|
||||
[width, theme.sidebarCollapsedWidth, collapsed]
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{ui.mobileSidebarVisible && (
|
||||
<Portal>
|
||||
<Fade>
|
||||
<Background onClick={ui.toggleMobileSidebar} />
|
||||
</Fade>
|
||||
</Portal>
|
||||
)}
|
||||
{children}
|
||||
<ResizeBorder
|
||||
onMouseDown={handleStartDrag}
|
||||
onDoubleClick={handleReset}
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={ui.sidebarCollapsed ? undefined : handleReset}
|
||||
$isResizing={isResizing}
|
||||
/>
|
||||
{ui.sidebarCollapsed && !ui.isEditing && (
|
||||
<Toggle
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
direction={"right"}
|
||||
aria-label={t("Expand")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container
|
||||
ref={ref}
|
||||
style={style}
|
||||
$sidebarWidth={ui.sidebarWidth}
|
||||
$isCollapsing={isCollapsing}
|
||||
$isAnimating={isAnimating}
|
||||
$isSmallerThanMinimum={isSmallerThanMinimum}
|
||||
$mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
$collapsed={collapsed}
|
||||
column
|
||||
>
|
||||
<ResizeHandle aria-label={t("Resize sidebar")} />
|
||||
</ResizeBorder>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
|
||||
// Fade in the sidebar on first render after page load
|
||||
if (firstRender) {
|
||||
firstRender = false;
|
||||
return <Fade>{content}</Fade>;
|
||||
{isFirstRender ? <Fade>{content}</Fade> : content}
|
||||
</Container>
|
||||
{!ui.isEditing && (
|
||||
<Toggle
|
||||
style={toggleStyle}
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
direction={ui.sidebarCollapsed ? "right" : "left"}
|
||||
aria-label={ui.sidebarCollapsed ? t("Expand") : t("Collapse")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
);
|
||||
|
||||
const Background = styled.a`
|
||||
position: fixed;
|
||||
@@ -195,29 +220,36 @@ const Container = styled(Flex)`
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
transition: box-shadow, 100ms, ease-in-out, margin-left 100ms ease-out,
|
||||
left 100ms ease-out,
|
||||
transition: box-shadow 100ms ease-in-out, transform 100ms ease-out,
|
||||
${(props) => props.theme.backgroundTransition}
|
||||
${(props) =>
|
||||
props.$isAnimating ? `,width ${BOUNCE_ANIMATION_MS}ms ease-out` : ""};
|
||||
margin-left: ${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")};
|
||||
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
|
||||
transform: translateX(
|
||||
${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")}
|
||||
);
|
||||
z-index: ${(props) => props.theme.depths.sidebar};
|
||||
max-width: 70%;
|
||||
min-width: 280px;
|
||||
|
||||
${Positioner} {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
left: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
margin: 0;
|
||||
z-index: 3;
|
||||
min-width: 0;
|
||||
transform: translateX(${(props) =>
|
||||
props.$collapsed ? "calc(-100% + 16px)" : 0});
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
left: 0 !important;
|
||||
transform: none;
|
||||
box-shadow: ${(props) =>
|
||||
props.$collapsed
|
||||
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
|
||||
@@ -225,11 +257,11 @@ const Container = styled(Flex)`
|
||||
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
|
||||
: "none"};
|
||||
|
||||
& ${CollapseButton} {
|
||||
opacity: .75;
|
||||
${Positioner} {
|
||||
display: block;
|
||||
}
|
||||
|
||||
& ${CollapseButton}:hover {
|
||||
${ToggleButton} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -241,4 +273,4 @@ const Container = styled(Flex)`
|
||||
`};
|
||||
`;
|
||||
|
||||
export default withRouter(observer(Sidebar));
|
||||
export default observer(Sidebar);
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
// @flow
|
||||
import { NextIcon, BackIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import { meta } from "utils/keyboard";
|
||||
|
||||
type Props = {|
|
||||
collapsed: boolean,
|
||||
onClick?: (event: SyntheticEvent<>) => void,
|
||||
|};
|
||||
|
||||
function CollapseToggle({ collapsed, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
tooltip={collapsed ? t("Expand") : t("Collapse")}
|
||||
shortcut={`${meta}+.`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button {...rest} tabIndex="-1" aria-hidden>
|
||||
{collapsed ? (
|
||||
<NextIcon color="currentColor" />
|
||||
) : (
|
||||
<BackIcon color="currentColor" />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export const Button = styled.button`
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
right: 8px;
|
||||
border: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
z-index: 1;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
background: transparent;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.white};
|
||||
background: ${(props) => props.theme.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
export default CollapseToggle;
|
||||
@@ -1,9 +1,9 @@
|
||||
// @flow
|
||||
import fractionalIndex from "fractional-index";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useDrop, useDrag } from "react-dnd";
|
||||
import styled from "styled-components";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Collection from "models/Collection";
|
||||
import Document from "models/Document";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
@@ -18,10 +18,12 @@ import CollectionSortMenu from "menus/CollectionSortMenu";
|
||||
|
||||
type Props = {|
|
||||
collection: Collection,
|
||||
ui: UiStore,
|
||||
canUpdate: boolean,
|
||||
activeDocument: ?Document,
|
||||
prefetchDocument: (id: string) => Promise<void>,
|
||||
belowCollection: Collection | void,
|
||||
isDraggingAnyCollection: boolean,
|
||||
onChangeDragging: (dragging: boolean) => void,
|
||||
|};
|
||||
|
||||
function CollectionLink({
|
||||
@@ -29,7 +31,9 @@ function CollectionLink({
|
||||
activeDocument,
|
||||
prefetchDocument,
|
||||
canUpdate,
|
||||
ui,
|
||||
belowCollection,
|
||||
isDraggingAnyCollection,
|
||||
onChangeDragging,
|
||||
}: Props) {
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
|
||||
@@ -40,10 +44,23 @@ function CollectionLink({
|
||||
[collection]
|
||||
);
|
||||
|
||||
const { documents, policies } = useStores();
|
||||
const expanded = collection.id === ui.activeCollectionId;
|
||||
const { ui, documents, policies, collections } = useStores();
|
||||
|
||||
const [expanded, setExpanded] = React.useState(
|
||||
collection.id === ui.activeCollectionId
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isDraggingAnyCollection) {
|
||||
setExpanded(false);
|
||||
} else {
|
||||
setExpanded(collection.id === ui.activeCollectionId);
|
||||
}
|
||||
}, [isDraggingAnyCollection, collection.id, ui.activeCollectionId]);
|
||||
|
||||
const manualSort = collection.sort.field === "index";
|
||||
const can = policies.abilities(collection.id);
|
||||
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
|
||||
|
||||
// Drop to re-parent
|
||||
const [{ isOver, canDrop }, drop] = useDrop({
|
||||
@@ -74,49 +91,101 @@ function CollectionLink({
|
||||
}),
|
||||
});
|
||||
|
||||
// Drop to reorder Collection
|
||||
const [{ isCollectionDropping }, dropToReorderCollection] = useDrop({
|
||||
accept: "collection",
|
||||
drop: async (item, monitor) => {
|
||||
collections.move(
|
||||
item.id,
|
||||
fractionalIndex(collection.index, belowCollectionIndex)
|
||||
);
|
||||
},
|
||||
canDrop: (item, monitor) => {
|
||||
return (
|
||||
collection.id !== item.id &&
|
||||
(!belowCollection || item.id !== belowCollection.id)
|
||||
);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isCollectionDropping: monitor.isOver(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Drag to reorder Collection
|
||||
const [{ isCollectionDragging }, dragToReorderCollection] = useDrag({
|
||||
type: "collection",
|
||||
item: () => {
|
||||
onChangeDragging(true);
|
||||
return {
|
||||
id: collection.id,
|
||||
};
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isCollectionDragging: monitor.isDragging(),
|
||||
}),
|
||||
canDrag: (monitor) => {
|
||||
return can.move;
|
||||
},
|
||||
end: (monitor) => {
|
||||
onChangeDragging(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={drop} style={{ position: "relative" }}>
|
||||
<DropToImport key={collection.id} collectionId={collection.id}>
|
||||
<SidebarLinkWithPadding
|
||||
key={collection.id}
|
||||
to={collection.url}
|
||||
icon={
|
||||
<CollectionIcon collection={collection} expanded={expanded} />
|
||||
}
|
||||
iconColor={collection.color}
|
||||
expanded={expanded}
|
||||
showActions={menuOpen || expanded}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={collection.name}
|
||||
onSubmit={handleTitleChange}
|
||||
canUpdate={canUpdate}
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
menu={
|
||||
<>
|
||||
{can.update && (
|
||||
<CollectionSortMenuWithMargin
|
||||
<Draggable
|
||||
key={collection.id}
|
||||
ref={dragToReorderCollection}
|
||||
$isDragging={isCollectionDragging}
|
||||
$isMoving={isCollectionDragging}
|
||||
>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<SidebarLinkWithPadding
|
||||
to={collection.url}
|
||||
icon={
|
||||
<CollectionIcon collection={collection} expanded={expanded} />
|
||||
}
|
||||
iconColor={collection.color}
|
||||
expanded={expanded}
|
||||
showActions={menuOpen || expanded}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={collection.name}
|
||||
onSubmit={handleTitleChange}
|
||||
canUpdate={canUpdate}
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
menu={
|
||||
<>
|
||||
{can.update && (
|
||||
<CollectionSortMenuWithMargin
|
||||
collection={collection}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</DropToImport>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</DropToImport>
|
||||
</Draggable>
|
||||
{expanded && manualSort && (
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
)}
|
||||
{isDraggingAnyCollection && (
|
||||
<DropCursor
|
||||
isActiveDrop={isCollectionDropping}
|
||||
innerRef={dropToReorderCollection}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded &&
|
||||
@@ -136,6 +205,11 @@ function CollectionLink({
|
||||
);
|
||||
}
|
||||
|
||||
const Draggable = styled("div")`
|
||||
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)};
|
||||
pointer-events: ${(props) => (props.$isMoving ? "none" : "auto")};
|
||||
`;
|
||||
|
||||
const SidebarLinkWithPadding = styled(SidebarLink)`
|
||||
padding-right: 60px;
|
||||
`;
|
||||
|
||||
@@ -1,100 +1,98 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import fractionalIndex from "fractional-index";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import keydown from "react-keydown";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import useStores from "../../../hooks/useStores";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import CollectionsLoading from "./CollectionsLoading";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Header from "./Header";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
policies: PoliciesStore,
|
||||
collections: CollectionsStore,
|
||||
documents: DocumentsStore,
|
||||
onCreateCollection: () => void,
|
||||
ui: UiStore,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
class Collections extends React.Component<Props> {
|
||||
isPreloaded: boolean = !!this.props.collections.orderedData.length;
|
||||
function Collections({ onCreateCollection }: Props) {
|
||||
const { ui, policies, documents, collections } = useStores();
|
||||
const isPreloaded: boolean = !!collections.orderedData.length;
|
||||
const { t } = useTranslation();
|
||||
const orderedCollections = collections.orderedData;
|
||||
const [isDraggingAnyCollection, setIsDraggingAnyCollection] = React.useState(
|
||||
false
|
||||
);
|
||||
|
||||
componentDidMount() {
|
||||
const { collections } = this.props;
|
||||
|
||||
if (!collections.isFetching && !collections.isLoaded) {
|
||||
React.useEffect(() => {
|
||||
if (!collections.isLoaded) {
|
||||
collections.fetchPage({ limit: 100 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@keydown("n")
|
||||
goToNewDocument() {
|
||||
if (this.props.ui.editMode) return;
|
||||
const [{ isCollectionDropping }, dropToReorderCollection] = useDrop({
|
||||
accept: "collection",
|
||||
drop: async (item, monitor) => {
|
||||
collections.move(
|
||||
item.id,
|
||||
fractionalIndex(null, orderedCollections[0].index)
|
||||
);
|
||||
},
|
||||
canDrop: (item, monitor) => {
|
||||
return item.id !== orderedCollections[0].id;
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isCollectionDropping: monitor.isOver(),
|
||||
}),
|
||||
});
|
||||
|
||||
const { activeCollectionId } = this.props.ui;
|
||||
if (!activeCollectionId) return;
|
||||
|
||||
const can = this.props.policies.abilities(activeCollectionId);
|
||||
if (!can.update) return;
|
||||
|
||||
this.props.history.push(newDocumentUrl(activeCollectionId));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { collections, ui, policies, documents, t } = this.props;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{collections.orderedData.map((collection) => (
|
||||
<CollectionLink
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
canUpdate={policies.abilities(collection.id).update}
|
||||
ui={ui}
|
||||
/>
|
||||
))}
|
||||
<SidebarLink
|
||||
to="/collections"
|
||||
onClick={this.props.onCreateCollection}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label={`${t("New collection")}…`}
|
||||
exact
|
||||
const content = (
|
||||
<>
|
||||
<DropCursor
|
||||
isActiveDrop={isCollectionDropping}
|
||||
innerRef={dropToReorderCollection}
|
||||
from="collections"
|
||||
/>
|
||||
{orderedCollections.map((collection, index) => (
|
||||
<CollectionLink
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
canUpdate={policies.abilities(collection.id).update}
|
||||
ui={ui}
|
||||
isDraggingAnyCollection={isDraggingAnyCollection}
|
||||
onChangeDragging={setIsDraggingAnyCollection}
|
||||
belowCollection={orderedCollections[index + 1]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
))}
|
||||
<SidebarLink
|
||||
to="/collections"
|
||||
onClick={onCreateCollection}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label={`${t("New collection")}…`}
|
||||
exact
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return (
|
||||
<Flex column>
|
||||
<Header>{t("Collections")}</Header>
|
||||
{collections.isLoaded ? (
|
||||
this.isPreloaded ? (
|
||||
content
|
||||
) : (
|
||||
<Fade>{content}</Fade>
|
||||
)
|
||||
) : (
|
||||
<CollectionsLoading />
|
||||
)}
|
||||
<CollectionsLoading />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<Header>{t("Collections")}</Header>
|
||||
{isPreloaded ? content : <Fade>{content}</Fade>}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default withTranslation()<Collections>(
|
||||
inject("collections", "ui", "documents", "policies")(withRouter(Collections))
|
||||
);
|
||||
export default observer(Collections);
|
||||
|
||||
@@ -123,7 +123,8 @@ function DocumentLink({
|
||||
|
||||
// Draggable
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
item: { type: "document", ...node, depth, active: isActiveDocument },
|
||||
type: "document",
|
||||
item: () => ({ ...node, depth, active: isActiveDocument }),
|
||||
collect: (monitor) => ({
|
||||
isDragging: !!monitor.isDragging(),
|
||||
}),
|
||||
@@ -146,7 +147,7 @@ function DocumentLink({
|
||||
// Drop to re-parent
|
||||
const [{ isOverReparent, canDropToReparent }, dropToReparent] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item, monitor) => {
|
||||
drop: (item, monitor) => {
|
||||
if (monitor.didDrop()) return;
|
||||
if (!collection) return;
|
||||
documents.move(item.id, collection.id, node.id);
|
||||
@@ -183,7 +184,7 @@ function DocumentLink({
|
||||
// Drop to reorder
|
||||
const [{ isOverReorder }, dropToReorder] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item, monitor) => {
|
||||
drop: (item, monitor) => {
|
||||
if (!collection) return;
|
||||
if (item.id === node.id) return;
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ function DropCursor({
|
||||
isActiveDrop,
|
||||
innerRef,
|
||||
theme,
|
||||
from,
|
||||
}: {
|
||||
isActiveDrop: boolean,
|
||||
innerRef: React.Ref<any>,
|
||||
theme: Theme,
|
||||
from: string,
|
||||
}) {
|
||||
return <Cursor isOver={isActiveDrop} ref={innerRef} />;
|
||||
return <Cursor isOver={isActiveDrop} ref={innerRef} from={from} />;
|
||||
}
|
||||
|
||||
// transparent hover zone with a thin visible band vertically centered
|
||||
@@ -25,7 +27,7 @@ const Cursor = styled("div")`
|
||||
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
bottom: -7px;
|
||||
${(props) => (props.from === "collections" ? "top: 15px;" : "bottom: -7px;")}
|
||||
background: transparent;
|
||||
|
||||
::after {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
import ResizeHandle from "./ResizeHandle";
|
||||
|
||||
const ResizeBorder = styled.div`
|
||||
position: absolute;
|
||||
@@ -9,20 +8,6 @@ const ResizeBorder = styled.div`
|
||||
right: -6px;
|
||||
width: 12px;
|
||||
cursor: ew-resize;
|
||||
|
||||
${(props) =>
|
||||
props.$isResizing &&
|
||||
`
|
||||
${ResizeHandle} {
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
|
||||
&:hover {
|
||||
${ResizeHandle} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default ResizeBorder;
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
const ResizeHandle = styled.button`
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
transform: translateY(-50%);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
height: 40px;
|
||||
right: -10px;
|
||||
width: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -24px;
|
||||
bottom: -24px;
|
||||
left: -12px;
|
||||
right: -12px;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: ${(props) => props.theme.sidebarText};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
pointer-events: all;
|
||||
cursor: ew-resize;
|
||||
`}
|
||||
`;
|
||||
|
||||
export default ResizeHandle;
|
||||
@@ -5,9 +5,13 @@ import Flex from "components/Flex";
|
||||
const Section = styled(Flex)`
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
margin: 20px 8px;
|
||||
margin: 0 8px 20px;
|
||||
min-width: ${(props) => props.theme.sidebarMinWidth}px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default Section;
|
||||
|
||||
+4
-5
@@ -13,7 +13,7 @@ type Props = {|
|
||||
logoUrl: string,
|
||||
|};
|
||||
|
||||
const HeaderBlock = React.forwardRef<Props, any>(
|
||||
const TeamButton = React.forwardRef<Props, any>(
|
||||
({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => (
|
||||
<Wrapper>
|
||||
<Header justify="flex-start" align="center" ref={ref} {...rest}>
|
||||
@@ -25,8 +25,7 @@ const HeaderBlock = React.forwardRef<Props, any>(
|
||||
/>
|
||||
<Flex align="flex-start" column>
|
||||
<TeamName showDisclosure>
|
||||
{teamName}{" "}
|
||||
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
|
||||
{teamName} {showDisclosure && <Disclosure color="currentColor" />}
|
||||
</TeamName>
|
||||
<Subheading>{subheading}</Subheading>
|
||||
</Flex>
|
||||
@@ -35,7 +34,7 @@ const HeaderBlock = React.forwardRef<Props, any>(
|
||||
)
|
||||
);
|
||||
|
||||
const StyledExpandedIcon = styled(ExpandedIcon)`
|
||||
const Disclosure = styled(ExpandedIcon)`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
@@ -84,4 +83,4 @@ const Header = styled.button`
|
||||
}
|
||||
`;
|
||||
|
||||
export default HeaderBlock;
|
||||
export default TeamButton;
|
||||
@@ -0,0 +1,66 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Arrow from "components/Arrow";
|
||||
|
||||
type Props = {
|
||||
direction: "left" | "right",
|
||||
style?: Object,
|
||||
onClick?: () => any,
|
||||
};
|
||||
|
||||
const Toggle = React.forwardRef<Props, HTMLButtonElement>(
|
||||
({ direction = "left", onClick, style }: Props, ref) => {
|
||||
return (
|
||||
<Positioner style={style}>
|
||||
<ToggleButton ref={ref} $direction={direction} onClick={onClick}>
|
||||
<Arrow />
|
||||
</ToggleButton>
|
||||
</Positioner>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const ToggleButton = styled.button`
|
||||
opacity: 0;
|
||||
background: none;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
transform: translateY(-50%)
|
||||
scaleX(${(props) => (props.$direction === "left" ? 1 : -1)});
|
||||
position: fixed;
|
||||
top: 50vh;
|
||||
padding: 8px;
|
||||
border: 0;
|
||||
pointer-events: none;
|
||||
color: ${(props) => props.theme.divider};
|
||||
|
||||
&:active {
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
pointer-events: all;
|
||||
cursor: pointer;
|
||||
`}
|
||||
`;
|
||||
|
||||
export const Positioner = styled.div`
|
||||
display: none;
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: -30px;
|
||||
width: 30px;
|
||||
|
||||
&:hover ${ToggleButton}, &:focus-within ${ToggleButton} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: block;
|
||||
`}
|
||||
`;
|
||||
|
||||
export default Toggle;
|
||||
@@ -272,6 +272,13 @@ class SocketProvider extends React.Component<Props> {
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("collections.update_index", (event) => {
|
||||
const collection = collections.get(event.collectionId);
|
||||
if (collection) {
|
||||
collection.updateIndex(event.index);
|
||||
}
|
||||
});
|
||||
|
||||
// received a message from the API server that we should request
|
||||
// to join a specific room. Forward that to the ws server.
|
||||
this.socket.on("join", (event) => {
|
||||
|
||||
@@ -2,19 +2,18 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
children: React.Node,
|
||||
};
|
||||
sticky?: boolean,
|
||||
|};
|
||||
|
||||
const H3 = styled.h3`
|
||||
border-bottom: 1px solid ${(props) => props.theme.divider};
|
||||
margin-top: 22px;
|
||||
margin-bottom: 12px;
|
||||
margin: 12px 0;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Underline = styled("span")`
|
||||
const Underline = styled.div`
|
||||
margin-top: -1px;
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
@@ -22,14 +21,29 @@ const Underline = styled("span")`
|
||||
line-height: 1.5;
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
border-bottom: 3px solid ${(props) => props.theme.textSecondary};
|
||||
padding-bottom: 5px;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 4px;
|
||||
`;
|
||||
|
||||
const Subheading = ({ children, ...rest }: Props) => {
|
||||
// When sticky we need extra background coverage around the sides otherwise
|
||||
// items that scroll past can "stick out" the sides of the heading
|
||||
const Background = styled.div`
|
||||
position: ${(props) => (props.sticky ? "sticky" : "relative")};
|
||||
${(props) => (props.sticky ? "top: 54px;" : "")}
|
||||
margin: 0 -8px;
|
||||
padding: 0 8px;
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const Subheading = ({ children, sticky, ...rest }: Props) => {
|
||||
return (
|
||||
<H3 {...rest}>
|
||||
<Underline>{children}</Underline>
|
||||
</H3>
|
||||
<Background sticky={sticky}>
|
||||
<H3 {...rest}>
|
||||
<Underline>{children}</Underline>
|
||||
</H3>
|
||||
</Background>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -13,17 +13,23 @@ type Props = {|
|
||||
id?: string,
|
||||
|};
|
||||
|
||||
function Switch({ width = 38, height = 20, label, ...props }: Props) {
|
||||
function Switch({ width = 38, height = 20, label, disabled, ...props }: Props) {
|
||||
const component = (
|
||||
<Wrapper width={width} height={height}>
|
||||
<HiddenInput type="checkbox" width={width} height={height} {...props} />
|
||||
<HiddenInput
|
||||
type="checkbox"
|
||||
width={width}
|
||||
height={height}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
<Slider width={width} height={height} />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
if (label) {
|
||||
return (
|
||||
<Label htmlFor={props.id}>
|
||||
<Label disabled={disabled} htmlFor={props.id}>
|
||||
{component}
|
||||
<LabelText>{label}</LabelText>
|
||||
</Label>
|
||||
@@ -36,6 +42,8 @@ function Switch({ width = 38, height = 20, label, ...props }: Props) {
|
||||
const Label = styled.label`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
${(props) => (props.disabled ? `opacity: 0.75;` : "")}
|
||||
`;
|
||||
|
||||
const Wrapper = styled.label`
|
||||
@@ -79,6 +87,11 @@ const HiddenInput = styled.input`
|
||||
height: 0;
|
||||
visibility: hidden;
|
||||
|
||||
&:disabled + ${Slider} {
|
||||
opacity: 0.75;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:checked + ${Slider} {
|
||||
background-color: ${(props) => props.theme.primary};
|
||||
}
|
||||
|
||||
@@ -8,15 +8,15 @@ type Props = {
|
||||
theme: Theme,
|
||||
};
|
||||
|
||||
const StyledNavLink = styled(NavLink)`
|
||||
const TabLink = styled(NavLink)`
|
||||
position: relative;
|
||||
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
margin-right: 24px;
|
||||
padding-bottom: 8px;
|
||||
padding: 6px 0;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
@@ -32,7 +32,7 @@ function Tab({ theme, ...rest }: Props) {
|
||||
color: theme.textSecondary,
|
||||
};
|
||||
|
||||
return <StyledNavLink {...rest} activeStyle={activeStyle} />;
|
||||
return <TabLink {...rest} activeStyle={activeStyle} />;
|
||||
}
|
||||
|
||||
export default withTheme(Tab);
|
||||
|
||||
+24
-4
@@ -1,13 +1,25 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Tabs = styled.nav`
|
||||
position: relative;
|
||||
const Nav = styled.nav`
|
||||
border-bottom: 1px solid ${(props) => props.theme.divider};
|
||||
margin-top: 22px;
|
||||
margin-bottom: 12px;
|
||||
margin: 12px 0;
|
||||
overflow-y: auto;
|
||||
white-space: nowrap;
|
||||
transition: opacity 250ms ease-out;
|
||||
`;
|
||||
|
||||
// When sticky we need extra background coverage around the sides otherwise
|
||||
// items that scroll past can "stick out" the sides of the heading
|
||||
const Sticky = styled.div`
|
||||
position: sticky;
|
||||
top: 54px;
|
||||
margin: 0 -8px;
|
||||
padding: 0 8px;
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
export const Separator = styled.span`
|
||||
@@ -18,4 +30,12 @@ export const Separator = styled.span`
|
||||
margin-top: 6px;
|
||||
`;
|
||||
|
||||
const Tabs = (props: {}) => {
|
||||
return (
|
||||
<Sticky>
|
||||
<Nav {...props}></Nav>
|
||||
</Sticky>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tabs;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Toast from "./components/Toast";
|
||||
import Toast from "components/Toast";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
function Toasts() {
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import Toasts from "./Toasts";
|
||||
export default Toasts;
|
||||
@@ -0,0 +1,31 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
const URL_REGEX = new RegExp("https?://cawemo.com/(?:share|embed)/(.*)$");
|
||||
|
||||
type Props = {|
|
||||
attrs: {|
|
||||
href: string,
|
||||
matches: string[],
|
||||
|},
|
||||
|};
|
||||
|
||||
export default class Cawemo extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
const { matches } = this.props.attrs;
|
||||
const shareId = matches[1];
|
||||
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={`https://cawemo.com/embed/${shareId}`}
|
||||
title={"Cawemo Embed"}
|
||||
border
|
||||
allowfullscreen
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import Cawemo from "./Cawemo";
|
||||
|
||||
describe("Cawemo", () => {
|
||||
const match = Cawemo.ENABLED[0];
|
||||
test("to be enabled on embed link", () => {
|
||||
expect(
|
||||
"https://cawemo.com/embed/a82e9f22-e283-4253-8d11".match(match)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("to be enabled on share link", () => {
|
||||
expect(
|
||||
"https://cawemo.com/embed/a82e9f22-e283-4253-8d11".match(match)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("to not be enabled elsewhere", () => {
|
||||
expect("https://cawemo.com/".match(match)).toBe(null);
|
||||
expect("https://cawemo.com/diagrams".match(match)).toBe(null);
|
||||
});
|
||||
});
|
||||
+15
-2
@@ -15,13 +15,23 @@ type Props = {|
|
||||
|
||||
class Gist extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
ref = React.createRef<HTMLIFrameElement>();
|
||||
|
||||
get id() {
|
||||
const gistUrl = new URL(this.props.attrs.href);
|
||||
return gistUrl.pathname.split("/")[2];
|
||||
}
|
||||
|
||||
updateIframeContent = (iframe: ?HTMLIFrameElement) => {
|
||||
componentDidMount() {
|
||||
this.updateIframeContent();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.updateIframeContent();
|
||||
}
|
||||
|
||||
updateIframeContent = () => {
|
||||
const iframe = this.ref.current;
|
||||
if (!iframe) return;
|
||||
const id = this.id;
|
||||
|
||||
@@ -39,6 +49,8 @@ class Gist extends React.Component<Props> {
|
||||
"<style>*{ font-size:12px; } body { margin: 0; } .gist .blob-wrapper.data { max-height:150px; overflow:auto; }</style>";
|
||||
const iframeHtml = `<html><head><base target="_parent">${styles}</head><body>${gistScript}</body></html>`;
|
||||
|
||||
if (!doc) return;
|
||||
|
||||
doc.open();
|
||||
doc.writeln(iframeHtml);
|
||||
doc.close();
|
||||
@@ -50,13 +62,14 @@ class Gist extends React.Component<Props> {
|
||||
return (
|
||||
<iframe
|
||||
className={this.props.isSelected ? "ProseMirror-selectednode" : ""}
|
||||
ref={this.updateIframeContent}
|
||||
ref={this.ref}
|
||||
type="text/html"
|
||||
frameBorder="0"
|
||||
width="100%"
|
||||
height="200px"
|
||||
id={`gist-${id}`}
|
||||
title={`Github Gist (${id})`}
|
||||
onLoad={this.updateIframeContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ type Props = {|
|
||||
|},
|
||||
|};
|
||||
|
||||
export default class GoogleSlides extends React.Component<Props> {
|
||||
export default class GoogleSheets extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
|
||||
@@ -4,6 +4,7 @@ import styled from "styled-components";
|
||||
import Image from "components/Image";
|
||||
import Abstract from "./Abstract";
|
||||
import Airtable from "./Airtable";
|
||||
import Cawemo from "./Cawemo";
|
||||
import ClickUp from "./ClickUp";
|
||||
import Codepen from "./Codepen";
|
||||
import Figma from "./Figma";
|
||||
@@ -61,9 +62,18 @@ export default [
|
||||
component: Airtable,
|
||||
matcher: matcher(Airtable),
|
||||
},
|
||||
{
|
||||
title: "Cawemo",
|
||||
keywords: "bpmn process",
|
||||
defaultHidden: true,
|
||||
icon: () => <Img src="/images/cawemo.png" />,
|
||||
component: Cawemo,
|
||||
matcher: matcher(Cawemo),
|
||||
},
|
||||
{
|
||||
title: "ClickUp",
|
||||
keywords: "project",
|
||||
defaultHidden: true,
|
||||
icon: () => <Img src="/images/clickup.png" />,
|
||||
component: ClickUp,
|
||||
matcher: matcher(ClickUp),
|
||||
@@ -133,6 +143,7 @@ export default [
|
||||
{
|
||||
title: "InVision",
|
||||
keywords: "design prototype",
|
||||
defaultHidden: true,
|
||||
icon: () => <Img src="/images/invision.png" />,
|
||||
component: InVision,
|
||||
matcher: matcher(InVision),
|
||||
@@ -175,6 +186,7 @@ export default [
|
||||
{
|
||||
title: "Mode",
|
||||
keywords: "analytics",
|
||||
defaultHidden: true,
|
||||
icon: () => <Img src="/images/mode-analytics.png" />,
|
||||
component: ModeAnalytics,
|
||||
matcher: matcher(ModeAnalytics),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
export default function useDebouncedCallback(
|
||||
callback: (any) => mixed,
|
||||
wait: number
|
||||
) {
|
||||
// track args & timeout handle between calls
|
||||
const argsRef = React.useRef();
|
||||
const timeout = React.useRef();
|
||||
|
||||
function cleanup() {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
}
|
||||
|
||||
// make sure our timeout gets cleared if consuming component gets unmounted
|
||||
React.useEffect(() => cleanup, []);
|
||||
|
||||
return function (...args: any) {
|
||||
argsRef.current = args;
|
||||
cleanup();
|
||||
|
||||
timeout.current = setTimeout(() => {
|
||||
if (argsRef.current) {
|
||||
callback(...argsRef.current);
|
||||
}
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
const useUnmount = (callback: Function) => {
|
||||
const ref = React.useRef(callback);
|
||||
|
||||
ref.current = callback;
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
ref.current();
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
export default useUnmount;
|
||||
+25
-9
@@ -3,13 +3,13 @@ import "focus-visible";
|
||||
import { createBrowserHistory } from "history";
|
||||
import { Provider } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { render } from "react-dom";
|
||||
import { Router } from "react-router-dom";
|
||||
import { initI18n } from "shared/i18n";
|
||||
import stores from "stores";
|
||||
import Analytics from "components/Analytics";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import PageTheme from "components/PageTheme";
|
||||
import ScrollToTop from "components/ScrollToTop";
|
||||
import Theme from "components/Theme";
|
||||
import Toasts from "components/Toasts";
|
||||
@@ -19,30 +19,46 @@ import { initSentry } from "utils/sentry";
|
||||
|
||||
initI18n();
|
||||
|
||||
const element = document.getElementById("root");
|
||||
const element = window.document.getElementById("root");
|
||||
const history = createBrowserHistory();
|
||||
|
||||
if (env.SENTRY_DSN) {
|
||||
initSentry(history);
|
||||
}
|
||||
|
||||
if ("serviceWorker" in window.navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
window.navigator.serviceWorker
|
||||
.register("/static/service-worker.js", {
|
||||
scope: "/",
|
||||
})
|
||||
.then((registration) => {
|
||||
console.log("SW registered: ", registration);
|
||||
})
|
||||
.catch((registrationError) => {
|
||||
console.log("SW registration failed: ", registrationError);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (element) {
|
||||
render(
|
||||
<Provider {...stores}>
|
||||
<Theme>
|
||||
<ErrorBoundary>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<Analytics>
|
||||
<Theme>
|
||||
<ErrorBoundary>
|
||||
<Router history={history}>
|
||||
<>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
</>
|
||||
</Router>
|
||||
</DndProvider>
|
||||
</ErrorBoundary>
|
||||
</Theme>
|
||||
</ErrorBoundary>
|
||||
</Theme>
|
||||
</Analytics>
|
||||
</Provider>,
|
||||
element
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import MenuItem, { MenuAnchor } from "components/ContextMenu/MenuItem";
|
||||
import Separator from "components/ContextMenu/Separator";
|
||||
import Flex from "components/Flex";
|
||||
import Modal from "components/Modal";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
@@ -74,12 +75,19 @@ function AccountMenu(props: Props) {
|
||||
placement: "bottom-start",
|
||||
modal: true,
|
||||
});
|
||||
const { auth } = useStores();
|
||||
const { auth, ui } = useStores();
|
||||
const previousTheme = usePrevious(ui.theme);
|
||||
const { t } = useTranslation();
|
||||
const [keyboardShortcutsOpen, setKeyboardShortcutsOpen] = React.useState(
|
||||
false
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (ui.theme !== previousTheme) {
|
||||
menu.hide();
|
||||
}
|
||||
}, [menu, ui.theme, previousTheme]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
|
||||
+19
-13
@@ -64,6 +64,10 @@ function CollectionMenu({
|
||||
[history, collection.id]
|
||||
);
|
||||
|
||||
const stopPropagation = React.useCallback((ev: SyntheticEvent<>) => {
|
||||
ev.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleImportDocument = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
@@ -81,22 +85,27 @@ function CollectionMenu({
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
const files = getDataTransferFiles(ev);
|
||||
|
||||
// Because this is the onChange handler it's possible for the change to be
|
||||
// from previously selecting a file to not selecting a file – aka empty
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const file = files[0];
|
||||
const document = await documents.import(
|
||||
file,
|
||||
null,
|
||||
this.props.collection.id,
|
||||
{ publish: true }
|
||||
);
|
||||
const document = await documents.import(file, null, collection.id, {
|
||||
publish: true,
|
||||
});
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
ui.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[history, ui, documents]
|
||||
[history, ui, collection.id, documents]
|
||||
);
|
||||
|
||||
const can = policies.abilities(collection.id);
|
||||
@@ -108,7 +117,7 @@ function CollectionMenu({
|
||||
type="file"
|
||||
ref={file}
|
||||
onChange={handleFilePicked}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
onClick={stopPropagation}
|
||||
accept={documents.importFileTypes.join(", ")}
|
||||
tabIndex="-1"
|
||||
/>
|
||||
@@ -146,7 +155,7 @@ function CollectionMenu({
|
||||
onClick: () => setShowCollectionEdit(true),
|
||||
},
|
||||
{
|
||||
title: `${t("Permissions")}…`,
|
||||
title: `${t("Members")}…`,
|
||||
visible: can.update,
|
||||
onClick: () => setShowCollectionMembers(true),
|
||||
},
|
||||
@@ -158,9 +167,6 @@ function CollectionMenu({
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: `${t("Delete")}…`,
|
||||
visible: !!(collection && can.delete),
|
||||
@@ -172,7 +178,7 @@ function CollectionMenu({
|
||||
{renderModals && (
|
||||
<>
|
||||
<Modal
|
||||
title={t("Collection permissions")}
|
||||
title={t("Collection members")}
|
||||
onRequestClose={() => setShowCollectionMembers(false)}
|
||||
isOpen={showCollectionMembers}
|
||||
>
|
||||
|
||||
@@ -4,9 +4,11 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import DocumentDelete from "scenes/DocumentDelete";
|
||||
import DocumentMove from "scenes/DocumentMove";
|
||||
import DocumentShare from "scenes/DocumentShare";
|
||||
import DocumentTemplatize from "scenes/DocumentTemplatize";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
@@ -15,10 +17,11 @@ import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import Flex from "components/Flex";
|
||||
import Modal from "components/Modal";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
import getDataTransferFiles from "utils/getDataTransferFiles";
|
||||
import {
|
||||
documentHistoryUrl,
|
||||
documentMoveUrl,
|
||||
documentUrl,
|
||||
editDocumentUrl,
|
||||
newDocumentUrl,
|
||||
@@ -49,14 +52,22 @@ function DocumentMenu({
|
||||
onOpen,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const { policies, collections, auth, ui } = useStores();
|
||||
const menu = useMenuState({ modal });
|
||||
const team = useCurrentTeam();
|
||||
const { policies, collections, ui, documents } = useStores();
|
||||
const menu = useMenuState({
|
||||
modal,
|
||||
unstable_preventOverflow: true,
|
||||
unstable_fixed: true,
|
||||
unstable_flip: true,
|
||||
});
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const [renderModals, setRenderModals] = React.useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
|
||||
const [showMoveModal, setShowMoveModal] = React.useState(false);
|
||||
const [showTemplateModal, setShowTemplateModal] = React.useState(false);
|
||||
const [showShareModal, setShowShareModal] = React.useState(false);
|
||||
const file = React.useRef<?HTMLInputElement>();
|
||||
|
||||
const handleOpen = React.useCallback(() => {
|
||||
setRenderModals(true);
|
||||
@@ -130,13 +141,76 @@ function DocumentMenu({
|
||||
[document]
|
||||
);
|
||||
|
||||
const can = policies.abilities(document.id);
|
||||
const canShareDocuments = !!(can.share && auth.team && auth.team.sharing);
|
||||
const canViewHistory = can.read && !can.restore;
|
||||
const collection = collections.get(document.collectionId);
|
||||
const can = policies.abilities(document.id);
|
||||
const canShareDocuments = !!(can.share && team.sharing);
|
||||
const canViewHistory = can.read && !can.restore;
|
||||
|
||||
const stopPropagation = React.useCallback((ev: SyntheticEvent<>) => {
|
||||
ev.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleImportDocument = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
// simulate a click on the file upload input element
|
||||
if (file.current) {
|
||||
file.current.click();
|
||||
}
|
||||
},
|
||||
[file]
|
||||
);
|
||||
|
||||
const handleFilePicked = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
const files = getDataTransferFiles(ev);
|
||||
|
||||
// Because this is the onChange handler it's possible for the change to be
|
||||
// from previously selecting a file to not selecting a file – aka empty
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const file = files[0];
|
||||
const importedDocument = await documents.import(
|
||||
file,
|
||||
document.id,
|
||||
collection.id,
|
||||
{
|
||||
publish: true,
|
||||
}
|
||||
);
|
||||
history.push(importedDocument.url);
|
||||
} catch (err) {
|
||||
ui.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[history, ui, collection, documents, document.id]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VisuallyHidden>
|
||||
<input
|
||||
type="file"
|
||||
ref={file}
|
||||
onChange={handleFilePicked}
|
||||
onClick={stopPropagation}
|
||||
accept={documents.importFileTypes.join(", ")}
|
||||
tabIndex="-1"
|
||||
/>
|
||||
</VisuallyHidden>
|
||||
{label ? (
|
||||
<MenuButton {...menu}>{label}</MenuButton>
|
||||
) : (
|
||||
@@ -241,6 +315,11 @@ function DocumentMenu({
|
||||
}),
|
||||
visible: !!can.createChildDocument,
|
||||
},
|
||||
{
|
||||
title: t("Import document"),
|
||||
visible: can.createChildDocument,
|
||||
onClick: handleImportDocument,
|
||||
},
|
||||
{
|
||||
title: `${t("Create template")}…`,
|
||||
onClick: () => setShowTemplateModal(true),
|
||||
@@ -273,7 +352,7 @@ function DocumentMenu({
|
||||
},
|
||||
{
|
||||
title: `${t("Move")}…`,
|
||||
to: documentMoveUrl(document),
|
||||
onClick: () => setShowMoveModal(true),
|
||||
visible: !!can.move,
|
||||
},
|
||||
{
|
||||
@@ -301,6 +380,18 @@ function DocumentMenu({
|
||||
</ContextMenu>
|
||||
{renderModals && (
|
||||
<>
|
||||
<Modal
|
||||
title={t("Move {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowMoveModal(false)}
|
||||
isOpen={showMoveModal}
|
||||
>
|
||||
<DocumentMove
|
||||
document={document}
|
||||
onRequestClose={() => setShowMoveModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
|
||||
@@ -14,9 +14,10 @@ type Props = {|
|
||||
|};
|
||||
|
||||
function UserMenu({ user }: Props) {
|
||||
const { users } = useStores();
|
||||
const { users, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({ modal: true });
|
||||
const can = policies.abilities(user.id);
|
||||
|
||||
const handlePromote = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
@@ -98,14 +99,14 @@ function UserMenu({ user }: Props) {
|
||||
userName: user.name,
|
||||
}),
|
||||
onClick: handleDemote,
|
||||
visible: user.isAdmin,
|
||||
visible: can.demote,
|
||||
},
|
||||
{
|
||||
title: t("Make {{ userName }} an admin…", {
|
||||
userName: user.name,
|
||||
}),
|
||||
onClick: handlePromote,
|
||||
visible: !user.isAdmin && !user.isSuspended,
|
||||
visible: can.promote,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
|
||||
@@ -16,9 +16,11 @@ export default class Collection extends BaseModel {
|
||||
icon: string;
|
||||
color: string;
|
||||
private: boolean;
|
||||
sharing: boolean;
|
||||
index: string;
|
||||
documents: NavigationNode[];
|
||||
createdAt: ?string;
|
||||
updatedAt: ?string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: ?string;
|
||||
sort: { field: string, direction: "asc" | "desc" };
|
||||
url: string;
|
||||
@@ -66,6 +68,11 @@ export default class Collection extends BaseModel {
|
||||
travelDocuments(this.documents);
|
||||
}
|
||||
|
||||
@action
|
||||
updateIndex(index: string) {
|
||||
this.index = index;
|
||||
}
|
||||
|
||||
getDocumentChildren(documentId: string): NavigationNode[] {
|
||||
let result = [];
|
||||
const traveler = (nodes) => {
|
||||
@@ -112,9 +119,11 @@ export default class Collection extends BaseModel {
|
||||
"name",
|
||||
"color",
|
||||
"description",
|
||||
"sharing",
|
||||
"icon",
|
||||
"private",
|
||||
"sort",
|
||||
"index",
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
+1
-7
@@ -6,8 +6,6 @@ class Team extends BaseModel {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
slackConnected: boolean;
|
||||
googleConnected: boolean;
|
||||
sharing: boolean;
|
||||
documentEmbeds: boolean;
|
||||
guestSignin: boolean;
|
||||
@@ -17,11 +15,7 @@ class Team extends BaseModel {
|
||||
|
||||
@computed
|
||||
get signinMethods(): string {
|
||||
if (this.slackConnected && this.googleConnected) {
|
||||
return "Slack or Google";
|
||||
}
|
||||
if (this.slackConnected) return "Slack";
|
||||
return "Google";
|
||||
return "SSO";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ import * as React from "react";
|
||||
import { Switch, Redirect, type Match } from "react-router-dom";
|
||||
import Archive from "scenes/Archive";
|
||||
import Collection from "scenes/Collection";
|
||||
import Dashboard from "scenes/Dashboard";
|
||||
import KeyedDocument from "scenes/Document/KeyedDocument";
|
||||
import DocumentNew from "scenes/DocumentNew";
|
||||
import Drafts from "scenes/Drafts";
|
||||
import Error404 from "scenes/Error404";
|
||||
import Home from "scenes/Home";
|
||||
import Search from "scenes/Search";
|
||||
import Starred from "scenes/Starred";
|
||||
import Templates from "scenes/Templates";
|
||||
@@ -37,8 +37,8 @@ export default function AuthenticatedRoutes() {
|
||||
<Layout>
|
||||
<Switch>
|
||||
<Redirect from="/dashboard" to="/home" />
|
||||
<Route path="/home/:tab" component={Dashboard} />
|
||||
<Route path="/home" component={Dashboard} />
|
||||
<Route path="/home/:tab" component={Home} />
|
||||
<Route path="/home" component={Home} />
|
||||
<Route exact path="/starred" component={Starred} />
|
||||
<Route exact path="/starred/:sort" component={Starred} />
|
||||
<Route exact path="/templates" component={Templates} />
|
||||
|
||||
@@ -3,8 +3,8 @@ import * as React from "react";
|
||||
import { Switch } from "react-router-dom";
|
||||
import Settings from "scenes/Settings";
|
||||
import Details from "scenes/Settings/Details";
|
||||
import Export from "scenes/Settings/Export";
|
||||
import Groups from "scenes/Settings/Groups";
|
||||
import ImportExport from "scenes/Settings/ImportExport";
|
||||
import Notifications from "scenes/Settings/Notifications";
|
||||
import People from "scenes/Settings/People";
|
||||
import Security from "scenes/Settings/Security";
|
||||
@@ -28,7 +28,7 @@ export default function SettingsRoutes() {
|
||||
<Route exact path="/settings/notifications" component={Notifications} />
|
||||
<Route exact path="/settings/integrations/slack" component={Slack} />
|
||||
<Route exact path="/settings/integrations/zapier" component={Zapier} />
|
||||
<Route exact path="/settings/export" component={Export} />
|
||||
<Route exact path="/settings/import-export" component={ImportExport} />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
+10
-16
@@ -1,40 +1,34 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ArchiveIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import Empty from "components/Empty";
|
||||
import Heading from "components/Heading";
|
||||
import PageTitle from "components/PageTitle";
|
||||
import PaginatedDocumentList from "components/PaginatedDocumentList";
|
||||
import Scene from "components/Scene";
|
||||
import Subheading from "components/Subheading";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
documents: DocumentsStore,
|
||||
};
|
||||
|
||||
function Archive(props: Props) {
|
||||
function Archive() {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = props;
|
||||
const { documents } = useStores();
|
||||
|
||||
return (
|
||||
<CenteredContent column auto>
|
||||
<PageTitle title={t("Archive")} />
|
||||
<Scene icon={<ArchiveIcon color="currentColor" />} title={t("Archive")}>
|
||||
<Heading>{t("Archive")}</Heading>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.archived}
|
||||
fetch={documents.fetchArchived}
|
||||
heading={<Subheading>{t("Documents")}</Subheading>}
|
||||
heading={<Subheading sticky>{t("Documents")}</Subheading>}
|
||||
empty={
|
||||
<Empty>{t("The document archive is empty at the moment.")}</Empty>
|
||||
}
|
||||
showCollection
|
||||
showTemplate
|
||||
/>
|
||||
</CenteredContent>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default inject("documents")(observer(Archive));
|
||||
export default observer(Archive);
|
||||
|
||||
+182
-195
@@ -1,28 +1,27 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
|
||||
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";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Collection from "models/Collection";
|
||||
|
||||
import CollectionEdit from "scenes/CollectionEdit";
|
||||
import CollectionMembers from "scenes/CollectionMembers";
|
||||
import Search from "scenes/Search";
|
||||
import Actions, { Action, Separator } from "components/Actions";
|
||||
import { Action, Separator } from "components/Actions";
|
||||
import Badge from "components/Badge";
|
||||
import Button from "components/Button";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import CollectionDescription from "components/CollectionDescription";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import DocumentList from "components/DocumentList";
|
||||
import Editor from "components/Editor";
|
||||
import Flex from "components/Flex";
|
||||
import Heading from "components/Heading";
|
||||
import HelpText from "components/HelpText";
|
||||
@@ -30,14 +29,13 @@ import InputSearch from "components/InputSearch";
|
||||
import { ListPlaceholder } from "components/LoadingPlaceholder";
|
||||
import Mask from "components/Mask";
|
||||
import Modal from "components/Modal";
|
||||
import PageTitle from "components/PageTitle";
|
||||
import PaginatedDocumentList from "components/PaginatedDocumentList";
|
||||
import Scene from "components/Scene";
|
||||
import Subheading from "components/Subheading";
|
||||
import Tab from "components/Tab";
|
||||
import Tabs from "components/Tabs";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import CollectionMenu from "menus/CollectionMenu";
|
||||
import { type Theme } from "types";
|
||||
import { AuthorizationError } from "utils/errors";
|
||||
import { newDocumentUrl, collectionUrl } from "utils/routeHelpers";
|
||||
|
||||
@@ -47,7 +45,6 @@ type Props = {
|
||||
collections: CollectionsStore,
|
||||
policies: PoliciesStore,
|
||||
match: Match,
|
||||
theme: Theme,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@@ -57,7 +54,6 @@ class CollectionScene extends React.Component<Props> {
|
||||
@observable isFetching: boolean = true;
|
||||
@observable permissionsModalOpen: boolean = false;
|
||||
@observable editModalOpen: boolean = false;
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
componentDidMount() {
|
||||
const { id } = this.props.match.params;
|
||||
@@ -108,14 +104,6 @@ class CollectionScene extends React.Component<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
onNewDocument = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
|
||||
if (this.collection) {
|
||||
this.redirectTo = newDocumentUrl(this.collection.id);
|
||||
}
|
||||
};
|
||||
|
||||
onPermissions = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.permissionsModalOpen = true;
|
||||
@@ -138,7 +126,7 @@ class CollectionScene extends React.Component<Props> {
|
||||
const can = policies.abilities(match.params.id || "");
|
||||
|
||||
return (
|
||||
<Actions align="center" justify="flex-end">
|
||||
<>
|
||||
{can.update && (
|
||||
<>
|
||||
<Action>
|
||||
@@ -157,7 +145,12 @@ class CollectionScene extends React.Component<Props> {
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button onClick={this.onNewDocument} icon={<PlusIcon />}>
|
||||
<Button
|
||||
as={Link}
|
||||
to={this.collection ? newDocumentUrl(this.collection.id) : ""}
|
||||
disabled={!this.collection}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -181,14 +174,13 @@ class CollectionScene extends React.Component<Props> {
|
||||
)}
|
||||
/>
|
||||
</Action>
|
||||
</Actions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { documents, theme, t } = this.props;
|
||||
const { documents, t } = this.props;
|
||||
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
if (!this.isFetching && !this.collection) return <Search notFound />;
|
||||
|
||||
const pinnedDocuments = this.collection
|
||||
@@ -197,181 +189,181 @@ class CollectionScene extends React.Component<Props> {
|
||||
const collection = this.collection;
|
||||
const collectionName = collection ? collection.name : "";
|
||||
const hasPinnedDocuments = !!pinnedDocuments.length;
|
||||
const hasDescription = collection ? collection.hasDescription : false;
|
||||
|
||||
return (
|
||||
<CenteredContent>
|
||||
{collection ? (
|
||||
return collection ? (
|
||||
<Scene
|
||||
textTitle={collection.name}
|
||||
title={
|
||||
<>
|
||||
<PageTitle title={collection.name} />
|
||||
{collection.isEmpty ? (
|
||||
<Centered column>
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="<em>{{ collectionName }}</em> doesn’t contain any
|
||||
documents yet."
|
||||
values={{ collectionName }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
<br />
|
||||
<Trans>Get started by creating a new one!</Trans>
|
||||
</HelpText>
|
||||
<Wrapper>
|
||||
<Link to={newDocumentUrl(collection.id)}>
|
||||
<Button icon={<NewDocumentIcon color={theme.buttonText} />}>
|
||||
{t("Create a document")}
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{collection.private && (
|
||||
<Button onClick={this.onPermissions} neutral>
|
||||
{t("Manage members")}…
|
||||
</Button>
|
||||
)}
|
||||
</Wrapper>
|
||||
<Modal
|
||||
title={t("Collection permissions")}
|
||||
onRequestClose={this.handlePermissionsModalClose}
|
||||
isOpen={this.permissionsModalOpen}
|
||||
>
|
||||
<CollectionMembers
|
||||
collection={this.collection}
|
||||
onSubmit={this.handlePermissionsModalClose}
|
||||
onEdit={this.handleEditModalOpen}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Edit collection")}
|
||||
onRequestClose={this.handleEditModalClose}
|
||||
isOpen={this.editModalOpen}
|
||||
>
|
||||
<CollectionEdit
|
||||
collection={this.collection}
|
||||
onSubmit={this.handleEditModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
</Centered>
|
||||
) : (
|
||||
<>
|
||||
<Heading>
|
||||
<CollectionIcon collection={collection} size={40} expanded />{" "}
|
||||
{collection.name}
|
||||
</Heading>
|
||||
|
||||
{hasDescription && (
|
||||
<React.Suspense fallback={<p>Loading…</p>}>
|
||||
<Editor
|
||||
id={collection.id}
|
||||
key={collection.description}
|
||||
defaultValue={collection.description}
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
)}
|
||||
|
||||
{hasPinnedDocuments && (
|
||||
<>
|
||||
<Subheading>
|
||||
<TinyPinIcon size={18} /> {t("Pinned")}
|
||||
</Subheading>
|
||||
<DocumentList documents={pinnedDocuments} showPin />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tabs>
|
||||
<Tab to={collectionUrl(collection.id)} exact>
|
||||
{t("Documents")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "updated")} exact>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "published")} exact>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "old")} exact>
|
||||
{t("Least recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "alphabetical")} exact>
|
||||
{t("A–Z")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Switch>
|
||||
<Route path={collectionUrl(collection.id, "alphabetical")}>
|
||||
<PaginatedDocumentList
|
||||
key="alphabetical"
|
||||
documents={documents.alphabeticalInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchAlphabetical}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "old")}>
|
||||
<PaginatedDocumentList
|
||||
key="old"
|
||||
documents={documents.leastRecentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchLeastRecentlyUpdated}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "recent")}>
|
||||
<Redirect to={collectionUrl(collection.id, "published")} />
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "published")}>
|
||||
<PaginatedDocumentList
|
||||
key="published"
|
||||
documents={documents.recentlyPublishedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyPublished}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPublished
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "updated")}>
|
||||
<PaginatedDocumentList
|
||||
key="updated"
|
||||
documents={documents.recentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyUpdated}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id)} exact>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.rootInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
sort: collection.sort.field,
|
||||
direction: "ASC",
|
||||
}}
|
||||
showNestedDocuments
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
)}
|
||||
|
||||
{this.renderActions()}
|
||||
<CollectionIcon collection={collection} expanded />
|
||||
|
||||
{collection.name}
|
||||
</>
|
||||
}
|
||||
actions={this.renderActions()}
|
||||
>
|
||||
{collection.isEmpty ? (
|
||||
<Centered column>
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="<em>{{ collectionName }}</em> doesn’t contain any
|
||||
documents yet."
|
||||
values={{ collectionName }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
<br />
|
||||
<Trans>Get started by creating a new one!</Trans>
|
||||
</HelpText>
|
||||
<Empty>
|
||||
<Link to={newDocumentUrl(collection.id)}>
|
||||
<Button icon={<NewDocumentIcon color="currentColor" />}>
|
||||
{t("Create a document")}
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{collection.private && (
|
||||
<Button onClick={this.onPermissions} neutral>
|
||||
{t("Manage members")}…
|
||||
</Button>
|
||||
)}
|
||||
</Empty>
|
||||
<Modal
|
||||
title={t("Collection members")}
|
||||
onRequestClose={this.handlePermissionsModalClose}
|
||||
isOpen={this.permissionsModalOpen}
|
||||
>
|
||||
<CollectionMembers
|
||||
collection={this.collection}
|
||||
onSubmit={this.handlePermissionsModalClose}
|
||||
onEdit={this.handleEditModalOpen}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Edit collection")}
|
||||
onRequestClose={this.handleEditModalClose}
|
||||
isOpen={this.editModalOpen}
|
||||
>
|
||||
<CollectionEdit
|
||||
collection={this.collection}
|
||||
onSubmit={this.handleEditModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
</Centered>
|
||||
) : (
|
||||
<>
|
||||
<Heading>
|
||||
<Mask height={35} />
|
||||
<CollectionIcon collection={collection} size={40} expanded />{" "}
|
||||
{collection.name}{" "}
|
||||
{collection.private && (
|
||||
<Tooltip
|
||||
tooltip={t(
|
||||
"This collection is only visible to people given access"
|
||||
)}
|
||||
placement="bottom"
|
||||
>
|
||||
<Badge>{t("Private")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Heading>
|
||||
<ListPlaceholder count={5} />
|
||||
<CollectionDescription collection={collection} />
|
||||
|
||||
{hasPinnedDocuments && (
|
||||
<>
|
||||
<Subheading sticky>
|
||||
<TinyPinIcon size={18} /> {t("Pinned")}
|
||||
</Subheading>
|
||||
<DocumentList documents={pinnedDocuments} showPin />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tabs>
|
||||
<Tab to={collectionUrl(collection.id)} exact>
|
||||
{t("Documents")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "updated")} exact>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "published")} exact>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "old")} exact>
|
||||
{t("Least recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "alphabetical")} exact>
|
||||
{t("A–Z")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Switch>
|
||||
<Route path={collectionUrl(collection.id, "alphabetical")}>
|
||||
<PaginatedDocumentList
|
||||
key="alphabetical"
|
||||
documents={documents.alphabeticalInCollection(collection.id)}
|
||||
fetch={documents.fetchAlphabetical}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "old")}>
|
||||
<PaginatedDocumentList
|
||||
key="old"
|
||||
documents={documents.leastRecentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchLeastRecentlyUpdated}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "recent")}>
|
||||
<Redirect to={collectionUrl(collection.id, "published")} />
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "published")}>
|
||||
<PaginatedDocumentList
|
||||
key="published"
|
||||
documents={documents.recentlyPublishedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyPublished}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPublished
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "updated")}>
|
||||
<PaginatedDocumentList
|
||||
key="updated"
|
||||
documents={documents.recentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyUpdated}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id)} exact>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.rootInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
sort: collection.sort.field,
|
||||
direction: "ASC",
|
||||
}}
|
||||
showNestedDocuments
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
)}
|
||||
</Scene>
|
||||
) : (
|
||||
<CenteredContent>
|
||||
<Heading>
|
||||
<Mask height={35} />
|
||||
</Heading>
|
||||
<ListPlaceholder count={5} />
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
@@ -390,16 +382,11 @@ const TinyPinIcon = styled(PinIcon)`
|
||||
opacity: 0.8;
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
const Empty = styled(Flex)`
|
||||
justify-content: center;
|
||||
margin: 10px 0;
|
||||
`;
|
||||
|
||||
export default withTranslation()<CollectionScene>(
|
||||
inject(
|
||||
"collections",
|
||||
"policies",
|
||||
"documents",
|
||||
"ui"
|
||||
)(withTheme(CollectionScene))
|
||||
inject("collections", "policies", "documents", "ui")(CollectionScene)
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, Trans, type TFunction } from "react-i18next";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Collection from "models/Collection";
|
||||
import Button from "components/Button";
|
||||
@@ -10,13 +11,13 @@ import Flex from "components/Flex";
|
||||
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 = {
|
||||
collection: Collection,
|
||||
ui: UiStore,
|
||||
auth: AuthStore,
|
||||
onSubmit: () => void,
|
||||
t: TFunction,
|
||||
};
|
||||
@@ -24,7 +25,7 @@ type Props = {
|
||||
@observer
|
||||
class CollectionEdit extends React.Component<Props> {
|
||||
@observable name: string = this.props.collection.name;
|
||||
@observable description: string = this.props.collection.description;
|
||||
@observable sharing: boolean = this.props.collection.sharing;
|
||||
@observable icon: string = this.props.collection.icon;
|
||||
@observable color: string = this.props.collection.color || "#4E5C6E";
|
||||
@observable private: boolean = this.props.collection.private;
|
||||
@@ -40,10 +41,10 @@ class CollectionEdit extends React.Component<Props> {
|
||||
try {
|
||||
await this.props.collection.save({
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
icon: this.icon,
|
||||
color: this.color,
|
||||
private: this.private,
|
||||
sharing: this.sharing,
|
||||
sort: this.sort,
|
||||
});
|
||||
this.props.onSubmit();
|
||||
@@ -65,10 +66,6 @@ class CollectionEdit extends React.Component<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
handleDescriptionChange = (getValue: () => string) => {
|
||||
this.description = getValue();
|
||||
};
|
||||
|
||||
handleNameChange = (ev: SyntheticInputEvent<*>) => {
|
||||
this.name = ev.target.value;
|
||||
};
|
||||
@@ -82,8 +79,13 @@ class CollectionEdit extends React.Component<Props> {
|
||||
this.private = ev.target.checked;
|
||||
};
|
||||
|
||||
handleSharingChange = (ev: SyntheticInputEvent<*>) => {
|
||||
this.sharing = ev.target.checked;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
const { auth, t } = this.props;
|
||||
const teamSharingEnabled = !!auth.team && auth.team.sharing;
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
@@ -111,15 +113,6 @@ class CollectionEdit extends React.Component<Props> {
|
||||
icon={this.icon}
|
||||
/>
|
||||
</Flex>
|
||||
<InputRich
|
||||
id={this.props.collection.id}
|
||||
label={t("Description")}
|
||||
onChange={this.handleDescriptionChange}
|
||||
defaultValue={this.description || ""}
|
||||
placeholder={t("More details about this collection…")}
|
||||
minHeight={68}
|
||||
maxHeight={200}
|
||||
/>
|
||||
<InputSelect
|
||||
label={t("Sort in sidebar")}
|
||||
options={[
|
||||
@@ -140,6 +133,25 @@ class CollectionEdit extends React.Component<Props> {
|
||||
A private collection will only be visible to invited team members.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
<Switch
|
||||
id="sharing"
|
||||
label={t("Public document sharing")}
|
||||
onChange={this.handleSharingChange}
|
||||
checked={this.sharing && teamSharingEnabled}
|
||||
disabled={!teamSharingEnabled}
|
||||
/>
|
||||
<HelpText>
|
||||
{teamSharingEnabled ? (
|
||||
<Trans>
|
||||
When enabled, documents can be shared publicly on the internet.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Public sharing is currently disabled in the team security
|
||||
settings.
|
||||
</Trans>
|
||||
)}
|
||||
</HelpText>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={this.isSaving || !this.props.collection.name}
|
||||
@@ -152,4 +164,6 @@ class CollectionEdit extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation()<CollectionEdit>(inject("ui")(CollectionEdit));
|
||||
export default withTranslation()<CollectionEdit>(
|
||||
inject("ui", "auth")(CollectionEdit)
|
||||
);
|
||||
|
||||
@@ -45,7 +45,7 @@ class AddGroupsToCollection extends React.Component<Props> {
|
||||
this.newGroupModalOpen = false;
|
||||
};
|
||||
|
||||
handleFilter = (ev: SyntheticInputEvent<HTMLInputElement>) => {
|
||||
handleFilter = (ev: SyntheticInputEvent<>) => {
|
||||
this.query = ev.target.value;
|
||||
this.debouncedFetch();
|
||||
};
|
||||
|
||||
@@ -43,7 +43,7 @@ class AddPeopleToCollection extends React.Component<Props> {
|
||||
this.inviteModalOpen = false;
|
||||
};
|
||||
|
||||
handleFilter = (ev: SyntheticInputEvent<HTMLInputElement>) => {
|
||||
handleFilter = (ev: SyntheticInputEvent<>) => {
|
||||
this.query = ev.target.value;
|
||||
this.debouncedFetch();
|
||||
};
|
||||
|
||||
+36
-25
@@ -3,8 +3,9 @@ import { intersection } from "lodash";
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { withTranslation, type TFunction, Trans } from "react-i18next";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Collection from "models/Collection";
|
||||
@@ -13,11 +14,11 @@ import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import IconPicker, { icons } from "components/IconPicker";
|
||||
import Input from "components/Input";
|
||||
import InputRich from "components/InputRich";
|
||||
import Switch from "components/Switch";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
collections: CollectionsStore,
|
||||
onSubmit: () => void,
|
||||
@@ -27,9 +28,9 @@ type Props = {
|
||||
@observer
|
||||
class CollectionNew extends React.Component<Props> {
|
||||
@observable name: string = "";
|
||||
@observable description: string = "";
|
||||
@observable icon: string = "";
|
||||
@observable color: string = "#4E5C6E";
|
||||
@observable sharing: boolean = true;
|
||||
@observable private: boolean = false;
|
||||
@observable isSaving: boolean;
|
||||
hasOpenedIconPicker: boolean = false;
|
||||
@@ -40,7 +41,7 @@ class CollectionNew extends React.Component<Props> {
|
||||
const collection = new Collection(
|
||||
{
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
sharing: this.sharing,
|
||||
icon: this.icon,
|
||||
color: this.color,
|
||||
private: this.private,
|
||||
@@ -59,7 +60,7 @@ class CollectionNew extends React.Component<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
handleNameChange = (ev: SyntheticInputEvent<*>) => {
|
||||
handleNameChange = (ev: SyntheticInputEvent<>) => {
|
||||
this.name = ev.target.value;
|
||||
|
||||
// If the user hasn't picked an icon yet, go ahead and suggest one based on
|
||||
@@ -86,12 +87,12 @@ class CollectionNew extends React.Component<Props> {
|
||||
this.hasOpenedIconPicker = true;
|
||||
};
|
||||
|
||||
handleDescriptionChange = (getValue: () => string) => {
|
||||
this.description = getValue();
|
||||
handlePrivateChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
|
||||
this.private = ev.target.checked;
|
||||
};
|
||||
|
||||
handlePrivateChange = (ev: SyntheticInputEvent<*>) => {
|
||||
this.private = ev.target.checked;
|
||||
handleSharingChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
|
||||
this.sharing = ev.target.checked;
|
||||
};
|
||||
|
||||
handleChange = (color: string, icon: string) => {
|
||||
@@ -100,14 +101,17 @@ class CollectionNew extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
const { t, auth } = this.props;
|
||||
const teamSharingEnabled = !!auth.team && auth.team.sharing;
|
||||
|
||||
return (
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<HelpText>
|
||||
{t(
|
||||
"Collections are for grouping your knowledge base. They work best when organized around a topic or internal team — Product or Engineering for example."
|
||||
)}
|
||||
<Trans>
|
||||
Collections are for grouping your documents. They work best when
|
||||
organized around a topic or internal team — Product or Engineering
|
||||
for example.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
<Flex>
|
||||
<Input
|
||||
@@ -127,14 +131,6 @@ class CollectionNew extends React.Component<Props> {
|
||||
icon={this.icon}
|
||||
/>
|
||||
</Flex>
|
||||
<InputRich
|
||||
label={t("Description")}
|
||||
onChange={this.handleDescriptionChange}
|
||||
defaultValue={this.description || ""}
|
||||
placeholder={t("More details about this collection…")}
|
||||
minHeight={68}
|
||||
maxHeight={200}
|
||||
/>
|
||||
<Switch
|
||||
id="private"
|
||||
label={t("Private collection")}
|
||||
@@ -142,10 +138,25 @@ class CollectionNew extends React.Component<Props> {
|
||||
checked={this.private}
|
||||
/>
|
||||
<HelpText>
|
||||
{t(
|
||||
"A private collection will only be visible to invited team members."
|
||||
)}
|
||||
<Trans>
|
||||
A private collection will only be visible to invited team members.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
{teamSharingEnabled && (
|
||||
<>
|
||||
<Switch
|
||||
id="sharing"
|
||||
label={t("Public document sharing")}
|
||||
onChange={this.handleSharingChange}
|
||||
checked={this.sharing}
|
||||
/>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
When enabled, documents can be shared publicly on the internet.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={this.isSaving || !this.name}>
|
||||
{this.isSaving ? `${t("Creating")}…` : t("Create")}
|
||||
@@ -156,5 +167,5 @@ class CollectionNew extends React.Component<Props> {
|
||||
}
|
||||
|
||||
export default withTranslation()<CollectionNew>(
|
||||
inject("collections", "ui")(withRouter(CollectionNew))
|
||||
inject("collections", "ui", "auth")(withRouter(CollectionNew))
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import Flex from "components/Flex";
|
||||
|
||||
const Container = styled(Flex)`
|
||||
position: relative;
|
||||
margin-top: ${(props) => (props.isShare ? "50px" : "0")};
|
||||
`;
|
||||
|
||||
export default Container;
|
||||
|
||||
@@ -7,7 +7,6 @@ import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import type { RouterHistory, Match } from "react-router-dom";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import { withTheme } from "styled-components";
|
||||
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
@@ -22,7 +21,7 @@ import DocumentComponent from "./Document";
|
||||
import HideSidebar from "./HideSidebar";
|
||||
import Loading from "./Loading";
|
||||
import SocketPresence from "./SocketPresence";
|
||||
import { type LocationWithState, type Theme } from "types";
|
||||
import { type LocationWithState } from "types";
|
||||
import { NotFoundError, OfflineError } from "utils/errors";
|
||||
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
|
||||
import { isInternalUrl } from "utils/urls";
|
||||
@@ -35,7 +34,6 @@ type Props = {|
|
||||
policies: PoliciesStore,
|
||||
revisions: RevisionsStore,
|
||||
ui: UiStore,
|
||||
theme: Theme,
|
||||
history: RouterHistory,
|
||||
|};
|
||||
|
||||
@@ -49,7 +47,6 @@ class DataLoader extends React.Component<Props> {
|
||||
const { documents, match } = this.props;
|
||||
this.document = documents.getByUrl(match.params.documentSlug);
|
||||
this.loadDocument();
|
||||
this.updateBackground();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
@@ -74,13 +71,6 @@ class DataLoader extends React.Component<Props> {
|
||||
) {
|
||||
this.loadRevision();
|
||||
}
|
||||
this.updateBackground();
|
||||
}
|
||||
|
||||
updateBackground() {
|
||||
// ensure the wider page color always matches the theme. This is to
|
||||
// account for share links which don't sit in the wider Layout component
|
||||
window.document.body.style.background = this.props.theme.background;
|
||||
}
|
||||
|
||||
get isEditing() {
|
||||
@@ -266,5 +256,5 @@ export default withRouter(
|
||||
"revisions",
|
||||
"policies",
|
||||
"shares"
|
||||
)(withTheme(DataLoader))
|
||||
)(DataLoader)
|
||||
);
|
||||
|
||||
@@ -9,22 +9,22 @@ import { Prompt, Route, withRouter } from "react-router-dom";
|
||||
import type { RouterHistory, Match } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Document from "models/Document";
|
||||
import Revision from "models/Revision";
|
||||
import DocumentMove from "scenes/DocumentMove";
|
||||
import Branding from "components/Branding";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import Flex from "components/Flex";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
import LoadingPlaceholder from "components/LoadingPlaceholder";
|
||||
import Modal from "components/Modal";
|
||||
import Notice from "components/Notice";
|
||||
import PageTitle from "components/PageTitle";
|
||||
import Time from "components/Time";
|
||||
import Container from "./Container";
|
||||
import Contents from "./Contents";
|
||||
import DocumentMove from "./DocumentMove";
|
||||
import Editor from "./Editor";
|
||||
import Header from "./Header";
|
||||
import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
|
||||
@@ -76,7 +76,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
@observable isPublishing: boolean = false;
|
||||
@observable isDirty: boolean = false;
|
||||
@observable isEmpty: boolean = true;
|
||||
@observable moveModalOpen: boolean = false;
|
||||
@observable lastRevision: number = this.props.document.revision;
|
||||
@observable title: string = this.props.document.title;
|
||||
getEditorText: () => string = () => this.props.document.text;
|
||||
@@ -187,9 +186,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
handleCloseMoveModal = () => (this.moveModalOpen = false);
|
||||
handleOpenMoveModal = () => (this.moveModalOpen = true);
|
||||
|
||||
onSave = async (
|
||||
options: {
|
||||
done?: boolean,
|
||||
@@ -325,8 +321,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
const headings = this.editor.current
|
||||
? this.editor.current.getHeadings()
|
||||
: [];
|
||||
const showContents =
|
||||
(ui.tocVisible && readOnly) || (isShare && !!headings.length);
|
||||
const showContents = ui.tocVisible && readOnly;
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
@@ -339,7 +334,16 @@ class DocumentScene extends React.Component<Props> {
|
||||
<Route
|
||||
path={`${match.url}/move`}
|
||||
component={() => (
|
||||
<DocumentMove document={document} onRequestClose={this.goBack} />
|
||||
<Modal
|
||||
title={`Move ${document.noun}`}
|
||||
onRequestClose={this.goBack}
|
||||
isOpen
|
||||
>
|
||||
<DocumentMove
|
||||
document={document}
|
||||
onRequestClose={this.goBack}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
/>
|
||||
<PageTitle
|
||||
@@ -361,22 +365,21 @@ class DocumentScene extends React.Component<Props> {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!isShare && (
|
||||
<Header
|
||||
document={document}
|
||||
isRevision={!!revision}
|
||||
isDraft={document.isDraft}
|
||||
isEditing={!readOnly}
|
||||
isSaving={this.isSaving}
|
||||
isPublishing={this.isPublishing}
|
||||
publishingIsDisabled={
|
||||
document.isSaving || this.isPublishing || this.isEmpty
|
||||
}
|
||||
savingIsDisabled={document.isSaving || this.isEmpty}
|
||||
goBack={this.goBack}
|
||||
onSave={this.onSave}
|
||||
/>
|
||||
)}
|
||||
<Header
|
||||
document={document}
|
||||
isShare={isShare}
|
||||
isRevision={!!revision}
|
||||
isDraft={document.isDraft}
|
||||
isEditing={!readOnly}
|
||||
isSaving={this.isSaving}
|
||||
isPublishing={this.isPublishing}
|
||||
publishingIsDisabled={
|
||||
document.isSaving || this.isPublishing || this.isEmpty
|
||||
}
|
||||
savingIsDisabled={document.isSaving || this.isEmpty}
|
||||
goBack={this.goBack}
|
||||
onSave={this.onSave}
|
||||
/>
|
||||
<MaxWidth
|
||||
archived={document.isArchived}
|
||||
showContents={showContents}
|
||||
@@ -452,7 +455,9 @@ class DocumentScene extends React.Component<Props> {
|
||||
</MaxWidth>
|
||||
</Container>
|
||||
</Background>
|
||||
{isShare && !isCustomDomain() && <Branding />}
|
||||
{isShare && !isCustomDomain() && (
|
||||
<Branding href="//www.getoutline.com" />
|
||||
)}
|
||||
{!isShare && <KeyboardShortcutsButton />}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
@@ -480,7 +485,7 @@ const ReferencesWrapper = styled("div")`
|
||||
const MaxWidth = styled(Flex)`
|
||||
${(props) =>
|
||||
props.archived && `* { color: ${props.theme.textSecondary} !important; } `};
|
||||
padding: 0 16px;
|
||||
padding: 0 12px;
|
||||
max-width: 100vw;
|
||||
width: 100%;
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// @flow
|
||||
import { throttle } from "lodash";
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
TableOfContentsIcon,
|
||||
EditIcon,
|
||||
@@ -9,18 +7,11 @@ import {
|
||||
PlusIcon,
|
||||
MoreIcon,
|
||||
} from "outline-icons";
|
||||
import { transparentize, darken } from "polished";
|
||||
import * as React from "react";
|
||||
import { withTranslation, Trans, type TFunction } from "react-i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import SharesStore from "stores/SharesStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Document from "models/Document";
|
||||
|
||||
import DocumentShare from "scenes/DocumentShare";
|
||||
import { Action, Separator } from "components/Actions";
|
||||
import Badge from "components/Badge";
|
||||
@@ -28,21 +19,19 @@ import Breadcrumb, { Slash } from "components/Breadcrumb";
|
||||
import Button from "components/Button";
|
||||
import Collaborators from "components/Collaborators";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import Header from "components/Header";
|
||||
import Modal from "components/Modal";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import useStores from "hooks/useStores";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
import NewChildDocumentMenu from "menus/NewChildDocumentMenu";
|
||||
import TemplatesMenu from "menus/TemplatesMenu";
|
||||
import { metaDisplay } from "utils/keyboard";
|
||||
import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
shares: SharesStore,
|
||||
policies: PoliciesStore,
|
||||
type Props = {|
|
||||
document: Document,
|
||||
isShare: boolean,
|
||||
isDraft: boolean,
|
||||
isEditing: boolean,
|
||||
isRevision: boolean,
|
||||
@@ -56,363 +45,273 @@ type Props = {
|
||||
publish?: boolean,
|
||||
autosave?: boolean,
|
||||
}) => void,
|
||||
t: TFunction,
|
||||
};
|
||||
|};
|
||||
|
||||
@observer
|
||||
class Header extends React.Component<Props> {
|
||||
@observable isScrolled = false;
|
||||
@observable showShareModal = false;
|
||||
function DocumentHeader({
|
||||
document,
|
||||
isShare,
|
||||
isEditing,
|
||||
isDraft,
|
||||
isPublishing,
|
||||
isRevision,
|
||||
isSaving,
|
||||
savingIsDisabled,
|
||||
publishingIsDisabled,
|
||||
onSave,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { auth, ui, shares, policies } = useStores();
|
||||
const [showShareModal, setShowShareModal] = React.useState(false);
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener("scroll", this.handleScroll);
|
||||
}
|
||||
const handleSave = React.useCallback(() => {
|
||||
onSave({ done: true });
|
||||
}, [onSave]);
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("scroll", this.handleScroll);
|
||||
}
|
||||
const handlePublish = React.useCallback(() => {
|
||||
onSave({ done: true, publish: true });
|
||||
}, [onSave]);
|
||||
|
||||
updateIsScrolled = () => {
|
||||
this.isScrolled = window.scrollY > 75;
|
||||
};
|
||||
const handleShareLink = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
await document.share();
|
||||
|
||||
handleScroll = throttle(this.updateIsScrolled, 50);
|
||||
setShowShareModal(true);
|
||||
},
|
||||
[document]
|
||||
);
|
||||
|
||||
handleSave = () => {
|
||||
this.props.onSave({ done: true });
|
||||
};
|
||||
const handleCloseShareModal = React.useCallback(() => {
|
||||
setShowShareModal(false);
|
||||
}, []);
|
||||
|
||||
handlePublish = () => {
|
||||
this.props.onSave({ done: true, publish: true });
|
||||
};
|
||||
const share = shares.getByDocumentId(document.id);
|
||||
const isPubliclyShared = share && share.published;
|
||||
const isNew = document.isNew;
|
||||
const isTemplate = document.isTemplate;
|
||||
const can = policies.abilities(document.id);
|
||||
const canShareDocument = auth.team && auth.team.sharing && can.share;
|
||||
const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
|
||||
const canEdit = can.update && !isEditing;
|
||||
|
||||
handleShareLink = async (ev: SyntheticEvent<>) => {
|
||||
const { document } = this.props;
|
||||
await document.share();
|
||||
const toc = (
|
||||
<Tooltip
|
||||
tooltip={ui.tocVisible ? t("Hide contents") : t("Show contents")}
|
||||
shortcut={`ctrl+${metaDisplay}+h`}
|
||||
delay={250}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
onClick={
|
||||
ui.tocVisible ? ui.hideTableOfContents : ui.showTableOfContents
|
||||
}
|
||||
icon={<TableOfContentsIcon />}
|
||||
iconColor="currentColor"
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
this.showShareModal = true;
|
||||
};
|
||||
|
||||
handleCloseShareModal = () => {
|
||||
this.showShareModal = false;
|
||||
};
|
||||
|
||||
handleClickTitle = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
shares,
|
||||
document,
|
||||
policies,
|
||||
isEditing,
|
||||
isDraft,
|
||||
isPublishing,
|
||||
isRevision,
|
||||
isSaving,
|
||||
savingIsDisabled,
|
||||
publishingIsDisabled,
|
||||
ui,
|
||||
auth,
|
||||
t,
|
||||
} = this.props;
|
||||
|
||||
const share = shares.getByDocumentId(document.id);
|
||||
const isPubliclyShared = share && share.published;
|
||||
const isNew = document.isNew;
|
||||
const isTemplate = document.isTemplate;
|
||||
const can = policies.abilities(document.id);
|
||||
const canShareDocuments = auth.team && auth.team.sharing && can.share;
|
||||
const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
|
||||
const canEdit = can.update && !isEditing;
|
||||
|
||||
return (
|
||||
<Actions
|
||||
align="center"
|
||||
justify="space-between"
|
||||
readOnly={!isEditing}
|
||||
isCompact={this.isScrolled}
|
||||
shrink={false}
|
||||
const editAction = (
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("Edit {{noun}}", { noun: document.noun })}
|
||||
shortcut="e"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Modal
|
||||
isOpen={this.showShareModal}
|
||||
onRequestClose={this.handleCloseShareModal}
|
||||
title={t("Share document")}
|
||||
<Button
|
||||
as={Link}
|
||||
icon={<EditIcon />}
|
||||
to={editDocumentUrl(document)}
|
||||
neutral
|
||||
>
|
||||
<DocumentShare
|
||||
document={document}
|
||||
onSubmit={this.handleCloseShareModal}
|
||||
/>
|
||||
</Modal>
|
||||
<BreadcrumbAndContents align="center" justify="flex-start">
|
||||
<Breadcrumb document={document} />
|
||||
{!isEditing && (
|
||||
<>
|
||||
<Slash />
|
||||
<Tooltip
|
||||
tooltip={
|
||||
ui.tocVisible ? t("Hide contents") : t("Show contents")
|
||||
}
|
||||
shortcut={`ctrl+${metaDisplay}+h`}
|
||||
delay={250}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
onClick={
|
||||
ui.tocVisible
|
||||
? ui.hideTableOfContents
|
||||
: ui.showTableOfContents
|
||||
}
|
||||
icon={<TableOfContentsIcon />}
|
||||
iconColor="currentColor"
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</BreadcrumbAndContents>
|
||||
{this.isScrolled && (
|
||||
<Title onClick={this.handleClickTitle}>
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
);
|
||||
|
||||
if (isShare) {
|
||||
return (
|
||||
<Header
|
||||
title={document.title}
|
||||
breadcrumb={toc}
|
||||
actions={canEdit ? editAction : <div />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={showShareModal}
|
||||
onRequestClose={handleCloseShareModal}
|
||||
title={t("Share document")}
|
||||
>
|
||||
<DocumentShare document={document} onSubmit={handleCloseShareModal} />
|
||||
</Modal>
|
||||
<Header
|
||||
breadcrumb={
|
||||
<Breadcrumb document={document}>
|
||||
{!isEditing && (
|
||||
<>
|
||||
<Slash />
|
||||
{toc}
|
||||
</>
|
||||
)}
|
||||
</Breadcrumb>
|
||||
}
|
||||
title={
|
||||
<>
|
||||
{document.title}{" "}
|
||||
{document.isArchived && <Badge>{t("Archived")}</Badge>}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{!isPublishing && isSaving && <Status>{t("Saving")}…</Status>}
|
||||
<Fade>
|
||||
{document.title}{" "}
|
||||
{document.isArchived && <Badge>{t("Archived")}</Badge>}
|
||||
<Collaborators
|
||||
document={document}
|
||||
currentUserId={auth.user ? auth.user.id : undefined}
|
||||
/>
|
||||
</Fade>
|
||||
</Title>
|
||||
)}
|
||||
<Wrapper align="center" justify="flex-end">
|
||||
{isSaving && !isPublishing && (
|
||||
<Action>
|
||||
<Status>{t("Saving")}…</Status>
|
||||
</Action>
|
||||
)}
|
||||
|
||||
<Fade>
|
||||
<Collaborators
|
||||
document={document}
|
||||
currentUserId={auth.user ? auth.user.id : undefined}
|
||||
/>
|
||||
</Fade>
|
||||
{isEditing && !isTemplate && isNew && (
|
||||
<Action>
|
||||
<TemplatesMenu document={document} />
|
||||
</Action>
|
||||
)}
|
||||
{!isEditing && canShareDocuments && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={
|
||||
isPubliclyShared ? (
|
||||
<Trans>
|
||||
Anyone with the link <br />
|
||||
can view this document
|
||||
</Trans>
|
||||
) : (
|
||||
""
|
||||
)
|
||||
}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
icon={isPubliclyShared ? <GlobeIcon /> : undefined}
|
||||
onClick={this.handleShareLink}
|
||||
neutral
|
||||
>
|
||||
{t("Share")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
)}
|
||||
{isEditing && (
|
||||
<>
|
||||
{isEditing && !isTemplate && isNew && (
|
||||
<Action>
|
||||
<TemplatesMenu document={document} />
|
||||
</Action>
|
||||
)}
|
||||
{!isEditing && canShareDocument && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("Save")}
|
||||
shortcut={`${metaDisplay}+enter`}
|
||||
tooltip={
|
||||
isPubliclyShared ? (
|
||||
<Trans>
|
||||
Anyone with the link <br />
|
||||
can view this document
|
||||
</Trans>
|
||||
) : (
|
||||
""
|
||||
)
|
||||
}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
onClick={this.handleSave}
|
||||
disabled={savingIsDisabled}
|
||||
neutral={isDraft}
|
||||
icon={isPubliclyShared ? <GlobeIcon /> : undefined}
|
||||
onClick={handleShareLink}
|
||||
neutral
|
||||
>
|
||||
{isDraft ? t("Save Draft") : t("Done Editing")}
|
||||
{t("Share")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
</>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("Edit {{noun}}", { noun: document.noun })}
|
||||
shortcut="e"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
as={Link}
|
||||
icon={<EditIcon />}
|
||||
to={editDocumentUrl(this.props.document)}
|
||||
neutral
|
||||
>
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
)}
|
||||
{canEdit && can.createChildDocument && (
|
||||
<Action>
|
||||
<NewChildDocumentMenu
|
||||
document={document}
|
||||
label={(props) => (
|
||||
)}
|
||||
{isEditing && (
|
||||
<>
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("New document")}
|
||||
shortcut="n"
|
||||
tooltip={t("Save")}
|
||||
shortcut={`${metaDisplay}+enter`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button icon={<PlusIcon />} {...props} neutral>
|
||||
{t("New doc")}
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={savingIsDisabled}
|
||||
neutral={isDraft}
|
||||
>
|
||||
{isDraft ? t("Save Draft") : t("Done Editing")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
{canEdit && isTemplate && !isDraft && !isRevision && (
|
||||
<Action>
|
||||
<Button
|
||||
icon={<PlusIcon />}
|
||||
as={Link}
|
||||
to={newDocumentUrl(document.collectionId, {
|
||||
templateId: document.id,
|
||||
})}
|
||||
primary
|
||||
>
|
||||
{t("New from template")}
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
{can.update && isDraft && !isRevision && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("Publish")}
|
||||
shortcut={`${metaDisplay}+shift+p`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
onClick={this.handlePublish}
|
||||
disabled={publishingIsDisabled}
|
||||
>
|
||||
{isPublishing ? `${t("Publishing")}…` : t("Publish")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
)}
|
||||
{!isEditing && (
|
||||
<>
|
||||
<Separator />
|
||||
</Action>
|
||||
</>
|
||||
)}
|
||||
{canEdit && editAction}
|
||||
{canEdit && can.createChildDocument && (
|
||||
<Action>
|
||||
<DocumentMenu
|
||||
<NewChildDocumentMenu
|
||||
document={document}
|
||||
isRevision={isRevision}
|
||||
label={(props) => (
|
||||
<Button
|
||||
icon={<MoreIcon />}
|
||||
iconColor="currentColor"
|
||||
{...props}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
<Tooltip
|
||||
tooltip={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button icon={<PlusIcon />} {...props} neutral>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
showToggleEmbeds={canToggleEmbeds}
|
||||
showPrint
|
||||
/>
|
||||
</Action>
|
||||
</>
|
||||
)}
|
||||
</Wrapper>
|
||||
</Actions>
|
||||
);
|
||||
}
|
||||
)}
|
||||
{canEdit && isTemplate && !isDraft && !isRevision && (
|
||||
<Action>
|
||||
<Button
|
||||
icon={<PlusIcon />}
|
||||
as={Link}
|
||||
to={newDocumentUrl(document.collectionId, {
|
||||
templateId: document.id,
|
||||
})}
|
||||
primary
|
||||
>
|
||||
{t("New from template")}
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
{can.update && isDraft && !isRevision && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("Publish")}
|
||||
shortcut={`${metaDisplay}+shift+p`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
onClick={handlePublish}
|
||||
disabled={publishingIsDisabled}
|
||||
>
|
||||
{isPublishing ? `${t("Publishing")}…` : t("Publish")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
)}
|
||||
{!isEditing && (
|
||||
<>
|
||||
<Separator />
|
||||
<Action>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
isRevision={isRevision}
|
||||
label={(props) => (
|
||||
<Button
|
||||
icon={<MoreIcon />}
|
||||
iconColor="currentColor"
|
||||
{...props}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
)}
|
||||
showToggleEmbeds={canToggleEmbeds}
|
||||
showPrint
|
||||
/>
|
||||
</Action>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Status = styled.div`
|
||||
const Status = styled(Action)`
|
||||
padding-left: 0;
|
||||
padding-right: 4px;
|
||||
color: ${(props) => props.theme.slate};
|
||||
`;
|
||||
|
||||
const BreadcrumbAndContents = styled(Flex)`
|
||||
display: none;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
width: 33.3%;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
width: 100%;
|
||||
align-self: flex-end;
|
||||
height: 32px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: 33.3%;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Actions = styled(Flex)`
|
||||
position: sticky;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
background: ${(props) => transparentize(0.2, props.theme.background)};
|
||||
box-shadow: 0 1px 0
|
||||
${(props) =>
|
||||
props.isCompact
|
||||
? darken(0.05, props.theme.sidebarBackground)
|
||||
: "transparent"};
|
||||
padding: 12px;
|
||||
transition: all 100ms ease-out;
|
||||
transform: translate3d(0, 0, 0);
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
|
||||
`};
|
||||
`;
|
||||
|
||||
const Title = styled.div`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
width: 0;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
`};
|
||||
`;
|
||||
|
||||
export default withTranslation()<Header>(
|
||||
inject("auth", "ui", "policies", "shares")(Header)
|
||||
);
|
||||
export default observer(DocumentHeader);
|
||||
|
||||
@@ -14,7 +14,6 @@ import Document from "models/Document";
|
||||
import Flex from "components/Flex";
|
||||
import { Outline } from "components/Input";
|
||||
import Labeled from "components/Labeled";
|
||||
import Modal from "components/Modal";
|
||||
import PathToDocument from "components/PathToDocument";
|
||||
|
||||
type Props = {|
|
||||
@@ -124,55 +123,55 @@ class DocumentMove extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { document, collections, onRequestClose } = this.props;
|
||||
const { document, collections } = this.props;
|
||||
const data = this.results;
|
||||
|
||||
return (
|
||||
<Modal isOpen onRequestClose={onRequestClose} title="Move document">
|
||||
{document && collections.isLoaded && (
|
||||
<Flex column>
|
||||
<Section>
|
||||
<Labeled label="Current location">
|
||||
{this.renderPathToCurrentDocument()}
|
||||
</Labeled>
|
||||
</Section>
|
||||
if (!document || !collections.isLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
<Section column>
|
||||
<Labeled label="Choose a new location" />
|
||||
<NewLocation>
|
||||
<InputWrapper>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search collections & documents…"
|
||||
onChange={this.handleFilter}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</InputWrapper>
|
||||
<Results>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<Flex role="listbox" column>
|
||||
<List
|
||||
key={data.length}
|
||||
width={width}
|
||||
height={height}
|
||||
itemData={data}
|
||||
itemCount={data.length}
|
||||
itemSize={40}
|
||||
itemKey={(index, data) => data[index].id}
|
||||
>
|
||||
{this.row}
|
||||
</List>
|
||||
</Flex>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</Results>
|
||||
</NewLocation>
|
||||
</Section>
|
||||
</Flex>
|
||||
)}
|
||||
</Modal>
|
||||
return (
|
||||
<Flex column>
|
||||
<Section>
|
||||
<Labeled label="Current location">
|
||||
{this.renderPathToCurrentDocument()}
|
||||
</Labeled>
|
||||
</Section>
|
||||
|
||||
<Section column>
|
||||
<Labeled label="Choose a new location" />
|
||||
<NewLocation>
|
||||
<InputWrapper>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search collections & documents…"
|
||||
onChange={this.handleFilter}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</InputWrapper>
|
||||
<Results>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<Flex role="listbox" column>
|
||||
<List
|
||||
key={data.length}
|
||||
width={width}
|
||||
height={height}
|
||||
itemData={data}
|
||||
itemCount={data.length}
|
||||
itemSize={40}
|
||||
itemKey={(index, data) => data[index].id}
|
||||
>
|
||||
{this.row}
|
||||
</List>
|
||||
</Flex>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</Results>
|
||||
</NewLocation>
|
||||
</Section>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
+23
-21
@@ -1,6 +1,7 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { EditIcon } from "outline-icons";
|
||||
import queryString from "query-string";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
@@ -9,15 +10,13 @@ import styled from "styled-components";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import CollectionFilter from "scenes/Search/components/CollectionFilter";
|
||||
import DateFilter from "scenes/Search/components/DateFilter";
|
||||
|
||||
import Actions, { Action } from "components/Actions";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import { Action } from "components/Actions";
|
||||
import Empty from "components/Empty";
|
||||
import Flex from "components/Flex";
|
||||
import Heading from "components/Heading";
|
||||
import InputSearch from "components/InputSearch";
|
||||
import PageTitle from "components/PageTitle";
|
||||
import PaginatedDocumentList from "components/PaginatedDocumentList";
|
||||
import Scene from "components/Scene";
|
||||
import Subheading from "components/Subheading";
|
||||
import NewDocumentMenu from "menus/NewDocumentMenu";
|
||||
import { type LocationWithState } from "types";
|
||||
@@ -78,10 +77,26 @@ class Drafts extends React.Component<Props> {
|
||||
};
|
||||
|
||||
return (
|
||||
<CenteredContent column auto>
|
||||
<PageTitle title={t("Drafts")} />
|
||||
<Scene
|
||||
icon={<EditIcon color="currentColor" />}
|
||||
title={t("Drafts")}
|
||||
actions={
|
||||
<>
|
||||
<Action>
|
||||
<InputSearch
|
||||
source="drafts"
|
||||
label={t("Search documents")}
|
||||
labelHidden
|
||||
/>
|
||||
</Action>
|
||||
<Action>
|
||||
<NewDocumentMenu />
|
||||
</Action>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Heading>{t("Drafts")}</Heading>
|
||||
<Subheading>
|
||||
<Subheading sticky>
|
||||
{t("Documents")}
|
||||
<Filters>
|
||||
<CollectionFilter
|
||||
@@ -110,20 +125,7 @@ class Drafts extends React.Component<Props> {
|
||||
options={options}
|
||||
showCollection
|
||||
/>
|
||||
|
||||
<Actions align="center" justify="flex-end">
|
||||
<Action>
|
||||
<InputSearch
|
||||
source="drafts"
|
||||
label={t("Search documents")}
|
||||
labelHidden
|
||||
/>
|
||||
</Action>
|
||||
<Action>
|
||||
<NewDocumentMenu />
|
||||
</Action>
|
||||
</Actions>
|
||||
</CenteredContent>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ class AddPeopleToGroup extends React.Component<Props> {
|
||||
this.inviteModalOpen = false;
|
||||
};
|
||||
|
||||
handleFilter = (ev: SyntheticInputEvent<HTMLInputElement>) => {
|
||||
handleFilter = (ev: SyntheticInputEvent<>) => {
|
||||
this.query = ev.target.value;
|
||||
this.debouncedFetch();
|
||||
};
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { HomeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch, Route } from "react-router-dom";
|
||||
|
||||
import Actions, { Action } from "components/Actions";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import { Action } from "components/Actions";
|
||||
import Heading from "components/Heading";
|
||||
import InputSearch from "components/InputSearch";
|
||||
import LanguagePrompt from "components/LanguagePrompt";
|
||||
import PageTitle from "components/PageTitle";
|
||||
import Scene from "components/Scene";
|
||||
import Tab from "components/Tab";
|
||||
import Tabs from "components/Tabs";
|
||||
import PaginatedDocumentList from "../components/PaginatedDocumentList";
|
||||
import useStores from "../hooks/useStores";
|
||||
import NewDocumentMenu from "menus/NewDocumentMenu";
|
||||
|
||||
function Dashboard() {
|
||||
function Home() {
|
||||
const { documents, ui, auth } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -23,10 +23,26 @@ function Dashboard() {
|
||||
const user = auth.user.id;
|
||||
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title={t("Home")} />
|
||||
<Scene
|
||||
icon={<HomeIcon color="currentColor" />}
|
||||
title={t("Home")}
|
||||
actions={
|
||||
<>
|
||||
<Action>
|
||||
<InputSearch
|
||||
source="dashboard"
|
||||
label={t("Search documents")}
|
||||
labelHidden
|
||||
/>
|
||||
</Action>
|
||||
<Action>
|
||||
<NewDocumentMenu />
|
||||
</Action>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{!ui.languagePromptDismissed && <LanguagePrompt />}
|
||||
<h1>{t("Home")}</h1>
|
||||
<Heading>{t("Home")}</Heading>
|
||||
<Tabs>
|
||||
<Tab to="/home" exact>
|
||||
{t("Recently updated")}
|
||||
@@ -62,20 +78,8 @@ function Dashboard() {
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
<Actions align="center" justify="flex-end">
|
||||
<Action>
|
||||
<InputSearch
|
||||
source="dashboard"
|
||||
label={t("Search documents")}
|
||||
labelHidden
|
||||
/>
|
||||
</Action>
|
||||
<Action>
|
||||
<NewDocumentMenu />
|
||||
</Action>
|
||||
</Actions>
|
||||
</CenteredContent>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Dashboard);
|
||||
export default observer(Home);
|
||||
@@ -15,6 +15,12 @@ export default function Notices({ notice }: Props) {
|
||||
signing in with your Google Workspace account.
|
||||
</NoticeAlert>
|
||||
)}
|
||||
{notice === "maximum-teams" && (
|
||||
<NoticeAlert>
|
||||
The team you authenticated with is not authorized on this
|
||||
installation. Try another?
|
||||
</NoticeAlert>
|
||||
)}
|
||||
{notice === "hd-not-allowed" && (
|
||||
<NoticeAlert>
|
||||
Sorry, your Google apps domain is not allowed. Please try again with
|
||||
@@ -35,7 +41,7 @@ export default function Notices({ notice }: Props) {
|
||||
)}
|
||||
{notice === "auth-error" && (
|
||||
<NoticeAlert>
|
||||
Authentication failed - we were unable to sign you in at this time.
|
||||
Authentication failed – we were unable to sign you in at this time.
|
||||
Please try again.
|
||||
</NoticeAlert>
|
||||
)}
|
||||
@@ -51,6 +57,12 @@ export default function Notices({ notice }: Props) {
|
||||
please contact a team admin.
|
||||
</NoticeAlert>
|
||||
)}
|
||||
{notice === "authentication-provider-disabled" && (
|
||||
<NoticeAlert>
|
||||
Authentication failed – this login method was disabled by a team
|
||||
admin.
|
||||
</NoticeAlert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
import { EmailIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import AuthLogo from "components/AuthLogo";
|
||||
import ButtonLarge from "components/ButtonLarge";
|
||||
import GoogleLogo from "components/GoogleLogo";
|
||||
import InputLarge from "components/InputLarge";
|
||||
import SlackLogo from "components/SlackLogo";
|
||||
import { client } from "utils/ApiClient";
|
||||
|
||||
type Props = {
|
||||
@@ -22,7 +21,7 @@ type State = {
|
||||
email: string,
|
||||
};
|
||||
|
||||
class Service extends React.Component<Props, State> {
|
||||
class Provider extends React.Component<Props, State> {
|
||||
state = {
|
||||
showEmailSignin: false,
|
||||
isSubmitting: false,
|
||||
@@ -98,22 +97,13 @@ class Service extends React.Component<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
const icon =
|
||||
id === "slack" ? (
|
||||
<Logo>
|
||||
<SlackLogo size={16} />
|
||||
</Logo>
|
||||
) : id === "google" ? (
|
||||
<Logo>
|
||||
<GoogleLogo size={16} />
|
||||
</Logo>
|
||||
) : undefined;
|
||||
const icon = <AuthLogo providerName={id} />;
|
||||
|
||||
return (
|
||||
<Wrapper key={id}>
|
||||
<ButtonLarge
|
||||
onClick={() => (window.location.href = authUrl)}
|
||||
icon={icon}
|
||||
icon={icon ? <Logo>{icon}</Logo> : null}
|
||||
fullwidth
|
||||
>
|
||||
{isCreate ? "Sign up" : "Continue"} with {name}
|
||||
@@ -142,4 +132,4 @@ const Form = styled.form`
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
export default Service;
|
||||
export default Provider;
|
||||
+18
-18
@@ -15,7 +15,7 @@ import OutlineLogo from "components/OutlineLogo";
|
||||
import PageTitle from "components/PageTitle";
|
||||
import TeamLogo from "components/TeamLogo";
|
||||
import Notices from "./Notices";
|
||||
import Service from "./Service";
|
||||
import Provider from "./Provider";
|
||||
import env from "env";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
@@ -41,8 +41,6 @@ function Login({ location }: Props) {
|
||||
auth.fetchConfig();
|
||||
}, [auth]);
|
||||
|
||||
console.log(config);
|
||||
|
||||
if (auth.authenticated) {
|
||||
return <Redirect to="/home" />;
|
||||
}
|
||||
@@ -52,10 +50,10 @@ function Login({ location }: Props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasMultipleServices = config.services.length > 1;
|
||||
const defaultService = find(
|
||||
config.services,
|
||||
(service) => service.id === auth.lastSignedIn && !isCreate
|
||||
const hasMultipleProviders = config.providers.length > 1;
|
||||
const defaultProvider = find(
|
||||
config.providers,
|
||||
(provider) => provider.id === auth.lastSignedIn && !isCreate
|
||||
);
|
||||
|
||||
const header =
|
||||
@@ -113,33 +111,35 @@ function Login({ location }: Props) {
|
||||
|
||||
<Notices notice={getQueryVariable("notice")} />
|
||||
|
||||
{defaultService && (
|
||||
<React.Fragment key={defaultService.id}>
|
||||
<Service
|
||||
{defaultProvider && (
|
||||
<React.Fragment key={defaultProvider.id}>
|
||||
<Provider
|
||||
isCreate={isCreate}
|
||||
onEmailSuccess={handleEmailSuccess}
|
||||
{...defaultService}
|
||||
{...defaultProvider}
|
||||
/>
|
||||
{hasMultipleServices && (
|
||||
{hasMultipleProviders && (
|
||||
<>
|
||||
<Note>You signed in with {defaultService.name} last time.</Note>
|
||||
<Note>
|
||||
You signed in with {defaultProvider.name} last time.
|
||||
</Note>
|
||||
<Or />
|
||||
</>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
{config.services.map((service) => {
|
||||
if (defaultService && service.id === defaultService.id) {
|
||||
{config.providers.map((provider) => {
|
||||
if (defaultProvider && provider.id === defaultProvider.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Service
|
||||
key={service.id}
|
||||
<Provider
|
||||
key={provider.id}
|
||||
isCreate={isCreate}
|
||||
onEmailSuccess={handleEmailSuccess}
|
||||
{...service}
|
||||
{...provider}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -37,6 +37,7 @@ import NewDocumentMenu from "menus/NewDocumentMenu";
|
||||
import { type LocationWithState } from "types";
|
||||
import { metaDisplay } from "utils/keyboard";
|
||||
import { newDocumentUrl, searchUrl } from "utils/routeHelpers";
|
||||
import { decodeURIComponentSafe } from "utils/urls";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
@@ -55,7 +56,7 @@ class Search extends React.Component<Props> {
|
||||
lastParams: Object;
|
||||
|
||||
@observable
|
||||
query: string = decodeURIComponent(this.props.match.params.term || "");
|
||||
query: string = decodeURIComponentSafe(this.props.match.params.term || "");
|
||||
@observable params: URLSearchParams = new URLSearchParams();
|
||||
@observable offset: number = 0;
|
||||
@observable allowLoadMore: boolean = true;
|
||||
@@ -116,7 +117,7 @@ class Search extends React.Component<Props> {
|
||||
};
|
||||
|
||||
handleTermChange = () => {
|
||||
const query = decodeURIComponent(this.props.match.params.term || "");
|
||||
const query = decodeURIComponentSafe(this.props.match.params.term || "");
|
||||
this.query = query ? query : "";
|
||||
this.offset = 0;
|
||||
this.allowLoadMore = true;
|
||||
|
||||
@@ -1,39 +1,44 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FilterOptions from "./FilterOptions";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
const defaultOption = {
|
||||
key: "",
|
||||
label: "Any collection",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
collections: CollectionsStore,
|
||||
type Props = {|
|
||||
collectionId: ?string,
|
||||
onSelect: (key: ?string) => void,
|
||||
};
|
||||
|};
|
||||
|
||||
@observer
|
||||
class CollectionFilter extends React.Component<Props> {
|
||||
render() {
|
||||
const { onSelect, collectionId, collections } = this.props;
|
||||
function CollectionFilter(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useStores();
|
||||
const { onSelect, collectionId } = props;
|
||||
|
||||
const options = React.useMemo(() => {
|
||||
const collectionOptions = collections.orderedData.map((user) => ({
|
||||
key: user.id,
|
||||
label: user.name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<FilterOptions
|
||||
options={[defaultOption, ...collectionOptions]}
|
||||
activeKey={collectionId}
|
||||
onSelect={onSelect}
|
||||
defaultLabel="Any collection"
|
||||
selectedPrefix="Collection:"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: "",
|
||||
label: t("Any collection"),
|
||||
},
|
||||
...collectionOptions,
|
||||
];
|
||||
}, [collections.orderedData, t]);
|
||||
|
||||
return (
|
||||
<FilterOptions
|
||||
options={options}
|
||||
activeKey={collectionId}
|
||||
onSelect={onSelect}
|
||||
defaultLabel={t("Any collection")}
|
||||
selectedPrefix={`${t("Collection")}:`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default inject("collections")(CollectionFilter);
|
||||
export default observer(CollectionFilter);
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FilterOptions from "./FilterOptions";
|
||||
|
||||
const options = [
|
||||
{ key: "", label: "Any time" },
|
||||
{ key: "day", label: "Past day" },
|
||||
{ key: "week", label: "Past week" },
|
||||
{ key: "month", label: "Past month" },
|
||||
{ key: "year", label: "Past year" },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
dateFilter: ?string,
|
||||
onSelect: (key: ?string) => void,
|
||||
};
|
||||
|};
|
||||
|
||||
const DateFilter = ({ dateFilter, onSelect }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const options = React.useMemo(
|
||||
() => [
|
||||
{ key: "", label: t("Any time") },
|
||||
{ key: "day", label: t("Past day") },
|
||||
{ key: "week", label: t("Past week") },
|
||||
{ key: "month", label: t("Past month") },
|
||||
{ key: "year", label: t("Past year") },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterOptions
|
||||
options={options}
|
||||
activeKey={dateFilter}
|
||||
onSelect={onSelect}
|
||||
defaultLabel="Any time"
|
||||
defaultLabel={t("Any time")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -34,7 +34,9 @@ const FilterOption = ({ label, note, onSelect, active, ...rest }: Props) => {
|
||||
};
|
||||
|
||||
const Description = styled(HelpText)`
|
||||
margin-top: 2px;
|
||||
margin-bottom: 0;
|
||||
line-height: 1.2em;
|
||||
`;
|
||||
|
||||
const Checkmark = styled(CheckmarkIcon)`
|
||||
|
||||
@@ -1,32 +1,38 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FilterOptions from "./FilterOptions";
|
||||
|
||||
const options = [
|
||||
{
|
||||
key: "",
|
||||
label: "Active documents",
|
||||
note: "Documents in collections you are able to access",
|
||||
},
|
||||
{
|
||||
key: "true",
|
||||
label: "All documents",
|
||||
note: "Include documents that are in the archive",
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
includeArchived: boolean,
|
||||
type Props = {|
|
||||
includeArchived?: boolean,
|
||||
onSelect: (key: ?string) => void,
|
||||
};
|
||||
|};
|
||||
|
||||
const StatusFilter = ({ includeArchived, onSelect }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const options = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "",
|
||||
label: t("Active documents"),
|
||||
note: t("Documents in collections you are able to access"),
|
||||
},
|
||||
{
|
||||
key: "true",
|
||||
label: t("All documents"),
|
||||
note: t("Include documents that are in the archive"),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterOptions
|
||||
options={options}
|
||||
activeKey={includeArchived ? "true" : undefined}
|
||||
onSelect={onSelect}
|
||||
defaultLabel="Active documents"
|
||||
defaultLabel={t("Active documents")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,43 +1,48 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import UsersStore from "stores/UsersStore";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FilterOptions from "./FilterOptions";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
const defaultOption = {
|
||||
key: "",
|
||||
label: "Any author",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
users: UsersStore,
|
||||
type Props = {|
|
||||
userId: ?string,
|
||||
onSelect: (key: ?string) => void,
|
||||
};
|
||||
|};
|
||||
|
||||
@observer
|
||||
class UserFilter extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
this.props.users.fetchPage({ limit: 100 });
|
||||
}
|
||||
function UserFilter(props: Props) {
|
||||
const { onSelect, userId } = props;
|
||||
const { t } = useTranslation();
|
||||
const { users } = useStores();
|
||||
|
||||
render() {
|
||||
const { onSelect, userId, users } = this.props;
|
||||
React.useEffect(() => {
|
||||
users.fetchPage({ limit: 100 });
|
||||
}, [users]);
|
||||
|
||||
const options = React.useMemo(() => {
|
||||
const userOptions = users.all.map((user) => ({
|
||||
key: user.id,
|
||||
label: user.name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<FilterOptions
|
||||
options={[defaultOption, ...userOptions]}
|
||||
activeKey={userId}
|
||||
onSelect={onSelect}
|
||||
defaultLabel="Any author"
|
||||
selectedPrefix="Author:"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: "",
|
||||
label: t("Any author"),
|
||||
},
|
||||
...userOptions,
|
||||
];
|
||||
}, [users.all, t]);
|
||||
|
||||
return (
|
||||
<FilterOptions
|
||||
options={options}
|
||||
activeKey={userId}
|
||||
onSelect={onSelect}
|
||||
defaultLabel={t("Any author")}
|
||||
selectedPrefix={`${t("Author")}:`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default inject("users")(UserFilter);
|
||||
export default observer(UserFilter);
|
||||
|
||||
@@ -66,7 +66,7 @@ class Groups extends React.Component<Props> {
|
||||
Groups can be used to organize and manage the people on your team.
|
||||
</HelpText>
|
||||
|
||||
{can.group && (
|
||||
{can.createGroup && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={this.handleNewGroupModalOpen}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import { observer } from "mobx-react";
|
||||
import { CollectionIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import { parseOutlineExport } from "shared/utils/zip";
|
||||
import Button from "components/Button";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import HelpText from "components/HelpText";
|
||||
import Notice from "components/Notice";
|
||||
import PageTitle from "components/PageTitle";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import useStores from "hooks/useStores";
|
||||
import getDataTransferFiles from "utils/getDataTransferFiles";
|
||||
import { uploadFile } from "utils/uploadFile";
|
||||
|
||||
function ImportExport() {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const fileRef = React.useRef();
|
||||
const { ui, collections } = useStores();
|
||||
const { showToast } = ui;
|
||||
const [isLoading, setLoading] = React.useState(false);
|
||||
const [isImporting, setImporting] = React.useState(false);
|
||||
const [isImported, setImported] = React.useState(false);
|
||||
const [isExporting, setExporting] = React.useState(false);
|
||||
const [file, setFile] = React.useState();
|
||||
const [importDetails, setImportDetails] = React.useState();
|
||||
|
||||
const handleImport = React.useCallback(
|
||||
async (ev) => {
|
||||
setImported(undefined);
|
||||
setImporting(true);
|
||||
|
||||
try {
|
||||
invariant(file, "File must exist to upload");
|
||||
const attachment = await uploadFile(file, {
|
||||
name: file.name,
|
||||
});
|
||||
await collections.import(attachment.id);
|
||||
showToast(t("Import started"));
|
||||
setImported(true);
|
||||
} catch (err) {
|
||||
showToast(err.message);
|
||||
} finally {
|
||||
if (fileRef.current) {
|
||||
fileRef.current.value = "";
|
||||
}
|
||||
setImporting(false);
|
||||
setFile(undefined);
|
||||
setImportDetails(undefined);
|
||||
}
|
||||
},
|
||||
[t, file, collections, showToast]
|
||||
);
|
||||
|
||||
const handleFilePicked = React.useCallback(async (ev) => {
|
||||
ev.preventDefault();
|
||||
|
||||
const files = getDataTransferFiles(ev);
|
||||
const file = files[0];
|
||||
setFile(file);
|
||||
|
||||
try {
|
||||
setImportDetails(await parseOutlineExport(file));
|
||||
} catch (err) {
|
||||
setImportDetails([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePickFile = React.useCallback(
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
|
||||
if (fileRef.current) {
|
||||
fileRef.current.click();
|
||||
}
|
||||
},
|
||||
[fileRef]
|
||||
);
|
||||
|
||||
const handleExport = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await collections.export();
|
||||
setExporting(true);
|
||||
showToast(t("Export in progress…"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[t, collections, showToast]
|
||||
);
|
||||
|
||||
const hasCollections = importDetails
|
||||
? !!importDetails.filter((detail) => detail.type === "collection").length
|
||||
: false;
|
||||
const hasDocuments = importDetails
|
||||
? !!importDetails.filter((detail) => detail.type === "document").length
|
||||
: false;
|
||||
const isImportable = hasCollections && hasDocuments;
|
||||
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title={`${t("Import")} / ${t("Export")}`} />
|
||||
<h1>{t("Import")}</h1>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
It is possible to import a zip file of folders and Markdown files
|
||||
previously exported from an Outline instance. Support will soon be
|
||||
added for importing from other services.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
<VisuallyHidden>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileRef}
|
||||
onChange={handleFilePicked}
|
||||
accept="application/zip"
|
||||
/>
|
||||
</VisuallyHidden>
|
||||
{isImported && (
|
||||
<Notice>
|
||||
<Trans>
|
||||
Your file has been uploaded and the import is currently being
|
||||
processed, you can safely leave this page while it completes.
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
{file && !isImportable && (
|
||||
<ImportPreview>
|
||||
<Trans
|
||||
defaults="Sorry, the file <em>{{ fileName }}</em> is missing valid collections or documents."
|
||||
values={{ fileName: file.name }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
</ImportPreview>
|
||||
)}
|
||||
{file && importDetails && isImportable ? (
|
||||
<>
|
||||
<ImportPreview as="div">
|
||||
<Trans
|
||||
defaults="<em>{{ fileName }}</em> looks good, the following collections and their documents will be imported:"
|
||||
values={{ fileName: file.name }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
<List>
|
||||
{importDetails
|
||||
.filter((detail) => detail.type === "collection")
|
||||
.map((detail) => (
|
||||
<ImportPreviewItem key={detail.path}>
|
||||
<CollectionIcon />
|
||||
<CollectionName>{detail.name}</CollectionName>
|
||||
</ImportPreviewItem>
|
||||
))}
|
||||
</List>
|
||||
</ImportPreview>
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={handleImport}
|
||||
disabled={isImporting}
|
||||
primary
|
||||
>
|
||||
{isImporting ? `${t("Uploading")}…` : t("Confirm & Import")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button type="submit" onClick={handlePickFile} primary>
|
||||
{t("Choose File")}…
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<h1>{t("Export")}</h1>
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="A full export might take some time, consider exporting a single document or collection if possible. We’ll put together a zip of all your documents in Markdown format and email it to <em>{{ userEmail }}</em>."
|
||||
values={{ userEmail: user.email }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
</HelpText>
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={handleExport}
|
||||
disabled={isLoading || isExporting}
|
||||
primary
|
||||
>
|
||||
{isExporting
|
||||
? t("Export Requested")
|
||||
: isLoading
|
||||
? `${t("Requesting Export")}…`
|
||||
: t("Export Data")}
|
||||
</Button>
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
|
||||
const List = styled.ul`
|
||||
padding: 0;
|
||||
margin: 8px 0 0;
|
||||
`;
|
||||
|
||||
const ImportPreview = styled(Notice)`
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
|
||||
const ImportPreviewItem = styled.li`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
list-style: none;
|
||||
`;
|
||||
|
||||
const CollectionName = styled.span`
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
`;
|
||||
|
||||
export default observer(ImportExport);
|
||||
@@ -97,8 +97,7 @@ class Notifications extends React.Component<Props> {
|
||||
|
||||
<HelpText>
|
||||
Manage when and where you receive email notifications from Outline.
|
||||
Your email address can be updated in your{" "}
|
||||
{team.slackConnected ? "Slack" : "Google"} account.
|
||||
Your email address can be updated in your SSO provider.
|
||||
</HelpText>
|
||||
|
||||
<Input
|
||||
|
||||
@@ -4,12 +4,13 @@ import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction, Trans } from "react-i18next";
|
||||
import { type Match } from "react-router-dom";
|
||||
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import UsersStore from "stores/UsersStore";
|
||||
import Invite from "scenes/Invite";
|
||||
import Bubble from "components/Bubble";
|
||||
import Button from "components/Button";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import Empty from "components/Empty";
|
||||
@@ -27,12 +28,20 @@ type Props = {
|
||||
users: UsersStore,
|
||||
policies: PoliciesStore,
|
||||
match: Match,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
class People extends React.Component<Props> {
|
||||
@observable inviteModalOpen: boolean = false;
|
||||
|
||||
componentDidMount() {
|
||||
const { team } = this.props.auth;
|
||||
if (team) {
|
||||
this.props.users.fetchCounts(team.id);
|
||||
}
|
||||
}
|
||||
|
||||
handleInviteModalOpen = () => {
|
||||
this.inviteModalOpen = true;
|
||||
};
|
||||
@@ -46,7 +55,7 @@ class People extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { auth, policies, match } = this.props;
|
||||
const { auth, policies, match, t } = this.props;
|
||||
const { filter } = match.params;
|
||||
const currentUser = auth.user;
|
||||
const team = auth.team;
|
||||
@@ -65,56 +74,60 @@ class People extends React.Component<Props> {
|
||||
}
|
||||
|
||||
const can = policies.abilities(team.id);
|
||||
const { counts } = this.props.users;
|
||||
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title="People" />
|
||||
<h1>People</h1>
|
||||
<PageTitle title={t("People")} />
|
||||
<h1>{t("People")}</h1>
|
||||
<HelpText>
|
||||
Everyone that has signed into Outline appears here. It’s possible that
|
||||
there are other users who have access through {team.signinMethods} but
|
||||
haven’t signed in yet.
|
||||
<Trans>
|
||||
Everyone that has signed into Outline appears here. It’s possible
|
||||
that there are other users who have access through{" "}
|
||||
{team.signinMethods} but haven’t signed in yet.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
<Button
|
||||
type="button"
|
||||
data-on="click"
|
||||
data-event-category="invite"
|
||||
data-event-action="peoplePage"
|
||||
onClick={this.handleInviteModalOpen}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
Invite people…
|
||||
</Button>
|
||||
{can.inviteUser && (
|
||||
<Button
|
||||
type="button"
|
||||
data-on="click"
|
||||
data-event-category="invite"
|
||||
data-event-action="peoplePage"
|
||||
onClick={this.handleInviteModalOpen}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("Invite people")}…
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Tabs>
|
||||
<Tab to="/settings/people" exact>
|
||||
Active
|
||||
{t("Active")} <Bubble count={counts.active} />
|
||||
</Tab>
|
||||
<Tab to="/settings/people/admins" exact>
|
||||
Admins
|
||||
{t("Admins")} <Bubble count={counts.admins} />
|
||||
</Tab>
|
||||
{can.update && (
|
||||
<Tab to="/settings/people/suspended" exact>
|
||||
Suspended
|
||||
{t("Suspended")} <Bubble count={counts.suspended} />
|
||||
</Tab>
|
||||
)}
|
||||
<Tab to="/settings/people/all" exact>
|
||||
Everyone
|
||||
{t("Everyone")} <Bubble count={counts.all - counts.invited} />
|
||||
</Tab>
|
||||
|
||||
{can.invite && (
|
||||
{can.inviteUser && (
|
||||
<>
|
||||
<Separator />
|
||||
<Tab to="/settings/people/invited" exact>
|
||||
Invited
|
||||
{t("Invited")} <Bubble count={counts.invited} />
|
||||
</Tab>
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
<PaginatedList
|
||||
items={users}
|
||||
empty={<Empty>No people to see here.</Empty>}
|
||||
empty={<Empty>{t("No people to see here.")}</Empty>}
|
||||
fetch={this.fetchPage}
|
||||
renderItem={(item) => (
|
||||
<UserListItem
|
||||
@@ -124,17 +137,22 @@ class People extends React.Component<Props> {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="Invite people"
|
||||
onRequestClose={this.handleInviteModalClose}
|
||||
isOpen={this.inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={this.handleInviteModalClose} />
|
||||
</Modal>
|
||||
{can.inviteUser && (
|
||||
<Modal
|
||||
title={t("Invite people")}
|
||||
onRequestClose={this.handleInviteModalClose}
|
||||
isOpen={this.inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={this.handleInviteModalClose} />
|
||||
</Modal>
|
||||
)}
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default inject("auth", "users", "policies")(People);
|
||||
export default inject(
|
||||
"auth",
|
||||
"users",
|
||||
"policies"
|
||||
)(withTranslation()<People>(People));
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import SharesStore from "stores/SharesStore";
|
||||
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import Empty from "components/Empty";
|
||||
import HelpText from "components/HelpText";
|
||||
@@ -12,55 +9,50 @@ import List from "components/List";
|
||||
import PageTitle from "components/PageTitle";
|
||||
import Subheading from "components/Subheading";
|
||||
import ShareListItem from "./components/ShareListItem";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
shares: SharesStore,
|
||||
auth: AuthStore,
|
||||
};
|
||||
function Shares() {
|
||||
const team = useCurrentTeam();
|
||||
const { shares, auth, policies } = useStores();
|
||||
const canShareDocuments = auth.team && auth.team.sharing;
|
||||
const hasSharedDocuments = shares.orderedData.length > 0;
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
@observer
|
||||
class Shares extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
this.props.shares.fetchPage({ limit: 100 });
|
||||
}
|
||||
React.useEffect(() => {
|
||||
shares.fetchPage({ limit: 100 });
|
||||
}, [shares]);
|
||||
|
||||
render() {
|
||||
const { shares, auth } = this.props;
|
||||
const { user } = auth;
|
||||
const canShareDocuments = auth.team && auth.team.sharing;
|
||||
const hasSharedDocuments = shares.orderedData.length > 0;
|
||||
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title="Share Links" />
|
||||
<h1>Share Links</h1>
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title="Share Links" />
|
||||
<h1>Share Links</h1>
|
||||
<HelpText>
|
||||
Documents that have been shared are listed below. Anyone that has the
|
||||
public link can access a read-only version of the document until the
|
||||
link has been revoked.
|
||||
</HelpText>
|
||||
{can.manage && (
|
||||
<HelpText>
|
||||
Documents that have been shared are listed below. Anyone that has the
|
||||
public link can access a read-only version of the document until the
|
||||
link has been revoked.
|
||||
{!canShareDocuments && (
|
||||
<strong>Sharing is currently disabled.</strong>
|
||||
)}{" "}
|
||||
You can turn {canShareDocuments ? "off" : "on"} public document
|
||||
sharing in <Link to="/settings/security">security settings</Link>.
|
||||
</HelpText>
|
||||
{user && user.isAdmin && (
|
||||
<HelpText>
|
||||
{!canShareDocuments && (
|
||||
<strong>Sharing is currently disabled.</strong>
|
||||
)}{" "}
|
||||
You can turn {canShareDocuments ? "off" : "on"} public document
|
||||
sharing in <Link to="/settings/security">security settings</Link>.
|
||||
</HelpText>
|
||||
)}
|
||||
<Subheading>Shared Documents</Subheading>
|
||||
{hasSharedDocuments ? (
|
||||
<List>
|
||||
{shares.published.map((share) => (
|
||||
<ShareListItem key={share.id} share={share} />
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Empty>No share links, yet.</Empty>
|
||||
)}
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
)}
|
||||
<Subheading>Shared Documents</Subheading>
|
||||
{hasSharedDocuments ? (
|
||||
<List>
|
||||
{shares.published.map((share) => (
|
||||
<ShareListItem key={share.id} share={share} />
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Empty>No share links, yet.</Empty>
|
||||
)}
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default inject("shares", "auth")(Shares);
|
||||
export default observer(Shares);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user