mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 481605d017 | |||
| 985ba9be29 | |||
| 8412efcd0c |
+28
-73
@@ -1,40 +1,31 @@
|
||||
# 👋 Welcome, we're glad you're setting up an installation of Outline. Copy this
|
||||
# file to .env or set the variables in your local environment manually. For
|
||||
# development with docker this should mostly work out of the box other than
|
||||
# setting the Slack keys and the SECRET_KEY.
|
||||
|
||||
|
||||
|
||||
|
||||
# –––––––––––––––– REQUIRED ––––––––––––––––
|
||||
|
||||
# Generate a unique random key, you can use `openssl rand -hex 32` in terminal
|
||||
# DO NOT LEAVE UNSET
|
||||
# Copy this file to .env, remove this comment and change the keys. For development
|
||||
# with docker this should mostly work out of the box other than setting the Slack
|
||||
# keys (for auth) and the SECRET_KEY.
|
||||
#
|
||||
# Please use `openssl rand -hex 32` to create SECRET_KEY
|
||||
SECRET_KEY=generate_a_new_key
|
||||
|
||||
# Generate a unique random key, you can use `openssl rand -hex 32` in terminal
|
||||
# DO NOT LEAVE UNSET
|
||||
UTILS_SECRET=generate_a_new_key
|
||||
|
||||
# For production point these at your databases, in development the default
|
||||
# should work out of the box.
|
||||
DATABASE_URL=postgres://user:pass@localhost:5532/outline
|
||||
DATABASE_URL_TEST=postgres://user:pass@localhost:5532/outline-test
|
||||
REDIS_URL=redis://localhost:6479
|
||||
|
||||
# URL should point to the fully qualified, publicly accessible URL. If using a
|
||||
# proxy the port in URL and PORT may be different.
|
||||
# Must point to the publicly accessible URL for the installation
|
||||
URL=http://localhost:3000
|
||||
PORT=3000
|
||||
|
||||
# Third party signin credentials, at least one of EITHER Google OR Slack is
|
||||
# required for a working installation or you'll have no sign-in options.
|
||||
# Optional. If using a Cloudfront distribution or similar the origin server
|
||||
# should be set to the same as URL.
|
||||
CDN_URL=
|
||||
|
||||
# To configure Slack auth, you'll need to create an Application at
|
||||
# => https://api.slack.com/apps
|
||||
#
|
||||
# When configuring the Client ID, add a redirect URL under "OAuth & Permissions":
|
||||
# https://<URL>/auth/slack.callback
|
||||
# enforce (auto redirect to) https in production, (optional) default is true.
|
||||
# set to false if your SSL is terminated at a loadbalancer, for example
|
||||
FORCE_HTTPS=true
|
||||
|
||||
ENABLE_UPDATES=true
|
||||
DEBUG=cache,presenters,events,emails,mailer,utils,multiplayer,server,services
|
||||
|
||||
# Third party signin credentials (at least one is required)
|
||||
SLACK_KEY=get_a_key_from_slack
|
||||
SLACK_SECRET=get_the_secret_of_above_key
|
||||
|
||||
@@ -42,59 +33,22 @@ SLACK_SECRET=get_the_secret_of_above_key
|
||||
# => https://console.cloud.google.com/apis/credentials
|
||||
#
|
||||
# When configuring the Client ID, add an Authorized redirect URI:
|
||||
# https://<URL>/auth/google.callback
|
||||
# https://<your Outline URL>/auth/google.callback
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
|
||||
|
||||
|
||||
# –––––––––––––––– 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
|
||||
# Comma separated list of domains to be allowed (optional)
|
||||
# If not set, all Google apps domains are allowed by default
|
||||
GOOGLE_ALLOWED_DOMAINS=
|
||||
|
||||
# For a complete Slack integration with search and posting to channels the
|
||||
# following configs are also needed, some more details
|
||||
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
|
||||
#
|
||||
SLACK_VERIFICATION_TOKEN=your_token
|
||||
# Third party credentials (optional)
|
||||
SLACK_VERIFICATION_TOKEN=PLxk6OlXXXXXVj3YYYY
|
||||
SLACK_APP_ID=A0XXXXXXX
|
||||
SLACK_MESSAGE_ACTIONS=true
|
||||
|
||||
# Optionally enable google analytics to track pageviews in the knowledge base
|
||||
GOOGLE_ANALYTICS_ID=
|
||||
|
||||
# Optionally enable Sentry (sentry.io) to track errors and performance
|
||||
SENTRY_DSN=
|
||||
|
||||
# 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 credentials (optional in development)
|
||||
AWS_ACCESS_KEY_ID=get_a_key_from_aws
|
||||
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
|
||||
AWS_REGION=xx-xxxx-x
|
||||
@@ -102,10 +56,11 @@ AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
|
||||
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
|
||||
AWS_S3_UPLOAD_MAX_SIZE=26214400
|
||||
AWS_S3_FORCE_PATH_STYLE=true
|
||||
# uploaded s3 objects permission level, default is private
|
||||
# set to "public-read" to allow public access
|
||||
AWS_S3_ACL=private
|
||||
|
||||
# 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
|
||||
# Emails configuration (optional)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_USERNAME=
|
||||
@@ -116,6 +71,6 @@ SMTP_REPLY_EMAIL=
|
||||
# Custom logo that displays on the authentication screen, scaled to height: 60px
|
||||
# TEAM_LOGO=https://example.com/images/logo.png
|
||||
|
||||
# The default interface language. See translate.getoutline.com for a list of
|
||||
# available language codes and their rough percentage translated.
|
||||
# See translate.getoutline.com for a list of available language codes and their
|
||||
# percentage translated.
|
||||
DEFAULT_LANGUAGE=en_US
|
||||
@@ -1,66 +0,0 @@
|
||||
|
||||
# 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
|
||||
```
|
||||
@@ -6,7 +6,7 @@
|
||||
<p align="center">
|
||||
<i>An open, extensible, wiki for your team built using React and Node.js.<br/>Try out Outline using our hosted version at <a href="https://www.getoutline.com">www.getoutline.com</a>.</i>
|
||||
<br/>
|
||||
<img src="https://www.getoutline.com/images/screenshot@2x.png" alt="Outline" width="800" />
|
||||
<img src="https://user-images.githubusercontent.com/380914/78513257-153ae080-775f-11ea-9b49-1e1939451a3e.png" alt="Outline" width="800" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield&circle-token=c0c4c2f39990e277385d5c1ae96169c409eb887a"></a>
|
||||
@@ -19,7 +19,7 @@ This is the source code that runs [**Outline**](https://www.getoutline.com) and
|
||||
|
||||
If you'd like to run your own copy of Outline or contribute to development then this is the place for you.
|
||||
|
||||
# Installation
|
||||
## Installation
|
||||
|
||||
Outline requires the following dependencies:
|
||||
|
||||
@@ -31,58 +31,33 @@ Outline requires the following dependencies:
|
||||
- Slack or Google developer application for authentication
|
||||
|
||||
|
||||
## Self-Hosted Production
|
||||
### Production
|
||||
|
||||
### Docker
|
||||
For a manual self-hosted production installation these are the suggested steps:
|
||||
|
||||
For a manual self-hosted production installation these are the recommended steps:
|
||||
|
||||
1. First setup Redis and Postgres servers, this is outside the scope of the guide.
|
||||
1. Download the latest official Docker image, new releases are available around the middle of every month:
|
||||
|
||||
`docker pull outlinewiki/outline`
|
||||
1. Using the [.env.sample](.env.sample) as a reference, set the required variables in your production environment. You can export the environment variables directly, or create a `.env` file and pass it to the docker image like so:
|
||||
|
||||
`docker run --env-file=.env outlinewiki/outline`
|
||||
1. Setup the database with `yarn sequelize:migrate`. Production assumes an SSL connection to the database by default, if
|
||||
Postgres is on the same machine and is not SSL you can migrate with `yarn sequelize:migrate --env=production-ssl-disabled`, for example:
|
||||
|
||||
`docker run --rm outlinewiki/outline yarn sequelize:migrate`
|
||||
1. Start the container:
|
||||
|
||||
`docker run outlinewiki/outline`
|
||||
1. Clone this repo and install dependencies with `yarn install`
|
||||
1. Build the source code with `yarn build`
|
||||
1. Using the `.env.sample` as a reference, set the required variables in your production environment. The following are required as a minimum:
|
||||
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
|
||||
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
|
||||
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
|
||||
1. `DATABASE_URL` (run your own local copy of Postgres, or use a cloud service)
|
||||
1. `REDIS_URL` (run your own local copy of Redis, or use a cloud service)
|
||||
1. `URL` (the public facing URL of your installation)
|
||||
1. `AWS_` (all of the keys beginning with AWS)
|
||||
1. Migrate database schema with `yarn sequelize:migrate`. Production assumes an SSL connection, if
|
||||
Postgres is on the same machine and is not SSL you can migrate with `yarn sequelize:migrate --env=production-ssl-disabled`.
|
||||
1. Start the service with any daemon tools you prefer. Take PM2 for example, `NODE_ENV=production pm2 start ./build/server/index.js --name outline `
|
||||
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
|
||||
|
||||
> Port number can be changed using the `PORT` environment variable
|
||||
|
||||
1. (Optional) You can add an `nginx` 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
|
||||
```
|
||||
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.
|
||||
|
||||
|
||||
## Local Development
|
||||
### Development
|
||||
|
||||
For contributing features and fixes you can quickly get an environment running using Docker by following these steps:
|
||||
In development you can quickly get an environment running using Docker by following these steps:
|
||||
|
||||
1. Install these dependencies if you don't already have them
|
||||
1. [Docker for Desktop](https://www.docker.com)
|
||||
@@ -100,29 +75,24 @@ For contributing features and fixes you can quickly get an environment running u
|
||||
1. Ensure that the bot token scope contains at least `users:read`
|
||||
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
|
||||
|
||||
### Upgrade
|
||||
|
||||
# Contributing
|
||||
#### Docker
|
||||
|
||||
Outline is built and maintained by a small team – we'd love your help to fix bugs and add features!
|
||||
If you're running Outline with Docker you'll need to run migrations within the docker container after updating the image. The command will be something like:
|
||||
```
|
||||
docker run --rm outlinewiki/outline:latest yarn sequelize:migrate
|
||||
```
|
||||
#### Yarn
|
||||
|
||||
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 running Outline by cloning this repository, run the following command to upgrade:
|
||||
```
|
||||
yarn run upgrade
|
||||
```
|
||||
|
||||
If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
|
||||
## Development
|
||||
|
||||
* [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
|
||||
### Server
|
||||
|
||||
Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging output, the following categories are available:
|
||||
|
||||
@@ -130,6 +100,52 @@ Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging o
|
||||
DEBUG=sql,cache,presenters,events,logistics,emails,mailer
|
||||
```
|
||||
|
||||
## Migrations
|
||||
|
||||
Sequelize is used to create and run migrations, for example:
|
||||
|
||||
```
|
||||
yarn sequelize migration:generate --name my-migration
|
||||
yarn sequelize db:migrate
|
||||
```
|
||||
|
||||
Or to run migrations on test database:
|
||||
|
||||
```
|
||||
yarn sequelize db:migrate --env test
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
Outline is composed of separate backend and frontend application which are both driven by the same Node process. As both are written in Javascript, they share some code but are mostly separate. We utilize the latest language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier and ESLint are enforced by CI.
|
||||
|
||||
### Frontend
|
||||
|
||||
Outline's frontend is a React application compiled with [Webpack](https://webpack.js.org/). It uses [Mobx](https://mobx.js.org/) for state management and [Styled Components](https://www.styled-components.com/) for component styles. Unless global, state logic and styles are always co-located with React components together with their subcomponents to make the component tree easier to manage.
|
||||
|
||||
The editor itself is built on [Prosemirror](https://github.com/prosemirror) and hosted in a separate repository to encourage reuse: [rich-markdown-editor](https://github.com/outline/rich-markdown-editor)
|
||||
|
||||
- `app/` - Frontend React application
|
||||
- `app/scenes` - Full page views
|
||||
- `app/components` - Reusable React components
|
||||
- `app/stores` - Global state stores
|
||||
- `app/models` - State models
|
||||
- `app/types` - Flow types for non-models
|
||||
|
||||
### Backend
|
||||
|
||||
Backend is driven by [Koa](http://koajs.com/) (API, web server), [Sequelize](http://docs.sequelizejs.com/) (database) and React for public pages and emails.
|
||||
|
||||
- `server/api` - API endpoints
|
||||
- `server/commands` - Domain logic, currently being refactored from /models
|
||||
- `server/emails` - React rendered email templates
|
||||
- `server/models` - Database models
|
||||
- `server/policies` - Authorization logic
|
||||
- `server/presenters` - API responses for database models
|
||||
- `server/test` - Test helps and support
|
||||
- `server/utils` - Utility methods
|
||||
- `shared` - Code shared between frontend and backend applications
|
||||
|
||||
## Tests
|
||||
|
||||
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
|
||||
@@ -155,21 +171,20 @@ yarn test:server
|
||||
yarn test:app
|
||||
```
|
||||
|
||||
## Migrations
|
||||
## Contributing
|
||||
|
||||
Sequelize is used to create and run migrations, for example:
|
||||
Outline is built and maintained by a small team – we'd love your help to fix bugs and add features!
|
||||
|
||||
```
|
||||
yarn sequelize migration:generate --name my-migration
|
||||
yarn sequelize db:migrate
|
||||
```
|
||||
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!
|
||||
|
||||
Or to run migrations on test database:
|
||||
If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
|
||||
|
||||
```
|
||||
yarn sequelize db:migrate --env test
|
||||
```
|
||||
* [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
|
||||
|
||||
## License
|
||||
|
||||
Outline is [BSL 1.1 licensed](LICENSE).
|
||||
Outline is [BSL 1.1 licensed](https://github.com/outline/outline/blob/master/LICENSE).
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
export default function Arrow() {
|
||||
return (
|
||||
<svg
|
||||
width="13"
|
||||
height="30"
|
||||
viewBox="0 0 13 30"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -20,11 +20,10 @@ import useStores from "hooks/useStores";
|
||||
import BreadcrumbMenu from "menus/BreadcrumbMenu";
|
||||
import { collectionUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
document: Document,
|
||||
children?: React.Node,
|
||||
onlyText: boolean,
|
||||
|};
|
||||
};
|
||||
|
||||
function Icon({ document }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -80,14 +79,10 @@ function Icon({ document }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Breadcrumb = ({ document, children, onlyText }: Props) => {
|
||||
const Breadcrumb = ({ document, onlyText }: Props) => {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return <Wrapper />;
|
||||
}
|
||||
|
||||
let collection = collections.get(document.collectionId);
|
||||
if (!collection) {
|
||||
collection = {
|
||||
@@ -145,7 +140,6 @@ const Breadcrumb = ({ document, children, onlyText }: Props) => {
|
||||
</Crumb>
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,18 +3,17 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
children?: React.Node,
|
||||
withStickyHeader?: boolean,
|
||||
|};
|
||||
};
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
|
||||
padding: 60px 20px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: ${(props) => (props.withStickyHeader ? "4px 60px" : "60px")};
|
||||
padding: 60px;
|
||||
`};
|
||||
`;
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
import { sortBy, keyBy } from "lodash";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { MAX_AVATAR_DISPLAY } from "shared/constants";
|
||||
|
||||
import DocumentPresenceStore from "stores/DocumentPresenceStore";
|
||||
import ViewsStore from "stores/ViewsStore";
|
||||
import Document from "models/Document";
|
||||
@@ -52,7 +51,7 @@ class Collaborators extends React.Component<Props> {
|
||||
const overflow = documentViews.length - mostRecentViewers.length;
|
||||
|
||||
return (
|
||||
<FacepileHiddenOnMobile
|
||||
<Facepile
|
||||
users={mostRecentViewers.map((v) => v.user)}
|
||||
overflow={overflow}
|
||||
renderAvatar={(user) => {
|
||||
@@ -76,10 +75,4 @@ class Collaborators extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
const FacepileHiddenOnMobile = styled(Facepile)`
|
||||
${breakpoint("mobile", "tablet")`
|
||||
display: none;
|
||||
`};
|
||||
`;
|
||||
|
||||
export default inject("views", "presence")(Collaborators);
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Collection from "models/Collection";
|
||||
import Arrow from "components/Arrow";
|
||||
import ButtonLink from "components/ButtonLink";
|
||||
import Editor from "components/Editor";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
import NudeButton from "components/NudeButton";
|
||||
import useDebouncedCallback from "hooks/useDebouncedCallback";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
collection: Collection,
|
||||
|};
|
||||
|
||||
function CollectionDescription({ collection }: Props) {
|
||||
const { collections, ui, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [isExpanded, setExpanded] = React.useState(false);
|
||||
const [isEditing, setEditing] = React.useState(false);
|
||||
const [isDirty, setDirty] = React.useState(false);
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
const handleStartEditing = React.useCallback(() => {
|
||||
setEditing(true);
|
||||
}, []);
|
||||
|
||||
const handleStopEditing = React.useCallback(() => {
|
||||
setEditing(false);
|
||||
}, []);
|
||||
|
||||
const handleClickDisclosure = React.useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (isExpanded && document.activeElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
setExpanded(!isExpanded);
|
||||
},
|
||||
[isExpanded]
|
||||
);
|
||||
|
||||
const handleSave = useDebouncedCallback(async (getValue) => {
|
||||
try {
|
||||
await collection.save({
|
||||
description: getValue(),
|
||||
});
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
ui.showToast(
|
||||
t("Sorry, an error occurred saving the collection", {
|
||||
type: "error",
|
||||
})
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(getValue) => {
|
||||
setDirty(true);
|
||||
handleSave(getValue);
|
||||
},
|
||||
[handleSave]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setEditing(false);
|
||||
}, [collection.id]);
|
||||
|
||||
const placeholder = `${t("Add a description")}…`;
|
||||
const key = isEditing || isDirty ? "draft" : collection.updatedAt;
|
||||
|
||||
return (
|
||||
<MaxHeight data-editing={isEditing} data-expanded={isExpanded}>
|
||||
<Input
|
||||
$isEditable={can.update}
|
||||
data-editing={isEditing}
|
||||
data-expanded={isExpanded}
|
||||
>
|
||||
<span onClick={can.update ? handleStartEditing : undefined}>
|
||||
{collections.isSaving && <LoadingIndicator />}
|
||||
{collection.hasDescription || isEditing || isDirty ? (
|
||||
<React.Suspense fallback={<Placeholder>Loading…</Placeholder>}>
|
||||
<Editor
|
||||
id={collection.id}
|
||||
key={key}
|
||||
defaultValue={collection.description || ""}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
readOnly={!isEditing}
|
||||
autoFocus={isEditing}
|
||||
onBlur={handleStopEditing}
|
||||
maxLength={1000}
|
||||
disableEmbeds
|
||||
readOnlyWriteCheckboxes
|
||||
grow
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
can.update && <Placeholder>{placeholder}</Placeholder>
|
||||
)}
|
||||
</span>
|
||||
</Input>
|
||||
{!isEditing && (
|
||||
<Disclosure
|
||||
onClick={handleClickDisclosure}
|
||||
aria-label={isExpanded ? t("Collapse") : t("Expand")}
|
||||
size={30}
|
||||
>
|
||||
<Arrow />
|
||||
</Disclosure>
|
||||
)}
|
||||
</MaxHeight>
|
||||
);
|
||||
}
|
||||
|
||||
const Disclosure = styled(NudeButton)`
|
||||
opacity: 0;
|
||||
color: ${(props) => props.theme.divider};
|
||||
position: absolute;
|
||||
top: calc(25vh - 50px);
|
||||
left: 50%;
|
||||
z-index: 1;
|
||||
transform: rotate(-90deg) translateX(-50%);
|
||||
transition: opacity 100ms ease-in-out;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
}
|
||||
`;
|
||||
|
||||
const Placeholder = styled(ButtonLink)`
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
cursor: text;
|
||||
min-height: 27px;
|
||||
`;
|
||||
|
||||
const MaxHeight = styled.div`
|
||||
position: relative;
|
||||
max-height: 25vh;
|
||||
overflow: hidden;
|
||||
margin: -8px;
|
||||
padding: 8px;
|
||||
|
||||
&[data-editing="true"],
|
||||
&[data-expanded="true"] {
|
||||
max-height: initial;
|
||||
overflow: initial;
|
||||
|
||||
${Disclosure} {
|
||||
top: initial;
|
||||
bottom: 0;
|
||||
transform: rotate(90deg) translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover ${Disclosure} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Input = styled.div`
|
||||
margin: -8px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: calc(25vh - 50px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
${(props) => transparentize(1, props.theme.background)} 0%,
|
||||
${(props) => props.theme.background} 100%
|
||||
);
|
||||
}
|
||||
|
||||
&[data-editing="true"],
|
||||
&[data-expanded="true"] {
|
||||
&:after {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-editing="true"] {
|
||||
background: ${(props) => props.theme.secondaryBackground};
|
||||
}
|
||||
|
||||
.block-menu-trigger,
|
||||
.heading-anchor {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(CollectionDescription);
|
||||
@@ -163,11 +163,8 @@ const DocumentLink = styled(Link)`
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
max-height: 50vh;
|
||||
width: calc(100vw - 8px);
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: auto;
|
||||
`};
|
||||
min-width: 100%;
|
||||
max-width: calc(100vw - 40px);
|
||||
|
||||
${Actions} {
|
||||
opacity: 0;
|
||||
|
||||
@@ -27,16 +27,13 @@ export type Props = {|
|
||||
autoFocus?: boolean,
|
||||
template?: boolean,
|
||||
placeholder?: string,
|
||||
maxLength?: number,
|
||||
scrollTo?: string,
|
||||
handleDOMEvents?: Object,
|
||||
readOnlyWriteCheckboxes?: boolean,
|
||||
onBlur?: (event: SyntheticEvent<>) => any,
|
||||
onFocus?: (event: SyntheticEvent<>) => any,
|
||||
onPublish?: (event: SyntheticEvent<>) => any,
|
||||
onSave?: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
|
||||
onCancel?: () => any,
|
||||
onDoubleClick?: () => any,
|
||||
onChange?: (getValue: () => string) => any,
|
||||
onSearchLink?: (title: string) => any,
|
||||
onHoverLink?: (event: MouseEvent) => any,
|
||||
@@ -180,7 +177,7 @@ const StyledEditor = styled(RichMarkdownEditor)`
|
||||
justify-content: start;
|
||||
|
||||
> div {
|
||||
background: transparent;
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
}
|
||||
|
||||
& * {
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
// @flow
|
||||
import { throttle } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
type Props = {|
|
||||
breadcrumb?: React.Node,
|
||||
title: React.Node,
|
||||
actions?: React.Node,
|
||||
|};
|
||||
|
||||
function Header({ breadcrumb, title, actions }: Props) {
|
||||
const [isScrolled, setScrolled] = React.useState(false);
|
||||
|
||||
const handleScroll = React.useCallback(
|
||||
throttle(() => setScrolled(window.scrollY > 75), 50),
|
||||
[]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
const handleClickTitle = React.useCallback(() => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
align="center"
|
||||
justify="space-between"
|
||||
isCompact={isScrolled}
|
||||
shrink={false}
|
||||
>
|
||||
{breadcrumb}
|
||||
{isScrolled ? (
|
||||
<Title
|
||||
align="center"
|
||||
justify={breadcrumb ? "center" : "flex-start"}
|
||||
onClick={handleClickTitle}
|
||||
>
|
||||
<Fade>
|
||||
<Flex align="center">{title}</Flex>
|
||||
</Fade>
|
||||
</Title>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{actions && <Actions>{actions}</Actions>}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
position: sticky;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
background: ${(props) => transparentize(0.2, props.theme.background)};
|
||||
padding: 12px;
|
||||
transition: all 100ms ease-out;
|
||||
transform: translate3d(0, 0, 0);
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
|
||||
`};
|
||||
`;
|
||||
|
||||
const Title = styled(Flex)`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
width: 0;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
flex-grow: 1;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Actions = styled(Flex)`
|
||||
align-self: flex-end;
|
||||
height: 32px;
|
||||
`;
|
||||
|
||||
export default observer(Header);
|
||||
@@ -4,7 +4,6 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
const RealTextarea = styled.textarea`
|
||||
@@ -34,10 +33,6 @@ const RealInput = styled.input`
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
font-size: 16px;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Helmet } from "react-helmet";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import keydown from "react-keydown";
|
||||
import { Switch, Route, Redirect } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
@@ -24,6 +24,7 @@ import Sidebar from "components/Sidebar";
|
||||
import SettingsSidebar from "components/Sidebar/Settings";
|
||||
import SkipNavContent from "components/SkipNavContent";
|
||||
import SkipNavLink from "components/SkipNavLink";
|
||||
import { type Theme } from "types";
|
||||
import { meta } from "utils/keyboard";
|
||||
import {
|
||||
homeUrl,
|
||||
@@ -39,6 +40,7 @@ type Props = {
|
||||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
notifications?: React.Node,
|
||||
theme: Theme,
|
||||
i18n: Object,
|
||||
t: TFunction,
|
||||
};
|
||||
@@ -49,12 +51,24 @@ class Layout extends React.Component<Props> {
|
||||
@observable redirectTo: ?string;
|
||||
@observable keyboardShortcutsOpen: boolean = false;
|
||||
|
||||
constructor(props: Props) {
|
||||
super();
|
||||
this.updateBackground(props);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.updateBackground(this.props);
|
||||
|
||||
if (this.redirectTo) {
|
||||
this.redirectTo = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
updateBackground(props: Props) {
|
||||
// ensure the wider page color always matches the theme
|
||||
window.document.body.style.background = props.theme.background;
|
||||
}
|
||||
|
||||
@keydown(`${meta}+.`)
|
||||
handleToggleSidebar() {
|
||||
this.props.ui.toggleCollapsedSidebar();
|
||||
@@ -62,6 +76,7 @@ class Layout extends React.Component<Props> {
|
||||
|
||||
@keydown("shift+/")
|
||||
handleOpenKeyboardShortcuts() {
|
||||
if (this.props.ui.editMode) return;
|
||||
this.keyboardShortcutsOpen = true;
|
||||
}
|
||||
|
||||
@@ -71,6 +86,7 @@ class Layout extends React.Component<Props> {
|
||||
|
||||
@keydown(["t", "/", `${meta}+k`])
|
||||
goToSearch(ev: SyntheticEvent<>) {
|
||||
if (this.props.ui.editMode) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.redirectTo = searchUrl();
|
||||
@@ -78,6 +94,7 @@ class Layout extends React.Component<Props> {
|
||||
|
||||
@keydown("d")
|
||||
goToDashboard() {
|
||||
if (this.props.ui.editMode) return;
|
||||
this.redirectTo = homeUrl();
|
||||
}
|
||||
|
||||
@@ -85,7 +102,7 @@ class Layout extends React.Component<Props> {
|
||||
const { auth, t, ui } = this.props;
|
||||
const { user, team } = auth;
|
||||
const showSidebar = auth.authenticated && user && team;
|
||||
const sidebarCollapsed = ui.isEditing || ui.sidebarCollapsed;
|
||||
const sidebarCollapsed = ui.editMode || ui.sidebarCollapsed;
|
||||
|
||||
if (auth.isSuspended) return <ErrorSuspended />;
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
@@ -198,5 +215,5 @@ const Content = styled(Flex)`
|
||||
`;
|
||||
|
||||
export default withTranslation()<Layout>(
|
||||
inject("auth", "ui", "documents")(Layout)
|
||||
inject("auth", "ui", "documents")(withTheme(Layout))
|
||||
);
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { useTheme } from "styled-components";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
export default function PageTheme() {
|
||||
const { ui } = useStores();
|
||||
const theme = useTheme();
|
||||
|
||||
React.useEffect(() => {
|
||||
// wider page background beyond the React root
|
||||
if (document.body) {
|
||||
document.body.style.background = theme.background;
|
||||
}
|
||||
|
||||
// theme-color adjusts the title bar color for desktop PWA
|
||||
const themeElement = document.querySelector('meta[name="theme-color"]');
|
||||
if (themeElement) {
|
||||
themeElement.setAttribute("content", theme.background);
|
||||
}
|
||||
|
||||
// status bar color for iOS PWA
|
||||
const statusElement = document.querySelector(
|
||||
'meta[name="apple-mobile-web-app-status-bar-style"]'
|
||||
);
|
||||
if (statusElement) {
|
||||
statusElement.setAttribute(
|
||||
"content",
|
||||
ui.resolvedTheme === "dark" ? "black-translucent" : "default"
|
||||
);
|
||||
}
|
||||
}, [theme, ui.resolvedTheme]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// @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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import Header from "components/Header";
|
||||
import PageTitle from "components/PageTitle";
|
||||
|
||||
type Props = {|
|
||||
icon?: React.Node,
|
||||
title: React.Node,
|
||||
textTitle?: string,
|
||||
children: React.Node,
|
||||
breadcrumb?: React.Node,
|
||||
actions?: React.Node,
|
||||
|};
|
||||
|
||||
function Scene({
|
||||
title,
|
||||
icon,
|
||||
textTitle,
|
||||
actions,
|
||||
breadcrumb,
|
||||
children,
|
||||
}: Props) {
|
||||
return (
|
||||
<FillWidth>
|
||||
<PageTitle title={textTitle || title} />
|
||||
<Header
|
||||
title={
|
||||
icon ? (
|
||||
<>
|
||||
{icon} {title}
|
||||
</>
|
||||
) : (
|
||||
title
|
||||
)
|
||||
}
|
||||
actions={actions}
|
||||
breadcrumb={breadcrumb}
|
||||
/>
|
||||
<CenteredContent withStickyHeader>{children}</CenteredContent>
|
||||
</FillWidth>
|
||||
);
|
||||
}
|
||||
|
||||
const FillWidth = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export default Scene;
|
||||
@@ -16,15 +16,15 @@ import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import CollectionNew from "scenes/CollectionNew";
|
||||
import Invite from "scenes/Invite";
|
||||
import Bubble from "components/Bubble";
|
||||
import Flex from "components/Flex";
|
||||
import Modal from "components/Modal";
|
||||
import Scrollable from "components/Scrollable";
|
||||
import Sidebar from "./Sidebar";
|
||||
import Bubble from "./components/Bubble";
|
||||
import Collections from "./components/Collections";
|
||||
import HeaderBlock from "./components/HeaderBlock";
|
||||
import Section from "./components/Section";
|
||||
import SidebarLink from "./components/SidebarLink";
|
||||
import TeamButton from "./components/TeamButton";
|
||||
import useStores from "hooks/useStores";
|
||||
import AccountMenu from "menus/AccountMenu";
|
||||
|
||||
@@ -72,7 +72,7 @@ function MainSidebar() {
|
||||
<Sidebar>
|
||||
<AccountMenu>
|
||||
{(props) => (
|
||||
<TeamButton
|
||||
<HeaderBlock
|
||||
{...props}
|
||||
subheading={user.name}
|
||||
teamName={team.name}
|
||||
@@ -118,7 +118,9 @@ function MainSidebar() {
|
||||
label={
|
||||
<Drafts align="center">
|
||||
{t("Drafts")}
|
||||
<Bubble count={documents.totalDrafts} />
|
||||
{documents.totalDrafts > 0 && (
|
||||
<Bubble count={documents.totalDrafts} />
|
||||
)}
|
||||
</Drafts>
|
||||
}
|
||||
active={
|
||||
|
||||
@@ -21,9 +21,9 @@ import Scrollable from "components/Scrollable";
|
||||
|
||||
import Sidebar from "./Sidebar";
|
||||
import Header from "./components/Header";
|
||||
import HeaderBlock from "./components/HeaderBlock";
|
||||
import Section from "./components/Section";
|
||||
import SidebarLink from "./components/SidebarLink";
|
||||
import TeamButton from "./components/TeamButton";
|
||||
import Version from "./components/Version";
|
||||
import SlackIcon from "./icons/Slack";
|
||||
import ZapierIcon from "./icons/Zapier";
|
||||
@@ -46,7 +46,7 @@ function SettingsSidebar() {
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<TeamButton
|
||||
<HeaderBlock
|
||||
subheading={
|
||||
<ReturnToApp align="center">
|
||||
<BackIcon color="currentColor" /> {t("Return to App")}
|
||||
|
||||
@@ -3,37 +3,29 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Portal } from "react-portal";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import type { Location } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import CollapseToggle, {
|
||||
Button as CollapseButton,
|
||||
} from "./components/CollapseToggle";
|
||||
import ResizeBorder from "./components/ResizeBorder";
|
||||
import Toggle, { ToggleButton, Positioner } from "./components/Toggle";
|
||||
import ResizeHandle from "./components/ResizeHandle";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
let firstRender = true;
|
||||
let ANIMATION_MS = 250;
|
||||
let BOUNCE_ANIMATION_MS = 250;
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
location: Location,
|
||||
};
|
||||
|
||||
function Sidebar({ children }: Props) {
|
||||
const [isCollapsing, setCollapsing] = React.useState(false);
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
const location = useLocation();
|
||||
const previousLocation = usePrevious(location);
|
||||
|
||||
const width = ui.sidebarWidth;
|
||||
const collapsed = ui.isEditing || ui.sidebarCollapsed;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
const setWidth = ui.setSidebarWidth;
|
||||
|
||||
const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [isAnimating, setAnimating] = React.useState(false);
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
@@ -46,45 +38,24 @@ function Sidebar({ children }: Props) {
|
||||
|
||||
// this is simple because the sidebar is always against the left edge
|
||||
const width = Math.min(event.pageX - offset, maxWidth);
|
||||
const isSmallerThanCollapsePoint = width < minWidth / 2;
|
||||
|
||||
if (isSmallerThanCollapsePoint) {
|
||||
setWidth(theme.sidebarCollapsedWidth);
|
||||
} else {
|
||||
setWidth(width);
|
||||
}
|
||||
setWidth(width);
|
||||
},
|
||||
[theme, offset, minWidth, maxWidth, setWidth]
|
||||
[offset, maxWidth, setWidth]
|
||||
);
|
||||
|
||||
const handleStopDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
setResizing(false);
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
setResizing(false);
|
||||
|
||||
if (document.activeElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
if (isSmallerThanMinimum) {
|
||||
setWidth(minWidth);
|
||||
setAnimating(true);
|
||||
} else {
|
||||
setWidth(width);
|
||||
}
|
||||
}, [isSmallerThanMinimum, minWidth, width, setWidth]);
|
||||
|
||||
if (isSmallerThanMinimum) {
|
||||
const isSmallerThanCollapsePoint = width < minWidth / 2;
|
||||
|
||||
if (isSmallerThanCollapsePoint) {
|
||||
setAnimating(false);
|
||||
setCollapsing(true);
|
||||
ui.collapseSidebar();
|
||||
} else {
|
||||
setWidth(minWidth);
|
||||
setAnimating(true);
|
||||
}
|
||||
} else {
|
||||
setWidth(width);
|
||||
}
|
||||
},
|
||||
[ui, isSmallerThanMinimum, minWidth, width, setWidth]
|
||||
);
|
||||
|
||||
const handleMouseDown = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
const handleStartDrag = React.useCallback(
|
||||
(event) => {
|
||||
setOffset(event.pageX - width);
|
||||
setResizing(true);
|
||||
setAnimating(false);
|
||||
@@ -94,19 +65,10 @@ function Sidebar({ children }: Props) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isAnimating) {
|
||||
setTimeout(() => setAnimating(false), ANIMATION_MS);
|
||||
setTimeout(() => setAnimating(false), BOUNCE_ANIMATION_MS);
|
||||
}
|
||||
}, [isAnimating]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isCollapsing) {
|
||||
setTimeout(() => {
|
||||
setWidth(minWidth);
|
||||
setCollapsing(false);
|
||||
}, ANIMATION_MS);
|
||||
}
|
||||
}, [setWidth, minWidth, isCollapsing]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isResizing) {
|
||||
document.addEventListener("mousemove", handleDrag);
|
||||
@@ -119,6 +81,32 @@ function Sidebar({ children }: Props) {
|
||||
};
|
||||
}, [isResizing, handleDrag, handleStopDrag]);
|
||||
|
||||
return { isAnimating, isSmallerThanMinimum, isResizing, handleStartDrag };
|
||||
};
|
||||
|
||||
function Sidebar({ location, children }: Props) {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
const previousLocation = usePrevious(location);
|
||||
|
||||
const width = ui.sidebarWidth;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
const collapsed = ui.editMode || ui.sidebarCollapsed;
|
||||
|
||||
const {
|
||||
isAnimating,
|
||||
isSmallerThanMinimum,
|
||||
isResizing,
|
||||
handleStartDrag,
|
||||
} = useResize({
|
||||
width,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
setWidth: ui.setSidebarWidth,
|
||||
});
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
ui.setSidebarWidth(theme.sidebarWidth);
|
||||
}, [ui, theme.sidebarWidth]);
|
||||
@@ -136,60 +124,49 @@ function Sidebar({ children }: Props) {
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
width: `${width}px`,
|
||||
left:
|
||||
collapsed && !ui.mobileSidebarVisible
|
||||
? `${-width + theme.sidebarCollapsedWidth}px`
|
||||
: 0,
|
||||
}),
|
||||
[width]
|
||||
);
|
||||
|
||||
const toggleStyle = React.useMemo(
|
||||
() => ({
|
||||
right: "auto",
|
||||
marginLeft: `${collapsed ? theme.sidebarCollapsedWidth : width}px`,
|
||||
}),
|
||||
[width, theme.sidebarCollapsedWidth, collapsed]
|
||||
[width, collapsed, theme.sidebarCollapsedWidth, ui.mobileSidebarVisible]
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<Container
|
||||
style={style}
|
||||
$sidebarWidth={ui.sidebarWidth}
|
||||
$isCollapsing={isCollapsing}
|
||||
$isAnimating={isAnimating}
|
||||
$isSmallerThanMinimum={isSmallerThanMinimum}
|
||||
$mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
$collapsed={collapsed}
|
||||
column
|
||||
>
|
||||
{ui.mobileSidebarVisible && (
|
||||
<Portal>
|
||||
<Fade>
|
||||
<Background onClick={ui.toggleMobileSidebar} />
|
||||
</Fade>
|
||||
</Portal>
|
||||
)}
|
||||
{children}
|
||||
<ResizeBorder
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={ui.sidebarCollapsed ? undefined : handleReset}
|
||||
$isResizing={isResizing}
|
||||
/>
|
||||
{ui.sidebarCollapsed && !ui.isEditing && (
|
||||
<Toggle
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
direction={"right"}
|
||||
aria-label={t("Expand")}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
{!ui.isEditing && (
|
||||
<Toggle
|
||||
style={toggleStyle}
|
||||
<Container
|
||||
style={style}
|
||||
$sidebarWidth={ui.sidebarWidth}
|
||||
$isAnimating={isAnimating}
|
||||
$isSmallerThanMinimum={isSmallerThanMinimum}
|
||||
$mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
$collapsed={collapsed}
|
||||
column
|
||||
>
|
||||
{!isResizing && (
|
||||
<CollapseToggle
|
||||
collapsed={ui.sidebarCollapsed}
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
direction={ui.sidebarCollapsed ? "right" : "left"}
|
||||
aria-label={ui.sidebarCollapsed ? t("Expand") : t("Collapse")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{ui.mobileSidebarVisible && (
|
||||
<Portal>
|
||||
<Fade>
|
||||
<Background onClick={ui.toggleMobileSidebar} />
|
||||
</Fade>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
{children}
|
||||
{!ui.sidebarCollapsed && (
|
||||
<ResizeBorder
|
||||
onMouseDown={handleStartDrag}
|
||||
onDoubleClick={handleReset}
|
||||
$isResizing={isResizing}
|
||||
>
|
||||
<ResizeHandle aria-label={t("Resize sidebar")} />
|
||||
</ResizeBorder>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
|
||||
// Fade in the sidebar on first render after page load
|
||||
@@ -218,36 +195,29 @@ const Container = styled(Flex)`
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
transition: box-shadow 100ms ease-in-out, transform 100ms ease-out,
|
||||
transition: box-shadow, 100ms, ease-in-out, margin-left 100ms ease-out,
|
||||
left 100ms ease-out,
|
||||
${(props) => props.theme.backgroundTransition}
|
||||
${(props) =>
|
||||
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
|
||||
transform: translateX(
|
||||
${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")}
|
||||
);
|
||||
props.$isAnimating ? `,width ${BOUNCE_ANIMATION_MS}ms ease-out` : ""};
|
||||
margin-left: ${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")};
|
||||
z-index: ${(props) => props.theme.depths.sidebar};
|
||||
max-width: 70%;
|
||||
min-width: 280px;
|
||||
|
||||
${Positioner} {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
transform: none;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
margin: 0;
|
||||
z-index: 3;
|
||||
min-width: 0;
|
||||
transform: translateX(${(props) =>
|
||||
props.$collapsed ? "calc(-100% + 16px)" : 0});
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
transform: none;
|
||||
left: 0 !important;
|
||||
box-shadow: ${(props) =>
|
||||
props.$collapsed
|
||||
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
|
||||
@@ -255,11 +225,11 @@ const Container = styled(Flex)`
|
||||
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
|
||||
: "none"};
|
||||
|
||||
${Positioner} {
|
||||
display: block;
|
||||
& ${CollapseButton} {
|
||||
opacity: .75;
|
||||
}
|
||||
|
||||
${ToggleButton} {
|
||||
& ${CollapseButton}:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -271,4 +241,4 @@ const Container = styled(Flex)`
|
||||
`};
|
||||
`;
|
||||
|
||||
export default observer(Sidebar);
|
||||
export default withRouter(observer(Sidebar));
|
||||
|
||||
@@ -3,15 +3,11 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { bounceIn } from "shared/styles/animations";
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
count: number,
|
||||
|};
|
||||
};
|
||||
|
||||
const Bubble = ({ count }: Props) => {
|
||||
if (!count) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Count>{count}</Count>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// @flow
|
||||
import { NextIcon, BackIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import { meta } from "utils/keyboard";
|
||||
|
||||
type Props = {|
|
||||
collapsed: boolean,
|
||||
onClick?: (event: SyntheticEvent<>) => void,
|
||||
|};
|
||||
|
||||
function CollapseToggle({ collapsed, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
tooltip={collapsed ? t("Expand") : t("Collapse")}
|
||||
shortcut={`${meta}+.`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button {...rest} tabIndex="-1" aria-hidden>
|
||||
{collapsed ? (
|
||||
<NextIcon color="currentColor" />
|
||||
) : (
|
||||
<BackIcon color="currentColor" />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export const Button = styled.button`
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
right: 8px;
|
||||
border: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
z-index: 1;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
background: transparent;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.white};
|
||||
background: ${(props) => props.theme.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
export default CollapseToggle;
|
||||
@@ -42,6 +42,8 @@ class Collections extends React.Component<Props> {
|
||||
|
||||
@keydown("n")
|
||||
goToNewDocument() {
|
||||
if (this.props.ui.editMode) return;
|
||||
|
||||
const { activeCollectionId } = this.props.ui;
|
||||
if (!activeCollectionId) return;
|
||||
|
||||
|
||||
+5
-4
@@ -13,7 +13,7 @@ type Props = {|
|
||||
logoUrl: string,
|
||||
|};
|
||||
|
||||
const TeamButton = React.forwardRef<Props, any>(
|
||||
const HeaderBlock = React.forwardRef<Props, any>(
|
||||
({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => (
|
||||
<Wrapper>
|
||||
<Header justify="flex-start" align="center" ref={ref} {...rest}>
|
||||
@@ -25,7 +25,8 @@ const TeamButton = React.forwardRef<Props, any>(
|
||||
/>
|
||||
<Flex align="flex-start" column>
|
||||
<TeamName showDisclosure>
|
||||
{teamName} {showDisclosure && <Disclosure color="currentColor" />}
|
||||
{teamName}{" "}
|
||||
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
|
||||
</TeamName>
|
||||
<Subheading>{subheading}</Subheading>
|
||||
</Flex>
|
||||
@@ -34,7 +35,7 @@ const TeamButton = React.forwardRef<Props, any>(
|
||||
)
|
||||
);
|
||||
|
||||
const Disclosure = styled(ExpandedIcon)`
|
||||
const StyledExpandedIcon = styled(ExpandedIcon)`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
@@ -83,4 +84,4 @@ const Header = styled.button`
|
||||
}
|
||||
`;
|
||||
|
||||
export default TeamButton;
|
||||
export default HeaderBlock;
|
||||
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
import ResizeHandle from "./ResizeHandle";
|
||||
|
||||
const ResizeBorder = styled.div`
|
||||
position: absolute;
|
||||
@@ -8,6 +9,20 @@ const ResizeBorder = styled.div`
|
||||
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;
|
||||
@@ -1,66 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Arrow from "components/Arrow";
|
||||
|
||||
type Props = {
|
||||
direction: "left" | "right",
|
||||
style?: Object,
|
||||
onClick?: () => any,
|
||||
};
|
||||
|
||||
const Toggle = React.forwardRef<Props, HTMLButtonElement>(
|
||||
({ direction = "left", onClick, style }: Props, ref) => {
|
||||
return (
|
||||
<Positioner style={style}>
|
||||
<ToggleButton ref={ref} $direction={direction} onClick={onClick}>
|
||||
<Arrow />
|
||||
</ToggleButton>
|
||||
</Positioner>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const ToggleButton = styled.button`
|
||||
opacity: 0;
|
||||
background: none;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
transform: translateY(-50%)
|
||||
scaleX(${(props) => (props.$direction === "left" ? 1 : -1)});
|
||||
position: absolute;
|
||||
top: 50vh;
|
||||
padding: 8px;
|
||||
border: 0;
|
||||
pointer-events: none;
|
||||
color: ${(props) => props.theme.divider};
|
||||
|
||||
&:active {
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
pointer-events: all;
|
||||
cursor: pointer;
|
||||
`}
|
||||
`;
|
||||
|
||||
export const Positioner = styled.div`
|
||||
display: none;
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: -30px;
|
||||
width: 30px;
|
||||
|
||||
&:hover ${ToggleButton}, &:focus-within ${ToggleButton} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: block;
|
||||
`}
|
||||
`;
|
||||
|
||||
export default Toggle;
|
||||
@@ -2,17 +2,19 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
|};
|
||||
};
|
||||
|
||||
const H3 = styled.h3`
|
||||
border-bottom: 1px solid ${(props) => props.theme.divider};
|
||||
margin: 12px 0;
|
||||
margin-top: 22px;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Underline = styled.div`
|
||||
const Underline = styled("span")`
|
||||
margin-top: -1px;
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
@@ -20,29 +22,14 @@ const Underline = styled.div`
|
||||
line-height: 1.5;
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
border-bottom: 3px solid ${(props) => props.theme.textSecondary};
|
||||
padding-top: 6px;
|
||||
padding-bottom: 4px;
|
||||
`;
|
||||
|
||||
// When sticky we need extra background coverage around the sides otherwise
|
||||
// items that scroll past can "stick out" the sides of the heading
|
||||
const Sticky = styled.div`
|
||||
position: sticky;
|
||||
top: 54px;
|
||||
margin: 0 -8px;
|
||||
padding: 0 8px;
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
z-index: 1;
|
||||
padding-bottom: 5px;
|
||||
`;
|
||||
|
||||
const Subheading = ({ children, ...rest }: Props) => {
|
||||
return (
|
||||
<Sticky>
|
||||
<H3 {...rest}>
|
||||
<Underline>{children}</Underline>
|
||||
</H3>
|
||||
</Sticky>
|
||||
<H3 {...rest}>
|
||||
<Underline>{children}</Underline>
|
||||
</H3>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -13,23 +13,17 @@ type Props = {|
|
||||
id?: string,
|
||||
|};
|
||||
|
||||
function Switch({ width = 38, height = 20, label, disabled, ...props }: Props) {
|
||||
function Switch({ width = 38, height = 20, label, ...props }: Props) {
|
||||
const component = (
|
||||
<Wrapper width={width} height={height}>
|
||||
<HiddenInput
|
||||
type="checkbox"
|
||||
width={width}
|
||||
height={height}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
<HiddenInput type="checkbox" width={width} height={height} {...props} />
|
||||
<Slider width={width} height={height} />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
if (label) {
|
||||
return (
|
||||
<Label disabled={disabled} htmlFor={props.id}>
|
||||
<Label htmlFor={props.id}>
|
||||
{component}
|
||||
<LabelText>{label}</LabelText>
|
||||
</Label>
|
||||
@@ -42,8 +36,6 @@ function Switch({ width = 38, height = 20, label, disabled, ...props }: Props) {
|
||||
const Label = styled.label`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
${(props) => (props.disabled ? `opacity: 0.75;` : "")}
|
||||
`;
|
||||
|
||||
const Wrapper = styled.label`
|
||||
@@ -87,11 +79,6 @@ const HiddenInput = styled.input`
|
||||
height: 0;
|
||||
visibility: hidden;
|
||||
|
||||
&:disabled + ${Slider} {
|
||||
opacity: 0.75;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:checked + ${Slider} {
|
||||
background-color: ${(props) => props.theme.primary};
|
||||
}
|
||||
|
||||
@@ -8,15 +8,15 @@ type Props = {
|
||||
theme: Theme,
|
||||
};
|
||||
|
||||
const TabLink = styled(NavLink)`
|
||||
const StyledNavLink = styled(NavLink)`
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
margin-right: 24px;
|
||||
padding: 6px 0;
|
||||
padding-bottom: 8px;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
@@ -32,7 +32,7 @@ function Tab({ theme, ...rest }: Props) {
|
||||
color: theme.textSecondary,
|
||||
};
|
||||
|
||||
return <TabLink {...rest} activeStyle={activeStyle} />;
|
||||
return <StyledNavLink {...rest} activeStyle={activeStyle} />;
|
||||
}
|
||||
|
||||
export default withTheme(Tab);
|
||||
|
||||
+4
-24
@@ -1,25 +1,13 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Nav = styled.nav`
|
||||
const Tabs = styled.nav`
|
||||
position: relative;
|
||||
border-bottom: 1px solid ${(props) => props.theme.divider};
|
||||
margin: 12px 0;
|
||||
margin-top: 22px;
|
||||
margin-bottom: 12px;
|
||||
overflow-y: auto;
|
||||
white-space: nowrap;
|
||||
transition: opacity 250ms ease-out;
|
||||
`;
|
||||
|
||||
// When sticky we need extra background coverage around the sides otherwise
|
||||
// items that scroll past can "stick out" the sides of the heading
|
||||
const Sticky = styled.div`
|
||||
position: sticky;
|
||||
top: 54px;
|
||||
margin: 0 -8px;
|
||||
padding: 0 8px;
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
export const Separator = styled.span`
|
||||
@@ -30,12 +18,4 @@ export const Separator = styled.span`
|
||||
margin-top: 6px;
|
||||
`;
|
||||
|
||||
const Tabs = (props: {}) => {
|
||||
return (
|
||||
<Sticky>
|
||||
<Nav {...props}></Nav>
|
||||
</Sticky>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tabs;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Toast from "components/Toast";
|
||||
import Toast from "./components/Toast";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
function Toasts() {
|
||||
@@ -0,0 +1,3 @@
|
||||
// @flow
|
||||
import Toasts from "./Toasts";
|
||||
export default Toasts;
|
||||
@@ -1,31 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
export default function useDebouncedCallback(
|
||||
callback: (any) => mixed,
|
||||
wait: number
|
||||
) {
|
||||
// track args & timeout handle between calls
|
||||
const argsRef = React.useRef();
|
||||
const timeout = React.useRef();
|
||||
|
||||
function cleanup() {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
}
|
||||
|
||||
// make sure our timeout gets cleared if consuming component gets unmounted
|
||||
React.useEffect(() => cleanup, []);
|
||||
|
||||
return function (...args: any) {
|
||||
argsRef.current = args;
|
||||
cleanup();
|
||||
|
||||
timeout.current = setTimeout(() => {
|
||||
if (argsRef.current) {
|
||||
callback(...argsRef.current);
|
||||
}
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
+1
-18
@@ -10,7 +10,6 @@ import { Router } from "react-router-dom";
|
||||
import { initI18n } from "shared/i18n";
|
||||
import stores from "stores";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import PageTheme from "components/PageTheme";
|
||||
import ScrollToTop from "components/ScrollToTop";
|
||||
import Theme from "components/Theme";
|
||||
import Toasts from "components/Toasts";
|
||||
@@ -20,28 +19,13 @@ import { initSentry } from "utils/sentry";
|
||||
|
||||
initI18n();
|
||||
|
||||
const element = window.document.getElementById("root");
|
||||
const element = document.getElementById("root");
|
||||
const history = createBrowserHistory();
|
||||
|
||||
if (env.SENTRY_DSN) {
|
||||
initSentry(history);
|
||||
}
|
||||
|
||||
if ("serviceWorker" in window.navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
window.navigator.serviceWorker
|
||||
.register("/static/service-worker.js", {
|
||||
scope: "/",
|
||||
})
|
||||
.then((registration) => {
|
||||
console.log("SW registered: ", registration);
|
||||
})
|
||||
.catch((registrationError) => {
|
||||
console.log("SW registration failed: ", registrationError);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (element) {
|
||||
render(
|
||||
<Provider {...stores}>
|
||||
@@ -50,7 +34,6 @@ if (element) {
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<Router history={history}>
|
||||
<>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
|
||||
+10
-13
@@ -64,10 +64,6 @@ function CollectionMenu({
|
||||
[history, collection.id]
|
||||
);
|
||||
|
||||
const stopPropagation = React.useCallback((ev: SyntheticEvent<>) => {
|
||||
ev.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleImportDocument = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
@@ -87,19 +83,20 @@ function CollectionMenu({
|
||||
|
||||
try {
|
||||
const file = files[0];
|
||||
const document = await documents.import(file, null, collection.id, {
|
||||
publish: 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",
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[history, ui, collection.id, documents]
|
||||
[history, ui, documents]
|
||||
);
|
||||
|
||||
const can = policies.abilities(collection.id);
|
||||
@@ -111,7 +108,7 @@ function CollectionMenu({
|
||||
type="file"
|
||||
ref={file}
|
||||
onChange={handleFilePicked}
|
||||
onClick={stopPropagation}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
accept={documents.importFileTypes.join(", ")}
|
||||
tabIndex="-1"
|
||||
/>
|
||||
@@ -149,7 +146,7 @@ function CollectionMenu({
|
||||
onClick: () => setShowCollectionEdit(true),
|
||||
},
|
||||
{
|
||||
title: `${t("Members")}…`,
|
||||
title: `${t("Permissions")}…`,
|
||||
visible: can.update,
|
||||
onClick: () => setShowCollectionMembers(true),
|
||||
},
|
||||
@@ -175,7 +172,7 @@ function CollectionMenu({
|
||||
{renderModals && (
|
||||
<>
|
||||
<Modal
|
||||
title={t("Collection members")}
|
||||
title={t("Collection permissions")}
|
||||
onRequestClose={() => setShowCollectionMembers(false)}
|
||||
isOpen={showCollectionMembers}
|
||||
>
|
||||
|
||||
@@ -15,7 +15,6 @@ import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import Flex from "components/Flex";
|
||||
import Modal from "components/Modal";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
import {
|
||||
documentHistoryUrl,
|
||||
@@ -50,8 +49,7 @@ function DocumentMenu({
|
||||
onOpen,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const team = useCurrentTeam();
|
||||
const { policies, collections, ui } = useStores();
|
||||
const { policies, collections, auth, ui } = useStores();
|
||||
const menu = useMenuState({ modal });
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
@@ -132,10 +130,10 @@ function DocumentMenu({
|
||||
[document]
|
||||
);
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
const can = policies.abilities(document.id);
|
||||
const canShareDocuments = !!(can.share && team.sharing);
|
||||
const canShareDocuments = !!(can.share && auth.team && auth.team.sharing);
|
||||
const canViewHistory = can.read && !can.restore;
|
||||
const collection = collections.get(document.collectionId);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -16,7 +16,6 @@ export default class Collection extends BaseModel {
|
||||
icon: string;
|
||||
color: string;
|
||||
private: boolean;
|
||||
sharing: boolean;
|
||||
documents: NavigationNode[];
|
||||
createdAt: ?string;
|
||||
updatedAt: ?string;
|
||||
@@ -113,7 +112,6 @@ export default class Collection extends BaseModel {
|
||||
"name",
|
||||
"color",
|
||||
"description",
|
||||
"sharing",
|
||||
"icon",
|
||||
"private",
|
||||
"sort",
|
||||
|
||||
@@ -3,11 +3,11 @@ import * as React from "react";
|
||||
import { Switch, Redirect, type Match } from "react-router-dom";
|
||||
import Archive from "scenes/Archive";
|
||||
import Collection from "scenes/Collection";
|
||||
import Dashboard from "scenes/Dashboard";
|
||||
import KeyedDocument from "scenes/Document/KeyedDocument";
|
||||
import DocumentNew from "scenes/DocumentNew";
|
||||
import Drafts from "scenes/Drafts";
|
||||
import Error404 from "scenes/Error404";
|
||||
import Home from "scenes/Home";
|
||||
import Search from "scenes/Search";
|
||||
import Starred from "scenes/Starred";
|
||||
import Templates from "scenes/Templates";
|
||||
@@ -37,8 +37,8 @@ export default function AuthenticatedRoutes() {
|
||||
<Layout>
|
||||
<Switch>
|
||||
<Redirect from="/dashboard" to="/home" />
|
||||
<Route path="/home/:tab" component={Home} />
|
||||
<Route path="/home" component={Home} />
|
||||
<Route path="/home/:tab" component={Dashboard} />
|
||||
<Route path="/home" component={Dashboard} />
|
||||
<Route exact path="/starred" component={Starred} />
|
||||
<Route exact path="/starred/:sort" component={Starred} />
|
||||
<Route exact path="/templates" component={Templates} />
|
||||
|
||||
@@ -20,7 +20,7 @@ function Archive(props: Props) {
|
||||
const { documents } = props;
|
||||
|
||||
return (
|
||||
<CenteredContent>
|
||||
<CenteredContent column auto>
|
||||
<PageTitle title={t("Archive")} />
|
||||
<Heading>{t("Archive")}</Heading>
|
||||
<PaginatedDocumentList
|
||||
|
||||
+195
-171
@@ -1,26 +1,28 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
|
||||
import { NewDocumentIcon, PlusIcon, PinIcon, MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, Trans, type TFunction } from "react-i18next";
|
||||
import { Redirect, Link, Switch, Route, type Match } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Collection from "models/Collection";
|
||||
|
||||
import CollectionEdit from "scenes/CollectionEdit";
|
||||
import CollectionMembers from "scenes/CollectionMembers";
|
||||
import Search from "scenes/Search";
|
||||
import { Action, Separator } from "components/Actions";
|
||||
import Actions, { Action, Separator } from "components/Actions";
|
||||
import Button from "components/Button";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import CollectionDescription from "components/CollectionDescription";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import DocumentList from "components/DocumentList";
|
||||
import Editor from "components/Editor";
|
||||
import Flex from "components/Flex";
|
||||
import Heading from "components/Heading";
|
||||
import HelpText from "components/HelpText";
|
||||
@@ -28,13 +30,14 @@ import InputSearch from "components/InputSearch";
|
||||
import { ListPlaceholder } from "components/LoadingPlaceholder";
|
||||
import Mask from "components/Mask";
|
||||
import Modal from "components/Modal";
|
||||
import PageTitle from "components/PageTitle";
|
||||
import PaginatedDocumentList from "components/PaginatedDocumentList";
|
||||
import Scene from "components/Scene";
|
||||
import Subheading from "components/Subheading";
|
||||
import Tab from "components/Tab";
|
||||
import Tabs from "components/Tabs";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import CollectionMenu from "menus/CollectionMenu";
|
||||
import { type Theme } from "types";
|
||||
import { AuthorizationError } from "utils/errors";
|
||||
import { newDocumentUrl, collectionUrl } from "utils/routeHelpers";
|
||||
|
||||
@@ -44,6 +47,7 @@ type Props = {
|
||||
collections: CollectionsStore,
|
||||
policies: PoliciesStore,
|
||||
match: Match,
|
||||
theme: Theme,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@@ -53,6 +57,7 @@ class CollectionScene extends React.Component<Props> {
|
||||
@observable isFetching: boolean = true;
|
||||
@observable permissionsModalOpen: boolean = false;
|
||||
@observable editModalOpen: boolean = false;
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
componentDidMount() {
|
||||
const { id } = this.props.match.params;
|
||||
@@ -103,6 +108,14 @@ class CollectionScene extends React.Component<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
onNewDocument = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
|
||||
if (this.collection) {
|
||||
this.redirectTo = newDocumentUrl(this.collection.id);
|
||||
}
|
||||
};
|
||||
|
||||
onPermissions = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.permissionsModalOpen = true;
|
||||
@@ -125,7 +138,7 @@ class CollectionScene extends React.Component<Props> {
|
||||
const can = policies.abilities(match.params.id || "");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Actions align="center" justify="flex-end">
|
||||
{can.update && (
|
||||
<>
|
||||
<Action>
|
||||
@@ -144,12 +157,7 @@ class CollectionScene extends React.Component<Props> {
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
as={Link}
|
||||
to={this.collection ? newDocumentUrl(this.collection.id) : ""}
|
||||
disabled={!this.collection}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
<Button onClick={this.onNewDocument} icon={<PlusIcon />}>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -173,13 +181,14 @@ class CollectionScene extends React.Component<Props> {
|
||||
)}
|
||||
/>
|
||||
</Action>
|
||||
</>
|
||||
</Actions>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { documents, t } = this.props;
|
||||
const { documents, theme, t } = this.props;
|
||||
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
if (!this.isFetching && !this.collection) return <Search notFound />;
|
||||
|
||||
const pinnedDocuments = this.collection
|
||||
@@ -188,171 +197,181 @@ class CollectionScene extends React.Component<Props> {
|
||||
const collection = this.collection;
|
||||
const collectionName = collection ? collection.name : "";
|
||||
const hasPinnedDocuments = !!pinnedDocuments.length;
|
||||
const hasDescription = collection ? collection.hasDescription : false;
|
||||
|
||||
return collection ? (
|
||||
<Scene
|
||||
textTitle={collection.name}
|
||||
title={
|
||||
return (
|
||||
<CenteredContent>
|
||||
{collection ? (
|
||||
<>
|
||||
<CollectionIcon collection={collection} expanded />
|
||||
|
||||
{collection.name}
|
||||
</>
|
||||
}
|
||||
actions={this.renderActions()}
|
||||
>
|
||||
{collection.isEmpty ? (
|
||||
<Centered column>
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="<em>{{ collectionName }}</em> doesn’t contain any
|
||||
<PageTitle title={collection.name} />
|
||||
{collection.isEmpty ? (
|
||||
<Centered column>
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="<em>{{ collectionName }}</em> doesn’t contain any
|
||||
documents yet."
|
||||
values={{ collectionName }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
<br />
|
||||
<Trans>Get started by creating a new one!</Trans>
|
||||
</HelpText>
|
||||
<Empty>
|
||||
<Link to={newDocumentUrl(collection.id)}>
|
||||
<Button icon={<NewDocumentIcon color="currentColor" />}>
|
||||
{t("Create a document")}
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{collection.private && (
|
||||
<Button onClick={this.onPermissions} neutral>
|
||||
{t("Manage members")}…
|
||||
</Button>
|
||||
)}
|
||||
</Empty>
|
||||
<Modal
|
||||
title={t("Collection members")}
|
||||
onRequestClose={this.handlePermissionsModalClose}
|
||||
isOpen={this.permissionsModalOpen}
|
||||
>
|
||||
<CollectionMembers
|
||||
collection={this.collection}
|
||||
onSubmit={this.handlePermissionsModalClose}
|
||||
onEdit={this.handleEditModalOpen}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Edit collection")}
|
||||
onRequestClose={this.handleEditModalClose}
|
||||
isOpen={this.editModalOpen}
|
||||
>
|
||||
<CollectionEdit
|
||||
collection={this.collection}
|
||||
onSubmit={this.handleEditModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
</Centered>
|
||||
) : (
|
||||
<>
|
||||
<Heading>
|
||||
<CollectionIcon collection={collection} size={40} expanded />{" "}
|
||||
{collection.name}
|
||||
</Heading>
|
||||
<CollectionDescription collection={collection} />
|
||||
|
||||
{hasPinnedDocuments && (
|
||||
values={{ collectionName }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
<br />
|
||||
<Trans>Get started by creating a new one!</Trans>
|
||||
</HelpText>
|
||||
<Wrapper>
|
||||
<Link to={newDocumentUrl(collection.id)}>
|
||||
<Button icon={<NewDocumentIcon color={theme.buttonText} />}>
|
||||
{t("Create a document")}
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{collection.private && (
|
||||
<Button onClick={this.onPermissions} neutral>
|
||||
{t("Manage members")}…
|
||||
</Button>
|
||||
)}
|
||||
</Wrapper>
|
||||
<Modal
|
||||
title={t("Collection permissions")}
|
||||
onRequestClose={this.handlePermissionsModalClose}
|
||||
isOpen={this.permissionsModalOpen}
|
||||
>
|
||||
<CollectionMembers
|
||||
collection={this.collection}
|
||||
onSubmit={this.handlePermissionsModalClose}
|
||||
onEdit={this.handleEditModalOpen}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Edit collection")}
|
||||
onRequestClose={this.handleEditModalClose}
|
||||
isOpen={this.editModalOpen}
|
||||
>
|
||||
<CollectionEdit
|
||||
collection={this.collection}
|
||||
onSubmit={this.handleEditModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
</Centered>
|
||||
) : (
|
||||
<>
|
||||
<Subheading>
|
||||
<TinyPinIcon size={18} /> {t("Pinned")}
|
||||
</Subheading>
|
||||
<DocumentList documents={pinnedDocuments} showPin />
|
||||
<Heading>
|
||||
<CollectionIcon collection={collection} size={40} expanded />{" "}
|
||||
{collection.name}
|
||||
</Heading>
|
||||
|
||||
{hasDescription && (
|
||||
<React.Suspense fallback={<p>Loading…</p>}>
|
||||
<Editor
|
||||
id={collection.id}
|
||||
key={collection.description}
|
||||
defaultValue={collection.description}
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
)}
|
||||
|
||||
{hasPinnedDocuments && (
|
||||
<>
|
||||
<Subheading>
|
||||
<TinyPinIcon size={18} /> {t("Pinned")}
|
||||
</Subheading>
|
||||
<DocumentList documents={pinnedDocuments} showPin />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tabs>
|
||||
<Tab to={collectionUrl(collection.id)} exact>
|
||||
{t("Documents")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "updated")} exact>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "published")} exact>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "old")} exact>
|
||||
{t("Least recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "alphabetical")} exact>
|
||||
{t("A–Z")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Switch>
|
||||
<Route path={collectionUrl(collection.id, "alphabetical")}>
|
||||
<PaginatedDocumentList
|
||||
key="alphabetical"
|
||||
documents={documents.alphabeticalInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchAlphabetical}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "old")}>
|
||||
<PaginatedDocumentList
|
||||
key="old"
|
||||
documents={documents.leastRecentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchLeastRecentlyUpdated}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "recent")}>
|
||||
<Redirect to={collectionUrl(collection.id, "published")} />
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "published")}>
|
||||
<PaginatedDocumentList
|
||||
key="published"
|
||||
documents={documents.recentlyPublishedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyPublished}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPublished
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "updated")}>
|
||||
<PaginatedDocumentList
|
||||
key="updated"
|
||||
documents={documents.recentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyUpdated}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id)} exact>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.rootInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
sort: collection.sort.field,
|
||||
direction: "ASC",
|
||||
}}
|
||||
showNestedDocuments
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tabs>
|
||||
<Tab to={collectionUrl(collection.id)} exact>
|
||||
{t("Documents")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "updated")} exact>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "published")} exact>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "old")} exact>
|
||||
{t("Least recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "alphabetical")} exact>
|
||||
{t("A–Z")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Switch>
|
||||
<Route path={collectionUrl(collection.id, "alphabetical")}>
|
||||
<PaginatedDocumentList
|
||||
key="alphabetical"
|
||||
documents={documents.alphabeticalInCollection(collection.id)}
|
||||
fetch={documents.fetchAlphabetical}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "old")}>
|
||||
<PaginatedDocumentList
|
||||
key="old"
|
||||
documents={documents.leastRecentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchLeastRecentlyUpdated}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "recent")}>
|
||||
<Redirect to={collectionUrl(collection.id, "published")} />
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "published")}>
|
||||
<PaginatedDocumentList
|
||||
key="published"
|
||||
documents={documents.recentlyPublishedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyPublished}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPublished
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "updated")}>
|
||||
<PaginatedDocumentList
|
||||
key="updated"
|
||||
documents={documents.recentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyUpdated}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id)} exact>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.rootInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
sort: collection.sort.field,
|
||||
direction: "ASC",
|
||||
}}
|
||||
showNestedDocuments
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
{this.renderActions()}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Heading>
|
||||
<Mask height={35} />
|
||||
</Heading>
|
||||
<ListPlaceholder count={5} />
|
||||
</>
|
||||
)}
|
||||
</Scene>
|
||||
) : (
|
||||
<CenteredContent>
|
||||
<Heading>
|
||||
<Mask height={35} />
|
||||
</Heading>
|
||||
<ListPlaceholder count={5} />
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
@@ -371,11 +390,16 @@ const TinyPinIcon = styled(PinIcon)`
|
||||
opacity: 0.8;
|
||||
`;
|
||||
|
||||
const Empty = styled(Flex)`
|
||||
const Wrapper = styled(Flex)`
|
||||
justify-content: center;
|
||||
margin: 10px 0;
|
||||
`;
|
||||
|
||||
export default withTranslation()<CollectionScene>(
|
||||
inject("collections", "policies", "documents", "ui")(CollectionScene)
|
||||
inject(
|
||||
"collections",
|
||||
"policies",
|
||||
"documents",
|
||||
"ui"
|
||||
)(withTheme(CollectionScene))
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, Trans, type TFunction } from "react-i18next";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Collection from "models/Collection";
|
||||
import Button from "components/Button";
|
||||
@@ -11,13 +10,13 @@ import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import IconPicker from "components/IconPicker";
|
||||
import Input from "components/Input";
|
||||
import InputRich from "components/InputRich";
|
||||
import InputSelect from "components/InputSelect";
|
||||
import Switch from "components/Switch";
|
||||
|
||||
type Props = {
|
||||
collection: Collection,
|
||||
ui: UiStore,
|
||||
auth: AuthStore,
|
||||
onSubmit: () => void,
|
||||
t: TFunction,
|
||||
};
|
||||
@@ -25,7 +24,7 @@ type Props = {
|
||||
@observer
|
||||
class CollectionEdit extends React.Component<Props> {
|
||||
@observable name: string = this.props.collection.name;
|
||||
@observable sharing: boolean = this.props.collection.sharing;
|
||||
@observable description: string = this.props.collection.description;
|
||||
@observable icon: string = this.props.collection.icon;
|
||||
@observable color: string = this.props.collection.color || "#4E5C6E";
|
||||
@observable private: boolean = this.props.collection.private;
|
||||
@@ -41,10 +40,10 @@ class CollectionEdit extends React.Component<Props> {
|
||||
try {
|
||||
await this.props.collection.save({
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
icon: this.icon,
|
||||
color: this.color,
|
||||
private: this.private,
|
||||
sharing: this.sharing,
|
||||
sort: this.sort,
|
||||
});
|
||||
this.props.onSubmit();
|
||||
@@ -66,6 +65,10 @@ class CollectionEdit extends React.Component<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
handleDescriptionChange = (getValue: () => string) => {
|
||||
this.description = getValue();
|
||||
};
|
||||
|
||||
handleNameChange = (ev: SyntheticInputEvent<*>) => {
|
||||
this.name = ev.target.value;
|
||||
};
|
||||
@@ -79,13 +82,8 @@ class CollectionEdit extends React.Component<Props> {
|
||||
this.private = ev.target.checked;
|
||||
};
|
||||
|
||||
handleSharingChange = (ev: SyntheticInputEvent<*>) => {
|
||||
this.sharing = ev.target.checked;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { auth, t } = this.props;
|
||||
const teamSharingEnabled = !!auth.team && auth.team.sharing;
|
||||
const { t } = this.props;
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
@@ -113,6 +111,15 @@ class CollectionEdit extends React.Component<Props> {
|
||||
icon={this.icon}
|
||||
/>
|
||||
</Flex>
|
||||
<InputRich
|
||||
id={this.props.collection.id}
|
||||
label={t("Description")}
|
||||
onChange={this.handleDescriptionChange}
|
||||
defaultValue={this.description || ""}
|
||||
placeholder={t("More details about this collection…")}
|
||||
minHeight={68}
|
||||
maxHeight={200}
|
||||
/>
|
||||
<InputSelect
|
||||
label={t("Sort in sidebar")}
|
||||
options={[
|
||||
@@ -133,25 +140,6 @@ class CollectionEdit extends React.Component<Props> {
|
||||
A private collection will only be visible to invited team members.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
<Switch
|
||||
id="sharing"
|
||||
label={t("Public document sharing")}
|
||||
onChange={this.handleSharingChange}
|
||||
checked={this.sharing && teamSharingEnabled}
|
||||
disabled={!teamSharingEnabled}
|
||||
/>
|
||||
<HelpText>
|
||||
{teamSharingEnabled ? (
|
||||
<Trans>
|
||||
When enabled, documents can be shared publicly on the internet.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Public sharing is currently disabled in the team security
|
||||
settings.
|
||||
</Trans>
|
||||
)}
|
||||
</HelpText>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={this.isSaving || !this.props.collection.name}
|
||||
@@ -164,6 +152,4 @@ class CollectionEdit extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation()<CollectionEdit>(
|
||||
inject("ui", "auth")(CollectionEdit)
|
||||
);
|
||||
export default withTranslation()<CollectionEdit>(inject("ui")(CollectionEdit));
|
||||
|
||||
+25
-36
@@ -3,9 +3,8 @@ import { intersection } from "lodash";
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction, Trans } from "react-i18next";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Collection from "models/Collection";
|
||||
@@ -14,11 +13,11 @@ import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import IconPicker, { icons } from "components/IconPicker";
|
||||
import Input from "components/Input";
|
||||
import InputRich from "components/InputRich";
|
||||
import Switch from "components/Switch";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
collections: CollectionsStore,
|
||||
onSubmit: () => void,
|
||||
@@ -28,9 +27,9 @@ type Props = {
|
||||
@observer
|
||||
class CollectionNew extends React.Component<Props> {
|
||||
@observable name: string = "";
|
||||
@observable description: string = "";
|
||||
@observable icon: string = "";
|
||||
@observable color: string = "#4E5C6E";
|
||||
@observable sharing: boolean = true;
|
||||
@observable private: boolean = false;
|
||||
@observable isSaving: boolean;
|
||||
hasOpenedIconPicker: boolean = false;
|
||||
@@ -41,7 +40,7 @@ class CollectionNew extends React.Component<Props> {
|
||||
const collection = new Collection(
|
||||
{
|
||||
name: this.name,
|
||||
sharing: this.sharing,
|
||||
description: this.description,
|
||||
icon: this.icon,
|
||||
color: this.color,
|
||||
private: this.private,
|
||||
@@ -60,7 +59,7 @@ class CollectionNew extends React.Component<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
handleNameChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
|
||||
handleNameChange = (ev: SyntheticInputEvent<*>) => {
|
||||
this.name = ev.target.value;
|
||||
|
||||
// If the user hasn't picked an icon yet, go ahead and suggest one based on
|
||||
@@ -87,12 +86,12 @@ class CollectionNew extends React.Component<Props> {
|
||||
this.hasOpenedIconPicker = true;
|
||||
};
|
||||
|
||||
handlePrivateChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
|
||||
this.private = ev.target.checked;
|
||||
handleDescriptionChange = (getValue: () => string) => {
|
||||
this.description = getValue();
|
||||
};
|
||||
|
||||
handleSharingChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
|
||||
this.sharing = ev.target.checked;
|
||||
handlePrivateChange = (ev: SyntheticInputEvent<*>) => {
|
||||
this.private = ev.target.checked;
|
||||
};
|
||||
|
||||
handleChange = (color: string, icon: string) => {
|
||||
@@ -101,17 +100,14 @@ class CollectionNew extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t, auth } = this.props;
|
||||
const teamSharingEnabled = !!auth.team && auth.team.sharing;
|
||||
const { t } = this.props;
|
||||
|
||||
return (
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
Collections are for grouping your documents. They work best when
|
||||
organized around a topic or internal team — Product or Engineering
|
||||
for example.
|
||||
</Trans>
|
||||
{t(
|
||||
"Collections are for grouping your knowledge base. They work best when organized around a topic or internal team — Product or Engineering for example."
|
||||
)}
|
||||
</HelpText>
|
||||
<Flex>
|
||||
<Input
|
||||
@@ -131,6 +127,14 @@ class CollectionNew extends React.Component<Props> {
|
||||
icon={this.icon}
|
||||
/>
|
||||
</Flex>
|
||||
<InputRich
|
||||
label={t("Description")}
|
||||
onChange={this.handleDescriptionChange}
|
||||
defaultValue={this.description || ""}
|
||||
placeholder={t("More details about this collection…")}
|
||||
minHeight={68}
|
||||
maxHeight={200}
|
||||
/>
|
||||
<Switch
|
||||
id="private"
|
||||
label={t("Private collection")}
|
||||
@@ -138,25 +142,10 @@ class CollectionNew extends React.Component<Props> {
|
||||
checked={this.private}
|
||||
/>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
A private collection will only be visible to invited team members.
|
||||
</Trans>
|
||||
{t(
|
||||
"A private collection will only be visible to invited team members."
|
||||
)}
|
||||
</HelpText>
|
||||
{teamSharingEnabled && (
|
||||
<>
|
||||
<Switch
|
||||
id="sharing"
|
||||
label={t("Public document sharing")}
|
||||
onChange={this.handleSharingChange}
|
||||
checked={this.sharing}
|
||||
/>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
When enabled, documents can be shared publicly on the internet.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={this.isSaving || !this.name}>
|
||||
{this.isSaving ? `${t("Creating")}…` : t("Create")}
|
||||
@@ -167,5 +156,5 @@ class CollectionNew extends React.Component<Props> {
|
||||
}
|
||||
|
||||
export default withTranslation()<CollectionNew>(
|
||||
inject("collections", "ui", "auth")(withRouter(CollectionNew))
|
||||
inject("collections", "ui")(withRouter(CollectionNew))
|
||||
);
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { HomeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch, Route } from "react-router-dom";
|
||||
import { Action } from "components/Actions";
|
||||
import Heading from "components/Heading";
|
||||
|
||||
import Actions, { Action } from "components/Actions";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import InputSearch from "components/InputSearch";
|
||||
import LanguagePrompt from "components/LanguagePrompt";
|
||||
import Scene from "components/Scene";
|
||||
import PageTitle from "components/PageTitle";
|
||||
import Tab from "components/Tab";
|
||||
import Tabs from "components/Tabs";
|
||||
import PaginatedDocumentList from "../components/PaginatedDocumentList";
|
||||
import useStores from "../hooks/useStores";
|
||||
import NewDocumentMenu from "menus/NewDocumentMenu";
|
||||
|
||||
function Home() {
|
||||
function Dashboard() {
|
||||
const { documents, ui, auth } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -23,26 +23,10 @@ function Home() {
|
||||
const user = auth.user.id;
|
||||
|
||||
return (
|
||||
<Scene
|
||||
icon={<HomeIcon color="currentColor" />}
|
||||
title={t("Home")}
|
||||
actions={
|
||||
<>
|
||||
<Action>
|
||||
<InputSearch
|
||||
source="dashboard"
|
||||
label={t("Search documents")}
|
||||
labelHidden
|
||||
/>
|
||||
</Action>
|
||||
<Action>
|
||||
<NewDocumentMenu />
|
||||
</Action>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<CenteredContent>
|
||||
<PageTitle title={t("Home")} />
|
||||
{!ui.languagePromptDismissed && <LanguagePrompt />}
|
||||
<Heading>{t("Home")}</Heading>
|
||||
<h1>{t("Home")}</h1>
|
||||
<Tabs>
|
||||
<Tab to="/home" exact>
|
||||
{t("Recently updated")}
|
||||
@@ -78,8 +62,20 @@ function Home() {
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Scene>
|
||||
<Actions align="center" justify="flex-end">
|
||||
<Action>
|
||||
<InputSearch
|
||||
source="dashboard"
|
||||
label={t("Search documents")}
|
||||
labelHidden
|
||||
/>
|
||||
</Action>
|
||||
<Action>
|
||||
<NewDocumentMenu />
|
||||
</Action>
|
||||
</Actions>
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Home);
|
||||
export default observer(Dashboard);
|
||||
@@ -7,6 +7,7 @@ import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import type { RouterHistory, Match } from "react-router-dom";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import { withTheme } from "styled-components";
|
||||
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
@@ -21,7 +22,7 @@ import DocumentComponent from "./Document";
|
||||
import HideSidebar from "./HideSidebar";
|
||||
import Loading from "./Loading";
|
||||
import SocketPresence from "./SocketPresence";
|
||||
import { type LocationWithState } from "types";
|
||||
import { type LocationWithState, type Theme } from "types";
|
||||
import { NotFoundError, OfflineError } from "utils/errors";
|
||||
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
|
||||
import { isInternalUrl } from "utils/urls";
|
||||
@@ -34,6 +35,7 @@ type Props = {|
|
||||
policies: PoliciesStore,
|
||||
revisions: RevisionsStore,
|
||||
ui: UiStore,
|
||||
theme: Theme,
|
||||
history: RouterHistory,
|
||||
|};
|
||||
|
||||
@@ -47,6 +49,7 @@ class DataLoader extends React.Component<Props> {
|
||||
const { documents, match } = this.props;
|
||||
this.document = documents.getByUrl(match.params.documentSlug);
|
||||
this.loadDocument();
|
||||
this.updateBackground();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
@@ -71,6 +74,13 @@ class DataLoader extends React.Component<Props> {
|
||||
) {
|
||||
this.loadRevision();
|
||||
}
|
||||
this.updateBackground();
|
||||
}
|
||||
|
||||
updateBackground() {
|
||||
// ensure the wider page color always matches the theme. This is to
|
||||
// account for share links which don't sit in the wider Layout component
|
||||
window.document.body.style.background = this.props.theme.background;
|
||||
}
|
||||
|
||||
get isEditing() {
|
||||
@@ -256,5 +266,5 @@ export default withRouter(
|
||||
"revisions",
|
||||
"policies",
|
||||
"shares"
|
||||
)(DataLoader)
|
||||
)(withTheme(DataLoader))
|
||||
);
|
||||
|
||||
@@ -480,7 +480,7 @@ const ReferencesWrapper = styled("div")`
|
||||
const MaxWidth = styled(Flex)`
|
||||
${(props) =>
|
||||
props.archived && `* { color: ${props.theme.textSecondary} !important; } `};
|
||||
padding: 0 12px;
|
||||
padding: 0 16px;
|
||||
max-width: 100vw;
|
||||
width: 100%;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { throttle } from "lodash";
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import {
|
||||
TableOfContentsIcon,
|
||||
EditIcon,
|
||||
@@ -7,11 +9,18 @@ import {
|
||||
PlusIcon,
|
||||
MoreIcon,
|
||||
} from "outline-icons";
|
||||
import { transparentize, darken } from "polished";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { withTranslation, Trans, type TFunction } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import SharesStore from "stores/SharesStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Document from "models/Document";
|
||||
|
||||
import DocumentShare from "scenes/DocumentShare";
|
||||
import { Action, Separator } from "components/Actions";
|
||||
import Badge from "components/Badge";
|
||||
@@ -19,17 +28,20 @@ import Breadcrumb, { Slash } from "components/Breadcrumb";
|
||||
import Button from "components/Button";
|
||||
import Collaborators from "components/Collaborators";
|
||||
import Fade from "components/Fade";
|
||||
import Header from "components/Header";
|
||||
import Flex from "components/Flex";
|
||||
import Modal from "components/Modal";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import useStores from "hooks/useStores";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
import NewChildDocumentMenu from "menus/NewChildDocumentMenu";
|
||||
import TemplatesMenu from "menus/TemplatesMenu";
|
||||
import { metaDisplay } from "utils/keyboard";
|
||||
import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
shares: SharesStore,
|
||||
policies: PoliciesStore,
|
||||
document: Document,
|
||||
isDraft: boolean,
|
||||
isEditing: boolean,
|
||||
@@ -44,263 +56,363 @@ type Props = {|
|
||||
publish?: boolean,
|
||||
autosave?: boolean,
|
||||
}) => void,
|
||||
|};
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
function DocumentHeader({
|
||||
document,
|
||||
isEditing,
|
||||
isDraft,
|
||||
isPublishing,
|
||||
isRevision,
|
||||
isSaving,
|
||||
savingIsDisabled,
|
||||
publishingIsDisabled,
|
||||
onSave,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { auth, ui, shares, policies } = useStores();
|
||||
const [showShareModal, setShowShareModal] = React.useState(false);
|
||||
@observer
|
||||
class Header extends React.Component<Props> {
|
||||
@observable isScrolled = false;
|
||||
@observable showShareModal = false;
|
||||
|
||||
const handleSave = React.useCallback(() => {
|
||||
onSave({ done: true });
|
||||
}, [onSave]);
|
||||
componentDidMount() {
|
||||
window.addEventListener("scroll", this.handleScroll);
|
||||
}
|
||||
|
||||
const handlePublish = React.useCallback(() => {
|
||||
onSave({ done: true, publish: true });
|
||||
}, [onSave]);
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("scroll", this.handleScroll);
|
||||
}
|
||||
|
||||
const handleShareLink = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
await document.share();
|
||||
updateIsScrolled = () => {
|
||||
this.isScrolled = window.scrollY > 75;
|
||||
};
|
||||
|
||||
setShowShareModal(true);
|
||||
},
|
||||
[document]
|
||||
);
|
||||
handleScroll = throttle(this.updateIsScrolled, 50);
|
||||
|
||||
const handleCloseShareModal = React.useCallback(() => {
|
||||
setShowShareModal(false);
|
||||
}, []);
|
||||
handleSave = () => {
|
||||
this.props.onSave({ done: true });
|
||||
};
|
||||
|
||||
const share = shares.getByDocumentId(document.id);
|
||||
const isPubliclyShared = share && share.published;
|
||||
const isNew = document.isNew;
|
||||
const isTemplate = document.isTemplate;
|
||||
const can = policies.abilities(document.id);
|
||||
const canShareDocument = auth.team && auth.team.sharing && can.share;
|
||||
const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
|
||||
const canEdit = can.update && !isEditing;
|
||||
handlePublish = () => {
|
||||
this.props.onSave({ done: true, publish: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={showShareModal}
|
||||
onRequestClose={handleCloseShareModal}
|
||||
title={t("Share document")}
|
||||
handleShareLink = async (ev: SyntheticEvent<>) => {
|
||||
const { document } = this.props;
|
||||
await document.share();
|
||||
|
||||
this.showShareModal = true;
|
||||
};
|
||||
|
||||
handleCloseShareModal = () => {
|
||||
this.showShareModal = false;
|
||||
};
|
||||
|
||||
handleClickTitle = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
shares,
|
||||
document,
|
||||
policies,
|
||||
isEditing,
|
||||
isDraft,
|
||||
isPublishing,
|
||||
isRevision,
|
||||
isSaving,
|
||||
savingIsDisabled,
|
||||
publishingIsDisabled,
|
||||
ui,
|
||||
auth,
|
||||
t,
|
||||
} = this.props;
|
||||
|
||||
const share = shares.getByDocumentId(document.id);
|
||||
const isPubliclyShared = share && share.published;
|
||||
const isNew = document.isNew;
|
||||
const isTemplate = document.isTemplate;
|
||||
const can = policies.abilities(document.id);
|
||||
const canShareDocuments = auth.team && auth.team.sharing && can.share;
|
||||
const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
|
||||
const canEdit = can.update && !isEditing;
|
||||
|
||||
return (
|
||||
<Actions
|
||||
align="center"
|
||||
justify="space-between"
|
||||
readOnly={!isEditing}
|
||||
isCompact={this.isScrolled}
|
||||
shrink={false}
|
||||
>
|
||||
<DocumentShare document={document} onSubmit={handleCloseShareModal} />
|
||||
</Modal>
|
||||
<Header
|
||||
breadcrumb={
|
||||
<Breadcrumb document={document}>
|
||||
{!isEditing && (
|
||||
<>
|
||||
<Slash />
|
||||
<Tooltip
|
||||
tooltip={
|
||||
ui.tocVisible ? t("Hide contents") : t("Show contents")
|
||||
<Modal
|
||||
isOpen={this.showShareModal}
|
||||
onRequestClose={this.handleCloseShareModal}
|
||||
title={t("Share document")}
|
||||
>
|
||||
<DocumentShare
|
||||
document={document}
|
||||
onSubmit={this.handleCloseShareModal}
|
||||
/>
|
||||
</Modal>
|
||||
<BreadcrumbAndContents align="center" justify="flex-start">
|
||||
<Breadcrumb document={document} />
|
||||
{!isEditing && (
|
||||
<>
|
||||
<Slash />
|
||||
<Tooltip
|
||||
tooltip={
|
||||
ui.tocVisible ? t("Hide contents") : t("Show contents")
|
||||
}
|
||||
shortcut={`ctrl+${metaDisplay}+h`}
|
||||
delay={250}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
onClick={
|
||||
ui.tocVisible
|
||||
? ui.hideTableOfContents
|
||||
: ui.showTableOfContents
|
||||
}
|
||||
shortcut={`ctrl+${metaDisplay}+h`}
|
||||
delay={250}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
onClick={
|
||||
ui.tocVisible
|
||||
? ui.hideTableOfContents
|
||||
: ui.showTableOfContents
|
||||
}
|
||||
icon={<TableOfContentsIcon />}
|
||||
iconColor="currentColor"
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Breadcrumb>
|
||||
}
|
||||
title={
|
||||
<>
|
||||
{document.title}{" "}
|
||||
{document.isArchived && <Badge>{t("Archived")}</Badge>}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{isSaving && !isPublishing && (
|
||||
<Action>
|
||||
<Status>{t("Saving")}…</Status>
|
||||
</Action>
|
||||
)}
|
||||
|
||||
icon={<TableOfContentsIcon />}
|
||||
iconColor="currentColor"
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</BreadcrumbAndContents>
|
||||
{this.isScrolled && (
|
||||
<Title onClick={this.handleClickTitle}>
|
||||
<Fade>
|
||||
<Collaborators
|
||||
document={document}
|
||||
currentUserId={auth.user ? auth.user.id : undefined}
|
||||
/>
|
||||
{document.title}{" "}
|
||||
{document.isArchived && <Badge>{t("Archived")}</Badge>}
|
||||
</Fade>
|
||||
{isEditing && !isTemplate && isNew && (
|
||||
<Action>
|
||||
<TemplatesMenu document={document} />
|
||||
</Action>
|
||||
)}
|
||||
{!isEditing && canShareDocument && (
|
||||
</Title>
|
||||
)}
|
||||
<Wrapper align="center" justify="flex-end">
|
||||
{isSaving && !isPublishing && (
|
||||
<Action>
|
||||
<Status>{t("Saving")}…</Status>
|
||||
</Action>
|
||||
)}
|
||||
|
||||
<Fade>
|
||||
<Collaborators
|
||||
document={document}
|
||||
currentUserId={auth.user ? auth.user.id : undefined}
|
||||
/>
|
||||
</Fade>
|
||||
{isEditing && !isTemplate && isNew && (
|
||||
<Action>
|
||||
<TemplatesMenu document={document} />
|
||||
</Action>
|
||||
)}
|
||||
{!isEditing && canShareDocuments && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={
|
||||
isPubliclyShared ? (
|
||||
<Trans>
|
||||
Anyone with the link <br />
|
||||
can view this document
|
||||
</Trans>
|
||||
) : (
|
||||
""
|
||||
)
|
||||
}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
icon={isPubliclyShared ? <GlobeIcon /> : undefined}
|
||||
onClick={this.handleShareLink}
|
||||
neutral
|
||||
>
|
||||
{t("Share")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
)}
|
||||
{isEditing && (
|
||||
<>
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={
|
||||
isPubliclyShared ? (
|
||||
<Trans>
|
||||
Anyone with the link <br />
|
||||
can view this document
|
||||
</Trans>
|
||||
) : (
|
||||
""
|
||||
)
|
||||
}
|
||||
tooltip={t("Save")}
|
||||
shortcut={`${metaDisplay}+enter`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
icon={isPubliclyShared ? <GlobeIcon /> : undefined}
|
||||
onClick={handleShareLink}
|
||||
neutral
|
||||
onClick={this.handleSave}
|
||||
disabled={savingIsDisabled}
|
||||
neutral={isDraft}
|
||||
>
|
||||
{t("Share")}
|
||||
{isDraft ? t("Save Draft") : t("Done Editing")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
)}
|
||||
{isEditing && (
|
||||
<>
|
||||
<Action>
|
||||
</>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("Edit {{noun}}", { noun: document.noun })}
|
||||
shortcut="e"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
as={Link}
|
||||
icon={<EditIcon />}
|
||||
to={editDocumentUrl(this.props.document)}
|
||||
neutral
|
||||
>
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
)}
|
||||
{canEdit && can.createChildDocument && (
|
||||
<Action>
|
||||
<NewChildDocumentMenu
|
||||
document={document}
|
||||
label={(props) => (
|
||||
<Tooltip
|
||||
tooltip={t("Save")}
|
||||
shortcut={`${metaDisplay}+enter`}
|
||||
tooltip={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={savingIsDisabled}
|
||||
neutral={isDraft}
|
||||
>
|
||||
{isDraft ? t("Save Draft") : t("Done Editing")}
|
||||
<Button icon={<PlusIcon />} {...props} neutral>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
</>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("Edit {{noun}}", { noun: document.noun })}
|
||||
shortcut="e"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
)}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
{canEdit && isTemplate && !isDraft && !isRevision && (
|
||||
<Action>
|
||||
<Button
|
||||
icon={<PlusIcon />}
|
||||
as={Link}
|
||||
to={newDocumentUrl(document.collectionId, {
|
||||
templateId: document.id,
|
||||
})}
|
||||
primary
|
||||
>
|
||||
{t("New from template")}
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
{can.update && isDraft && !isRevision && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("Publish")}
|
||||
shortcut={`${metaDisplay}+shift+p`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
onClick={this.handlePublish}
|
||||
disabled={publishingIsDisabled}
|
||||
>
|
||||
<Button
|
||||
as={Link}
|
||||
icon={<EditIcon />}
|
||||
to={editDocumentUrl(document)}
|
||||
neutral
|
||||
>
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
)}
|
||||
{canEdit && can.createChildDocument && (
|
||||
{isPublishing ? `${t("Publishing")}…` : t("Publish")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
)}
|
||||
{!isEditing && (
|
||||
<>
|
||||
<Separator />
|
||||
<Action>
|
||||
<NewChildDocumentMenu
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
isRevision={isRevision}
|
||||
label={(props) => (
|
||||
<Tooltip
|
||||
tooltip={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button icon={<PlusIcon />} {...props} neutral>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
icon={<MoreIcon />}
|
||||
iconColor="currentColor"
|
||||
{...props}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
)}
|
||||
showToggleEmbeds={canToggleEmbeds}
|
||||
showPrint
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
{canEdit && isTemplate && !isDraft && !isRevision && (
|
||||
<Action>
|
||||
<Button
|
||||
icon={<PlusIcon />}
|
||||
as={Link}
|
||||
to={newDocumentUrl(document.collectionId, {
|
||||
templateId: document.id,
|
||||
})}
|
||||
primary
|
||||
>
|
||||
{t("New from template")}
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
{can.update && isDraft && !isRevision && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("Publish")}
|
||||
shortcut={`${metaDisplay}+shift+p`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
onClick={handlePublish}
|
||||
disabled={publishingIsDisabled}
|
||||
>
|
||||
{isPublishing ? `${t("Publishing")}…` : t("Publish")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
)}
|
||||
{!isEditing && (
|
||||
<>
|
||||
<Separator />
|
||||
<Action>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
isRevision={isRevision}
|
||||
label={(props) => (
|
||||
<Button
|
||||
icon={<MoreIcon />}
|
||||
iconColor="currentColor"
|
||||
{...props}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
)}
|
||||
showToggleEmbeds={canToggleEmbeds}
|
||||
showPrint
|
||||
/>
|
||||
</Action>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
</>
|
||||
)}
|
||||
</Wrapper>
|
||||
</Actions>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Status = styled.div`
|
||||
color: ${(props) => props.theme.slate};
|
||||
`;
|
||||
|
||||
export default observer(DocumentHeader);
|
||||
const BreadcrumbAndContents = styled(Flex)`
|
||||
display: none;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
width: 33.3%;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
width: 100%;
|
||||
align-self: flex-end;
|
||||
height: 32px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: 33.3%;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Actions = styled(Flex)`
|
||||
position: sticky;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
background: ${(props) => transparentize(0.2, props.theme.background)};
|
||||
box-shadow: 0 1px 0
|
||||
${(props) =>
|
||||
props.isCompact
|
||||
? darken(0.05, props.theme.sidebarBackground)
|
||||
: "transparent"};
|
||||
padding: 12px;
|
||||
transition: all 100ms ease-out;
|
||||
transform: translate3d(0, 0, 0);
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
|
||||
`};
|
||||
`;
|
||||
|
||||
const Title = styled.div`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
width: 0;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
`};
|
||||
`;
|
||||
|
||||
export default withTranslation()<Header>(
|
||||
inject("auth", "ui", "policies", "shares")(Header)
|
||||
);
|
||||
|
||||
+20
-22
@@ -1,7 +1,6 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { EditIcon } from "outline-icons";
|
||||
import queryString from "query-string";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
@@ -10,13 +9,15 @@ import styled from "styled-components";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import CollectionFilter from "scenes/Search/components/CollectionFilter";
|
||||
import DateFilter from "scenes/Search/components/DateFilter";
|
||||
import { Action } from "components/Actions";
|
||||
|
||||
import Actions, { Action } from "components/Actions";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import Empty from "components/Empty";
|
||||
import Flex from "components/Flex";
|
||||
import Heading from "components/Heading";
|
||||
import InputSearch from "components/InputSearch";
|
||||
import PageTitle from "components/PageTitle";
|
||||
import PaginatedDocumentList from "components/PaginatedDocumentList";
|
||||
import Scene from "components/Scene";
|
||||
import Subheading from "components/Subheading";
|
||||
import NewDocumentMenu from "menus/NewDocumentMenu";
|
||||
import { type LocationWithState } from "types";
|
||||
@@ -77,24 +78,8 @@ class Drafts extends React.Component<Props> {
|
||||
};
|
||||
|
||||
return (
|
||||
<Scene
|
||||
icon={<EditIcon color="currentColor" />}
|
||||
title={t("Drafts")}
|
||||
actions={
|
||||
<>
|
||||
<Action>
|
||||
<InputSearch
|
||||
source="drafts"
|
||||
label={t("Search documents")}
|
||||
labelHidden
|
||||
/>
|
||||
</Action>
|
||||
<Action>
|
||||
<NewDocumentMenu />
|
||||
</Action>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<CenteredContent column auto>
|
||||
<PageTitle title={t("Drafts")} />
|
||||
<Heading>{t("Drafts")}</Heading>
|
||||
<Subheading>
|
||||
{t("Documents")}
|
||||
@@ -125,7 +110,20 @@ class Drafts extends React.Component<Props> {
|
||||
options={options}
|
||||
showCollection
|
||||
/>
|
||||
</Scene>
|
||||
|
||||
<Actions align="center" justify="flex-end">
|
||||
<Action>
|
||||
<InputSearch
|
||||
source="drafts"
|
||||
label={t("Search documents")}
|
||||
labelHidden
|
||||
/>
|
||||
</Action>
|
||||
<Action>
|
||||
<NewDocumentMenu />
|
||||
</Action>
|
||||
</Actions>
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,12 @@ import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction, Trans } from "react-i18next";
|
||||
import { type Match } from "react-router-dom";
|
||||
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import UsersStore from "stores/UsersStore";
|
||||
import Invite from "scenes/Invite";
|
||||
import Bubble from "components/Bubble";
|
||||
import Button from "components/Button";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import Empty from "components/Empty";
|
||||
@@ -28,20 +27,12 @@ type Props = {
|
||||
users: UsersStore,
|
||||
policies: PoliciesStore,
|
||||
match: Match,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
class People extends React.Component<Props> {
|
||||
@observable inviteModalOpen: boolean = false;
|
||||
|
||||
componentDidMount() {
|
||||
const { team } = this.props.auth;
|
||||
if (team) {
|
||||
this.props.users.fetchCounts(team.id);
|
||||
}
|
||||
}
|
||||
|
||||
handleInviteModalOpen = () => {
|
||||
this.inviteModalOpen = true;
|
||||
};
|
||||
@@ -55,7 +46,7 @@ class People extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { auth, policies, match, t } = this.props;
|
||||
const { auth, policies, match } = this.props;
|
||||
const { filter } = match.params;
|
||||
const currentUser = auth.user;
|
||||
const team = auth.team;
|
||||
@@ -74,18 +65,15 @@ class People extends React.Component<Props> {
|
||||
}
|
||||
|
||||
const can = policies.abilities(team.id);
|
||||
const { counts } = this.props.users;
|
||||
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title={t("People")} />
|
||||
<h1>{t("People")}</h1>
|
||||
<PageTitle title="People" />
|
||||
<h1>People</h1>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
Everyone that has signed into Outline appears here. It’s possible
|
||||
that there are other users who have access through{" "}
|
||||
{team.signinMethods} but haven’t signed in yet.
|
||||
</Trans>
|
||||
Everyone that has signed into Outline appears here. It’s possible that
|
||||
there are other users who have access through {team.signinMethods} but
|
||||
haven’t signed in yet.
|
||||
</HelpText>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -96,36 +84,37 @@ class People extends React.Component<Props> {
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("Invite people")}…
|
||||
Invite people…
|
||||
</Button>
|
||||
|
||||
<Tabs>
|
||||
<Tab to="/settings/people" exact>
|
||||
{t("Active")} <Bubble count={counts.active} />
|
||||
Active
|
||||
</Tab>
|
||||
<Tab to="/settings/people/admins" exact>
|
||||
{t("Admins")} <Bubble count={counts.admins} />
|
||||
Admins
|
||||
</Tab>
|
||||
{can.update && (
|
||||
<Tab to="/settings/people/suspended" exact>
|
||||
{t("Suspended")} <Bubble count={counts.suspended} />
|
||||
Suspended
|
||||
</Tab>
|
||||
)}
|
||||
<Tab to="/settings/people/all" exact>
|
||||
{t("Everyone")} <Bubble count={counts.all - counts.invited} />
|
||||
Everyone
|
||||
</Tab>
|
||||
|
||||
{can.invite && (
|
||||
<>
|
||||
<Separator />
|
||||
<Tab to="/settings/people/invited" exact>
|
||||
{t("Invited")} <Bubble count={counts.invited} />
|
||||
Invited
|
||||
</Tab>
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
<PaginatedList
|
||||
items={users}
|
||||
empty={<Empty>{t("No people to see here.")}</Empty>}
|
||||
empty={<Empty>No people to see here.</Empty>}
|
||||
fetch={this.fetchPage}
|
||||
renderItem={(item) => (
|
||||
<UserListItem
|
||||
@@ -137,7 +126,7 @@ class People extends React.Component<Props> {
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t("Invite people")}
|
||||
title="Invite people"
|
||||
onRequestClose={this.handleInviteModalClose}
|
||||
isOpen={this.inviteModalOpen}
|
||||
>
|
||||
@@ -148,8 +137,4 @@ class People extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default inject(
|
||||
"auth",
|
||||
"users",
|
||||
"policies"
|
||||
)(withTranslation()<People>(People));
|
||||
export default inject("auth", "users", "policies")(People);
|
||||
|
||||
+19
-22
@@ -1,15 +1,15 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { StarredIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Match } from "react-router-dom";
|
||||
import { Action } from "components/Actions";
|
||||
import Actions, { Action } from "components/Actions";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import Empty from "components/Empty";
|
||||
import Heading from "components/Heading";
|
||||
import InputSearch from "components/InputSearch";
|
||||
import PageTitle from "components/PageTitle";
|
||||
import PaginatedDocumentList from "components/PaginatedDocumentList";
|
||||
import Scene from "components/Scene";
|
||||
import Tab from "components/Tab";
|
||||
import Tabs from "components/Tabs";
|
||||
import useStores from "hooks/useStores";
|
||||
@@ -26,24 +26,8 @@ function Starred(props: Props) {
|
||||
const { sort } = props.match.params;
|
||||
|
||||
return (
|
||||
<Scene
|
||||
icon={<StarredIcon color="currentColor" />}
|
||||
title={t("Starred")}
|
||||
actions={
|
||||
<>
|
||||
<Action>
|
||||
<InputSearch
|
||||
source="starred"
|
||||
label={t("Search documents")}
|
||||
labelHidden
|
||||
/>
|
||||
</Action>
|
||||
<Action>
|
||||
<NewDocumentMenu />
|
||||
</Action>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<CenteredContent column auto>
|
||||
<PageTitle title={t("Starred")} />
|
||||
<Heading>{t("Starred")}</Heading>
|
||||
<PaginatedDocumentList
|
||||
heading={
|
||||
@@ -61,7 +45,20 @@ function Starred(props: Props) {
|
||||
documents={sort === "alphabetical" ? starredAlphabetical : starred}
|
||||
showCollection
|
||||
/>
|
||||
</Scene>
|
||||
|
||||
<Actions align="center" justify="flex-end">
|
||||
<Action>
|
||||
<InputSearch
|
||||
source="starred"
|
||||
label={t("Search documents")}
|
||||
labelHidden
|
||||
/>
|
||||
</Action>
|
||||
<Action>
|
||||
<NewDocumentMenu />
|
||||
</Action>
|
||||
</Actions>
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+13
-13
@@ -1,14 +1,15 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { TemplateIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Match } from "react-router-dom";
|
||||
import { Action } from "components/Actions";
|
||||
|
||||
import Actions, { Action } from "components/Actions";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import Empty from "components/Empty";
|
||||
import Heading from "components/Heading";
|
||||
import PageTitle from "components/PageTitle";
|
||||
import PaginatedDocumentList from "components/PaginatedDocumentList";
|
||||
import Scene from "components/Scene";
|
||||
import Tab from "components/Tab";
|
||||
import Tabs from "components/Tabs";
|
||||
import useStores from "hooks/useStores";
|
||||
@@ -25,15 +26,8 @@ function Templates(props: Props) {
|
||||
const { sort } = props.match.params;
|
||||
|
||||
return (
|
||||
<Scene
|
||||
icon={<TemplateIcon color="currentColor" />}
|
||||
title={t("Templates")}
|
||||
actions={
|
||||
<Action>
|
||||
<NewTemplateMenu />
|
||||
</Action>
|
||||
}
|
||||
>
|
||||
<CenteredContent column auto>
|
||||
<PageTitle title={t("Templates")} />
|
||||
<Heading>{t("Templates")}</Heading>
|
||||
<PaginatedDocumentList
|
||||
heading={
|
||||
@@ -58,7 +52,13 @@ function Templates(props: Props) {
|
||||
showCollection
|
||||
showDraft
|
||||
/>
|
||||
</Scene>
|
||||
|
||||
<Actions align="center" justify="flex-end">
|
||||
<Action>
|
||||
<NewTemplateMenu />
|
||||
</Action>
|
||||
</Actions>
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+6
-4
@@ -1,12 +1,13 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import Empty from "components/Empty";
|
||||
import Heading from "components/Heading";
|
||||
import PageTitle from "components/PageTitle";
|
||||
import PaginatedDocumentList from "components/PaginatedDocumentList";
|
||||
import Scene from "components/Scene";
|
||||
import Subheading from "components/Subheading";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
@@ -15,7 +16,8 @@ function Trash() {
|
||||
const { documents } = useStores();
|
||||
|
||||
return (
|
||||
<Scene icon={<TrashIcon color="currentColor" />} title={t("Trash")}>
|
||||
<CenteredContent column auto>
|
||||
<PageTitle title={t("Trash")} />
|
||||
<Heading>{t("Trash")}</Heading>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.deleted}
|
||||
@@ -25,7 +27,7 @@ function Trash() {
|
||||
showCollection
|
||||
showTemplate
|
||||
/>
|
||||
</Scene>
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -55,9 +55,9 @@ function UserProfile(props: Props) {
|
||||
time: distanceInWordsToNow(new Date(user.createdAt)),
|
||||
})}
|
||||
{user.isAdmin && (
|
||||
<StyledBadge primary={user.isAdmin}>{t("Admin")}</StyledBadge>
|
||||
<StyledBadge admin={user.isAdmin}>{t("Admin")}</StyledBadge>
|
||||
)}
|
||||
{user.isSuspended && <StyledBadge>{t("Suspended")}</StyledBadge>}
|
||||
{user.isSuspended && <Badge>{t("Suspended")}</Badge>}
|
||||
{isCurrentUser && (
|
||||
<Edit>
|
||||
<Button
|
||||
|
||||
@@ -7,7 +7,7 @@ import BaseModel from "../models/BaseModel";
|
||||
import type { PaginationParams } from "types";
|
||||
import { client } from "utils/ApiClient";
|
||||
|
||||
type Action = "list" | "info" | "create" | "update" | "delete" | "count";
|
||||
type Action = "list" | "info" | "create" | "update" | "delete";
|
||||
|
||||
function modelNameFromClassName(string) {
|
||||
return string.charAt(0).toLowerCase() + string.slice(1);
|
||||
@@ -24,7 +24,7 @@ export default class BaseStore<T: BaseModel> {
|
||||
model: Class<T>;
|
||||
modelName: string;
|
||||
rootStore: RootStore;
|
||||
actions: Action[] = ["list", "info", "create", "update", "delete", "count"];
|
||||
actions: Action[] = ["list", "info", "create", "update", "delete"];
|
||||
|
||||
constructor(rootStore: RootStore, model: Class<T>) {
|
||||
this.rootStore = rootStore;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import { concat, filter, last } from "lodash";
|
||||
import { computed, action } from "mobx";
|
||||
import { computed } from "mobx";
|
||||
|
||||
import naturalSort from "shared/utils/naturalSort";
|
||||
import Collection from "models/Collection";
|
||||
@@ -88,25 +88,6 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
async update(params: Object): Promise<Collection> {
|
||||
const result = await super.update(params);
|
||||
|
||||
// If we're changing sharing permissions on the collection then we need to
|
||||
// remove all locally cached policies for documents in the collection as they
|
||||
// are now invalid
|
||||
if (params.sharing !== undefined) {
|
||||
const collection = this.get(params.id);
|
||||
if (collection) {
|
||||
collection.documentIds.forEach((id) => {
|
||||
this.rootStore.policies.remove(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
getPathForDocument(documentId: string): ?DocumentPath {
|
||||
return this.pathsToDocuments.find((path) => path.id === documentId);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
@observable movingDocumentId: ?string;
|
||||
|
||||
importFileTypes: string[] = [
|
||||
".md",
|
||||
"text/markdown",
|
||||
"text/plain",
|
||||
"text/html",
|
||||
|
||||
@@ -21,7 +21,7 @@ class UiStore {
|
||||
@observable activeDocumentId: ?string;
|
||||
@observable activeCollectionId: ?string;
|
||||
@observable progressBarVisible: boolean = false;
|
||||
@observable isEditing: boolean = false;
|
||||
@observable editMode: boolean = false;
|
||||
@observable tocVisible: boolean = false;
|
||||
@observable mobileSidebarVisible: boolean = false;
|
||||
@observable sidebarWidth: number;
|
||||
@@ -151,12 +151,12 @@ class UiStore {
|
||||
|
||||
@action
|
||||
enableEditMode = () => {
|
||||
this.isEditing = true;
|
||||
this.editMode = true;
|
||||
};
|
||||
|
||||
@action
|
||||
disableEditMode = () => {
|
||||
this.isEditing = false;
|
||||
this.editMode = false;
|
||||
};
|
||||
|
||||
@action
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import { filter, orderBy } from "lodash";
|
||||
import { observable, computed, action, runInAction } from "mobx";
|
||||
import { computed, action, runInAction } from "mobx";
|
||||
import User from "models/User";
|
||||
import BaseStore from "./BaseStore";
|
||||
import RootStore from "./RootStore";
|
||||
import { client } from "utils/ApiClient";
|
||||
|
||||
export default class UsersStore extends BaseStore<User> {
|
||||
@observable counts: {
|
||||
active: number,
|
||||
admins: number,
|
||||
all: number,
|
||||
invited: number,
|
||||
suspended: number,
|
||||
} = {};
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, User);
|
||||
}
|
||||
@@ -60,25 +52,21 @@ export default class UsersStore extends BaseStore<User> {
|
||||
|
||||
@action
|
||||
promote = (user: User) => {
|
||||
this.counts.admins += 1;
|
||||
return this.actionOnUser("promote", user);
|
||||
};
|
||||
|
||||
@action
|
||||
demote = (user: User) => {
|
||||
this.counts.admins -= 1;
|
||||
return this.actionOnUser("demote", user);
|
||||
};
|
||||
|
||||
@action
|
||||
suspend = (user: User) => {
|
||||
this.counts.suspended += 1;
|
||||
return this.actionOnUser("suspend", user);
|
||||
};
|
||||
|
||||
@action
|
||||
activate = (user: User) => {
|
||||
this.counts.suspended -= 1;
|
||||
return this.actionOnUser("activate", user);
|
||||
};
|
||||
|
||||
@@ -88,39 +76,10 @@ export default class UsersStore extends BaseStore<User> {
|
||||
invariant(res && res.data, "Data should be available");
|
||||
runInAction(`invite`, () => {
|
||||
res.data.users.forEach(this.add);
|
||||
this.counts.invited += res.data.sent.length;
|
||||
this.counts.all += res.data.sent.length;
|
||||
});
|
||||
return res.data;
|
||||
};
|
||||
|
||||
@action
|
||||
fetchCounts = async (teamId: string): Promise<*> => {
|
||||
const res = await client.post(`/users.count`, { teamId });
|
||||
invariant(res && res.data, "Data should be available");
|
||||
|
||||
this.counts = res.data.counts;
|
||||
return res.data;
|
||||
};
|
||||
|
||||
@action
|
||||
async delete(user: User, options: Object = {}) {
|
||||
super.delete(user, options);
|
||||
if (!user.isSuspended && user.lastActiveAt) {
|
||||
this.counts.active -= 1;
|
||||
}
|
||||
if (user.isInvited) {
|
||||
this.counts.invited -= 1;
|
||||
}
|
||||
if (user.isAdmin) {
|
||||
this.counts.admins -= 1;
|
||||
}
|
||||
if (user.isSuspended) {
|
||||
this.counts.suspended -= 1;
|
||||
}
|
||||
this.counts.all -= 1;
|
||||
}
|
||||
|
||||
notInCollection = (collectionId: string, query: string = "") => {
|
||||
const memberships = filter(
|
||||
this.rootStore.memberships.orderedData,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import { map, trim } from "lodash";
|
||||
import { getCookie } from "tiny-cookie";
|
||||
import stores from "stores";
|
||||
import download from "./download";
|
||||
import {
|
||||
@@ -19,11 +18,6 @@ type Options = {
|
||||
baseUrl?: string,
|
||||
};
|
||||
|
||||
// authorization cookie set by a Cloudflare Access proxy
|
||||
const CF_AUTHORIZATION = getCookie("CF_Authorization");
|
||||
// if the cookie is set, we must pass it with all ApiClient requests
|
||||
const CREDENTIALS = CF_AUTHORIZATION ? "same-origin" : "omit";
|
||||
|
||||
class ApiClient {
|
||||
baseUrl: string;
|
||||
userAgent: string;
|
||||
@@ -97,7 +91,7 @@ class ApiClient {
|
||||
body,
|
||||
headers,
|
||||
redirect: "follow",
|
||||
credentials: CREDENTIALS,
|
||||
credentials: "omit",
|
||||
cache: "no-cache",
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -7,8 +7,6 @@ import env from "env";
|
||||
export function initSentry(history: RouterHistory) {
|
||||
Sentry.init({
|
||||
dsn: env.SENTRY_DSN,
|
||||
environment: env.ENVIRONMENT,
|
||||
release: env.RELEASE,
|
||||
integrations: [
|
||||
new Integrations.BrowserTracing({
|
||||
routingInstrumentation: Sentry.reactRouterV5Instrumentation(history),
|
||||
@@ -16,7 +14,6 @@ export function initSentry(history: RouterHistory) {
|
||||
],
|
||||
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1,
|
||||
ignoreErrors: [
|
||||
"ResizeObserver loop completed with undelivered notifications",
|
||||
"ResizeObserver loop limit exceeded",
|
||||
"AuthorizationError",
|
||||
"BadRequestError",
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
// flow-typed signature: 81720de1e8cfea1529815ce45326fdff
|
||||
// flow-typed version: <<STUB>>/boundless-popover_v^1.0.4/flow_v0.104.0
|
||||
|
||||
/**
|
||||
* This is an autogenerated libdef stub for:
|
||||
*
|
||||
* 'boundless-popover'
|
||||
*
|
||||
* Fill this stub out by replacing all the `any` types.
|
||||
*
|
||||
* Once filled out, we encourage you to share your work with the
|
||||
* community by sending a pull request to:
|
||||
* https://github.com/flowtype/flow-typed
|
||||
*/
|
||||
|
||||
declare module 'boundless-popover' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* We include stubs for each file inside this npm package in case you need to
|
||||
* require those files directly. Feel free to delete any files that aren't
|
||||
* needed.
|
||||
*/
|
||||
declare module 'boundless-popover/build' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'boundless-popover/demo' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'boundless-popover/index.spec' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
// Filename aliases
|
||||
declare module 'boundless-popover/build/index' {
|
||||
declare module.exports: $Exports<'boundless-popover/build'>;
|
||||
}
|
||||
declare module 'boundless-popover/build/index.js' {
|
||||
declare module.exports: $Exports<'boundless-popover/build'>;
|
||||
}
|
||||
declare module 'boundless-popover/demo/index' {
|
||||
declare module.exports: $Exports<'boundless-popover/demo'>;
|
||||
}
|
||||
declare module 'boundless-popover/demo/index.js' {
|
||||
declare module.exports: $Exports<'boundless-popover/demo'>;
|
||||
}
|
||||
declare module 'boundless-popover/index' {
|
||||
declare module.exports: $Exports<'boundless-popover'>;
|
||||
}
|
||||
declare module 'boundless-popover/index.js' {
|
||||
declare module.exports: $Exports<'boundless-popover'>;
|
||||
}
|
||||
declare module 'boundless-popover/index.spec.js' {
|
||||
declare module.exports: $Exports<'boundless-popover/index.spec'>;
|
||||
}
|
||||
+7
-10
@@ -67,9 +67,9 @@
|
||||
"@babel/preset-flow": "^7.10.4",
|
||||
"@babel/preset-react": "^7.10.4",
|
||||
"@rehooks/window-scroll-position": "^1.0.1",
|
||||
"@sentry/node": "^6.1.0",
|
||||
"@sentry/react": "^6.1.0",
|
||||
"@sentry/tracing": "^6.1.0",
|
||||
"@sentry/node": "^5.23.0",
|
||||
"@sentry/react": "^6.0.1",
|
||||
"@sentry/tracing": "^6.0.1",
|
||||
"@tippy.js/react": "^2.2.2",
|
||||
"@tommoor/remove-markdown": "0.3.1",
|
||||
"autotrack": "^2.4.1",
|
||||
@@ -78,13 +78,13 @@
|
||||
"babel-plugin-styled-components": "^1.11.1",
|
||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
||||
"boundless-arrow-key-navigation": "^1.0.4",
|
||||
"boundless-popover": "^1.0.4",
|
||||
"bull": "^3.5.2",
|
||||
"cancan": "3.1.0",
|
||||
"compressorjs": "^1.0.7",
|
||||
"copy-to-clipboard": "^3.0.6",
|
||||
"core-js": "2",
|
||||
"date-fns": "1.29.0",
|
||||
"dd-trace": "^0.30.6",
|
||||
"debug": "^4.1.1",
|
||||
"dotenv": "^4.0.0",
|
||||
"emoji-regex": "^6.5.1",
|
||||
@@ -154,7 +154,7 @@
|
||||
"react-waypoint": "^9.0.2",
|
||||
"react-window": "^1.8.6",
|
||||
"reakit": "^1.3.4",
|
||||
"rich-markdown-editor": "^11.3.0",
|
||||
"rich-markdown-editor": "^11.1.6",
|
||||
"semver": "^7.3.2",
|
||||
"sequelize": "^6.3.4",
|
||||
"sequelize-cli": "^6.2.0",
|
||||
@@ -183,7 +183,6 @@
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-jest": "^26.2.2",
|
||||
"babel-loader": "^8.1.0",
|
||||
"babel-plugin-transform-inline-environment-variables": "^0.4.3",
|
||||
"eslint": "^7.6.0",
|
||||
"eslint-config-react-app": "3.0.6",
|
||||
"eslint-plugin-flowtype": "^5.2.0",
|
||||
@@ -206,13 +205,11 @@
|
||||
"url-loader": "^0.6.2",
|
||||
"webpack": "4.44.1",
|
||||
"webpack-cli": "^3.3.12",
|
||||
"webpack-manifest-plugin": "^3.0.0",
|
||||
"webpack-pwa-manifest": "^4.3.0",
|
||||
"workbox-webpack-plugin": "^6.1.0"
|
||||
"webpack-manifest-plugin": "^3.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"dot-prop": "^5.2.0",
|
||||
"js-yaml": "^3.13.1"
|
||||
},
|
||||
"version": "0.53.1"
|
||||
"version": "0.52.0"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 102 KiB |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"short_name": "Outline",
|
||||
"name": "Outline",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": "/home?source=pwa",
|
||||
"background_color": "#FFFFFF",
|
||||
"display": "standalone",
|
||||
"theme_color": "#FFFFFF"
|
||||
}
|
||||
+1
-10
@@ -17,15 +17,6 @@
|
||||
]
|
||||
],
|
||||
"plugins": [
|
||||
"transform-class-properties",
|
||||
[
|
||||
"transform-inline-environment-variables",
|
||||
{
|
||||
"include": [
|
||||
"SOURCE_COMMIT",
|
||||
"SOURCE_VERSION"
|
||||
]
|
||||
}
|
||||
]
|
||||
"transform-class-properties"
|
||||
]
|
||||
}
|
||||
@@ -34,7 +34,6 @@ router.post("collections.create", auth(), async (ctx) => {
|
||||
name,
|
||||
color,
|
||||
description,
|
||||
sharing,
|
||||
icon,
|
||||
sort = Collection.DEFAULT_SORT,
|
||||
} = ctx.body;
|
||||
@@ -56,7 +55,6 @@ router.post("collections.create", auth(), async (ctx) => {
|
||||
teamId: user.teamId,
|
||||
createdById: user.id,
|
||||
private: isPrivate,
|
||||
sharing,
|
||||
sort,
|
||||
});
|
||||
|
||||
@@ -454,7 +452,7 @@ router.post("collections.export_all", auth(), async (ctx) => {
|
||||
});
|
||||
|
||||
router.post("collections.update", auth(), async (ctx) => {
|
||||
let { id, name, description, icon, color, sort, sharing } = ctx.body;
|
||||
let { id, name, description, icon, color, sort } = ctx.body;
|
||||
const isPrivate = ctx.body.private;
|
||||
|
||||
if (color) {
|
||||
@@ -500,9 +498,6 @@ router.post("collections.update", auth(), async (ctx) => {
|
||||
if (isPrivate !== undefined) {
|
||||
collection.private = isPrivate;
|
||||
}
|
||||
if (sharing !== undefined) {
|
||||
collection.sharing = sharing;
|
||||
}
|
||||
if (sort !== undefined) {
|
||||
collection.sort = sort;
|
||||
}
|
||||
|
||||
@@ -876,18 +876,6 @@ describe("#collections.create", () => {
|
||||
expect(body.policies[0].abilities.export).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should allow setting sharing to false", async () => {
|
||||
const { user } = await seed();
|
||||
const res = await server.post("/api/collections.create", {
|
||||
body: { token: user.getJwtToken(), name: "Test", sharing: false },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toBeTruthy();
|
||||
expect(body.data.sharing).toBe(false);
|
||||
});
|
||||
|
||||
it("should return correct policies with private collection", async () => {
|
||||
const { user } = await seed();
|
||||
const res = await server.post("/api/collections.create", {
|
||||
|
||||
@@ -488,11 +488,6 @@ async function loadDocument({ id, shareId, user }) {
|
||||
authorize(user, "read", document);
|
||||
}
|
||||
|
||||
const collection = await Collection.findByPk(document.collectionId);
|
||||
if (!collection.sharing) {
|
||||
throw new AuthorizationError();
|
||||
}
|
||||
|
||||
const team = await Team.findByPk(document.teamId);
|
||||
if (!team.sharing) {
|
||||
throw new AuthorizationError();
|
||||
|
||||
@@ -112,23 +112,6 @@ describe("#documents.info", () => {
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should not return document from shareId if sharing is disabled for collection", async () => {
|
||||
const { document, collection, user } = await seed();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
collection.sharing = false;
|
||||
await collection.save();
|
||||
|
||||
const res = await server.post("/api/documents.info", {
|
||||
body: { shareId: share.id },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should not return document from revoked shareId", async () => {
|
||||
const { document, user } = await seed();
|
||||
const share = await buildShare({
|
||||
|
||||
+13
-45
@@ -2,7 +2,7 @@
|
||||
import Router from "koa-router";
|
||||
import Sequelize from "sequelize";
|
||||
import auth from "../middlewares/authentication";
|
||||
import { Event, Team, User, Collection } from "../models";
|
||||
import { Event, Team, User } from "../models";
|
||||
import policy from "../policies";
|
||||
import { presentEvent } from "../presenters";
|
||||
import pagination from "./middlewares/pagination";
|
||||
@@ -12,62 +12,30 @@ const { authorize } = policy;
|
||||
const router = new Router();
|
||||
|
||||
router.post("events.list", auth(), pagination(), async (ctx) => {
|
||||
const user = ctx.state.user;
|
||||
let {
|
||||
sort = "createdAt",
|
||||
actorId,
|
||||
collectionId,
|
||||
direction,
|
||||
name,
|
||||
auditLog = false,
|
||||
} = ctx.body;
|
||||
let { sort = "createdAt", direction, auditLog = false } = ctx.body;
|
||||
if (direction !== "ASC") direction = "DESC";
|
||||
|
||||
const user = ctx.state.user;
|
||||
const collectionIds = await user.collectionIds({ paranoid: false });
|
||||
|
||||
let where = {
|
||||
name: Event.ACTIVITY_EVENTS,
|
||||
teamId: user.teamId,
|
||||
};
|
||||
|
||||
if (actorId) {
|
||||
ctx.assertUuid(actorId, "actorId must be a UUID");
|
||||
where = {
|
||||
...where,
|
||||
actorId,
|
||||
};
|
||||
}
|
||||
|
||||
if (collectionId) {
|
||||
ctx.assertUuid(collectionId, "collection must be a UUID");
|
||||
|
||||
where = { ...where, collectionId };
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId);
|
||||
authorize(user, "read", collection);
|
||||
} else {
|
||||
const collectionIds = await user.collectionIds({ paranoid: false });
|
||||
where = {
|
||||
...where,
|
||||
[Op.or]: [
|
||||
{ collectionId: collectionIds },
|
||||
{
|
||||
collectionId: {
|
||||
[Op.eq]: null,
|
||||
},
|
||||
[Op.or]: [
|
||||
{ collectionId: collectionIds },
|
||||
{
|
||||
collectionId: {
|
||||
[Op.eq]: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (auditLog) {
|
||||
authorize(user, "auditLog", Team);
|
||||
where.name = Event.AUDIT_EVENTS;
|
||||
}
|
||||
|
||||
if (name && where.name.includes(name)) {
|
||||
where.name = name;
|
||||
}
|
||||
|
||||
const events = await Event.findAll({
|
||||
where,
|
||||
order: [[sort, direction]],
|
||||
|
||||
+1
-105
@@ -13,7 +13,7 @@ describe("#events.list", () => {
|
||||
it("should only return activity events", async () => {
|
||||
const { user, admin, document, collection } = await seed();
|
||||
|
||||
// audit event
|
||||
// private event
|
||||
await buildEvent({
|
||||
name: "users.promote",
|
||||
teamId: user.teamId,
|
||||
@@ -29,7 +29,6 @@ describe("#events.list", () => {
|
||||
teamId: user.teamId,
|
||||
actorId: admin.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/events.list", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
@@ -40,100 +39,6 @@ describe("#events.list", () => {
|
||||
expect(body.data[0].id).toEqual(event.id);
|
||||
});
|
||||
|
||||
it("should return audit events", async () => {
|
||||
const { user, admin, document, collection } = await seed();
|
||||
|
||||
// audit event
|
||||
const auditEvent = await buildEvent({
|
||||
name: "users.promote",
|
||||
teamId: user.teamId,
|
||||
actorId: admin.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// event viewable in activity stream
|
||||
const event = await buildEvent({
|
||||
name: "documents.publish",
|
||||
collectionId: collection.id,
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
actorId: admin.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/events.list", {
|
||||
body: { token: admin.getJwtToken(), auditLog: true },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(2);
|
||||
expect(body.data[0].id).toEqual(event.id);
|
||||
expect(body.data[1].id).toEqual(auditEvent.id);
|
||||
});
|
||||
|
||||
it("should allow filtering by actorId", async () => {
|
||||
const { user, admin, document, collection } = await seed();
|
||||
|
||||
// audit event
|
||||
const auditEvent = await buildEvent({
|
||||
name: "users.promote",
|
||||
teamId: user.teamId,
|
||||
actorId: admin.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// event viewable in activity stream
|
||||
await buildEvent({
|
||||
name: "documents.publish",
|
||||
collectionId: collection.id,
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/events.list", {
|
||||
body: { token: admin.getJwtToken(), auditLog: true, actorId: admin.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(auditEvent.id);
|
||||
});
|
||||
|
||||
it("should allow filtering by event name", async () => {
|
||||
const { user, admin, document, collection } = await seed();
|
||||
|
||||
// audit event
|
||||
await buildEvent({
|
||||
name: "users.promote",
|
||||
teamId: user.teamId,
|
||||
actorId: admin.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// event viewable in activity stream
|
||||
const event = await buildEvent({
|
||||
name: "documents.publish",
|
||||
collectionId: collection.id,
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/events.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
name: "documents.publish",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(event.id);
|
||||
});
|
||||
|
||||
it("should return events with deleted actors", async () => {
|
||||
const { user, admin, document, collection } = await seed();
|
||||
|
||||
@@ -159,15 +64,6 @@ describe("#events.list", () => {
|
||||
expect(body.data[0].id).toEqual(event.id);
|
||||
});
|
||||
|
||||
it("should require authorization for audit events", async () => {
|
||||
const { user } = await seed();
|
||||
const res = await server.post("/api/events.list", {
|
||||
body: { token: user.getJwtToken(), auditLog: true },
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/events.list");
|
||||
const body = await res.json();
|
||||
|
||||
@@ -202,7 +202,7 @@ describe("#shares.create", () => {
|
||||
expect(body.data.id).toBe(share.id);
|
||||
});
|
||||
|
||||
it("should not allow creating a share record if team sharing disabled", async () => {
|
||||
it("should not allow creating a share record if disabled", async () => {
|
||||
const { user, document, team } = await seed();
|
||||
await team.update({ sharing: false });
|
||||
const res = await server.post("/api/shares.create", {
|
||||
@@ -211,15 +211,6 @@ describe("#shares.create", () => {
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should not allow creating a share record if collection sharing disabled", async () => {
|
||||
const { user, collection, document } = await seed();
|
||||
await collection.update({ sharing: false });
|
||||
const res = await server.post("/api/shares.create", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const { document } = await seed();
|
||||
const res = await server.post("/api/shares.create", {
|
||||
|
||||
@@ -55,17 +55,6 @@ router.post("users.list", auth(), pagination(), async (ctx) => {
|
||||
};
|
||||
});
|
||||
|
||||
router.post("users.count", auth(), async (ctx) => {
|
||||
const { user } = ctx.state;
|
||||
const counts = await User.getCounts(user.teamId);
|
||||
|
||||
ctx.body = {
|
||||
data: {
|
||||
counts,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
router.post("users.info", auth(), async (ctx) => {
|
||||
ctx.body = {
|
||||
data: presentUser(ctx.state.user),
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
import TestServer from "fetch-test-server";
|
||||
import app from "../app";
|
||||
|
||||
import { buildTeam, buildUser } from "../test/factories";
|
||||
|
||||
import { buildUser } from "../test/factories";
|
||||
import { flushdb, seed } from "../test/support";
|
||||
|
||||
const server = new TestServer(app.callback());
|
||||
@@ -354,75 +353,3 @@ describe("#users.activate", () => {
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#users.count", () => {
|
||||
it("should count active users", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const res = await server.post("/api/users.count", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.counts.all).toEqual(1);
|
||||
expect(body.data.counts.admins).toEqual(0);
|
||||
expect(body.data.counts.invited).toEqual(0);
|
||||
expect(body.data.counts.suspended).toEqual(0);
|
||||
expect(body.data.counts.active).toEqual(1);
|
||||
});
|
||||
|
||||
it("should count admin users", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id, isAdmin: true });
|
||||
const res = await server.post("/api/users.count", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.counts.all).toEqual(1);
|
||||
expect(body.data.counts.admins).toEqual(1);
|
||||
expect(body.data.counts.invited).toEqual(0);
|
||||
expect(body.data.counts.suspended).toEqual(0);
|
||||
expect(body.data.counts.active).toEqual(1);
|
||||
});
|
||||
|
||||
it("should count suspended users", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
await buildUser({ teamId: team.id, suspendedAt: new Date() });
|
||||
const res = await server.post("/api/users.count", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.counts.all).toEqual(2);
|
||||
expect(body.data.counts.admins).toEqual(0);
|
||||
expect(body.data.counts.invited).toEqual(0);
|
||||
expect(body.data.counts.suspended).toEqual(1);
|
||||
expect(body.data.counts.active).toEqual(1);
|
||||
});
|
||||
|
||||
it("should count invited users", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id, lastActiveAt: null });
|
||||
const res = await server.post("/api/users.count", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.counts.all).toEqual(1);
|
||||
expect(body.data.counts.admins).toEqual(0);
|
||||
expect(body.data.counts.invited).toEqual(1);
|
||||
expect(body.data.counts.suspended).toEqual(0);
|
||||
expect(body.data.counts.active).toEqual(0);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/users.count");
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
+3
-4
@@ -114,11 +114,10 @@ if (isProduction) {
|
||||
// catch errors in one place, automatically set status and response headers
|
||||
onerror(app);
|
||||
|
||||
if (env.SENTRY_DSN) {
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: env.SENTRY_DSN,
|
||||
environment: env.ENVIRONMENT,
|
||||
release: env.RELEASE,
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
environment: process.env.NODE_ENV,
|
||||
maxBreadcrumbs: 0,
|
||||
ignoreErrors: [
|
||||
// emitted by Koa when bots attempt to snoop on paths such as wp-admin
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
// @flow
|
||||
|
||||
// Note: This entire object is stringified in the HTML exposed to the client
|
||||
// do not add anything here that should be a secret or password
|
||||
export default {
|
||||
URL: process.env.URL,
|
||||
CDN_URL: process.env.CDN_URL || "",
|
||||
DEPLOYMENT: process.env.DEPLOYMENT,
|
||||
ENVIRONMENT: process.env.NODE_ENV,
|
||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||
TEAM_LOGO: process.env.TEAM_LOGO,
|
||||
SLACK_KEY: process.env.SLACK_KEY,
|
||||
SLACK_APP_ID: process.env.SLACK_APP_ID,
|
||||
SUBDOMAINS_ENABLED: process.env.SUBDOMAINS_ENABLED === "true",
|
||||
GOOGLE_ANALYTICS_ID: process.env.GOOGLE_ANALYTICS_ID,
|
||||
RELEASE: process.env.SOURCE_COMMIT || process.env.SOURCE_VERSION || undefined,
|
||||
};
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
// @flow
|
||||
require("dotenv").config({ silent: true });
|
||||
|
||||
// If the DataDog agent is installed and the DD_API_KEY environment variable is
|
||||
// in the environment then we can safely attempt to start the DD tracer
|
||||
if (process.env.DD_API_KEY) {
|
||||
require("dd-trace").init({
|
||||
// SOURCE_COMMIT is used by Docker Hub
|
||||
// SOURCE_VERSION is used by Heroku
|
||||
version: process.env.SOURCE_COMMIT || process.env.SOURCE_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!process.env.SECRET_KEY ||
|
||||
process.env.SECRET_KEY === "generate_a_new_key"
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn('collections', 'sharing', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
});
|
||||
},
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn('collections', 'sharing');
|
||||
}
|
||||
}
|
||||
@@ -24,11 +24,6 @@ const Collection = sequelize.define(
|
||||
private: DataTypes.BOOLEAN,
|
||||
maintainerApprovalRequired: DataTypes.BOOLEAN,
|
||||
documentStructure: DataTypes.JSONB,
|
||||
sharing: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
sort: {
|
||||
type: DataTypes.JSONB,
|
||||
validate: {
|
||||
|
||||
@@ -62,7 +62,6 @@ Event.ACTIVITY_EVENTS = [
|
||||
"documents.unarchive",
|
||||
"documents.pin",
|
||||
"documents.unpin",
|
||||
"documents.move",
|
||||
"documents.delete",
|
||||
"documents.restore",
|
||||
"users.create",
|
||||
@@ -87,7 +86,6 @@ Event.AUDIT_EVENTS = [
|
||||
"documents.unpin",
|
||||
"documents.move",
|
||||
"documents.delete",
|
||||
"documents.restore",
|
||||
"groups.create",
|
||||
"groups.update",
|
||||
"groups.delete",
|
||||
|
||||
@@ -51,9 +51,6 @@ const User = sequelize.define(
|
||||
isSuspended() {
|
||||
return !!this.suspendedAt;
|
||||
},
|
||||
isInvited() {
|
||||
return !this.lastActiveAt;
|
||||
},
|
||||
avatarUrl() {
|
||||
const original = this.getDataValue("avatarUrl");
|
||||
if (original) {
|
||||
@@ -270,33 +267,4 @@ User.afterCreate(async (user, options) => {
|
||||
]);
|
||||
});
|
||||
|
||||
User.getCounts = async function (teamId: string) {
|
||||
const countSql = `
|
||||
SELECT
|
||||
COUNT(CASE WHEN "suspendedAt" IS NOT NULL THEN 1 END) as "suspendedCount",
|
||||
COUNT(CASE WHEN "isAdmin" = true THEN 1 END) as "adminCount",
|
||||
COUNT(CASE WHEN "lastActiveAt" IS NULL THEN 1 END) as "invitedCount",
|
||||
COUNT(CASE WHEN "suspendedAt" IS NULL AND "lastActiveAt" IS NOT NULL THEN 1 END) as "activeCount",
|
||||
COUNT(*) as count
|
||||
FROM users
|
||||
WHERE "deletedAt" IS NULL
|
||||
AND "teamId" = :teamId
|
||||
`;
|
||||
const results = await sequelize.query(countSql, {
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
replacements: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
const counts = results[0];
|
||||
|
||||
return {
|
||||
active: parseInt(counts.activeCount),
|
||||
admins: parseInt(counts.adminCount),
|
||||
all: parseInt(counts.count),
|
||||
invited: parseInt(counts.invitedCount),
|
||||
suspended: parseInt(counts.suspendedCount),
|
||||
};
|
||||
};
|
||||
|
||||
export default User;
|
||||
|
||||
@@ -31,29 +31,6 @@ allow(User, ["read", "export"], Collection, (user, collection) => {
|
||||
return true;
|
||||
});
|
||||
|
||||
allow(User, "share", Collection, (user, collection) => {
|
||||
if (!collection || user.teamId !== collection.teamId) return false;
|
||||
if (!collection.sharing) return false;
|
||||
|
||||
if (collection.private) {
|
||||
invariant(
|
||||
collection.memberships,
|
||||
"membership should be preloaded, did you forget withMembership scope?"
|
||||
);
|
||||
|
||||
const allMemberships = concat(
|
||||
collection.memberships,
|
||||
collection.collectionGroupMemberships
|
||||
);
|
||||
|
||||
return some(allMemberships, (m) =>
|
||||
["read_write", "maintainer"].includes(m.permission)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
allow(User, ["publish", "update"], Collection, (user, collection) => {
|
||||
if (!collection || user.teamId !== collection.teamId) return false;
|
||||
|
||||
|
||||
@@ -31,22 +31,12 @@ allow(User, ["star", "unstar"], Document, (user, document) => {
|
||||
return user.teamId === document.teamId;
|
||||
});
|
||||
|
||||
allow(User, "share", Document, (user, document) => {
|
||||
allow(User, ["update", "share"], Document, (user, document) => {
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
|
||||
if (cannot(user, "share", document.collection)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return user.teamId === document.teamId;
|
||||
});
|
||||
|
||||
allow(User, "update", Document, (user, document) => {
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
|
||||
if (cannot(user, "update", document.collection)) {
|
||||
// existence of collection option is not required here to account for share tokens
|
||||
if (document.collection && cannot(user, "update", document.collection)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ export default function present(collection: Collection) {
|
||||
icon: collection.icon,
|
||||
color: collection.color || "#4E5C6E",
|
||||
private: collection.private,
|
||||
sharing: collection.sharing,
|
||||
createdAt: collection.createdAt,
|
||||
updatedAt: collection.updatedAt,
|
||||
deletedAt: collection.deletedAt,
|
||||
|
||||
@@ -67,8 +67,6 @@ router.get("/_health", (ctx) => (ctx.body = "OK"));
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
router.get("/static/*", async (ctx) => {
|
||||
ctx.set({
|
||||
"Service-Worker-Allowed": "/",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Cache-Control": `max-age=${356 * 24 * 60 * 60}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,25 +2,17 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Outline</title>
|
||||
<meta name="theme-color" content="#FFF" />
|
||||
<meta name="slack-app-id" content="//inject-slack-app-id//" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="description" content="A modern team knowledge base for your internal documentation, product specs, support answers, meeting notes, onboarding, & more…">
|
||||
//inject-prefetch//
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
type="image/png"
|
||||
href="/favicon-32.png"
|
||||
href="favicon-32.png"
|
||||
sizes="32x32"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
type="image/png"
|
||||
href="/apple-touch-icon.png"
|
||||
sizes="192x192"
|
||||
/>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link
|
||||
rel="search"
|
||||
type="application/opensearchdescription+xml"
|
||||
@@ -54,9 +46,7 @@
|
||||
</script>
|
||||
<script>
|
||||
if (window.localStorage && window.localStorage.getItem("theme") === "dark") {
|
||||
var color = "#111319";
|
||||
document.querySelector("#root").style.background = color;
|
||||
document.querySelector('meta[name="theme-color"]').setAttribute("content", color);
|
||||
window.document.querySelector("#root").style.background = "#111319";
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+2
-5
@@ -17,11 +17,6 @@ const s3 = new AWS.S3({
|
||||
accessKeyId: AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: AWS_SECRET_ACCESS_KEY,
|
||||
region: AWS_REGION,
|
||||
endpoint: process.env.AWS_S3_UPLOAD_BUCKET_URL.includes(
|
||||
AWS_S3_UPLOAD_BUCKET_NAME
|
||||
)
|
||||
? undefined
|
||||
: new AWS.Endpoint(process.env.AWS_S3_UPLOAD_BUCKET_URL),
|
||||
signatureVersion: "v4",
|
||||
});
|
||||
|
||||
@@ -115,6 +110,7 @@ export const uploadToS3FromBuffer = async (
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
ContentLength: buffer.length,
|
||||
ServerSideEncryption: "AES256",
|
||||
Body: buffer,
|
||||
})
|
||||
.promise();
|
||||
@@ -139,6 +135,7 @@ export const uploadToS3FromUrl = async (
|
||||
Key: key,
|
||||
ContentType: res.headers["content-type"],
|
||||
ContentLength: res.headers["content-length"],
|
||||
ServerSideEncryption: "AES256",
|
||||
Body: buffer,
|
||||
})
|
||||
.promise();
|
||||
|
||||
@@ -78,8 +78,8 @@
|
||||
"Warning": "Warnung",
|
||||
"Warning notice": "Warnhinweis",
|
||||
"Icon": "Icon",
|
||||
"Show menu": "Show menu",
|
||||
"Choose icon": "Choose icon",
|
||||
"Show menu": "Menü anzeigen",
|
||||
"Choose icon": "Symbol auswählen",
|
||||
"Loading": "Laden",
|
||||
"Search": "Suche",
|
||||
"Outline is available in your language {{optionLabel}}, would you like to change?": "Outline ist in Ihrer Sprache {{optionLabel}} verfügbar. Möchten Sie diese ändern?",
|
||||
@@ -97,7 +97,7 @@
|
||||
"Invite people": "Personen einladen",
|
||||
"Create a collection": "Sammlung erstellen",
|
||||
"Return to App": "Zurück zur App",
|
||||
"Account": "Account",
|
||||
"Account": "Benutzerkonto",
|
||||
"Profile": "Profil",
|
||||
"Notifications": "Benachrichtigungen",
|
||||
"API Tokens": "API-Tokens",
|
||||
@@ -110,7 +110,7 @@
|
||||
"Export Data": "Daten exportieren",
|
||||
"Integrations": "Integrationen",
|
||||
"Installation": "Installation",
|
||||
"Resize sidebar": "Resize sidebar",
|
||||
"Resize sidebar": "Ändern Sie die Größe der Seitenleiste",
|
||||
"Unstar": "Aus den Favoriten entfernen",
|
||||
"Star": "Favorisieren",
|
||||
"Appearance": "Darstellung",
|
||||
@@ -122,9 +122,9 @@
|
||||
"Send us feedback": "Schicken Sie uns Feedback",
|
||||
"Report a bug": "Ein technischer Fehler melden",
|
||||
"Log out": "Ausloggen",
|
||||
"Show path to document": "Show path to document",
|
||||
"Path to document": "Path to document",
|
||||
"Group member options": "Group member options",
|
||||
"Show path to document": "Pfad zum Dokument anzeigen",
|
||||
"Path to document": "Pfad zum Dokument",
|
||||
"Group member options": "Optionen für Gruppenmitglieder",
|
||||
"Members": "Mitglieder",
|
||||
"Remove": "Entfernen",
|
||||
"Collection": "Sammlung",
|
||||
@@ -138,7 +138,7 @@
|
||||
"Edit collection": "Sammlung bearbeiten",
|
||||
"Delete collection": "Sammlung löschen",
|
||||
"Export collection": "Sammlung exportieren",
|
||||
"Show sort menu": "Show sort menu",
|
||||
"Show sort menu": "Sortiermenü anzeigen",
|
||||
"Sort in sidebar": "Seitenleiste sortieren",
|
||||
"Alphabetical sort": "Alphabetische Sortierung",
|
||||
"Manual sort": "Manuelle Sortierung",
|
||||
@@ -240,7 +240,7 @@
|
||||
"Create": "Erstellen",
|
||||
"Recently viewed": "Zuletzt angesehen",
|
||||
"Created by me": "Von mir erstellt",
|
||||
"Search documents": "Search documents",
|
||||
"Search documents": "Dokumente durchsuchen",
|
||||
"Hide contents": "Inhalt ausblenden",
|
||||
"Show contents": "Inhalt anzeigen",
|
||||
"Archived": "Archiviert",
|
||||
@@ -252,8 +252,8 @@
|
||||
"New from template": "Neu aus Vorlage",
|
||||
"Publish": "Veröffentlichen",
|
||||
"Publishing": "Wird publiziert",
|
||||
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Are you sure you want to delete the <em>{{ documentTitle }}</em> template?",
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.",
|
||||
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Möchten Sie die Vorlage <em>{{ documentTitle }}</em> wirklich löschen?",
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.": "Bist du dir da sicher? Durch Löschen des <em>{{ documentTitle }}</em> -Dokuments werden der gesamte Verlauf und alle verschachtelten Dokumente gelöscht.",
|
||||
"If you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "Wenn Sie die Möglichkeit haben möchten, die {{noun}} in Zukunft zu referenzieren oder wiederherzustellen, sollten Sie sie stattdessen archivieren.",
|
||||
"Deleting": "Löscht",
|
||||
"I’m sure – Delete": "Ich bin sicher – Löschen",
|
||||
|
||||
@@ -8,10 +8,6 @@
|
||||
"Drafts": "Drafts",
|
||||
"Templates": "Templates",
|
||||
"Deleted Collection": "Deleted Collection",
|
||||
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
|
||||
"Add a description": "Add a description",
|
||||
"Collapse": "Collapse",
|
||||
"Expand": "Expand",
|
||||
"Submenu": "Submenu",
|
||||
"New": "New",
|
||||
"Only visible to you": "Only visible to you",
|
||||
@@ -90,6 +86,8 @@
|
||||
"Change Language": "Change Language",
|
||||
"Dismiss": "Dismiss",
|
||||
"Keyboard shortcuts": "Keyboard shortcuts",
|
||||
"Expand": "Expand",
|
||||
"Collapse": "Collapse",
|
||||
"New collection": "New collection",
|
||||
"Collections": "Collections",
|
||||
"Untitled": "Untitled",
|
||||
@@ -98,7 +96,7 @@
|
||||
"Settings": "Settings",
|
||||
"Invite people": "Invite people",
|
||||
"Create a collection": "Create a collection",
|
||||
"Return to App": "Back to App",
|
||||
"Return to App": "Return to App",
|
||||
"Account": "Account",
|
||||
"Profile": "Profile",
|
||||
"Notifications": "Notifications",
|
||||
@@ -112,6 +110,7 @@
|
||||
"Export Data": "Export Data",
|
||||
"Integrations": "Integrations",
|
||||
"Installation": "Installation",
|
||||
"Resize sidebar": "Resize sidebar",
|
||||
"Unstar": "Unstar",
|
||||
"Star": "Star",
|
||||
"Appearance": "Appearance",
|
||||
@@ -132,9 +131,10 @@
|
||||
"New document": "New document",
|
||||
"Import document": "Import document",
|
||||
"Edit": "Edit",
|
||||
"Permissions": "Permissions",
|
||||
"Export": "Export",
|
||||
"Delete": "Delete",
|
||||
"Collection members": "Collection members",
|
||||
"Collection permissions": "Collection permissions",
|
||||
"Edit collection": "Edit collection",
|
||||
"Delete collection": "Delete collection",
|
||||
"Export collection": "Export collection",
|
||||
@@ -206,12 +206,11 @@
|
||||
"The collection was updated": "The collection was updated",
|
||||
"You can edit the name and other details at any time, however doing so often might confuse your team mates.": "You can edit the name and other details at any time, however doing so often might confuse your team mates.",
|
||||
"Name": "Name",
|
||||
"Description": "Description",
|
||||
"More details about this collection…": "More details about this collection…",
|
||||
"Alphabetical": "Alphabetical",
|
||||
"Private collection": "Private collection",
|
||||
"A private collection will only be visible to invited team members.": "A private collection will only be visible to invited team members.",
|
||||
"Public document sharing": "Public document sharing",
|
||||
"When enabled, documents can be shared publicly on the internet.": "When enabled, documents can be shared publicly on the internet.",
|
||||
"Public sharing is currently disabled in the team security settings.": "Public sharing is currently disabled in the team security settings.",
|
||||
"Saving": "Saving",
|
||||
"Save": "Save",
|
||||
"{{ groupName }} was added to the collection": "{{ groupName }} was added to the collection",
|
||||
@@ -232,14 +231,16 @@
|
||||
"No people left to add": "No people left to add",
|
||||
"Read only": "Read only",
|
||||
"Read & Edit": "Read & Edit",
|
||||
"Permissions": "Permissions",
|
||||
"Active <1></1> ago": "Active <1></1> ago",
|
||||
"Never signed in": "Never signed in",
|
||||
"Invited": "Invited",
|
||||
"Admin": "Admin",
|
||||
"Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.": "Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.",
|
||||
"Collections are for grouping your knowledge base. They work best when organized around a topic or internal team — Product or Engineering for example.": "Collections are for grouping your knowledge base. They work best when organized around a topic or internal team — Product or Engineering for example.",
|
||||
"Creating": "Creating",
|
||||
"Create": "Create",
|
||||
"Recently viewed": "Recently viewed",
|
||||
"Created by me": "Created by me",
|
||||
"Search documents": "Search documents",
|
||||
"Hide contents": "Hide contents",
|
||||
"Show contents": "Show contents",
|
||||
"Archived": "Archived",
|
||||
@@ -257,7 +258,6 @@
|
||||
"Deleting": "Deleting",
|
||||
"I’m sure – Delete": "I’m sure – Delete",
|
||||
"Archiving": "Archiving",
|
||||
"Search documents": "Search documents",
|
||||
"No documents found for your filters.": "No documents found for your filters.",
|
||||
"You’ve not got any drafts at the moment.": "You’ve not got any drafts at the moment.",
|
||||
"Not found": "Not found",
|
||||
@@ -273,8 +273,6 @@
|
||||
"Could not remove user": "Could not remove user",
|
||||
"Add people": "Add people",
|
||||
"This group has no members.": "This group has no members.",
|
||||
"Recently viewed": "Recently viewed",
|
||||
"Created by me": "Created by me",
|
||||
"Outline is designed to be fast and easy to use. All of your usual keyboard shortcuts work here, and there’s Markdown too.": "Outline is designed to be fast and easy to use. All of your usual keyboard shortcuts work here, and there’s Markdown too.",
|
||||
"Navigation": "Navigation",
|
||||
"New document in current collection": "New document in current collection",
|
||||
@@ -306,12 +304,6 @@
|
||||
"Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base": "Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base",
|
||||
"No documents found for your search filters. <1></1>Create a new document?": "No documents found for your search filters. <1></1>Create a new document?",
|
||||
"Clear filters": "Clear filters",
|
||||
"Everyone that has signed into Outline appears here. It’s possible that there are other users who have access through {team.signinMethods} but haven’t signed in yet.": "Everyone that has signed into Outline appears here. It’s possible that there are other users who have access through {team.signinMethods} but haven’t signed in yet.",
|
||||
"Active": "Active",
|
||||
"Admins": "Admins",
|
||||
"Suspended": "Suspended",
|
||||
"Everyone": "Everyone",
|
||||
"No people to see here.": "No people to see here.",
|
||||
"Profile saved": "Profile saved",
|
||||
"Profile picture updated": "Profile picture updated",
|
||||
"Unable to upload new profile picture": "Unable to upload new profile picture",
|
||||
@@ -329,6 +321,7 @@
|
||||
"You joined": "You joined",
|
||||
"Joined": "Joined",
|
||||
"{{ time }} ago.": "{{ time }} ago.",
|
||||
"Suspended": "Suspended",
|
||||
"Edit Profile": "Edit Profile",
|
||||
"{{ userName }} hasn’t updated any documents yet.": "{{ userName }} hasn’t updated any documents yet."
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"Drafts": "임시 보관함",
|
||||
"Templates": "템플릿",
|
||||
"Deleted Collection": "삭제 된 콜렉션",
|
||||
"Submenu": "Submenu",
|
||||
"Submenu": "하위 메뉴",
|
||||
"New": "신규",
|
||||
"Only visible to you": "나에게만 보임",
|
||||
"Draft": "임시보관",
|
||||
@@ -23,8 +23,8 @@
|
||||
"Never viewed": "미조회",
|
||||
"Viewed": "조회",
|
||||
"in": "안에",
|
||||
"nested document": "nested document",
|
||||
"nested document_plural": "nested documents",
|
||||
"nested document": "하위 문서",
|
||||
"nested document_plural": "하위 문서들",
|
||||
"Insert column after": "뒤에 열 추가",
|
||||
"Insert column before": "앞에 열 추가",
|
||||
"Insert row after": "뒤에 행 추가",
|
||||
@@ -78,16 +78,16 @@
|
||||
"Warning": "주의",
|
||||
"Warning notice": "주의 사항",
|
||||
"Icon": "아이콘",
|
||||
"Show menu": "Show menu",
|
||||
"Choose icon": "Choose icon",
|
||||
"Show menu": "메뉴 보기",
|
||||
"Choose icon": "아이콘 선택",
|
||||
"Loading": "로딩 중",
|
||||
"Search": "검색",
|
||||
"Outline is available in your language {{optionLabel}}, would you like to change?": "Outline을 {{optionLabel}}어로 사용할 수 있어요. 적용할까요?",
|
||||
"Change Language": "언어 변경",
|
||||
"Dismiss": "닫기",
|
||||
"Keyboard shortcuts": "단축키",
|
||||
"Expand": "Expand",
|
||||
"Collapse": "Collapse",
|
||||
"Expand": "펼치기",
|
||||
"Collapse": "감추기",
|
||||
"New collection": "새 컬렉션",
|
||||
"Collections": "컬렉션",
|
||||
"Untitled": "제목없음",
|
||||
@@ -97,11 +97,11 @@
|
||||
"Invite people": "초대하기",
|
||||
"Create a collection": "컬렉션 만들기",
|
||||
"Return to App": "앱으로 돌아가기",
|
||||
"Account": "Account",
|
||||
"Account": "계정",
|
||||
"Profile": "사용자 정보",
|
||||
"Notifications": "알림",
|
||||
"API Tokens": "API 토큰",
|
||||
"Team": "Team",
|
||||
"Team": "팀",
|
||||
"Details": "세부 정보",
|
||||
"Security": "보안",
|
||||
"People": "사용자",
|
||||
@@ -110,7 +110,7 @@
|
||||
"Export Data": "데이터 내보내기",
|
||||
"Integrations": "연동",
|
||||
"Installation": "설치",
|
||||
"Resize sidebar": "Resize sidebar",
|
||||
"Resize sidebar": "사이드 바 크기 조정",
|
||||
"Unstar": "중요 문서 해제",
|
||||
"Star": "중요 문서 표시",
|
||||
"Appearance": "화면",
|
||||
@@ -122,12 +122,12 @@
|
||||
"Send us feedback": "의견 보내기",
|
||||
"Report a bug": "버그 신고",
|
||||
"Log out": "로그아웃",
|
||||
"Show path to document": "Show path to document",
|
||||
"Path to document": "Path to document",
|
||||
"Group member options": "Group member options",
|
||||
"Members": "Members",
|
||||
"Show path to document": "문서 경로 표시",
|
||||
"Path to document": "문서 경로",
|
||||
"Group member options": "그룹 멤버 옵션",
|
||||
"Members": "멤버",
|
||||
"Remove": "제거",
|
||||
"Collection": "Collection",
|
||||
"Collection": "컬렉션",
|
||||
"New document": "새 문서",
|
||||
"Import document": "문서 가져 오기",
|
||||
"Edit": "편집",
|
||||
@@ -138,15 +138,15 @@
|
||||
"Edit collection": "컬렉션 편집",
|
||||
"Delete collection": "컬렉션 삭제",
|
||||
"Export collection": "컬렉션 내보내기",
|
||||
"Show sort menu": "Show sort menu",
|
||||
"Sort in sidebar": "Sort in sidebar",
|
||||
"Alphabetical sort": "Alphabetical sort",
|
||||
"Manual sort": "Manual sort",
|
||||
"Show sort menu": "정렬 메뉴 표시",
|
||||
"Sort in sidebar": "사이드바 정렬",
|
||||
"Alphabetical sort": "알파벳 순 정렬",
|
||||
"Manual sort": "수동 정렬",
|
||||
"Document duplicated": "문서가 중복되었습니다",
|
||||
"Document archived": "문서가 보관되었습니다",
|
||||
"Document restored": "문서가 복원되었습니다",
|
||||
"Document unpublished": "문서가 게시되지 않았습니다",
|
||||
"Document options": "Document options",
|
||||
"Document options": "문서 옵션",
|
||||
"Restore": "복원하기",
|
||||
"Choose a collection": "컬렉션 선택",
|
||||
"Unpin": "고정 해제",
|
||||
@@ -158,7 +158,7 @@
|
||||
"Create template": "템플릿 만들기",
|
||||
"Duplicate": "복사하기",
|
||||
"Unpublish": "게시 취소",
|
||||
"Move": "Move",
|
||||
"Move": "이동",
|
||||
"History": "히스토리",
|
||||
"Download": "다운로드",
|
||||
"Print": "인쇄",
|
||||
@@ -166,38 +166,38 @@
|
||||
"Share document": "문서 공유하기",
|
||||
"Edit group": "그룹 수정",
|
||||
"Delete group": "그룹 삭제",
|
||||
"Group options": "Group options",
|
||||
"Member options": "Member options",
|
||||
"Group options": "그룹 옵션",
|
||||
"Member options": "멤버 옵션",
|
||||
"collection": "컬렉션",
|
||||
"New child document": "New child document",
|
||||
"New document in <em>{{ collectionName }}</em>": "New document in <em>{{ collectionName }}</em>",
|
||||
"New template": "New template",
|
||||
"New child document": "새 하위 문서",
|
||||
"New document in <em>{{ collectionName }}</em>": "<em>{{ collectionName }}</em> 컬렉션의 새 문서",
|
||||
"New template": "새 템플릿",
|
||||
"Link copied": "링크 복사됨",
|
||||
"Revision options": "Revision options",
|
||||
"Revision options": "수정 옵션",
|
||||
"Restore version": "버전 복원",
|
||||
"Copy link": "링크 복사",
|
||||
"Share link revoked": "링크 공유가 취소되었습니다",
|
||||
"Share link copied": "공유 할 링크가 복사되었습니다.",
|
||||
"Share options": "Share options",
|
||||
"Share options": "공유 옵션",
|
||||
"Go to document": "문서로 이동",
|
||||
"Revoke link": "링크 삭제",
|
||||
"By {{ author }}": "{{ author }} 작성",
|
||||
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.": "정말로 {{ userName }} 에게 관리자 권한을 부여 하시겠습니까? 관리자는 팀과 결제 정보를 수정할 수 있습니다.",
|
||||
"Are you sure you want to make {{ userName }} a member?": "정말로 {{ userName }} 에게 멤버 권한을 부여 하시겠습니까?",
|
||||
"Are you sure you want to suspend this account? Suspended users will be prevented from logging in.": "이 계정 사용을 중지 하시겠습니까? 정지된 사용자는 로그인 할 수 없습니다.",
|
||||
"User options": "User options",
|
||||
"User options": "사용자 옵션",
|
||||
"Make {{ userName }} a member…": "{{ userName }} 을 멤버로 지정...",
|
||||
"Make {{ userName }} an admin…": "{{ userName }} 을 관리자로 지정...",
|
||||
"Revoke invite": "Revoke invite",
|
||||
"Revoke invite": "초대 취소",
|
||||
"Activate account": "계정 활성화",
|
||||
"Suspend account": "Suspend account",
|
||||
"Suspend account": "계정 정지",
|
||||
"Documents": "문서",
|
||||
"The document archive is empty at the moment.": "현재 보관 문서함이 비어 있습니다.",
|
||||
"Search in collection": "Search in collection",
|
||||
"<em>{{ collectionName }}</em> doesn’t contain any\n documents yet.": "<em>{{ collectionName }}</em> doesn’t contain any\n documents yet.",
|
||||
"Search in collection": "컬렉션 검색",
|
||||
"<em>{{ collectionName }}</em> doesn’t contain any\n documents yet.": "<em>{{ collectionName }}</em> 컬렉션에 문서가 없습니다.",
|
||||
"Get started by creating a new one!": "새 문서를 만들어 시작해 보세요!",
|
||||
"Create a document": "새 문서",
|
||||
"Manage members": "Manage members",
|
||||
"Manage members": "멤버 관리",
|
||||
"Pinned": "고정됨",
|
||||
"Recently updated": "최근 업데이트 순",
|
||||
"Recently published": "최근 게시 순",
|
||||
@@ -217,7 +217,7 @@
|
||||
"Could not add user": "사용자를 추가할 수 없습니다",
|
||||
"Can’t find the group you’re looking for?": "그룹을 찾지 못하시겠습니까?",
|
||||
"Create a group": "그룹 만들기",
|
||||
"Search by group name": "Search by group name",
|
||||
"Search by group name": "그룹명으로 검색",
|
||||
"Search groups": "그룹 검색",
|
||||
"No groups matching your search": "찾으시는 그룹이 없습니다",
|
||||
"No groups left to add": "사용자를 추가할 그룹이 없습니다",
|
||||
@@ -240,7 +240,7 @@
|
||||
"Create": "생성",
|
||||
"Recently viewed": "최근에 조회됨",
|
||||
"Created by me": "나에 의해 생성됨",
|
||||
"Search documents": "Search documents",
|
||||
"Search documents": "문서 검색",
|
||||
"Hide contents": "내용 숨기기",
|
||||
"Show contents": "내용 보기",
|
||||
"Archived": "보관됨",
|
||||
@@ -252,8 +252,8 @@
|
||||
"New from template": "새 템플릿 문서",
|
||||
"Publish": "게시하기",
|
||||
"Publishing": "게시 중",
|
||||
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Are you sure you want to delete the <em>{{ documentTitle }}</em> template?",
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.",
|
||||
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "템플릿 <em>{{ documentTitle }}</em> 을 정말 삭제하시겠습니까?",
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.": "정말 <em>{{ documentTitle }}</em> 문서를 삭제하시겠습니까? 문서를 삭제하면 모든 이력과 하위 문서들이 삭제됩니다.",
|
||||
"If you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "If you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.",
|
||||
"Deleting": "Deleting",
|
||||
"I’m sure – Delete": "I’m sure – Delete",
|
||||
@@ -281,7 +281,7 @@
|
||||
"Jump to search": "검색으로 이동",
|
||||
"Jump to dashboard": "대시보드로 이동",
|
||||
"Table of contents": "목차",
|
||||
"Toggle sidebar": "Toggle sidebar",
|
||||
"Toggle sidebar": "사이드 바 전환",
|
||||
"Open this guide": "이 가이드 열기",
|
||||
"Editor": "편집자",
|
||||
"Save and exit document edit mode": "문서 저장하고 편집 모드 종료",
|
||||
@@ -301,7 +301,7 @@
|
||||
"Inline code": "인라인 코드",
|
||||
"Not Found": "찾을 수 없습니다",
|
||||
"We were unable to find the page you’re looking for.": "페이지를 찾을 수 없습니다.",
|
||||
"Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base": "Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base",
|
||||
"Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base": "<em>{{ meta }}+K</em> 단축키를 사용 해 문서를 검색을 해 보세요.",
|
||||
"No documents found for your search filters. <1></1>Create a new document?": "문서를 찾을 수 없어요. <1></1>새 문서를 만들까요?",
|
||||
"Clear filters": "필터 지우기",
|
||||
"Profile saved": "프로필이 저장되었습니다",
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"Archive": "Архив",
|
||||
"Drafts": "Черновики",
|
||||
"Templates": "Шаблоны",
|
||||
"Deleted Collection": "Удаленная коллекция",
|
||||
"Submenu": "Submenu",
|
||||
"Deleted Collection": "Удаленная подборка",
|
||||
"Submenu": "Подменю",
|
||||
"New": "Новое",
|
||||
"Only visible to you": "Видно только вам",
|
||||
"Draft": "Черновик",
|
||||
@@ -23,19 +23,19 @@
|
||||
"Never viewed": "Не просмотрено",
|
||||
"Viewed": "Просмотрено",
|
||||
"in": "в",
|
||||
"nested document": "nested document",
|
||||
"nested document_plural": "nested documents",
|
||||
"Insert column after": "Вставить столбец справа",
|
||||
"Insert column before": "Вставить столбец слева",
|
||||
"Insert row after": "Вставить строку снизу",
|
||||
"Insert row before": "Вставить строку сверху",
|
||||
"Align center": "Выровнять по центру",
|
||||
"Align left": "Выровнять по левому краю",
|
||||
"Align right": "Выровнять по правому краю",
|
||||
"nested document": "вложенный документ",
|
||||
"nested document_plural": "вложенные документы",
|
||||
"Insert column after": "Вставить справа",
|
||||
"Insert column before": "Вставить слева",
|
||||
"Insert row after": "Вставить снизу",
|
||||
"Insert row before": "Вставить сверху",
|
||||
"Align center": "По центру",
|
||||
"Align left": "По левому краю",
|
||||
"Align right": "По правому краю",
|
||||
"Bulleted list": "Маркированный список",
|
||||
"Todo list": "Список дел",
|
||||
"Todo list": "Чек-лист",
|
||||
"Code block": "Блок кода",
|
||||
"Copied to clipboard": "Скопировано в буфер обмена",
|
||||
"Copied to clipboard": "Скопировано",
|
||||
"Code": "Код",
|
||||
"Create link": "Создать ссылку",
|
||||
"Sorry, an error occurred creating the link": "К сожалению, при создании ссылки возникла ошибка",
|
||||
@@ -44,24 +44,24 @@
|
||||
"Delete row": "Удалить строку",
|
||||
"Delete table": "Удалить таблицу",
|
||||
"Italic": "Курсив",
|
||||
"Sorry, that link won’t work for this embed type": "К сожалению, эта ссылка не работает для такого типа встраивания",
|
||||
"Sorry, that link won’t work for this embed type": "Упс... Ссылка не сработает для такого типа встраивания",
|
||||
"Find or create a doc": "Найти или создать документ",
|
||||
"Big heading": "Большой заголовок",
|
||||
"Medium heading": "Средний заголовок",
|
||||
"Small heading": "Малый заголовок",
|
||||
"Heading": "Заголовок",
|
||||
"Divider": "Разделитель",
|
||||
"Image": "Изображение",
|
||||
"Sorry, an error occurred uploading the image": "К сожалению, при загрузке изображения произошла ошибка",
|
||||
"Info": "Информация",
|
||||
"Info notice": "Информационное уведомление",
|
||||
"Image": "Картинка",
|
||||
"Sorry, an error occurred uploading the image": "Упс! Ошибка при загрузке картинки. Попробуйте ещё раз",
|
||||
"Info": "Детали",
|
||||
"Info notice": "Уведомление с деталями",
|
||||
"Link": "Ссылка",
|
||||
"Link copied to clipboard": "Ссылка скопирована в буфер обмена",
|
||||
"Highlight": "Выделение",
|
||||
"Type '/' to insert": "Наберите '/' для вставки",
|
||||
"Keep typing to filter": "Продолжайте печатать для фильтрации",
|
||||
"No results": "Ничего не найденo",
|
||||
"Open link": "Перейти по ссылке",
|
||||
"Type '/' to insert": "Наберите '/' чтоб вставить",
|
||||
"Keep typing to filter": "Напишите, чтобы отфильтровать",
|
||||
"No results": "Не найдено",
|
||||
"Open link": "Открыть ссылку",
|
||||
"Ordered list": "Нумерованный список",
|
||||
"Paste a link": "Вставить ссылку",
|
||||
"Paste a {{service}} link…": "Вставить ссылку {{service}}…",
|
||||
@@ -69,17 +69,17 @@
|
||||
"Quote": "Цитата",
|
||||
"Remove link": "Удалить ссылку",
|
||||
"Search or paste a link": "Найти или вставить ссылку",
|
||||
"Strikethrough": "Зачеркнуто",
|
||||
"Strikethrough": "Зачеркнуть",
|
||||
"Bold": "Жирный",
|
||||
"Subheading": "Подзаголовок",
|
||||
"Table": "Таблица",
|
||||
"Tip": "Подсказка",
|
||||
"Tip notice": "Подсказка",
|
||||
"Warning": "Предупреждение",
|
||||
"Tip notice": "Совет",
|
||||
"Warning": "Внимание",
|
||||
"Warning notice": "Предупреждение",
|
||||
"Icon": "Значок",
|
||||
"Show menu": "Show menu",
|
||||
"Choose icon": "Choose icon",
|
||||
"Show menu": "Показать меню",
|
||||
"Choose icon": "Выбрать значок",
|
||||
"Loading": "Загрузка",
|
||||
"Search": "Поиск",
|
||||
"Outline is available in your language {{optionLabel}}, would you like to change?": "Outline доступен на Вашем языке {{optionLabel}}, хотите изменить?",
|
||||
@@ -88,113 +88,113 @@
|
||||
"Keyboard shortcuts": "Горячие клавиши",
|
||||
"Expand": "Развернуть",
|
||||
"Collapse": "Свернуть",
|
||||
"New collection": "Новая коллекция",
|
||||
"Collections": "Коллекции",
|
||||
"New collection": "Новая подборка",
|
||||
"Collections": "Подборки",
|
||||
"Untitled": "Без названия",
|
||||
"Home": "Домашняя страница",
|
||||
"Starred": "Избранное",
|
||||
"Settings": "Настройки",
|
||||
"Invite people": "Пригласить людей",
|
||||
"Create a collection": "Создать коллекцию",
|
||||
"Create a collection": "Создать подборку",
|
||||
"Return to App": "Вернуться в приложение",
|
||||
"Account": "Account",
|
||||
"Account": "Аккаунт",
|
||||
"Profile": "Профиль",
|
||||
"Notifications": "Уведомления",
|
||||
"API Tokens": "Токены API",
|
||||
"Team": "Team",
|
||||
"Details": "Подробнее",
|
||||
"Team": "Команда",
|
||||
"Details": "Детали",
|
||||
"Security": "Безопасность",
|
||||
"People": "Люди",
|
||||
"Groups": "Группы",
|
||||
"Share Links": "Ссылки общего доступа",
|
||||
"Share Links": "Публичные ссылки",
|
||||
"Export Data": "Экспорт данных",
|
||||
"Integrations": "Интеграции",
|
||||
"Installation": "Установка",
|
||||
"Resize sidebar": "Resize sidebar",
|
||||
"Unstar": "Убрать из избранного",
|
||||
"Star": "Добавить в избранное",
|
||||
"Resize sidebar": "Изменить размер панели",
|
||||
"Unstar": "Снять пометку",
|
||||
"Star": "Пометить",
|
||||
"Appearance": "Оформление",
|
||||
"System": "Система",
|
||||
"System": "Как в системе",
|
||||
"Light": "Светлая",
|
||||
"Dark": "Темная",
|
||||
"API documentation": "Документация по API",
|
||||
"API documentation": "Документация API",
|
||||
"Changelog": "История изменений",
|
||||
"Send us feedback": "Отправьте нам отзыв",
|
||||
"Send us feedback": "Будем признательны за отзыв :)",
|
||||
"Report a bug": "Сообщить об ошибке",
|
||||
"Log out": "Выйти из системы",
|
||||
"Show path to document": "Show path to document",
|
||||
"Path to document": "Path to document",
|
||||
"Group member options": "Group member options",
|
||||
"Log out": "Выйти",
|
||||
"Show path to document": "Показать путь к документу",
|
||||
"Path to document": "Путь к документу",
|
||||
"Group member options": "Параметры участников группы",
|
||||
"Members": "Участники",
|
||||
"Remove": "Удалить",
|
||||
"Collection": "Collection",
|
||||
"Collection": "Подборка",
|
||||
"New document": "Новый документ",
|
||||
"Import document": "Импорт документа",
|
||||
"Edit": "Редактировать",
|
||||
"Permissions": "Права доступа",
|
||||
"Export": "Экспорт",
|
||||
"Delete": "Удалить",
|
||||
"Collection permissions": "Права доступа к коллекциям",
|
||||
"Edit collection": "Редактировать коллекцию",
|
||||
"Delete collection": "Удалить коллекцию",
|
||||
"Export collection": "Экспорт коллекции",
|
||||
"Show sort menu": "Show sort menu",
|
||||
"Sort in sidebar": "Сортировать на боковой панели",
|
||||
"Collection permissions": "Права доступа к подборкам",
|
||||
"Edit collection": "Изменить подборку",
|
||||
"Delete collection": "Удалить подборку",
|
||||
"Export collection": "Экспорт подборки",
|
||||
"Show sort menu": "Сортировка",
|
||||
"Sort in sidebar": "Сортировка в боковой панели",
|
||||
"Alphabetical sort": "Сортировка по алфавиту",
|
||||
"Manual sort": "Ручная сортировка",
|
||||
"Document duplicated": "Документ скопирован",
|
||||
"Document duplicated": "Документ дублирован",
|
||||
"Document archived": "Документ архивирован",
|
||||
"Document restored": "Документ восстановлен",
|
||||
"Document unpublished": "Документ снят с публикации",
|
||||
"Document options": "Document options",
|
||||
"Document options": "Параметры документа",
|
||||
"Restore": "Восстановить",
|
||||
"Choose a collection": "Выберите коллекцию",
|
||||
"Choose a collection": "Выберите подборку",
|
||||
"Unpin": "Открепить",
|
||||
"Pin to collection": "Прикрепить к коллекции",
|
||||
"Pin to collection": "Прикрепить к подборке",
|
||||
"Share link": "Поделиться ссылкой",
|
||||
"Enable embeds": "Включить встраивание",
|
||||
"Disable embeds": "Отключить встраивание",
|
||||
"New nested document": "Новый вложенный документ",
|
||||
"Create template": "Создать шаблон",
|
||||
"Duplicate": "Сделать копию",
|
||||
"Duplicate": "Создать копию",
|
||||
"Unpublish": "Снять с публикации",
|
||||
"Move": "Переместить",
|
||||
"History": "История",
|
||||
"Download": "Скачать",
|
||||
"Print": "Распечатать",
|
||||
"Print": "Печать",
|
||||
"Delete {{ documentName }}": "Удалить {{ documentName }}",
|
||||
"Share document": "Поделиться документом",
|
||||
"Edit group": "Редактировать группу",
|
||||
"Delete group": "Удалить группу",
|
||||
"Group options": "Group options",
|
||||
"Member options": "Member options",
|
||||
"collection": "коллекция",
|
||||
"Group options": "Параметры группы",
|
||||
"Member options": "Параметры участников",
|
||||
"collection": "подборка",
|
||||
"New child document": "Новый вложенный документ",
|
||||
"New document in <em>{{ collectionName }}</em>": "New document in <em>{{ collectionName }}</em>",
|
||||
"New document in <em>{{ collectionName }}</em>": "Новый документ в <em>{{ collectionName }}</em>",
|
||||
"New template": "Новый шаблон",
|
||||
"Link copied": "Ссылка скопирована",
|
||||
"Revision options": "Revision options",
|
||||
"Revision options": "Настройка версии",
|
||||
"Restore version": "Восстановить версию",
|
||||
"Copy link": "Скопировать ссылку",
|
||||
"Share link revoked": "Ссылка общего доступа отозвана",
|
||||
"Share link copied": "Ссылка общего доступа скопирована",
|
||||
"Share options": "Share options",
|
||||
"Share options": "Настройка доступа",
|
||||
"Go to document": "Перейти к документу",
|
||||
"Revoke link": "Отозвать ссылку",
|
||||
"By {{ author }}": "По {{ author }}",
|
||||
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.": "Вы уверены, что хотите сделать {{ userName }} администратором? Администраторы могут изменять информацию о команде и платежную информацию.",
|
||||
"Are you sure you want to make {{ userName }} a member?": "Вы уверены, что хотите сделать {{ userName }} участником?",
|
||||
"Are you sure you want to suspend this account? Suspended users will be prevented from logging in.": "Вы уверены, что хотите заблокировать эту учетную запись? Заблокированным пользователям будет запрещено входить в систему.",
|
||||
"User options": "User options",
|
||||
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.": "Удаляем админа {{ userName }}? Админы могут настраивать команду и платежную информацию.",
|
||||
"Are you sure you want to make {{ userName }} a member?": "Делаем {{ userName }} участником?",
|
||||
"Are you sure you want to suspend this account? Suspended users will be prevented from logging in.": "Блокируем этот аккаунт? После блокировки он не сможет зайти в систему.",
|
||||
"User options": "Настройки пользователя",
|
||||
"Make {{ userName }} a member…": "Сделать {{ userName }} участником…",
|
||||
"Make {{ userName }} an admin…": "Сделать {{ userName }} администратором…",
|
||||
"Make {{ userName }} an admin…": "Сделать {{ userName }} админом…",
|
||||
"Revoke invite": "Отозвать приглашение",
|
||||
"Activate account": "Активировать аккаунт",
|
||||
"Activate account": "Включить аккаунт",
|
||||
"Suspend account": "Заблокировать аккаунт",
|
||||
"Documents": "Документы",
|
||||
"The document archive is empty at the moment.": "Архив документов на данный момент пуст.",
|
||||
"Search in collection": "Поиск в коллекции",
|
||||
"<em>{{ collectionName }}</em> doesn’t contain any\n documents yet.": "<em>{{ collectionName }}</em> doesn’t contain any\n documents yet.",
|
||||
"Search in collection": "Поиск в подборке",
|
||||
"<em>{{ collectionName }}</em> doesn’t contain any\n documents yet.": "<em>{{ collectionName }}</em> пока не содержит\n документов.",
|
||||
"Get started by creating a new one!": "Начните с создания нового!",
|
||||
"Create a document": "Создать документ",
|
||||
"Manage members": "Управление участниками",
|
||||
@@ -203,26 +203,26 @@
|
||||
"Recently published": "Недавно опубликовано",
|
||||
"Least recently updated": "Наиболее давно обновлено",
|
||||
"A–Z": "А–Я",
|
||||
"The collection was updated": "Коллекция обновлена",
|
||||
"You can edit the name and other details at any time, however doing so often might confuse your team mates.": "Вы можете изменить имя и прочие подробности в любое время, однако зачастую это может сбить с толку членов вашей команды.",
|
||||
"The collection was updated": "Подборка обновлена",
|
||||
"You can edit the name and other details at any time, however doing so often might confuse your team mates.": "Изменяйте название и подробности в любое время. Главное, не сбейте с толку членов команды.",
|
||||
"Name": "Имя",
|
||||
"Description": "Описание",
|
||||
"More details about this collection…": "Подробнее об этой коллекции…",
|
||||
"More details about this collection…": "Подробнее об этой подборке…",
|
||||
"Alphabetical": "По алфавиту",
|
||||
"Private collection": "Частная коллекция",
|
||||
"A private collection will only be visible to invited team members.": "Частная коллекция будет видна только приглашенным членам команды.",
|
||||
"Private collection": "Закрытая подборка",
|
||||
"A private collection will only be visible to invited team members.": "Закрытая подборка будет видна только приглашенным членам команды.",
|
||||
"Saving": "Сохранение",
|
||||
"Save": "Сохранить",
|
||||
"{{ groupName }} was added to the collection": "{{ groupName }} добавлен в коллекцию",
|
||||
"{{ groupName }} was added to the collection": "{{ groupName }} добавлен в подборку",
|
||||
"Could not add user": "Не удалось добавить пользователя",
|
||||
"Can’t find the group you’re looking for?": "Не можете найти интересующую Вас группу?",
|
||||
"Can’t find the group you’re looking for?": "Не получается найти нужную группу?",
|
||||
"Create a group": "Создать группу",
|
||||
"Search by group name": "Поиск по названию группы",
|
||||
"Search groups": "Поиск групп",
|
||||
"No groups matching your search": "Нет групп, соответствующих вашему запросу",
|
||||
"No groups left to add": "Не осталось групп для добавления",
|
||||
"No groups matching your search": "Нет групп по вашему запросу",
|
||||
"No groups left to add": "Нет групп для добавления",
|
||||
"Add": "Добавить",
|
||||
"{{ userName }} was added to the collection": "{{ userName }} добавлен в коллекцию",
|
||||
"{{ userName }} was added to the collection": "{{ userName }} добавлен в подборку",
|
||||
"Need to add someone who’s not yet on the team yet?": "Нужно добавить кого-то, кого еще нет в команде?",
|
||||
"Invite people to {{ teamName }}": "Пригласить людей в {{ teamName }}",
|
||||
"Search by name": "Поиск по имени",
|
||||
@@ -231,11 +231,11 @@
|
||||
"No people left to add": "Не осталось людей для добавления",
|
||||
"Read only": "Только для чтения",
|
||||
"Read & Edit": "Читать и редактировать",
|
||||
"Active <1></1> ago": "Активен <1></1> тому назад",
|
||||
"Active <1></1> ago": "Был в сети <1></1> тому назад",
|
||||
"Never signed in": "Никогда не входил",
|
||||
"Invited": "Приглашенные",
|
||||
"Admin": "Администратор",
|
||||
"Collections are for grouping your knowledge base. They work best when organized around a topic or internal team — Product or Engineering for example.": "Коллекции предназначены для группирования вашей базы знаний. Они работают лучше всего, когда организованы вокруг определенной темы или внутренней команды — например, Продукта или Разработки.",
|
||||
"Admin": "Админ",
|
||||
"Collections are for grouping your knowledge base. They work best when organized around a topic or internal team — Product or Engineering for example.": "Подборка группирует документы в базы знаний: работают круто, если она формируется вокруг темы или команды — например, продукт или разработка.",
|
||||
"Creating": "Создание",
|
||||
"Create": "Создать",
|
||||
"Recently viewed": "Недавно просмотренные",
|
||||
@@ -244,7 +244,7 @@
|
||||
"Hide contents": "Скрыть содержимое",
|
||||
"Show contents": "Показать содержимое",
|
||||
"Archived": "Архивировано",
|
||||
"Anyone with the link <1></1>can view this document": "Все, у кого есть ссылка <1></1> могут просмотреть этот документ",
|
||||
"Anyone with the link <1></1>can view this document": "Все, у кого есть ссылка <1></1> смогут просмотреть этот документ",
|
||||
"Share": "Поделиться",
|
||||
"Save Draft": "Сохранить черновик",
|
||||
"Done Editing": "Правки внесены",
|
||||
@@ -252,30 +252,30 @@
|
||||
"New from template": "Создать из шаблона",
|
||||
"Publish": "Опубликовать",
|
||||
"Publishing": "Публикация",
|
||||
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Are you sure you want to delete the <em>{{ documentTitle }}</em> template?",
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.",
|
||||
"If you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "Если вы хотите ссылаться или вернуться к {{noun}} в будущем, лучше воспользуйтесь функцией архивирования.",
|
||||
"Deleting": "Идет удаление",
|
||||
"I’m sure – Delete": "Я уверен - Удалить",
|
||||
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Удаляем шаблон <em>{{ documentTitle }}</em>?",
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.": "Точно? При удалении <em>{{ documentTitle }}</em> удалится вся история и вложенные документы.",
|
||||
"If you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "Если понадобится вернуться к {{noun}} в дальнейшем, лучше архивируйте.",
|
||||
"Deleting": "Удаление",
|
||||
"I’m sure – Delete": "Я уверен – удалить",
|
||||
"Archiving": "Архивирование",
|
||||
"No documents found for your filters.": "По вашему запросу ничего не найдено.",
|
||||
"You’ve not got any drafts at the moment.": "На данный момент у вас нет черновиков.",
|
||||
"No documents found for your filters.": "По запросу ничего не найдено.",
|
||||
"You’ve not got any drafts at the moment.": "У вас пока нет черновиков.",
|
||||
"Not found": "Не найдено",
|
||||
"We were unable to find the page you’re looking for. Go to the <2>homepage</2>?": "Нам не удалось найти страницу, которую вы ищете. Перейти на <2>домашнюю страницу</2>?",
|
||||
"Offline": "Оффлайн",
|
||||
"We were unable to load the document while offline.": "Нам не удалось загрузить документ в режиме оффлайн.",
|
||||
"Your account has been suspended": "Ваш аккаунт был заблокирован",
|
||||
"A team admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.": "A team admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.",
|
||||
"We were unable to find the page you’re looking for. Go to the <2>homepage</2>?": "Мы не нашли страницу, которую вы ищете. Перейти на <2>домашнюю страницу</2>?",
|
||||
"Offline": "Не в сети",
|
||||
"We were unable to load the document while offline.": "Не удалось загрузить документ без сети.",
|
||||
"Your account has been suspended": "Ваш аккаунт отключен",
|
||||
"A team admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.": "Админ (<em>{{ suspendedContactEmail }}</em>) отключил ваш аккаунт. Свяжитесь с ним, чтобы получить доступ.",
|
||||
"{{userName}} was added to the group": "{{userName}} добавлен в группу",
|
||||
"Add team members below to give them access to the group. Need to add someone who’s not yet on the team yet?": "Добавьте ниже членов команды, чтобы предоставить им доступ к группе. Нужно добавить кого-то, кого еще нет в команде?",
|
||||
"Add team members below to give them access to the group. Need to add someone who’s not yet on the team yet?": "Добавьте участников команды, чтобы открыть им доступ к группе. Нужно добавить кого-то, кого еще нет в команде?",
|
||||
"Invite them to {{teamName}}": "Пригласить их в {{teamName}}",
|
||||
"{{userName}} was removed from the group": "{{userName}} удален из группы",
|
||||
"Could not remove user": "Не удалось удалить пользователя",
|
||||
"Add people": "Добавить людей",
|
||||
"This group has no members.": "В этой группе нет участников.",
|
||||
"Outline is designed to be fast and easy to use. All of your usual keyboard shortcuts work here, and there’s Markdown too.": "Outline разработан, чтобы быть быстрым и простым в использовании. Здесь работают все привычные сочетания клавиш, а также есть Markdown.",
|
||||
"Outline is designed to be fast and easy to use. All of your usual keyboard shortcuts work here, and there’s Markdown too.": "Outline быстрый и простой в использовании — здесь работают привычные сочетания клавиш и есть Markdown.",
|
||||
"Navigation": "Навигация",
|
||||
"New document in current collection": "Новый документ в текущей коллекции",
|
||||
"New document in current collection": "Новый документ в текущей подборке",
|
||||
"Edit current document": "Редактировать текущий документ",
|
||||
"Move current document": "Переместить текущий документ",
|
||||
"Jump to search": "Перейти к поиску",
|
||||
@@ -301,27 +301,27 @@
|
||||
"Inline code": "Встроенный код",
|
||||
"Not Found": "Не найдено",
|
||||
"We were unable to find the page you’re looking for.": "Нам не удалось найти нужную вам страницу.",
|
||||
"Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base": "Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base",
|
||||
"No documents found for your search filters. <1></1>Create a new document?": "Не найдено документов для ваших поисковых фильтров. <1></1>Создать новый документ?",
|
||||
"Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base": "Подсказка: <em>{{ meta }}+K</em> в любом месте найдет всё, что нужно в Outline",
|
||||
"No documents found for your search filters. <1></1>Create a new document?": "Нет документов во вашим фильтрам. <1></1>Создать новый документ?",
|
||||
"Clear filters": "Сбросить фильтры",
|
||||
"Profile saved": "Профиль сохранен",
|
||||
"Profile picture updated": "Фото профиля обновлено",
|
||||
"Unable to upload new profile picture": "Невозможно загрузить новое фото профиля",
|
||||
"Unable to upload new profile picture": "Упс. Ошибка при загрузке фото в профиль",
|
||||
"Photo": "Фото",
|
||||
"Upload": "Загрузить",
|
||||
"Full name": "Полное имя",
|
||||
"Language": "Язык",
|
||||
"Please note that translations are currently in early access.<1></1>Community contributions are accepted though our <4>translation portal</4>": "Обратите внимание, что в настоящее время переводы находятся в раннем доступе.<1></1>Вклады сообщества принимаются через наш <4>портал переводов</4>",
|
||||
"Please note that translations are currently in early access.<1></1>Community contributions are accepted though our <4>translation portal</4>": "Переводы всё ещё дорабатываются :) <1></1>Мы примем ваши правки через <4>портал переводов</4>. Спасибо",
|
||||
"Delete Account": "Удалить аккаунт",
|
||||
"You may delete your account at any time, note that this is unrecoverable": "Вы можете удалить свой аккаунт в любое время, обратите внимание, что это необратимо",
|
||||
"You may delete your account at any time, note that this is unrecoverable": "Удалить аккаунт можно в любое время — но вернуть данные не получится",
|
||||
"Delete account": "Удалить аккаунт",
|
||||
"You’ve not starred any documents yet.": "Вы еще не добавили ни одного документа в избранное.",
|
||||
"There are no templates just yet. You can create templates to help your team create consistent and accurate documentation.": "Шаблонов пока нет. Вы можете создавать шаблоны, которые помогут вашей команде создать последовательную и точную документацию.",
|
||||
"Trash is empty at the moment.": "Корзина пока пуста.",
|
||||
"You joined": "Вы присоединились",
|
||||
"Joined": "Присоединились",
|
||||
"{{ time }} ago.": "{{ time }} назад.",
|
||||
"Suspended": "Заблокированы",
|
||||
"Edit Profile": "Редактировать профиль",
|
||||
"{{ userName }} hasn’t updated any documents yet.": "{{ userName }} еще не обновил ни одного документа."
|
||||
"You’ve not starred any documents yet.": "Нет избранных документов.",
|
||||
"There are no templates just yet. You can create templates to help your team create consistent and accurate documentation.": "Шаблонов пока нет — создайте шаблоны, которые помогут команде правильно вести документацию и оставаться осведомленными.",
|
||||
"Trash is empty at the moment.": "Корзина пуста.",
|
||||
"You joined": "Вы вступили",
|
||||
"Joined": "Вступил",
|
||||
"{{ time }} ago.": "{{ time }} тому назад.",
|
||||
"Suspended": "Отключен",
|
||||
"Edit Profile": "Изменить профиль",
|
||||
"{{ userName }} hasn’t updated any documents yet.": "{{ userName }} пока не изменял документы."
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
"Never viewed": "未被浏览",
|
||||
"Viewed": "已浏览",
|
||||
"in": "在",
|
||||
"nested document": "nested document",
|
||||
"nested document_plural": "nested documents",
|
||||
"nested document": "子文档",
|
||||
"nested document_plural": "子文档",
|
||||
"Insert column after": "向后插入列",
|
||||
"Insert column before": "在左侧插入列",
|
||||
"Insert row after": "上方插入行",
|
||||
@@ -170,7 +170,7 @@
|
||||
"Member options": "成员选项",
|
||||
"collection": "文档集",
|
||||
"New child document": "新的嵌套文档",
|
||||
"New document in <em>{{ collectionName }}</em>": "New document in <em>{{ collectionName }}</em>",
|
||||
"New document in <em>{{ collectionName }}</em>": "在 <em>{{ collectionName }}</em> 中新建文档",
|
||||
"New template": "新建模板",
|
||||
"Link copied": "链接已复制",
|
||||
"Revision options": "修订选项",
|
||||
@@ -194,7 +194,7 @@
|
||||
"Documents": "文档",
|
||||
"The document archive is empty at the moment.": "尚未归档的文档",
|
||||
"Search in collection": "在文档集中搜索",
|
||||
"<em>{{ collectionName }}</em> doesn’t contain any\n documents yet.": "<em>{{ collectionName }}</em> doesn’t contain any\n documents yet.",
|
||||
"<em>{{ collectionName }}</em> doesn’t contain any\n documents yet.": "<em>{{ collectionName }}</em> 尚未包含任何文档",
|
||||
"Get started by creating a new one!": "从创建一个新文档集开始!",
|
||||
"Create a document": "创建文档",
|
||||
"Manage members": "管理成员",
|
||||
@@ -252,8 +252,8 @@
|
||||
"New from template": "从模板新建",
|
||||
"Publish": "发布",
|
||||
"Publishing": "发布中",
|
||||
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Are you sure you want to delete the <em>{{ documentTitle }}</em> template?",
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.",
|
||||
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "确认删除 <em>{{ documentTitle }}</em> 文档模板?",
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.": "删除文档 <em>{{ documentTitle }}</em> 也将会删除该文档所有历史版本和子文档,是否确认删除?",
|
||||
"If you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "如果您将来希望引用或还原{{noun}},请考虑将其存档。",
|
||||
"Deleting": "正在删除",
|
||||
"I’m sure – Delete": "确定– 删除",
|
||||
@@ -265,7 +265,7 @@
|
||||
"Offline": "离线",
|
||||
"We were unable to load the document while offline.": "离线状态无法加载文档。",
|
||||
"Your account has been suspended": "您的账户已被停用",
|
||||
"A team admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.": "A team admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.",
|
||||
"A team admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.": "团队管理员(<em>{{ suspendedContactEmail }}</em>)已暂停您的帐户。要重新激活您的帐户,请直接与他们联系。",
|
||||
"{{userName}} was added to the group": "{{userName}} 已添加到群组",
|
||||
"Add team members below to give them access to the group. Need to add someone who’s not yet on the team yet?": "在下方添加团队成员以便让他们访问群组。需要添加尚未加入团队的人吗?",
|
||||
"Invite them to {{teamName}}": "邀请他们加入 {{teamName}}",
|
||||
@@ -301,7 +301,7 @@
|
||||
"Inline code": "行内代码",
|
||||
"Not Found": "没找到",
|
||||
"We were unable to find the page you’re looking for.": "我们找不到您要查找的页面。",
|
||||
"Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base": "Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base",
|
||||
"Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base": "使用快捷键 <em>{{ meta }}+K</em> 在知识库中搜索",
|
||||
"No documents found for your search filters. <1></1>Create a new document?": "找不到相关文档。<1></1>创建一个新文档?",
|
||||
"Clear filters": "清除筛选",
|
||||
"Profile saved": "配置已保存",
|
||||
|
||||
@@ -37,21 +37,6 @@ export default createGlobalStyle`
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
@media (min-width: ${(props) =>
|
||||
props.theme.breakpoints.tablet}px) and (display-mode: standalone) {
|
||||
body:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: ${(props) => props.theme.divider};
|
||||
z-index: ${(props) => props.theme.depths.pwaSeparator};
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: ${(props) => props.theme.link};
|
||||
text-decoration: none;
|
||||
|
||||
@@ -114,7 +114,6 @@ export const base = {
|
||||
toasts: 5000,
|
||||
loadingIndicatorBar: 6000,
|
||||
popover: 9000,
|
||||
pwaSeparator: 10000,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ const webpack = require('webpack');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const { RelativeCiAgentWebpackPlugin } = require('@relative-ci/agent');
|
||||
const pkg = require("rich-markdown-editor/package.json");
|
||||
const WebpackPwaManifest = require("webpack-pwa-manifest");
|
||||
const WorkboxPlugin = require("workbox-webpack-plugin");
|
||||
|
||||
require('dotenv').config({ silent: true });
|
||||
|
||||
@@ -61,30 +59,6 @@ module.exports = {
|
||||
new HtmlWebpackPlugin({
|
||||
template: 'server/static/index.html',
|
||||
}),
|
||||
new WebpackPwaManifest({
|
||||
name: "Outline",
|
||||
short_name: "Outline",
|
||||
background_color: "#fff",
|
||||
theme_color: "#fff",
|
||||
start_url: process.env.URL,
|
||||
display: "standalone",
|
||||
icons: [
|
||||
{
|
||||
src: path.resolve("public/icon-512.png"),
|
||||
// For Chrome, you must provide at least a 192x192 pixel icon, and a 512x512 pixel icon.
|
||||
// If only those two icon sizes are provided, Chrome will automatically scale the icons
|
||||
// to fit the device. If you'd prefer to scale your own icons, and adjust them for
|
||||
// pixel-perfection, provide icons in increments of 48dp.
|
||||
sizes: [512, 192],
|
||||
purpose: "any maskable",
|
||||
},
|
||||
]
|
||||
}),
|
||||
new WorkboxPlugin.GenerateSW({
|
||||
clientsClaim: true,
|
||||
skipWaiting: true,
|
||||
maximumFileSizeToCacheInBytes: 15 * 1024 * 1024, // For large bundles
|
||||
}),
|
||||
new RelativeCiAgentWebpackPlugin(),
|
||||
],
|
||||
stats: {
|
||||
|
||||
Reference in New Issue
Block a user