Merge main
This commit is contained in:
commit
cada91a135
94
.env.sample
94
.env.sample
|
@ -1,26 +1,40 @@
|
||||||
# Copy this file to .env, remove this comment and change the keys. For development
|
# 👋 Welcome, we're glad you're setting up an installation of Outline. Copy this
|
||||||
# with docker this should mostly work out of the box other than setting the Slack
|
# file to .env or set the variables in your local environment manually. For
|
||||||
# keys (for auth) and the SECRET_KEY.
|
# development with docker this should mostly work out of the box other than
|
||||||
#
|
# setting the Slack keys and the SECRET_KEY.
|
||||||
# Please use `openssl rand -hex 32` to create 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
|
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
|
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=postgres://user:pass@localhost:5532/outline
|
||||||
DATABASE_URL_TEST=postgres://user:pass@localhost:5532/outline-test
|
DATABASE_URL_TEST=postgres://user:pass@localhost:5532/outline-test
|
||||||
REDIS_URL=redis://localhost:6479
|
REDIS_URL=redis://localhost:6479
|
||||||
|
|
||||||
|
# URL should point to the fully qualified, publicly accessible URL. If using a
|
||||||
|
# proxy the port in URL and PORT may be different.
|
||||||
URL=http://localhost:3000
|
URL=http://localhost:3000
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
# enforce (auto redirect to) https in production, (optional) default is true.
|
# Third party signin credentials, at least one of EITHER Google OR Slack is
|
||||||
# set to false if your SSL is terminated at a loadbalancer, for example
|
# required for a working installation or you'll have no sign-in options.
|
||||||
FORCE_HTTPS=true
|
|
||||||
|
|
||||||
ENABLE_UPDATES=true
|
# To configure Slack auth, you'll need to create an Application at
|
||||||
DEBUG=cache,presenters,events,emails,mailer,utils,multiplayer,server,services
|
# => https://api.slack.com/apps
|
||||||
|
#
|
||||||
# Third party signin credentials (at least one is required)
|
# 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_KEY=get_a_key_from_slack
|
||||||
SLACK_SECRET=get_the_secret_of_above_key
|
SLACK_SECRET=get_the_secret_of_above_key
|
||||||
|
|
||||||
|
@ -28,22 +42,59 @@ SLACK_SECRET=get_the_secret_of_above_key
|
||||||
# => https://console.cloud.google.com/apis/credentials
|
# => https://console.cloud.google.com/apis/credentials
|
||||||
#
|
#
|
||||||
# When configuring the Client ID, add an Authorized redirect URI:
|
# 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_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
# Comma separated list of domains to be allowed (optional)
|
|
||||||
# If not set, all Google apps domains are allowed by default
|
|
||||||
|
|
||||||
|
# –––––––––––––––– OPTIONAL ––––––––––––––––
|
||||||
|
|
||||||
|
# If using a Cloudfront/Cloudflare distribution or similar it can be set below.
|
||||||
|
# This will cause paths to javascript, stylesheets, and images to be updated to
|
||||||
|
# the hostname defined in CDN_URL. In your CDN configuration the origin server
|
||||||
|
# should be set to the same as URL.
|
||||||
|
CDN_URL=
|
||||||
|
|
||||||
|
# Auto-redirect to https in production. The default is true but you may set to
|
||||||
|
# false if you can be sure that SSL is terminated at an external loadbalancer.
|
||||||
|
FORCE_HTTPS=true
|
||||||
|
|
||||||
|
# Have the installation check for updates by sending anonymized statistics to
|
||||||
|
# the maintainers
|
||||||
|
ENABLE_UPDATES=true
|
||||||
|
|
||||||
|
# You may enable or disable debugging categories to increase the noisiness of
|
||||||
|
# logs. The default is a good balance
|
||||||
|
DEBUG=cache,presenters,events,emails,mailer,utils,multiplayer,server,services
|
||||||
|
|
||||||
|
# Comma separated list of domains to be allowed to signin to the wiki. If not
|
||||||
|
# set, all domains are allowed by default when using Google OAuth to signin
|
||||||
GOOGLE_ALLOWED_DOMAINS=
|
GOOGLE_ALLOWED_DOMAINS=
|
||||||
|
|
||||||
# Third party credentials (optional)
|
# For a complete Slack integration with search and posting to channels the
|
||||||
SLACK_VERIFICATION_TOKEN=PLxk6OlXXXXXVj3YYYY
|
# 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_APP_ID=A0XXXXXXX
|
||||||
SLACK_MESSAGE_ACTIONS=true
|
SLACK_MESSAGE_ACTIONS=true
|
||||||
|
|
||||||
|
# Optionally enable google analytics to track pageviews in the knowledge base
|
||||||
GOOGLE_ANALYTICS_ID=
|
GOOGLE_ANALYTICS_ID=
|
||||||
|
|
||||||
|
# Optionally enable Sentry (sentry.io) to track errors and performance
|
||||||
SENTRY_DSN=
|
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_ACCESS_KEY_ID=get_a_key_from_aws
|
||||||
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
|
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
|
||||||
AWS_REGION=xx-xxxx-x
|
AWS_REGION=xx-xxxx-x
|
||||||
|
@ -51,11 +102,10 @@ AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
|
||||||
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
|
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
|
||||||
AWS_S3_UPLOAD_MAX_SIZE=26214400
|
AWS_S3_UPLOAD_MAX_SIZE=26214400
|
||||||
AWS_S3_FORCE_PATH_STYLE=true
|
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
|
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_HOST=
|
||||||
SMTP_PORT=
|
SMTP_PORT=
|
||||||
SMTP_USERNAME=
|
SMTP_USERNAME=
|
||||||
|
@ -66,4 +116,6 @@ SMTP_REPLY_EMAIL=
|
||||||
# Custom logo that displays on the authentication screen, scaled to height: 60px
|
# Custom logo that displays on the authentication screen, scaled to height: 60px
|
||||||
# TEAM_LOGO=https://example.com/images/logo.png
|
# TEAM_LOGO=https://example.com/images/logo.png
|
||||||
|
|
||||||
|
# The default interface language. See translate.getoutline.com for a list of
|
||||||
|
# available language codes and their rough percentage translated.
|
||||||
DEFAULT_LANGUAGE=en_US
|
DEFAULT_LANGUAGE=en_US
|
|
@ -18,6 +18,7 @@
|
||||||
|
|
||||||
[options]
|
[options]
|
||||||
emoji=true
|
emoji=true
|
||||||
|
sharedmemory.heap_size=3221225472
|
||||||
|
|
||||||
module.system.node.resolve_dirname=node_modules
|
module.system.node.resolve_dirname=node_modules
|
||||||
module.system.node.resolve_dirname=app
|
module.system.node.resolve_dirname=app
|
||||||
|
@ -32,6 +33,7 @@ module.file_ext=.json
|
||||||
esproposal.decorators=ignore
|
esproposal.decorators=ignore
|
||||||
esproposal.class_static_fields=enable
|
esproposal.class_static_fields=enable
|
||||||
esproposal.class_instance_fields=enable
|
esproposal.class_instance_fields=enable
|
||||||
|
esproposal.optional_chaining=enable
|
||||||
|
|
||||||
suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe
|
suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe
|
||||||
suppress_comment=\\(.\\|\n\\)*\\$FlowIssue
|
suppress_comment=\\(.\\|\n\\)*\\$FlowIssue
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"javascript.validate.enable": false,
|
"javascript.validate.enable": false,
|
||||||
|
"javascript.format.enable": false,
|
||||||
"typescript.validate.enable": false,
|
"typescript.validate.enable": false,
|
||||||
|
"typescript.format.enable": false,
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"typescript.format.enable": false
|
|
||||||
}
|
}
|
|
@ -0,0 +1,66 @@
|
||||||
|
|
||||||
|
# Architecture
|
||||||
|
|
||||||
|
Outline is composed of a backend and frontend codebase in this monorepo. As both are written in Javascript, they share some code where possible. We utilize the latest ES6 language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier formatting and ESLint are enforced by CI.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
Outline's frontend is a React application compiled with [Webpack](https://webpack.js.org/). It uses [MobX](https://mobx.js.org/) for state management and [Styled Components](https://www.styled-components.com/) for component styles. Unless global, state logic and styles are always co-located with React components together with their subcomponents to make the component tree easier to manage.
|
||||||
|
|
||||||
|
> Important Note: The Outline editor is built on [Prosemirror](https://github.com/prosemirror) and managed in a separate open source repository to encourage re-use: [rich-markdown-editor](https://github.com/outline/rich-markdown-editor).
|
||||||
|
|
||||||
|
```
|
||||||
|
app
|
||||||
|
├── components - React components reusable across scenes
|
||||||
|
├── embeds - Embed definitions that represent rich interactive embeds in the editor
|
||||||
|
├── hooks - Reusable React hooks
|
||||||
|
├── menus - Context menus, often appear in multiple places in the UI
|
||||||
|
├── models - State models using MobX observables
|
||||||
|
├── routes - Route definitions, note that chunks are async loaded with suspense
|
||||||
|
├── scenes - A scene represents a full-page view that contains several components
|
||||||
|
├── stores - Collections of models and associated fetch logic
|
||||||
|
├── types - Flow types
|
||||||
|
└── utils - Utility methods specific to the frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
The API server is driven by [Koa](http://koajs.com/), it uses [Sequelize](http://docs.sequelizejs.com/) as the ORM and Redis with [Bull](https://github.com/OptimalBits/bull) for queues and async event management. Authorization logic
|
||||||
|
is contained in [cancan](https://www.npmjs.com/package/cancan) policies under the "policies" directory.
|
||||||
|
|
||||||
|
Interested in more documentation on the API routes? Check out the [API documentation](https://getoutline.com/developers).
|
||||||
|
|
||||||
|
```
|
||||||
|
server
|
||||||
|
├── api - All API routes are contained within here
|
||||||
|
│ └── middlewares - Koa middlewares specific to the API
|
||||||
|
├── auth - OAuth routes for Slack and Google, plus email authentication routes
|
||||||
|
├── commands - We are gradually moving to the command pattern for new write logic
|
||||||
|
├── config - Database configuration
|
||||||
|
├── emails - Transactional email templates
|
||||||
|
│ └── components - Shared React components for email templates
|
||||||
|
├── middlewares - Koa middlewares
|
||||||
|
├── migrations - Database migrations
|
||||||
|
├── models - Sequelize models
|
||||||
|
├── onboarding - Markdown templates for onboarding documents
|
||||||
|
├── policies - Authorization logic based on cancan
|
||||||
|
├── presenters - JSON presenters for database models, the interface between backend -> frontend
|
||||||
|
├── services - Service definitions are triggered for events and perform async jobs
|
||||||
|
├── static - Static assets
|
||||||
|
├── test - Test helpers and fixtures, tests themselves are colocated
|
||||||
|
└── utils - Utility methods specific to the backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shared
|
||||||
|
|
||||||
|
Where logic is shared between the client and server it is placed in this directory. This is generally
|
||||||
|
small utilities.
|
||||||
|
|
||||||
|
```
|
||||||
|
shared
|
||||||
|
├── i18n - Internationalization confiuration
|
||||||
|
│ └── locales - Language specific translation files
|
||||||
|
├── styles - Styles, colors and other global aesthetics
|
||||||
|
├── utils - Shared utility methods
|
||||||
|
└── constants - Shared constants
|
||||||
|
```
|
153
README.md
153
README.md
|
@ -19,7 +19,7 @@ This is the source code that runs [**Outline**](https://www.getoutline.com) and
|
||||||
|
|
||||||
If you'd like to run your own copy of Outline or contribute to development then this is the place for you.
|
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:
|
Outline requires the following dependencies:
|
||||||
|
|
||||||
|
@ -31,33 +31,58 @@ Outline requires the following dependencies:
|
||||||
- Slack or Google developer application for authentication
|
- 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`
|
For a manual self-hosted production installation these are the recommended steps:
|
||||||
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. First setup Redis and Postgres servers, this is outside the scope of the guide.
|
||||||
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
|
1. Download the latest official Docker image, new releases are available around the middle of every month:
|
||||||
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
|
|
||||||
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
|
`docker pull outlinewiki/outline`
|
||||||
1. `DATABASE_URL` (run your own local copy of Postgres, or use a cloud service)
|
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:
|
||||||
1. `REDIS_URL` (run your own local copy of Redis, or use a cloud service)
|
|
||||||
1. `URL` (the public facing URL of your installation)
|
`docker run --env-file=.env outlinewiki/outline`
|
||||||
1. `AWS_` (all of the keys beginning with AWS)
|
1. Setup the database with `yarn sequelize:migrate`. Production assumes an SSL connection to the database by default, if
|
||||||
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`, for example:
|
||||||
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 `
|
`docker run --rm outlinewiki/outline yarn sequelize:migrate`
|
||||||
|
1. Start the container:
|
||||||
|
|
||||||
|
`docker run outlinewiki/outline`
|
||||||
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
|
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
|
> Port number can be changed using the `PORT` environment variable
|
||||||
|
|
||||||
1. (Optional) You can add an `nginx` reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc.
|
1. (Optional) You can add an `nginx` or other reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc.
|
||||||
|
|
||||||
|
### Terraform
|
||||||
|
|
||||||
|
Alternatively a community member maintains a script to deploy Outline on Google Cloud Platform with [Terraform & Ansible](https://github.com/rjsgn/outline-terraform-ansible).
|
||||||
|
|
||||||
|
### Upgrading
|
||||||
|
|
||||||
|
#### Docker
|
||||||
|
|
||||||
|
If you're running Outline with Docker you'll need to run migrations within the docker container after updating the image. The command will be something like:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker run --rm outlinewiki/outline:latest yarn sequelize:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Git
|
||||||
|
|
||||||
|
If you're running Outline by cloning this repository, run the following command to upgrade:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
yarn run upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
### Development
|
## Local Development
|
||||||
|
|
||||||
In development you can quickly get an environment running using Docker by following these steps:
|
For contributing features and fixes you can quickly get an environment running using Docker by following these steps:
|
||||||
|
|
||||||
1. Install these dependencies if you don't already have them
|
1. Install these dependencies if you don't already have them
|
||||||
1. [Docker for Desktop](https://www.docker.com)
|
1. [Docker for Desktop](https://www.docker.com)
|
||||||
|
@ -76,9 +101,28 @@ In development you can quickly get an environment running using Docker by follow
|
||||||
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
|
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
|
||||||
|
|
||||||
|
|
||||||
## Development
|
# Contributing
|
||||||
|
|
||||||
### Server
|
Outline is built and maintained by a small team – we'd love your help to fix bugs and add features!
|
||||||
|
|
||||||
|
Before submitting a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and we'd also love to hear from you in the [Discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher liklihood of code being accepted.
|
||||||
|
|
||||||
|
If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
|
||||||
|
|
||||||
|
* [Translation](TRANSLATION.md) into other languages
|
||||||
|
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
|
||||||
|
* Performance improvements, both on server and frontend
|
||||||
|
* Developer happiness and documentation
|
||||||
|
* Bugs and other issues listed on GitHub
|
||||||
|
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
If you're interested in contributing or learning more about the Outline codebase
|
||||||
|
please refer to the [architecture document](ARCHITECTURE.md) first for a high level overview of how the application is put together.
|
||||||
|
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging output, the following categories are available:
|
Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging output, the following categories are available:
|
||||||
|
|
||||||
|
@ -86,52 +130,6 @@ Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging o
|
||||||
DEBUG=sql,cache,presenters,events,logistics,emails,mailer
|
DEBUG=sql,cache,presenters,events,logistics,emails,mailer
|
||||||
```
|
```
|
||||||
|
|
||||||
## Migrations
|
|
||||||
|
|
||||||
Sequelize is used to create and run migrations, for example:
|
|
||||||
|
|
||||||
```
|
|
||||||
yarn sequelize migration:generate --name my-migration
|
|
||||||
yarn sequelize db:migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
Or to run migrations on test database:
|
|
||||||
|
|
||||||
```
|
|
||||||
yarn sequelize db:migrate --env test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
|
|
||||||
Outline is composed of separate backend and frontend application which are both driven by the same Node process. As both are written in Javascript, they share some code but are mostly separate. We utilize the latest language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier and ESLint are enforced by CI.
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
Outline's frontend is a React application compiled with [Webpack](https://webpack.js.org/). It uses [Mobx](https://mobx.js.org/) for state management and [Styled Components](https://www.styled-components.com/) for component styles. Unless global, state logic and styles are always co-located with React components together with their subcomponents to make the component tree easier to manage.
|
|
||||||
|
|
||||||
The editor itself is built on [Prosemirror](https://github.com/prosemirror) and hosted in a separate repository to encourage reuse: [rich-markdown-editor](https://github.com/outline/rich-markdown-editor)
|
|
||||||
|
|
||||||
- `app/` - Frontend React application
|
|
||||||
- `app/scenes` - Full page views
|
|
||||||
- `app/components` - Reusable React components
|
|
||||||
- `app/stores` - Global state stores
|
|
||||||
- `app/models` - State models
|
|
||||||
- `app/types` - Flow types for non-models
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
Backend is driven by [Koa](http://koajs.com/) (API, web server), [Sequelize](http://docs.sequelizejs.com/) (database) and React for public pages and emails.
|
|
||||||
|
|
||||||
- `server/api` - API endpoints
|
|
||||||
- `server/commands` - Domain logic, currently being refactored from /models
|
|
||||||
- `server/emails` - React rendered email templates
|
|
||||||
- `server/models` - Database models
|
|
||||||
- `server/policies` - Authorization logic
|
|
||||||
- `server/presenters` - API responses for database models
|
|
||||||
- `server/test` - Test helps and support
|
|
||||||
- `server/utils` - Utility methods
|
|
||||||
- `shared` - Code shared between frontend and backend applications
|
|
||||||
|
|
||||||
## Tests
|
## 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.
|
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
|
||||||
|
@ -157,20 +155,21 @@ yarn test:server
|
||||||
yarn test:app
|
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
|
yarn sequelize db:migrate --env test
|
||||||
* Performance improvements, both on server and frontend
|
```
|
||||||
* Developer happiness and documentation
|
|
||||||
* Bugs and other issues listed on GitHub
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Outline is [BSL 1.1 licensed](https://github.com/outline/outline/blob/master/LICENSE).
|
Outline is [BSL 1.1 licensed](LICENSE).
|
||||||
|
|
|
@ -11,11 +11,6 @@ export const Action = styled(Flex)`
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
a {
|
|
||||||
color: ${(props) => props.theme.text};
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:empty {
|
&:empty {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,8 +20,10 @@ const Authenticated = ({ children }: Props) => {
|
||||||
// Watching for language changes here as this is the earliest point we have
|
// Watching for language changes here as this is the earliest point we have
|
||||||
// the user available and means we can start loading translations faster
|
// the user available and means we can start loading translations faster
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (i18n.language !== language) {
|
if (language && i18n.language !== language) {
|
||||||
i18n.changeLanguage(language);
|
// Languages are stored in en_US format in the database, however the
|
||||||
|
// frontend translation framework (i18next) expects en-US
|
||||||
|
i18n.changeLanguage(language.replace("_", "-"));
|
||||||
}
|
}
|
||||||
}, [i18n, language]);
|
}, [i18n, language]);
|
||||||
|
|
||||||
|
|
|
@ -3,13 +3,17 @@ import { observable } from "mobx";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
import User from "models/User";
|
||||||
import placeholder from "./placeholder.png";
|
import placeholder from "./placeholder.png";
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
src: string,
|
src: string,
|
||||||
size: number,
|
size: number,
|
||||||
icon?: React.Node,
|
icon?: React.Node,
|
||||||
};
|
user?: User,
|
||||||
|
onClick?: () => void,
|
||||||
|
className?: string,
|
||||||
|
|};
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class Avatar extends React.Component<Props> {
|
class Avatar extends React.Component<Props> {
|
||||||
|
|
|
@ -4,7 +4,6 @@ import {
|
||||||
ArchiveIcon,
|
ArchiveIcon,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
GoToIcon,
|
GoToIcon,
|
||||||
MoreIcon,
|
|
||||||
PadlockIcon,
|
PadlockIcon,
|
||||||
ShapesIcon,
|
ShapesIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
|
@ -14,20 +13,18 @@ import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
|
|
||||||
import CollectionsStore from "stores/CollectionsStore";
|
|
||||||
import Document from "models/Document";
|
import Document from "models/Document";
|
||||||
import CollectionIcon from "components/CollectionIcon";
|
import CollectionIcon from "components/CollectionIcon";
|
||||||
import Flex from "components/Flex";
|
import Flex from "components/Flex";
|
||||||
import BreadcrumbMenu from "./BreadcrumbMenu";
|
|
||||||
import useStores from "hooks/useStores";
|
import useStores from "hooks/useStores";
|
||||||
|
import BreadcrumbMenu from "menus/BreadcrumbMenu";
|
||||||
import { collectionUrl } from "utils/routeHelpers";
|
import { collectionUrl } from "utils/routeHelpers";
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
document: Document,
|
document: Document,
|
||||||
collections: CollectionsStore,
|
children?: React.Node,
|
||||||
onlyText: boolean,
|
onlyText: boolean,
|
||||||
};
|
|};
|
||||||
|
|
||||||
function Icon({ document }) {
|
function Icon({ document }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
@ -35,11 +32,11 @@ function Icon({ document }) {
|
||||||
if (document.isDeleted) {
|
if (document.isDeleted) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CollectionName to="/trash">
|
<CategoryName to="/trash">
|
||||||
<TrashIcon color="currentColor" />
|
<TrashIcon color="currentColor" />
|
||||||
|
|
||||||
<span>{t("Trash")}</span>
|
<span>{t("Trash")}</span>
|
||||||
</CollectionName>
|
</CategoryName>
|
||||||
<Slash />
|
<Slash />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -47,11 +44,11 @@ function Icon({ document }) {
|
||||||
if (document.isArchived) {
|
if (document.isArchived) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CollectionName to="/archive">
|
<CategoryName to="/archive">
|
||||||
<ArchiveIcon color="currentColor" />
|
<ArchiveIcon color="currentColor" />
|
||||||
|
|
||||||
<span>{t("Archive")}</span>
|
<span>{t("Archive")}</span>
|
||||||
</CollectionName>
|
</CategoryName>
|
||||||
<Slash />
|
<Slash />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -59,11 +56,11 @@ function Icon({ document }) {
|
||||||
if (document.isDraft) {
|
if (document.isDraft) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CollectionName to="/drafts">
|
<CategoryName to="/drafts">
|
||||||
<EditIcon color="currentColor" />
|
<EditIcon color="currentColor" />
|
||||||
|
|
||||||
<span>{t("Drafts")}</span>
|
<span>{t("Drafts")}</span>
|
||||||
</CollectionName>
|
</CategoryName>
|
||||||
<Slash />
|
<Slash />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -71,11 +68,11 @@ function Icon({ document }) {
|
||||||
if (document.isTemplate) {
|
if (document.isTemplate) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CollectionName to="/templates">
|
<CategoryName to="/templates">
|
||||||
<ShapesIcon color="currentColor" />
|
<ShapesIcon color="currentColor" />
|
||||||
|
|
||||||
<span>{t("Templates")}</span>
|
<span>{t("Templates")}</span>
|
||||||
</CollectionName>
|
</CategoryName>
|
||||||
<Slash />
|
<Slash />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -83,14 +80,16 @@ function Icon({ document }) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Breadcrumb = ({ document, onlyText }: Props) => {
|
const Breadcrumb = ({ document, children, onlyText }: Props) => {
|
||||||
const { collections } = useStores();
|
const { collections } = useStores();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!collections.isLoaded) {
|
||||||
|
return <Wrapper />;
|
||||||
|
}
|
||||||
|
|
||||||
let collection = collections.get(document.collectionId);
|
let collection = collections.get(document.collectionId);
|
||||||
if (!collection) {
|
if (!collection) {
|
||||||
if (!document.deletedAt) return <div />;
|
|
||||||
|
|
||||||
collection = {
|
collection = {
|
||||||
id: document.collectionId,
|
id: document.collectionId,
|
||||||
name: t("Deleted Collection"),
|
name: t("Deleted Collection"),
|
||||||
|
@ -135,7 +134,7 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
|
||||||
</CollectionName>
|
</CollectionName>
|
||||||
{isNestedDocument && (
|
{isNestedDocument && (
|
||||||
<>
|
<>
|
||||||
<Slash /> <BreadcrumbMenu label={<Overflow />} path={menuPath} />
|
<Slash /> <BreadcrumbMenu path={menuPath} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{lastPath && (
|
{lastPath && (
|
||||||
|
@ -146,10 +145,16 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
|
||||||
</Crumb>
|
</Crumb>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{children}
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Slash = styled(GoToIcon)`
|
||||||
|
flex-shrink: 0;
|
||||||
|
fill: ${(props) => props.theme.divider};
|
||||||
|
`;
|
||||||
|
|
||||||
const Wrapper = styled(Flex)`
|
const Wrapper = styled(Flex)`
|
||||||
display: none;
|
display: none;
|
||||||
|
|
||||||
|
@ -170,22 +175,6 @@ const SmallSlash = styled(GoToIcon)`
|
||||||
opacity: 0.25;
|
opacity: 0.25;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const Slash = styled(GoToIcon)`
|
|
||||||
flex-shrink: 0;
|
|
||||||
fill: ${(props) => props.theme.divider};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Overflow = styled(MoreIcon)`
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: opacity 100ms ease-in-out;
|
|
||||||
fill: ${(props) => props.theme.divider};
|
|
||||||
|
|
||||||
&:active,
|
|
||||||
&:hover {
|
|
||||||
fill: ${(props) => props.theme.text};
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Crumb = styled(Link)`
|
const Crumb = styled(Link)`
|
||||||
color: ${(props) => props.theme.text};
|
color: ${(props) => props.theme.text};
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
@ -201,12 +190,21 @@ const Crumb = styled(Link)`
|
||||||
|
|
||||||
const CollectionName = styled(Link)`
|
const CollectionName = styled(Link)`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 1;
|
||||||
color: ${(props) => props.theme.text};
|
color: ${(props) => props.theme.text};
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CategoryName = styled(CollectionName)`
|
||||||
|
flex-shrink: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default observer(Breadcrumb);
|
export default observer(Breadcrumb);
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
// @flow
|
|
||||||
import * as React from "react";
|
|
||||||
import { DropdownMenu } from "components/DropdownMenu";
|
|
||||||
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
label: React.Node,
|
|
||||||
path: Array<any>,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function BreadcrumbMenu({ label, path }: Props) {
|
|
||||||
return (
|
|
||||||
<DropdownMenu label={label} position="center">
|
|
||||||
<DropdownMenuItems
|
|
||||||
items={path.map((item) => ({
|
|
||||||
title: item.title,
|
|
||||||
to: item.url,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -22,9 +22,13 @@ const RealButton = styled.button`
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
svg {
|
${(props) =>
|
||||||
fill: ${(props) => props.iconColor || props.theme.buttonText};
|
!props.borderOnHover &&
|
||||||
}
|
`
|
||||||
|
svg {
|
||||||
|
fill: ${props.iconColor || props.theme.buttonText};
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
|
||||||
&::-moz-focus-inner {
|
&::-moz-focus-inner {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@ -42,24 +46,30 @@ const RealButton = styled.button`
|
||||||
}
|
}
|
||||||
|
|
||||||
${(props) =>
|
${(props) =>
|
||||||
props.neutral &&
|
props.$neutral &&
|
||||||
`
|
`
|
||||||
background: ${props.theme.buttonNeutralBackground};
|
background: ${props.theme.buttonNeutralBackground};
|
||||||
color: ${props.theme.buttonNeutralText};
|
color: ${props.theme.buttonNeutralText};
|
||||||
box-shadow: ${
|
box-shadow: ${
|
||||||
props.borderOnHover ? "none" : "rgba(0, 0, 0, 0.07) 0px 1px 2px"
|
props.borderOnHover
|
||||||
};
|
? "none"
|
||||||
border: 1px solid ${
|
: `rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.buttonNeutralBorder} 0 0 0 1px inset`
|
||||||
props.borderOnHover ? "transparent" : props.theme.buttonNeutralBorder
|
|
||||||
};
|
};
|
||||||
|
|
||||||
svg {
|
${
|
||||||
|
props.borderOnHover
|
||||||
|
? ""
|
||||||
|
: `svg {
|
||||||
fill: ${props.iconColor || props.theme.buttonNeutralText};
|
fill: ${props.iconColor || props.theme.buttonNeutralText};
|
||||||
|
}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
|
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
|
||||||
border: 1px solid ${props.theme.buttonNeutralBorder};
|
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
|
||||||
|
props.theme.buttonNeutralBorder
|
||||||
|
} 0 0 0 1px inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
|
@ -71,9 +81,9 @@ const RealButton = styled.button`
|
||||||
background: ${props.theme.danger};
|
background: ${props.theme.danger};
|
||||||
color: ${props.theme.white};
|
color: ${props.theme.white};
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: ${darken(0.05, props.theme.danger)};
|
background: ${darken(0.05, props.theme.danger)};
|
||||||
}
|
}
|
||||||
`};
|
`};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
@ -92,14 +102,14 @@ export const Inner = styled.span`
|
||||||
line-height: ${(props) => (props.hasIcon ? 24 : 32)}px;
|
line-height: ${(props) => (props.hasIcon ? 24 : 32)}px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 30px;
|
min-height: 32px;
|
||||||
|
|
||||||
${(props) => props.hasIcon && props.hasText && "padding-left: 4px;"};
|
${(props) => props.hasIcon && props.hasText && "padding-left: 4px;"};
|
||||||
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
|
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {|
|
||||||
type?: string,
|
type?: "button" | "submit",
|
||||||
value?: string,
|
value?: string,
|
||||||
icon?: React.Node,
|
icon?: React.Node,
|
||||||
iconColor?: string,
|
iconColor?: string,
|
||||||
|
@ -107,9 +117,22 @@ export type Props = {
|
||||||
children?: React.Node,
|
children?: React.Node,
|
||||||
innerRef?: React.ElementRef<any>,
|
innerRef?: React.ElementRef<any>,
|
||||||
disclosure?: boolean,
|
disclosure?: boolean,
|
||||||
|
neutral?: boolean,
|
||||||
|
danger?: boolean,
|
||||||
|
primary?: boolean,
|
||||||
|
disabled?: boolean,
|
||||||
fullwidth?: boolean,
|
fullwidth?: boolean,
|
||||||
|
autoFocus?: boolean,
|
||||||
|
style?: Object,
|
||||||
|
as?: React.ComponentType<any>,
|
||||||
|
to?: string,
|
||||||
|
onClick?: (event: SyntheticEvent<>) => mixed,
|
||||||
borderOnHover?: boolean,
|
borderOnHover?: boolean,
|
||||||
};
|
|
||||||
|
"data-on"?: string,
|
||||||
|
"data-event-category"?: string,
|
||||||
|
"data-event-action"?: string,
|
||||||
|
|};
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
type = "text",
|
type = "text",
|
||||||
|
@ -118,13 +141,14 @@ function Button({
|
||||||
value,
|
value,
|
||||||
disclosure,
|
disclosure,
|
||||||
innerRef,
|
innerRef,
|
||||||
|
neutral,
|
||||||
...rest
|
...rest
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const hasText = children !== undefined || value !== undefined;
|
const hasText = children !== undefined || value !== undefined;
|
||||||
const hasIcon = icon !== undefined;
|
const hasIcon = icon !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RealButton type={type} ref={innerRef} {...rest}>
|
<RealButton type={type} ref={innerRef} $neutral={neutral} {...rest}>
|
||||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||||
{hasIcon && icon}
|
{hasIcon && icon}
|
||||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
// @flow
|
||||||
|
import * as React from "react";
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClick: (ev: SyntheticEvent<>) => void,
|
||||||
|
children: React.Node,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ButtonLink(props: Props) {
|
||||||
|
return <Button {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = styled.button`
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
color: ${(props) => props.theme.link};
|
||||||
|
line-height: inherit;
|
||||||
|
background: none;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
`;
|
|
@ -1,18 +1,21 @@
|
||||||
// @flow
|
// @flow
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import HelpText from "components/HelpText";
|
import HelpText from "components/HelpText";
|
||||||
import VisuallyHidden from "components/VisuallyHidden";
|
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {|
|
||||||
checked?: boolean,
|
checked?: boolean,
|
||||||
label?: string,
|
label?: string,
|
||||||
labelHidden?: boolean,
|
labelHidden?: boolean,
|
||||||
className?: string,
|
className?: string,
|
||||||
|
name?: string,
|
||||||
|
disabled?: boolean,
|
||||||
|
onChange: (event: SyntheticInputEvent<HTMLInputElement>) => mixed,
|
||||||
note?: string,
|
note?: string,
|
||||||
short?: boolean,
|
short?: boolean,
|
||||||
small?: boolean,
|
small?: boolean,
|
||||||
};
|
|};
|
||||||
|
|
||||||
const LabelText = styled.span`
|
const LabelText = styled.span`
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
// @flow
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
const Header = styled.h3`
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: ${(props) => props.theme.sidebarText};
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin: 1em 12px 0.5em;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default Header;
|
|
@ -1,50 +1,62 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { CheckmarkIcon } from "outline-icons";
|
import { CheckmarkIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
onClick?: (SyntheticEvent<>) => void | Promise<void>,
|
onClick?: (SyntheticEvent<>) => void | Promise<void>,
|
||||||
children?: React.Node,
|
children?: React.Node,
|
||||||
selected?: boolean,
|
selected?: boolean,
|
||||||
disabled?: boolean,
|
disabled?: boolean,
|
||||||
};
|
to?: string,
|
||||||
|
href?: string,
|
||||||
|
target?: "_blank",
|
||||||
|
as?: string | React.ComponentType<*>,
|
||||||
|
|};
|
||||||
|
|
||||||
const DropdownMenuItem = ({
|
const MenuItem = ({
|
||||||
onClick,
|
onClick,
|
||||||
children,
|
children,
|
||||||
selected,
|
selected,
|
||||||
disabled,
|
disabled,
|
||||||
|
as,
|
||||||
...rest
|
...rest
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<BaseMenuItem
|
||||||
onClick={disabled ? undefined : onClick}
|
onClick={disabled ? undefined : onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
role="menuitem"
|
|
||||||
tabIndex="-1"
|
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{selected !== undefined && (
|
{(props) => (
|
||||||
<>
|
<MenuAnchor as={onClick ? "button" : as} {...props}>
|
||||||
<CheckmarkIcon
|
{selected !== undefined && (
|
||||||
color={selected === false ? "transparent" : undefined}
|
<>
|
||||||
/>
|
{selected ? <CheckmarkIcon /> : <Spacer />}
|
||||||
|
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</MenuAnchor>
|
||||||
)}
|
)}
|
||||||
{children}
|
</BaseMenuItem>
|
||||||
</MenuItem>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const MenuItem = styled.a`
|
const Spacer = styled.div`
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MenuAnchor = styled.a`
|
||||||
display: flex;
|
display: flex;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
|
background: none;
|
||||||
color: ${(props) =>
|
color: ${(props) =>
|
||||||
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
|
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
|
||||||
justify-content: left;
|
justify-content: left;
|
||||||
|
@ -58,6 +70,7 @@ const MenuItem = styled.a`
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
opacity: ${(props) => (props.disabled ? ".5" : 1)};
|
opacity: ${(props) => (props.disabled ? ".5" : 1)};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -66,7 +79,8 @@ const MenuItem = styled.a`
|
||||||
? "pointer-events: none;"
|
? "pointer-events: none;"
|
||||||
: `
|
: `
|
||||||
|
|
||||||
&:hover {
|
&:hover,
|
||||||
|
&.focus-visible {
|
||||||
color: ${props.theme.white};
|
color: ${props.theme.white};
|
||||||
background: ${props.theme.primary};
|
background: ${props.theme.primary};
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
@ -84,4 +98,4 @@ const MenuItem = styled.a`
|
||||||
`};
|
`};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default DropdownMenuItem;
|
export default MenuItem;
|
|
@ -0,0 +1,21 @@
|
||||||
|
// @flow
|
||||||
|
import { MoreIcon } from "outline-icons";
|
||||||
|
import * as React from "react";
|
||||||
|
import { MenuButton } from "reakit/Menu";
|
||||||
|
import NudeButton from "components/NudeButton";
|
||||||
|
|
||||||
|
export default function OverflowMenuButton({
|
||||||
|
iconColor,
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: any) {
|
||||||
|
return (
|
||||||
|
<MenuButton {...rest}>
|
||||||
|
{(props) => (
|
||||||
|
<NudeButton className={className} {...props}>
|
||||||
|
<MoreIcon color={iconColor} />
|
||||||
|
</NudeButton>
|
||||||
|
)}
|
||||||
|
</MenuButton>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
// @flow
|
||||||
|
import * as React from "react";
|
||||||
|
import { MenuSeparator } from "reakit/Menu";
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
export default function Separator(rest: {}) {
|
||||||
|
return (
|
||||||
|
<MenuSeparator {...rest}>
|
||||||
|
{(props) => <HorizontalRule {...props} />}
|
||||||
|
</MenuSeparator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const HorizontalRule = styled.hr`
|
||||||
|
margin: 0.5em 12px;
|
||||||
|
`;
|
|
@ -1,26 +1,38 @@
|
||||||
// @flow
|
// @flow
|
||||||
|
import { ExpandedIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import DropdownMenu from "./DropdownMenu";
|
import {
|
||||||
import DropdownMenuItem from "./DropdownMenuItem";
|
useMenuState,
|
||||||
|
MenuButton,
|
||||||
|
MenuItem as BaseMenuItem,
|
||||||
|
} from "reakit/Menu";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import MenuItem, { MenuAnchor } from "./MenuItem";
|
||||||
|
import Separator from "./Separator";
|
||||||
|
import ContextMenu from ".";
|
||||||
|
|
||||||
type MenuItem =
|
type TMenuItem =
|
||||||
| {|
|
| {|
|
||||||
title: React.Node,
|
title: React.Node,
|
||||||
to: string,
|
to: string,
|
||||||
visible?: boolean,
|
visible?: boolean,
|
||||||
|
selected?: boolean,
|
||||||
disabled?: boolean,
|
disabled?: boolean,
|
||||||
|}
|
|}
|
||||||
| {|
|
| {|
|
||||||
title: React.Node,
|
title: React.Node,
|
||||||
onClick: (event: SyntheticEvent<>) => void | Promise<void>,
|
onClick: (event: SyntheticEvent<>) => void | Promise<void>,
|
||||||
visible?: boolean,
|
visible?: boolean,
|
||||||
|
selected?: boolean,
|
||||||
disabled?: boolean,
|
disabled?: boolean,
|
||||||
|}
|
|}
|
||||||
| {|
|
| {|
|
||||||
title: React.Node,
|
title: React.Node,
|
||||||
href: string,
|
href: string,
|
||||||
visible?: boolean,
|
visible?: boolean,
|
||||||
|
selected?: boolean,
|
||||||
disabled?: boolean,
|
disabled?: boolean,
|
||||||
|}
|
|}
|
||||||
| {|
|
| {|
|
||||||
|
@ -29,7 +41,7 @@ type MenuItem =
|
||||||
disabled?: boolean,
|
disabled?: boolean,
|
||||||
style?: Object,
|
style?: Object,
|
||||||
hover?: boolean,
|
hover?: boolean,
|
||||||
items: MenuItem[],
|
items: TMenuItem[],
|
||||||
|}
|
|}
|
||||||
| {|
|
| {|
|
||||||
type: "separator",
|
type: "separator",
|
||||||
|
@ -42,10 +54,35 @@ type MenuItem =
|
||||||
|};
|
|};
|
||||||
|
|
||||||
type Props = {|
|
type Props = {|
|
||||||
items: MenuItem[],
|
items: TMenuItem[],
|
||||||
|};
|
|};
|
||||||
|
|
||||||
export default function DropdownMenuItems({ items }: Props): React.Node {
|
const Disclosure = styled(ExpandedIcon)`
|
||||||
|
transform: rotate(270deg);
|
||||||
|
justify-self: flex-end;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const menu = useMenuState({ modal: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MenuButton ref={ref} {...menu} {...rest}>
|
||||||
|
{(props) => (
|
||||||
|
<MenuAnchor {...props}>
|
||||||
|
{title} <Disclosure color="currentColor" />
|
||||||
|
</MenuAnchor>
|
||||||
|
)}
|
||||||
|
</MenuButton>
|
||||||
|
<ContextMenu {...menu} aria-label={t("Submenu")}>
|
||||||
|
<Template {...menu} items={templateItems} />
|
||||||
|
</ContextMenu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function Template({ items, ...menu }: Props): React.Node {
|
||||||
let filtered = items.filter((item) => item.visible !== false);
|
let filtered = items.filter((item) => item.visible !== false);
|
||||||
|
|
||||||
// this block literally just trims unneccessary separators
|
// this block literally just trims unneccessary separators
|
||||||
|
@ -66,63 +103,67 @@ export default function DropdownMenuItems({ items }: Props): React.Node {
|
||||||
return filtered.map((item, index) => {
|
return filtered.map((item, index) => {
|
||||||
if (item.to) {
|
if (item.to) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<MenuItem
|
||||||
as={Link}
|
as={Link}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
key={index}
|
key={index}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
|
selected={item.selected}
|
||||||
|
{...menu}
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</DropdownMenuItem>
|
</MenuItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.href) {
|
if (item.href) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<MenuItem
|
||||||
href={item.href}
|
href={item.href}
|
||||||
key={index}
|
key={index}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
|
selected={item.selected}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
{...menu}
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</DropdownMenuItem>
|
</MenuItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.onClick) {
|
if (item.onClick) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<MenuItem
|
||||||
|
as="button"
|
||||||
onClick={item.onClick}
|
onClick={item.onClick}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
|
selected={item.selected}
|
||||||
key={index}
|
key={index}
|
||||||
|
{...menu}
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</DropdownMenuItem>
|
</MenuItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.items) {
|
if (item.items) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu
|
<BaseMenuItem
|
||||||
style={item.style}
|
|
||||||
label={
|
|
||||||
<DropdownMenuItem disabled={item.disabled}>
|
|
||||||
{item.title}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
}
|
|
||||||
hover={item.hover}
|
|
||||||
key={index}
|
key={index}
|
||||||
>
|
as={Submenu}
|
||||||
<DropdownMenuItems items={item.items} />
|
templateItems={item.items}
|
||||||
</DropdownMenu>
|
title={item.title}
|
||||||
|
{...menu}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.type === "separator") {
|
if (item.type === "separator") {
|
||||||
return <hr key={index} />;
|
return <Separator key={index} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default React.memo<Props>(Template);
|
|
@ -0,0 +1,77 @@
|
||||||
|
// @flow
|
||||||
|
import { rgba } from "polished";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Menu } from "reakit/Menu";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||||
|
import usePrevious from "hooks/usePrevious";
|
||||||
|
|
||||||
|
type Props = {|
|
||||||
|
"aria-label": string,
|
||||||
|
visible?: boolean,
|
||||||
|
animating?: boolean,
|
||||||
|
children: React.Node,
|
||||||
|
onOpen?: () => void,
|
||||||
|
onClose?: () => void,
|
||||||
|
|};
|
||||||
|
|
||||||
|
export default function ContextMenu({
|
||||||
|
children,
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
|
...rest
|
||||||
|
}: Props) {
|
||||||
|
const previousVisible = usePrevious(rest.visible);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (rest.visible && !previousVisible) {
|
||||||
|
if (onOpen) {
|
||||||
|
onOpen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rest.visible && previousVisible) {
|
||||||
|
if (onClose) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [onOpen, onClose, previousVisible, rest.visible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu {...rest}>
|
||||||
|
{(props) => (
|
||||||
|
<Position {...props}>
|
||||||
|
<Background>
|
||||||
|
{rest.visible || rest.animating ? children : null}
|
||||||
|
</Background>
|
||||||
|
</Position>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Position = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
z-index: ${(props) => props.theme.depths.menu};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Background = styled.div`
|
||||||
|
animation: ${fadeAndScaleIn} 200ms ease;
|
||||||
|
transform-origin: ${(props) => (props.left !== undefined ? "25%" : "75%")} 0;
|
||||||
|
background: ${(props) => rgba(props.theme.menuBackground, 0.95)};
|
||||||
|
border: ${(props) =>
|
||||||
|
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0.5em 0;
|
||||||
|
min-width: 180px;
|
||||||
|
overflow: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 75vh;
|
||||||
|
max-width: 276px;
|
||||||
|
box-shadow: ${(props) => props.theme.menuShadow};
|
||||||
|
pointer-events: all;
|
||||||
|
font-weight: normal;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`;
|
|
@ -156,7 +156,7 @@ const Wrapper = styled(Flex)`
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
min-width: ${(props) => props.theme.sidebarWidth};
|
min-width: ${(props) => props.theme.sidebarWidth}px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
|
@ -165,7 +165,7 @@ const Wrapper = styled(Flex)`
|
||||||
const Sidebar = styled(Flex)`
|
const Sidebar = styled(Flex)`
|
||||||
display: none;
|
display: none;
|
||||||
background: ${(props) => props.theme.background};
|
background: ${(props) => props.theme.background};
|
||||||
min-width: ${(props) => props.theme.sidebarWidth};
|
min-width: ${(props) => props.theme.sidebarWidth}px;
|
||||||
border-left: 1px solid ${(props) => props.theme.divider};
|
border-left: 1px solid ${(props) => props.theme.divider};
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
// @flow
|
// @flow
|
||||||
import format from "date-fns/format";
|
import format from "date-fns/format";
|
||||||
import { MoreIcon } from "outline-icons";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { NavLink } from "react-router-dom";
|
import { NavLink } from "react-router-dom";
|
||||||
import styled, { withTheme } from "styled-components";
|
import styled, { withTheme } from "styled-components";
|
||||||
|
@ -45,9 +44,7 @@ class RevisionListItem extends React.Component<Props> {
|
||||||
<StyledRevisionMenu
|
<StyledRevisionMenu
|
||||||
document={document}
|
document={document}
|
||||||
revision={revision}
|
revision={revision}
|
||||||
label={
|
iconColor={selected ? theme.white : theme.textTertiary}
|
||||||
<MoreIcon color={selected ? theme.white : theme.textTertiary} />
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</StyledNavLink>
|
</StyledNavLink>
|
||||||
|
|
|
@ -2,12 +2,17 @@
|
||||||
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
|
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Document from "models/Document";
|
import Document from "models/Document";
|
||||||
import DocumentPreview from "components/DocumentPreview";
|
import DocumentListItem from "components/DocumentListItem";
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
documents: Document[],
|
documents: Document[],
|
||||||
limit?: number,
|
limit?: number,
|
||||||
};
|
showCollection?: boolean,
|
||||||
|
showPublished?: boolean,
|
||||||
|
showPin?: boolean,
|
||||||
|
showDraft?: boolean,
|
||||||
|
showTemplate?: boolean,
|
||||||
|
|};
|
||||||
|
|
||||||
export default function DocumentList({ limit, documents, ...rest }: Props) {
|
export default function DocumentList({ limit, documents, ...rest }: Props) {
|
||||||
const items = limit ? documents.splice(0, limit) : documents;
|
const items = limit ? documents.splice(0, limit) : documents;
|
||||||
|
@ -18,7 +23,7 @@ export default function DocumentList({ limit, documents, ...rest }: Props) {
|
||||||
defaultActiveChildIndex={0}
|
defaultActiveChildIndex={0}
|
||||||
>
|
>
|
||||||
{items.map((document) => (
|
{items.map((document) => (
|
||||||
<DocumentPreview key={document.id} document={document} {...rest} />
|
<DocumentListItem key={document.id} document={document} {...rest} />
|
||||||
))}
|
))}
|
||||||
</ArrowKeyNavigation>
|
</ArrowKeyNavigation>
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,243 @@
|
||||||
|
// @flow
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { PlusIcon } from "outline-icons";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import styled, { css } from "styled-components";
|
||||||
|
import breakpoint from "styled-components-breakpoint";
|
||||||
|
import Document from "models/Document";
|
||||||
|
import Badge from "components/Badge";
|
||||||
|
import Button from "components/Button";
|
||||||
|
import DocumentMeta from "components/DocumentMeta";
|
||||||
|
import EventBoundary from "components/EventBoundary";
|
||||||
|
import Flex from "components/Flex";
|
||||||
|
import Highlight from "components/Highlight";
|
||||||
|
import StarButton, { AnimatedStar } from "components/Star";
|
||||||
|
import Tooltip from "components/Tooltip";
|
||||||
|
import useCurrentUser from "hooks/useCurrentUser";
|
||||||
|
import DocumentMenu from "menus/DocumentMenu";
|
||||||
|
import { newDocumentUrl } from "utils/routeHelpers";
|
||||||
|
|
||||||
|
type Props = {|
|
||||||
|
document: Document,
|
||||||
|
highlight?: ?string,
|
||||||
|
context?: ?string,
|
||||||
|
showNestedDocuments?: boolean,
|
||||||
|
showCollection?: boolean,
|
||||||
|
showPublished?: boolean,
|
||||||
|
showPin?: boolean,
|
||||||
|
showDraft?: boolean,
|
||||||
|
showTemplate?: boolean,
|
||||||
|
|};
|
||||||
|
|
||||||
|
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||||
|
|
||||||
|
function replaceResultMarks(tag: string) {
|
||||||
|
// don't use SEARCH_RESULT_REGEX here as it causes
|
||||||
|
// an infinite loop to trigger a regex inside it's own callback
|
||||||
|
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocumentListItem(props: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const currentUser = useCurrentUser();
|
||||||
|
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||||
|
const {
|
||||||
|
document,
|
||||||
|
showNestedDocuments,
|
||||||
|
showCollection,
|
||||||
|
showPublished,
|
||||||
|
showPin,
|
||||||
|
showDraft = true,
|
||||||
|
showTemplate,
|
||||||
|
highlight,
|
||||||
|
context,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const queryIsInTitle =
|
||||||
|
!!highlight &&
|
||||||
|
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||||
|
const canStar =
|
||||||
|
!document.isDraft && !document.isArchived && !document.isTemplate;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentLink
|
||||||
|
$isStarred={document.isStarred}
|
||||||
|
$menuOpen={menuOpen}
|
||||||
|
to={{
|
||||||
|
pathname: document.url,
|
||||||
|
state: { title: document.titleWithDefault },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Content>
|
||||||
|
<Heading>
|
||||||
|
<Title text={document.titleWithDefault} highlight={highlight} />
|
||||||
|
{document.isNew && document.createdBy.id !== currentUser.id && (
|
||||||
|
<Badge yellow>{t("New")}</Badge>
|
||||||
|
)}
|
||||||
|
{canStar && (
|
||||||
|
<StarPositioner>
|
||||||
|
<StarButton document={document} />
|
||||||
|
</StarPositioner>
|
||||||
|
)}
|
||||||
|
{document.isDraft && showDraft && (
|
||||||
|
<Tooltip
|
||||||
|
tooltip={t("Only visible to you")}
|
||||||
|
delay={500}
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<Badge>{t("Draft")}</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{document.isTemplate && showTemplate && (
|
||||||
|
<Badge primary>{t("Template")}</Badge>
|
||||||
|
)}
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
{!queryIsInTitle && (
|
||||||
|
<ResultContext
|
||||||
|
text={context}
|
||||||
|
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||||
|
processResult={replaceResultMarks}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<DocumentMeta
|
||||||
|
document={document}
|
||||||
|
showCollection={showCollection}
|
||||||
|
showPublished={showPublished}
|
||||||
|
showNestedDocuments={showNestedDocuments}
|
||||||
|
showLastViewed
|
||||||
|
/>
|
||||||
|
</Content>
|
||||||
|
<Actions>
|
||||||
|
{document.isTemplate && !document.isArchived && !document.isDeleted && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
to={newDocumentUrl(document.collectionId, {
|
||||||
|
templateId: document.id,
|
||||||
|
})}
|
||||||
|
icon={<PlusIcon />}
|
||||||
|
neutral
|
||||||
|
>
|
||||||
|
{t("New doc")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DocumentMenu
|
||||||
|
document={document}
|
||||||
|
showPin={showPin}
|
||||||
|
onOpen={() => setMenuOpen(true)}
|
||||||
|
onClose={() => setMenuOpen(false)}
|
||||||
|
modal={false}
|
||||||
|
/>
|
||||||
|
</Actions>
|
||||||
|
</DocumentLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Content = styled.div`
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Actions = styled(EventBoundary)`
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
margin: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-grow: 0;
|
||||||
|
|
||||||
|
${breakpoint("tablet")`
|
||||||
|
display: flex;
|
||||||
|
`};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DocumentLink = styled(Link)`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 10px -8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-height: 50vh;
|
||||||
|
min-width: 100%;
|
||||||
|
max-width: calc(100vw - 40px);
|
||||||
|
|
||||||
|
${Actions} {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
${AnimatedStar} {
|
||||||
|
opacity: ${(props) => (props.$isStarred ? "1 !important" : 0)};
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&:focus,
|
||||||
|
&:focus-within {
|
||||||
|
background: ${(props) => props.theme.listItemHoverBackground};
|
||||||
|
|
||||||
|
${Actions} {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
${AnimatedStar} {
|
||||||
|
opacity: 0.5;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
${(props) =>
|
||||||
|
props.$menuOpen &&
|
||||||
|
css`
|
||||||
|
background: ${(props) => props.theme.listItemHoverBackground};
|
||||||
|
|
||||||
|
${Actions} {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
${AnimatedStar} {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Heading = styled.h3`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 24px;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: ${(props) => props.theme.text};
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||||
|
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StarPositioner = styled(Flex)`
|
||||||
|
margin-left: 4px;
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Title = styled(Highlight)`
|
||||||
|
max-width: 90%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ResultContext = styled(Highlight)`
|
||||||
|
display: block;
|
||||||
|
color: ${(props) => props.theme.textTertiary};
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: -0.25em;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default observer(DocumentListItem);
|
|
@ -15,6 +15,7 @@ const Container = styled(Flex)`
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Modified = styled.span`
|
const Modified = styled.span`
|
||||||
|
@ -22,19 +23,21 @@ const Modified = styled.span`
|
||||||
font-weight: ${(props) => (props.highlight ? "600" : "400")};
|
font-weight: ${(props) => (props.highlight ? "600" : "400")};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
showCollection?: boolean,
|
showCollection?: boolean,
|
||||||
showPublished?: boolean,
|
showPublished?: boolean,
|
||||||
showLastViewed?: boolean,
|
showLastViewed?: boolean,
|
||||||
|
showNestedDocuments?: boolean,
|
||||||
document: Document,
|
document: Document,
|
||||||
children: React.Node,
|
children: React.Node,
|
||||||
to?: string,
|
to?: string,
|
||||||
};
|
|};
|
||||||
|
|
||||||
function DocumentMeta({
|
function DocumentMeta({
|
||||||
showPublished,
|
showPublished,
|
||||||
showCollection,
|
showCollection,
|
||||||
showLastViewed,
|
showLastViewed,
|
||||||
|
showNestedDocuments,
|
||||||
document,
|
document,
|
||||||
children,
|
children,
|
||||||
to,
|
to,
|
||||||
|
@ -122,6 +125,10 @@ function DocumentMeta({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const nestedDocumentsCount = collection
|
||||||
|
? collection.getDocumentChildren(document.id).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container align="center" {...rest}>
|
<Container align="center" {...rest}>
|
||||||
{updatedByMe ? t("You") : updatedBy.name}
|
{updatedByMe ? t("You") : updatedBy.name}
|
||||||
|
@ -134,6 +141,12 @@ function DocumentMeta({
|
||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{showNestedDocuments && nestedDocumentsCount > 0 && (
|
||||||
|
<span>
|
||||||
|
· {nestedDocumentsCount}{" "}
|
||||||
|
{t("nested document", { count: nestedDocumentsCount })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{timeSinceNow()}
|
{timeSinceNow()}
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
|
|
|
@ -35,6 +35,8 @@ function DocumentMetaWithViews({ to, isDraft, document }: Props) {
|
||||||
const Meta = styled(DocumentMeta)`
|
const Meta = styled(DocumentMeta)`
|
||||||
margin: -12px 0 2em 0;
|
margin: -12px 0 2em 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
|
|
@ -1,247 +0,0 @@
|
||||||
// @flow
|
|
||||||
import { observable } from "mobx";
|
|
||||||
import { observer } from "mobx-react";
|
|
||||||
import { StarredIcon, PlusIcon } from "outline-icons";
|
|
||||||
import * as React from "react";
|
|
||||||
import { withTranslation, type TFunction } from "react-i18next";
|
|
||||||
import { Link, Redirect } from "react-router-dom";
|
|
||||||
import styled, { withTheme } from "styled-components";
|
|
||||||
import Document from "models/Document";
|
|
||||||
import Badge from "components/Badge";
|
|
||||||
import Button from "components/Button";
|
|
||||||
import DocumentMeta from "components/DocumentMeta";
|
|
||||||
import EventBoundary from "components/EventBoundary";
|
|
||||||
import Flex from "components/Flex";
|
|
||||||
import Highlight from "components/Highlight";
|
|
||||||
import Tooltip from "components/Tooltip";
|
|
||||||
import DocumentMenu from "menus/DocumentMenu";
|
|
||||||
import { newDocumentUrl } from "utils/routeHelpers";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
document: Document,
|
|
||||||
highlight?: ?string,
|
|
||||||
context?: ?string,
|
|
||||||
showCollection?: boolean,
|
|
||||||
showPublished?: boolean,
|
|
||||||
showPin?: boolean,
|
|
||||||
showDraft?: boolean,
|
|
||||||
showTemplate?: boolean,
|
|
||||||
t: TFunction,
|
|
||||||
};
|
|
||||||
|
|
||||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
|
||||||
|
|
||||||
@observer
|
|
||||||
class DocumentPreview extends React.Component<Props> {
|
|
||||||
@observable redirectTo: ?string;
|
|
||||||
|
|
||||||
handleStar = (ev: SyntheticEvent<>) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
this.props.document.star();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleUnstar = (ev: SyntheticEvent<>) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
this.props.document.unstar();
|
|
||||||
};
|
|
||||||
|
|
||||||
replaceResultMarks = (tag: string) => {
|
|
||||||
// don't use SEARCH_RESULT_REGEX here as it causes
|
|
||||||
// an infinite loop to trigger a regex inside it's own callback
|
|
||||||
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
|
|
||||||
};
|
|
||||||
|
|
||||||
handleNewFromTemplate = (event: SyntheticEvent<>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
const { document } = this.props;
|
|
||||||
|
|
||||||
this.redirectTo = newDocumentUrl(document.collectionId, {
|
|
||||||
templateId: document.id,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
document,
|
|
||||||
showCollection,
|
|
||||||
showPublished,
|
|
||||||
showPin,
|
|
||||||
showDraft = true,
|
|
||||||
showTemplate,
|
|
||||||
highlight,
|
|
||||||
context,
|
|
||||||
t,
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
if (this.redirectTo) {
|
|
||||||
return <Redirect to={this.redirectTo} push />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryIsInTitle =
|
|
||||||
!!highlight &&
|
|
||||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DocumentLink
|
|
||||||
to={{
|
|
||||||
pathname: document.url,
|
|
||||||
state: { title: document.titleWithDefault },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Heading>
|
|
||||||
<Title text={document.titleWithDefault} highlight={highlight} />
|
|
||||||
{document.isNew && <Badge yellow>{t("New")}</Badge>}
|
|
||||||
{!document.isDraft &&
|
|
||||||
!document.isArchived &&
|
|
||||||
!document.isTemplate && (
|
|
||||||
<Actions>
|
|
||||||
{document.isStarred ? (
|
|
||||||
<StyledStar onClick={this.handleUnstar} solid />
|
|
||||||
) : (
|
|
||||||
<StyledStar onClick={this.handleStar} />
|
|
||||||
)}
|
|
||||||
</Actions>
|
|
||||||
)}
|
|
||||||
{document.isDraft && showDraft && (
|
|
||||||
<Tooltip
|
|
||||||
tooltip={t("Only visible to you")}
|
|
||||||
delay={500}
|
|
||||||
placement="top"
|
|
||||||
>
|
|
||||||
<Badge>{t("Draft")}</Badge>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{document.isTemplate && showTemplate && (
|
|
||||||
<Badge primary>{t("Template")}</Badge>
|
|
||||||
)}
|
|
||||||
<SecondaryActions>
|
|
||||||
{document.isTemplate &&
|
|
||||||
!document.isArchived &&
|
|
||||||
!document.isDeleted && (
|
|
||||||
<Button
|
|
||||||
onClick={this.handleNewFromTemplate}
|
|
||||||
icon={<PlusIcon />}
|
|
||||||
neutral
|
|
||||||
>
|
|
||||||
{t("New doc")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<EventBoundary>
|
|
||||||
<DocumentMenu document={document} showPin={showPin} />
|
|
||||||
</EventBoundary>
|
|
||||||
</SecondaryActions>
|
|
||||||
</Heading>
|
|
||||||
|
|
||||||
{!queryIsInTitle && (
|
|
||||||
<ResultContext
|
|
||||||
text={context}
|
|
||||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
|
||||||
processResult={this.replaceResultMarks}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<DocumentMeta
|
|
||||||
document={document}
|
|
||||||
showCollection={showCollection}
|
|
||||||
showPublished={showPublished}
|
|
||||||
showLastViewed
|
|
||||||
/>
|
|
||||||
</DocumentLink>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const StyledStar = withTheme(styled(({ solid, theme, ...props }) => (
|
|
||||||
<StarredIcon color={theme.text} {...props} />
|
|
||||||
))`
|
|
||||||
flex-shrink: 0;
|
|
||||||
opacity: ${(props) => (props.solid ? "1 !important" : 0)};
|
|
||||||
transition: all 100ms ease-in-out;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
|
|
||||||
const SecondaryActions = styled(Flex)`
|
|
||||||
align-items: center;
|
|
||||||
position: absolute;
|
|
||||||
right: 16px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const DocumentLink = styled(Link)`
|
|
||||||
display: block;
|
|
||||||
margin: 10px -8px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-radius: 8px;
|
|
||||||
max-height: 50vh;
|
|
||||||
min-width: 100%;
|
|
||||||
max-width: calc(100vw - 40px);
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
${SecondaryActions} {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:active,
|
|
||||||
&:focus {
|
|
||||||
background: ${(props) => props.theme.listItemHoverBackground};
|
|
||||||
|
|
||||||
${SecondaryActions} {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
${StyledStar} {
|
|
||||||
opacity: 0.5;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Heading = styled.h3`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 24px;
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 0.25em;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: ${(props) => props.theme.text};
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
|
||||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Actions = styled(Flex)`
|
|
||||||
margin-left: 4px;
|
|
||||||
align-items: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Title = styled(Highlight)`
|
|
||||||
max-width: 90%;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ResultContext = styled(Highlight)`
|
|
||||||
display: block;
|
|
||||||
color: ${(props) => props.theme.textTertiary};
|
|
||||||
font-size: 14px;
|
|
||||||
margin-top: -0.25em;
|
|
||||||
margin-bottom: 0.25em;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default withTranslation()<DocumentPreview>(DocumentPreview);
|
|
|
@ -1,3 +0,0 @@
|
||||||
// @flow
|
|
||||||
import DocumentPreview from "./DocumentPreview";
|
|
||||||
export default DocumentPreview;
|
|
|
@ -60,7 +60,9 @@ class DropToImport extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.props.ui.showToast(`Could not import file. ${err.message}`);
|
this.props.ui.showToast(`Could not import file. ${err.message}`, {
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
this.isImporting = false;
|
this.isImporting = false;
|
||||||
importingLock = false;
|
importingLock = false;
|
||||||
|
@ -87,7 +89,11 @@ class DropToImport extends React.Component<Props> {
|
||||||
isDragAccept,
|
isDragAccept,
|
||||||
isDragReject,
|
isDragReject,
|
||||||
}) => (
|
}) => (
|
||||||
<DropzoneContainer {...getRootProps()} {...{ isDragActive }}>
|
<DropzoneContainer
|
||||||
|
{...getRootProps()}
|
||||||
|
{...{ isDragActive }}
|
||||||
|
tabIndex="-1"
|
||||||
|
>
|
||||||
<input {...getInputProps()} />
|
<input {...getInputProps()} />
|
||||||
{this.isImporting && <LoadingIndicator />}
|
{this.isImporting && <LoadingIndicator />}
|
||||||
{this.props.children}
|
{this.props.children}
|
||||||
|
|
|
@ -1,289 +0,0 @@
|
||||||
// @flow
|
|
||||||
import invariant from "invariant";
|
|
||||||
import { observable } from "mobx";
|
|
||||||
import { observer } from "mobx-react";
|
|
||||||
import { MoreIcon } from "outline-icons";
|
|
||||||
import { rgba } from "polished";
|
|
||||||
import * as React from "react";
|
|
||||||
import { withTranslation, type TFunction } from "react-i18next";
|
|
||||||
import { PortalWithState } from "react-portal";
|
|
||||||
import styled from "styled-components";
|
|
||||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
|
||||||
import Flex from "components/Flex";
|
|
||||||
import NudeButton from "components/NudeButton";
|
|
||||||
|
|
||||||
let previousClosePortal;
|
|
||||||
let counter = 0;
|
|
||||||
|
|
||||||
type Children =
|
|
||||||
| React.Node
|
|
||||||
| ((options: { closePortal: () => void }) => React.Node);
|
|
||||||
|
|
||||||
type Props = {|
|
|
||||||
label?: React.Node,
|
|
||||||
onOpen?: () => void,
|
|
||||||
onClose?: () => void,
|
|
||||||
children?: Children,
|
|
||||||
className?: string,
|
|
||||||
hover?: boolean,
|
|
||||||
style?: Object,
|
|
||||||
position?: "left" | "right" | "center",
|
|
||||||
t: TFunction,
|
|
||||||
|};
|
|
||||||
|
|
||||||
@observer
|
|
||||||
class DropdownMenu extends React.Component<Props> {
|
|
||||||
id: string = `menu${counter++}`;
|
|
||||||
closeTimeout: TimeoutID;
|
|
||||||
|
|
||||||
@observable top: ?number;
|
|
||||||
@observable bottom: ?number;
|
|
||||||
@observable right: ?number;
|
|
||||||
@observable left: ?number;
|
|
||||||
@observable position: "left" | "right" | "center";
|
|
||||||
@observable fixed: ?boolean;
|
|
||||||
@observable bodyRect: ClientRect;
|
|
||||||
@observable labelRect: ClientRect;
|
|
||||||
@observable dropdownRef: { current: null | HTMLElement } = React.createRef();
|
|
||||||
@observable menuRef: { current: null | HTMLElement } = React.createRef();
|
|
||||||
|
|
||||||
handleOpen = (
|
|
||||||
openPortal: (SyntheticEvent<>) => void,
|
|
||||||
closePortal: () => void
|
|
||||||
) => {
|
|
||||||
return (ev: SyntheticMouseEvent<HTMLElement>) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
const currentTarget = ev.currentTarget;
|
|
||||||
invariant(document.body, "why you not here");
|
|
||||||
|
|
||||||
if (currentTarget instanceof HTMLDivElement) {
|
|
||||||
this.bodyRect = document.body.getBoundingClientRect();
|
|
||||||
this.labelRect = currentTarget.getBoundingClientRect();
|
|
||||||
this.top = this.labelRect.bottom - this.bodyRect.top;
|
|
||||||
this.bottom = undefined;
|
|
||||||
this.position = this.props.position || "left";
|
|
||||||
|
|
||||||
if (currentTarget.parentElement) {
|
|
||||||
const triggerParentStyle = getComputedStyle(
|
|
||||||
currentTarget.parentElement
|
|
||||||
);
|
|
||||||
|
|
||||||
if (triggerParentStyle.position === "static") {
|
|
||||||
this.fixed = true;
|
|
||||||
this.top = this.labelRect.bottom;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.initPosition();
|
|
||||||
|
|
||||||
// attempt to keep only one flyout menu open at once
|
|
||||||
if (previousClosePortal && !this.props.hover) {
|
|
||||||
previousClosePortal();
|
|
||||||
}
|
|
||||||
previousClosePortal = closePortal;
|
|
||||||
openPortal(ev);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
initPosition() {
|
|
||||||
if (this.position === "left") {
|
|
||||||
this.right =
|
|
||||||
this.bodyRect.width - this.labelRect.left - this.labelRect.width;
|
|
||||||
} else if (this.position === "center") {
|
|
||||||
this.left = this.labelRect.left + this.labelRect.width / 2;
|
|
||||||
} else {
|
|
||||||
this.left = this.labelRect.left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onOpen = () => {
|
|
||||||
if (typeof this.props.onOpen === "function") {
|
|
||||||
this.props.onOpen();
|
|
||||||
}
|
|
||||||
this.fitOnTheScreen();
|
|
||||||
};
|
|
||||||
|
|
||||||
fitOnTheScreen() {
|
|
||||||
if (!this.dropdownRef || !this.dropdownRef.current) return;
|
|
||||||
const el = this.dropdownRef.current;
|
|
||||||
|
|
||||||
const sticksOutPastBottomEdge =
|
|
||||||
el.clientHeight + this.top > window.innerHeight;
|
|
||||||
if (sticksOutPastBottomEdge) {
|
|
||||||
this.top = undefined;
|
|
||||||
this.bottom = this.fixed ? 0 : -1 * window.pageYOffset;
|
|
||||||
} else {
|
|
||||||
this.bottom = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.position === "left" || this.position === "right") {
|
|
||||||
const totalWidth =
|
|
||||||
Math.sign(this.position === "left" ? -1 : 1) * el.offsetLeft +
|
|
||||||
el.scrollWidth;
|
|
||||||
const isVisible = totalWidth < window.innerWidth;
|
|
||||||
|
|
||||||
if (!isVisible) {
|
|
||||||
if (this.position === "right") {
|
|
||||||
this.position = "left";
|
|
||||||
this.left = undefined;
|
|
||||||
} else if (this.position === "left") {
|
|
||||||
this.position = "right";
|
|
||||||
this.right = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.initPosition();
|
|
||||||
this.forceUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
closeAfterTimeout = (closePortal: () => void) => () => {
|
|
||||||
if (this.closeTimeout) {
|
|
||||||
clearTimeout(this.closeTimeout);
|
|
||||||
}
|
|
||||||
this.closeTimeout = setTimeout(closePortal, 500);
|
|
||||||
};
|
|
||||||
|
|
||||||
clearCloseTimeout = () => {
|
|
||||||
if (this.closeTimeout) {
|
|
||||||
clearTimeout(this.closeTimeout);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { className, hover, label, children, t } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<PortalWithState
|
|
||||||
onOpen={this.onOpen}
|
|
||||||
onClose={this.props.onClose}
|
|
||||||
closeOnOutsideClick
|
|
||||||
closeOnEsc
|
|
||||||
>
|
|
||||||
{({ closePortal, openPortal, isOpen, portal }) => (
|
|
||||||
<>
|
|
||||||
<Label
|
|
||||||
onMouseMove={hover ? this.clearCloseTimeout : undefined}
|
|
||||||
onMouseOut={
|
|
||||||
hover ? this.closeAfterTimeout(closePortal) : undefined
|
|
||||||
}
|
|
||||||
onMouseEnter={
|
|
||||||
hover ? this.handleOpen(openPortal, closePortal) : undefined
|
|
||||||
}
|
|
||||||
onClick={
|
|
||||||
hover ? undefined : this.handleOpen(openPortal, closePortal)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{label || (
|
|
||||||
<NudeButton
|
|
||||||
id={`${this.id}button`}
|
|
||||||
aria-label={t("More options")}
|
|
||||||
aria-haspopup="true"
|
|
||||||
aria-expanded={isOpen ? "true" : "false"}
|
|
||||||
aria-controls={this.id}
|
|
||||||
>
|
|
||||||
<MoreIcon />
|
|
||||||
</NudeButton>
|
|
||||||
)}
|
|
||||||
</Label>
|
|
||||||
{portal(
|
|
||||||
<Position
|
|
||||||
ref={this.dropdownRef}
|
|
||||||
position={this.position}
|
|
||||||
fixed={this.fixed}
|
|
||||||
top={this.top}
|
|
||||||
bottom={this.bottom}
|
|
||||||
left={this.left}
|
|
||||||
right={this.right}
|
|
||||||
>
|
|
||||||
<Menu
|
|
||||||
ref={this.menuRef}
|
|
||||||
onMouseMove={hover ? this.clearCloseTimeout : undefined}
|
|
||||||
onMouseOut={
|
|
||||||
hover ? this.closeAfterTimeout(closePortal) : undefined
|
|
||||||
}
|
|
||||||
onClick={
|
|
||||||
typeof children === "function"
|
|
||||||
? undefined
|
|
||||||
: (ev) => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
closePortal();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
style={this.props.style}
|
|
||||||
id={this.id}
|
|
||||||
aria-labelledby={`${this.id}button`}
|
|
||||||
role="menu"
|
|
||||||
>
|
|
||||||
{typeof children === "function"
|
|
||||||
? children({ closePortal })
|
|
||||||
: children}
|
|
||||||
</Menu>
|
|
||||||
</Position>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</PortalWithState>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const Label = styled(Flex).attrs({
|
|
||||||
justify: "center",
|
|
||||||
align: "center",
|
|
||||||
})`
|
|
||||||
z-index: ${(props) => props.theme.depths.menu};
|
|
||||||
cursor: pointer;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Position = styled.div`
|
|
||||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
|
||||||
display: flex;
|
|
||||||
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
|
|
||||||
${({ right }) => (right !== undefined ? `right: ${right}px` : "")};
|
|
||||||
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
|
|
||||||
${({ bottom }) => (bottom !== undefined ? `bottom: ${bottom}px` : "")};
|
|
||||||
max-height: 75%;
|
|
||||||
z-index: ${(props) => props.theme.depths.menu};
|
|
||||||
transform: ${(props) =>
|
|
||||||
props.position === "center" ? "translateX(-50%)" : "initial"};
|
|
||||||
pointer-events: none;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Menu = styled.div`
|
|
||||||
animation: ${fadeAndScaleIn} 200ms ease;
|
|
||||||
transform-origin: ${(props) => (props.left !== undefined ? "25%" : "75%")} 0;
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
background: ${(props) => rgba(props.theme.menuBackground, 0.8)};
|
|
||||||
border: ${(props) =>
|
|
||||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
|
||||||
border-radius: 2px;
|
|
||||||
padding: 0.5em 0;
|
|
||||||
min-width: 180px;
|
|
||||||
overflow: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
box-shadow: ${(props) => props.theme.menuShadow};
|
|
||||||
pointer-events: all;
|
|
||||||
|
|
||||||
hr {
|
|
||||||
margin: 0.5em 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const Header = styled.h3`
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: ${(props) => props.theme.sidebarText};
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
margin: 1em 12px 0.5em;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default withTranslation()<DropdownMenu>(DropdownMenu);
|
|
|
@ -1,3 +0,0 @@
|
||||||
// @flow
|
|
||||||
export { default as DropdownMenu, Header } from "./DropdownMenu";
|
|
||||||
export { default as DropdownMenuItem } from "./DropdownMenuItem";
|
|
|
@ -8,21 +8,39 @@ import UiStore from "stores/UiStore";
|
||||||
import ErrorBoundary from "components/ErrorBoundary";
|
import ErrorBoundary from "components/ErrorBoundary";
|
||||||
import Tooltip from "components/Tooltip";
|
import Tooltip from "components/Tooltip";
|
||||||
import embeds from "../embeds";
|
import embeds from "../embeds";
|
||||||
import isInternalUrl from "utils/isInternalUrl";
|
import { isModKey } from "utils/keyboard";
|
||||||
import { uploadFile } from "utils/uploadFile";
|
import { uploadFile } from "utils/uploadFile";
|
||||||
|
import { isInternalUrl } from "utils/urls";
|
||||||
|
|
||||||
const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));
|
const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));
|
||||||
|
|
||||||
const EMPTY_ARRAY = [];
|
const EMPTY_ARRAY = [];
|
||||||
|
|
||||||
type Props = {
|
export type Props = {|
|
||||||
id?: string,
|
id?: string,
|
||||||
|
value?: string,
|
||||||
defaultValue?: string,
|
defaultValue?: string,
|
||||||
readOnly?: boolean,
|
readOnly?: boolean,
|
||||||
grow?: boolean,
|
grow?: boolean,
|
||||||
disableEmbeds?: boolean,
|
disableEmbeds?: boolean,
|
||||||
ui?: UiStore,
|
ui?: UiStore,
|
||||||
};
|
autoFocus?: boolean,
|
||||||
|
template?: boolean,
|
||||||
|
placeholder?: string,
|
||||||
|
scrollTo?: string,
|
||||||
|
readOnlyWriteCheckboxes?: boolean,
|
||||||
|
onBlur?: (event: SyntheticEvent<>) => any,
|
||||||
|
onFocus?: (event: SyntheticEvent<>) => any,
|
||||||
|
onPublish?: (event: SyntheticEvent<>) => any,
|
||||||
|
onSave?: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
|
||||||
|
onCancel?: () => any,
|
||||||
|
onChange?: (getValue: () => string) => any,
|
||||||
|
onSearchLink?: (title: string) => any,
|
||||||
|
onHoverLink?: (event: MouseEvent) => any,
|
||||||
|
onCreateLink?: (title: string) => Promise<string>,
|
||||||
|
onImageUploadStart?: () => any,
|
||||||
|
onImageUploadStop?: () => any,
|
||||||
|
|};
|
||||||
|
|
||||||
type PropsWithRef = Props & {
|
type PropsWithRef = Props & {
|
||||||
forwardedRef: React.Ref<any>,
|
forwardedRef: React.Ref<any>,
|
||||||
|
@ -49,7 +67,7 @@ function Editor(props: PropsWithRef) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInternalUrl(href) && !event.metaKey && !event.shiftKey) {
|
if (isInternalUrl(href) && !isModKey(event) && !event.shiftKey) {
|
||||||
// relative
|
// relative
|
||||||
let navigateTo = href;
|
let navigateTo = href;
|
||||||
|
|
||||||
|
@ -171,17 +189,16 @@ const StyledEditor = styled(RichMarkdownEditor)`
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heading-name {
|
.heading-anchor {
|
||||||
pointer-events: none;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* pseudo element allows us to add spacing for fixed header */
|
.heading-name {
|
||||||
/* ref: https://stackoverflow.com/a/28824157 */
|
pointer-events: none;
|
||||||
.heading-name::before {
|
display: block;
|
||||||
content: "";
|
position: relative;
|
||||||
display: ${(props) => (props.readOnly ? "block" : "none")};
|
top: -60px;
|
||||||
height: 72px;
|
visibility: hidden;
|
||||||
margin: -72px 0 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.heading-name:first-child {
|
.heading-name:first-child {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
// @flow
|
// @flow
|
||||||
|
import * as Sentry from "@sentry/react";
|
||||||
import { observable } from "mobx";
|
import { observable } from "mobx";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
@ -36,8 +37,8 @@ class ErrorBoundary extends React.Component<Props> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.Sentry) {
|
if (env.SENTRY_DSN) {
|
||||||
window.Sentry.captureException(error);
|
Sentry.captureException(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,7 +57,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||||
render() {
|
render() {
|
||||||
if (this.error) {
|
if (this.error) {
|
||||||
const error = this.error;
|
const error = this.error;
|
||||||
const isReported = !!window.Sentry && env.DEPLOYMENT === "hosted";
|
const isReported = !!env.SENTRY_DSN && env.DEPLOYMENT === "hosted";
|
||||||
const isChunkError = this.error.message.match(/chunk/);
|
const isChunkError = this.error.message.match(/chunk/);
|
||||||
|
|
||||||
if (isChunkError) {
|
if (isChunkError) {
|
||||||
|
|
|
@ -4,13 +4,18 @@ import * as React from "react";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.Node,
|
children: React.Node,
|
||||||
|
className?: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function EventBoundary({ children }: Props) {
|
export default function EventBoundary({ children, className }: Props) {
|
||||||
const handleClick = React.useCallback((event: SyntheticEvent<>) => {
|
const handleClick = React.useCallback((event: SyntheticEvent<>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <span onClick={handleClick}>{children}</span>;
|
return (
|
||||||
|
<span onClick={handleClick} className={className}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,8 +7,11 @@ const Heading = styled.h1`
|
||||||
${(props) => (props.centered ? "text-align: center;" : "")}
|
${(props) => (props.centered ? "text-align: center;" : "")}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
|
margin-top: 4px;
|
||||||
margin-left: -6px;
|
margin-left: -6px;
|
||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
|
align-self: flex-start;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { fadeAndSlideIn } from "shared/styles/animations";
|
||||||
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
|
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
|
||||||
import DocumentsStore from "stores/DocumentsStore";
|
import DocumentsStore from "stores/DocumentsStore";
|
||||||
import HoverPreviewDocument from "components/HoverPreviewDocument";
|
import HoverPreviewDocument from "components/HoverPreviewDocument";
|
||||||
import isInternalUrl from "utils/isInternalUrl";
|
import { isInternalUrl } from "utils/urls";
|
||||||
|
|
||||||
const DELAY_OPEN = 300;
|
const DELAY_OPEN = 300;
|
||||||
const DELAY_CLOSE = 300;
|
const DELAY_CLOSE = 300;
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { observable } from "mobx";
|
|
||||||
import { observer } from "mobx-react";
|
|
||||||
import {
|
import {
|
||||||
CollectionIcon,
|
CollectionIcon,
|
||||||
CoinsIcon,
|
CoinsIcon,
|
||||||
|
@ -22,14 +20,17 @@ import {
|
||||||
VehicleIcon,
|
VehicleIcon,
|
||||||
} from "outline-icons";
|
} from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { withTranslation, type TFunction } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { DropdownMenu } from "components/DropdownMenu";
|
import ContextMenu from "components/ContextMenu";
|
||||||
import Flex from "components/Flex";
|
import Flex from "components/Flex";
|
||||||
import HelpText from "components/HelpText";
|
import HelpText from "components/HelpText";
|
||||||
import { LabelText } from "components/Input";
|
import { LabelText } from "components/Input";
|
||||||
import NudeButton from "components/NudeButton";
|
import NudeButton from "components/NudeButton";
|
||||||
|
|
||||||
|
const style = { width: 30, height: 30 };
|
||||||
|
|
||||||
const TwitterPicker = React.lazy(() =>
|
const TwitterPicker = React.lazy(() =>
|
||||||
import("react-color/lib/components/twitter/Twitter")
|
import("react-color/lib/components/twitter/Twitter")
|
||||||
);
|
);
|
||||||
|
@ -122,107 +123,77 @@ const colors = [
|
||||||
"#2F362F",
|
"#2F362F",
|
||||||
];
|
];
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
onOpen?: () => void,
|
onOpen?: () => void,
|
||||||
onChange: (color: string, icon: string) => void,
|
onChange: (color: string, icon: string) => void,
|
||||||
icon: string,
|
icon: string,
|
||||||
color: string,
|
color: string,
|
||||||
t: TFunction,
|
|};
|
||||||
};
|
|
||||||
|
|
||||||
function preventEventBubble(event) {
|
function IconPicker({ onOpen, icon, color, onChange }: Props) {
|
||||||
event.stopPropagation();
|
const { t } = useTranslation();
|
||||||
|
const menu = useMenuState({
|
||||||
|
modal: true,
|
||||||
|
placement: "bottom-end",
|
||||||
|
});
|
||||||
|
const Component = icons[icon || "collection"].component;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
<Label>
|
||||||
|
<LabelText>{t("Icon")}</LabelText>
|
||||||
|
</Label>
|
||||||
|
<MenuButton {...menu}>
|
||||||
|
{(props) => (
|
||||||
|
<Button aria-label={t("Show menu")} {...props}>
|
||||||
|
<Component color={color} size={30} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</MenuButton>
|
||||||
|
<ContextMenu {...menu} onOpen={onOpen} aria-label={t("Choose icon")}>
|
||||||
|
<Icons>
|
||||||
|
{Object.keys(icons).map((name) => {
|
||||||
|
const Component = icons[name].component;
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={name}
|
||||||
|
onClick={() => onChange(color, name)}
|
||||||
|
{...menu}
|
||||||
|
>
|
||||||
|
{(props) => (
|
||||||
|
<IconButton style={style} {...props}>
|
||||||
|
<Component color={color} size={30} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Icons>
|
||||||
|
<Flex>
|
||||||
|
<React.Suspense fallback={<Loading>{t("Loading")}…</Loading>}>
|
||||||
|
<ColorPicker
|
||||||
|
color={color}
|
||||||
|
onChange={(color) => onChange(color.hex, icon)}
|
||||||
|
colors={colors}
|
||||||
|
triangle="hide"
|
||||||
|
/>
|
||||||
|
</React.Suspense>
|
||||||
|
</Flex>
|
||||||
|
</ContextMenu>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
const Label = styled.label`
|
||||||
class IconPicker extends React.Component<Props> {
|
display: block;
|
||||||
@observable isOpen: boolean = false;
|
`;
|
||||||
node: ?HTMLElement;
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
window.addEventListener("click", this.handleClickOutside);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
window.removeEventListener("click", this.handleClickOutside);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClose = () => {
|
|
||||||
this.isOpen = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
handleOpen = () => {
|
|
||||||
this.isOpen = true;
|
|
||||||
|
|
||||||
if (this.props.onOpen) {
|
|
||||||
this.props.onOpen();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleClickOutside = (ev: SyntheticMouseEvent<>) => {
|
|
||||||
// $FlowFixMe
|
|
||||||
if (ev.target && this.node && this.node.contains(ev.target)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.handleClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { t } = this.props;
|
|
||||||
const Component = icons[this.props.icon || "collection"].component;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Wrapper ref={(ref) => (this.node = ref)}>
|
|
||||||
<label>
|
|
||||||
<LabelText>{t("Icon")}</LabelText>
|
|
||||||
</label>
|
|
||||||
<DropdownMenu
|
|
||||||
onOpen={this.handleOpen}
|
|
||||||
label={
|
|
||||||
<LabelButton>
|
|
||||||
<Component role="button" color={this.props.color} size={30} />
|
|
||||||
</LabelButton>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icons onClick={preventEventBubble}>
|
|
||||||
{Object.keys(icons).map((name) => {
|
|
||||||
const Component = icons[name].component;
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
key={name}
|
|
||||||
onClick={() => this.props.onChange(this.props.color, name)}
|
|
||||||
style={{ width: 30, height: 30 }}
|
|
||||||
>
|
|
||||||
<Component color={this.props.color} size={30} />
|
|
||||||
</IconButton>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Icons>
|
|
||||||
<Flex onClick={preventEventBubble}>
|
|
||||||
<React.Suspense fallback={<Loading>{t("Loading")}…</Loading>}>
|
|
||||||
<ColorPicker
|
|
||||||
color={this.props.color}
|
|
||||||
onChange={(color) =>
|
|
||||||
this.props.onChange(color.hex, this.props.icon)
|
|
||||||
}
|
|
||||||
colors={colors}
|
|
||||||
triangle="hide"
|
|
||||||
/>
|
|
||||||
</React.Suspense>
|
|
||||||
</Flex>
|
|
||||||
</DropdownMenu>
|
|
||||||
</Wrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const Icons = styled.div`
|
const Icons = styled.div`
|
||||||
padding: 15px 9px 9px 15px;
|
padding: 15px 9px 9px 15px;
|
||||||
width: 276px;
|
width: 276px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const LabelButton = styled(NudeButton)`
|
const Button = styled(NudeButton)`
|
||||||
border: 1px solid ${(props) => props.theme.inputBorder};
|
border: 1px solid ${(props) => props.theme.inputBorder};
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
@ -249,4 +220,4 @@ const Wrapper = styled("div")`
|
||||||
position: relative;
|
position: relative;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default withTranslation()<IconPicker>(IconPicker);
|
export default IconPicker;
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
// @flow
|
||||||
|
import * as React from "react";
|
||||||
|
import { cdnPath } from "utils/urls";
|
||||||
|
|
||||||
|
type Props = {|
|
||||||
|
alt: string,
|
||||||
|
src: string,
|
||||||
|
title?: string,
|
||||||
|
width?: number,
|
||||||
|
height?: number,
|
||||||
|
|};
|
||||||
|
|
||||||
|
export default function Image({ src, alt, ...rest }: Props) {
|
||||||
|
return <img src={cdnPath(src)} alt={alt} {...rest} />;
|
||||||
|
}
|
|
@ -2,9 +2,9 @@
|
||||||
import { observable } from "mobx";
|
import { observable } from "mobx";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import Flex from "components/Flex";
|
import Flex from "components/Flex";
|
||||||
import VisuallyHidden from "components/VisuallyHidden";
|
|
||||||
|
|
||||||
const RealTextarea = styled.textarea`
|
const RealTextarea = styled.textarea`
|
||||||
border: 0;
|
border: 0;
|
||||||
|
@ -75,8 +75,8 @@ export const LabelText = styled.div`
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {|
|
||||||
type?: string,
|
type?: "text" | "email" | "checkbox" | "search",
|
||||||
value?: string,
|
value?: string,
|
||||||
label?: string,
|
label?: string,
|
||||||
className?: string,
|
className?: string,
|
||||||
|
@ -85,9 +85,18 @@ export type Props = {
|
||||||
short?: boolean,
|
short?: boolean,
|
||||||
margin?: string | number,
|
margin?: string | number,
|
||||||
icon?: React.Node,
|
icon?: React.Node,
|
||||||
|
name?: string,
|
||||||
|
minLength?: number,
|
||||||
|
maxLength?: number,
|
||||||
|
autoFocus?: boolean,
|
||||||
|
autoComplete?: boolean | string,
|
||||||
|
readOnly?: boolean,
|
||||||
|
required?: boolean,
|
||||||
|
placeholder?: string,
|
||||||
|
onChange?: (ev: SyntheticInputEvent<HTMLInputElement>) => mixed,
|
||||||
onFocus?: (ev: SyntheticEvent<>) => void,
|
onFocus?: (ev: SyntheticEvent<>) => void,
|
||||||
onBlur?: (ev: SyntheticEvent<>) => void,
|
onBlur?: (ev: SyntheticEvent<>) => void,
|
||||||
};
|
|};
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class Input extends React.Component<Props> {
|
class Input extends React.Component<Props> {
|
||||||
|
|
|
@ -8,13 +8,13 @@ import Editor from "components/Editor";
|
||||||
import HelpText from "components/HelpText";
|
import HelpText from "components/HelpText";
|
||||||
import { LabelText, Outline } from "components/Input";
|
import { LabelText, Outline } from "components/Input";
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
label: string,
|
label: string,
|
||||||
minHeight?: number,
|
minHeight?: number,
|
||||||
maxHeight?: number,
|
maxHeight?: number,
|
||||||
readOnly?: boolean,
|
readOnly?: boolean,
|
||||||
ui: UiStore,
|
ui: UiStore,
|
||||||
};
|
|};
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class InputRich extends React.Component<Props> {
|
class InputRich extends React.Component<Props> {
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { withRouter, type RouterHistory } from "react-router-dom";
|
||||||
import styled, { withTheme } from "styled-components";
|
import styled, { withTheme } from "styled-components";
|
||||||
import Input from "./Input";
|
import Input from "./Input";
|
||||||
import { type Theme } from "types";
|
import { type Theme } from "types";
|
||||||
|
import { meta } from "utils/keyboard";
|
||||||
import { searchUrl } from "utils/routeHelpers";
|
import { searchUrl } from "utils/routeHelpers";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -16,6 +17,8 @@ type Props = {
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
source: string,
|
source: string,
|
||||||
placeholder?: string,
|
placeholder?: string,
|
||||||
|
label?: string,
|
||||||
|
labelHidden?: boolean,
|
||||||
collectionId?: string,
|
collectionId?: string,
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
};
|
};
|
||||||
|
@ -25,7 +28,7 @@ class InputSearch extends React.Component<Props> {
|
||||||
input: ?Input;
|
input: ?Input;
|
||||||
@observable focused: boolean = false;
|
@observable focused: boolean = false;
|
||||||
|
|
||||||
@keydown("meta+f")
|
@keydown(`${meta}+f`)
|
||||||
focus(ev: SyntheticEvent<>) {
|
focus(ev: SyntheticEvent<>) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
|
||||||
|
@ -67,6 +70,8 @@ class InputSearch extends React.Component<Props> {
|
||||||
color={this.focused ? theme.inputBorderFocused : theme.inputBorder}
|
color={this.focused ? theme.inputBorderFocused : theme.inputBorder}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
label={this.props.label}
|
||||||
|
labelHidden={this.props.labelHidden}
|
||||||
onFocus={this.handleFocus}
|
onFocus={this.handleFocus}
|
||||||
onBlur={this.handleBlur}
|
onBlur={this.handleBlur}
|
||||||
margin={0}
|
margin={0}
|
||||||
|
|
|
@ -2,17 +2,19 @@
|
||||||
import { observable } from "mobx";
|
import { observable } from "mobx";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import VisuallyHidden from "components/VisuallyHidden";
|
|
||||||
import { Outline, LabelText } from "./Input";
|
import { Outline, LabelText } from "./Input";
|
||||||
|
|
||||||
const Select = styled.select`
|
const Select = styled.select`
|
||||||
border: 0;
|
border: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 8px 12px;
|
padding: 4px 0;
|
||||||
|
margin: 0 12px;
|
||||||
outline: none;
|
outline: none;
|
||||||
background: none;
|
background: none;
|
||||||
color: ${(props) => props.theme.text};
|
color: ${(props) => props.theme.text};
|
||||||
|
height: 30px;
|
||||||
|
|
||||||
&:disabled,
|
&:disabled,
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
|
@ -34,6 +36,8 @@ export type Props = {
|
||||||
className?: string,
|
className?: string,
|
||||||
labelHidden?: boolean,
|
labelHidden?: boolean,
|
||||||
options: Option[],
|
options: Option[],
|
||||||
|
onBlur?: () => void,
|
||||||
|
onFocus?: () => void,
|
||||||
};
|
};
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
|
|
|
@ -4,10 +4,10 @@ import * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import Flex from "components/Flex";
|
import Flex from "components/Flex";
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
label: React.Node | string,
|
label: React.Node | string,
|
||||||
children: React.Node,
|
children: React.Node,
|
||||||
};
|
|};
|
||||||
|
|
||||||
const Labeled = ({ label, children, ...props }: Props) => (
|
const Labeled = ({ label, children, ...props }: Props) => (
|
||||||
<Flex column {...props}>
|
<Flex column {...props}>
|
||||||
|
|
|
@ -4,6 +4,7 @@ import * as React from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { languages, languageOptions } from "shared/i18n";
|
import { languages, languageOptions } from "shared/i18n";
|
||||||
|
import ButtonLink from "components/ButtonLink";
|
||||||
import Flex from "components/Flex";
|
import Flex from "components/Flex";
|
||||||
import NoticeTip from "components/NoticeTip";
|
import NoticeTip from "components/NoticeTip";
|
||||||
import useCurrentUser from "hooks/useCurrentUser";
|
import useCurrentUser from "hooks/useCurrentUser";
|
||||||
|
@ -68,7 +69,7 @@ export default function LanguagePrompt() {
|
||||||
like to change?
|
like to change?
|
||||||
</Trans>
|
</Trans>
|
||||||
<br />
|
<br />
|
||||||
<a
|
<Link
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
auth.updateUser({
|
auth.updateUser({
|
||||||
language,
|
language,
|
||||||
|
@ -77,14 +78,24 @@ export default function LanguagePrompt() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("Change Language")}
|
{t("Change Language")}
|
||||||
</a>{" "}
|
</Link>{" "}
|
||||||
· <a onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</a>
|
·{" "}
|
||||||
|
<Link onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</Link>
|
||||||
</span>
|
</span>
|
||||||
</Flex>
|
</Flex>
|
||||||
</NoticeTip>
|
</NoticeTip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Link = styled(ButtonLink)`
|
||||||
|
color: ${(props) => props.theme.almostBlack};
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const LanguageIcon = styled(Icon)`
|
const LanguageIcon = styled(Icon)`
|
||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { observable } from "mobx";
|
import { observable } from "mobx";
|
||||||
import { observer, inject } from "mobx-react";
|
import { observer, inject } from "mobx-react";
|
||||||
|
import { MenuIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import { withTranslation, type TFunction } from "react-i18next";
|
import { withTranslation, type TFunction } from "react-i18next";
|
||||||
|
@ -14,14 +15,17 @@ import UiStore from "stores/UiStore";
|
||||||
import ErrorSuspended from "scenes/ErrorSuspended";
|
import ErrorSuspended from "scenes/ErrorSuspended";
|
||||||
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
|
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
|
||||||
import Analytics from "components/Analytics";
|
import Analytics from "components/Analytics";
|
||||||
|
import Button from "components/Button";
|
||||||
import DocumentHistory from "components/DocumentHistory";
|
import DocumentHistory from "components/DocumentHistory";
|
||||||
import Flex from "components/Flex";
|
import Flex from "components/Flex";
|
||||||
|
|
||||||
import { LoadingIndicatorBar } from "components/LoadingIndicator";
|
import { LoadingIndicatorBar } from "components/LoadingIndicator";
|
||||||
import Modal from "components/Modal";
|
import Modal from "components/Modal";
|
||||||
import Sidebar from "components/Sidebar";
|
import Sidebar from "components/Sidebar";
|
||||||
import SettingsSidebar from "components/Sidebar/Settings";
|
import SettingsSidebar from "components/Sidebar/Settings";
|
||||||
|
import SkipNavContent from "components/SkipNavContent";
|
||||||
|
import SkipNavLink from "components/SkipNavLink";
|
||||||
import { type Theme } from "types";
|
import { type Theme } from "types";
|
||||||
|
import { meta } from "utils/keyboard";
|
||||||
import {
|
import {
|
||||||
homeUrl,
|
homeUrl,
|
||||||
searchUrl,
|
searchUrl,
|
||||||
|
@ -65,7 +69,7 @@ class Layout extends React.Component<Props> {
|
||||||
window.document.body.style.background = props.theme.background;
|
window.document.body.style.background = props.theme.background;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keydown("meta+.")
|
@keydown(`${meta}+.`)
|
||||||
handleToggleSidebar() {
|
handleToggleSidebar() {
|
||||||
this.props.ui.toggleCollapsedSidebar();
|
this.props.ui.toggleCollapsedSidebar();
|
||||||
}
|
}
|
||||||
|
@ -80,7 +84,7 @@ class Layout extends React.Component<Props> {
|
||||||
this.keyboardShortcutsOpen = false;
|
this.keyboardShortcutsOpen = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@keydown(["t", "/", "meta+k"])
|
@keydown(["t", "/", `${meta}+k`])
|
||||||
goToSearch(ev: SyntheticEvent<>) {
|
goToSearch(ev: SyntheticEvent<>) {
|
||||||
if (this.props.ui.editMode) return;
|
if (this.props.ui.editMode) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
@ -98,6 +102,7 @@ class Layout extends React.Component<Props> {
|
||||||
const { auth, t, ui } = this.props;
|
const { auth, t, ui } = this.props;
|
||||||
const { user, team } = auth;
|
const { user, team } = auth;
|
||||||
const showSidebar = auth.authenticated && user && team;
|
const showSidebar = auth.authenticated && user && team;
|
||||||
|
const sidebarCollapsed = ui.editMode || ui.sidebarCollapsed;
|
||||||
|
|
||||||
if (auth.isSuspended) return <ErrorSuspended />;
|
if (auth.isSuspended) return <ErrorSuspended />;
|
||||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||||
|
@ -111,11 +116,19 @@ class Layout extends React.Component<Props> {
|
||||||
content="width=device-width, initial-scale=1.0"
|
content="width=device-width, initial-scale=1.0"
|
||||||
/>
|
/>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
<SkipNavLink />
|
||||||
<Analytics />
|
<Analytics />
|
||||||
|
|
||||||
{this.props.ui.progressBarVisible && <LoadingIndicatorBar />}
|
{this.props.ui.progressBarVisible && <LoadingIndicatorBar />}
|
||||||
{this.props.notifications}
|
{this.props.notifications}
|
||||||
|
|
||||||
|
<MobileMenuButton
|
||||||
|
onClick={ui.toggleMobileSidebar}
|
||||||
|
icon={<MenuIcon />}
|
||||||
|
iconColor="currentColor"
|
||||||
|
neutral
|
||||||
|
/>
|
||||||
|
|
||||||
<Container auto>
|
<Container auto>
|
||||||
{showSidebar && (
|
{showSidebar && (
|
||||||
<Switch>
|
<Switch>
|
||||||
|
@ -124,10 +137,17 @@ class Layout extends React.Component<Props> {
|
||||||
</Switch>
|
</Switch>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SkipNavContent />
|
||||||
<Content
|
<Content
|
||||||
auto
|
auto
|
||||||
justify="center"
|
justify="center"
|
||||||
sidebarCollapsed={ui.editMode || ui.sidebarCollapsed}
|
$isResizing={ui.sidebarIsResizing}
|
||||||
|
$sidebarCollapsed={sidebarCollapsed}
|
||||||
|
style={
|
||||||
|
sidebarCollapsed
|
||||||
|
? undefined
|
||||||
|
: { marginLeft: `${ui.sidebarWidth}px` }
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{this.props.children}
|
{this.props.children}
|
||||||
</Content>
|
</Content>
|
||||||
|
@ -159,19 +179,38 @@ const Container = styled(Flex)`
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const MobileMenuButton = styled(Button)`
|
||||||
|
position: fixed;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
z-index: ${(props) => props.theme.depths.sidebar - 1};
|
||||||
|
|
||||||
|
${breakpoint("tablet")`
|
||||||
|
display: none;
|
||||||
|
`};
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const Content = styled(Flex)`
|
const Content = styled(Flex)`
|
||||||
margin: 0;
|
margin: 0;
|
||||||
transition: margin-left 100ms ease-out;
|
transition: ${(props) =>
|
||||||
|
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
${breakpoint("mobile", "tablet")`
|
||||||
|
margin-left: 0 !important;
|
||||||
|
`}
|
||||||
|
|
||||||
${breakpoint("tablet")`
|
${breakpoint("tablet")`
|
||||||
margin-left: ${(props) =>
|
${(props) =>
|
||||||
props.sidebarCollapsed
|
props.$sidebarCollapsed &&
|
||||||
? props.theme.sidebarCollapsedWidth
|
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
|
||||||
: props.theme.sidebarWidth};
|
|
||||||
`};
|
`};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,89 @@
|
||||||
|
// @flow
|
||||||
|
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||||
|
import format from "date-fns/format";
|
||||||
|
import * as React from "react";
|
||||||
|
import Tooltip from "components/Tooltip";
|
||||||
|
import useUserLocale from "hooks/useUserLocale";
|
||||||
|
|
||||||
|
const locales = {
|
||||||
|
en: require(`date-fns/locale/en`),
|
||||||
|
de: require(`date-fns/locale/de`),
|
||||||
|
es: require(`date-fns/locale/es`),
|
||||||
|
fr: require(`date-fns/locale/fr`),
|
||||||
|
it: require(`date-fns/locale/it`),
|
||||||
|
ko: require(`date-fns/locale/ko`),
|
||||||
|
pt: require(`date-fns/locale/pt`),
|
||||||
|
zh: require(`date-fns/locale/zh_cn`),
|
||||||
|
};
|
||||||
|
|
||||||
|
let callbacks = [];
|
||||||
|
|
||||||
|
// This is a shared timer that fires every minute, used for
|
||||||
|
// updating all Time components across the page all at once.
|
||||||
|
setInterval(() => {
|
||||||
|
callbacks.forEach((cb) => cb());
|
||||||
|
}, 1000 * 60);
|
||||||
|
|
||||||
|
function eachMinute(fn) {
|
||||||
|
callbacks.push(fn);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
callbacks = callbacks.filter((cb) => cb !== fn);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
dateTime: string,
|
||||||
|
children?: React.Node,
|
||||||
|
tooltipDelay?: number,
|
||||||
|
addSuffix?: boolean,
|
||||||
|
shorten?: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
function LocaleTime({
|
||||||
|
addSuffix,
|
||||||
|
children,
|
||||||
|
dateTime,
|
||||||
|
shorten,
|
||||||
|
tooltipDelay,
|
||||||
|
}: Props) {
|
||||||
|
const userLocale = useUserLocale();
|
||||||
|
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line no-unused-vars
|
||||||
|
const callback = React.useRef();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
callback.current = eachMinute(() => {
|
||||||
|
setMinutesMounted((state) => ++state);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (callback.current) {
|
||||||
|
callback.current();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
let content = distanceInWordsToNow(dateTime, {
|
||||||
|
addSuffix,
|
||||||
|
locale: userLocale ? locales[userLocale] : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shorten) {
|
||||||
|
content = content
|
||||||
|
.replace("about", "")
|
||||||
|
.replace("less than a minute ago", "just now")
|
||||||
|
.replace("minute", "min");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
tooltip={format(dateTime, "MMMM Do, YYYY h:mm a")}
|
||||||
|
delay={tooltipDelay}
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
|
<time dateTime={dateTime}>{children || content}</time>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LocaleTime;
|
|
@ -5,10 +5,10 @@ import { randomInteger } from "shared/random";
|
||||||
import { pulsate } from "shared/styles/animations";
|
import { pulsate } from "shared/styles/animations";
|
||||||
import Flex from "components/Flex";
|
import Flex from "components/Flex";
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
header?: boolean,
|
header?: boolean,
|
||||||
height?: number,
|
height?: number,
|
||||||
};
|
|};
|
||||||
|
|
||||||
class Mask extends React.Component<Props> {
|
class Mask extends React.Component<Props> {
|
||||||
width: number;
|
width: number;
|
||||||
|
@ -23,7 +23,7 @@ class Mask extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return <Redacted width={this.width} {...this.props} />;
|
return <Redacted width={this.width} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,12 +13,12 @@ import Scrollable from "components/Scrollable";
|
||||||
|
|
||||||
ReactModal.setAppElement("#root");
|
ReactModal.setAppElement("#root");
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
children?: React.Node,
|
children?: React.Node,
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
title?: string,
|
title?: string,
|
||||||
onRequestClose: () => void,
|
onRequestClose: () => void,
|
||||||
};
|
|};
|
||||||
|
|
||||||
const GlobalStyles = createGlobalStyle`
|
const GlobalStyles = createGlobalStyle`
|
||||||
.ReactModal__Overlay {
|
.ReactModal__Overlay {
|
||||||
|
|
|
@ -3,15 +3,17 @@ import * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
|
||||||
const Button = styled.button`
|
const Button = styled.button`
|
||||||
width: 24px;
|
width: ${(props) => props.size}px;
|
||||||
height: 24px;
|
height: ${(props) => props.size}px;
|
||||||
background: none;
|
background: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default React.forwardRef<any, typeof Button>((props, ref) => (
|
export default React.forwardRef<any, typeof Button>(
|
||||||
<Button {...props} ref={ref} />
|
({ size = 24, ...props }, ref) => <Button size={size} {...props} ref={ref} />
|
||||||
));
|
);
|
||||||
|
|
|
@ -1,16 +1,17 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { observer, inject } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import AuthStore from "stores/AuthStore";
|
import useStores from "hooks/useStores";
|
||||||
|
import { cdnPath } from "utils/urls";
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
title: string,
|
title: string,
|
||||||
favicon?: string,
|
favicon?: string,
|
||||||
auth: AuthStore,
|
|};
|
||||||
};
|
|
||||||
|
|
||||||
const PageTitle = observer(({ auth, title, favicon }: Props) => {
|
const PageTitle = ({ title, favicon }: Props) => {
|
||||||
|
const { auth } = useStores();
|
||||||
const { team } = auth;
|
const { team } = auth;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -21,12 +22,12 @@ const PageTitle = observer(({ auth, title, favicon }: Props) => {
|
||||||
<link
|
<link
|
||||||
rel="shortcut icon"
|
rel="shortcut icon"
|
||||||
type="image/png"
|
type="image/png"
|
||||||
href={favicon || "/favicon-32.png"}
|
href={favicon || cdnPath("/favicon-32.png")}
|
||||||
sizes="32x32"
|
sizes="32x32"
|
||||||
/>
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
</Helmet>
|
</Helmet>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
export default inject("auth")(PageTitle);
|
export default observer(PageTitle);
|
||||||
|
|
|
@ -2,16 +2,22 @@
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Document from "models/Document";
|
import Document from "models/Document";
|
||||||
import DocumentPreview from "components/DocumentPreview";
|
import DocumentListItem from "components/DocumentListItem";
|
||||||
import PaginatedList from "components/PaginatedList";
|
import PaginatedList from "components/PaginatedList";
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
documents: Document[],
|
documents: Document[],
|
||||||
fetch: (options: ?Object) => Promise<void>,
|
fetch: (options: ?Object) => Promise<void>,
|
||||||
options?: Object,
|
options?: Object,
|
||||||
heading?: React.Node,
|
heading?: React.Node,
|
||||||
empty?: React.Node,
|
empty?: React.Node,
|
||||||
};
|
showNestedDocuments?: boolean,
|
||||||
|
showCollection?: boolean,
|
||||||
|
showPublished?: boolean,
|
||||||
|
showPin?: boolean,
|
||||||
|
showDraft?: boolean,
|
||||||
|
showTemplate?: boolean,
|
||||||
|
|};
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class PaginatedDocumentList extends React.Component<Props> {
|
class PaginatedDocumentList extends React.Component<Props> {
|
||||||
|
@ -26,7 +32,7 @@ class PaginatedDocumentList extends React.Component<Props> {
|
||||||
fetch={fetch}
|
fetch={fetch}
|
||||||
options={options}
|
options={options}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<DocumentPreview key={item.id} document={item} {...rest} />
|
<DocumentListItem key={item.id} document={item} {...rest} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,12 @@
|
||||||
|
// @flow
|
||||||
|
import * as Sentry from "@sentry/react";
|
||||||
|
import { Route } from "react-router-dom";
|
||||||
|
import env from "env";
|
||||||
|
|
||||||
|
let Component = Route;
|
||||||
|
|
||||||
|
if (env.SENTRY_DSN) {
|
||||||
|
Component = Sentry.withSentryRouting(Route);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Component;
|
|
@ -1,28 +1,52 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { observable } from "mobx";
|
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
import useWindowSize from "hooks/useWindowSize";
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
shadow?: boolean,
|
shadow?: boolean,
|
||||||
};
|
topShadow?: boolean,
|
||||||
|
bottomShadow?: boolean,
|
||||||
|
|};
|
||||||
|
|
||||||
@observer
|
function Scrollable({ shadow, topShadow, bottomShadow, ...rest }: Props) {
|
||||||
class Scrollable extends React.Component<Props> {
|
const ref = React.useRef<?HTMLDivElement>();
|
||||||
@observable shadow: boolean = false;
|
const [topShadowVisible, setTopShadow] = React.useState(false);
|
||||||
|
const [bottomShadowVisible, setBottomShadow] = React.useState(false);
|
||||||
|
const { height } = useWindowSize();
|
||||||
|
|
||||||
handleScroll = (ev: SyntheticMouseEvent<HTMLDivElement>) => {
|
const updateShadows = React.useCallback(() => {
|
||||||
this.shadow = !!(this.props.shadow && ev.currentTarget.scrollTop > 0);
|
const c = ref.current;
|
||||||
};
|
if (!c) return;
|
||||||
|
|
||||||
render() {
|
const scrollTop = c.scrollTop;
|
||||||
const { shadow, ...rest } = this.props;
|
const tsv = !!((shadow || topShadow) && scrollTop > 0);
|
||||||
|
if (tsv !== topShadowVisible) {
|
||||||
|
setTopShadow(tsv);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
const wrapperHeight = c.scrollHeight - c.clientHeight;
|
||||||
<Wrapper onScroll={this.handleScroll} shadow={this.shadow} {...rest} />
|
const bsv = !!((shadow || bottomShadow) && wrapperHeight - scrollTop !== 0);
|
||||||
);
|
|
||||||
}
|
if (bsv !== bottomShadowVisible) {
|
||||||
|
setBottomShadow(bsv);
|
||||||
|
}
|
||||||
|
}, [shadow, topShadow, bottomShadow, topShadowVisible, bottomShadowVisible]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
updateShadows();
|
||||||
|
}, [height, updateShadows]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper
|
||||||
|
ref={ref}
|
||||||
|
onScroll={updateShadows}
|
||||||
|
$topShadowVisible={topShadowVisible}
|
||||||
|
$bottomShadowVisible={bottomShadowVisible}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Wrapper = styled.div`
|
const Wrapper = styled.div`
|
||||||
|
@ -31,9 +55,20 @@ const Wrapper = styled.div`
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
box-shadow: ${(props) =>
|
box-shadow: ${(props) => {
|
||||||
props.shadow ? "0 1px inset rgba(0,0,0,.1)" : "none"};
|
if (props.$topShadowVisible && props.$bottomShadowVisible) {
|
||||||
transition: all 250ms ease-in-out;
|
return "0 1px inset rgba(0,0,0,.1), 0 -1px inset rgba(0,0,0,.1)";
|
||||||
|
}
|
||||||
|
if (props.$topShadowVisible) {
|
||||||
|
return "0 1px inset rgba(0,0,0,.1)";
|
||||||
|
}
|
||||||
|
if (props.$bottomShadowVisible) {
|
||||||
|
return "0 -1px inset rgba(0,0,0,.1)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "none";
|
||||||
|
}};
|
||||||
|
transition: all 100ms ease-in-out;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default Scrollable;
|
export default observer(Scrollable);
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { observable } from "mobx";
|
import { observer } from "mobx-react";
|
||||||
import { observer, inject } from "mobx-react";
|
|
||||||
import {
|
import {
|
||||||
ArchiveIcon,
|
ArchiveIcon,
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
|
@ -10,14 +9,11 @@ import {
|
||||||
ShapesIcon,
|
ShapesIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
SettingsIcon,
|
||||||
} from "outline-icons";
|
} from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { withTranslation, type TFunction } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
|
||||||
import AuthStore from "stores/AuthStore";
|
|
||||||
import DocumentsStore from "stores/DocumentsStore";
|
|
||||||
import PoliciesStore from "stores/PoliciesStore";
|
|
||||||
import CollectionNew from "scenes/CollectionNew";
|
import CollectionNew from "scenes/CollectionNew";
|
||||||
import Invite from "scenes/Invite";
|
import Invite from "scenes/Invite";
|
||||||
import Flex from "components/Flex";
|
import Flex from "components/Flex";
|
||||||
|
@ -29,175 +25,179 @@ import Collections from "./components/Collections";
|
||||||
import HeaderBlock from "./components/HeaderBlock";
|
import HeaderBlock from "./components/HeaderBlock";
|
||||||
import Section from "./components/Section";
|
import Section from "./components/Section";
|
||||||
import SidebarLink from "./components/SidebarLink";
|
import SidebarLink from "./components/SidebarLink";
|
||||||
|
import useStores from "hooks/useStores";
|
||||||
import AccountMenu from "menus/AccountMenu";
|
import AccountMenu from "menus/AccountMenu";
|
||||||
|
|
||||||
type Props = {
|
function MainSidebar() {
|
||||||
auth: AuthStore,
|
const { t } = useTranslation();
|
||||||
documents: DocumentsStore,
|
const { policies, auth, documents } = useStores();
|
||||||
policies: PoliciesStore,
|
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
|
||||||
t: TFunction,
|
const [
|
||||||
};
|
createCollectionModalOpen,
|
||||||
|
setCreateCollectionModalOpen,
|
||||||
|
] = React.useState(false);
|
||||||
|
|
||||||
@observer
|
React.useEffect(() => {
|
||||||
class MainSidebar extends React.Component<Props> {
|
documents.fetchDrafts();
|
||||||
@observable inviteModalOpen = false;
|
documents.fetchTemplates();
|
||||||
@observable createCollectionModalOpen = false;
|
}, [documents]);
|
||||||
|
|
||||||
componentDidMount() {
|
const handleCreateCollectionModalOpen = React.useCallback(
|
||||||
this.props.documents.fetchDrafts();
|
(ev: SyntheticEvent<>) => {
|
||||||
this.props.documents.fetchTemplates();
|
ev.preventDefault();
|
||||||
}
|
setCreateCollectionModalOpen(true);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
handleCreateCollectionModalOpen = (ev: SyntheticEvent<>) => {
|
const handleCreateCollectionModalClose = React.useCallback(() => {
|
||||||
|
setCreateCollectionModalOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInviteModalOpen = React.useCallback((ev: SyntheticEvent<>) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
this.createCollectionModalOpen = true;
|
setInviteModalOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
handleCreateCollectionModalClose = (ev: SyntheticEvent<>) => {
|
const handleInviteModalClose = React.useCallback(() => {
|
||||||
this.createCollectionModalOpen = false;
|
setInviteModalOpen(false);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
handleInviteModalOpen = (ev: SyntheticEvent<>) => {
|
const { user, team } = auth;
|
||||||
ev.preventDefault();
|
if (!user || !team) return null;
|
||||||
this.inviteModalOpen = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
handleInviteModalClose = () => {
|
const can = policies.abilities(team.id);
|
||||||
this.inviteModalOpen = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
return (
|
||||||
const { auth, documents, policies, t } = this.props;
|
<Sidebar>
|
||||||
const { user, team } = auth;
|
<AccountMenu>
|
||||||
if (!user || !team) return null;
|
{(props) => (
|
||||||
|
<HeaderBlock
|
||||||
const can = policies.abilities(team.id);
|
{...props}
|
||||||
|
subheading={user.name}
|
||||||
return (
|
teamName={team.name}
|
||||||
<Sidebar>
|
logoUrl={team.avatarUrl}
|
||||||
<AccountMenu
|
showDisclosure
|
||||||
label={
|
/>
|
||||||
<HeaderBlock
|
)}
|
||||||
subheading={user.name}
|
</AccountMenu>
|
||||||
teamName={team.name}
|
<Flex auto column>
|
||||||
logoUrl={team.avatarUrl}
|
<Scrollable shadow>
|
||||||
showDisclosure
|
<Section>
|
||||||
|
<SidebarLink
|
||||||
|
to="/home"
|
||||||
|
icon={<HomeIcon color="currentColor" />}
|
||||||
|
exact={false}
|
||||||
|
label={t("Home")}
|
||||||
/>
|
/>
|
||||||
}
|
<SidebarLink
|
||||||
/>
|
to={{
|
||||||
<Flex auto column>
|
pathname: "/search",
|
||||||
<Scrollable shadow>
|
state: { fromMenu: true },
|
||||||
<Section>
|
}}
|
||||||
|
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
|
<SidebarLink
|
||||||
to="/home"
|
to="/settings/people"
|
||||||
icon={<HomeIcon color="currentColor" />}
|
onClick={handleInviteModalOpen}
|
||||||
exact={false}
|
icon={<PlusIcon color="currentColor" />}
|
||||||
label={t("Home")}
|
label={`${t("Invite people")}…`}
|
||||||
/>
|
/>
|
||||||
<SidebarLink
|
)}
|
||||||
to={{
|
</Section>
|
||||||
pathname: "/search",
|
</Secondary>
|
||||||
state: { fromMenu: true },
|
</Flex>
|
||||||
}}
|
<Modal
|
||||||
icon={<SearchIcon color="currentColor" />}
|
title={t("Invite people")}
|
||||||
label={t("Search")}
|
onRequestClose={handleInviteModalClose}
|
||||||
exact={false}
|
isOpen={inviteModalOpen}
|
||||||
/>
|
>
|
||||||
<SidebarLink
|
<Invite onSubmit={handleInviteModalClose} />
|
||||||
to="/starred"
|
</Modal>
|
||||||
icon={<StarredIcon color="currentColor" />}
|
<Modal
|
||||||
exact={false}
|
title={t("Create a collection")}
|
||||||
label={t("Starred")}
|
onRequestClose={handleCreateCollectionModalClose}
|
||||||
/>
|
isOpen={createCollectionModalOpen}
|
||||||
<SidebarLink
|
>
|
||||||
to="/templates"
|
<CollectionNew onSubmit={handleCreateCollectionModalClose} />
|
||||||
icon={<ShapesIcon color="currentColor" />}
|
</Modal>
|
||||||
exact={false}
|
</Sidebar>
|
||||||
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={this.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
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{can.invite && (
|
|
||||||
<SidebarLink
|
|
||||||
to="/settings/people"
|
|
||||||
onClick={this.handleInviteModalOpen}
|
|
||||||
icon={<PlusIcon color="currentColor" />}
|
|
||||||
label={t("Invite people…")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
</Scrollable>
|
|
||||||
</Flex>
|
|
||||||
<Modal
|
|
||||||
title={t("Invite people")}
|
|
||||||
onRequestClose={this.handleInviteModalClose}
|
|
||||||
isOpen={this.inviteModalOpen}
|
|
||||||
>
|
|
||||||
<Invite onSubmit={this.handleInviteModalClose} />
|
|
||||||
</Modal>
|
|
||||||
<Modal
|
|
||||||
title={t("Create a collection")}
|
|
||||||
onRequestClose={this.handleCreateCollectionModalClose}
|
|
||||||
isOpen={this.createCollectionModalOpen}
|
|
||||||
>
|
|
||||||
<CollectionNew onSubmit={this.handleCreateCollectionModalClose} />
|
|
||||||
</Modal>
|
|
||||||
</Sidebar>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Secondary = styled.div`
|
||||||
|
overflow-x: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
const Drafts = styled(Flex)`
|
const Drafts = styled(Flex)`
|
||||||
height: 24px;
|
height: 24px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default withTranslation()<MainSidebar>(
|
export default observer(MainSidebar);
|
||||||
inject("documents", "policies", "auth")(MainSidebar)
|
|
||||||
);
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { observer, inject } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import {
|
import {
|
||||||
DocumentIcon,
|
DocumentIcon,
|
||||||
EmailIcon,
|
EmailIcon,
|
||||||
|
@ -13,11 +13,9 @@ import {
|
||||||
ExpandedIcon,
|
ExpandedIcon,
|
||||||
} from "outline-icons";
|
} from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { withTranslation, type TFunction } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { RouterHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import AuthStore from "stores/AuthStore";
|
|
||||||
import PoliciesStore from "stores/PoliciesStore";
|
|
||||||
import Flex from "components/Flex";
|
import Flex from "components/Flex";
|
||||||
import Scrollable from "components/Scrollable";
|
import Scrollable from "components/Scrollable";
|
||||||
|
|
||||||
|
@ -30,131 +28,123 @@ import Version from "./components/Version";
|
||||||
import SlackIcon from "./icons/Slack";
|
import SlackIcon from "./icons/Slack";
|
||||||
import ZapierIcon from "./icons/Zapier";
|
import ZapierIcon from "./icons/Zapier";
|
||||||
import env from "env";
|
import env from "env";
|
||||||
|
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||||
|
import useStores from "hooks/useStores";
|
||||||
|
|
||||||
const isHosted = env.DEPLOYMENT === "hosted";
|
const isHosted = env.DEPLOYMENT === "hosted";
|
||||||
|
|
||||||
type Props = {
|
function SettingsSidebar() {
|
||||||
history: RouterHistory,
|
const { t } = useTranslation();
|
||||||
policies: PoliciesStore,
|
const history = useHistory();
|
||||||
auth: AuthStore,
|
const team = useCurrentTeam();
|
||||||
t: TFunction,
|
const { policies } = useStores();
|
||||||
};
|
const can = policies.abilities(team.id);
|
||||||
|
|
||||||
@observer
|
const returnToDashboard = React.useCallback(() => {
|
||||||
class SettingsSidebar extends React.Component<Props> {
|
history.push("/home");
|
||||||
returnToDashboard = () => {
|
}, [history]);
|
||||||
this.props.history.push("/home");
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
return (
|
||||||
const { policies, t, auth } = this.props;
|
<Sidebar>
|
||||||
const { team } = auth;
|
<HeaderBlock
|
||||||
if (!team) return null;
|
subheading={
|
||||||
|
<ReturnToApp align="center">
|
||||||
|
<BackIcon color="currentColor" /> {t("Return to App")}
|
||||||
|
</ReturnToApp>
|
||||||
|
}
|
||||||
|
teamName={team.name}
|
||||||
|
logoUrl={team.avatarUrl}
|
||||||
|
onClick={returnToDashboard}
|
||||||
|
/>
|
||||||
|
|
||||||
const can = policies.abilities(team.id);
|
<Flex auto column>
|
||||||
|
<Scrollable topShadow>
|
||||||
return (
|
<Section>
|
||||||
<Sidebar>
|
<Header>{t("Account")}</Header>
|
||||||
<HeaderBlock
|
<SidebarLink
|
||||||
subheading={
|
to="/settings"
|
||||||
<ReturnToApp align="center">
|
icon={<ProfileIcon color="currentColor" />}
|
||||||
<BackIcon color="currentColor" /> {t("Return to App")}
|
label={t("Profile")}
|
||||||
</ReturnToApp>
|
/>
|
||||||
}
|
<SidebarLink
|
||||||
teamName={team.name}
|
to="/settings/notifications"
|
||||||
logoUrl={team.avatarUrl}
|
icon={<EmailIcon color="currentColor" />}
|
||||||
onClick={this.returnToDashboard}
|
label={t("Notifications")}
|
||||||
/>
|
/>
|
||||||
|
<SidebarLink
|
||||||
<Flex auto column>
|
to="/settings/tokens"
|
||||||
<Scrollable shadow>
|
icon={<CodeIcon color="currentColor" />}
|
||||||
<Section>
|
label={t("API Tokens")}
|
||||||
<Header>Account</Header>
|
/>
|
||||||
<SidebarLink
|
</Section>
|
||||||
to="/settings"
|
<Section>
|
||||||
icon={<ProfileIcon color="currentColor" />}
|
<Header>{t("Team")}</Header>
|
||||||
label={t("Profile")}
|
|
||||||
/>
|
|
||||||
<SidebarLink
|
|
||||||
to="/settings/notifications"
|
|
||||||
icon={<EmailIcon color="currentColor" />}
|
|
||||||
label={t("Notifications")}
|
|
||||||
/>
|
|
||||||
<SidebarLink
|
|
||||||
to="/settings/tokens"
|
|
||||||
icon={<CodeIcon color="currentColor" />}
|
|
||||||
label={t("API Tokens")}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
<Section>
|
|
||||||
<Header>Team</Header>
|
|
||||||
{can.update && (
|
|
||||||
<SidebarLink
|
|
||||||
to="/settings/details"
|
|
||||||
icon={<TeamIcon color="currentColor" />}
|
|
||||||
label={t("Details")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{can.update && (
|
|
||||||
<SidebarLink
|
|
||||||
to="/settings/security"
|
|
||||||
icon={<PadlockIcon color="currentColor" />}
|
|
||||||
label={t("Security")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<SidebarLink
|
|
||||||
to="/settings/people"
|
|
||||||
icon={<UserIcon color="currentColor" />}
|
|
||||||
exact={false}
|
|
||||||
label={t("People")}
|
|
||||||
/>
|
|
||||||
<SidebarLink
|
|
||||||
to="/settings/groups"
|
|
||||||
icon={<GroupIcon color="currentColor" />}
|
|
||||||
exact={false}
|
|
||||||
label={t("Groups")}
|
|
||||||
/>
|
|
||||||
<SidebarLink
|
|
||||||
to="/settings/shares"
|
|
||||||
icon={<LinkIcon color="currentColor" />}
|
|
||||||
label={t("Share Links")}
|
|
||||||
/>
|
|
||||||
{can.export && (
|
|
||||||
<SidebarLink
|
|
||||||
to="/settings/import-export"
|
|
||||||
icon={<DocumentIcon color="currentColor" />}
|
|
||||||
label={t("Import / Export")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
{can.update && (
|
{can.update && (
|
||||||
<Section>
|
<SidebarLink
|
||||||
<Header>{t("Integrations")}</Header>
|
to="/settings/details"
|
||||||
|
icon={<TeamIcon color="currentColor" />}
|
||||||
|
label={t("Details")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{can.update && (
|
||||||
|
<SidebarLink
|
||||||
|
to="/settings/security"
|
||||||
|
icon={<PadlockIcon color="currentColor" />}
|
||||||
|
label={t("Security")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SidebarLink
|
||||||
|
to="/settings/people"
|
||||||
|
icon={<UserIcon color="currentColor" />}
|
||||||
|
exact={false}
|
||||||
|
label={t("People")}
|
||||||
|
/>
|
||||||
|
<SidebarLink
|
||||||
|
to="/settings/groups"
|
||||||
|
icon={<GroupIcon color="currentColor" />}
|
||||||
|
exact={false}
|
||||||
|
label={t("Groups")}
|
||||||
|
/>
|
||||||
|
<SidebarLink
|
||||||
|
to="/settings/shares"
|
||||||
|
icon={<LinkIcon color="currentColor" />}
|
||||||
|
label={t("Share Links")}
|
||||||
|
/>
|
||||||
|
{can.export && (
|
||||||
|
<SidebarLink
|
||||||
|
to="/settings/import-export"
|
||||||
|
icon={<DocumentIcon color="currentColor" />}
|
||||||
|
label={t("Import / Export")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
{can.update && (
|
||||||
|
<Section>
|
||||||
|
<Header>{t("Integrations")}</Header>
|
||||||
|
<SidebarLink
|
||||||
|
to="/settings/integrations/slack"
|
||||||
|
icon={<SlackIcon color="currentColor" />}
|
||||||
|
label="Slack"
|
||||||
|
/>
|
||||||
|
{isHosted && (
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
to="/settings/integrations/slack"
|
to="/settings/integrations/zapier"
|
||||||
icon={<SlackIcon color="currentColor" />}
|
icon={<ZapierIcon color="currentColor" />}
|
||||||
label="Slack"
|
label="Zapier"
|
||||||
/>
|
/>
|
||||||
{isHosted && (
|
)}
|
||||||
<SidebarLink
|
</Section>
|
||||||
to="/settings/integrations/zapier"
|
)}
|
||||||
icon={<ZapierIcon color="currentColor" />}
|
{can.update && !isHosted && (
|
||||||
label="Zapier"
|
<Section>
|
||||||
/>
|
<Header>{t("Installation")}</Header>
|
||||||
)}
|
<Version />
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
{can.update && !isHosted && (
|
</Scrollable>
|
||||||
<Section>
|
</Flex>
|
||||||
<Header>{t("Installation")}</Header>
|
</Sidebar>
|
||||||
<Version />
|
);
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
</Scrollable>
|
|
||||||
</Flex>
|
|
||||||
</Sidebar>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const BackIcon = styled(ExpandedIcon)`
|
const BackIcon = styled(ExpandedIcon)`
|
||||||
|
@ -166,6 +156,4 @@ const ReturnToApp = styled(Flex)`
|
||||||
height: 16px;
|
height: 16px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default withTranslation()<SettingsSidebar>(
|
export default observer(SettingsSidebar);
|
||||||
inject("auth", "policies")(SettingsSidebar)
|
|
||||||
);
|
|
||||||
|
|
|
@ -1,55 +1,171 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { CloseIcon, MenuIcon } from "outline-icons";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Portal } from "react-portal";
|
||||||
import { withRouter } from "react-router-dom";
|
import { withRouter } from "react-router-dom";
|
||||||
import type { Location } from "react-router-dom";
|
import type { Location } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
import styled, { useTheme } from "styled-components";
|
||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
import Fade from "components/Fade";
|
import Fade from "components/Fade";
|
||||||
import Flex from "components/Flex";
|
import Flex from "components/Flex";
|
||||||
import CollapseToggle, { Button } from "./components/CollapseToggle";
|
import CollapseToggle, {
|
||||||
|
Button as CollapseButton,
|
||||||
|
} from "./components/CollapseToggle";
|
||||||
|
import ResizeBorder from "./components/ResizeBorder";
|
||||||
|
import ResizeHandle from "./components/ResizeHandle";
|
||||||
import usePrevious from "hooks/usePrevious";
|
import usePrevious from "hooks/usePrevious";
|
||||||
import useStores from "hooks/useStores";
|
import useStores from "hooks/useStores";
|
||||||
|
|
||||||
let firstRender = true;
|
let firstRender = true;
|
||||||
|
let BOUNCE_ANIMATION_MS = 250;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.Node,
|
children: React.Node,
|
||||||
location: Location,
|
location: Location,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
|
||||||
|
const [offset, setOffset] = React.useState(0);
|
||||||
|
const [isAnimating, setAnimating] = React.useState(false);
|
||||||
|
const [isResizing, setResizing] = React.useState(false);
|
||||||
|
const isSmallerThanMinimum = width < minWidth;
|
||||||
|
|
||||||
|
const handleDrag = React.useCallback(
|
||||||
|
(event: MouseEvent) => {
|
||||||
|
// suppresses text selection
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// this is simple because the sidebar is always against the left edge
|
||||||
|
const width = Math.min(event.pageX - offset, maxWidth);
|
||||||
|
setWidth(width);
|
||||||
|
},
|
||||||
|
[offset, maxWidth, setWidth]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStopDrag = React.useCallback(() => {
|
||||||
|
setResizing(false);
|
||||||
|
|
||||||
|
if (isSmallerThanMinimum) {
|
||||||
|
setWidth(minWidth);
|
||||||
|
setAnimating(true);
|
||||||
|
} else {
|
||||||
|
setWidth(width);
|
||||||
|
}
|
||||||
|
}, [isSmallerThanMinimum, minWidth, width, setWidth]);
|
||||||
|
|
||||||
|
const handleStartDrag = React.useCallback(
|
||||||
|
(event) => {
|
||||||
|
setOffset(event.pageX - width);
|
||||||
|
setResizing(true);
|
||||||
|
setAnimating(false);
|
||||||
|
},
|
||||||
|
[width]
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isAnimating) {
|
||||||
|
setTimeout(() => setAnimating(false), BOUNCE_ANIMATION_MS);
|
||||||
|
}
|
||||||
|
}, [isAnimating]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isResizing) {
|
||||||
|
document.addEventListener("mousemove", handleDrag);
|
||||||
|
document.addEventListener("mouseup", handleStopDrag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", handleDrag);
|
||||||
|
document.removeEventListener("mouseup", handleStopDrag);
|
||||||
|
};
|
||||||
|
}, [isResizing, handleDrag, handleStopDrag]);
|
||||||
|
|
||||||
|
return { isAnimating, isSmallerThanMinimum, isResizing, handleStartDrag };
|
||||||
|
};
|
||||||
|
|
||||||
function Sidebar({ location, children }: Props) {
|
function Sidebar({ location, children }: Props) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
const { ui } = useStores();
|
const { ui } = useStores();
|
||||||
const previousLocation = usePrevious(location);
|
const previousLocation = usePrevious(location);
|
||||||
|
|
||||||
|
const width = ui.sidebarWidth;
|
||||||
|
const maxWidth = theme.sidebarMaxWidth;
|
||||||
|
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||||
|
const collapsed = ui.editMode || ui.sidebarCollapsed;
|
||||||
|
|
||||||
|
const {
|
||||||
|
isAnimating,
|
||||||
|
isSmallerThanMinimum,
|
||||||
|
isResizing,
|
||||||
|
handleStartDrag,
|
||||||
|
} = useResize({
|
||||||
|
width,
|
||||||
|
minWidth,
|
||||||
|
maxWidth,
|
||||||
|
setWidth: ui.setSidebarWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleReset = React.useCallback(() => {
|
||||||
|
ui.setSidebarWidth(theme.sidebarWidth);
|
||||||
|
}, [ui, theme.sidebarWidth]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
ui.setSidebarResizing(isResizing);
|
||||||
|
}, [ui, isResizing]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (location !== previousLocation) {
|
if (location !== previousLocation) {
|
||||||
ui.hideMobileSidebar();
|
ui.hideMobileSidebar();
|
||||||
}
|
}
|
||||||
}, [ui, location, previousLocation]);
|
}, [ui, location, previousLocation]);
|
||||||
|
|
||||||
|
const style = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
width: `${width}px`,
|
||||||
|
left:
|
||||||
|
collapsed && !ui.mobileSidebarVisible
|
||||||
|
? `${-width + theme.sidebarCollapsedWidth}px`
|
||||||
|
: 0,
|
||||||
|
}),
|
||||||
|
[width, collapsed, theme.sidebarCollapsedWidth, ui.mobileSidebarVisible]
|
||||||
|
);
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<Container
|
<Container
|
||||||
mobileSidebarVisible={ui.mobileSidebarVisible}
|
style={style}
|
||||||
collapsed={ui.editMode || ui.sidebarCollapsed}
|
$sidebarWidth={ui.sidebarWidth}
|
||||||
|
$isAnimating={isAnimating}
|
||||||
|
$isSmallerThanMinimum={isSmallerThanMinimum}
|
||||||
|
$mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||||
|
$collapsed={collapsed}
|
||||||
column
|
column
|
||||||
>
|
>
|
||||||
<CollapseToggle
|
{!isResizing && (
|
||||||
collapsed={ui.sidebarCollapsed}
|
<CollapseToggle
|
||||||
onClick={ui.toggleCollapsedSidebar}
|
collapsed={ui.sidebarCollapsed}
|
||||||
/>
|
onClick={ui.toggleCollapsedSidebar}
|
||||||
<Toggle
|
/>
|
||||||
onClick={ui.toggleMobileSidebar}
|
)}
|
||||||
mobileSidebarVisible={ui.mobileSidebarVisible}
|
{ui.mobileSidebarVisible && (
|
||||||
>
|
<Portal>
|
||||||
{ui.mobileSidebarVisible ? (
|
<Fade>
|
||||||
<CloseIcon size={32} />
|
<Background onClick={ui.toggleMobileSidebar} />
|
||||||
) : (
|
</Fade>
|
||||||
<MenuIcon size={32} />
|
</Portal>
|
||||||
)}
|
)}
|
||||||
</Toggle>
|
|
||||||
{children}
|
{children}
|
||||||
|
{!ui.sidebarCollapsed && (
|
||||||
|
<ResizeBorder
|
||||||
|
onMouseDown={handleStartDrag}
|
||||||
|
onDoubleClick={handleReset}
|
||||||
|
$isResizing={isResizing}
|
||||||
|
>
|
||||||
|
<ResizeHandle aria-label={t("Resize sidebar")} />
|
||||||
|
</ResizeBorder>
|
||||||
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -62,82 +178,67 @@ function Sidebar({ location, children }: Props) {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Background = styled.a`
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
cursor: default;
|
||||||
|
z-index: ${(props) => props.theme.depths.sidebar - 1};
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
`;
|
||||||
|
|
||||||
const Container = styled(Flex)`
|
const Container = styled(Flex)`
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: ${(props) => props.theme.sidebarBackground};
|
background: ${(props) => props.theme.sidebarBackground};
|
||||||
transition: box-shadow, 100ms, ease-in-out, left 100ms ease-out,
|
transition: box-shadow, 100ms, ease-in-out, margin-left 100ms ease-out,
|
||||||
${(props) => props.theme.backgroundTransition};
|
left 100ms ease-out,
|
||||||
margin-left: ${(props) => (props.mobileSidebarVisible ? 0 : "-100%")};
|
${(props) => props.theme.backgroundTransition}
|
||||||
|
${(props) =>
|
||||||
|
props.$isAnimating ? `,width ${BOUNCE_ANIMATION_MS}ms ease-out` : ""};
|
||||||
|
margin-left: ${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")};
|
||||||
z-index: ${(props) => props.theme.depths.sidebar};
|
z-index: ${(props) => props.theme.depths.sidebar};
|
||||||
|
max-width: 70%;
|
||||||
|
min-width: 280px;
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
display: none;
|
display: none;
|
||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:before,
|
|
||||||
&:after {
|
|
||||||
content: "";
|
|
||||||
background: ${(props) => props.theme.sidebarBackground};
|
|
||||||
position: absolute;
|
|
||||||
top: -50vh;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 50vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:after {
|
|
||||||
top: auto;
|
|
||||||
bottom: -50vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
${breakpoint("tablet")`
|
${breakpoint("tablet")`
|
||||||
left: ${(props) =>
|
|
||||||
props.collapsed
|
|
||||||
? `calc(-${props.theme.sidebarWidth} + ${props.theme.sidebarCollapsedWidth})`
|
|
||||||
: 0};
|
|
||||||
width: ${(props) => props.theme.sidebarWidth};
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus-within {
|
&:focus-within {
|
||||||
left: 0;
|
left: 0 !important;
|
||||||
box-shadow: ${(props) =>
|
box-shadow: ${(props) =>
|
||||||
props.collapsed ? "rgba(0, 0, 0, 0.2) 1px 0 4px" : "none"};
|
props.$collapsed
|
||||||
|
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
|
||||||
|
: props.$isSmallerThanMinimum
|
||||||
|
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
|
||||||
|
: "none"};
|
||||||
|
|
||||||
& ${Button} {
|
& ${CollapseButton} {
|
||||||
opacity: .75;
|
opacity: .75;
|
||||||
}
|
}
|
||||||
|
|
||||||
& ${Button}:hover {
|
& ${CollapseButton}:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:not(:hover):not(:focus-within) > div {
|
&:not(:hover):not(:focus-within) > div {
|
||||||
opacity: ${(props) => (props.collapsed ? "0" : "1")};
|
opacity: ${(props) => (props.$collapsed ? "0" : "1")};
|
||||||
transition: opacity 100ms ease-in-out;
|
transition: opacity 100ms ease-in-out;
|
||||||
}
|
}
|
||||||
`};
|
`};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Toggle = styled.a`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: ${(props) => (props.mobileSidebarVisible ? "auto" : 0)};
|
|
||||||
right: ${(props) => (props.mobileSidebarVisible ? 0 : "auto")};
|
|
||||||
z-index: 1;
|
|
||||||
margin: 12px;
|
|
||||||
|
|
||||||
${breakpoint("tablet")`
|
|
||||||
display: none;
|
|
||||||
`};
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default withRouter(observer(Sidebar));
|
export default withRouter(observer(Sidebar));
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { meta } from "utils/keyboard";
|
||||||
|
|
||||||
type Props = {|
|
type Props = {|
|
||||||
collapsed: boolean,
|
collapsed: boolean,
|
||||||
onClick?: () => void,
|
onClick?: (event: SyntheticEvent<>) => void,
|
||||||
|};
|
|};
|
||||||
|
|
||||||
function CollapseToggle({ collapsed, ...rest }: Props) {
|
function CollapseToggle({ collapsed, ...rest }: Props) {
|
||||||
|
@ -21,7 +21,7 @@ function CollapseToggle({ collapsed, ...rest }: Props) {
|
||||||
delay={500}
|
delay={500}
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
>
|
>
|
||||||
<Button {...rest} aria-hidden>
|
<Button {...rest} tabIndex="-1" aria-hidden>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<NextIcon color="currentColor" />
|
<NextIcon color="currentColor" />
|
||||||
) : (
|
) : (
|
||||||
|
@ -43,7 +43,7 @@ export const Button = styled.button`
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: ${(props) => props.theme.sidebarText};
|
color: ${(props) => props.theme.sidebarText};
|
||||||
background: ${(props) => props.theme.sidebarItemBackground};
|
background: transparent;
|
||||||
transition: opacity 100ms ease-in-out;
|
transition: opacity 100ms ease-in-out;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
|
@ -2,16 +2,19 @@
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useDrop } from "react-dnd";
|
import { useDrop } from "react-dnd";
|
||||||
|
import styled from "styled-components";
|
||||||
import UiStore from "stores/UiStore";
|
import UiStore from "stores/UiStore";
|
||||||
import Collection from "models/Collection";
|
import Collection from "models/Collection";
|
||||||
import Document from "models/Document";
|
import Document from "models/Document";
|
||||||
import CollectionIcon from "components/CollectionIcon";
|
import CollectionIcon from "components/CollectionIcon";
|
||||||
import DropToImport from "components/DropToImport";
|
import DropToImport from "components/DropToImport";
|
||||||
import DocumentLink from "./DocumentLink";
|
import DocumentLink from "./DocumentLink";
|
||||||
|
import DropCursor from "./DropCursor";
|
||||||
import EditableTitle from "./EditableTitle";
|
import EditableTitle from "./EditableTitle";
|
||||||
import SidebarLink from "./SidebarLink";
|
import SidebarLink from "./SidebarLink";
|
||||||
import useStores from "hooks/useStores";
|
import useStores from "hooks/useStores";
|
||||||
import CollectionMenu from "menus/CollectionMenu";
|
import CollectionMenu from "menus/CollectionMenu";
|
||||||
|
import CollectionSortMenu from "menus/CollectionSortMenu";
|
||||||
|
|
||||||
type Props = {|
|
type Props = {|
|
||||||
collection: Collection,
|
collection: Collection,
|
||||||
|
@ -39,11 +42,14 @@ function CollectionLink({
|
||||||
|
|
||||||
const { documents, policies } = useStores();
|
const { documents, policies } = useStores();
|
||||||
const expanded = collection.id === ui.activeCollectionId;
|
const expanded = collection.id === ui.activeCollectionId;
|
||||||
|
const manualSort = collection.sort.field === "index";
|
||||||
|
const can = policies.abilities(collection.id);
|
||||||
|
|
||||||
// Droppable
|
// Drop to re-parent
|
||||||
const [{ isOver, canDrop }, drop] = useDrop({
|
const [{ isOver, canDrop }, drop] = useDrop({
|
||||||
accept: "document",
|
accept: "document",
|
||||||
drop: (item, monitor) => {
|
drop: (item, monitor) => {
|
||||||
|
if (monitor.didDrop()) return;
|
||||||
if (!collection) return;
|
if (!collection) return;
|
||||||
documents.move(item.id, collection.id);
|
documents.move(item.id, collection.id);
|
||||||
},
|
},
|
||||||
|
@ -51,16 +57,28 @@ function CollectionLink({
|
||||||
return policies.abilities(collection.id).update;
|
return policies.abilities(collection.id).update;
|
||||||
},
|
},
|
||||||
collect: (monitor) => ({
|
collect: (monitor) => ({
|
||||||
isOver: !!monitor.isOver(),
|
isOver: !!monitor.isOver({ shallow: true }),
|
||||||
canDrop: monitor.canDrop(),
|
canDrop: monitor.canDrop(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Drop to reorder
|
||||||
|
const [{ isOverReorder }, dropToReorder] = useDrop({
|
||||||
|
accept: "document",
|
||||||
|
drop: async (item, monitor) => {
|
||||||
|
if (!collection) return;
|
||||||
|
documents.move(item.id, collection.id, undefined, 0);
|
||||||
|
},
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isOverReorder: !!monitor.isOver(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={drop}>
|
<div ref={drop} style={{ position: "relative" }}>
|
||||||
<DropToImport key={collection.id} collectionId={collection.id}>
|
<DropToImport key={collection.id} collectionId={collection.id}>
|
||||||
<SidebarLink
|
<SidebarLinkWithPadding
|
||||||
key={collection.id}
|
key={collection.id}
|
||||||
to={collection.url}
|
to={collection.url}
|
||||||
icon={
|
icon={
|
||||||
|
@ -68,7 +86,7 @@ function CollectionLink({
|
||||||
}
|
}
|
||||||
iconColor={collection.color}
|
iconColor={collection.color}
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
menuOpen={menuOpen}
|
showActions={menuOpen || expanded}
|
||||||
isActiveDrop={isOver && canDrop}
|
isActiveDrop={isOver && canDrop}
|
||||||
label={
|
label={
|
||||||
<EditableTitle
|
<EditableTitle
|
||||||
|
@ -79,19 +97,30 @@ function CollectionLink({
|
||||||
}
|
}
|
||||||
exact={false}
|
exact={false}
|
||||||
menu={
|
menu={
|
||||||
<CollectionMenu
|
<>
|
||||||
position="right"
|
{can.update && (
|
||||||
collection={collection}
|
<CollectionSortMenuWithMargin
|
||||||
onOpen={() => setMenuOpen(true)}
|
collection={collection}
|
||||||
onClose={() => setMenuOpen(false)}
|
onOpen={() => setMenuOpen(true)}
|
||||||
/>
|
onClose={() => setMenuOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<CollectionMenu
|
||||||
|
collection={collection}
|
||||||
|
onOpen={() => setMenuOpen(true)}
|
||||||
|
onClose={() => setMenuOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
></SidebarLink>
|
/>
|
||||||
</DropToImport>
|
</DropToImport>
|
||||||
|
{expanded && manualSort && (
|
||||||
|
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expanded &&
|
{expanded &&
|
||||||
collection.documents.map((node) => (
|
collection.documents.map((node, index) => (
|
||||||
<DocumentLink
|
<DocumentLink
|
||||||
key={node.id}
|
key={node.id}
|
||||||
node={node}
|
node={node}
|
||||||
|
@ -100,10 +129,19 @@ function CollectionLink({
|
||||||
prefetchDocument={prefetchDocument}
|
prefetchDocument={prefetchDocument}
|
||||||
canUpdate={canUpdate}
|
canUpdate={canUpdate}
|
||||||
depth={1.5}
|
depth={1.5}
|
||||||
|
index={index}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SidebarLinkWithPadding = styled(SidebarLink)`
|
||||||
|
padding-right: 60px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CollectionSortMenuWithMargin = styled(CollectionSortMenu)`
|
||||||
|
margin-right: 4px;
|
||||||
|
`;
|
||||||
|
|
||||||
export default observer(CollectionLink);
|
export default observer(CollectionLink);
|
||||||
|
|
|
@ -9,6 +9,7 @@ import Collection from "models/Collection";
|
||||||
import Document from "models/Document";
|
import Document from "models/Document";
|
||||||
import DropToImport from "components/DropToImport";
|
import DropToImport from "components/DropToImport";
|
||||||
import Fade from "components/Fade";
|
import Fade from "components/Fade";
|
||||||
|
import DropCursor from "./DropCursor";
|
||||||
import EditableTitle from "./EditableTitle";
|
import EditableTitle from "./EditableTitle";
|
||||||
import SidebarLink from "./SidebarLink";
|
import SidebarLink from "./SidebarLink";
|
||||||
import useStores from "hooks/useStores";
|
import useStores from "hooks/useStores";
|
||||||
|
@ -20,19 +21,21 @@ type Props = {|
|
||||||
canUpdate: boolean,
|
canUpdate: boolean,
|
||||||
collection?: Collection,
|
collection?: Collection,
|
||||||
activeDocument: ?Document,
|
activeDocument: ?Document,
|
||||||
activeDocumentRef?: (?HTMLElement) => void,
|
|
||||||
prefetchDocument: (documentId: string) => Promise<void>,
|
prefetchDocument: (documentId: string) => Promise<void>,
|
||||||
depth: number,
|
depth: number,
|
||||||
|
index: number,
|
||||||
|
parentId?: string,
|
||||||
|};
|
|};
|
||||||
|
|
||||||
function DocumentLink({
|
function DocumentLink({
|
||||||
node,
|
node,
|
||||||
|
canUpdate,
|
||||||
collection,
|
collection,
|
||||||
activeDocument,
|
activeDocument,
|
||||||
activeDocumentRef,
|
|
||||||
prefetchDocument,
|
prefetchDocument,
|
||||||
depth,
|
depth,
|
||||||
canUpdate,
|
index,
|
||||||
|
parentId,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { documents, policies } = useStores();
|
const { documents, policies } = useStores();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
@ -76,6 +79,14 @@ function DocumentLink({
|
||||||
}
|
}
|
||||||
}, [showChildren]);
|
}, [showChildren]);
|
||||||
|
|
||||||
|
// when the last child document is removed,
|
||||||
|
// also close the local folder state to closed
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (expanded && !hasChildDocuments) {
|
||||||
|
setExpanded(false);
|
||||||
|
}
|
||||||
|
}, [expanded, hasChildDocuments]);
|
||||||
|
|
||||||
const handleDisclosureClick = React.useCallback(
|
const handleDisclosureClick = React.useCallback(
|
||||||
(ev: SyntheticEvent<>) => {
|
(ev: SyntheticEvent<>) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
@ -108,6 +119,7 @@ function DocumentLink({
|
||||||
|
|
||||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||||
const isMoving = documents.movingDocumentId === node.id;
|
const isMoving = documents.movingDocumentId === node.id;
|
||||||
|
const manualSort = collection?.sort.field === "index";
|
||||||
|
|
||||||
// Draggable
|
// Draggable
|
||||||
const [{ isDragging }, drag] = useDrag({
|
const [{ isDragging }, drag] = useDrag({
|
||||||
|
@ -120,77 +132,131 @@ function DocumentLink({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Droppable
|
const hoverExpanding = React.useRef(null);
|
||||||
const [{ isOver, canDrop }, drop] = useDrop({
|
|
||||||
|
// We set a timeout when the user first starts hovering over the document link,
|
||||||
|
// to trigger expansion of children. Clear this timeout when they stop hovering.
|
||||||
|
const resetHoverExpanding = React.useCallback(() => {
|
||||||
|
if (hoverExpanding.current) {
|
||||||
|
clearTimeout(hoverExpanding.current);
|
||||||
|
hoverExpanding.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Drop to re-parent
|
||||||
|
const [{ isOverReparent, canDropToReparent }, dropToReparent] = useDrop({
|
||||||
accept: "document",
|
accept: "document",
|
||||||
drop: async (item, monitor) => {
|
drop: async (item, monitor) => {
|
||||||
|
if (monitor.didDrop()) return;
|
||||||
if (!collection) return;
|
if (!collection) return;
|
||||||
documents.move(item.id, collection.id, node.id);
|
documents.move(item.id, collection.id, node.id);
|
||||||
},
|
},
|
||||||
|
|
||||||
canDrop: (item, monitor) =>
|
canDrop: (item, monitor) =>
|
||||||
pathToNode && !pathToNode.includes(monitor.getItem().id),
|
pathToNode && !pathToNode.includes(monitor.getItem().id),
|
||||||
|
|
||||||
|
hover: (item, monitor) => {
|
||||||
|
// Enables expansion of document children when hovering over the document
|
||||||
|
// for more than half a second.
|
||||||
|
if (
|
||||||
|
hasChildDocuments &&
|
||||||
|
monitor.canDrop() &&
|
||||||
|
monitor.isOver({ shallow: true })
|
||||||
|
) {
|
||||||
|
if (!hoverExpanding.current) {
|
||||||
|
hoverExpanding.current = setTimeout(() => {
|
||||||
|
hoverExpanding.current = null;
|
||||||
|
if (monitor.isOver({ shallow: true })) {
|
||||||
|
setExpanded(true);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
collect: (monitor) => ({
|
collect: (monitor) => ({
|
||||||
isOver: !!monitor.isOver(),
|
isOverReparent: !!monitor.isOver({ shallow: true }),
|
||||||
canDrop: monitor.canDrop(),
|
canDropToReparent: monitor.canDrop(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drop to reorder
|
||||||
|
const [{ isOverReorder }, dropToReorder] = useDrop({
|
||||||
|
accept: "document",
|
||||||
|
drop: async (item, monitor) => {
|
||||||
|
if (!collection) return;
|
||||||
|
if (item.id === node.id) return;
|
||||||
|
|
||||||
|
if (expanded) {
|
||||||
|
documents.move(item.id, collection.id, node.id, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
documents.move(item.id, collection.id, parentId, index + 1);
|
||||||
|
},
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isOverReorder: !!monitor.isOver(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Draggable
|
<div style={{ position: "relative" }} onDragLeave={resetHoverExpanding}>
|
||||||
key={node.id}
|
<Draggable
|
||||||
ref={drag}
|
key={node.id}
|
||||||
$isDragging={isDragging}
|
ref={drag}
|
||||||
$isMoving={isMoving}
|
$isDragging={isDragging}
|
||||||
>
|
$isMoving={isMoving}
|
||||||
<div ref={drop}>
|
>
|
||||||
<DropToImport documentId={node.id} activeClassName="activeDropZone">
|
<div ref={dropToReparent}>
|
||||||
<SidebarLink
|
<DropToImport documentId={node.id} activeClassName="activeDropZone">
|
||||||
innerRef={isActiveDocument ? activeDocumentRef : undefined}
|
<SidebarLink
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
to={{
|
to={{
|
||||||
pathname: node.url,
|
pathname: node.url,
|
||||||
state: { title: node.title },
|
state: { title: node.title },
|
||||||
}}
|
}}
|
||||||
label={
|
label={
|
||||||
<>
|
<>
|
||||||
{hasChildDocuments && (
|
{hasChildDocuments && (
|
||||||
<Disclosure
|
<Disclosure
|
||||||
expanded={expanded && !isDragging}
|
expanded={expanded && !isDragging}
|
||||||
onClick={handleDisclosureClick}
|
onClick={handleDisclosureClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<EditableTitle
|
||||||
|
title={node.title || t("Untitled")}
|
||||||
|
onSubmit={handleTitleChange}
|
||||||
|
canUpdate={canUpdate}
|
||||||
/>
|
/>
|
||||||
)}
|
</>
|
||||||
<EditableTitle
|
}
|
||||||
title={node.title || t("Untitled")}
|
isActiveDrop={isOverReparent && canDropToReparent}
|
||||||
onSubmit={handleTitleChange}
|
depth={depth}
|
||||||
canUpdate={canUpdate}
|
exact={false}
|
||||||
/>
|
showActions={menuOpen}
|
||||||
</>
|
menu={
|
||||||
}
|
document && !isMoving ? (
|
||||||
isActiveDrop={isOver && canDrop}
|
<Fade>
|
||||||
depth={depth}
|
<DocumentMenu
|
||||||
exact={false}
|
document={document}
|
||||||
menuOpen={menuOpen}
|
onOpen={() => setMenuOpen(true)}
|
||||||
menu={
|
onClose={() => setMenuOpen(false)}
|
||||||
document && !isMoving ? (
|
/>
|
||||||
<Fade>
|
</Fade>
|
||||||
<DocumentMenu
|
) : undefined
|
||||||
position="right"
|
}
|
||||||
document={document}
|
/>
|
||||||
onOpen={() => setMenuOpen(true)}
|
</DropToImport>
|
||||||
onClose={() => setMenuOpen(false)}
|
</div>
|
||||||
/>
|
</Draggable>
|
||||||
</Fade>
|
{manualSort && (
|
||||||
) : undefined
|
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||||
}
|
)}
|
||||||
/>
|
</div>
|
||||||
</DropToImport>
|
|
||||||
</div>
|
|
||||||
</Draggable>
|
|
||||||
|
|
||||||
{expanded && !isDragging && (
|
{expanded && !isDragging && (
|
||||||
<>
|
<>
|
||||||
{node.children.map((childNode) => (
|
{node.children.map((childNode, index) => (
|
||||||
<ObservedDocumentLink
|
<ObservedDocumentLink
|
||||||
key={childNode.id}
|
key={childNode.id}
|
||||||
collection={collection}
|
collection={collection}
|
||||||
|
@ -199,6 +265,8 @@ function DocumentLink({
|
||||||
prefetchDocument={prefetchDocument}
|
prefetchDocument={prefetchDocument}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
canUpdate={canUpdate}
|
canUpdate={canUpdate}
|
||||||
|
index={index}
|
||||||
|
parentId={node.id}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -0,0 +1,42 @@
|
||||||
|
// @flow
|
||||||
|
import * as React from "react";
|
||||||
|
import styled, { withTheme } from "styled-components";
|
||||||
|
import { type Theme } from "types";
|
||||||
|
|
||||||
|
function DropCursor({
|
||||||
|
isActiveDrop,
|
||||||
|
innerRef,
|
||||||
|
theme,
|
||||||
|
}: {
|
||||||
|
isActiveDrop: boolean,
|
||||||
|
innerRef: React.Ref<any>,
|
||||||
|
theme: Theme,
|
||||||
|
}) {
|
||||||
|
return <Cursor isOver={isActiveDrop} ref={innerRef} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// transparent hover zone with a thin visible band vertically centered
|
||||||
|
const Cursor = styled("div")`
|
||||||
|
opacity: ${(props) => (props.isOver ? 1 : 0)};
|
||||||
|
transition: opacity 150ms;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 14px;
|
||||||
|
bottom: -7px;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
::after {
|
||||||
|
background: ${(props) => props.theme.slateDark};
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
content: "";
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default withTheme(DropCursor);
|
|
@ -52,7 +52,9 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
|
||||||
setOriginalValue(value);
|
setOriginalValue(value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setValue(originalValue);
|
setValue(originalValue);
|
||||||
ui.showToast(error.message);
|
ui.showToast(error.message, {
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,33 +5,35 @@ import styled from "styled-components";
|
||||||
import Flex from "components/Flex";
|
import Flex from "components/Flex";
|
||||||
import TeamLogo from "components/TeamLogo";
|
import TeamLogo from "components/TeamLogo";
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
teamName: string,
|
teamName: string,
|
||||||
subheading: React.Node,
|
subheading: React.Node,
|
||||||
showDisclosure?: boolean,
|
showDisclosure?: boolean,
|
||||||
|
onClick: (event: SyntheticEvent<>) => void,
|
||||||
logoUrl: string,
|
logoUrl: string,
|
||||||
};
|
|};
|
||||||
|
|
||||||
function HeaderBlock({
|
const HeaderBlock = React.forwardRef<Props, any>(
|
||||||
showDisclosure,
|
({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => (
|
||||||
teamName,
|
<Wrapper>
|
||||||
subheading,
|
<Header justify="flex-start" align="center" ref={ref} {...rest}>
|
||||||
logoUrl,
|
<TeamLogo
|
||||||
...rest
|
alt={`${teamName} logo`}
|
||||||
}: Props) {
|
src={logoUrl}
|
||||||
return (
|
width={38}
|
||||||
<Header justify="flex-start" align="center" {...rest}>
|
height={38}
|
||||||
<TeamLogo alt={`${teamName} logo`} src={logoUrl} size="38px" />
|
/>
|
||||||
<Flex align="flex-start" column>
|
<Flex align="flex-start" column>
|
||||||
<TeamName showDisclosure>
|
<TeamName showDisclosure>
|
||||||
{teamName}{" "}
|
{teamName}{" "}
|
||||||
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
|
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
|
||||||
</TeamName>
|
</TeamName>
|
||||||
<Subheading>{subheading}</Subheading>
|
<Subheading>{subheading}</Subheading>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Header>
|
</Header>
|
||||||
);
|
</Wrapper>
|
||||||
}
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const StyledExpandedIcon = styled(ExpandedIcon)`
|
const StyledExpandedIcon = styled(ExpandedIcon)`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
@ -44,6 +46,7 @@ const Subheading = styled.div`
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
color: ${(props) => props.theme.sidebarText};
|
color: ${(props) => props.theme.sidebarText};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
@ -53,16 +56,20 @@ const TeamName = styled.div`
|
||||||
padding-right: 24px;
|
padding-right: 24px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: ${(props) => props.theme.text};
|
color: ${(props) => props.theme.text};
|
||||||
|
white-space: nowrap;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
`;
|
||||||
|
|
||||||
const Header = styled.button`
|
const Header = styled.button`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
position: relative;
|
|
||||||
background: none;
|
background: none;
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|
|
@ -0,0 +1,105 @@
|
||||||
|
// @flow
|
||||||
|
// ref: https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/NavLink.js
|
||||||
|
|
||||||
|
// This file is pulled almost 100% from react-router with the addition of one
|
||||||
|
// thing, automatic scroll to the active link. It's worth the copy paste because
|
||||||
|
// it avoids recalculating the link match again.
|
||||||
|
import { createLocation } from "history";
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
__RouterContext as RouterContext,
|
||||||
|
matchPath,
|
||||||
|
type Location,
|
||||||
|
} from "react-router";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||||
|
|
||||||
|
const resolveToLocation = (to, currentLocation) =>
|
||||||
|
typeof to === "function" ? to(currentLocation) : to;
|
||||||
|
|
||||||
|
const normalizeToLocation = (to, currentLocation) => {
|
||||||
|
return typeof to === "string"
|
||||||
|
? createLocation(to, null, null, currentLocation)
|
||||||
|
: to;
|
||||||
|
};
|
||||||
|
|
||||||
|
const joinClassnames = (...classnames) => {
|
||||||
|
return classnames.filter((i) => i).join(" ");
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {|
|
||||||
|
activeClassName?: String,
|
||||||
|
activeStyle?: Object,
|
||||||
|
className?: string,
|
||||||
|
exact?: boolean,
|
||||||
|
isActive?: any,
|
||||||
|
location?: Location,
|
||||||
|
strict?: boolean,
|
||||||
|
style?: Object,
|
||||||
|
to: string,
|
||||||
|
|};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A <Link> wrapper that knows if it's "active" or not.
|
||||||
|
*/
|
||||||
|
const NavLink = ({
|
||||||
|
"aria-current": ariaCurrent = "page",
|
||||||
|
activeClassName = "active",
|
||||||
|
activeStyle,
|
||||||
|
className: classNameProp,
|
||||||
|
exact,
|
||||||
|
isActive: isActiveProp,
|
||||||
|
location: locationProp,
|
||||||
|
strict,
|
||||||
|
style: styleProp,
|
||||||
|
to,
|
||||||
|
...rest
|
||||||
|
}: Props) => {
|
||||||
|
const linkRef = React.useRef();
|
||||||
|
const context = React.useContext(RouterContext);
|
||||||
|
const currentLocation = locationProp || context.location;
|
||||||
|
const toLocation = normalizeToLocation(
|
||||||
|
resolveToLocation(to, currentLocation),
|
||||||
|
currentLocation
|
||||||
|
);
|
||||||
|
const { pathname: path } = toLocation;
|
||||||
|
// Regex taken from: https://github.com/pillarjs/path-to-regexp/blob/master/index.js#L202
|
||||||
|
const escapedPath = path && path.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1");
|
||||||
|
|
||||||
|
const match = escapedPath
|
||||||
|
? matchPath(currentLocation.pathname, {
|
||||||
|
path: escapedPath,
|
||||||
|
exact,
|
||||||
|
strict,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const isActive = !!(isActiveProp
|
||||||
|
? isActiveProp(match, currentLocation)
|
||||||
|
: match);
|
||||||
|
|
||||||
|
const className = isActive
|
||||||
|
? joinClassnames(classNameProp, activeClassName)
|
||||||
|
: classNameProp;
|
||||||
|
const style = isActive ? { ...styleProp, ...activeStyle } : styleProp;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isActive && linkRef.current) {
|
||||||
|
scrollIntoView(linkRef.current, {
|
||||||
|
scrollMode: "if-needed",
|
||||||
|
behavior: "instant",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [linkRef, isActive]);
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
"aria-current": (isActive && ariaCurrent) || null,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
to: toLocation,
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Link ref={linkRef} {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NavLink;
|
|
@ -0,0 +1,28 @@
|
||||||
|
// @flow
|
||||||
|
import styled from "styled-components";
|
||||||
|
import ResizeHandle from "./ResizeHandle";
|
||||||
|
|
||||||
|
const ResizeBorder = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: -6px;
|
||||||
|
width: 12px;
|
||||||
|
cursor: ew-resize;
|
||||||
|
|
||||||
|
${(props) =>
|
||||||
|
props.$isResizing &&
|
||||||
|
`
|
||||||
|
${ResizeHandle} {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
${ResizeHandle} {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default ResizeBorder;
|
|
@ -0,0 +1,39 @@
|
||||||
|
// @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,7 +5,9 @@ import Flex from "components/Flex";
|
||||||
const Section = styled(Flex)`
|
const Section = styled(Flex)`
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin: 24px 8px;
|
margin: 20px 8px;
|
||||||
|
min-width: ${(props) => props.theme.sidebarMinWidth}px;
|
||||||
|
flex-shrink: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default Section;
|
export default Section;
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
// @flow
|
// @flow
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { withRouter, NavLink } from "react-router-dom";
|
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
|
||||||
import styled, { withTheme } from "styled-components";
|
import styled, { withTheme } from "styled-components";
|
||||||
|
import breakpoint from "styled-components-breakpoint";
|
||||||
|
import EventBoundary from "components/EventBoundary";
|
||||||
|
import NavLink from "./NavLink";
|
||||||
import { type Theme } from "types";
|
import { type Theme } from "types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -10,14 +13,17 @@ type Props = {
|
||||||
innerRef?: (?HTMLElement) => void,
|
innerRef?: (?HTMLElement) => void,
|
||||||
onClick?: (SyntheticEvent<>) => void,
|
onClick?: (SyntheticEvent<>) => void,
|
||||||
onMouseEnter?: (SyntheticEvent<>) => void,
|
onMouseEnter?: (SyntheticEvent<>) => void,
|
||||||
|
className?: string,
|
||||||
children?: React.Node,
|
children?: React.Node,
|
||||||
icon?: React.Node,
|
icon?: React.Node,
|
||||||
label?: React.Node,
|
label?: React.Node,
|
||||||
menu?: React.Node,
|
menu?: React.Node,
|
||||||
menuOpen?: boolean,
|
showActions?: boolean,
|
||||||
iconColor?: string,
|
iconColor?: string,
|
||||||
active?: boolean,
|
active?: boolean,
|
||||||
isActiveDrop?: boolean,
|
isActiveDrop?: boolean,
|
||||||
|
history: RouterHistory,
|
||||||
|
match: Match,
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
exact?: boolean,
|
exact?: boolean,
|
||||||
depth?: number,
|
depth?: number,
|
||||||
|
@ -33,13 +39,14 @@ function SidebarLink({
|
||||||
active,
|
active,
|
||||||
isActiveDrop,
|
isActiveDrop,
|
||||||
menu,
|
menu,
|
||||||
menuOpen,
|
showActions,
|
||||||
theme,
|
theme,
|
||||||
exact,
|
exact,
|
||||||
href,
|
href,
|
||||||
innerRef,
|
|
||||||
depth,
|
depth,
|
||||||
...rest
|
history,
|
||||||
|
match,
|
||||||
|
className,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const style = React.useMemo(() => {
|
const style = React.useMemo(() => {
|
||||||
return {
|
return {
|
||||||
|
@ -48,16 +55,20 @@ function SidebarLink({
|
||||||
}, [depth]);
|
}, [depth]);
|
||||||
|
|
||||||
const activeStyle = {
|
const activeStyle = {
|
||||||
color: theme.text,
|
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
color: theme.text,
|
||||||
background: theme.sidebarItemBackground,
|
background: theme.sidebarItemBackground,
|
||||||
...style,
|
...style,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const activeDropStyle = {
|
||||||
|
fontWeight: 600,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledNavLink
|
<Link
|
||||||
$isActiveDrop={isActiveDrop}
|
$isActiveDrop={isActiveDrop}
|
||||||
activeStyle={isActiveDrop ? undefined : activeStyle}
|
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
|
||||||
style={active ? activeStyle : style}
|
style={active ? activeStyle : style}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onMouseEnter={onMouseEnter}
|
onMouseEnter={onMouseEnter}
|
||||||
|
@ -65,12 +76,12 @@ function SidebarLink({
|
||||||
to={to}
|
to={to}
|
||||||
as={to ? undefined : href ? "a" : "div"}
|
as={to ? undefined : href ? "a" : "div"}
|
||||||
href={href}
|
href={href}
|
||||||
ref={innerRef}
|
className={className}
|
||||||
>
|
>
|
||||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||||
<Label>{label}</Label>
|
<Label>{label}</Label>
|
||||||
{menu && <Action menuOpen={menuOpen}>{menu}</Action>}
|
{menu && <Actions showActions={showActions}>{menu}</Actions>}
|
||||||
</StyledNavLink>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,42 +90,49 @@ const IconWrapper = styled.span`
|
||||||
margin-left: -4px;
|
margin-left: -4px;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Action = styled.span`
|
const Actions = styled(EventBoundary)`
|
||||||
display: ${(props) => (props.menuOpen ? "inline" : "none")};
|
display: ${(props) => (props.showActions ? "inline-flex" : "none")};
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
right: 4px;
|
right: 4px;
|
||||||
color: ${(props) => props.theme.textTertiary};
|
color: ${(props) => props.theme.textTertiary};
|
||||||
|
transition: opacity 50ms;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
opacity: 0.75;
|
color: ${(props) => props.theme.textSecondary};
|
||||||
|
fill: currentColor;
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
svg {
|
svg {
|
||||||
opacity: 1;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledNavLink = styled(NavLink)`
|
const Link = styled(NavLink)`
|
||||||
display: flex;
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
padding: 4px 16px;
|
padding: 6px 16px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
transition: background 50ms, color 50ms;
|
||||||
background: ${(props) =>
|
background: ${(props) =>
|
||||||
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
|
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
|
||||||
color: ${(props) =>
|
color: ${(props) =>
|
||||||
props.$isActiveDrop ? props.theme.white : props.theme.sidebarText};
|
props.$isActiveDrop ? props.theme.white : props.theme.sidebarText};
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
${(props) => (props.$isActiveDrop ? `fill: ${props.theme.white};` : "")}
|
${(props) => (props.$isActiveDrop ? `fill: ${props.theme.white};` : "")}
|
||||||
|
transition: fill 50ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -127,11 +145,20 @@ const StyledNavLink = styled(NavLink)`
|
||||||
background: ${(props) => props.theme.black05};
|
background: ${(props) => props.theme.black05};
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover,
|
||||||
> ${Action} {
|
&:active {
|
||||||
display: inline;
|
> ${Actions} {
|
||||||
|
display: inline-flex;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
${breakpoint("tablet")`
|
||||||
|
padding: 4px 16px;
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Label = styled.div`
|
const Label = styled.div`
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
// @flow
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export const id = "skip-nav";
|
||||||
|
|
||||||
|
export default function SkipNavContent() {
|
||||||
|
return <div id={id} />;
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
// @flow
|
||||||
|
import * as React from "react";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { id } from "components/SkipNavContent";
|
||||||
|
|
||||||
|
export default function SkipNavLink() {
|
||||||
|
return <Anchor href={`#${id}`}>Skip navigation</Anchor>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Anchor = styled.a`
|
||||||
|
border: 0;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
height: 1px;
|
||||||
|
width: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
padding: 1rem;
|
||||||
|
position: fixed;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
background: ${(props) => props.theme.background};
|
||||||
|
color: ${(props) => props.theme.text};
|
||||||
|
outline-color: ${(props) => props.theme.primary};
|
||||||
|
z-index: ${(props) => props.theme.depths.popover};
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
clip: auto;
|
||||||
|
}
|
||||||
|
`;
|
|
@ -110,7 +110,9 @@ class SocketProvider extends React.Component<Props> {
|
||||||
|
|
||||||
this.socket.on("unauthorized", (err) => {
|
this.socket.on("unauthorized", (err) => {
|
||||||
this.socket.authenticated = false;
|
this.socket.authenticated = false;
|
||||||
ui.showToast(err.message);
|
ui.showToast(err.message, {
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
// @flow
|
||||||
|
import { StarredIcon, UnstarredIcon } from "outline-icons";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import Document from "models/Document";
|
||||||
|
import NudeButton from "./NudeButton";
|
||||||
|
|
||||||
|
type Props = {|
|
||||||
|
document: Document,
|
||||||
|
size?: number,
|
||||||
|
|};
|
||||||
|
|
||||||
|
function Star({ size, document, ...rest }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const handleClick = React.useCallback(
|
||||||
|
(ev: SyntheticEvent<>) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
if (document.isStarred) {
|
||||||
|
document.unstar();
|
||||||
|
} else {
|
||||||
|
document.star();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[document]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={handleClick}
|
||||||
|
size={size}
|
||||||
|
aria-label={document.isStarred ? t("Unstar") : t("Star")}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{document.isStarred ? (
|
||||||
|
<AnimatedStar size={size} color="currentColor" />
|
||||||
|
) : (
|
||||||
|
<AnimatedStar size={size} color="currentColor" as={UnstarredIcon} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = styled(NudeButton)`
|
||||||
|
color: ${(props) => props.theme.text};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const AnimatedStar = styled(StarredIcon)`
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 100ms ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default Star;
|
|
@ -3,12 +3,15 @@ import * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { LabelText } from "components/Input";
|
import { LabelText } from "components/Input";
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
width?: number,
|
width?: number,
|
||||||
height?: number,
|
height?: number,
|
||||||
label?: string,
|
label?: string,
|
||||||
|
checked?: boolean,
|
||||||
|
disabled?: boolean,
|
||||||
|
onChange: (event: SyntheticInputEvent<HTMLInputElement>) => mixed,
|
||||||
id?: string,
|
id?: string,
|
||||||
};
|
|};
|
||||||
|
|
||||||
function Switch({ width = 38, height = 20, label, ...props }: Props) {
|
function Switch({ width = 38, height = 20, label, ...props }: Props) {
|
||||||
const component = (
|
const component = (
|
||||||
|
|
|
@ -2,12 +2,15 @@
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
|
||||||
const TeamLogo = styled.img`
|
const TeamLogo = styled.img`
|
||||||
width: ${(props) => props.size || "auto"};
|
width: ${(props) =>
|
||||||
height: ${(props) => props.size || "38px"};
|
props.width ? `${props.width}px` : props.size || "auto"};
|
||||||
|
height: ${(props) =>
|
||||||
|
props.height ? `${props.height}px` : props.size || "38px"};
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: ${(props) => props.theme.background};
|
background: ${(props) => props.theme.background};
|
||||||
border: 1px solid ${(props) => props.theme.divider};
|
border: 1px solid ${(props) => props.theme.divider};
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default TeamLogo;
|
export default TeamLogo;
|
||||||
|
|
|
@ -1,24 +1,8 @@
|
||||||
// @flow
|
// @flow
|
||||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||||
import format from "date-fns/format";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Tooltip from "components/Tooltip";
|
|
||||||
|
|
||||||
let callbacks = [];
|
const LocaleTime = React.lazy(() => import("components/LocaleTime"));
|
||||||
|
|
||||||
// This is a shared timer that fires every minute, used for
|
|
||||||
// updating all Time components across the page all at once.
|
|
||||||
setInterval(() => {
|
|
||||||
callbacks.forEach((cb) => cb());
|
|
||||||
}, 1000 * 60);
|
|
||||||
|
|
||||||
function eachMinute(fn) {
|
|
||||||
callbacks.push(fn);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
callbacks = callbacks.filter((cb) => cb !== fn);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
dateTime: string,
|
dateTime: string,
|
||||||
|
@ -28,44 +12,27 @@ type Props = {
|
||||||
shorten?: boolean,
|
shorten?: boolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
class Time extends React.Component<Props> {
|
function Time(props: Props) {
|
||||||
removeEachMinuteCallback: () => void;
|
let content = distanceInWordsToNow(props.dateTime, {
|
||||||
|
addSuffix: props.addSuffix,
|
||||||
|
});
|
||||||
|
|
||||||
componentDidMount() {
|
if (props.shorten) {
|
||||||
this.removeEachMinuteCallback = eachMinute(() => {
|
content = content
|
||||||
this.forceUpdate();
|
.replace("about", "")
|
||||||
});
|
.replace("less than a minute ago", "just now")
|
||||||
|
.replace("minute", "min");
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
return (
|
||||||
this.removeEachMinuteCallback();
|
<React.Suspense
|
||||||
}
|
fallback={
|
||||||
|
<time dateTime={props.dateTime}>{props.children || content}</time>
|
||||||
render() {
|
}
|
||||||
const { shorten, addSuffix } = this.props;
|
>
|
||||||
let content = distanceInWordsToNow(this.props.dateTime, {
|
<LocaleTime {...props} />
|
||||||
addSuffix,
|
</React.Suspense>
|
||||||
});
|
);
|
||||||
|
|
||||||
if (shorten) {
|
|
||||||
content = content
|
|
||||||
.replace("about", "")
|
|
||||||
.replace("less than a minute ago", "just now")
|
|
||||||
.replace("minute", "min");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
tooltip={format(this.props.dateTime, "MMMM Do, YYYY h:mm a")}
|
|
||||||
delay={this.props.tooltipDelay}
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
<time dateTime={this.props.dateTime}>
|
|
||||||
{this.props.children || content}
|
|
||||||
</time>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Time;
|
export default Time;
|
||||||
|
|
|
@ -1,30 +1,24 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { observer, inject } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import UiStore from "../../stores/UiStore";
|
|
||||||
import Toast from "./components/Toast";
|
import Toast from "./components/Toast";
|
||||||
|
import useStores from "hooks/useStores";
|
||||||
|
|
||||||
type Props = {
|
function Toasts() {
|
||||||
ui: UiStore,
|
const { ui } = useStores();
|
||||||
};
|
|
||||||
@observer
|
|
||||||
class Toasts extends React.Component<Props> {
|
|
||||||
render() {
|
|
||||||
const { ui } = this.props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List>
|
<List>
|
||||||
{ui.orderedToasts.map((toast) => (
|
{ui.orderedToasts.map((toast) => (
|
||||||
<Toast
|
<Toast
|
||||||
key={toast.id}
|
key={toast.id}
|
||||||
toast={toast}
|
toast={toast}
|
||||||
onRequestClose={() => ui.removeToast(toast.id)}
|
onRequestClose={() => ui.removeToast(toast.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const List = styled.ol`
|
const List = styled.ol`
|
||||||
|
@ -37,4 +31,4 @@ const List = styled.ol`
|
||||||
z-index: ${(props) => props.theme.depths.toasts};
|
z-index: ${(props) => props.theme.depths.toasts};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default inject("ui")(Toasts);
|
export default observer(Toasts);
|
||||||
|
|
|
@ -1,58 +1,61 @@
|
||||||
// @flow
|
// @flow
|
||||||
|
import { CheckboxIcon, InfoIcon, WarningIcon } from "outline-icons";
|
||||||
import { darken } from "polished";
|
import { darken } from "polished";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
import { fadeAndScaleIn, pulse } from "shared/styles/animations";
|
||||||
import type { Toast as TToast } from "types";
|
import type { Toast as TToast } from "types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onRequestClose: () => void,
|
onRequestClose: () => void,
|
||||||
closeAfterMs: number,
|
closeAfterMs?: number,
|
||||||
toast: TToast,
|
toast: TToast,
|
||||||
};
|
};
|
||||||
|
|
||||||
class Toast extends React.Component<Props> {
|
function Toast({ closeAfterMs = 3000, onRequestClose, toast }: Props) {
|
||||||
timeout: TimeoutID;
|
const timeout = React.useRef();
|
||||||
|
const [pulse, setPulse] = React.useState(false);
|
||||||
|
const { action, type = "info", reoccurring } = toast;
|
||||||
|
|
||||||
static defaultProps = {
|
React.useEffect(() => {
|
||||||
closeAfterMs: 3000,
|
timeout.current = setTimeout(onRequestClose, toast.timeout || closeAfterMs);
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount() {
|
return () => clearTimeout(timeout.current);
|
||||||
this.timeout = setTimeout(
|
}, [onRequestClose, toast, closeAfterMs]);
|
||||||
this.props.onRequestClose,
|
|
||||||
this.props.toast.timeout || this.props.closeAfterMs
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
React.useEffect(() => {
|
||||||
clearTimeout(this.timeout);
|
if (reoccurring) {
|
||||||
}
|
setPulse(reoccurring);
|
||||||
|
|
||||||
render() {
|
// must match animation time in css below vvv
|
||||||
const { toast, onRequestClose } = this.props;
|
setTimeout(() => setPulse(false), 250);
|
||||||
const { action } = toast;
|
}
|
||||||
const message =
|
}, [reoccurring]);
|
||||||
typeof toast.message === "string"
|
|
||||||
? toast.message
|
|
||||||
: toast.message.toString();
|
|
||||||
|
|
||||||
return (
|
const message =
|
||||||
<li>
|
typeof toast.message === "string"
|
||||||
<Container
|
? toast.message
|
||||||
onClick={action ? undefined : onRequestClose}
|
: toast.message.toString();
|
||||||
type={toast.type || "success"}
|
|
||||||
>
|
return (
|
||||||
<Message>{message}</Message>
|
<ListItem $pulse={pulse}>
|
||||||
{action && (
|
<Container
|
||||||
<Action type={toast.type || "success"} onClick={action.onClick}>
|
onClick={action ? undefined : onRequestClose}
|
||||||
{action.text}
|
type={toast.type || "success"}
|
||||||
</Action>
|
>
|
||||||
)}
|
{type === "info" && <InfoIcon color="currentColor" />}
|
||||||
</Container>
|
{type === "success" && <CheckboxIcon checked color="currentColor" />}
|
||||||
</li>
|
{type === "warning" ||
|
||||||
);
|
(type === "error" && <WarningIcon color="currentColor" />)}
|
||||||
}
|
<Message>{message}</Message>
|
||||||
|
{action && (
|
||||||
|
<Action type={toast.type || "success"} onClick={action.onClick}>
|
||||||
|
{action.text}
|
||||||
|
</Action>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Action = styled.span`
|
const Action = styled.span`
|
||||||
|
@ -71,11 +74,20 @@ const Action = styled.span`
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const ListItem = styled.li`
|
||||||
|
${(props) =>
|
||||||
|
props.$pulse &&
|
||||||
|
css`
|
||||||
|
animation: ${pulse} 250ms;
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
const Container = styled.div`
|
const Container = styled.div`
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
animation: ${fadeAndScaleIn} 100ms ease;
|
animation: ${fadeAndScaleIn} 100ms ease;
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
|
padding: 0 12px;
|
||||||
color: ${(props) => props.theme.toastText};
|
color: ${(props) => props.theme.toastText};
|
||||||
background: ${(props) => props.theme.toastBackground};
|
background: ${(props) => props.theme.toastBackground};
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
@ -89,7 +101,8 @@ const Container = styled.div`
|
||||||
|
|
||||||
const Message = styled.div`
|
const Message = styled.div`
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 10px 12px;
|
font-weight: 500;
|
||||||
|
padding: 10px 4px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default Toast;
|
export default Toast;
|
||||||
|
|
|
@ -3,14 +3,14 @@ import Tippy from "@tippy.js/react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
tooltip: React.Node,
|
tooltip: React.Node,
|
||||||
shortcut?: React.Node,
|
shortcut?: React.Node,
|
||||||
placement?: "top" | "bottom" | "left" | "right",
|
placement?: "top" | "bottom" | "left" | "right",
|
||||||
children: React.Node,
|
children: React.Node,
|
||||||
delay?: number,
|
delay?: number,
|
||||||
className?: string,
|
className?: string,
|
||||||
};
|
|};
|
||||||
|
|
||||||
class Tooltip extends React.Component<Props> {
|
class Tooltip extends React.Component<Props> {
|
||||||
render() {
|
render() {
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
// @flow
|
|
||||||
import styled from "styled-components";
|
|
||||||
|
|
||||||
const VisuallyHidden = styled("span")`
|
|
||||||
position: absolute !important;
|
|
||||||
height: 1px;
|
|
||||||
width: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
|
|
||||||
clip: rect(1px, 1px, 1px, 1px);
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default VisuallyHidden;
|
|
|
@ -1,5 +1,6 @@
|
||||||
// @flow
|
// @flow
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import Image from "components/Image";
|
||||||
import Frame from "./components/Frame";
|
import Frame from "./components/Frame";
|
||||||
|
|
||||||
const URL_REGEX = new RegExp("^https?://docs.google.com/document/(.*)$");
|
const URL_REGEX = new RegExp("^https?://docs.google.com/document/(.*)$");
|
||||||
|
@ -20,7 +21,7 @@ export default class GoogleDocs extends React.Component<Props> {
|
||||||
{...this.props}
|
{...this.props}
|
||||||
src={this.props.attrs.href.replace("/edit", "/preview")}
|
src={this.props.attrs.href.replace("/edit", "/preview")}
|
||||||
icon={
|
icon={
|
||||||
<img
|
<Image
|
||||||
src="/images/google-docs.png"
|
src="/images/google-docs.png"
|
||||||
alt="Google Docs Icon"
|
alt="Google Docs Icon"
|
||||||
width={16}
|
width={16}
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
// @flow
|
||||||
|
import * as React from "react";
|
||||||
|
import Image from "components/Image";
|
||||||
|
import Frame from "./components/Frame";
|
||||||
|
|
||||||
|
const URL_REGEX = new RegExp(
|
||||||
|
"^https://docs.google.com/drawings/d/(.*)/(edit|preview)(.*)$"
|
||||||
|
);
|
||||||
|
|
||||||
|
type Props = {|
|
||||||
|
attrs: {|
|
||||||
|
href: string,
|
||||||
|
matches: string[],
|
||||||
|
|},
|
||||||
|
|};
|
||||||
|
|
||||||
|
export default class GoogleDrawings extends React.Component<Props> {
|
||||||
|
static ENABLED = [URL_REGEX];
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<Frame
|
||||||
|
{...this.props}
|
||||||
|
src={this.props.attrs.href.replace("/edit", "/preview")}
|
||||||
|
icon={
|
||||||
|
<Image
|
||||||
|
src="/images/google-drawings.png"
|
||||||
|
alt="Google Drawings"
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
canonicalUrl={this.props.attrs.href.replace("/preview", "/edit")}
|
||||||
|
title="Google Drawings"
|
||||||
|
border
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||||
|
import GoogleDrawings from "./GoogleDrawings";
|
||||||
|
|
||||||
|
describe("GoogleDrawings", () => {
|
||||||
|
const match = GoogleDrawings.ENABLED[0];
|
||||||
|
test("to be enabled on share link", () => {
|
||||||
|
expect(
|
||||||
|
"https://docs.google.com/drawings/d/1zDLtJ4HSCnjGCGSoCgqGe3F8p6o7R8Vjk8MDR6dKf-U/edit".match(
|
||||||
|
match
|
||||||
|
)
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
"https://docs.google.com/drawings/d/1zDLtJ4HSCnjGCGSoCgqGe3F8p6o7R8Vjk8MDR6dKf-U/edit?usp=sharing".match(
|
||||||
|
match
|
||||||
|
)
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("to not be enabled elsewhere", () => {
|
||||||
|
expect(
|
||||||
|
"https://docs.google.com/drawings/d/e/2PACX-1vRtzIzEWN6svSrIYZq-kq2XZEN6WaOFXHbPKRLXNOFRlxLIdJg0Vo6RfretGqs9SzD-fUazLeS594Kw/pub?w=960&h=720".match(
|
||||||
|
match
|
||||||
|
)
|
||||||
|
).toBe(null);
|
||||||
|
expect("https://docs.google.com/drawings".match(match)).toBe(null);
|
||||||
|
expect("https://docs.google.com".match(match)).toBe(null);
|
||||||
|
expect("https://www.google.com".match(match)).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,38 @@
|
||||||
|
// @flow
|
||||||
|
import * as React from "react";
|
||||||
|
import Image from "components/Image";
|
||||||
|
import Frame from "./components/Frame";
|
||||||
|
|
||||||
|
const URL_REGEX = new RegExp(
|
||||||
|
"^https?://drive.google.com/file/d/(.*)/(preview|view).?usp=sharing$"
|
||||||
|
);
|
||||||
|
|
||||||
|
type Props = {|
|
||||||
|
attrs: {|
|
||||||
|
href: string,
|
||||||
|
matches: string[],
|
||||||
|
|},
|
||||||
|
|};
|
||||||
|
|
||||||
|
export default class GoogleDrive extends React.Component<Props> {
|
||||||
|
static ENABLED = [URL_REGEX];
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<Frame
|
||||||
|
src={this.props.attrs.href.replace("/view", "/preview")}
|
||||||
|
icon={
|
||||||
|
<Image
|
||||||
|
src="/images/google-drive.png"
|
||||||
|
alt="Google Drive Icon"
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title="Google Drive Embed"
|
||||||
|
canonicalUrl={this.props.attrs.href}
|
||||||
|
border
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||||
|
import GoogleDrive from "./GoogleDrive";
|
||||||
|
|
||||||
|
describe("GoogleDrive", () => {
|
||||||
|
const match = GoogleDrive.ENABLED[0];
|
||||||
|
test("to be enabled on share link", () => {
|
||||||
|
expect(
|
||||||
|
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/view?usp=sharing".match(
|
||||||
|
match
|
||||||
|
)
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/preview?usp=sharing".match(
|
||||||
|
match
|
||||||
|
)
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("to not be enabled elsewhere", () => {
|
||||||
|
expect(
|
||||||
|
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/view".match(
|
||||||
|
match
|
||||||
|
)
|
||||||
|
).toBe(null);
|
||||||
|
expect(
|
||||||
|
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/preview".match(
|
||||||
|
match
|
||||||
|
)
|
||||||
|
).toBe(null);
|
||||||
|
expect(
|
||||||
|
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/view?usp=restricted".match(
|
||||||
|
match
|
||||||
|
)
|
||||||
|
).toBe(null);
|
||||||
|
expect("https://drive.google.com/file".match(match)).toBe(null);
|
||||||
|
expect("https://drive.google.com".match(match)).toBe(null);
|
||||||
|
expect("https://www.google.com".match(match)).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,5 +1,6 @@
|
||||||
// @flow
|
// @flow
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import Image from "components/Image";
|
||||||
import Frame from "./components/Frame";
|
import Frame from "./components/Frame";
|
||||||
|
|
||||||
const URL_REGEX = new RegExp("^https?://docs.google.com/spreadsheets/d/(.*)$");
|
const URL_REGEX = new RegExp("^https?://docs.google.com/spreadsheets/d/(.*)$");
|
||||||
|
@ -20,7 +21,7 @@ export default class GoogleSlides extends React.Component<Props> {
|
||||||
{...this.props}
|
{...this.props}
|
||||||
src={this.props.attrs.href.replace("/edit", "/preview")}
|
src={this.props.attrs.href.replace("/edit", "/preview")}
|
||||||
icon={
|
icon={
|
||||||
<img
|
<Image
|
||||||
src="/images/google-sheets.png"
|
src="/images/google-sheets.png"
|
||||||
alt="Google Sheets Icon"
|
alt="Google Sheets Icon"
|
||||||
width={16}
|
width={16}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
// @flow
|
// @flow
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import Image from "components/Image";
|
||||||
import Frame from "./components/Frame";
|
import Frame from "./components/Frame";
|
||||||
|
|
||||||
const URL_REGEX = new RegExp("^https?://docs.google.com/presentation/d/(.*)$");
|
const URL_REGEX = new RegExp("^https?://docs.google.com/presentation/d/(.*)$");
|
||||||
|
@ -22,7 +23,7 @@ export default class GoogleSlides extends React.Component<Props> {
|
||||||
.replace("/edit", "/preview")
|
.replace("/edit", "/preview")
|
||||||
.replace("/pub", "/embed")}
|
.replace("/pub", "/embed")}
|
||||||
icon={
|
icon={
|
||||||
<img
|
<Image
|
||||||
src="/images/google-slides.png"
|
src="/images/google-slides.png"
|
||||||
alt="Google Slides Icon"
|
alt="Google Slides Icon"
|
||||||
width={16}
|
width={16}
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Frame from "./components/Frame";
|
import Frame from "./components/Frame";
|
||||||
|
|
||||||
const URL_REGEX = /^https:\/\/(?:realtimeboard|miro).com\/app\/board\/(.*)$/;
|
const URL_REGEX = /^https:\/\/(realtimeboard|miro).com\/app\/board\/(.*)$/;
|
||||||
|
|
||||||
type Props = {|
|
type Props = {|
|
||||||
attrs: {|
|
attrs: {|
|
||||||
|
@ -16,13 +16,15 @@ export default class RealtimeBoard extends React.Component<Props> {
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { matches } = this.props.attrs;
|
const { matches } = this.props.attrs;
|
||||||
const boardId = matches[1];
|
const domain = matches[1];
|
||||||
|
const boardId = matches[2];
|
||||||
|
const titleName = domain === "realtimeboard" ? "RealtimeBoard" : "Miro";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame
|
<Frame
|
||||||
{...this.props}
|
{...this.props}
|
||||||
src={`https://realtimeboard.com/app/embed/${boardId}`}
|
src={`https://${domain}.com/app/embed/${boardId}`}
|
||||||
title={`RealtimeBoard (${boardId})`}
|
title={`${titleName} (${boardId})`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,12 @@ describe("Miro", () => {
|
||||||
expect("https://miro.com/app/board/o9J_k0fwiss=".match(match)).toBeTruthy();
|
expect("https://miro.com/app/board/o9J_k0fwiss=".match(match)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("to extract the domain as part of the match for later use", () => {
|
||||||
|
expect(
|
||||||
|
"https://realtimeboard.com/app/board/o9J_k0fwiss=".match(match)[1]
|
||||||
|
).toBe("realtimeboard");
|
||||||
|
});
|
||||||
|
|
||||||
test("to not be enabled elsewhere", () => {
|
test("to not be enabled elsewhere", () => {
|
||||||
expect("https://miro.com".match(match)).toBe(null);
|
expect("https://miro.com".match(match)).toBe(null);
|
||||||
expect("https://realtimeboard.com".match(match)).toBe(null);
|
expect("https://realtimeboard.com".match(match)).toBe(null);
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Frame from "./components/Frame";
|
import Frame from "./components/Frame";
|
||||||
|
|
||||||
const URL_REGEX = /(http|https)?:\/\/(www\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^\/]*)\/videos\/|)(\d+)(?:|\/\?)/;
|
const URL_REGEX = /(http|https)?:\/\/(www\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|)(\d+)(?:|\/\?)/;
|
||||||
|
|
||||||
type Props = {|
|
type Props = {|
|
||||||
attrs: {|
|
attrs: {|
|
||||||
|
|
|
@ -6,6 +6,17 @@ import * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import Flex from "components/Flex";
|
import Flex from "components/Flex";
|
||||||
|
|
||||||
|
// This wrapper allows us to pass non-standard HTML attributes through to the DOM element
|
||||||
|
// https://www.styled-components.com/docs/basics#passed-props
|
||||||
|
const Iframe = (props) => <iframe title="Embed" {...props} />;
|
||||||
|
|
||||||
|
const StyledIframe = styled(Iframe)`
|
||||||
|
border: 1px solid;
|
||||||
|
border-color: ${(props) => props.theme.embedBorder};
|
||||||
|
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
|
||||||
|
display: block;
|
||||||
|
`;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
src?: string,
|
src?: string,
|
||||||
border?: boolean,
|
border?: boolean,
|
||||||
|
@ -129,17 +140,6 @@ const Bar = styled(Flex)`
|
||||||
user-select: none;
|
user-select: none;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// This wrapper allows us to pass non-standard HTML attributes through to the DOM element
|
|
||||||
// https://www.styled-components.com/docs/basics#passed-props
|
|
||||||
const Iframe = (props) => <iframe {...props} />;
|
|
||||||
|
|
||||||
const StyledIframe = styled(Iframe)`
|
|
||||||
border: 1px solid;
|
|
||||||
border-color: ${(props) => props.theme.embedBorder};
|
|
||||||
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
|
|
||||||
display: block;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default React.forwardRef<Props, typeof Frame>((props, ref) => (
|
export default React.forwardRef<Props, typeof Frame>((props, ref) => (
|
||||||
<Frame {...props} forwardedRef={ref} />
|
<Frame {...props} forwardedRef={ref} />
|
||||||
));
|
));
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
// @flow
|
// @flow
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
import Image from "components/Image";
|
||||||
import Abstract from "./Abstract";
|
import Abstract from "./Abstract";
|
||||||
import Airtable from "./Airtable";
|
import Airtable from "./Airtable";
|
||||||
import ClickUp from "./ClickUp";
|
import ClickUp from "./ClickUp";
|
||||||
|
@ -9,6 +10,8 @@ import Figma from "./Figma";
|
||||||
import Framer from "./Framer";
|
import Framer from "./Framer";
|
||||||
import Gist from "./Gist";
|
import Gist from "./Gist";
|
||||||
import GoogleDocs from "./GoogleDocs";
|
import GoogleDocs from "./GoogleDocs";
|
||||||
|
import GoogleDrawings from "./GoogleDrawings";
|
||||||
|
import GoogleDrive from "./GoogleDrive";
|
||||||
import GoogleSheets from "./GoogleSheets";
|
import GoogleSheets from "./GoogleSheets";
|
||||||
import GoogleSlides from "./GoogleSlides";
|
import GoogleSlides from "./GoogleSlides";
|
||||||
import InVision from "./InVision";
|
import InVision from "./InVision";
|
||||||
|
@ -37,7 +40,7 @@ function matcher(Component) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const Img = styled.img`
|
const Img = styled(Image)`
|
||||||
margin: 4px;
|
margin: 4px;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
@ -93,6 +96,20 @@ export default [
|
||||||
component: Gist,
|
component: Gist,
|
||||||
matcher: matcher(Gist),
|
matcher: matcher(Gist),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Google Drawings",
|
||||||
|
keywords: "drawings",
|
||||||
|
icon: () => <Img src="/images/google-drawings.png" />,
|
||||||
|
component: GoogleDrawings,
|
||||||
|
matcher: matcher(GoogleDrawings),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Google Drive",
|
||||||
|
keywords: "drive",
|
||||||
|
icon: () => <Img src="/images/google-drive.png" />,
|
||||||
|
component: GoogleDrive,
|
||||||
|
matcher: matcher(GoogleDrive),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Google Docs",
|
title: "Google Docs",
|
||||||
icon: () => <Img src="/images/google-docs.png" />,
|
icon: () => <Img src="/images/google-docs.png" />,
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
// @flow
|
||||||
|
import invariant from "invariant";
|
||||||
|
import useStores from "./useStores";
|
||||||
|
|
||||||
|
export default function useCurrentTeam() {
|
||||||
|
const { auth } = useStores();
|
||||||
|
invariant(auth.team, "team required");
|
||||||
|
return auth.team;
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
// @flow
|
||||||
|
import useStores from "./useStores";
|
||||||
|
|
||||||
|
export default function useUserLocale() {
|
||||||
|
const { auth } = useStores();
|
||||||
|
|
||||||
|
if (!auth.user || !auth.user.language) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return auth.user.language.split("_")[0];
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
// @flow
|
||||||
|
import { debounce } from "lodash";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export default function useWindowSize() {
|
||||||
|
const [windowSize, setWindowSize] = React.useState({
|
||||||
|
width: undefined,
|
||||||
|
height: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// Handler to call on window resize
|
||||||
|
const handleResize = debounce(() => {
|
||||||
|
// Set window width/height to state
|
||||||
|
setWindowSize({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Add event listener
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
// Call handler right away so state gets updated with initial window size
|
||||||
|
handleResize();
|
||||||
|
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return windowSize;
|
||||||
|
}
|
24
app/index.js
24
app/index.js
|
@ -1,12 +1,12 @@
|
||||||
// @flow
|
// @flow
|
||||||
import "mobx-react-lite/batchingForReactDom";
|
|
||||||
import "focus-visible";
|
import "focus-visible";
|
||||||
|
import { createBrowserHistory } from "history";
|
||||||
import { Provider } from "mobx-react";
|
import { Provider } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { DndProvider } from "react-dnd";
|
import { DndProvider } from "react-dnd";
|
||||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||||
import { render } from "react-dom";
|
import { render } from "react-dom";
|
||||||
import { BrowserRouter as Router } from "react-router-dom";
|
import { Router } from "react-router-dom";
|
||||||
import { initI18n } from "shared/i18n";
|
import { initI18n } from "shared/i18n";
|
||||||
import stores from "stores";
|
import stores from "stores";
|
||||||
import ErrorBoundary from "components/ErrorBoundary";
|
import ErrorBoundary from "components/ErrorBoundary";
|
||||||
|
@ -15,18 +15,24 @@ import Theme from "components/Theme";
|
||||||
import Toasts from "components/Toasts";
|
import Toasts from "components/Toasts";
|
||||||
import Routes from "./routes";
|
import Routes from "./routes";
|
||||||
import env from "env";
|
import env from "env";
|
||||||
|
import { initSentry } from "utils/sentry";
|
||||||
|
|
||||||
initI18n();
|
initI18n();
|
||||||
|
|
||||||
const element = document.getElementById("root");
|
const element = document.getElementById("root");
|
||||||
|
const history = createBrowserHistory();
|
||||||
|
|
||||||
|
if (env.SENTRY_DSN) {
|
||||||
|
initSentry(history);
|
||||||
|
}
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
render(
|
render(
|
||||||
<ErrorBoundary>
|
<Provider {...stores}>
|
||||||
<Provider {...stores}>
|
<Theme>
|
||||||
<Theme>
|
<ErrorBoundary>
|
||||||
<DndProvider backend={HTML5Backend}>
|
<DndProvider backend={HTML5Backend}>
|
||||||
<Router>
|
<Router history={history}>
|
||||||
<>
|
<>
|
||||||
<ScrollToTop>
|
<ScrollToTop>
|
||||||
<Routes />
|
<Routes />
|
||||||
|
@ -35,9 +41,9 @@ if (element) {
|
||||||
</>
|
</>
|
||||||
</Router>
|
</Router>
|
||||||
</DndProvider>
|
</DndProvider>
|
||||||
</Theme>
|
</ErrorBoundary>
|
||||||
</Provider>
|
</Theme>
|
||||||
</ErrorBoundary>,
|
</Provider>,
|
||||||
element
|
element
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,134 +1,128 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { observable } from "mobx";
|
import { observer } from "mobx-react";
|
||||||
import { inject, observer } from "mobx-react";
|
|
||||||
import { SunIcon, MoonIcon } from "outline-icons";
|
import { SunIcon, MoonIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { withTranslation, type TFunction } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import AuthStore from "stores/AuthStore";
|
|
||||||
import UiStore from "stores/UiStore";
|
|
||||||
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
|
|
||||||
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
|
|
||||||
import Flex from "components/Flex";
|
|
||||||
import Modal from "components/Modal";
|
|
||||||
import {
|
import {
|
||||||
developers,
|
developers,
|
||||||
changelog,
|
changelog,
|
||||||
githubIssuesUrl,
|
githubIssuesUrl,
|
||||||
mailToUrl,
|
mailToUrl,
|
||||||
settings,
|
settings,
|
||||||
} from "../../shared/utils/routeHelpers";
|
} from "shared/utils/routeHelpers";
|
||||||
|
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
|
||||||
|
import ContextMenu from "components/ContextMenu";
|
||||||
|
import MenuItem, { MenuAnchor } from "components/ContextMenu/MenuItem";
|
||||||
|
import Separator from "components/ContextMenu/Separator";
|
||||||
|
import Flex from "components/Flex";
|
||||||
|
import Modal from "components/Modal";
|
||||||
|
import useStores from "hooks/useStores";
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
label: React.Node,
|
children: (props: any) => React.Node,
|
||||||
ui: UiStore,
|
|};
|
||||||
auth: AuthStore,
|
|
||||||
t: TFunction,
|
|
||||||
};
|
|
||||||
|
|
||||||
@observer
|
const AppearanceMenu = React.forwardRef((props, ref) => {
|
||||||
class AccountMenu extends React.Component<Props> {
|
const { ui } = useStores();
|
||||||
@observable keyboardShortcutsOpen: boolean = false;
|
const { t } = useTranslation();
|
||||||
|
const menu = useMenuState();
|
||||||
|
|
||||||
handleLogout = () => {
|
return (
|
||||||
this.props.auth.logout();
|
<>
|
||||||
};
|
<MenuButton ref={ref} {...menu} {...props}>
|
||||||
|
{(props) => (
|
||||||
handleOpenKeyboardShortcuts = () => {
|
<MenuAnchor {...props}>
|
||||||
this.keyboardShortcutsOpen = true;
|
<ChangeTheme justify="space-between">
|
||||||
};
|
{t("Appearance")}
|
||||||
|
{ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />}
|
||||||
handleCloseKeyboardShortcuts = () => {
|
</ChangeTheme>
|
||||||
this.keyboardShortcutsOpen = false;
|
</MenuAnchor>
|
||||||
};
|
)}
|
||||||
|
</MenuButton>
|
||||||
render() {
|
<ContextMenu {...menu} aria-label={t("Appearance")}>
|
||||||
const { ui, t } = this.props;
|
<MenuItem
|
||||||
|
{...menu}
|
||||||
return (
|
onClick={() => ui.setTheme("system")}
|
||||||
<>
|
selected={ui.theme === "system"}
|
||||||
<Modal
|
|
||||||
isOpen={this.keyboardShortcutsOpen}
|
|
||||||
onRequestClose={this.handleCloseKeyboardShortcuts}
|
|
||||||
title={t("Keyboard shortcuts")}
|
|
||||||
>
|
>
|
||||||
<KeyboardShortcuts />
|
{t("System")}
|
||||||
</Modal>
|
</MenuItem>
|
||||||
<DropdownMenu
|
<MenuItem
|
||||||
style={{ marginRight: 10, marginTop: -10 }}
|
{...menu}
|
||||||
label={this.props.label}
|
onClick={() => ui.setTheme("light")}
|
||||||
|
selected={ui.theme === "light"}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem as={Link} to={settings()}>
|
{t("Light")}
|
||||||
{t("Settings")}
|
</MenuItem>
|
||||||
</DropdownMenuItem>
|
<MenuItem
|
||||||
<DropdownMenuItem onClick={this.handleOpenKeyboardShortcuts}>
|
{...menu}
|
||||||
{t("Keyboard shortcuts")}
|
onClick={() => ui.setTheme("dark")}
|
||||||
</DropdownMenuItem>
|
selected={ui.theme === "dark"}
|
||||||
<DropdownMenuItem href={developers()} target="_blank">
|
>
|
||||||
{t("API documentation")}
|
{t("Dark")}
|
||||||
</DropdownMenuItem>
|
</MenuItem>
|
||||||
<hr />
|
</ContextMenu>
|
||||||
<DropdownMenuItem href={changelog()} target="_blank">
|
</>
|
||||||
{t("Changelog")}
|
);
|
||||||
</DropdownMenuItem>
|
});
|
||||||
<DropdownMenuItem href={mailToUrl()} target="_blank">
|
|
||||||
{t("Send us feedback")}
|
function AccountMenu(props: Props) {
|
||||||
</DropdownMenuItem>
|
const menu = useMenuState({
|
||||||
<DropdownMenuItem href={githubIssuesUrl()} target="_blank">
|
placement: "bottom-start",
|
||||||
{t("Report a bug")}
|
modal: true,
|
||||||
</DropdownMenuItem>
|
});
|
||||||
<hr />
|
const { auth } = useStores();
|
||||||
<DropdownMenu
|
const { t } = useTranslation();
|
||||||
position="right"
|
const [keyboardShortcutsOpen, setKeyboardShortcutsOpen] = React.useState(
|
||||||
style={{
|
false
|
||||||
left: 170,
|
);
|
||||||
position: "relative",
|
|
||||||
top: -40,
|
return (
|
||||||
}}
|
<>
|
||||||
label={
|
<Modal
|
||||||
<DropdownMenuItem>
|
isOpen={keyboardShortcutsOpen}
|
||||||
<ChangeTheme justify="space-between">
|
onRequestClose={() => setKeyboardShortcutsOpen(false)}
|
||||||
{t("Appearance")}
|
title={t("Keyboard shortcuts")}
|
||||||
{ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />}
|
>
|
||||||
</ChangeTheme>
|
<KeyboardShortcuts />
|
||||||
</DropdownMenuItem>
|
</Modal>
|
||||||
}
|
<MenuButton {...menu}>{props.children}</MenuButton>
|
||||||
hover
|
<ContextMenu {...menu} aria-label={t("Account")}>
|
||||||
>
|
<MenuItem {...menu} as={Link} to={settings()}>
|
||||||
<DropdownMenuItem
|
{t("Settings")}
|
||||||
onClick={() => ui.setTheme("system")}
|
</MenuItem>
|
||||||
selected={ui.theme === "system"}
|
<MenuItem {...menu} onClick={() => setKeyboardShortcutsOpen(true)}>
|
||||||
>
|
{t("Keyboard shortcuts")}
|
||||||
{t("System")}
|
</MenuItem>
|
||||||
</DropdownMenuItem>
|
<MenuItem {...menu} href={developers()} target="_blank">
|
||||||
<DropdownMenuItem
|
{t("API documentation")}
|
||||||
onClick={() => ui.setTheme("light")}
|
</MenuItem>
|
||||||
selected={ui.theme === "light"}
|
<Separator {...menu} />
|
||||||
>
|
<MenuItem {...menu} href={changelog()} target="_blank">
|
||||||
{t("Light")}
|
{t("Changelog")}
|
||||||
</DropdownMenuItem>
|
</MenuItem>
|
||||||
<DropdownMenuItem
|
<MenuItem {...menu} href={mailToUrl()} target="_blank">
|
||||||
onClick={() => ui.setTheme("dark")}
|
{t("Send us feedback")}
|
||||||
selected={ui.theme === "dark"}
|
</MenuItem>
|
||||||
>
|
<MenuItem {...menu} href={githubIssuesUrl()} target="_blank">
|
||||||
{t("Dark")}
|
{t("Report a bug")}
|
||||||
</DropdownMenuItem>
|
</MenuItem>
|
||||||
</DropdownMenu>
|
<Separator {...menu} />
|
||||||
<hr />
|
<MenuItem {...menu} as={AppearanceMenu} />
|
||||||
<DropdownMenuItem onClick={this.handleLogout}>
|
<Separator {...menu} />
|
||||||
{t("Log out")}
|
<MenuItem {...menu} onClick={auth.logout}>
|
||||||
</DropdownMenuItem>
|
{t("Log out")}
|
||||||
</DropdownMenu>
|
</MenuItem>
|
||||||
</>
|
</ContextMenu>
|
||||||
);
|
</>
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChangeTheme = styled(Flex)`
|
const ChangeTheme = styled(Flex)`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default withTranslation()<AccountMenu>(
|
export default observer(AccountMenu);
|
||||||
inject("ui", "auth")(AccountMenu)
|
|
||||||
);
|
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
// @flow
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMenuState } from "reakit/Menu";
|
||||||
|
import ContextMenu from "components/ContextMenu";
|
||||||
|
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||||
|
import Template from "components/ContextMenu/Template";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
path: Array<any>,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BreadcrumbMenu({ path }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const menu = useMenuState({
|
||||||
|
modal: true,
|
||||||
|
placement: "bottom",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<OverflowMenuButton aria-label={t("Show path to document")} {...menu} />
|
||||||
|
<ContextMenu {...menu} aria-label={t("Path to document")}>
|
||||||
|
<Template
|
||||||
|
{...menu}
|
||||||
|
items={path.map((item) => ({
|
||||||
|
title: item.title,
|
||||||
|
to: item.url,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
// @flow
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMenuState } from "reakit/Menu";
|
||||||
|
import ContextMenu from "components/ContextMenu";
|
||||||
|
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||||
|
import Template from "components/ContextMenu/Template";
|
||||||
|
|
||||||
|
type Props = {|
|
||||||
|
onMembers: () => void,
|
||||||
|
onRemove: () => void,
|
||||||
|
|};
|
||||||
|
|
||||||
|
function CollectionGroupMemberMenu({ onMembers, onRemove }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const menu = useMenuState({ modal: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||||
|
<ContextMenu {...menu} aria-label={t("Group member options")}>
|
||||||
|
<Template
|
||||||
|
{...menu}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
title: t("Members"),
|
||||||
|
onClick: onMembers,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "separator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("Remove"),
|
||||||
|
onClick: onRemove,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(CollectionGroupMemberMenu);
|
|
@ -1,221 +1,221 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { observable } from "mobx";
|
import { observer } from "mobx-react";
|
||||||
import { inject, observer } from "mobx-react";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { withTranslation, type TFunction } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
import DocumentsStore from "stores/DocumentsStore";
|
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||||
import PoliciesStore from "stores/PoliciesStore";
|
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||||
import UiStore from "stores/UiStore";
|
|
||||||
import Collection from "models/Collection";
|
import Collection from "models/Collection";
|
||||||
import CollectionDelete from "scenes/CollectionDelete";
|
import CollectionDelete from "scenes/CollectionDelete";
|
||||||
import CollectionEdit from "scenes/CollectionEdit";
|
import CollectionEdit from "scenes/CollectionEdit";
|
||||||
import CollectionExport from "scenes/CollectionExport";
|
import CollectionExport from "scenes/CollectionExport";
|
||||||
import CollectionMembers from "scenes/CollectionMembers";
|
import CollectionMembers from "scenes/CollectionMembers";
|
||||||
import { DropdownMenu } from "components/DropdownMenu";
|
import ContextMenu from "components/ContextMenu";
|
||||||
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
|
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||||
|
import Template from "components/ContextMenu/Template";
|
||||||
import Modal from "components/Modal";
|
import Modal from "components/Modal";
|
||||||
import VisuallyHidden from "components/VisuallyHidden";
|
import useStores from "hooks/useStores";
|
||||||
import getDataTransferFiles from "utils/getDataTransferFiles";
|
import getDataTransferFiles from "utils/getDataTransferFiles";
|
||||||
import { newDocumentUrl } from "utils/routeHelpers";
|
import { newDocumentUrl } from "utils/routeHelpers";
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
position?: "left" | "right" | "center",
|
|
||||||
ui: UiStore,
|
|
||||||
policies: PoliciesStore,
|
|
||||||
documents: DocumentsStore,
|
|
||||||
collection: Collection,
|
collection: Collection,
|
||||||
history: RouterHistory,
|
placement?: string,
|
||||||
|
modal?: boolean,
|
||||||
|
label?: (any) => React.Node,
|
||||||
onOpen?: () => void,
|
onOpen?: () => void,
|
||||||
onClose?: () => void,
|
onClose?: () => void,
|
||||||
t: TFunction,
|
|};
|
||||||
};
|
|
||||||
|
|
||||||
@observer
|
function CollectionMenu({
|
||||||
class CollectionMenu extends React.Component<Props> {
|
collection,
|
||||||
file: ?HTMLInputElement;
|
label,
|
||||||
@observable showCollectionMembers = false;
|
modal = true,
|
||||||
@observable showCollectionEdit = false;
|
placement,
|
||||||
@observable showCollectionDelete = false;
|
onOpen,
|
||||||
@observable showCollectionExport = false;
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const menu = useMenuState({ modal, placement });
|
||||||
|
const [renderModals, setRenderModals] = React.useState(false);
|
||||||
|
const { ui, documents, policies } = useStores();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
onNewDocument = (ev: SyntheticEvent<>) => {
|
const file = React.useRef<?HTMLInputElement>();
|
||||||
ev.preventDefault();
|
const [showCollectionMembers, setShowCollectionMembers] = React.useState(
|
||||||
const { collection } = this.props;
|
false
|
||||||
this.props.history.push(newDocumentUrl(collection.id));
|
);
|
||||||
};
|
const [showCollectionEdit, setShowCollectionEdit] = React.useState(false);
|
||||||
|
const [showCollectionDelete, setShowCollectionDelete] = React.useState(false);
|
||||||
|
const [showCollectionExport, setShowCollectionExport] = React.useState(false);
|
||||||
|
|
||||||
onImportDocument = (ev: SyntheticEvent<>) => {
|
const handleOpen = React.useCallback(() => {
|
||||||
ev.preventDefault();
|
setRenderModals(true);
|
||||||
ev.stopPropagation();
|
if (onOpen) {
|
||||||
|
onOpen();
|
||||||
// simulate a click on the file upload input element
|
|
||||||
if (this.file) this.file.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
onFilePicked = async (ev: SyntheticEvent<>) => {
|
|
||||||
const files = getDataTransferFiles(ev);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const file = files[0];
|
|
||||||
const document = await this.props.documents.import(
|
|
||||||
file,
|
|
||||||
null,
|
|
||||||
this.props.collection.id,
|
|
||||||
{ publish: true }
|
|
||||||
);
|
|
||||||
this.props.history.push(document.url);
|
|
||||||
} catch (err) {
|
|
||||||
this.props.ui.showToast(err.message);
|
|
||||||
}
|
}
|
||||||
};
|
}, [onOpen]);
|
||||||
|
|
||||||
handleEditCollectionOpen = (ev: SyntheticEvent<>) => {
|
const handleNewDocument = React.useCallback(
|
||||||
ev.preventDefault();
|
(ev: SyntheticEvent<>) => {
|
||||||
this.showCollectionEdit = true;
|
ev.preventDefault();
|
||||||
};
|
history.push(newDocumentUrl(collection.id));
|
||||||
|
},
|
||||||
|
[history, collection.id]
|
||||||
|
);
|
||||||
|
|
||||||
handleEditCollectionClose = () => {
|
const handleImportDocument = React.useCallback(
|
||||||
this.showCollectionEdit = false;
|
(ev: SyntheticEvent<>) => {
|
||||||
};
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
handleDeleteCollectionOpen = (ev: SyntheticEvent<>) => {
|
// simulate a click on the file upload input element
|
||||||
ev.preventDefault();
|
if (file.current) {
|
||||||
this.showCollectionDelete = true;
|
file.current.click();
|
||||||
};
|
}
|
||||||
|
},
|
||||||
|
[file]
|
||||||
|
);
|
||||||
|
|
||||||
handleDeleteCollectionClose = () => {
|
const handleFilePicked = React.useCallback(
|
||||||
this.showCollectionDelete = false;
|
async (ev: SyntheticEvent<>) => {
|
||||||
};
|
const files = getDataTransferFiles(ev);
|
||||||
|
|
||||||
handleExportCollectionOpen = (ev: SyntheticEvent<>) => {
|
try {
|
||||||
ev.preventDefault();
|
const file = files[0];
|
||||||
this.showCollectionExport = true;
|
const document = await documents.import(
|
||||||
};
|
file,
|
||||||
|
null,
|
||||||
|
this.props.collection.id,
|
||||||
|
{ publish: true }
|
||||||
|
);
|
||||||
|
history.push(document.url);
|
||||||
|
} catch (err) {
|
||||||
|
ui.showToast(err.message, {
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[history, ui, documents]
|
||||||
|
);
|
||||||
|
|
||||||
handleExportCollectionClose = () => {
|
const can = policies.abilities(collection.id);
|
||||||
this.showCollectionExport = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMembersModalOpen = (ev: SyntheticEvent<>) => {
|
return (
|
||||||
ev.preventDefault();
|
<>
|
||||||
this.showCollectionMembers = true;
|
<VisuallyHidden>
|
||||||
};
|
<input
|
||||||
|
type="file"
|
||||||
handleMembersModalClose = () => {
|
ref={file}
|
||||||
this.showCollectionMembers = false;
|
onChange={handleFilePicked}
|
||||||
};
|
onClick={(ev) => ev.stopPropagation()}
|
||||||
|
accept={documents.importFileTypes.join(", ")}
|
||||||
render() {
|
tabIndex="-1"
|
||||||
const {
|
/>
|
||||||
policies,
|
</VisuallyHidden>
|
||||||
documents,
|
{label ? (
|
||||||
collection,
|
<MenuButton {...menu}>{label}</MenuButton>
|
||||||
position,
|
) : (
|
||||||
onOpen,
|
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||||
onClose,
|
)}
|
||||||
t,
|
<ContextMenu
|
||||||
} = this.props;
|
{...menu}
|
||||||
const can = policies.abilities(collection.id);
|
onOpen={handleOpen}
|
||||||
|
onClose={onClose}
|
||||||
return (
|
aria-label={t("Collection")}
|
||||||
<>
|
>
|
||||||
<VisuallyHidden>
|
<Template
|
||||||
<input
|
{...menu}
|
||||||
type="file"
|
items={[
|
||||||
ref={(ref) => (this.file = ref)}
|
{
|
||||||
onChange={this.onFilePicked}
|
title: t("New document"),
|
||||||
onClick={(ev) => ev.stopPropagation()}
|
visible: can.update,
|
||||||
accept={documents.importFileTypes.join(", ")}
|
onClick: handleNewDocument,
|
||||||
/>
|
},
|
||||||
</VisuallyHidden>
|
{
|
||||||
|
title: t("Import document"),
|
||||||
<Modal
|
visible: can.update,
|
||||||
title={t("Collection permissions")}
|
onClick: handleImportDocument,
|
||||||
onRequestClose={this.handleMembersModalClose}
|
},
|
||||||
isOpen={this.showCollectionMembers}
|
{
|
||||||
>
|
type: "separator",
|
||||||
<CollectionMembers
|
},
|
||||||
collection={collection}
|
{
|
||||||
onSubmit={this.handleMembersModalClose}
|
title: `${t("Edit")}…`,
|
||||||
handleEditCollectionOpen={this.handleEditCollectionOpen}
|
visible: can.update,
|
||||||
onEdit={this.handleEditCollectionOpen}
|
onClick: () => setShowCollectionEdit(true),
|
||||||
/>
|
},
|
||||||
</Modal>
|
{
|
||||||
<DropdownMenu onOpen={onOpen} onClose={onClose} position={position}>
|
title: `${t("Permissions")}…`,
|
||||||
<DropdownMenuItems
|
visible: can.update,
|
||||||
items={[
|
onClick: () => setShowCollectionMembers(true),
|
||||||
{
|
},
|
||||||
title: t("New document"),
|
{
|
||||||
visible: !!(collection && can.update),
|
title: `${t("Export")}…`,
|
||||||
onClick: this.onNewDocument,
|
visible: !!(collection && can.export),
|
||||||
},
|
onClick: () => setShowCollectionExport(true),
|
||||||
{
|
},
|
||||||
title: t("Import document"),
|
{
|
||||||
visible: !!(collection && can.update),
|
type: "separator",
|
||||||
onClick: this.onImportDocument,
|
},
|
||||||
},
|
{
|
||||||
{
|
type: "separator",
|
||||||
type: "separator",
|
},
|
||||||
},
|
{
|
||||||
{
|
title: `${t("Delete")}…`,
|
||||||
title: `${t("Edit")}…`,
|
visible: !!(collection && can.delete),
|
||||||
visible: !!(collection && can.update),
|
onClick: () => setShowCollectionDelete(true),
|
||||||
onClick: this.handleEditCollectionOpen,
|
},
|
||||||
},
|
]}
|
||||||
{
|
/>
|
||||||
title: `${t("Permissions")}…`,
|
</ContextMenu>
|
||||||
visible: !!(collection && can.update),
|
{renderModals && (
|
||||||
onClick: this.handleMembersModalOpen,
|
<>
|
||||||
},
|
<Modal
|
||||||
{
|
title={t("Collection permissions")}
|
||||||
title: `${t("Export")}…`,
|
onRequestClose={() => setShowCollectionMembers(false)}
|
||||||
visible: !!(collection && can.export),
|
isOpen={showCollectionMembers}
|
||||||
onClick: this.handleExportCollectionOpen,
|
>
|
||||||
},
|
<CollectionMembers
|
||||||
{
|
collection={collection}
|
||||||
title: `${t("Delete")}…`,
|
onSubmit={() => setShowCollectionMembers(false)}
|
||||||
visible: !!(collection && can.delete),
|
onEdit={() => setShowCollectionEdit(true)}
|
||||||
onClick: this.handleDeleteCollectionOpen,
|
/>
|
||||||
},
|
</Modal>
|
||||||
]}
|
<Modal
|
||||||
/>
|
title={t("Edit collection")}
|
||||||
</DropdownMenu>
|
isOpen={showCollectionEdit}
|
||||||
<Modal
|
onRequestClose={() => setShowCollectionEdit(false)}
|
||||||
title={t("Edit collection")}
|
>
|
||||||
isOpen={this.showCollectionEdit}
|
<CollectionEdit
|
||||||
onRequestClose={this.handleEditCollectionClose}
|
onSubmit={() => setShowCollectionEdit(false)}
|
||||||
>
|
collection={collection}
|
||||||
<CollectionEdit
|
/>
|
||||||
onSubmit={this.handleEditCollectionClose}
|
</Modal>
|
||||||
collection={collection}
|
<Modal
|
||||||
/>
|
title={t("Delete collection")}
|
||||||
</Modal>
|
isOpen={showCollectionDelete}
|
||||||
<Modal
|
onRequestClose={() => setShowCollectionDelete(false)}
|
||||||
title={t("Delete collection")}
|
>
|
||||||
isOpen={this.showCollectionDelete}
|
<CollectionDelete
|
||||||
onRequestClose={this.handleDeleteCollectionClose}
|
onSubmit={() => setShowCollectionDelete(false)}
|
||||||
>
|
collection={collection}
|
||||||
<CollectionDelete
|
/>
|
||||||
onSubmit={this.handleDeleteCollectionClose}
|
</Modal>
|
||||||
collection={collection}
|
<Modal
|
||||||
/>
|
title={t("Export collection")}
|
||||||
</Modal>
|
isOpen={showCollectionExport}
|
||||||
<Modal
|
onRequestClose={() => setShowCollectionExport(false)}
|
||||||
title={t("Export collection")}
|
>
|
||||||
isOpen={this.showCollectionExport}
|
<CollectionExport
|
||||||
onRequestClose={this.handleExportCollectionClose}
|
onSubmit={() => setShowCollectionExport(false)}
|
||||||
>
|
collection={collection}
|
||||||
<CollectionExport
|
/>
|
||||||
onSubmit={this.handleExportCollectionClose}
|
</Modal>
|
||||||
collection={collection}
|
</>
|
||||||
/>
|
)}
|
||||||
</Modal>
|
</>
|
||||||
</>
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withTranslation()<CollectionMenu>(
|
export default observer(CollectionMenu);
|
||||||
inject("ui", "documents", "policies")(withRouter(CollectionMenu))
|
|
||||||
);
|
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
// @flow
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { AlphabeticalSortIcon, ManualSortIcon } from "outline-icons";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||||
|
import Collection from "models/Collection";
|
||||||
|
import ContextMenu from "components/ContextMenu";
|
||||||
|
import Template from "components/ContextMenu/Template";
|
||||||
|
import NudeButton from "components/NudeButton";
|
||||||
|
|
||||||
|
type Props = {|
|
||||||
|
collection: Collection,
|
||||||
|
onOpen?: () => void,
|
||||||
|
onClose?: () => void,
|
||||||
|
|};
|
||||||
|
|
||||||
|
function CollectionSortMenu({ collection, onOpen, onClose, ...rest }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const menu = useMenuState({ modal: true });
|
||||||
|
|
||||||
|
const handleChangeSort = React.useCallback(
|
||||||
|
(field: string) => {
|
||||||
|
menu.hide();
|
||||||
|
return collection.save({
|
||||||
|
sort: {
|
||||||
|
field,
|
||||||
|
direction: "asc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[collection, menu]
|
||||||
|
);
|
||||||
|
|
||||||
|
const alphabeticalSort = collection.sort.field === "title";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MenuButton {...menu}>
|
||||||
|
{(props) => (
|
||||||
|
<NudeButton aria-label={t("Show sort menu")} {...props}>
|
||||||
|
{alphabeticalSort ? <AlphabeticalSortIcon /> : <ManualSortIcon />}
|
||||||
|
</NudeButton>
|
||||||
|
)}
|
||||||
|
</MenuButton>
|
||||||
|
<ContextMenu
|
||||||
|
{...menu}
|
||||||
|
onOpen={onOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
aria-label={t("Sort in sidebar")}
|
||||||
|
>
|
||||||
|
<Template
|
||||||
|
{...menu}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
title: t("Alphabetical sort"),
|
||||||
|
onClick: () => handleChangeSort("title"),
|
||||||
|
selected: alphabeticalSort,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("Manual sort"),
|
||||||
|
onClick: () => handleChangeSort("index"),
|
||||||
|
selected: !alphabeticalSort,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(CollectionSortMenu);
|
|
@ -1,21 +1,21 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { observable } from "mobx";
|
import { observer } from "mobx-react";
|
||||||
import { inject, observer } from "mobx-react";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { withTranslation, type TFunction } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Redirect } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
import AuthStore from "stores/AuthStore";
|
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||||
import CollectionStore from "stores/CollectionsStore";
|
import styled from "styled-components";
|
||||||
import PoliciesStore from "stores/PoliciesStore";
|
|
||||||
import UiStore from "stores/UiStore";
|
|
||||||
import Document from "models/Document";
|
import Document from "models/Document";
|
||||||
import DocumentDelete from "scenes/DocumentDelete";
|
import DocumentDelete from "scenes/DocumentDelete";
|
||||||
import DocumentShare from "scenes/DocumentShare";
|
import DocumentShare from "scenes/DocumentShare";
|
||||||
import DocumentTemplatize from "scenes/DocumentTemplatize";
|
import DocumentTemplatize from "scenes/DocumentTemplatize";
|
||||||
import CollectionIcon from "components/CollectionIcon";
|
import CollectionIcon from "components/CollectionIcon";
|
||||||
import { DropdownMenu } from "components/DropdownMenu";
|
import ContextMenu from "components/ContextMenu";
|
||||||
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
|
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||||
|
import Template from "components/ContextMenu/Template";
|
||||||
|
import Flex from "components/Flex";
|
||||||
import Modal from "components/Modal";
|
import Modal from "components/Modal";
|
||||||
|
import useStores from "hooks/useStores";
|
||||||
import {
|
import {
|
||||||
documentHistoryUrl,
|
documentHistoryUrl,
|
||||||
documentMoveUrl,
|
documentMoveUrl,
|
||||||
|
@ -24,348 +24,325 @@ import {
|
||||||
newDocumentUrl,
|
newDocumentUrl,
|
||||||
} from "utils/routeHelpers";
|
} from "utils/routeHelpers";
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
ui: UiStore,
|
|
||||||
auth: AuthStore,
|
|
||||||
position?: "left" | "right" | "center",
|
|
||||||
document: Document,
|
document: Document,
|
||||||
collections: CollectionStore,
|
|
||||||
policies: PoliciesStore,
|
|
||||||
className: string,
|
className: string,
|
||||||
isRevision?: boolean,
|
isRevision?: boolean,
|
||||||
showPrint?: boolean,
|
showPrint?: boolean,
|
||||||
|
modal?: boolean,
|
||||||
showToggleEmbeds?: boolean,
|
showToggleEmbeds?: boolean,
|
||||||
showPin?: boolean,
|
showPin?: boolean,
|
||||||
label?: React.Node,
|
label?: (any) => React.Node,
|
||||||
onOpen?: () => void,
|
onOpen?: () => void,
|
||||||
onClose?: () => void,
|
onClose?: () => void,
|
||||||
t: TFunction,
|
|};
|
||||||
};
|
|
||||||
|
|
||||||
@observer
|
function DocumentMenu({
|
||||||
class DocumentMenu extends React.Component<Props> {
|
document,
|
||||||
@observable redirectTo: ?string;
|
isRevision,
|
||||||
@observable showDeleteModal = false;
|
className,
|
||||||
@observable showTemplateModal = false;
|
modal = true,
|
||||||
@observable showShareModal = false;
|
showToggleEmbeds,
|
||||||
|
showPrint,
|
||||||
|
showPin,
|
||||||
|
label,
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const { policies, collections, auth, ui } = useStores();
|
||||||
|
const menu = useMenuState({ modal });
|
||||||
|
const history = useHistory();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [renderModals, setRenderModals] = React.useState(false);
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
|
||||||
|
const [showTemplateModal, setShowTemplateModal] = React.useState(false);
|
||||||
|
const [showShareModal, setShowShareModal] = React.useState(false);
|
||||||
|
|
||||||
componentDidUpdate() {
|
const handleOpen = React.useCallback(() => {
|
||||||
this.redirectTo = undefined;
|
setRenderModals(true);
|
||||||
}
|
if (onOpen) {
|
||||||
|
onOpen();
|
||||||
handleNewChild = (ev: SyntheticEvent<>) => {
|
|
||||||
const { document } = this.props;
|
|
||||||
this.redirectTo = newDocumentUrl(document.collectionId, {
|
|
||||||
parentDocumentId: document.id,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
handleDelete = (ev: SyntheticEvent<>) => {
|
|
||||||
this.showDeleteModal = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
handleDocumentHistory = () => {
|
|
||||||
if (this.props.isRevision) {
|
|
||||||
this.redirectTo = documentUrl(this.props.document);
|
|
||||||
} else {
|
|
||||||
this.redirectTo = documentHistoryUrl(this.props.document);
|
|
||||||
}
|
}
|
||||||
};
|
}, [onOpen]);
|
||||||
|
|
||||||
handleMove = (ev: SyntheticEvent<>) => {
|
const handleDuplicate = React.useCallback(
|
||||||
this.redirectTo = documentMoveUrl(this.props.document);
|
async (ev: SyntheticEvent<>) => {
|
||||||
};
|
const duped = await document.duplicate();
|
||||||
|
|
||||||
handleEdit = (ev: SyntheticEvent<>) => {
|
// when duplicating, go straight to the duplicated document content
|
||||||
this.redirectTo = editDocumentUrl(this.props.document);
|
history.push(duped.url);
|
||||||
};
|
ui.showToast(t("Document duplicated"), { type: "success" });
|
||||||
|
},
|
||||||
|
[ui, t, history, document]
|
||||||
|
);
|
||||||
|
|
||||||
handleDuplicate = async (ev: SyntheticEvent<>) => {
|
const handleArchive = React.useCallback(
|
||||||
const duped = await this.props.document.duplicate();
|
async (ev: SyntheticEvent<>) => {
|
||||||
|
await document.archive();
|
||||||
|
ui.showToast(t("Document archived"), { type: "success" });
|
||||||
|
},
|
||||||
|
[ui, t, document]
|
||||||
|
);
|
||||||
|
|
||||||
// when duplicating, go straight to the duplicated document content
|
const handleRestore = React.useCallback(
|
||||||
this.redirectTo = duped.url;
|
async (ev: SyntheticEvent<>, options?: { collectionId: string }) => {
|
||||||
const { t } = this.props;
|
await document.restore(options);
|
||||||
this.props.ui.showToast(t("Document duplicated"));
|
ui.showToast(t("Document restored"), { type: "success" });
|
||||||
};
|
},
|
||||||
|
[ui, t, document]
|
||||||
|
);
|
||||||
|
|
||||||
handleOpenTemplateModal = () => {
|
const handleUnpublish = React.useCallback(
|
||||||
this.showTemplateModal = true;
|
async (ev: SyntheticEvent<>) => {
|
||||||
};
|
await document.unpublish();
|
||||||
|
ui.showToast(t("Document unpublished"), { type: "success" });
|
||||||
|
},
|
||||||
|
[ui, t, document]
|
||||||
|
);
|
||||||
|
|
||||||
handleCloseTemplateModal = () => {
|
const handlePrint = React.useCallback((ev: SyntheticEvent<>) => {
|
||||||
this.showTemplateModal = false;
|
window.print();
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
handleCloseDeleteModal = () => {
|
const handleStar = React.useCallback(
|
||||||
this.showDeleteModal = false;
|
(ev: SyntheticEvent<>) => {
|
||||||
};
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
document.star();
|
||||||
|
},
|
||||||
|
[document]
|
||||||
|
);
|
||||||
|
|
||||||
handleArchive = async (ev: SyntheticEvent<>) => {
|
const handleUnstar = React.useCallback(
|
||||||
await this.props.document.archive();
|
(ev: SyntheticEvent<>) => {
|
||||||
const { t } = this.props;
|
ev.preventDefault();
|
||||||
this.props.ui.showToast(t("Document archived"));
|
ev.stopPropagation();
|
||||||
};
|
document.unstar();
|
||||||
|
},
|
||||||
|
[document]
|
||||||
|
);
|
||||||
|
|
||||||
handleRestore = async (
|
const handleShareLink = React.useCallback(
|
||||||
ev: SyntheticEvent<>,
|
async (ev: SyntheticEvent<>) => {
|
||||||
options?: { collectionId: string }
|
await document.share();
|
||||||
) => {
|
setShowShareModal(true);
|
||||||
await this.props.document.restore(options);
|
},
|
||||||
const { t } = this.props;
|
[document]
|
||||||
this.props.ui.showToast(t("Document restored"));
|
);
|
||||||
};
|
|
||||||
|
|
||||||
handleUnpublish = async (ev: SyntheticEvent<>) => {
|
const can = policies.abilities(document.id);
|
||||||
await this.props.document.unpublish();
|
const canShareDocuments = !!(can.share && auth.team && auth.team.sharing);
|
||||||
const { t } = this.props;
|
const canViewHistory = can.read && !can.restore;
|
||||||
this.props.ui.showToast(t("Document unpublished"));
|
const collection = collections.get(document.collectionId);
|
||||||
};
|
|
||||||
|
|
||||||
handlePin = (ev: SyntheticEvent<>) => {
|
return (
|
||||||
this.props.document.pin();
|
<>
|
||||||
};
|
{label ? (
|
||||||
|
<MenuButton {...menu}>{label}</MenuButton>
|
||||||
handleUnpin = (ev: SyntheticEvent<>) => {
|
) : (
|
||||||
this.props.document.unpin();
|
<OverflowMenuButton
|
||||||
};
|
|
||||||
|
|
||||||
handleStar = (ev: SyntheticEvent<>) => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
this.props.document.star();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleUnstar = (ev: SyntheticEvent<>) => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
this.props.document.unstar();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleExport = (ev: SyntheticEvent<>) => {
|
|
||||||
this.props.document.download();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleShareLink = async (ev: SyntheticEvent<>) => {
|
|
||||||
const { document } = this.props;
|
|
||||||
await document.share();
|
|
||||||
this.showShareModal = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
handleCloseShareModal = () => {
|
|
||||||
this.showShareModal = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
|
||||||
|
|
||||||
const {
|
|
||||||
policies,
|
|
||||||
document,
|
|
||||||
position,
|
|
||||||
className,
|
|
||||||
showToggleEmbeds,
|
|
||||||
showPrint,
|
|
||||||
showPin,
|
|
||||||
auth,
|
|
||||||
collections,
|
|
||||||
label,
|
|
||||||
onOpen,
|
|
||||||
onClose,
|
|
||||||
t,
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DropdownMenu
|
|
||||||
className={className}
|
className={className}
|
||||||
position={position}
|
aria-label={t("Show menu")}
|
||||||
onOpen={onOpen}
|
{...menu}
|
||||||
onClose={onClose}
|
/>
|
||||||
label={label}
|
)}
|
||||||
>
|
<ContextMenu
|
||||||
<DropdownMenuItems
|
{...menu}
|
||||||
items={[
|
aria-label={t("Document options")}
|
||||||
{
|
onOpen={handleOpen}
|
||||||
title: t("Restore"),
|
onClose={onClose}
|
||||||
visible: !!can.unarchive,
|
>
|
||||||
onClick: this.handleRestore,
|
<Template
|
||||||
|
{...menu}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
title: t("Restore"),
|
||||||
|
visible: !!can.unarchive,
|
||||||
|
onClick: handleRestore,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("Restore"),
|
||||||
|
visible: !!(collection && can.restore),
|
||||||
|
onClick: handleRestore,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("Restore"),
|
||||||
|
visible: !collection && !!can.restore,
|
||||||
|
style: {
|
||||||
|
left: -170,
|
||||||
|
position: "relative",
|
||||||
|
top: -40,
|
||||||
},
|
},
|
||||||
{
|
hover: true,
|
||||||
title: t("Restore"),
|
items: [
|
||||||
visible: !!(collection && can.restore),
|
{
|
||||||
onClick: this.handleRestore,
|
type: "heading",
|
||||||
},
|
title: t("Choose a collection"),
|
||||||
{
|
|
||||||
title: `${t("Restore")}…`,
|
|
||||||
visible: !collection && !!can.restore,
|
|
||||||
style: {
|
|
||||||
left: -170,
|
|
||||||
position: "relative",
|
|
||||||
top: -40,
|
|
||||||
},
|
},
|
||||||
hover: true,
|
...collections.orderedData.map((collection) => {
|
||||||
items: [
|
const can = policies.abilities(collection.id);
|
||||||
{
|
|
||||||
type: "heading",
|
|
||||||
title: t("Choose a collection"),
|
|
||||||
},
|
|
||||||
...collections.orderedData.map((collection) => {
|
|
||||||
const can = policies.abilities(collection.id);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: (
|
title: (
|
||||||
<>
|
<Flex align="center">
|
||||||
<CollectionIcon collection={collection} />
|
<CollectionIcon collection={collection} />
|
||||||
{collection.name}
|
<CollectionName>{collection.name}</CollectionName>
|
||||||
</>
|
</Flex>
|
||||||
),
|
),
|
||||||
onClick: (ev) =>
|
onClick: (ev) =>
|
||||||
this.handleRestore(ev, { collectionId: collection.id }),
|
handleRestore(ev, { collectionId: collection.id }),
|
||||||
disabled: !can.update,
|
disabled: !can.update,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("Unpin"),
|
title: t("Unpin"),
|
||||||
onClick: this.handleUnpin,
|
onClick: document.unpin,
|
||||||
visible: !!(showPin && document.pinned && can.unpin),
|
visible: !!(showPin && document.pinned && can.unpin),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("Pin to collection"),
|
title: t("Pin to collection"),
|
||||||
onClick: this.handlePin,
|
onClick: document.pin,
|
||||||
visible: !!(showPin && !document.pinned && can.pin),
|
visible: !!(showPin && !document.pinned && can.pin),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("Unstar"),
|
title: t("Unstar"),
|
||||||
onClick: this.handleUnstar,
|
onClick: handleUnstar,
|
||||||
visible: document.isStarred && !!can.unstar,
|
visible: document.isStarred && !!can.unstar,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("Star"),
|
title: t("Star"),
|
||||||
onClick: this.handleStar,
|
onClick: handleStar,
|
||||||
visible: !document.isStarred && !!can.star,
|
visible: !document.isStarred && !!can.star,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: `${t("Share link")}…`,
|
title: `${t("Share link")}…`,
|
||||||
onClick: this.handleShareLink,
|
onClick: handleShareLink,
|
||||||
visible: canShareDocuments,
|
visible: canShareDocuments,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("Enable embeds"),
|
title: t("Enable embeds"),
|
||||||
onClick: document.enableEmbeds,
|
onClick: document.enableEmbeds,
|
||||||
visible: !!showToggleEmbeds && document.embedsDisabled,
|
visible: !!showToggleEmbeds && document.embedsDisabled,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("Disable embeds"),
|
title: t("Disable embeds"),
|
||||||
onClick: document.disableEmbeds,
|
onClick: document.disableEmbeds,
|
||||||
visible: !!showToggleEmbeds && !document.embedsDisabled,
|
visible: !!showToggleEmbeds && !document.embedsDisabled,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "separator",
|
type: "separator",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("New nested document"),
|
title: t("New nested document"),
|
||||||
onClick: this.handleNewChild,
|
to: newDocumentUrl(document.collectionId, {
|
||||||
visible: !!can.createChildDocument,
|
parentDocumentId: document.id,
|
||||||
},
|
}),
|
||||||
{
|
visible: !!can.createChildDocument,
|
||||||
title: `${t("Create template")}…`,
|
},
|
||||||
onClick: this.handleOpenTemplateModal,
|
{
|
||||||
visible: !!can.update && !document.isTemplate,
|
title: `${t("Create template")}…`,
|
||||||
},
|
onClick: () => setShowTemplateModal(true),
|
||||||
{
|
visible: !!can.update && !document.isTemplate,
|
||||||
title: t("Edit"),
|
},
|
||||||
onClick: this.handleEdit,
|
{
|
||||||
visible: !!can.update,
|
title: t("Edit"),
|
||||||
},
|
to: editDocumentUrl(document),
|
||||||
{
|
visible: !!can.update,
|
||||||
title: t("Duplicate"),
|
},
|
||||||
onClick: this.handleDuplicate,
|
{
|
||||||
visible: !!can.update,
|
title: t("Duplicate"),
|
||||||
},
|
onClick: handleDuplicate,
|
||||||
{
|
visible: !!can.update,
|
||||||
title: t("Unpublish"),
|
},
|
||||||
onClick: this.handleUnpublish,
|
{
|
||||||
visible: !!can.unpublish,
|
title: t("Unpublish"),
|
||||||
},
|
onClick: handleUnpublish,
|
||||||
{
|
visible: !!can.unpublish,
|
||||||
title: t("Archive"),
|
},
|
||||||
onClick: this.handleArchive,
|
{
|
||||||
visible: !!can.archive,
|
title: t("Archive"),
|
||||||
},
|
onClick: handleArchive,
|
||||||
{
|
visible: !!can.archive,
|
||||||
title: `${t("Delete")}…`,
|
},
|
||||||
onClick: this.handleDelete,
|
{
|
||||||
visible: !!can.delete,
|
title: `${t("Delete")}…`,
|
||||||
},
|
onClick: () => setShowDeleteModal(true),
|
||||||
{
|
visible: !!can.delete,
|
||||||
title: `${t("Move")}…`,
|
},
|
||||||
onClick: this.handleMove,
|
{
|
||||||
visible: !!can.move,
|
title: `${t("Move")}…`,
|
||||||
},
|
to: documentMoveUrl(document),
|
||||||
{
|
visible: !!can.move,
|
||||||
type: "separator",
|
},
|
||||||
},
|
{
|
||||||
{
|
type: "separator",
|
||||||
title: t("History"),
|
},
|
||||||
onClick: this.handleDocumentHistory,
|
{
|
||||||
visible: canViewHistory,
|
title: t("History"),
|
||||||
},
|
to: isRevision
|
||||||
{
|
? documentUrl(document)
|
||||||
title: t("Download"),
|
: documentHistoryUrl(document),
|
||||||
onClick: this.handleExport,
|
visible: canViewHistory,
|
||||||
visible: !!can.download,
|
},
|
||||||
},
|
{
|
||||||
{
|
title: t("Download"),
|
||||||
title: t("Print"),
|
onClick: document.download,
|
||||||
onClick: window.print,
|
visible: !!can.download,
|
||||||
visible: !!showPrint,
|
},
|
||||||
},
|
{
|
||||||
]}
|
title: t("Print"),
|
||||||
/>
|
onClick: handlePrint,
|
||||||
</DropdownMenu>
|
visible: !!showPrint,
|
||||||
<Modal
|
},
|
||||||
title={t("Delete {{ documentName }}", {
|
]}
|
||||||
documentName: this.props.document.noun,
|
/>
|
||||||
})}
|
</ContextMenu>
|
||||||
onRequestClose={this.handleCloseDeleteModal}
|
{renderModals && (
|
||||||
isOpen={this.showDeleteModal}
|
<>
|
||||||
>
|
<Modal
|
||||||
<DocumentDelete
|
title={t("Delete {{ documentName }}", {
|
||||||
document={this.props.document}
|
documentName: document.noun,
|
||||||
onSubmit={this.handleCloseDeleteModal}
|
})}
|
||||||
/>
|
onRequestClose={() => setShowDeleteModal(false)}
|
||||||
</Modal>
|
isOpen={showDeleteModal}
|
||||||
<Modal
|
>
|
||||||
title={t("Create template")}
|
<DocumentDelete
|
||||||
onRequestClose={this.handleCloseTemplateModal}
|
document={document}
|
||||||
isOpen={this.showTemplateModal}
|
onSubmit={() => setShowDeleteModal(false)}
|
||||||
>
|
/>
|
||||||
<DocumentTemplatize
|
</Modal>
|
||||||
document={this.props.document}
|
<Modal
|
||||||
onSubmit={this.handleCloseTemplateModal}
|
title={t("Create template")}
|
||||||
/>
|
onRequestClose={() => setShowTemplateModal(false)}
|
||||||
</Modal>
|
isOpen={showTemplateModal}
|
||||||
<Modal
|
>
|
||||||
title={t("Share document")}
|
<DocumentTemplatize
|
||||||
onRequestClose={this.handleCloseShareModal}
|
document={document}
|
||||||
isOpen={this.showShareModal}
|
onSubmit={() => setShowTemplateModal(false)}
|
||||||
>
|
/>
|
||||||
<DocumentShare
|
</Modal>
|
||||||
document={this.props.document}
|
<Modal
|
||||||
onSubmit={this.handleCloseShareModal}
|
title={t("Share document")}
|
||||||
/>
|
onRequestClose={() => setShowShareModal(false)}
|
||||||
</Modal>
|
isOpen={showShareModal}
|
||||||
</>
|
>
|
||||||
);
|
<DocumentShare
|
||||||
}
|
document={document}
|
||||||
|
onSubmit={() => setShowShareModal(false)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withTranslation()<DocumentMenu>(
|
const CollectionName = styled.div`
|
||||||
inject("ui", "auth", "collections", "policies")(DocumentMenu)
|
overflow: hidden;
|
||||||
);
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default observer(DocumentMenu);
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue