Compare commits

..

3 Commits

Author SHA1 Message Date
Translate-O-Tron 481605d017 New Crowdin updates (#1876)
* fix: New Russian translations from Crowdin [ci skip]

* fix: New Russian translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]
2021-02-18 18:48:53 -08:00
Translate-O-Tron 985ba9be29 fix: New Chinese Simplified translations from Crowdin [ci skip] (#1869) 2021-02-07 10:32:28 -08:00
Translate-O-Tron 8412efcd0c New Crowdin updates (#1864)
* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]
2021-02-04 18:19:29 -08:00
763 changed files with 51584 additions and 29370 deletions
+1 -2
View File
@@ -2,12 +2,11 @@
"presets": [
"@babel/preset-react",
"@babel/preset-flow",
"@babel/preset-typescript",
[
"@babel/preset-env",
{
"corejs": {
"version": "3",
"version": "2",
"proposals": true
},
"useBuiltIns": "usage"
+37 -100
View File
@@ -1,63 +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 hex-encoded 32-byte random key. You should use `openssl rand -hex 32`
# in your terminal to generate a random value.
# 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. The format is not important but you could still use
# `openssl rand -hex 32` in your terminal to produce this.
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
# Uncomment this to disable SSL for connecting to Postgres
# PGSSLMODE=disable
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
# 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.
# Optional. If using a Cloudfront distribution or similar the origin server
# should be set to the same as URL.
CDN_URL=
# A more detailed guide on setting up S3 is available here:
# => https://wiki.generaloutline.com/share/125de1cc-9ff6-424b-8415-0d58c809a40f
#
AWS_ACCESS_KEY_ID=get_a_key_from_aws
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
AWS_REGION=xx-xxxx-x
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
AWS_S3_ACL=private
# 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
# –––––––––––––– AUTHENTICATION ––––––––––––––
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
# or Microsoft is required for a working installation or you'll have no sign-in
# options.
# 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
# Third party signin credentials (at least one is required)
SLACK_KEY=get_a_key_from_slack
SLACK_SECRET=get_the_secret_of_above_key
@@ -65,75 +33,44 @@ 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=
# To configure Microsoft/Azure auth, you'll need to create an OAuth Client. See
# the guide for details on setting up your Azure App:
# => https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4
AZURE_CLIENT_ID=
AZURE_CLIENT_SECRET=
AZURE_RESOURCE_APP_ID=
# Comma separated list of domains to be allowed (optional)
# If not set, all Google apps domains are allowed by default
GOOGLE_ALLOWED_DOMAINS=
# –––––––––––––––– 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
# Override the maxium size of document imports, could be required if you have
# especially large Word documents with embedded imagery
MAXIMUM_IMPORT_SIZE=5120000
# 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,http,server,services
# Comma separated list of domains to be allowed to signin to the wiki. If not
# set, all domains are allowed by default when using Google OAuth to signin
ALLOWED_DOMAINS=
# For a complete Slack integration with search and posting to channels the
# following configs are also needed, some more details
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
#
SLACK_VERIFICATION_TOKEN=your_token
# 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 sending outgoing transactional emails such as "document updated" or
# "you've been invited" you'll need to provide authentication for an SMTP server
# 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
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
AWS_S3_UPLOAD_MAX_SIZE=26214400
AWS_S3_FORCE_PATH_STYLE=true
# uploaded s3 objects permission level, default is private
# set to "public-read" to allow public access
AWS_S3_ACL=private
# Emails configuration (optional)
SMTP_HOST=
SMTP_PORT=
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_REPLY_EMAIL=
SMTP_TLS_CIPHERS=
SMTP_SECURE=true
# Custom logo that displays on the authentication screen, scaled to height: 60px
# TEAM_LOGO=https://example.com/images/logo.png
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
# See translate.getoutline.com for a list of available language codes and their
# percentage translated.
DEFAULT_LANGUAGE=en_US
+21 -11
View File
@@ -4,11 +4,12 @@
"react-app",
"plugin:import/errors",
"plugin:import/warnings",
"plugin:import/typescript",
"plugin:flowtype/recommended",
"plugin:react-hooks/recommended"
],
"plugins": [
"prettier"
"prettier",
"flowtype"
],
"rules": {
"eqeqeq": 2,
@@ -54,6 +55,21 @@
]
}
],
"flowtype/require-valid-file-annotation": [
2,
"always",
{
"annotationStyle": "line"
}
],
"flowtype/space-after-type-colon": [
2,
"always"
],
"flowtype/space-before-type-colon": [
2,
"never"
],
"prettier/prettier": [
"error",
{
@@ -68,22 +84,16 @@
"pragma": "React",
"version": "detect"
},
"import/parsers": {
"@typescript-eslint/parser": [
".ts",
".tsx"
]
},
"import/resolver": {
"typescript": {
"alwaysTryTypes": true
},
"node": {
"paths": [
"app",
"."
]
}
},
"flowtype": {
"onlyFilesWithFlowAnnotation": false
}
},
"env": {
-3
View File
@@ -1,3 +0,0 @@
# These are supported funding model platforms
github: [outline]
+2 -3
View File
@@ -1,13 +1,12 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 90
daysUntilStale: 60
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 14
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- security
- pinned
# Label to use when marking an issue as stale
staleLabel: stale
+1
View File
@@ -1,6 +1,7 @@
dist
build
node_modules/*
server/scripts
.env
.log
npm-debug.log
+2 -2
View File
@@ -1,7 +1,7 @@
{
"javascript.validate.enable": false,
"javascript.format.enable": false,
"typescript.validate.enable": true,
"typescript.format.enable": true,
"typescript.validate.enable": false,
"typescript.format.enable": false,
"editor.formatOnSave": true,
}
-67
View File
@@ -1,67 +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 - Authentication logic
│ └── providers - Authentication providers export passport.js strategies and config
├── commands - We are gradually moving to the command pattern for new write logic
├── config - Database configuration
├── emails - Transactional email templates
│ └── components - Shared React components for email templates
├── middlewares - Koa middlewares
├── migrations - Database migrations
├── models - Sequelize models
├── onboarding - Markdown templates for onboarding documents
├── policies - Authorization logic based on cancan
├── presenters - JSON presenters for database models, the interface between backend -> frontend
├── services - Service definitions are triggered for events and perform async jobs
├── static - Static assets
├── test - Test helpers and fixtures, tests themselves are colocated
└── utils - Utility methods specific to the backend
```
## Shared
Where logic is shared between the client and server it is placed in this directory. This is generally
small utilities.
```
shared
├── i18n - Internationalization confiuration
│ └── locales - Language specific translation files
├── styles - Styles, colors and other global aesthetics
├── utils - Shared utility methods
└── constants - Shared constants
```
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.55.0
Licensed Work: Outline 0.51.0
The Licensed Work is (c) 2020 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2024-04-22
Change Date: 2023-12-13
Change License: Apache License, Version 2.0
+94 -80
View File
@@ -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 width="1640" alt="screenshot" src="https://user-images.githubusercontent.com/380914/110356468-26374600-7fef-11eb-9f6a-f2cc2c8c6590.png">
<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&amp;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,63 +31,38 @@ 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 db:migrate`. Production assumes an SSL connection to the database by default, if
Postgres is on the same machine and is not SSL you can migrate with `yarn db:migrate --env=production-ssl-disabled`, for example:
`docker run --rm outlinewiki/outline yarn db:migrate`
1. Start the container:
`docker run outlinewiki/outline`
1. 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 db: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)
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
1. [Yarn](https://yarnpkg.com)
1. [Docker for Desktop](https://www.docker.com)
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
1. [Yarn](https://yarnpkg.com)
1. Clone this repo
1. Register a Slack app at https://api.slack.com/apps
1. Copy the file `.env.sample` to `.env`
@@ -96,41 +71,81 @@ For contributing features and fixes you can quickly get an environment running u
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
1. Configure your Slack app's Oauth & Permissions settings
1. Slack recently prevented the use of `http` protocol for localhost. For local development, you can use a tool like [ngrok](https://ngrok.com) or a package like `mkcert`. ([How to use HTTPS for local development](https://web.dev/how-to-use-local-https/))
1. Add `https://my_ngrok_address/auth/slack.callback` as an Oauth redirect URL
1. Add `http://localhost:3000/auth/slack.callback` as an Oauth redirect URL
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 youre 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:
```
DEBUG=sql,cache,presenters,events,importer,exporter,emails,mailer
DEBUG=sql,cache,presenters,events,logistics,emails,mailer
```
## Migrations
Sequelize is used to create and run migrations, for example:
```
yarn sequelize migration:generate --name my-migration
yarn sequelize db:migrate
```
Or to run migrations on test database:
```
yarn sequelize db:migrate --env test
```
## Structure
Outline is composed of separate backend and frontend application which are both driven by the same Node process. As both are written in Javascript, they share some code but are mostly separate. We utilize the latest language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier and ESLint are enforced by CI.
### Frontend
Outline's frontend is a React application compiled with [Webpack](https://webpack.js.org/). It uses [Mobx](https://mobx.js.org/) for state management and [Styled Components](https://www.styled-components.com/) for component styles. Unless global, state logic and styles are always co-located with React components together with their subcomponents to make the component tree easier to manage.
The editor itself is built on [Prosemirror](https://github.com/prosemirror) and hosted in a separate repository to encourage reuse: [rich-markdown-editor](https://github.com/outline/rich-markdown-editor)
- `app/` - Frontend React application
- `app/scenes` - Full page views
- `app/components` - Reusable React components
- `app/stores` - Global state stores
- `app/models` - State models
- `app/types` - Flow types for non-models
### Backend
Backend is driven by [Koa](http://koajs.com/) (API, web server), [Sequelize](http://docs.sequelizejs.com/) (database) and React for public pages and emails.
- `server/api` - API endpoints
- `server/commands` - Domain logic, currently being refactored from /models
- `server/emails` - React rendered email templates
- `server/models` - Database models
- `server/policies` - Authorization logic
- `server/presenters` - API responses for database models
- `server/test` - Test helps and support
- `server/utils` - Utility methods
- `shared` - Code shared between frontend and backend applications
## Tests
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.
@@ -156,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 youre 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 -14
View File
@@ -30,10 +30,6 @@
"postdeploy": "yarn sequelize db:migrate"
},
"env": {
"NODE_ENV": {
"value": "production",
"required": true
},
"SECRET_KEY": {
"description": "A secret key",
"generator": "secret",
@@ -55,7 +51,7 @@
"description": "",
"required": false
},
"ALLOWED_DOMAINS": {
"GOOGLE_ALLOWED_DOMAINS": {
"description": "Comma separated list of domains to be allowed (optional). If not set, all Google apps domains are allowed by default",
"required": false
},
@@ -135,15 +131,6 @@
"description": "wikireply@example.com (optional)",
"required": false
},
"SMTP_SECURE": {
"value": "true",
"description": "Use a secure SMTP connection (optional)",
"required": false
},
"SMTP_TLS_CIPHERS": {
"description": "Override SMTP cipher configuration (optional)",
"required": false
},
"GOOGLE_ANALYTICS_ID": {
"description": "UA-xxxx (optional)",
"required": false
-30
View File
@@ -1,30 +0,0 @@
{
"testURL": "http://localhost",
"verbose": false,
"rootDir": "..",
"roots": [
"<rootDir>/app",
"<rootDir>/shared"
],
"moduleNameMapper": {
"^shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"moduleFileExtensions": [
"js",
"jsx",
"json"
],
"moduleDirectories": [
"node_modules"
],
"modulePaths": [
"<rootDir>/app"
],
"setupFiles": [
"<rootDir>/__mocks__/window.js"
],
"setupFilesAfterEnv": [
"./app/test/setup.js"
]
}
@@ -1,3 +1,4 @@
// @flow
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Flex from "components/Flex";
@@ -32,7 +33,7 @@ const Actions = styled(Flex)`
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
padding: 12px;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
@media print {
display: none;
@@ -1,16 +1,15 @@
// @flow
/* global ga */
import * as React from "react";
import env from "env";
type Props = {
children?: React.ReactNode;
children?: React.Node,
};
export default class Analytics extends React.Component<Props> {
componentDidMount() {
if (!env.GOOGLE_ANALYTICS_ID) {
return null;
}
if (!env.GOOGLE_ANALYTICS_ID) return;
// standard Google Analytics script
window.ga =
@@ -30,11 +29,6 @@ export default class Analytics extends React.Component<Props> {
script.src = "https://www.google-analytics.com/analytics.js";
script.async = true;
// Track PWA install event
window.addEventListener("appinstalled", () => {
ga("send", "event", "pwa", "install");
});
if (document.body) {
document.body.appendChild(script);
}
-22
View File
@@ -1,22 +0,0 @@
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>
);
}
-43
View File
@@ -1,43 +0,0 @@
import * as React from "react";
type Props = {
size?: number;
fill?: string;
className?: string;
};
function MicrosoftLogo({ size = 34, fill = "#FFF", className }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 34 34"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M18.0002 1H33.9998C33.9998 5.8172 34.0007 10.6344 33.9988 15.4516C28.6666 15.4508 23.3334 15.4516 18.0012 15.4516C17.9993 10.6344 18.0002 5.8172 18.0002 1Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M18.0009 17.5173C23.3333 17.5155 28.6667 17.5164 34 17.5164V33H18C18.0009 27.8388 17.9991 22.6776 18.0009 17.5173V17.5173Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 1H16L15.9988 15.4516H0V1Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 17.5161C5.3332 17.5179 10.6664 17.5155 15.9996 17.5179C16.0005 22.6789 15.9996 27.839 15.9996 33H0V17.5161Z"
/>
</svg>
);
}
export default MicrosoftLogo;
-45
View File
@@ -1,45 +0,0 @@
import * as React from "react";
import styled from "styled-components";
import GoogleLogo from "./GoogleLogo";
import MicrosoftLogo from "./MicrosoftLogo";
import SlackLogo from "./SlackLogo";
type Props = {
providerName: string;
size?: number;
};
function AuthLogo({ providerName, size = 16 }: Props) {
switch (providerName) {
case "slack":
return (
<Logo>
<SlackLogo size={size} />
</Logo>
);
case "google":
return (
<Logo>
<GoogleLogo size={size} />
</Logo>
);
case "azure":
return (
<Logo>
<MicrosoftLogo size={size} />
</Logo>
);
default:
return null;
}
}
const Logo = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
`;
export default AuthLogo;
@@ -1,14 +1,15 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom";
import { isCustomSubdomain } from "shared/utils/domains";
import LoadingIndicator from "components/LoadingIndicator";
import useStores from "../hooks/useStores";
import env from "env";
import useStores from "hooks/useStores";
type Props = {
children: JSX.Element;
children: React.Node,
};
const Authenticated = ({ children }: Props) => {
@@ -1,3 +1,4 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
@@ -5,19 +6,18 @@ import styled from "styled-components";
import User from "models/User";
import placeholder from "./placeholder.png";
type Props = {
src: string;
size: number;
icon?: React.ReactNode;
user?: User;
onClick?: () => void;
className?: string;
};
type Props = {|
src: string,
size: number,
icon?: React.Node,
user?: User,
onClick?: () => void,
className?: string,
|};
@observer
class Avatar extends React.Component<Props> {
@observable
error: boolean;
@observable error: boolean;
static defaultProps = {
size: 24,
@@ -1,9 +1,10 @@
// @flow
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { EditIcon } from "outline-icons";
import * as React from "react";
import { withTranslation } from "react-i18next";
import { TFunction } from "react-i18next";
import { withTranslation, type TFunction } from "react-i18next";
import styled from "styled-components";
import User from "models/User";
import UserProfile from "scenes/UserProfile";
@@ -11,18 +12,17 @@ import Avatar from "components/Avatar";
import Tooltip from "components/Tooltip";
type Props = {
user: User;
isPresent: boolean;
isEditing: boolean;
isCurrentUser: boolean;
profileOnClick: boolean;
t: TFunction;
user: User,
isPresent: boolean,
isEditing: boolean,
isCurrentUser: boolean,
lastViewedAt: string,
t: TFunction,
};
@observer
class AvatarWithPresence extends React.Component<Props> {
@observable
isOpen: boolean = false;
@observable isOpen: boolean = false;
handleOpenProfile = () => {
this.isOpen = true;
@@ -33,13 +33,22 @@ class AvatarWithPresence extends React.Component<Props> {
};
render() {
const { user, isPresent, isEditing, isCurrentUser, t } = this.props;
const {
user,
lastViewedAt,
isPresent,
isEditing,
isCurrentUser,
t,
} = this.props;
const action = isPresent
? isEditing
? t("currently editing")
: t("currently viewing")
: t("previously edited");
: t("viewed {{ timeAgo }} ago", {
timeAgo: distanceInWordsToNow(new Date(lastViewedAt)),
});
return (
<>
@@ -47,12 +56,8 @@ class AvatarWithPresence extends React.Component<Props> {
tooltip={
<Centered>
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
{action && (
<>
<br />
{action}
</>
)}
<br />
{action}
</Centered>
}
placement="bottom"
@@ -60,11 +65,7 @@ class AvatarWithPresence extends React.Component<Props> {
<AvatarWrapper isPresent={isPresent}>
<Avatar
src={user.avatarUrl}
onClick={
this.props.profileOnClick === false
? undefined
: this.handleOpenProfile
}
onClick={this.handleOpenProfile}
size={32}
icon={isEditing ? <EditIcon size={16} color="#FFF" /> : undefined}
/>
@@ -1,3 +1,4 @@
// @flow
import Avatar from "./Avatar";
import AvatarWithPresence from "./AvatarWithPresence";
+18
View File
@@ -0,0 +1,18 @@
// @flow
import styled from "styled-components";
const Badge = styled.span`
margin-left: 10px;
padding: 2px 6px 3px;
background-color: ${({ yellow, primary, theme }) =>
yellow ? theme.yellow : primary ? theme.primary : theme.textTertiary};
color: ${({ primary, yellow, theme }) =>
primary ? theme.white : yellow ? theme.almostBlack : theme.background};
border-radius: 4px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
user-select: none;
`;
export default Badge;
-19
View File
@@ -1,19 +0,0 @@
import styled from "styled-components";
const Badge = styled.span`
margin-left: 10px;
padding: 1px 5px 2px;
background-color: ${({ yellow, primary, theme }) =>
yellow ? theme.yellow : primary ? theme.primary : "transparent"};
color: ${({ primary, yellow, theme }) =>
primary ? theme.white : yellow ? theme.almostBlack : theme.textTertiary};
border: 1px solid
${({ primary, yellow, theme }) =>
primary || yellow ? "transparent" : theme.textTertiary};
border-radius: 8px;
font-size: 12px;
font-weight: 500;
user-select: none;
`;
export default Badge;
@@ -1,10 +1,11 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import OutlineLogo from "./OutlineLogo";
import env from "env";
type Props = {
href?: string;
href?: string,
};
function Branding({ href = env.URL }: Props) {
+204
View File
@@ -0,0 +1,204 @@
// @flow
import { observer } from "mobx-react";
import {
ArchiveIcon,
EditIcon,
GoToIcon,
PadlockIcon,
ShapesIcon,
TrashIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Document from "models/Document";
import CollectionIcon from "components/CollectionIcon";
import Flex from "components/Flex";
import useStores from "hooks/useStores";
import BreadcrumbMenu from "menus/BreadcrumbMenu";
import { collectionUrl } from "utils/routeHelpers";
type Props = {
document: Document,
onlyText: boolean,
};
function Icon({ document }) {
const { t } = useTranslation();
if (document.isDeleted) {
return (
<>
<CategoryName to="/trash">
<TrashIcon color="currentColor" />
&nbsp;
<span>{t("Trash")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isArchived) {
return (
<>
<CategoryName to="/archive">
<ArchiveIcon color="currentColor" />
&nbsp;
<span>{t("Archive")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isDraft) {
return (
<>
<CategoryName to="/drafts">
<EditIcon color="currentColor" />
&nbsp;
<span>{t("Drafts")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isTemplate) {
return (
<>
<CategoryName to="/templates">
<ShapesIcon color="currentColor" />
&nbsp;
<span>{t("Templates")}</span>
</CategoryName>
<Slash />
</>
);
}
return null;
}
const Breadcrumb = ({ document, onlyText }: Props) => {
const { collections } = useStores();
const { t } = useTranslation();
let collection = collections.get(document.collectionId);
if (!collection) {
collection = {
id: document.collectionId,
name: t("Deleted Collection"),
color: "currentColor",
};
}
const path = collection.pathToDocument
? collection.pathToDocument(document.id).slice(0, -1)
: [];
if (onlyText === true) {
return (
<>
{collection.private && (
<>
<SmallPadlockIcon color="currentColor" size={16} />{" "}
</>
)}
{collection.name}
{path.map((n) => (
<React.Fragment key={n.id}>
<SmallSlash />
{n.title}
</React.Fragment>
))}
</>
);
}
const isNestedDocument = path.length > 1;
const lastPath = path.length ? path[path.length - 1] : undefined;
const menuPath = isNestedDocument ? path.slice(0, -1) : [];
return (
<Wrapper justify="flex-start" align="center">
<Icon document={document} />
<CollectionName to={collectionUrl(collection.id)}>
<CollectionIcon collection={collection} expanded />
&nbsp;
<span>{collection.name}</span>
</CollectionName>
{isNestedDocument && (
<>
<Slash /> <BreadcrumbMenu path={menuPath} />
</>
)}
{lastPath && (
<>
<Slash />{" "}
<Crumb to={lastPath.url} title={lastPath.title}>
{lastPath.title}
</Crumb>
</>
)}
</Wrapper>
);
};
export const Slash = styled(GoToIcon)`
flex-shrink: 0;
fill: ${(props) => props.theme.divider};
`;
const Wrapper = styled(Flex)`
display: none;
${breakpoint("tablet")`
display: flex;
`};
`;
const SmallPadlockIcon = styled(PadlockIcon)`
display: inline-block;
vertical-align: sub;
`;
const SmallSlash = styled(GoToIcon)`
width: 15px;
height: 10px;
flex-shrink: 0;
opacity: 0.25;
`;
const Crumb = styled(Link)`
color: ${(props) => props.theme.text};
font-size: 15px;
height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
&:hover {
text-decoration: underline;
}
`;
const CollectionName = styled(Link)`
display: flex;
flex-shrink: 1;
color: ${(props) => props.theme.text};
font-size: 15px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
min-width: 0;
svg {
flex-shrink: 0;
}
`;
const CategoryName = styled(CollectionName)`
flex-shrink: 0;
`;
export default observer(Breadcrumb);
-86
View File
@@ -1,86 +0,0 @@
import { GoToIcon } from "outline-icons";
import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Flex from "components/Flex";
import BreadcrumbMenu from "menus/BreadcrumbMenu";
type MenuItem = {
icon?: React.ReactNode;
title: React.ReactNode;
to?: string;
};
type Props = {
items: MenuItem[];
max?: number;
children?: React.ReactNode;
highlightFirstItem?: boolean;
};
function Breadcrumb({ items, highlightFirstItem, children, max = 2 }: Props) {
const totalItems = items.length;
let topLevelItems: MenuItem[] = [...items];
let overflowItems;
// chop middle breadcrumbs and present a "..." menu instead
if (totalItems > max) {
const halfMax = Math.floor(max / 2);
overflowItems = topLevelItems.splice(halfMax, totalItems - max);
topLevelItems.splice(halfMax, 0, {
title: <BreadcrumbMenu items={overflowItems} />,
});
}
return (
<Flex justify="flex-start" align="center">
{topLevelItems.map((item, index) => (
<React.Fragment key={item.to || index}>
{item.icon}
{item.to ? (
<Item
to={item.to}
$withIcon={!!item.icon}
$highlight={highlightFirstItem && index === 0}
>
{item.title}
</Item>
) : (
item.title
)}
{index !== topLevelItems.length - 1 || !!children ? <Slash /> : null}
</React.Fragment>
))}
{children}
</Flex>
);
}
const Slash = styled(GoToIcon)`
flex-shrink: 0;
fill: ${(props) => props.theme.divider};
`;
const Item = styled(Link)`
display: flex;
flex-shrink: 1;
min-width: 0;
color: ${(props) => props.theme.text};
font-size: 15px;
height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
svg {
flex-shrink: 0;
}
&:hover {
text-decoration: underline;
}
`;
export default Breadcrumb;
@@ -1,7 +1,7 @@
// @flow
import { ExpandedIcon } from "outline-icons";
import { darken } from "polished";
import * as React from "react";
import { SyntheticEvent } from "react";
import styled from "styled-components";
const RealButton = styled.button`
@@ -108,57 +108,56 @@ export const Inner = styled.span`
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
`;
export type Props = {
type?: "button" | "submit";
value?: string;
icon?: React.ReactNode;
iconColor?: string;
className?: string;
children?: React.ReactNode;
innerRef?: React.RefObject<any>;
disclosure?: boolean;
neutral?: boolean;
danger?: boolean;
primary?: boolean;
disabled?: boolean;
fullwidth?: boolean;
autoFocus?: boolean;
style?: any;
as?: React.ComponentType<any>;
to?: string;
onClick?: (event: SyntheticEvent) => unknown;
borderOnHover?: boolean;
"data-on"?: string;
"data-event-category"?: string;
"data-event-action"?: string;
};
export type Props = {|
type?: "button" | "submit",
value?: string,
icon?: React.Node,
iconColor?: string,
className?: string,
children?: React.Node,
innerRef?: React.ElementRef<any>,
disclosure?: boolean,
neutral?: boolean,
danger?: boolean,
primary?: boolean,
disabled?: boolean,
fullwidth?: boolean,
autoFocus?: boolean,
style?: Object,
as?: React.ComponentType<any>,
to?: string,
onClick?: (event: SyntheticEvent<>) => mixed,
borderOnHover?: boolean,
const Button = React.forwardRef<HTMLButtonElement>(
(
{
type = "button",
icon,
children,
value,
disclosure,
neutral,
...rest
}: Props,
innerRef
) => {
const hasText = children !== undefined || value !== undefined;
const hasIcon = icon !== undefined;
"data-on"?: string,
"data-event-category"?: string,
"data-event-action"?: string,
|};
return (
<RealButton type={type} ref={innerRef} $neutral={neutral} {...rest}>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
{hasIcon && icon}
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
{disclosure && <ExpandedIcon />}
</Inner>
</RealButton>
);
}
);
function Button({
type = "text",
icon,
children,
value,
disclosure,
innerRef,
neutral,
...rest
}: Props) {
const hasText = children !== undefined || value !== undefined;
const hasIcon = icon !== undefined;
export default Button;
return (
<RealButton type={type} ref={innerRef} $neutral={neutral} {...rest}>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
{hasIcon && icon}
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
{disclosure && <ExpandedIcon />}
</Inner>
</RealButton>
);
}
export default React.forwardRef<Props, typeof Button>((props, ref) => (
<Button {...props} innerRef={ref} />
));
@@ -1,3 +1,4 @@
// @flow
import styled from "styled-components";
import Button, { Inner } from "./Button";
@@ -1,13 +1,17 @@
// @flow
import * as React from "react";
import { SyntheticEvent } from "react";
import styled from "styled-components";
type Props = {
onClick: (ev: SyntheticEvent) => void;
children: React.ReactNode;
onClick: (ev: SyntheticEvent<>) => void,
children: React.Node,
};
const ButtonLink = styled.button<Props>`
export default function ButtonLink(props: Props) {
return <Button {...props} />;
}
const Button = styled.button`
margin: 0;
padding: 0;
border: 0;
@@ -17,5 +21,3 @@ const ButtonLink = styled.button<Props>`
text-decoration: none;
cursor: pointer;
`;
export default ButtonLink;
@@ -1,29 +1,25 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
type Props = {
children?: React.ReactNode;
withStickyHeader?: boolean;
children?: React.Node,
};
const Container = styled.div<Props>`
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;
`};
`;
const Content = styled.div`
max-width: 46em;
margin: 0 auto;
${breakpoint("desktopLarge")`
max-width: 52em;
`};
`;
const CenteredContent = ({ children, ...rest }: Props) => {
@@ -1,36 +1,31 @@
// @flow
import * as React from "react";
import { ChangeEvent } from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import HelpText from "components/HelpText";
export type Props = {
checked?: boolean;
label?: React.ReactNode;
labelHidden?: boolean;
className?: string;
name?: string;
disabled?: boolean;
onChange: (event: ChangeEvent<HTMLInputElement>) => unknown;
note?: string;
short?: boolean;
small?: boolean;
};
export type Props = {|
checked?: boolean,
label?: string,
labelHidden?: boolean,
className?: string,
name?: string,
disabled?: boolean,
onChange: (event: SyntheticInputEvent<HTMLInputElement>) => mixed,
note?: string,
short?: boolean,
small?: boolean,
|};
type LabelTextProps = { small: boolean };
const LabelText = styled.span<LabelTextProps>`
const LabelText = styled.span`
font-weight: 500;
margin-left: ${(props) => (props.small ? "6px" : "10px")};
${(props) => (props.small ? `color: ${props.theme.textSecondary}` : "")};
`;
type WrapperProps = { small: boolean };
const Wrapper = styled.div<WrapperProps>`
const Wrapper = styled.div`
padding-bottom: 8px;
${(props) => (props.small ? "font-size: 14px" : "")};
width: 100%;
`;
const Label = styled.label`
@@ -1,10 +1,7 @@
// @flow
import styled from "styled-components";
type Props = {
grow: boolean;
};
const ClickablePadding = styled.div<Props>`
const ClickablePadding = styled.div`
min-height: 10em;
cursor: ${({ onClick }) => (onClick ? "text" : "default")};
${({ grow }) => grow && `flex-grow: 100;`};
+78
View File
@@ -0,0 +1,78 @@
// @flow
import { sortBy, keyBy } from "lodash";
import { observer, inject } from "mobx-react";
import * as React from "react";
import { MAX_AVATAR_DISPLAY } from "shared/constants";
import DocumentPresenceStore from "stores/DocumentPresenceStore";
import ViewsStore from "stores/ViewsStore";
import Document from "models/Document";
import { AvatarWithPresence } from "components/Avatar";
import Facepile from "components/Facepile";
type Props = {
views: ViewsStore,
presence: DocumentPresenceStore,
document: Document,
currentUserId: string,
};
@observer
class Collaborators extends React.Component<Props> {
componentDidMount() {
if (!this.props.document.isDeleted) {
this.props.views.fetchPage({ documentId: this.props.document.id });
}
}
render() {
const { document, presence, views, currentUserId } = this.props;
let documentPresence = presence.get(document.id);
documentPresence = documentPresence
? Array.from(documentPresence.values())
: [];
const documentViews = views.inDocument(document.id);
const presentIds = documentPresence.map((p) => p.userId);
const editingIds = documentPresence
.filter((p) => p.isEditing)
.map((p) => p.userId);
// ensure currently present via websocket are always ordered first
const mostRecentViewers = sortBy(
documentViews.slice(0, MAX_AVATAR_DISPLAY),
(view) => {
return presentIds.includes(view.user.id);
}
);
const viewersKeyedByUserId = keyBy(mostRecentViewers, (v) => v.user.id);
const overflow = documentViews.length - mostRecentViewers.length;
return (
<Facepile
users={mostRecentViewers.map((v) => v.user)}
overflow={overflow}
renderAvatar={(user) => {
const isPresent = presentIds.includes(user.id);
const isEditing = editingIds.includes(user.id);
const { lastViewedAt } = viewersKeyedByUserId[user.id];
return (
<AvatarWithPresence
key={user.id}
user={user}
lastViewedAt={lastViewedAt}
isPresent={isPresent}
isEditing={isEditing}
isCurrentUser={currentUserId === user.id}
/>
);
}}
/>
);
}
}
export default inject("views", "presence")(Collaborators);
-108
View File
@@ -1,108 +0,0 @@
import { sortBy, filter, uniq } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Document from "models/Document";
import { AvatarWithPresence } from "components/Avatar";
import DocumentViews from "components/DocumentViews";
import Facepile from "components/Facepile";
import NudeButton from "components/NudeButton";
import Popover from "components/Popover";
import useStores from "hooks/useStores";
type Props = {
document: Document;
currentUserId: string;
};
function Collaborators(props: Props) {
const { t } = useTranslation();
const { users, presence } = useStores();
const { document, currentUserId } = props;
let documentPresence = presence.get(document.id);
documentPresence = documentPresence
? Array.from(documentPresence.values())
: [];
const presentIds = documentPresence.map((p) => p.userId);
const editingIds = documentPresence
.filter((p) => p.isEditing)
.map((p) => p.userId);
// ensure currently present via websocket are always ordered first
const collaborators = React.useMemo(
() =>
sortBy(
filter(
users.orderedData,
(user) =>
presentIds.includes(user.id) ||
document.collaboratorIds.includes(user.id)
),
(user) => presentIds.includes(user.id)
),
[document.collaboratorIds, users.orderedData, presentIds]
);
// load any users we don't know about
React.useEffect(() => {
if (users.isFetching) {
return;
}
uniq([...document.collaboratorIds, ...presentIds]).forEach((userId) => {
if (!users.get(userId)) {
return users.fetch(userId);
}
});
}, [document, users, presentIds, document.collaboratorIds]);
const popover = usePopoverState({
gutter: 0,
placement: "bottom-end",
});
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<NudeButton width={collaborators.length * 32} height={32} {...props}>
<FacepileHiddenOnMobile
users={collaborators}
renderAvatar={(user) => {
const isPresent = presentIds.includes(user.id);
const isEditing = editingIds.includes(user.id);
return (
<AvatarWithPresence
key={user.id}
user={user}
isPresent={isPresent}
isEditing={isEditing}
isCurrentUser={currentUserId === user.id}
profileOnClick={false}
/>
);
}}
/>
</NudeButton>
)}
</PopoverDisclosure>
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
<DocumentViews document={document} isOpen={popover.visible} />
</Popover>
</>
);
}
const FacepileHiddenOnMobile = styled(Facepile)`
${breakpoint("mobile", "tablet")`
display: none;
`};
`;
export default observer(Collaborators);
-210
View File
@@ -1,210 +0,0 @@
import { observer } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Collection from "models/Collection";
import Arrow from "components/Arrow";
import ButtonLink from "components/ButtonLink";
import Editor from "components/Editor";
import LoadingIndicator from "components/LoadingIndicator";
import NudeButton from "components/NudeButton";
import useDebouncedCallback from "hooks/useDebouncedCallback";
import useStores from "hooks/useStores";
type Props = {
collection: Collection;
};
function CollectionDescription({ collection }: Props) {
const { collections, ui, policies } = useStores();
const { t } = useTranslation();
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
const [isDirty, setDirty] = React.useState(false);
const can = policies.abilities(collection.id);
const handleStartEditing = React.useCallback(() => {
setEditing(true);
}, []);
const handleStopEditing = React.useCallback(() => {
setEditing(false);
}, []);
const handleClickDisclosure = React.useCallback(
(event) => {
event.preventDefault();
if (isExpanded && document.activeElement) {
document.activeElement.blur();
}
setExpanded(!isExpanded);
},
[isExpanded]
);
const handleSave = useDebouncedCallback(async (getValue) => {
try {
await collection.save({
description: getValue(),
});
setDirty(false);
} catch (err) {
ui.showToast(
t("Sorry, an error occurred saving the collection", {
type: "error",
})
);
throw err;
}
}, 1000);
const handleChange = React.useCallback(
(getValue) => {
setDirty(true);
handleSave(getValue);
},
[handleSave]
);
React.useEffect(() => {
setEditing(false);
}, [collection.id]);
const placeholder = `${t("Add a description")}`;
const key = isEditing || isDirty ? "draft" : collection.updatedAt;
return (
<MaxHeight data-editing={isEditing} data-expanded={isExpanded}>
<Input
$isEditable={can.update}
data-editing={isEditing}
data-expanded={isExpanded}
>
<span onClick={can.update ? handleStartEditing : undefined}>
{collections.isSaving && <LoadingIndicator />}
{collection.hasDescription || isEditing || isDirty ? (
<React.Suspense fallback={<Placeholder>Loading</Placeholder>}>
<Editor
key={key}
defaultValue={collection.description || ""}
onChange={handleChange}
placeholder={placeholder}
readOnly={!isEditing}
autoFocus={isEditing}
onBlur={handleStopEditing}
maxLength={1000}
disableEmbeds
readOnlyWriteCheckboxes
grow
/>
</React.Suspense>
) : (
can.update && <Placeholder>{placeholder}</Placeholder>
)}
</span>
</Input>
{!isEditing && (
<Disclosure
onClick={handleClickDisclosure}
aria-label={isExpanded ? t("Collapse") : t("Expand")}
size={30}
>
<Arrow />
</Disclosure>
)}
</MaxHeight>
);
}
const Disclosure = styled(NudeButton)`
opacity: 0;
color: ${(props) => props.theme.divider};
position: absolute;
top: calc(25vh - 50px);
left: 50%;
z-index: 1;
transform: rotate(-90deg) translateX(-50%);
transition: opacity 100ms ease-in-out;
&:focus,
&:hover {
opacity: 1;
}
&:active {
color: ${(props) => props.theme.sidebarText};
}
`;
const Placeholder = styled(ButtonLink)`
color: ${(props) => props.theme.placeholder};
cursor: text;
min-height: 27px;
`;
const MaxHeight = styled.div`
position: relative;
max-height: 25vh;
overflow: hidden;
margin: -12px -8px -8px;
padding: 8px;
&[data-editing="true"],
&[data-expanded="true"] {
max-height: initial;
overflow: initial;
${Disclosure} {
top: initial;
bottom: 0;
transform: rotate(90deg) translateX(-50%);
}
}
&:hover ${Disclosure} {
opacity: 1;
}
`;
const Input = styled.div`
margin: -8px;
padding: 8px;
border-radius: 8px;
transition: ${(props) => props.theme.backgroundTransition};
&:after {
content: "";
position: absolute;
top: calc(25vh - 50px);
left: 0;
right: 0;
height: 50px;
pointer-events: none;
background: linear-gradient(
180deg,
${(props) => transparentize(1, props.theme.background)} 0%,
${(props) => props.theme.background} 100%
);
}
&[data-editing="true"],
&[data-expanded="true"] {
&:after {
background: transparent;
}
}
&[data-editing="true"] {
background: ${(props) => props.theme.secondaryBackground};
}
.block-menu-trigger,
.heading-anchor {
display: none !important;
}
`;
export default observer(CollectionDescription);
@@ -1,20 +1,20 @@
import { observer } from "mobx-react";
import { CollectionIcon } from "outline-icons";
// @flow
import { inject, observer } from "mobx-react";
import { PrivateCollectionIcon, CollectionIcon } from "outline-icons";
import { getLuminance } from "polished";
import * as React from "react";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import { icons } from "components/IconPicker";
import useStores from "hooks/useStores";
type Props = {
collection: Collection;
expanded?: boolean;
size?: number;
collection: Collection,
expanded?: boolean,
size?: number,
ui: UiStore,
};
function ResolvedCollectionIcon({ collection, expanded, size }: Props) {
const { ui } = useStores();
function ResolvedCollectionIcon({ collection, expanded, size, ui }: Props) {
// If the chosen icon color is very dark then we invert it in dark mode
// otherwise it will be impossible to see against the dark background.
const color =
@@ -33,7 +33,13 @@ function ResolvedCollectionIcon({ collection, expanded, size }: Props) {
}
}
if (collection.private) {
return (
<PrivateCollectionIcon color={color} expanded={expanded} size={size} />
);
}
return <CollectionIcon color={color} expanded={expanded} size={size} />;
}
export default observer(ResolvedCollectionIcon);
export default inject("ui")(observer(ResolvedCollectionIcon));
@@ -1,3 +1,4 @@
// @flow
import styled from "styled-components";
const Header = styled.h3`
@@ -1,22 +1,19 @@
// @flow
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { SyntheticEvent } from "react";
type Props = {
onClick?: (a: SyntheticEvent) => void | Promise<void>;
children?: React.ReactNode;
selected?: boolean;
disabled?: boolean;
to?: string;
href?: string;
target?: "_blank";
as?: string | React.ComponentType<any>;
hide?: () => void;
};
type Props = {|
onClick?: (SyntheticEvent<>) => void | Promise<void>,
children?: React.Node,
selected?: boolean,
disabled?: boolean,
to?: string,
href?: string,
target?: "_blank",
as?: string | React.ComponentType<*>,
|};
const MenuItem = ({
onClick,
@@ -24,41 +21,19 @@ const MenuItem = ({
selected,
disabled,
as,
hide,
...rest
}: Props) => {
// We bind to mousedown instead of onClick here as otherwise menu items do not
// work in Firefox which triggers the hideOnClickOutside handler first via
// mousedown.
const handleMouseDown = React.useCallback(
(ev) => {
if (onClick) {
ev.preventDefault();
ev.stopPropagation();
onClick(ev);
}
},
[onClick]
);
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
disabled={disabled}
hide={hide}
{...rest}
>
{(props) => (
<MenuAnchor
{...props}
$toggleable={selected !== undefined}
as={onClick ? "button" : as}
onMouseDown={handleMouseDown}
onClick={hide}
>
<MenuAnchor as={onClick ? "button" : as} {...props}>
{selected !== undefined && (
<>
{selected ? <CheckmarkIcon color="currentColor" /> : <Spacer />}
{selected ? <CheckmarkIcon /> : <Spacer />}
&nbsp;
</>
)}
@@ -69,17 +44,16 @@ const MenuItem = ({
);
};
const Spacer = styled.svg`
const Spacer = styled.div`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
export const MenuAnchor = styled.a`
display: flex;
margin: 0;
border: 0;
padding: 12px;
padding: 6px 12px;
width: 100%;
min-height: 32px;
background: none;
@@ -87,12 +61,12 @@ export const MenuAnchor = styled.a`
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
justify-content: left;
align-items: center;
font-size: 16px;
font-size: 15px;
cursor: default;
user-select: none;
svg:not(:last-child) {
margin-right: 4px;
margin-right: 8px;
}
svg {
@@ -105,8 +79,7 @@ export const MenuAnchor = styled.a`
? "pointer-events: none;"
: `
&:hover,
&:focus,
&:hover,
&.focus-visible {
color: ${props.theme.white};
background: ${props.theme.primary};
@@ -117,11 +90,11 @@ export const MenuAnchor = styled.a`
fill: ${props.theme.white};
}
}
`};
${breakpoint("tablet")`
padding: ${(props) => (props.$toggleable ? "4px 12px" : "6px 12px")};
font-size: 15px;
&:focus {
color: ${props.theme.white};
background: ${props.theme.primary};
}
`};
`;
@@ -1,3 +1,4 @@
// @flow
import { MoreIcon } from "outline-icons";
import * as React from "react";
import { MenuButton } from "reakit/Menu";
@@ -1,3 +1,4 @@
// @flow
import * as React from "react";
import { MenuSeparator } from "reakit/Menu";
import styled from "styled-components";
@@ -1,3 +1,4 @@
// @flow
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -12,56 +13,53 @@ import MenuItem, { MenuAnchor } from "./MenuItem";
import Separator from "./Separator";
import ContextMenu from ".";
import { SyntheticEvent } from "react";
type TMenuItem =
| {
title: React.ReactNode;
to: string;
visible?: boolean;
selected?: boolean;
disabled?: boolean;
}
| {
title: React.ReactNode;
onClick: (event: SyntheticEvent) => void | Promise<void>;
visible?: boolean;
selected?: boolean;
disabled?: boolean;
}
| {
title: React.ReactNode;
href: string;
visible?: boolean;
selected?: boolean;
disabled?: boolean;
}
| {
title: React.ReactNode;
visible?: boolean;
disabled?: boolean;
style?: any;
hover?: boolean;
items: TMenuItem[];
}
| {
type: "separator";
visible?: boolean;
}
| {
type: "heading";
visible?: boolean;
title: React.ReactNode;
};
| {|
title: React.Node,
to: string,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
onClick: (event: SyntheticEvent<>) => void | Promise<void>,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
href: string,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
visible?: boolean,
disabled?: boolean,
style?: Object,
hover?: boolean,
items: TMenuItem[],
|}
| {|
type: "separator",
visible?: boolean,
|}
| {|
type: "heading",
visible?: boolean,
title: React.Node,
|};
type Props = {
items: TMenuItem[];
};
type Props = {|
items: TMenuItem[],
|};
const Disclosure = styled(ExpandedIcon)`
transform: rotate(270deg);
position: absolute;
right: 8px;
justify-self: flex-end;
`;
const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
@@ -84,7 +82,7 @@ const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
);
});
function Template({ items, ...menu }: Props): React.ReactNode {
function Template({ items, ...menu }: Props): React.Node {
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unneccessary separators
+77
View File
@@ -0,0 +1,77 @@
// @flow
import { rgba } from "polished";
import * as React from "react";
import { Menu } from "reakit/Menu";
import styled from "styled-components";
import { fadeAndScaleIn } from "shared/styles/animations";
import usePrevious from "hooks/usePrevious";
type Props = {|
"aria-label": string,
visible?: boolean,
animating?: boolean,
children: React.Node,
onOpen?: () => void,
onClose?: () => void,
|};
export default function ContextMenu({
children,
onOpen,
onClose,
...rest
}: Props) {
const previousVisible = usePrevious(rest.visible);
React.useEffect(() => {
if (rest.visible && !previousVisible) {
if (onOpen) {
onOpen();
}
}
if (!rest.visible && previousVisible) {
if (onClose) {
onClose();
}
}
}, [onOpen, onClose, previousVisible, rest.visible]);
return (
<Menu {...rest}>
{(props) => (
<Position {...props}>
<Background>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
)}
</Menu>
);
}
const Position = styled.div`
position: absolute;
z-index: ${(props) => props.theme.depths.menu};
`;
const Background = styled.div`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: ${(props) => (props.left !== undefined ? "25%" : "75%")} 0;
background: ${(props) => rgba(props.theme.menuBackground, 0.95)};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
border-radius: 2px;
padding: 0.5em 0;
min-width: 180px;
overflow: hidden;
overflow-y: auto;
max-height: 75vh;
max-width: 276px;
box-shadow: ${(props) => props.theme.menuShadow};
pointer-events: all;
font-weight: normal;
@media print {
display: none;
}
`;
-120
View File
@@ -1,120 +0,0 @@
import * as React from "react";
import { Portal } from "react-portal";
import { Menu } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import {
fadeIn,
fadeAndScaleIn,
fadeAndSlideIn,
} from "shared/styles/animations";
import usePrevious from "hooks/usePrevious";
type Props = {
"aria-label": string;
visible?: boolean;
animating?: boolean;
children: React.ReactNode;
onOpen?: () => void;
onClose?: () => void;
};
export default function ContextMenu({
children,
onOpen,
onClose,
...rest
}: Props) {
const previousVisible = usePrevious(rest.visible);
React.useEffect(() => {
if (rest.visible && !previousVisible) {
if (onOpen) {
onOpen();
}
}
if (!rest.visible && previousVisible) {
if (onClose) {
onClose();
}
}
}, [onOpen, onClose, previousVisible, rest.visible]);
return (
<>
<Menu hideOnClickOutside preventBodyScroll {...rest}>
{(props) => (
<Position {...props}>
<Background dir="auto">
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
)}
</Menu>
{(rest.visible || rest.animating) && (
<Portal>
<Backdrop />
</Portal>
)}
</>
);
}
const Backdrop = styled.div`
animation: ${fadeIn} 200ms ease-in-out;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: ${(props) => props.theme.backdrop};
z-index: ${(props) => props.theme.depths.menu - 1};
${breakpoint("tablet")`
display: none;
`};
`;
const Position = styled.div`
position: absolute;
z-index: ${(props) => props.theme.depths.menu};
${breakpoint("mobile", "tablet")`
position: fixed !important;
transform: none !important;
top: auto !important;
right: 8px !important;
bottom: 16px !important;
left: 8px !important;
`};
`;
const Background = styled.div`
animation: ${fadeAndSlideIn} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
background: ${(props) => props.theme.menuBackground};
border-radius: 6px;
padding: 6px 0;
min-width: 180px;
overflow: hidden;
overflow-y: auto;
max-height: 75vh;
pointer-events: all;
font-weight: normal;
@media print {
display: none;
}
${breakpoint("tablet")`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: ${(props) =>
props.left !== undefined ? "25%" : "75%"} 0;
max-width: 276px;
background: ${(props) => props.theme.menuBackground};
box-shadow: ${(props) => props.theme.menuShadow};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
`};
`;
@@ -1,22 +1,20 @@
// @flow
import copy from "copy-to-clipboard";
import * as React from "react";
import { SyntheticEvent } from "react";
type Props = {
text: string;
children?: React.ReactNode;
onClick?: () => void;
onCopy: () => void;
text: string,
children?: React.Node,
onClick?: () => void,
onCopy: () => void,
};
class CopyToClipboard extends React.PureComponent<Props> {
onClick = (ev: SyntheticEvent) => {
onClick = (ev: SyntheticEvent<>) => {
const { text, onCopy, children } = this.props;
const elem = React.Children.only(children);
copy(text, {
debug: process.env.NODE_ENV !== "production",
format: "text/plain",
});
if (onCopy) onCopy();
@@ -1,8 +1,9 @@
// @flow
import * as React from "react";
type Props = {
delay?: number;
children: JSX.Element;
delay?: number,
children: React.Node,
};
export default function DelayedMount({ delay = 250, children }: Props) {
-10
View File
@@ -1,10 +0,0 @@
import styled from "styled-components";
const Divider = styled.hr`
border: 0;
border-bottom: 1px solid ${(props) => props.theme.divider};
margin: 0;
padding: 0;
`;
export default Divider;
-137
View File
@@ -1,137 +0,0 @@
import { observer } from "mobx-react";
import {
ArchiveIcon,
EditIcon,
GoToIcon,
ShapesIcon,
TrashIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Document from "models/Document";
import Breadcrumb from "components/Breadcrumb";
import CollectionIcon from "components/CollectionIcon";
import useStores from "hooks/useStores";
import { collectionUrl } from "utils/routeHelpers";
type Props = {
document: Document;
children?: React.ReactNode;
onlyText: boolean;
};
function useCategory(document) {
const { t } = useTranslation();
if (document.isDeleted) {
return {
icon: <TrashIcon color="currentColor" />,
title: t("Trash"),
to: "/trash",
};
}
if (document.isArchived) {
return {
icon: <ArchiveIcon color="currentColor" />,
title: t("Archive"),
to: "/archive",
};
}
if (document.isDraft) {
return {
icon: <EditIcon color="currentColor" />,
title: t("Drafts"),
to: "/drafts",
};
}
if (document.isTemplate) {
return {
icon: <ShapesIcon color="currentColor" />,
title: t("Templates"),
to: "/templates",
};
}
return null;
}
const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
const { collections } = useStores();
const { t } = useTranslation();
const category = useCategory(document);
let collection = collections.get(document.collectionId);
if (!collection) {
collection = {
id: document.collectionId,
name: t("Deleted Collection"),
color: "currentColor",
url: "deleted-collection",
};
}
const path = React.useMemo(
() =>
collection && collection.pathToDocument
? collection.pathToDocument(document.id).slice(0, -1)
: [],
[collection, document.id]
);
const items = React.useMemo(() => {
let output = [];
if (category) {
output.push(category);
}
if (collection) {
output.push({
icon: <CollectionIcon collection={collection} expanded />,
title: collection.name,
to: collectionUrl(collection.url),
});
}
path.forEach((p) => {
output.push({
title: p.title,
to: p.url,
});
});
return output;
}, [path, category, collection]);
if (!collections.isLoaded) {
return null;
}
if (onlyText === true) {
return (
<>
{collection.name}
{path.map((n) => (
<React.Fragment key={n.id}>
<SmallSlash />
{n.title}
</React.Fragment>
))}
</>
);
}
return <Breadcrumb items={items} children={children} highlightFirstItem />;
};
const SmallSlash = styled(GoToIcon)`
width: 12px;
height: 12px;
vertical-align: middle;
flex-shrink: 0;
fill: ${(props) => props.theme.slate};
opacity: 0.5;
`;
export default observer(DocumentBreadcrumb);
@@ -1,10 +1,10 @@
// @flow
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { action, observable } from "mobx";
import { inject, observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { Redirect } from "react-router-dom";
import { Match, RouterHistory } from "react-router-dom";
import { type Match, Redirect, type RouterHistory } from "react-router-dom";
import { Waypoint } from "react-waypoint";
import styled from "styled-components";
@@ -20,24 +20,19 @@ import Revision from "./components/Revision";
import { documentHistoryUrl, documentUrl } from "utils/routeHelpers";
type Props = {
match: Match;
documents: DocumentsStore;
revisions: RevisionsStore;
history: RouterHistory;
match: Match,
documents: DocumentsStore,
revisions: RevisionsStore,
history: RouterHistory,
};
@observer
class DocumentHistory extends React.Component<Props> {
@observable
isLoaded: boolean = false;
@observable
isFetching: boolean = false;
@observable
offset: number = 0;
@observable
allowLoadMore: boolean = true;
@observable
redirectTo: string | undefined | null;
@observable isLoaded: boolean = false;
@observable isFetching: boolean = false;
@observable offset: number = 0;
@observable allowLoadMore: boolean = true;
@observable redirectTo: ?string;
async componentDidMount() {
await this.loadMoreResults();
@@ -1,4 +1,5 @@
import { format } from "date-fns";
// @flow
import format from "date-fns/format";
import * as React from "react";
import { NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components";
@@ -9,16 +10,16 @@ import Avatar from "components/Avatar";
import Flex from "components/Flex";
import Time from "components/Time";
import RevisionMenu from "menus/RevisionMenu";
import { Theme } from "types";
import { type Theme } from "types";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {
theme: Theme;
showMenu: boolean;
selected: boolean;
document: Document;
revision: Revision;
theme: Theme,
showMenu: boolean,
selected: boolean,
document: Document,
revision: Revision,
};
class RevisionListItem extends React.Component<Props> {
@@ -36,7 +37,7 @@ class RevisionListItem extends React.Component<Props> {
</Author>
<Meta>
<Time dateTime={revision.createdAt} tooltipDelay={250}>
{format(Date.parse(revision.createdAt), "MMMM do, yyyy h:mm a")}
{format(revision.createdAt, "MMMM Do, YYYY h:mm a")}
</Time>
</Meta>
{showMenu && (
@@ -1,2 +1,3 @@
// @flow
import DocumentHistory from "./DocumentHistory";
export default DocumentHistory;
@@ -1,17 +1,18 @@
// @flow
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import * as React from "react";
import Document from "models/Document";
import DocumentListItem from "components/DocumentListItem";
type Props = {
documents: Document[];
limit?: number;
showCollection?: boolean;
showPublished?: boolean;
showPin?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
type Props = {|
documents: Document[],
limit?: number,
showCollection?: boolean,
showPublished?: boolean,
showPin?: boolean,
showDraft?: boolean,
showTemplate?: boolean,
|};
export default function DocumentList({ limit, documents, ...rest }: Props) {
const items = limit ? documents.splice(0, limit) : documents;
@@ -1,3 +1,4 @@
// @flow
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
@@ -14,23 +15,21 @@ import Flex from "components/Flex";
import Highlight from "components/Highlight";
import StarButton, { AnimatedStar } from "components/Star";
import Tooltip from "components/Tooltip";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {
document: Document;
highlight?: string | null;
context?: string | null;
showNestedDocuments?: boolean;
showCollection?: boolean;
showPublished?: boolean;
showPin?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
type Props = {|
document: Document,
highlight?: ?string,
context?: ?string,
showNestedDocuments?: boolean,
showCollection?: boolean,
showPublished?: boolean,
showPin?: boolean,
showDraft?: boolean,
showTemplate?: boolean,
|};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@@ -40,11 +39,9 @@ function replaceResultMarks(tag: string) {
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
}
function DocumentListItem(props: Props, ref) {
function DocumentListItem(props: Props) {
const { t } = useTranslation();
const { policies } = useStores();
const currentUser = useCurrentUser();
const currentTeam = useCurrentTeam();
const [menuOpen, setMenuOpen] = React.useState(false);
const {
document,
@@ -63,12 +60,9 @@ function DocumentListItem(props: Props, ref) {
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
const can = policies.abilities(currentTeam.id);
return (
<DocumentLink
ref={ref}
dir={document.dir}
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
@@ -77,12 +71,8 @@ function DocumentListItem(props: Props, ref) {
}}
>
<Content>
<Heading dir={document.dir}>
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
<Heading>
<Title text={document.titleWithDefault} highlight={highlight} />
{document.isNew && document.createdBy.id !== currentUser.id && (
<Badge yellow>{t("New")}</Badge>
)}
@@ -121,24 +111,21 @@ function DocumentListItem(props: Props, ref) {
/>
</Content>
<Actions>
{document.isTemplate &&
!document.isArchived &&
!document.isDeleted &&
can.createDocument && (
<>
<Button
as={Link}
to={newDocumentUrl(document.collectionId, {
templateId: document.id,
})}
icon={<PlusIcon />}
neutral
>
{t("New doc")}
</Button>
&nbsp;
</>
)}
{document.isTemplate && !document.isArchived && !document.isDeleted && (
<>
<Button
as={Link}
to={newDocumentUrl(document.collectionId, {
templateId: document.id,
})}
icon={<PlusIcon />}
neutral
>
{t("New doc")}
</Button>
&nbsp;
</>
)}
<DocumentMenu
document={document}
showPin={showPin}
@@ -176,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;
@@ -226,7 +210,6 @@ const DocumentLink = styled(Link)`
const Heading = styled.h3`
display: flex;
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
align-items: center;
height: 24px;
margin-top: 0;
@@ -257,4 +240,4 @@ const ResultContext = styled(Highlight)`
margin-bottom: 0.25em;
`;
export default observer(React.forwardRef(DocumentListItem));
export default observer(DocumentListItem);
@@ -1,16 +1,16 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Document from "models/Document";
import DocumentBreadcrumb from "components/DocumentBreadcrumb";
import Breadcrumb from "components/Breadcrumb";
import Flex from "components/Flex";
import Time from "components/Time";
import useStores from "hooks/useStores";
const Container = styled(Flex)`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${(props) => props.theme.textTertiary};
font-size: 13px;
white-space: nowrap;
@@ -18,25 +18,20 @@ const Container = styled(Flex)`
min-width: 0;
`;
const Viewed = styled.span`
text-overflow: ellipsis;
overflow: hidden;
`;
const Modified = styled.span`
color: ${(props) => props.theme.textTertiary};
font-weight: ${(props) => (props.highlight ? "600" : "400")};
`;
type Props = {
showCollection?: boolean;
showPublished?: boolean;
showLastViewed?: boolean;
showNestedDocuments?: boolean;
document: Document;
children: React.ReactNode;
to?: string;
};
type Props = {|
showCollection?: boolean,
showPublished?: boolean,
showLastViewed?: boolean,
showNestedDocuments?: boolean,
document: Document,
children: React.Node,
to?: string,
|};
function DocumentMeta({
showPublished,
@@ -117,16 +112,16 @@ function DocumentMeta({
}
if (!lastViewedAt) {
return (
<Viewed>
<>
&nbsp;<Modified highlight>{t("Never viewed")}</Modified>
</Viewed>
</>
);
}
return (
<Viewed>
<span>
&nbsp;{t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
</Viewed>
</span>
);
};
@@ -135,14 +130,14 @@ function DocumentMeta({
: 0;
return (
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
<Container align="center" {...rest}>
{updatedByMe ? t("You") : updatedBy.name}&nbsp;
{to ? <Link to={to}>{content}</Link> : content}
{showCollection && collection && (
<span>
&nbsp;{t("in")}&nbsp;
<strong>
<DocumentBreadcrumb document={document} onlyText />
<Breadcrumb document={document} onlyText />
</strong>
</span>
)}
+54
View File
@@ -0,0 +1,54 @@
// @flow
import { useObserver } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import Document from "models/Document";
import DocumentMeta from "components/DocumentMeta";
import useStores from "../hooks/useStores";
type Props = {|
document: Document,
isDraft: boolean,
to?: string,
|};
function DocumentMetaWithViews({ to, isDraft, document }: Props) {
const { views } = useStores();
const documentViews = useObserver(() => views.inDocument(document.id));
const totalViewers = documentViews.length;
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
return (
<Meta document={document} to={to}>
{totalViewers && !isDraft ? (
<>
&nbsp;&middot; Viewed by{" "}
{onlyYou
? "only you"
: `${totalViewers} ${totalViewers === 1 ? "person" : "people"}`}
</>
) : null}
</Meta>
);
}
const Meta = styled(DocumentMeta)`
margin: -12px 0 2em 0;
font-size: 14px;
position: relative;
z-index: 1;
a {
color: inherit;
&:hover {
text-decoration: underline;
}
}
@media print {
display: none;
}
`;
export default DocumentMetaWithViews;
-84
View File
@@ -1,84 +0,0 @@
import { useObserver } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components";
import Document from "models/Document";
import DocumentMeta from "components/DocumentMeta";
import DocumentViews from "components/DocumentViews";
import Popover from "components/Popover";
import useStores from "hooks/useStores";
type Props = {
document: Document;
isDraft: boolean;
to?: string;
rtl?: boolean;
};
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
const { views } = useStores();
const { t } = useTranslation();
const documentViews = useObserver(() => views.inDocument(document.id));
const totalViewers = documentViews.length;
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
React.useEffect(() => {
if (!document.isDeleted) {
views.fetchPage({ documentId: document.id });
}
}, [views, document.id, document.isDeleted]);
const popover = usePopoverState({
gutter: 8,
placement: "bottom",
modal: true,
});
return (
<Meta document={document} to={to} {...rest}>
{totalViewers && !isDraft ? (
<PopoverDisclosure {...popover}>
{(props) => (
<>
&nbsp;&middot;&nbsp;
<a {...props}>
{t("Viewed by")}{" "}
{onlyYou
? t("only you")
: `${totalViewers} ${
totalViewers === 1 ? t("person") : t("people")
}`}
</a>
</>
)}
</PopoverDisclosure>
) : null}
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
<DocumentViews document={document} isOpen={popover.visible} />
</Popover>
</Meta>
);
}
const Meta = styled(DocumentMeta)<{ rtl: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
margin: -12px 0 2em 0;
font-size: 14px;
position: relative;
z-index: 1;
a {
color: inherit;
&:hover {
text-decoration: underline;
}
}
@media print {
display: none;
}
`;
export default DocumentMetaWithViews;
-80
View File
@@ -1,80 +0,0 @@
import { formatDistanceToNow } from "date-fns";
import { sortBy } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Document from "models/Document";
import Avatar from "components/Avatar";
import ListItem from "components/List/Item";
import PaginatedList from "components/PaginatedList";
import useStores from "hooks/useStores";
type Props = {
document: Document;
isOpen?: boolean;
};
function DocumentViews({ document, isOpen }: Props) {
const { t } = useTranslation();
const { views, presence } = useStores();
let documentPresence = presence.get(document.id);
documentPresence = documentPresence
? Array.from(documentPresence.values())
: [];
const presentIds = documentPresence.map((p) => p.userId);
const editingIds = documentPresence
.filter((p) => p.isEditing)
.map((p) => p.userId);
// ensure currently present via websocket are always ordered first
const documentViews = views.inDocument(document.id);
const sortedViews = sortBy(
documentViews,
(view) => !presentIds.includes(view.user.id)
);
const users = React.useMemo(
() => sortedViews.map((v) => v.user),
[sortedViews]
);
return (
<>
{isOpen && (
<PaginatedList
items={users}
renderItem={(item) => {
const view = documentViews.find((v) => v.user.id === item.id);
const isPresent = presentIds.includes(item.id);
const isEditing = editingIds.includes(item.id);
const subtitle = isPresent
? isEditing
? t("Currently editing")
: t("Currently viewing")
: t("Viewed {{ timeAgo }} ago", {
timeAgo: formatDistanceToNow(
view ? Date.parse(view.lastViewedAt) : new Date()
),
});
return (
<ListItem
key={item.id}
title={item.name}
subtitle={subtitle}
image={<Avatar key={item.id} src={item.avatarUrl} size={32} />}
border={false}
small
/>
);
}}
/>
)}
</>
);
}
export default observer(DocumentViews);
+123
View File
@@ -0,0 +1,123 @@
// @flow
import invariant from "invariant";
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import * as React from "react";
import Dropzone from "react-dropzone";
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
import styled, { css } from "styled-components";
import DocumentsStore from "stores/DocumentsStore";
import UiStore from "stores/UiStore";
import LoadingIndicator from "components/LoadingIndicator";
const EMPTY_OBJECT = {};
let importingLock = false;
type Props = {
children: React.Node,
collectionId: string,
documentId?: string,
ui: UiStore,
documents: DocumentsStore,
disabled: boolean,
location: Object,
match: Match,
history: RouterHistory,
staticContext: Object,
};
@observer
class DropToImport extends React.Component<Props> {
@observable isImporting: boolean = false;
onDropAccepted = async (files = []) => {
if (importingLock) return;
this.isImporting = true;
importingLock = true;
try {
let collectionId = this.props.collectionId;
const documentId = this.props.documentId;
const redirect = files.length === 1;
if (documentId && !collectionId) {
const document = await this.props.documents.fetch(documentId);
invariant(document, "Document not available");
collectionId = document.collectionId;
}
for (const file of files) {
const doc = await this.props.documents.import(
file,
documentId,
collectionId,
{ publish: true }
);
if (redirect) {
this.props.history.push(doc.url);
}
}
} catch (err) {
this.props.ui.showToast(`Could not import file. ${err.message}`, {
type: "error",
});
} finally {
this.isImporting = false;
importingLock = false;
}
};
render() {
const { documents } = this.props;
if (this.props.disabled) return this.props.children;
return (
<Dropzone
accept={documents.importFileTypes.join(", ")}
onDropAccepted={this.onDropAccepted}
style={EMPTY_OBJECT}
noClick
multiple
>
{({
getRootProps,
getInputProps,
isDragActive,
isDragAccept,
isDragReject,
}) => (
<DropzoneContainer
{...getRootProps()}
{...{ isDragActive }}
tabIndex="-1"
>
<input {...getInputProps()} />
{this.isImporting && <LoadingIndicator />}
{this.props.children}
</DropzoneContainer>
)}
</Dropzone>
);
}
}
const DropzoneContainer = styled("div")`
border-radius: 4px;
${({ isDragActive, theme }) =>
isDragActive &&
css`
background: ${theme.slateDark};
a {
color: ${theme.white} !important;
}
svg {
fill: ${theme.white};
}
`}
`;
export default inject("documents", "ui")(withRouter(DropToImport));
@@ -1,74 +1,55 @@
// @flow
import { lighten } from "polished";
import * as React from "react";
import { SyntheticEvent } from "react";
import { useTranslation } from "react-i18next";
import { withRouter, RouterHistory } from "react-router-dom";
import { withRouter, type RouterHistory } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import { light } from "shared/styles/theme";
import UiStore from "stores/UiStore";
import ErrorBoundary from "components/ErrorBoundary";
import Tooltip from "components/Tooltip";
import embeds from "../embeds";
import useMediaQuery from "hooks/useMediaQuery";
import { Theme } from "types";
import { isModKey } from "utils/keyboard";
import { uploadFile } from "utils/uploadFile";
import { isInternalUrl } from "utils/urls";
const RichMarkdownEditor = React.lazy(
() =>
import(
/* webpackChunkName: "rich-markdown-editor" */ "rich-markdown-editor"
)
);
const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));
const EMPTY_ARRAY = [];
export type Props = {
id?: string;
value?: string;
defaultValue?: string;
readOnly?: boolean;
grow?: boolean;
disableEmbeds?: boolean;
ui?: UiStore;
shareId?: string | null;
autoFocus?: boolean;
template?: boolean;
placeholder?: string;
maxLength?: number;
scrollTo?: string;
theme?: Theme;
handleDOMEvents?: any;
readOnlyWriteCheckboxes?: boolean;
onBlur?: (event: SyntheticEvent) => any;
onFocus?: (event: SyntheticEvent) => any;
onPublish?: (event: SyntheticEvent) => any;
onSave?: (a: {
done?: boolean;
autosave?: boolean;
publish?: boolean;
}) => any;
onCancel?: () => any;
onDoubleClick?: () => any;
onChange?: (getValue: () => string) => any;
onSearchLink?: (title: string) => any;
onHoverLink?: (event: MouseEvent) => any;
onCreateLink?: (title: string) => Promise<string>;
onImageUploadStart?: () => any;
onImageUploadStop?: () => any;
};
export type Props = {|
id?: string,
value?: string,
defaultValue?: string,
readOnly?: boolean,
grow?: boolean,
disableEmbeds?: boolean,
ui?: UiStore,
autoFocus?: boolean,
template?: boolean,
placeholder?: string,
scrollTo?: string,
readOnlyWriteCheckboxes?: boolean,
onBlur?: (event: SyntheticEvent<>) => any,
onFocus?: (event: SyntheticEvent<>) => any,
onPublish?: (event: SyntheticEvent<>) => any,
onSave?: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
onCancel?: () => any,
onChange?: (getValue: () => string) => any,
onSearchLink?: (title: string) => any,
onHoverLink?: (event: MouseEvent) => any,
onCreateLink?: (title: string) => Promise<string>,
onImageUploadStart?: () => any,
onImageUploadStop?: () => any,
|};
type PropsWithRef = Props & {
forwardedRef: React.Ref<any>;
history: RouterHistory;
forwardedRef: React.Ref<any>,
history: RouterHistory,
};
function Editor(props: PropsWithRef) {
const { id, ui, shareId, history } = props;
const { id, ui, history } = props;
const { t } = useTranslation();
const isPrinting = useMediaQuery("print");
const onUploadImage = React.useCallback(
async (file: File) => {
@@ -100,16 +81,12 @@ function Editor(props: PropsWithRef) {
}
}
if (shareId) {
navigateTo = `/share/${shareId}${navigateTo}`;
}
history.push(navigateTo);
} else if (href) {
window.open(href, "_blank");
}
},
[history, shareId]
[history]
);
const onShowToast = React.useCallback(
@@ -141,11 +118,6 @@ function Editor(props: PropsWithRef) {
deleteColumn: t("Delete column"),
deleteRow: t("Delete row"),
deleteTable: t("Delete table"),
deleteImage: t("Delete image"),
downloadImage: t("Download image"),
alignImageLeft: t("Float left"),
alignImageRight: t("Float right"),
alignImageDefault: t("Center large"),
em: t("Italic"),
embedInvalidLink: t("Sorry, that link wont work for this embed type"),
findOrCreateDoc: `${t("Find or create a doc")}`,
@@ -166,7 +138,6 @@ function Editor(props: PropsWithRef) {
noResults: t("No results"),
openLink: t("Open link"),
orderedList: t("Ordered list"),
pageBreak: t("Page break"),
pasteLink: `${t("Paste a link")}`,
pasteLinkWithTitle: (service: string) =>
t("Paste a {{service}} link…", { service }),
@@ -196,7 +167,6 @@ function Editor(props: PropsWithRef) {
tooltip={EditorTooltip}
dictionary={dictionary}
{...props}
theme={isPrinting ? light : props.theme}
/>
</ErrorBoundary>
);
@@ -207,7 +177,7 @@ const StyledEditor = styled(RichMarkdownEditor)`
justify-content: start;
> div {
background: transparent;
transition: ${(props) => props.theme.backgroundTransition};
}
& * {
@@ -267,6 +237,6 @@ const Span = styled.span`
const EditorWithRouterAndTheme = withRouter(withTheme(Editor));
export default React.forwardRef<typeof Editor>((props, ref) => (
export default React.forwardRef<Props, typeof Editor>((props, ref) => (
<EditorWithRouterAndTheme {...props} forwardedRef={ref} />
));
@@ -1,3 +1,4 @@
// @flow
import styled from "styled-components";
const Empty = styled.p`
@@ -1,3 +1,4 @@
// @flow
import * as Sentry from "@sentry/react";
import { observable } from "mobx";
import { observer } from "mobx-react";
@@ -11,18 +12,16 @@ import { githubIssuesUrl } from "../../shared/utils/routeHelpers";
import env from "env";
type Props = {
children: React.ReactNode;
reloadOnChunkMissing?: boolean;
children: React.Node,
reloadOnChunkMissing?: boolean,
};
@observer
class ErrorBoundary extends React.Component<Props> {
@observable
error: Error | undefined | null;
@observable
showDetails: boolean = false;
@observable error: ?Error;
@observable showDetails: boolean = false;
componentDidCatch(error: Error, info: any) {
componentDidCatch(error: Error, info: Object) {
this.error = error;
console.error(error);
@@ -108,7 +107,7 @@ class ErrorBoundary extends React.Component<Props> {
}
const Pre = styled.pre`
background: ${(props) => props.theme.secondaryBackground};
background: ${(props) => props.theme.smoke};
padding: 16px;
border-radius: 4px;
font-size: 12px;
@@ -1,14 +1,14 @@
// @flow
import * as React from "react";
import { SyntheticEvent } from "react";
type Props = {
children: React.ReactNode;
className?: string;
children: React.Node,
className?: string,
};
export default function EventBoundary({ children, className }: Props) {
const handleClick = React.useCallback((event: SyntheticEvent) => {
const handleClick = React.useCallback((event: SyntheticEvent<>) => {
event.preventDefault();
event.stopPropagation();
}, []);
@@ -1,42 +1,45 @@
import { observer } from "mobx-react";
// @flow
import { observer, inject } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import styled, { withTheme } from "styled-components";
import User from "models/User";
import Avatar from "components/Avatar";
import Flex from "components/Flex";
import { SyntheticEvent } from "react";
type Props = {
users: User[];
size?: number;
overflow: number;
onClick?: (event: SyntheticEvent) => unknown;
renderAvatar?: (user: User) => React.ReactNode;
users: User[],
size?: number,
overflow: number,
renderAvatar: (user: User) => React.Node,
};
function Facepile({
users,
overflow,
size = 32,
renderAvatar = DefaultAvatar,
...rest
}: Props) {
return (
<Avatars {...rest}>
{overflow > 0 && (
<More size={size}>
<span>+{overflow}</span>
</More>
)}
{users.map((user) => (
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
))}
</Avatars>
);
@observer
class Facepile extends React.Component<Props> {
render() {
const {
users,
overflow,
size = 32,
renderAvatar = renderDefaultAvatar,
...rest
} = this.props;
return (
<Avatars {...rest}>
{overflow > 0 && (
<More size={size}>
<span>+{overflow}</span>
</More>
)}
{users.map((user) => (
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
))}
</Avatars>
);
}
}
function DefaultAvatar(user: User) {
function renderDefaultAvatar(user: User) {
return <Avatar user={user} src={user.avatarUrl} size={32} />;
}
@@ -70,4 +73,4 @@ const Avatars = styled(Flex)`
cursor: pointer;
`;
export default observer(Facepile);
export default inject("views", "presence")(withTheme(Facepile));
@@ -1,3 +1,4 @@
// @flow
import styled from "styled-components";
import { fadeIn } from "shared/styles/animations";
-110
View File
@@ -1,110 +0,0 @@
import { find } from "lodash";
import * as React from "react";
import { useMenuState, MenuButton } from "reakit/Menu";
import styled from "styled-components";
import Button, { Inner } from "components/Button";
import ContextMenu from "components/ContextMenu";
import MenuItem from "components/ContextMenu/MenuItem";
import HelpText from "components/HelpText";
type TFilterOption = {
key: string;
label: string;
note?: string;
};
type Props = {
options: TFilterOption[];
activeKey: string | undefined | null;
defaultLabel?: string;
selectedPrefix?: string;
className?: string;
onSelect: (key?: string | null) => void;
};
const FilterOptions = ({
options,
activeKey = "",
defaultLabel,
selectedPrefix = "",
className,
onSelect,
}: Props) => {
const menu = useMenuState({ modal: true });
const selected = find(options, { key: activeKey }) || options[0];
const selectedLabel = selected ? `${selectedPrefix} ${selected.label}` : "";
return (
<Wrapper>
<MenuButton {...menu}>
{(props) => (
<StyledButton
{...props}
className={className}
neutral
disclosure
small
>
{activeKey ? selectedLabel : defaultLabel}
</StyledButton>
)}
</MenuButton>
<ContextMenu aria-label={defaultLabel} {...menu}>
{options.map((option) => (
<MenuItem
key={option.key}
onClick={() => {
onSelect(option.key);
menu.hide();
}}
selected={option.key === activeKey}
{...menu}
>
{option.note ? (
<LabelWithNote>
{option.label}
<Note>{option.note}</Note>
</LabelWithNote>
) : (
option.label
)}
</MenuItem>
))}
</ContextMenu>
</Wrapper>
);
};
const LabelWithNote = styled.div`
font-weight: 500;
text-align: left;
`;
const Note = styled(HelpText)`
margin-top: 2px;
margin-bottom: 0;
line-height: 1.2em;
font-size: 14px;
font-weight: 400;
color: ${(props) => props.theme.textTertiary};
`;
const StyledButton = styled(Button)`
box-shadow: none;
text-transform: none;
border-color: transparent;
&:hover {
background: transparent;
}
${Inner} {
line-height: 28px;
}
`;
const Wrapper = styled.div`
margin-right: 8px;
`;
export default FilterOptions;
@@ -1,3 +1,4 @@
// @flow
import * as React from "react";
import styled from "styled-components";
@@ -7,6 +8,7 @@ type JustifyValues =
| "space-between"
| "flex-start"
| "flex-end";
type AlignValues =
| "stretch"
| "center"
@@ -15,25 +17,27 @@ type AlignValues =
| "flex-end";
type Props = {
column?: boolean | null;
shrink?: boolean | null;
align?: AlignValues;
justify?: JustifyValues;
auto?: boolean | null;
className?: string;
children?: React.ReactNode;
role?: string;
gap?: number;
column?: ?boolean,
shrink?: ?boolean,
align?: AlignValues,
justify?: JustifyValues,
auto?: ?boolean,
className?: string,
children?: React.Node,
};
const Flex = styled.div<Props>`
const Flex = (props: Props) => {
const { children, ...restProps } = props;
return <Container {...restProps}>{children}</Container>;
};
const Container = styled.div`
display: flex;
flex: ${({ auto }) => (auto ? "1 1 auto" : "initial")};
flex-direction: ${({ column }) => (column ? "column" : "row")};
align-items: ${({ align }) => align};
justify-content: ${({ justify }) => justify};
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
gap: ${({ gap }) => `${gap}px` || "initial"};
min-height: 0;
min-width: 0;
`;
@@ -1,3 +1,4 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import Empty from "components/Empty";
@@ -1,9 +1,10 @@
// @flow
import * as React from "react";
type Props = {
size?: number;
fill?: string;
className?: string;
size?: number,
fill?: string,
className?: string,
};
function GithubLogo({ size = 34, fill = "#FFF", className }: Props) {
@@ -1,9 +1,10 @@
// @flow
import * as React from "react";
type Props = {
size?: number;
fill?: string;
className?: string;
size?: number,
fill?: string,
className?: string,
};
function GoogleLogo({ size = 34, fill = "#FFF", className }: Props) {
@@ -1,6 +1,6 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { GroupIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { MAX_AVATAR_DISPLAY } from "shared/constants";
@@ -14,18 +14,16 @@ import ListItem from "components/List/Item";
import Modal from "components/Modal";
type Props = {
group: Group;
groupMemberships: GroupMembershipsStore;
membership?: CollectionGroupMembership;
showFacepile?: boolean;
showAvatar?: boolean;
renderActions: (a: { openMembersModal: () => void }) => React.ReactNode;
group: Group,
groupMemberships: GroupMembershipsStore,
membership?: CollectionGroupMembership,
showFacepile: boolean,
renderActions: ({ openMembersModal: () => void }) => React.Node,
};
@observer
class GroupListItem extends React.Component<Props> {
@observable
membersModalOpen: boolean = false;
@observable membersModalOpen: boolean = false;
handleMembersModalOpen = () => {
this.membersModalOpen = true;
@@ -50,11 +48,6 @@ class GroupListItem extends React.Component<Props> {
return (
<>
<ListItem
image={
<Image>
<GroupIcon size={24} />
</Image>
}
title={
<Title onClick={this.handleMembersModalOpen}>{group.name}</Title>
}
@@ -91,15 +84,6 @@ class GroupListItem extends React.Component<Props> {
}
}
const Image = styled(Flex)`
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: ${(props) => props.theme.secondaryBackground};
border-radius: 32px;
`;
const Title = styled.span`
&:hover {
text-decoration: underline;
-111
View File
@@ -1,111 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
import styled from "styled-components";
import Scrollable from "components/Scrollable";
import usePrevious from "hooks/usePrevious";
type Props = {
children?: React.ReactNode;
isOpen: boolean;
title?: string;
onRequestClose: () => void;
};
const Guide = ({
children,
isOpen,
title = "Untitled",
onRequestClose,
...rest
}: Props) => {
const dialog = useDialogState({ animated: 250 });
const wasOpen = usePrevious(isOpen);
React.useEffect(() => {
if (!wasOpen && isOpen) {
dialog.show();
}
if (wasOpen && !isOpen) {
dialog.hide();
}
}, [dialog, wasOpen, isOpen]);
return (
<DialogBackdrop {...dialog}>
{(props) => (
<Backdrop {...props}>
<Dialog
{...dialog}
aria-label={title}
preventBodyScrollhideOnEsc
hide={onRequestClose}
>
{(props) => (
<Scene {...props} {...rest}>
<Content>
{title && <Header>{title}</Header>}
{children}
</Content>
</Scene>
)}
</Dialog>
</Backdrop>
)}
</DialogBackdrop>
);
};
const Header = styled.h1`
font-size: 18px;
margin-top: 0;
margin-bottom: 1em;
`;
const Backdrop = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: ${(props) => props.theme.backdrop} !important;
z-index: ${(props) => props.theme.depths.modalOverlay};
transition: opacity 200ms ease-in-out;
opacity: 0;
&[data-enter] {
opacity: 1;
}
`;
const Scene = styled.div`
position: absolute;
top: 0;
right: 0;
bottom: 0;
margin: 12px;
z-index: ${(props) => props.theme.depths.modal};
display: flex;
justify-content: center;
align-items: flex-start;
width: 350px;
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
border-radius: 8px;
outline: none;
opacity: 0;
transform: translateX(16px);
transition: transform 250ms ease, opacity 250ms ease;
&[data-enter] {
opacity: 1;
transform: translateX(0px);
}
`;
const Content = styled(Scrollable)`
width: 100%;
padding: 16px;
`;
export default observer(Guide);
-123
View File
@@ -1,123 +0,0 @@
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.ReactNode;
title: React.ReactNode;
actions?: React.ReactNode;
};
function Header({ breadcrumb, title, actions }: Props) {
const [isScrolled, setScrolled] = React.useState(false);
const handleScroll = React.useCallback(
throttle(() => setScrolled(window.scrollY > 75), 50),
[]
);
React.useEffect(() => {
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
const handleClickTitle = React.useCallback(() => {
window.scrollTo({
top: 0,
behavior: "smooth",
});
}, []);
return (
<Wrapper align="center" isCompact={isScrolled} shrink={false}>
{breadcrumb ? <Breadcrumbs>{breadcrumb}</Breadcrumbs> : null}
{isScrolled ? (
<Title align="center" justify="flex-start" onClick={handleClickTitle}>
<Fade>{title}</Fade>
</Title>
) : (
<div />
)}
{actions && (
<Actions align="center" justify="flex-end">
{actions}
</Actions>
)}
</Wrapper>
);
}
const Breadcrumbs = styled("div")`
flex-grow: 1;
flex-basis: 0;
align-items: center;
padding-right: 8px;
/* Don't show breadcrumbs on mobile */
display: none;
${breakpoint("tablet")`
display: flex;
`};
`;
const Actions = styled(Flex)`
flex-grow: 1;
flex-basis: 0;
min-width: auto;
padding-left: 8px;
`;
const Wrapper = styled(Flex)`
position: sticky;
top: 0;
z-index: ${(props) => props.theme.depths.header};
background: ${(props) => transparentize(0.2, props.theme.background)};
padding: 12px;
transition: all 100ms ease-out;
transform: translate3d(0, 0, 0);
backdrop-filter: blur(20px);
min-height: 56px;
@media print {
display: none;
}
justify-content: flex-start;
${breakpoint("tablet")`
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
justify-content: "center";
`};
`;
const Title = styled("div")`
display: none;
font-size: 16px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
cursor: pointer;
min-width: 0;
${breakpoint("tablet")`
padding-left: 0;
display: block;
`};
svg {
vertical-align: bottom;
}
@media (display-mode: standalone) {
overflow: hidden;
flex-grow: 0 !important;
}
`;
export default observer(Header);
@@ -1,3 +1,4 @@
// @flow
import styled from "styled-components";
const Heading = styled.h1`
@@ -1,10 +1,7 @@
// @flow
import styled from "styled-components";
type Props = {
small?: boolean;
};
const HelpText = styled.p<Props>`
const HelpText = styled.p`
margin-top: 0;
color: ${(props) => props.theme.textSecondary};
font-size: ${(props) => (props.small ? "13px" : "inherit")};
@@ -1,12 +1,13 @@
// @flow
import * as React from "react";
import replace from "string-replace-to-array";
import styled from "styled-components";
type Props = {
highlight: string | undefined | null | RegExp;
processResult?: (tag: string) => string;
text: string;
caseSensitive?: boolean;
highlight: ?string | RegExp,
processResult?: (tag: string) => string,
text: string,
caseSensitive?: boolean,
};
function Highlight({
@@ -1,3 +1,4 @@
// @flow
import { inject } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
@@ -13,10 +14,10 @@ const DELAY_OPEN = 300;
const DELAY_CLOSE = 300;
type Props = {
node: HTMLAnchorElement;
event: MouseEvent;
documents: DocumentsStore;
onClose: () => void;
node: HTMLAnchorElement,
event: MouseEvent,
documents: DocumentsStore,
onClose: () => void,
};
function HoverPreviewInternal({ node, documents, onClose, event }: Props) {
@@ -25,7 +26,7 @@ function HoverPreviewInternal({ node, documents, onClose, event }: Props) {
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef();
const timerOpen = React.useRef();
const cardRef = React.useRef<HTMLDivElement | undefined | null>();
const cardRef = React.useRef<?HTMLDivElement>();
const startCloseTimer = () => {
stopOpenTimer();
@@ -200,7 +201,6 @@ const Card = styled.div`
const Position = styled.div`
margin-top: 10px;
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
z-index: ${(props) => props.theme.depths.hoverPreview};
display: flex;
max-height: 75%;
@@ -1,3 +1,4 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
@@ -8,8 +9,8 @@ import Editor from "components/Editor";
import useStores from "hooks/useStores";
type Props = {
url: string;
children: (a: React.ReactNode) => React.ReactNode;
url: string,
children: (React.Node) => React.Node,
};
function HoverPreviewDocument({ url, children }: Props) {
@@ -1,3 +1,4 @@
// @flow
import {
CollectionIcon,
CoinsIcon,
@@ -31,10 +32,7 @@ import NudeButton from "components/NudeButton";
const style = { width: 30, height: 30 };
const TwitterPicker = React.lazy(() =>
import(
/* webpackChunkName: "twitter-picker" */
"react-color/lib/components/twitter/Twitter"
)
import("react-color/lib/components/twitter/Twitter")
);
export const icons = {
@@ -125,12 +123,12 @@ const colors = [
"#2F362F",
];
type Props = {
onOpen?: () => void;
onChange: (color: string, icon: string) => void;
icon: string;
color: string;
};
type Props = {|
onOpen?: () => void,
onChange: (color: string, icon: string) => void,
icon: string,
color: string,
|};
function IconPicker({ onOpen, icon, color, onChange }: Props) {
const { t } = useTranslation();
@@ -1,13 +1,14 @@
// @flow
import * as React from "react";
import { cdnPath } from "utils/urls";
type Props = {
alt: string;
src: string;
title?: string;
width?: number;
height?: number;
};
type Props = {|
alt: string,
src: string,
title?: string,
width?: number,
height?: number,
|};
export default function Image({ src, alt, ...rest }: Props) {
return <img src={cdnPath(src)} alt={alt} {...rest} />;
@@ -1,13 +1,11 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Flex from "components/Flex";
import { SyntheticEvent, ChangeEvent, KeyboardEvent } from "react";
const RealTextarea = styled.textarea`
border: 0;
flex: 1;
@@ -35,19 +33,10 @@ const RealInput = styled.input`
&::placeholder {
color: ${(props) => props.theme.placeholder};
}
&::-webkit-search-cancel-button {
-webkit-appearance: none;
}
${breakpoint("mobile", "tablet")`
font-size: 16px;
`};
`;
const Wrapper = styled.div`
flex: ${(props) => (props.flex ? "1" : "0")};
width: ${(props) => (props.short ? "49%" : "auto")};
max-width: ${(props) => (props.short ? "350px" : "100%")};
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : "0")};
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : "initial")};
@@ -61,6 +50,7 @@ const IconWrapper = styled.span`
`;
export const Outline = styled(Flex)`
display: flex;
flex: 1;
margin: ${(props) =>
props.margin !== undefined ? props.margin : "0 0 16px"};
@@ -69,7 +59,7 @@ export const Outline = styled(Flex)`
border-style: solid;
border-color: ${(props) =>
props.hasError
? props.theme.danger
? "red"
: props.focused
? props.theme.inputBorderFocused
: props.theme.inputBorder};
@@ -85,47 +75,42 @@ export const LabelText = styled.div`
display: inline-block;
`;
export type Props = {
type?: "text" | "email" | "checkbox" | "search" | "textarea";
value?: string;
label?: string;
className?: string;
labelHidden?: boolean;
flex?: boolean;
short?: boolean;
margin?: string | number;
icon?: React.ReactNode;
name?: string;
minLength?: number;
maxLength?: number;
autoFocus?: boolean;
autoComplete?: boolean | string;
readOnly?: boolean;
required?: boolean;
disabled?: boolean;
placeholder?: string;
onChange?: (
ev: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
onKeyDown?: (ev: KeyboardEvent<HTMLInputElement>) => unknown;
onFocus?: (ev: SyntheticEvent) => unknown;
onBlur?: (ev: SyntheticEvent) => unknown;
};
export type Props = {|
type?: "text" | "email" | "checkbox" | "search",
value?: string,
label?: string,
className?: string,
labelHidden?: boolean,
flex?: boolean,
short?: boolean,
margin?: string | number,
icon?: React.Node,
name?: string,
minLength?: number,
maxLength?: number,
autoFocus?: boolean,
autoComplete?: boolean | string,
readOnly?: boolean,
required?: boolean,
placeholder?: string,
onChange?: (ev: SyntheticInputEvent<HTMLInputElement>) => mixed,
onFocus?: (ev: SyntheticEvent<>) => void,
onBlur?: (ev: SyntheticEvent<>) => void,
|};
@observer
class Input extends React.Component<Props> {
input: HTMLInputElement | undefined | null;
@observable
focused: boolean = false;
input: ?HTMLInputElement;
@observable focused: boolean = false;
handleBlur = (ev: SyntheticEvent) => {
handleBlur = (ev: SyntheticEvent<>) => {
this.focused = false;
if (this.props.onBlur) {
this.props.onBlur(ev);
}
};
handleFocus = (ev: SyntheticEvent) => {
handleFocus = (ev: SyntheticEvent<>) => {
this.focused = true;
if (this.props.onFocus) {
this.props.onFocus(ev);
@@ -1,13 +1,12 @@
// @flow
import styled from "styled-components";
import Input from "./Input";
const InputLarge = styled(Input)`
height: 38px;
flex-grow: 1;
margin-right: 8px;
height: 40px;
input {
height: 38px;
height: 40px;
}
`;
@@ -1,3 +1,4 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import * as React from "react";
@@ -7,20 +8,18 @@ import Editor from "components/Editor";
import HelpText from "components/HelpText";
import { LabelText, Outline } from "components/Input";
type Props = {
label: string;
minHeight?: number;
maxHeight?: number;
readOnly?: boolean;
ui: UiStore;
};
type Props = {|
label: string,
minHeight?: number,
maxHeight?: number,
readOnly?: boolean,
ui: UiStore,
|};
@observer
class InputRich extends React.Component<Props> {
@observable
editorComponent: React.ComponentType<any>;
@observable
focused: boolean = false;
@observable editorComponent: React.ComponentType<any>;
@observable focused: boolean = false;
handleBlur = () => {
this.focused = false;
@@ -1,38 +1,35 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import { SearchIcon } from "outline-icons";
import * as React from "react";
import { SyntheticEvent, ChangeEvent, KeyboardEvent } from "react";
import { withTranslation, TFunction } from "react-i18next";
import { withTranslation, type TFunction } from "react-i18next";
import keydown from "react-keydown";
import { withRouter, RouteComponentProps } from "react-router-dom";
import { withRouter, type RouterHistory } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import Input from "./Input";
import { Theme } from "types";
import { type Theme } from "types";
import { meta } from "utils/keyboard";
import { searchUrl } from "utils/routeHelpers";
type Props = RouteComponentProps & {
theme: Theme;
source: string;
placeholder?: string;
label?: string;
labelHidden?: boolean;
collectionId?: string;
value: string;
onChange: (event: ChangeEvent) => unknown;
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => unknown;
t: TFunction;
type Props = {
history: RouterHistory,
theme: Theme,
source: string,
placeholder?: string,
label?: string,
labelHidden?: boolean,
collectionId?: string,
t: TFunction,
};
@observer
class InputSearchPage extends React.Component<Props> {
input: Input | undefined | null;
@observable
focused: boolean = false;
class InputSearch extends React.Component<Props> {
input: ?Input;
@observable focused: boolean = false;
@keydown(`${meta}+f`)
focus(ev: SyntheticEvent) {
focus(ev: SyntheticEvent<>) {
ev.preventDefault();
if (this.input) {
@@ -40,7 +37,7 @@ class InputSearchPage extends React.Component<Props> {
}
}
handleSearchInput = (ev: ChangeEvent) => {
handleSearchInput = (ev: SyntheticInputEvent<>) => {
ev.preventDefault();
this.props.history.push(
searchUrl(ev.target.value, {
@@ -59,7 +56,7 @@ class InputSearchPage extends React.Component<Props> {
};
render() {
const { t, value, onChange, onKeyDown } = this.props;
const { t } = this.props;
const { theme, placeholder = `${t("Search")}` } = this.props;
return (
@@ -68,19 +65,16 @@ class InputSearchPage extends React.Component<Props> {
type="search"
placeholder={placeholder}
onInput={this.handleSearchInput}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
icon={
<SearchIcon
color={this.focused ? theme.inputBorderFocused : theme.inputBorder}
/>
}
label={this.props.label}
labelHidden={this.props.labelHidden}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
margin={0}
labelHidden
/>
);
}
@@ -90,6 +84,6 @@ const InputMaxWidth = styled(Input)`
max-width: 30vw;
`;
export default withTranslation()<InputSearchPage>(
withTheme(withRouter(InputSearchPage))
export default withTranslation()<InputSearch>(
withTheme(withRouter(InputSearch))
);
-50
View File
@@ -1,50 +0,0 @@
import { SearchIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components";
import Input from "./Input";
import { Props as InputProps } from "./Input";
import { ChangeEvent, KeyboardEvent } from "react";
type Props = {
placeholder?: string;
value?: string;
onChange: (event: ChangeEvent) => unknown;
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => unknown;
} & InputProps;
export default function InputSearch(props: Props) {
const { t } = useTranslation();
const theme = useTheme();
const [isFocused, setIsFocused] = React.useState(false);
const handleFocus = React.useCallback(() => {
setIsFocused(true);
}, []);
const handleBlur = React.useCallback(() => {
setIsFocused(false);
}, []);
const { placeholder = `${t("Search")}`, onKeyDown, ...rest } = props;
return (
<Input
type="search"
placeholder={placeholder}
icon={
<SearchIcon
color={isFocused ? theme.inputBorderFocused : theme.inputBorder}
/>
}
onKeyDown={onKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
margin={0}
labelHidden
{...rest}
/>
);
}
@@ -1,3 +1,4 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
@@ -15,10 +16,6 @@ const Select = styled.select`
color: ${(props) => props.theme.text};
height: 30px;
option {
background: ${(props) => props.theme.buttonNeutralBackground};
}
&:disabled,
&::placeholder {
color: ${(props) => props.theme.placeholder};
@@ -30,26 +27,22 @@ const Wrapper = styled.label`
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
export type Option = {
label: string;
value: string;
};
type Option = { label: string, value: string };
export type Props = {
value?: string;
label?: string;
short?: boolean;
className?: string;
labelHidden?: boolean;
options: Option[];
onBlur?: () => void;
onFocus?: () => void;
value?: string,
label?: string,
short?: boolean,
className?: string,
labelHidden?: boolean,
options: Option[],
onBlur?: () => void,
onFocus?: () => void,
};
@observer
class InputSelect extends React.Component<Props> {
@observable
focused: boolean = false;
@observable focused: boolean = false;
handleBlur = () => {
this.focused = false;
@@ -60,8 +53,14 @@ class InputSelect extends React.Component<Props> {
};
render() {
const { label, className, labelHidden, options, short, ...rest } =
this.props;
const {
label,
className,
labelHidden,
options,
short,
...rest
} = this.props;
const wrappedLabel = <LabelText>{label}</LabelText>;
-21
View File
@@ -1,21 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import InputSelect from "./InputSelect";
import { Props, Option } from "./InputSelect";
export default function InputSelectPermission(props: Omit<Props, "options">) {
const { t } = useTranslation();
return (
<InputSelect
label={t("Default access")}
options={[
{ label: t("View and edit"), value: "read_write" },
{ label: t("View only"), value: "read" },
{ label: t("No access"), value: "" },
]}
{...props}
/>
);
}
@@ -1,3 +1,4 @@
// @flow
import styled from "styled-components";
const Key = styled.kbd`
@@ -1,12 +1,13 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import Flex from "components/Flex";
type Props = {
label: React.ReactNode | string;
children: React.ReactNode;
};
type Props = {|
label: React.Node | string,
children: React.Node,
|};
const Labeled = ({ label, children, ...props }: Props) => (
<Flex column {...props}>
@@ -16,10 +17,12 @@ const Labeled = ({ label, children, ...props }: Props) => (
);
export const Label = styled(Flex)`
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
padding-bottom: 4px;
display: inline-block;
color: ${(props) => props.theme.text};
text-transform: uppercase;
color: ${(props) => props.theme.textTertiary};
letter-spacing: 0.04em;
`;
export default observer(Labeled);
@@ -1,3 +1,4 @@
// @flow
import { find } from "lodash";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
@@ -1,73 +1,74 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { MenuIcon } from "outline-icons";
import * as React from "react";
import { SyntheticEvent } from "react";
import { Helmet } from "react-helmet";
import { withTranslation, TFunction } from "react-i18next";
import { withTranslation, type TFunction } from "react-i18next";
import keydown from "react-keydown";
import {
Switch,
Route,
Redirect,
withRouter,
RouterHistory,
} from "react-router-dom";
import styled from "styled-components";
import { Switch, Route, Redirect } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import AuthStore from "stores/AuthStore";
import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import ErrorSuspended from "scenes/ErrorSuspended";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import Analytics from "components/Analytics";
import Button from "components/Button";
import DocumentHistory from "components/DocumentHistory";
import Flex from "components/Flex";
import Guide from "components/Guide";
import { LoadingIndicatorBar } from "components/LoadingIndicator";
import Modal from "components/Modal";
import Sidebar from "components/Sidebar";
import SettingsSidebar from "components/Sidebar/Settings";
import SkipNavContent from "components/SkipNavContent";
import SkipNavLink from "components/SkipNavLink";
import { type Theme } from "types";
import { meta } from "utils/keyboard";
import {
homeUrl,
searchUrl,
matchDocumentSlug as slug,
newDocumentUrl,
} from "utils/routeHelpers";
type Props = {
documents: DocumentsStore;
children?: React.ReactNode | null;
actions?: React.ReactNode | null;
title?: React.ReactNode | null;
auth: AuthStore;
ui: UiStore;
history: RouterHistory;
policies: PoliciesStore;
notifications?: React.ReactNode;
i18n: any;
t: TFunction;
documents: DocumentsStore,
children?: ?React.Node,
actions?: ?React.Node,
title?: ?React.Node,
auth: AuthStore,
ui: UiStore,
notifications?: React.Node,
theme: Theme,
i18n: Object,
t: TFunction,
};
@observer
class Layout extends React.Component<Props> {
scrollable: HTMLDivElement | undefined | null;
@observable
redirectTo: string | undefined | null;
@observable
keyboardShortcutsOpen: boolean = false;
scrollable: ?HTMLDivElement;
@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();
@@ -75,6 +76,7 @@ class Layout extends React.Component<Props> {
@keydown("shift+/")
handleOpenKeyboardShortcuts() {
if (this.props.ui.editMode) return;
this.keyboardShortcutsOpen = true;
}
@@ -83,7 +85,8 @@ class Layout extends React.Component<Props> {
};
@keydown(["t", "/", `${meta}+k`])
goToSearch(ev: SyntheticEvent) {
goToSearch(ev: SyntheticEvent<>) {
if (this.props.ui.editMode) return;
ev.preventDefault();
ev.stopPropagation();
this.redirectTo = searchUrl();
@@ -91,25 +94,15 @@ class Layout extends React.Component<Props> {
@keydown("d")
goToDashboard() {
if (this.props.ui.editMode) return;
this.redirectTo = homeUrl();
}
@keydown("n")
goToNewDocument() {
const { activeCollectionId } = this.props.ui;
if (!activeCollectionId) return;
const can = this.props.policies.abilities(activeCollectionId);
if (!can.update) return;
this.props.history.push(newDocumentUrl(activeCollectionId));
}
render() {
const { auth, t, ui } = this.props;
const { user, team } = auth;
const showSidebar = auth.authenticated && user && team;
const sidebarCollapsed = ui.isEditing || ui.sidebarCollapsed;
const sidebarCollapsed = ui.editMode || ui.sidebarCollapsed;
if (auth.isSuspended) return <ErrorSuspended />;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
@@ -124,6 +117,7 @@ class Layout extends React.Component<Props> {
/>
</Helmet>
<SkipNavLink />
<Analytics />
{this.props.ui.progressBarVisible && <LoadingIndicatorBar />}
{this.props.notifications}
@@ -165,13 +159,13 @@ class Layout extends React.Component<Props> {
/>
</Switch>
</Container>
<Guide
<Modal
isOpen={this.keyboardShortcutsOpen}
onRequestClose={this.handleCloseKeyboardShortcuts}
title={t("Keyboard shortcuts")}
>
<KeyboardShortcuts />
</Guide>
</Modal>
</Container>
);
}
@@ -206,7 +200,7 @@ const Content = styled(Flex)`
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
@media print {
margin: 0 !important;
margin: 0;
}
${breakpoint("mobile", "tablet")`
@@ -221,5 +215,5 @@ const Content = styled(Flex)`
`;
export default withTranslation()<Layout>(
inject("auth", "ui", "documents", "policies")(withRouter(Layout))
inject("auth", "ui", "documents")(withTheme(Layout))
);
+69
View File
@@ -0,0 +1,69 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import Flex from "components/Flex";
type Props = {
image?: React.Node,
title: React.Node,
subtitle?: React.Node,
actions?: React.Node,
};
const ListItem = ({ image, title, subtitle, actions }: Props) => {
const compact = !subtitle;
return (
<Wrapper compact={compact}>
{image && <Image>{image}</Image>}
<Content align={compact ? "center" : undefined} column={!compact}>
<Heading>{title}</Heading>
{subtitle && <Subtitle>{subtitle}</Subtitle>}
</Content>
{actions && <Actions>{actions}</Actions>}
</Wrapper>
);
};
const Wrapper = styled.li`
display: flex;
padding: ${(props) => (props.compact ? "8px" : "12px")} 0;
margin: 0;
border-bottom: 1px solid ${(props) => props.theme.divider};
&:last-child {
border-bottom: 0;
}
`;
const Image = styled(Flex)`
padding: 0 8px 0 0;
max-height: 40px;
align-items: center;
user-select: none;
flex-shrink: 0;
align-self: flex-start;
`;
const Heading = styled.p`
font-size: 16px;
font-weight: 500;
line-height: 1.2;
margin: 0;
`;
const Content = styled(Flex)`
flex-grow: 1;
`;
const Subtitle = styled.p`
margin: 0;
font-size: 14px;
color: ${(props) => props.theme.slate};
`;
const Actions = styled.div`
align-self: center;
`;
export default ListItem;
-83
View File
@@ -1,83 +0,0 @@
import * as React from "react";
import styled from "styled-components";
import Flex from "components/Flex";
type Props = {
image?: React.ReactNode;
title: React.ReactNode;
subtitle?: React.ReactNode;
actions?: React.ReactNode;
border?: boolean;
small?: boolean;
};
const ListItem = ({
image,
title,
subtitle,
actions,
small,
border,
}: Props) => {
const compact = !subtitle;
return (
<Wrapper compact={compact} $border={border}>
{image && <Image>{image}</Image>}
<Content align={compact ? "center" : undefined} column={!compact}>
<Heading $small={small}>{title}</Heading>
{subtitle && <Subtitle $small={small}>{subtitle}</Subtitle>}
</Content>
{actions && <Actions>{actions}</Actions>}
</Wrapper>
);
};
const Wrapper = styled.li`
display: flex;
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
margin: ${(props) => (props.$border === false ? "8px 0" : 0)};
border-bottom: 1px solid
${(props) =>
props.$border === false ? "transparent" : props.theme.divider};
&:last-child {
border-bottom: 0;
}
`;
const Image = styled(Flex)`
padding: 0 8px 0 0;
max-height: 32px;
align-items: center;
user-select: none;
flex-shrink: 0;
align-self: center;
`;
const Heading = styled.p`
font-size: ${(props) => (props.$small ? 15 : 16)}px;
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
line-height: 1.2;
margin: 0;
`;
const Content = styled(Flex)`
flex-grow: 1;
`;
const Subtitle = styled.p`
margin: 0;
font-size: ${(props) => (props.$small ? 13 : 14)}px;
color: ${(props) => props.theme.textTertiary};
margin-top: -2px;
`;
const Actions = styled.div`
align-self: center;
`;
export default ListItem;
@@ -1,3 +1,4 @@
// @flow
import styled from "styled-components";
const List = styled.ol`
@@ -1,3 +1,4 @@
// @flow
import { times } from "lodash";
import * as React from "react";
import styled from "styled-components";
@@ -6,7 +7,7 @@ import Flex from "components/Flex";
import Mask from "components/Mask";
type Props = {
count?: number;
count?: number,
};
const Placeholder = ({ count }: Props) => {
@@ -1,2 +1,3 @@
// @flow
import List from "./List";
export default List;
@@ -0,0 +1,25 @@
// @flow
import { inject, observer } from "mobx-react";
import * as React from "react";
import UiStore from "stores/UiStore";
type Props = {
ui: UiStore,
};
@observer
class LoadingIndicator extends React.Component<Props> {
componentDidMount() {
this.props.ui.enableProgressBar();
}
componentWillUnmount() {
this.props.ui.disableProgressBar();
}
render() {
return null;
}
}
export default inject("ui")(LoadingIndicator);
@@ -1,16 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import useStores from "hooks/useStores";
function LoadingIndicator() {
const { ui } = useStores();
React.useEffect(() => {
ui.enableProgressBar();
return () => ui.disableProgressBar();
}, [ui]);
return null;
}
export default observer(LoadingIndicator);
@@ -1,3 +1,4 @@
// @flow
import * as React from "react";
import styled, { keyframes } from "styled-components";
@@ -1,3 +1,4 @@
// @flow
import LoadingIndicator from "./LoadingIndicator";
import LoadingIndicatorBar from "./LoadingIndicatorBar";
export default LoadingIndicator;
@@ -1,3 +1,4 @@
// @flow
import { times } from "lodash";
import * as React from "react";
import styled from "styled-components";
@@ -6,7 +7,7 @@ import Flex from "components/Flex";
import Mask from "components/Mask";
type Props = {
count?: number;
count?: number,
};
const ListPlaceHolder = ({ count }: Props) => {
@@ -1,3 +1,4 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import DelayedMount from "components/DelayedMount";
@@ -5,7 +6,7 @@ import Fade from "components/Fade";
import Flex from "components/Flex";
import Mask from "components/Mask";
export default function LoadingPlaceholder(props: any) {
export default function LoadingPlaceholder(props: Object) {
return (
<DelayedMount>
<Wrapper>

Some files were not shown because too many files have changed in this diff Show More