@@ -3,7 +3,7 @@ jobs:
|
||||
build:
|
||||
working_directory: ~/outline
|
||||
docker:
|
||||
- image: circleci/node:8.11
|
||||
- image: circleci/node:12
|
||||
- image: circleci/redis:latest
|
||||
- image: circleci/postgres:9.6.5-alpine-ram
|
||||
environment:
|
||||
@@ -34,4 +34,7 @@ jobs:
|
||||
command: yarn test
|
||||
- run:
|
||||
name: lint
|
||||
command: yarn lint
|
||||
command: yarn lint
|
||||
- run:
|
||||
name: flow
|
||||
command: yarn flow
|
||||
21
.env.sample
21
.env.sample
@@ -3,21 +3,24 @@
|
||||
# keys (for auth) and the SECRET_KEY.
|
||||
#
|
||||
# Please use `openssl rand -hex 32` to create SECRET_KEY
|
||||
SECRET_KEY=generate_a_new_key
|
||||
|
||||
DATABASE_URL=postgres://user:pass@postgres:5432/outline
|
||||
DATABASE_URL_TEST=postgres://user:pass@postgres:5432/outline-test
|
||||
SECRET_KEY=F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
|
||||
PORT=3000
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
URL=http://localhost:3000
|
||||
PORT=3000
|
||||
|
||||
DEPLOYMENT=self
|
||||
ENABLE_UPDATES=true
|
||||
SUBDOMAINS_ENABLED=false
|
||||
DEBUG=sql,cache,presenters,events
|
||||
WEBSOCKETS_ENABLED=true
|
||||
DEBUG=cache,presenters,events
|
||||
|
||||
# Third party signin credentials (at least one is required)
|
||||
SLACK_KEY=71315967491.XXXXXXXXXX
|
||||
SLACK_SECRET=d2dc414f9953226bad0a356cXXXXYYYY
|
||||
SLACK_KEY=get_a_key_from_slack
|
||||
SLACK_SECRET=get_the_secret_of_above_key
|
||||
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
@@ -28,15 +31,17 @@ GOOGLE_ALLOWED_DOMAINS=
|
||||
# Third party credentials (optional)
|
||||
SLACK_VERIFICATION_TOKEN=PLxk6OlXXXXXVj3YYYY
|
||||
SLACK_APP_ID=A0XXXXXXX
|
||||
SLACK_MESSAGE_ACTIONS=true
|
||||
GOOGLE_ANALYTICS_ID=
|
||||
BUGSNAG_KEY=
|
||||
GITHUB_ACCESS_TOKEN=
|
||||
|
||||
# AWS credentials (optional in dev)
|
||||
AWS_ACCESS_KEY_ID=notcheckedindev
|
||||
AWS_SECRET_ACCESS_KEY=notcheckedindev
|
||||
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=outline-dev
|
||||
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
|
||||
AWS_S3_UPLOAD_MAX_SIZE=26214400
|
||||
|
||||
# Emails configuration (optional)
|
||||
|
||||
19
.eslintrc
19
.eslintrc
@@ -10,19 +10,7 @@
|
||||
"rules": {
|
||||
"eqeqeq": 2,
|
||||
"no-unused-vars": 2,
|
||||
// // Bring back after we remove CSS Modules 100%
|
||||
// "import/order": "warn",
|
||||
// Prettier automatically uses the least amount of parens possible, so this
|
||||
// does more harm than good.
|
||||
"no-mixed-operators": "off",
|
||||
// Temporary fix for a failing import lint
|
||||
"import/no-unresolved": [
|
||||
"error",
|
||||
{
|
||||
"ignore": ["slate-drop-or-paste-images"]
|
||||
}
|
||||
],
|
||||
// Flow
|
||||
"flowtype/require-valid-file-annotation": [
|
||||
2,
|
||||
"always",
|
||||
@@ -32,7 +20,6 @@
|
||||
],
|
||||
"flowtype/space-after-type-colon": [2, "always"],
|
||||
"flowtype/space-before-type-colon": [2, "never"],
|
||||
// Enforce that code is formatted with prettier.
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
@@ -43,6 +30,12 @@
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"createClass": "createReactClass",
|
||||
"pragma": "React",
|
||||
"version": "detect",
|
||||
"flowVersion": "0.86"
|
||||
},
|
||||
"import/resolver": {
|
||||
"node": {
|
||||
"paths": ["app", "."]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
.*/shared/.*
|
||||
|
||||
[ignore]
|
||||
.*/node_modules/tiny-cookie/flow/.*
|
||||
.*/node_modules/styled-components/.*
|
||||
.*/node_modules/polished/.*
|
||||
.*/node_modules/react-side-effect/.*
|
||||
@@ -23,12 +24,10 @@ emoji=true
|
||||
module.system.node.resolve_dirname=node_modules
|
||||
module.system.node.resolve_dirname=app
|
||||
|
||||
module.name_mapper='^\(.*\)\.s?css$' -> 'empty/object'
|
||||
module.name_mapper='^\(.*\)\.md$' -> 'empty/object'
|
||||
module.name_mapper='^shared\/\(.*\)$' -> '<PROJECT_ROOT>/shared/\1'
|
||||
|
||||
module.file_ext=.js
|
||||
module.file_ext=.scss
|
||||
module.file_ext=.md
|
||||
module.file_ext=.json
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
yarn lint:flow
|
||||
yarn flow
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"javascript.validate.enable": false
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:8.11
|
||||
FROM node:12-alpine
|
||||
|
||||
ENV PATH /opt/outline/node_modules/.bin:/opt/node_modules/.bin:$PATH
|
||||
ENV NODE_PATH /opt/outline/node_modules:/opt/node_modules
|
||||
@@ -7,7 +7,10 @@ RUN mkdir -p $APP_PATH
|
||||
|
||||
WORKDIR $APP_PATH
|
||||
COPY . $APP_PATH
|
||||
RUN yarn
|
||||
|
||||
RUN yarn install --pure-lockfile
|
||||
RUN cp -r /opt/outline/node_modules /opt/node_modules
|
||||
|
||||
CMD yarn build && yarn start
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
2
Makefile
2
Makefile
@@ -1,6 +1,6 @@
|
||||
up:
|
||||
docker-compose up -d redis postgres s3
|
||||
docker-compose run --rm outline bash -c "yarn && yarn sequelize db:migrate"
|
||||
docker-compose run --rm outline /bin/sh -c "yarn && yarn sequelize db:migrate"
|
||||
docker-compose up outline
|
||||
|
||||
build:
|
||||
|
||||
69
README.md
69
README.md
@@ -21,26 +21,63 @@ If you'd like to run your own copy of Outline or contribute to development then
|
||||
|
||||
Outline requires the following dependencies:
|
||||
|
||||
- Node.js >= 12
|
||||
- Postgres >=9.5
|
||||
- Redis
|
||||
- Slack or Google developer application for OAuth
|
||||
- AWS S3 storage bucket for media and other attachments
|
||||
- Slack or Google developer application for authentication
|
||||
|
||||
|
||||
### Development
|
||||
|
||||
In development you can quickly get an environment running using Docker by following these steps:
|
||||
|
||||
1. Install [Docker for Desktop](https://www.docker.com) if you don't already have it.
|
||||
1. Clone this repo
|
||||
1. Install [Docker for Desktop](https://www.docker.com) if you don't already have it
|
||||
1. Register a Slack app at https://api.slack.com/apps
|
||||
1. Copy the file `.env.sample` to `.env` and fill out the Slack keys, everything
|
||||
else should work well for development.
|
||||
1. Run `make up`. This will download dependencies, build and launch a development version of Outline.
|
||||
1. Copy the file `.env.sample` to `.env`
|
||||
1. Fill out the following fields:
|
||||
1. `SECRET_KEY` (follow instructions in the comments of `.env`)
|
||||
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
|
||||
1. `SLACK_SECRET`
|
||||
1. Add `http://localhost:3000/auth/slack.callback` as an Oauth callback URL in Slack App settings
|
||||
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
|
||||
|
||||
|
||||
### Production
|
||||
|
||||
For a self-hosted production installation there is more flexibility, but these are the suggested steps:
|
||||
|
||||
1. Clone this repo and install dependencies with `yarn` or `npm install`
|
||||
|
||||
> Requires [Node.js, npm](https://nodejs.org/) and [yarn](https://yarnpkg.com) installed
|
||||
|
||||
1. Build the web app with `yarn build:webpack` or `npm run build:webpack`
|
||||
1. Copy the file `.env.sample` to `.env` and fill out at least the essential fields:
|
||||
1. `SECRET_KEY` (follow instructions in the comments of `.env`)
|
||||
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
|
||||
1. `SLACK_SECRET`
|
||||
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` or `npm run sequelize:migrate `
|
||||
1. Start the service with any daemon tools you prefer. Take PM2 for example, `NODE_ENV=production pm2 start 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 in the `.env` file
|
||||
|
||||
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.
|
||||
|
||||
|
||||
## Development
|
||||
|
||||
### Server
|
||||
|
||||
To enable debugging statements, set the following env vars:
|
||||
Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging output, the following categories are available:
|
||||
|
||||
```
|
||||
DEBUG=sql,cache,presenters,events
|
||||
DEBUG=sql,cache,presenters,events,logistics,emails,mailer
|
||||
```
|
||||
|
||||
## Migrations
|
||||
@@ -48,7 +85,7 @@ DEBUG=sql,cache,presenters,events
|
||||
Sequelize is used to create and run migrations, for example:
|
||||
|
||||
```
|
||||
yarn sequelize migration:create
|
||||
yarn sequelize migration:generate --name my-migration
|
||||
yarn sequelize db:migrate
|
||||
```
|
||||
|
||||
@@ -60,7 +97,7 @@ 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 latest language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier and ESLint are ran as pre-commit hooks.
|
||||
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
|
||||
|
||||
@@ -80,15 +117,19 @@ The editor itself is built ontop of [Slate](https://github.com/ianstormtaylor/sl
|
||||
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 (Sequelize)
|
||||
- `server/pages` - Server-side rendered public pages (React)
|
||||
- `server/models` - Database models
|
||||
- `server/pages` - Server-side rendered public pages
|
||||
- `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, and it's generally good to add tests for backend features and code.
|
||||
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.
|
||||
|
||||
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.js` extension next to the tested code.
|
||||
|
||||
@@ -105,7 +146,7 @@ yarn test:app
|
||||
|
||||
## Contributing
|
||||
|
||||
Outline is still built and maintained by a small team – we'd love your help to fix bugs and add features!
|
||||
Outline is built and maintained by a small team – we'd love your help to fix bugs and add features!
|
||||
|
||||
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 our [Spectrum community](https://spectrum.chat/outline). 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! Take a look at our [roadmap](https://www.getoutline.com/share/3e6cb2b5-d68b-4ad8-8900-062476820311).
|
||||
|
||||
@@ -119,4 +160,4 @@ If you’re looking for ways to get started, here's a list of ways to help us im
|
||||
|
||||
## License
|
||||
|
||||
Outline is [BSD licensed](/blob/master/LICENSE).
|
||||
Outline is [BSD licensed](https://github.com/outline/outline/blob/master/LICENSE).
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
import idObj from 'identity-obj-proxy';
|
||||
export default idObj;
|
||||
147
app.json
Normal file
147
app.json
Normal file
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"name": "Outline",
|
||||
"description": "Open source wiki and knowledge base for growing teams",
|
||||
"website": "https://www.getoutline.com/",
|
||||
"repository": "https://github.com/outline/outline",
|
||||
"keywords": [
|
||||
"wiki",
|
||||
"team",
|
||||
"node",
|
||||
"markdown",
|
||||
"slack"
|
||||
],
|
||||
"success_url": "/",
|
||||
"formation": {
|
||||
"web": {
|
||||
"quantity": 1,
|
||||
"size": "Hobby"
|
||||
}
|
||||
},
|
||||
"image": "heroku/node",
|
||||
"addons": [
|
||||
{
|
||||
"plan": "heroku-redis"
|
||||
},
|
||||
{
|
||||
"plan": "heroku-postgresql"
|
||||
}
|
||||
],
|
||||
"scripts": {
|
||||
"postdeploy": "yarn sequelize db:migrate"
|
||||
},
|
||||
"env": {
|
||||
"SECRET_KEY": {
|
||||
"description": "A secret key",
|
||||
"generator": "secret",
|
||||
"required": true
|
||||
},
|
||||
"DEPLOYMENT": {
|
||||
"description": "Should be 'self' for self hosted installations, turns off things like pricing pages",
|
||||
"value": "self",
|
||||
"required": true
|
||||
},
|
||||
"ENABLE_UPDATES": {
|
||||
"value": "true",
|
||||
"required": true
|
||||
},
|
||||
"SUBDOMAINS_ENABLED": {
|
||||
"value": "false",
|
||||
"required": true,
|
||||
"description": "Allows each team to have a different subdomain. Not recommend when self hosting"
|
||||
},
|
||||
"WEBSOCKETS_ENABLED": {
|
||||
"value": "true",
|
||||
"required": true,
|
||||
"description": "Allow realtime data to be pushed to clients over websockets"
|
||||
},
|
||||
"URL": {
|
||||
"description": "https://{your app name}.herokuapp.com",
|
||||
"required": true
|
||||
},
|
||||
"GOOGLE_CLIENT_ID": {
|
||||
"description": "See https://developers.google.com/identity/protocols/OAuth2 to create a new Google OAuth client. You must configure at least one of Slack or Google to control login.",
|
||||
"required": false
|
||||
},
|
||||
"GOOGLE_CLIENT_SECRET": {
|
||||
"description": "",
|
||||
"required": false
|
||||
},
|
||||
"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
|
||||
},
|
||||
"SLACK_KEY": {
|
||||
"description": "See https://api.slack.com/apps to create a new Slack app. You must configure at least one of Slack or Google to control login.",
|
||||
"required": false
|
||||
},
|
||||
"SLACK_SECRET": {
|
||||
"description": "Your Slack client secret - d2dc414f9953226bad0a356cXXXXYYYY",
|
||||
"required": false
|
||||
},
|
||||
"SLACK_VERIFICATION_TOKEN": {
|
||||
"description": "Your Slack verification token - PLxk6OlXXXXXVj3YYYY",
|
||||
"required": false
|
||||
},
|
||||
"SLACK_APP_ID": {
|
||||
"description": "A0XXXXXXXXX",
|
||||
"required": false
|
||||
},
|
||||
"AWS_ACCESS_KEY_ID": {
|
||||
"description": "Needed to save file uploads. Optional for dev / testing.",
|
||||
"required": false
|
||||
},
|
||||
"AWS_SECRET_ACCESS_KEY": {
|
||||
"description": "",
|
||||
"required": false
|
||||
},
|
||||
"AWS_S3_UPLOAD_BUCKET_NAME": {
|
||||
"description": "yourbucket.example.com",
|
||||
"required": false
|
||||
},
|
||||
"AWS_S3_UPLOAD_BUCKET_URL": {
|
||||
"description": "Live web link to your bucket. For CNAMEs, https://yourbucket.example.com",
|
||||
"required": false
|
||||
},
|
||||
"AWS_S3_UPLOAD_MAX_SIZE": {
|
||||
"description": "Maximum file upload size in bytes",
|
||||
"value": "26214400",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_HOST": {
|
||||
"description": "smtp.example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_PORT": {
|
||||
"description": "1234 (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_USERNAME": {
|
||||
"description": "me@example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_PASSWORD": {
|
||||
"description": "(optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_FROM_EMAIL": {
|
||||
"description": "wiki@example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_REPLY_EMAIL": {
|
||||
"description": "wikireply@example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"GOOGLE_ANALYTICS_ID": {
|
||||
"description": "UA-xxxx (optional)",
|
||||
"required": false
|
||||
},
|
||||
"BUGSNAG_KEY": {
|
||||
"description": "An API key for bugsnag if you wish to collect error reporting (optional)",
|
||||
"required": false
|
||||
},
|
||||
"GITHUB_ACCESS_TOKEN": {
|
||||
"description": "An API token for GitHub, optional for self hosted (optional)",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,8 @@ export const Action = styled(Flex)`
|
||||
export const Separator = styled.div`
|
||||
margin-left: 12px;
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: ${props => props.theme.slateLight};
|
||||
height: 28px;
|
||||
background: ${props => props.theme.divider};
|
||||
`;
|
||||
|
||||
const Actions = styled(Flex)`
|
||||
@@ -29,7 +29,8 @@ const Actions = styled(Flex)`
|
||||
right: 0;
|
||||
left: 0;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
background: ${props => props.theme.background};
|
||||
transition: ${props => props.theme.backgroundTransition};
|
||||
padding: 12px;
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class Alert extends React.Component<Props> {
|
||||
|
||||
const Container = styled(Flex)`
|
||||
height: $headerHeight;
|
||||
color: #ffffff;
|
||||
color: ${props => props.theme.white};
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
/* global ga */
|
||||
import * as React from 'react';
|
||||
|
||||
export default class Analytics extends React.Component<*> {
|
||||
type Props = {
|
||||
children?: React.Node,
|
||||
};
|
||||
|
||||
export default class Analytics extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
if (!process.env.GOOGLE_ANALYTICS_ID) return;
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ const Authenticated = observer(({ auth, children }: Props) => {
|
||||
return children;
|
||||
}
|
||||
|
||||
auth.logout();
|
||||
auth.logout(true);
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ const CircleImg = styled.img`
|
||||
width: ${props => props.size}px;
|
||||
height: ${props => props.size}px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid ${props => props.theme.white};
|
||||
border: 2px solid ${props => props.theme.background};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
|
||||
17
app/components/Badge.js
Normal file
17
app/components/Badge.js
Normal file
@@ -0,0 +1,17 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Badge = styled.span`
|
||||
margin-left: 10px;
|
||||
padding: 2px 6px 3px;
|
||||
background-color: ${({ admin, theme }) =>
|
||||
admin ? theme.primary : theme.smokeDark};
|
||||
color: ${({ admin, theme }) => (admin ? theme.white : theme.text)};
|
||||
border-radius: 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
export default Badge;
|
||||
@@ -2,61 +2,94 @@
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { darken, lighten } from 'polished';
|
||||
import { ExpandedIcon } from 'outline-icons';
|
||||
|
||||
const RealButton = styled.button`
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: ${props => props.theme.blackLight};
|
||||
color: ${props => props.theme.white};
|
||||
background: ${props => props.theme.buttonBackground};
|
||||
color: ${props => props.theme.buttonText};
|
||||
box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 2px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
height: ${props => (props.small ? 24 : 36)}px;
|
||||
height: 32px;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
svg {
|
||||
fill: ${props => props.theme.buttonText};
|
||||
}
|
||||
|
||||
&::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${props => darken(0.05, props.theme.blackLight)};
|
||||
background: ${props => darken(0.05, props.theme.buttonBackground)};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
transition-duration: 0.05s;
|
||||
box-shadow: ${props => lighten(0.4, props.theme.buttonBackground)} 0px 0px
|
||||
0px 3px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
color: ${props => lighten(0.2, props.theme.blackLight)};
|
||||
color: ${props => props.theme.white50};
|
||||
}
|
||||
|
||||
${props =>
|
||||
props.neutral &&
|
||||
`
|
||||
background: ${props.theme.white};
|
||||
color: ${props.theme.text};
|
||||
background: ${props.theme.buttonNeutralBackground};
|
||||
color: ${props.theme.buttonNeutralText};
|
||||
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px;
|
||||
border: 1px solid ${props.theme.slateLight};
|
||||
border: 1px solid ${darken(0.1, props.theme.buttonNeutralBackground)};
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.buttonNeutralText};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${darken(0.05, props.theme.white)};
|
||||
border: 1px solid ${darken(0.05, props.theme.slateLight)};
|
||||
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
|
||||
border: 1px solid ${darken(0.15, props.theme.buttonNeutralBackground)};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
transition-duration: 0.05s;
|
||||
border: 1px solid ${lighten(0.4, props.theme.buttonBackground)};
|
||||
box-shadow: ${lighten(0.4, props.theme.buttonBackground)} 0px 0px
|
||||
0px 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: ${props.theme.textTertiary};
|
||||
}
|
||||
`} ${props =>
|
||||
props.danger &&
|
||||
`
|
||||
background: ${props.theme.danger};
|
||||
background: ${props.theme.danger};
|
||||
color: ${props.theme.white};
|
||||
|
||||
&:hover {
|
||||
background: ${darken(0.05, props.theme.danger)};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
transition-duration: 0.05s;
|
||||
box-shadow: ${lighten(0.4, props.theme.danger)} 0px 0px
|
||||
0px 3px;
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -68,16 +101,15 @@ const Label = styled.span`
|
||||
${props => props.hasIcon && 'padding-left: 4px;'};
|
||||
`;
|
||||
|
||||
const Inner = styled.span`
|
||||
padding: 0 ${props => (props.small ? 8 : 12)}px;
|
||||
export const Inner = styled.span`
|
||||
display: flex;
|
||||
line-height: ${props => (props.small ? 24 : 28)}px;
|
||||
padding: 0 8px;
|
||||
padding-right: ${props => (props.disclosure ? 2 : 8)}px;
|
||||
line-height: ${props => (props.hasIcon ? 24 : 32)}px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
${props =>
|
||||
props.hasIcon &&
|
||||
(props.small ? 'padding-left: 6px;' : 'padding-left: 8px;')};
|
||||
${props => props.hasIcon && 'padding-left: 4px;'};
|
||||
`;
|
||||
|
||||
export type Props = {
|
||||
@@ -86,26 +118,34 @@ export type Props = {
|
||||
icon?: React.Node,
|
||||
className?: string,
|
||||
children?: React.Node,
|
||||
small?: boolean,
|
||||
innerRef?: React.ElementRef<any>,
|
||||
disclosure?: boolean,
|
||||
};
|
||||
|
||||
export default function Button({
|
||||
function Button({
|
||||
type = 'text',
|
||||
icon,
|
||||
children,
|
||||
value,
|
||||
small,
|
||||
disclosure,
|
||||
innerRef,
|
||||
...rest
|
||||
}: Props) {
|
||||
const hasText = children !== undefined || value !== undefined;
|
||||
const hasIcon = icon !== undefined;
|
||||
|
||||
return (
|
||||
<RealButton small={small} {...rest}>
|
||||
<Inner hasIcon={hasIcon} small={small}>
|
||||
<RealButton type={type} ref={innerRef} {...rest}>
|
||||
<Inner hasIcon={hasIcon} disclosure={disclosure}>
|
||||
{hasIcon && icon}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
{disclosure && <ExpandedIcon />}
|
||||
</Inner>
|
||||
</RealButton>
|
||||
);
|
||||
}
|
||||
|
||||
// $FlowFixMe - need to upgrade to get forwardRef
|
||||
export default React.forwardRef((props, ref) => (
|
||||
<Button {...props} innerRef={ref} />
|
||||
));
|
||||
|
||||
@@ -8,35 +8,40 @@ export type Props = {
|
||||
label?: string,
|
||||
className?: string,
|
||||
note?: string,
|
||||
small?: boolean,
|
||||
};
|
||||
|
||||
const LabelText = styled.span`
|
||||
font-weight: 500;
|
||||
margin-left: 10px;
|
||||
margin-left: ${props => (props.small ? '6px' : '10px')};
|
||||
${props => (props.small ? `color: ${props.theme.textSecondary}` : '')};
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
padding-bottom: 8px;
|
||||
${props => (props.small ? 'font-size: 14px' : '')};
|
||||
`;
|
||||
|
||||
const Label = styled.label`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
export default function Checkbox({
|
||||
label,
|
||||
note,
|
||||
className,
|
||||
small,
|
||||
short,
|
||||
...rest
|
||||
}: Props) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Wrapper>
|
||||
<Wrapper small={small}>
|
||||
<Label>
|
||||
<input type="checkbox" {...rest} />
|
||||
{label && <LabelText>{label}</LabelText>}
|
||||
{label && <LabelText small={small}>{label}</LabelText>}
|
||||
</Label>
|
||||
{note && <HelpText small>{note}</HelpText>}
|
||||
</Wrapper>
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const ClickablePadding = styled.div`
|
||||
min-height: 50vh;
|
||||
min-height: 10em;
|
||||
cursor: ${({ onClick }) => (onClick ? 'text' : 'default')};
|
||||
${({ grow }) => grow && `flex-grow: 1;`};
|
||||
${({ grow }) => grow && `flex-grow: 100;`};
|
||||
`;
|
||||
|
||||
export default ClickablePadding;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import { filter } from 'lodash';
|
||||
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
|
||||
@@ -8,6 +9,7 @@ import Flex from 'shared/components/Flex';
|
||||
import Avatar from 'components/Avatar';
|
||||
import Tooltip from 'components/Tooltip';
|
||||
import Document from 'models/Document';
|
||||
import UserProfile from 'scenes/UserProfile';
|
||||
import ViewsStore from 'stores/ViewsStore';
|
||||
|
||||
const MAX_DISPLAY = 6;
|
||||
@@ -19,31 +21,24 @@ type Props = {
|
||||
|
||||
@observer
|
||||
class Collaborators extends React.Component<Props> {
|
||||
@observable openProfileId: ?string;
|
||||
|
||||
componentDidMount() {
|
||||
this.props.views.fetchPage({ documentId: this.props.document.id });
|
||||
}
|
||||
|
||||
handleOpenProfile = (userId: string) => {
|
||||
this.openProfileId = userId;
|
||||
};
|
||||
|
||||
handleCloseProfile = () => {
|
||||
this.openProfileId = undefined;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { document, views } = this.props;
|
||||
const documentViews = views.inDocument(document.id);
|
||||
const {
|
||||
createdAt,
|
||||
updatedAt,
|
||||
createdBy,
|
||||
updatedBy,
|
||||
collaborators,
|
||||
} = document;
|
||||
let tooltip;
|
||||
|
||||
if (createdAt === updatedAt) {
|
||||
tooltip = `${createdBy.name} published ${distanceInWordsToNow(
|
||||
new Date(createdAt)
|
||||
)} ago`;
|
||||
} else {
|
||||
tooltip = `${updatedBy.name} updated ${distanceInWordsToNow(
|
||||
new Date(updatedAt)
|
||||
)} ago`;
|
||||
}
|
||||
const { createdAt, updatedAt, updatedBy, collaborators } = document;
|
||||
|
||||
// filter to only show views that haven't collaborated
|
||||
const collaboratorIds = collaborators.map(user => user.id);
|
||||
@@ -65,35 +60,71 @@ class Collaborators extends React.Component<Props> {
|
||||
<Avatars>
|
||||
{overflow > 0 && <More>+{overflow}</More>}
|
||||
{mostRecentViewers.map(({ lastViewedAt, user }) => (
|
||||
<StyledTooltip
|
||||
key={user.id}
|
||||
tooltip={`${user.name} viewed ${distanceInWordsToNow(
|
||||
new Date(lastViewedAt)
|
||||
)} ago`}
|
||||
placement="bottom"
|
||||
>
|
||||
<Viewer>
|
||||
<Avatar src={user.avatarUrl} />
|
||||
</Viewer>
|
||||
</StyledTooltip>
|
||||
<React.Fragment key={user.id}>
|
||||
<AvatarPile
|
||||
tooltip={
|
||||
<TooltipCentered>
|
||||
<strong>{user.name}</strong>
|
||||
<br />
|
||||
viewed {distanceInWordsToNow(new Date(lastViewedAt))} ago
|
||||
</TooltipCentered>
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
<Viewer>
|
||||
<Avatar
|
||||
src={user.avatarUrl}
|
||||
onClick={() => this.handleOpenProfile(user.id)}
|
||||
size={32}
|
||||
/>
|
||||
</Viewer>
|
||||
</AvatarPile>
|
||||
<UserProfile
|
||||
user={user}
|
||||
isOpen={this.openProfileId === user.id}
|
||||
onRequestClose={this.handleCloseProfile}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{collaborators.map(user => (
|
||||
<StyledTooltip
|
||||
key={user.id}
|
||||
tooltip={collaborators.length > 1 ? user.name : tooltip}
|
||||
placement="bottom"
|
||||
>
|
||||
<Collaborator>
|
||||
<Avatar src={user.avatarUrl} />
|
||||
</Collaborator>
|
||||
</StyledTooltip>
|
||||
<React.Fragment key={user.id}>
|
||||
<AvatarPile
|
||||
tooltip={
|
||||
<TooltipCentered>
|
||||
<strong>{user.name}</strong>
|
||||
<br />
|
||||
{createdAt === updatedAt ? 'published' : 'updated'}{' '}
|
||||
{updatedBy.id === user.id &&
|
||||
`${distanceInWordsToNow(new Date(updatedAt))} ago`}
|
||||
</TooltipCentered>
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
<Collaborator>
|
||||
<Avatar
|
||||
src={user.avatarUrl}
|
||||
onClick={() => this.handleOpenProfile(user.id)}
|
||||
size={32}
|
||||
/>
|
||||
</Collaborator>
|
||||
</AvatarPile>
|
||||
<UserProfile
|
||||
user={user}
|
||||
isOpen={this.openProfileId === user.id}
|
||||
onRequestClose={this.handleCloseProfile}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Avatars>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const StyledTooltip = styled(Tooltip)`
|
||||
const TooltipCentered = styled.div`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const AvatarPile = styled(Tooltip)`
|
||||
margin-right: -8px;
|
||||
|
||||
&:first-child {
|
||||
@@ -102,14 +133,14 @@ const StyledTooltip = styled(Tooltip)`
|
||||
`;
|
||||
|
||||
const Viewer = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
opacity: 0.75;
|
||||
`;
|
||||
|
||||
const Collaborator = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
`;
|
||||
|
||||
const More = styled.div`
|
||||
@@ -118,7 +149,7 @@ const More = styled.div`
|
||||
border-radius: 12px;
|
||||
background: ${props => props.theme.slate};
|
||||
color: ${props => props.theme.text};
|
||||
border: 2px solid #fff;
|
||||
border: 2px solid ${props => props.theme.background};
|
||||
text-align: center;
|
||||
line-height: 20px;
|
||||
font-size: 11px;
|
||||
@@ -128,6 +159,7 @@ const More = styled.div`
|
||||
const Avatars = styled(Flex)`
|
||||
align-items: center;
|
||||
flex-direction: row-reverse;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export default inject('views')(Collaborators);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observable, computed, action } from 'mobx';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import { TwitterPicker } from 'react-color';
|
||||
import styled from 'styled-components';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { LabelText, Outline } from 'components/Input';
|
||||
import { validateColorHex } from 'shared/utils/color';
|
||||
import Fade from 'components/Fade';
|
||||
import { LabelText } from 'components/Input';
|
||||
|
||||
const colors = [
|
||||
'#4E5C6E',
|
||||
@@ -15,175 +15,92 @@ const colors = [
|
||||
'#FC2D2D',
|
||||
'#FFE100',
|
||||
'#14CF9F',
|
||||
'#00D084',
|
||||
'#EE84F0',
|
||||
'#2F362F',
|
||||
];
|
||||
|
||||
type Props = {
|
||||
onSelect: (color: string) => void,
|
||||
onChange: (color: string) => void,
|
||||
value?: string,
|
||||
};
|
||||
|
||||
@observer
|
||||
class ColorPicker extends React.Component<Props> {
|
||||
@observable selectedColor: string = colors[0];
|
||||
@observable customColorValue: string = '';
|
||||
@observable customColorSelected: boolean;
|
||||
|
||||
componentWillMount() {
|
||||
const { value } = this.props;
|
||||
if (value && colors.includes(value)) {
|
||||
this.selectedColor = value;
|
||||
} else if (value) {
|
||||
this.customColorSelected = true;
|
||||
this.customColorValue = value.replace('#', '');
|
||||
}
|
||||
}
|
||||
@observable isOpen: boolean = false;
|
||||
node: ?HTMLElement;
|
||||
|
||||
componentDidMount() {
|
||||
this.fireCallback();
|
||||
window.addEventListener('click', this.handleClickOutside);
|
||||
}
|
||||
|
||||
fireCallback = () => {
|
||||
this.props.onSelect(
|
||||
this.customColorSelected ? this.customColor : this.selectedColor
|
||||
);
|
||||
};
|
||||
|
||||
@computed
|
||||
get customColor(): string {
|
||||
return this.customColorValue &&
|
||||
validateColorHex(`#${this.customColorValue}`)
|
||||
? `#${this.customColorValue}`
|
||||
: colors[0];
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('click', this.handleClickOutside);
|
||||
}
|
||||
|
||||
@action
|
||||
setColor = (color: string) => {
|
||||
this.selectedColor = color;
|
||||
this.customColorSelected = false;
|
||||
this.fireCallback();
|
||||
handleClose = () => {
|
||||
this.isOpen = false;
|
||||
};
|
||||
|
||||
@action
|
||||
focusOnCustomColor = (event: SyntheticEvent<*>) => {
|
||||
this.selectedColor = '';
|
||||
this.customColorSelected = true;
|
||||
this.fireCallback();
|
||||
handleOpen = () => {
|
||||
this.isOpen = true;
|
||||
};
|
||||
|
||||
@action
|
||||
setCustomColor = (event: SyntheticEvent<*>) => {
|
||||
let target = event.target;
|
||||
if (target instanceof HTMLInputElement) {
|
||||
const color = target.value;
|
||||
this.customColorValue = color.replace('#', '');
|
||||
this.fireCallback();
|
||||
handleClickOutside = (ev: SyntheticMouseEvent<>) => {
|
||||
// $FlowFixMe
|
||||
if (ev.target && this.node && this.node.contains(ev.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleClose();
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Flex column>
|
||||
<LabelText>Color</LabelText>
|
||||
<StyledOutline justify="space-between">
|
||||
<Flex>
|
||||
{colors.map(color => (
|
||||
<Swatch
|
||||
key={color}
|
||||
color={color}
|
||||
active={
|
||||
color === this.selectedColor && !this.customColorSelected
|
||||
}
|
||||
onClick={() => this.setColor(color)}
|
||||
<Wrapper ref={ref => (this.node = ref)}>
|
||||
<label>
|
||||
<LabelText>Color</LabelText>
|
||||
</label>
|
||||
<Swatch
|
||||
role="button"
|
||||
onClick={this.isOpen ? this.handleClose : this.handleOpen}
|
||||
color={this.props.value}
|
||||
/>
|
||||
<Floating>
|
||||
{this.isOpen && (
|
||||
<Fade>
|
||||
<TwitterPicker
|
||||
colors={colors}
|
||||
color={this.props.value}
|
||||
onChange={color => this.props.onChange(color.hex)}
|
||||
triangle="top-right"
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
<Flex justify="flex-end">
|
||||
<strong>Custom color:</strong>
|
||||
<HexHash>#</HexHash>
|
||||
<CustomColorInput
|
||||
placeholder="FFFFFF"
|
||||
onFocus={this.focusOnCustomColor}
|
||||
onChange={this.setCustomColor}
|
||||
value={this.customColorValue}
|
||||
maxLength={6}
|
||||
/>
|
||||
<Swatch
|
||||
color={this.customColor}
|
||||
active={this.customColorSelected}
|
||||
/>
|
||||
</Flex>
|
||||
</StyledOutline>
|
||||
</Flex>
|
||||
</Fade>
|
||||
)}
|
||||
</Floating>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type SwatchProps = {
|
||||
onClick?: () => void,
|
||||
color?: string,
|
||||
active?: boolean,
|
||||
};
|
||||
|
||||
const Swatch = ({ onClick, ...props }: SwatchProps) => (
|
||||
<SwatchOutset onClick={onClick} {...props}>
|
||||
<SwatchInset {...props} />
|
||||
</SwatchOutset>
|
||||
);
|
||||
|
||||
const SwatchOutset = styled(Flex)`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 5px;
|
||||
border: 2px solid ${({ active, color }) => (active ? color : 'transparent')};
|
||||
border-radius: 2px;
|
||||
background: ${({ color }) => color};
|
||||
${({ onClick }) => onClick && `cursor: pointer;`} &:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
const Wrapper = styled('div')`
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
`;
|
||||
const Floating = styled('div')`
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const SwatchInset = styled(Flex)`
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
const Swatch = styled('div')`
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 36px;
|
||||
border: 1px solid ${({ active, color }) => (active ? 'white' : 'transparent')};
|
||||
border-radius: 2px;
|
||||
border-radius: 4px;
|
||||
background: ${({ color }) => color};
|
||||
`;
|
||||
|
||||
const StyledOutline = styled(Outline)`
|
||||
padding: 5px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
`;
|
||||
|
||||
const HexHash = styled.div`
|
||||
margin-left: 12px;
|
||||
padding-bottom: 0;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const CustomColorInput = styled.input`
|
||||
border: 0;
|
||||
flex: 1;
|
||||
width: 65px;
|
||||
margin-right: 12px;
|
||||
padding-bottom: 0;
|
||||
outline: none;
|
||||
background: none;
|
||||
font-family: ${props => props.theme.monospaceFontFamily};
|
||||
font-weight: 500;
|
||||
|
||||
&::placeholder {
|
||||
color: ${props => props.theme.slate};
|
||||
font-family: ${props => props.theme.monospaceFontFamily};
|
||||
font-weight: 500;
|
||||
}
|
||||
`;
|
||||
|
||||
export default ColorPicker;
|
||||
|
||||
@@ -5,12 +5,12 @@ import copy from 'copy-to-clipboard';
|
||||
type Props = {
|
||||
text: string,
|
||||
children?: React.Node,
|
||||
onClick?: () => *,
|
||||
onCopy: () => *,
|
||||
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, {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import Flex from 'shared/components/Flex';
|
||||
|
||||
const Divider = () => {
|
||||
return (
|
||||
<Flex auto justify="center">
|
||||
<Content />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const Content = styled.span`
|
||||
display: flex;
|
||||
width: 50%;
|
||||
margin: 20px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
`;
|
||||
|
||||
export default Divider;
|
||||
@@ -1,15 +1,16 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { observable, action } from 'mobx';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import type { RouterHistory } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import Waypoint from 'react-waypoint';
|
||||
import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
|
||||
|
||||
import { DEFAULT_PAGINATION_LIMIT } from 'stores/BaseStore';
|
||||
import Document from 'models/Document';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
import RevisionsStore from 'stores/RevisionsStore';
|
||||
import Document from 'models/Document';
|
||||
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { ListPlaceholder } from 'components/LoadingPlaceholder';
|
||||
@@ -18,10 +19,9 @@ import { documentHistoryUrl } from 'utils/routeHelpers';
|
||||
|
||||
type Props = {
|
||||
match: Object,
|
||||
document: Document,
|
||||
documents: DocumentsStore,
|
||||
revisions: RevisionsStore,
|
||||
revision?: Object,
|
||||
history: Object,
|
||||
history: RouterHistory,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -30,13 +30,29 @@ class DocumentHistory extends React.Component<Props> {
|
||||
@observable isFetching: boolean = false;
|
||||
@observable offset: number = 0;
|
||||
@observable allowLoadMore: boolean = true;
|
||||
@observable document: Document;
|
||||
|
||||
constructor(props) {
|
||||
super();
|
||||
this.document = props.documents.getByUrl(props.match.params.documentSlug);
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
this.selectFirstRevision();
|
||||
await this.loadMoreResults();
|
||||
this.selectFirstRevision();
|
||||
}
|
||||
|
||||
async componentWillReceiveProps(nextProps) {
|
||||
const document = nextProps.documents.getByUrl(
|
||||
nextProps.match.params.documentSlug
|
||||
);
|
||||
if (!this.document && document) {
|
||||
this.document = document;
|
||||
await this.loadMoreResults();
|
||||
this.selectFirstRevision();
|
||||
}
|
||||
}
|
||||
|
||||
fetchResults = async () => {
|
||||
this.isFetching = true;
|
||||
|
||||
@@ -44,7 +60,7 @@ class DocumentHistory extends React.Component<Props> {
|
||||
const results = await this.props.revisions.fetchPage({
|
||||
limit,
|
||||
offset: this.offset,
|
||||
id: this.props.document.id,
|
||||
id: this.document.id,
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -61,10 +77,9 @@ class DocumentHistory extends React.Component<Props> {
|
||||
};
|
||||
|
||||
selectFirstRevision = () => {
|
||||
const revisions = this.revisions;
|
||||
if (revisions.length && !this.props.revision) {
|
||||
if (this.revisions.length) {
|
||||
this.props.history.replace(
|
||||
documentHistoryUrl(this.props.document, this.revisions[0].id)
|
||||
documentHistoryUrl(this.document, this.revisions[0].id)
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -72,12 +87,13 @@ class DocumentHistory extends React.Component<Props> {
|
||||
@action
|
||||
loadMoreResults = async () => {
|
||||
// Don't paginate if there aren't more results or we’re in the middle of fetching
|
||||
if (!this.allowLoadMore || this.isFetching) return;
|
||||
if (!this.allowLoadMore || this.isFetching || !this.document) return;
|
||||
await this.fetchResults();
|
||||
};
|
||||
|
||||
get revisions() {
|
||||
return this.props.revisions.getDocumentRevisions(this.props.document.id);
|
||||
if (!this.document) return [];
|
||||
return this.props.revisions.getDocumentRevisions(this.document.id);
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -98,7 +114,7 @@ class DocumentHistory extends React.Component<Props> {
|
||||
<Revision
|
||||
key={revision.id}
|
||||
revision={revision}
|
||||
document={this.props.document}
|
||||
document={this.document}
|
||||
showMenu={index !== 0}
|
||||
/>
|
||||
))}
|
||||
@@ -117,15 +133,10 @@ const Loading = styled.div`
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
min-width: ${props => props.theme.sidebarWidth};
|
||||
border-left: 1px solid ${props => props.theme.slateLight};
|
||||
border-left: 1px solid ${props => props.theme.divider};
|
||||
overflow: scroll;
|
||||
overscroll-behavior: none;
|
||||
`;
|
||||
|
||||
export default withRouter(inject('revisions')(DocumentHistory));
|
||||
export default inject('documents', 'revisions')(DocumentHistory);
|
||||
|
||||
@@ -9,10 +9,19 @@ import Flex from 'shared/components/Flex';
|
||||
import Time from 'shared/components/Time';
|
||||
import Avatar from 'components/Avatar';
|
||||
import RevisionMenu from 'menus/RevisionMenu';
|
||||
import Document from 'models/Document';
|
||||
import Revision from 'models/Revision';
|
||||
|
||||
import { documentHistoryUrl } from 'utils/routeHelpers';
|
||||
|
||||
class Revision extends React.Component<*> {
|
||||
type Props = {
|
||||
theme: Object,
|
||||
showMenu: () => void,
|
||||
document: Document,
|
||||
revision: Revision,
|
||||
};
|
||||
|
||||
class RevisionListItem extends React.Component<Props> {
|
||||
render() {
|
||||
const { revision, document, showMenu, theme } = this.props;
|
||||
|
||||
@@ -74,4 +83,4 @@ const Meta = styled.p`
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
export default withTheme(Revision);
|
||||
export default withTheme(RevisionListItem);
|
||||
|
||||
@@ -6,17 +6,10 @@ import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
|
||||
|
||||
type Props = {
|
||||
documents: Document[],
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
limit?: number,
|
||||
};
|
||||
|
||||
export default function DocumentList({
|
||||
limit,
|
||||
showCollection,
|
||||
showPublished,
|
||||
documents,
|
||||
}: Props) {
|
||||
export default function DocumentList({ limit, documents, ...rest }: Props) {
|
||||
const items = limit ? documents.splice(0, limit) : documents;
|
||||
|
||||
return (
|
||||
@@ -25,12 +18,7 @@ export default function DocumentList({
|
||||
defaultActiveChildIndex={0}
|
||||
>
|
||||
{items.map(document => (
|
||||
<DocumentPreview
|
||||
key={document.id}
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
/>
|
||||
<DocumentPreview key={document.id} document={document} {...rest} />
|
||||
))}
|
||||
</ArrowKeyNavigation>
|
||||
);
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Document from 'models/Document';
|
||||
import { StarredIcon } from 'outline-icons';
|
||||
import styled, { withTheme } from 'styled-components';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import Highlight from 'components/Highlight';
|
||||
import { StarredIcon } from 'outline-icons';
|
||||
import PublishingInfo from './components/PublishingInfo';
|
||||
import PublishingInfo from 'components/PublishingInfo';
|
||||
import DocumentMenu from 'menus/DocumentMenu';
|
||||
import Document from 'models/Document';
|
||||
|
||||
type Props = {
|
||||
document: Document,
|
||||
@@ -16,11 +16,11 @@ type Props = {
|
||||
context?: ?string,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
ref?: *,
|
||||
showPin?: boolean,
|
||||
};
|
||||
|
||||
const StyledStar = withTheme(styled(({ solid, theme, ...props }) => (
|
||||
<StarredIcon color={solid ? theme.black : theme.text} {...props} />
|
||||
<StarredIcon color={theme.text} {...props} />
|
||||
))`
|
||||
flex-shrink: 0;
|
||||
opacity: ${props => (props.solid ? '1 !important' : 0)};
|
||||
@@ -43,10 +43,9 @@ const StyledDocumentMenu = styled(DocumentMenu)`
|
||||
|
||||
const DocumentLink = styled(Link)`
|
||||
display: block;
|
||||
margin: 0 -16px;
|
||||
padding: 10px 16px;
|
||||
margin: 10px -8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
max-height: 50vh;
|
||||
min-width: 100%;
|
||||
overflow: hidden;
|
||||
@@ -59,8 +58,7 @@ const DocumentLink = styled(Link)`
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background: ${props => props.theme.smokeLight};
|
||||
border: 2px solid ${props => props.theme.smoke};
|
||||
background: ${props => props.theme.listItemHoverBackground};
|
||||
outline: none;
|
||||
|
||||
${StyledStar}, ${StyledDocumentMenu} {
|
||||
@@ -71,10 +69,6 @@ const DocumentLink = styled(Link)`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border: 2px solid ${props => props.theme.slateDark};
|
||||
}
|
||||
`;
|
||||
|
||||
const Heading = styled.h3`
|
||||
@@ -102,7 +96,7 @@ const Title = styled(Highlight)`
|
||||
|
||||
const ResultContext = styled(Highlight)`
|
||||
display: block;
|
||||
color: ${props => props.theme.slateDark};
|
||||
color: ${props => props.theme.textTertiary};
|
||||
font-size: 14px;
|
||||
margin-top: -0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
@@ -112,13 +106,13 @@ const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
|
||||
@observer
|
||||
class DocumentPreview extends React.Component<Props> {
|
||||
star = (ev: SyntheticEvent<*>) => {
|
||||
star = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.document.star();
|
||||
};
|
||||
|
||||
unstar = (ev: SyntheticEvent<*>) => {
|
||||
unstar = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.document.unstar();
|
||||
@@ -135,6 +129,7 @@ class DocumentPreview extends React.Component<Props> {
|
||||
document,
|
||||
showCollection,
|
||||
showPublished,
|
||||
showPin,
|
||||
highlight,
|
||||
context,
|
||||
...rest
|
||||
@@ -154,16 +149,17 @@ class DocumentPreview extends React.Component<Props> {
|
||||
>
|
||||
<Heading>
|
||||
<Title text={document.title} highlight={highlight} />
|
||||
{!document.isDraft && (
|
||||
<Actions>
|
||||
{document.starred ? (
|
||||
<StyledStar onClick={this.unstar} solid />
|
||||
) : (
|
||||
<StyledStar onClick={this.star} />
|
||||
)}
|
||||
</Actions>
|
||||
)}
|
||||
<StyledDocumentMenu document={document} />
|
||||
{!document.isDraft &&
|
||||
!document.isArchived && (
|
||||
<Actions>
|
||||
{document.isStarred ? (
|
||||
<StyledStar onClick={this.unstar} solid />
|
||||
) : (
|
||||
<StyledStar onClick={this.star} />
|
||||
)}
|
||||
</Actions>
|
||||
)}
|
||||
<StyledDocumentMenu document={document} showPin={showPin} />
|
||||
</Heading>
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
@@ -174,7 +170,7 @@ class DocumentPreview extends React.Component<Props> {
|
||||
)}
|
||||
<PublishingInfo
|
||||
document={document}
|
||||
collection={showCollection ? document.collection : undefined}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
/>
|
||||
</DocumentLink>
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import Collection from 'models/Collection';
|
||||
import Document from 'models/Document';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import Time from 'shared/components/Time';
|
||||
|
||||
const Container = styled(Flex)`
|
||||
color: ${props => props.theme.slate};
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
const Modified = styled.span`
|
||||
color: ${props =>
|
||||
props.highlight ? props.theme.slateDark : props.theme.slate};
|
||||
font-weight: ${props => (props.highlight ? '600' : '400')};
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
collection?: Collection,
|
||||
showPublished?: boolean,
|
||||
document: Document,
|
||||
views?: number,
|
||||
};
|
||||
|
||||
function PublishingInfo({ collection, showPublished, document }: Props) {
|
||||
const {
|
||||
modifiedSinceViewed,
|
||||
updatedAt,
|
||||
updatedBy,
|
||||
publishedAt,
|
||||
isDraft,
|
||||
} = document;
|
||||
const neverUpdated = publishedAt === updatedAt;
|
||||
|
||||
return (
|
||||
<Container align="center">
|
||||
{publishedAt && (neverUpdated || showPublished) ? (
|
||||
<span>
|
||||
{updatedBy.name} published <Time dateTime={publishedAt} /> ago
|
||||
</span>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
{updatedBy.name}
|
||||
{isDraft ? (
|
||||
<span>
|
||||
saved <Time dateTime={updatedAt} /> ago
|
||||
</span>
|
||||
) : (
|
||||
<Modified highlight={modifiedSinceViewed}>
|
||||
updated <Time dateTime={updatedAt} /> ago
|
||||
</Modified>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
{collection && (
|
||||
<span>
|
||||
in <strong>{isDraft ? 'Drafts' : collection.name}</strong>
|
||||
</span>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublishingInfo;
|
||||
@@ -2,14 +2,16 @@
|
||||
import * as React from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import { withRouter, type RouterHistory } from 'react-router-dom';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
import { omit } from 'lodash';
|
||||
import invariant from 'invariant';
|
||||
import importFile from 'utils/importFile';
|
||||
import Dropzone from 'react-dropzone';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
import LoadingIndicator from 'components/LoadingIndicator';
|
||||
|
||||
const EMPTY_OBJECT = {};
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
collectionId: string,
|
||||
@@ -18,11 +20,15 @@ type Props = {
|
||||
rejectClassName?: string,
|
||||
documents: DocumentsStore,
|
||||
disabled: boolean,
|
||||
history: Object,
|
||||
location: Object,
|
||||
match: Object,
|
||||
history: RouterHistory,
|
||||
staticContext: Object,
|
||||
};
|
||||
|
||||
const GlobalStyles = createGlobalStyle`
|
||||
export const GlobalStyles = createGlobalStyle`
|
||||
.activeDropZone {
|
||||
border-radius: 4px;
|
||||
background: ${props => props.theme.slateDark};
|
||||
svg { fill: ${props => props.theme.white}; }
|
||||
}
|
||||
@@ -47,7 +53,7 @@ class DropToImport extends React.Component<Props> {
|
||||
if (documentId && !collectionId) {
|
||||
const document = await this.props.documents.fetch(documentId);
|
||||
invariant(document, 'Document not available');
|
||||
collectionId = document.collection.id;
|
||||
collectionId = document.collectionId;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
@@ -68,15 +74,17 @@ class DropToImport extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const props = omit(
|
||||
this.props,
|
||||
'history',
|
||||
'documentId',
|
||||
'collectionId',
|
||||
'documents',
|
||||
'disabled',
|
||||
'menuOpen'
|
||||
);
|
||||
const {
|
||||
documentId,
|
||||
collectionId,
|
||||
documents,
|
||||
disabled,
|
||||
location,
|
||||
match,
|
||||
history,
|
||||
staticContext,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
if (this.props.disabled) return this.props.children;
|
||||
|
||||
@@ -84,13 +92,12 @@ class DropToImport extends React.Component<Props> {
|
||||
<Dropzone
|
||||
accept="text/markdown, text/plain"
|
||||
onDropAccepted={this.onDropAccepted}
|
||||
style={{}}
|
||||
style={EMPTY_OBJECT}
|
||||
disableClick
|
||||
disablePreview
|
||||
multiple
|
||||
{...props}
|
||||
{...rest}
|
||||
>
|
||||
<GlobalStyles />
|
||||
{this.isImporting && <LoadingIndicator />}
|
||||
{this.props.children}
|
||||
</Dropzone>
|
||||
@@ -98,4 +105,4 @@ class DropToImport extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default inject('documents')(DropToImport);
|
||||
export default inject('documents')(withRouter(DropToImport));
|
||||
|
||||
@@ -3,27 +3,38 @@ import * as React from 'react';
|
||||
import invariant from 'invariant';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import styled from 'styled-components';
|
||||
import { PortalWithState } from 'react-portal';
|
||||
import styled from 'styled-components';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { fadeAndScaleIn } from 'shared/styles/animations';
|
||||
|
||||
let previousClosePortal;
|
||||
|
||||
type Children =
|
||||
| React.Node
|
||||
| ((options: { closePortal: () => void }) => React.Node);
|
||||
|
||||
type Props = {
|
||||
label: React.Node,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
children?: React.Node,
|
||||
children?: Children,
|
||||
className?: string,
|
||||
style?: Object,
|
||||
position?: 'left' | 'right' | 'center',
|
||||
};
|
||||
|
||||
@observer
|
||||
class DropdownMenu extends React.Component<Props> {
|
||||
@observable top: number;
|
||||
@observable right: number;
|
||||
@observable left: number;
|
||||
|
||||
handleOpen = (openPortal: (SyntheticEvent<*>) => *) => {
|
||||
return (ev: SyntheticMouseEvent<*>) => {
|
||||
handleOpen = (
|
||||
openPortal: (SyntheticEvent<>) => void,
|
||||
closePortal: () => void
|
||||
) => {
|
||||
return (ev: SyntheticMouseEvent<HTMLElement>) => {
|
||||
ev.preventDefault();
|
||||
const currentTarget = ev.currentTarget;
|
||||
invariant(document.body, 'why you not here');
|
||||
@@ -32,14 +43,27 @@ class DropdownMenu extends React.Component<Props> {
|
||||
const bodyRect = document.body.getBoundingClientRect();
|
||||
const targetRect = currentTarget.getBoundingClientRect();
|
||||
this.top = targetRect.bottom - bodyRect.top;
|
||||
this.right = bodyRect.width - targetRect.left - targetRect.width;
|
||||
|
||||
if (this.props.position === 'left') {
|
||||
this.left = targetRect.left;
|
||||
} else if (this.props.position === 'center') {
|
||||
this.left = targetRect.left + targetRect.width / 2;
|
||||
} else {
|
||||
this.right = bodyRect.width - targetRect.left - targetRect.width;
|
||||
}
|
||||
|
||||
// attempt to keep only one flyout menu open at once
|
||||
if (previousClosePortal) {
|
||||
previousClosePortal();
|
||||
}
|
||||
previousClosePortal = closePortal;
|
||||
openPortal(ev);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
render() {
|
||||
const { className, label, children } = this.props;
|
||||
const { className, label, position, children } = this.props;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
@@ -51,19 +75,32 @@ class DropdownMenu extends React.Component<Props> {
|
||||
>
|
||||
{({ closePortal, openPortal, portal }) => (
|
||||
<React.Fragment>
|
||||
<Label onClick={this.handleOpen(openPortal)}>{label}</Label>
|
||||
<Label onClick={this.handleOpen(openPortal, closePortal)}>
|
||||
{label}
|
||||
</Label>
|
||||
{portal(
|
||||
<Menu
|
||||
onClick={ev => {
|
||||
ev.stopPropagation();
|
||||
closePortal();
|
||||
}}
|
||||
style={this.props.style}
|
||||
<Position
|
||||
position={position}
|
||||
top={this.top}
|
||||
left={this.left}
|
||||
right={this.right}
|
||||
>
|
||||
{children}
|
||||
</Menu>
|
||||
<Menu
|
||||
onClick={
|
||||
typeof children === 'function'
|
||||
? undefined
|
||||
: ev => {
|
||||
ev.stopPropagation();
|
||||
closePortal();
|
||||
}
|
||||
}
|
||||
style={this.props.style}
|
||||
>
|
||||
{typeof children === 'function'
|
||||
? children({ closePortal })
|
||||
: children}
|
||||
</Menu>
|
||||
</Position>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
@@ -81,22 +118,25 @@ const Label = styled(Flex).attrs({
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const Menu = styled.div`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: 75% 0;
|
||||
|
||||
const Position = styled.div`
|
||||
position: absolute;
|
||||
right: ${({ right }) => right}px;
|
||||
${({ left }) => (left !== undefined ? `left: ${left}px` : '')};
|
||||
${({ right }) => (right !== undefined ? `right: ${right}px` : '')};
|
||||
top: ${({ top }) => top}px;
|
||||
z-index: 1000;
|
||||
border: ${props => props.theme.slateLight};
|
||||
background: ${props => props.theme.white};
|
||||
transform: ${props =>
|
||||
props.position === 'center' ? 'translateX(-50%)' : 'initial'};
|
||||
`;
|
||||
|
||||
const Menu = styled.div`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: ${props => (props.left !== undefined ? '25%' : '75%')} 0;
|
||||
background: ${props => props.theme.menuBackground};
|
||||
border-radius: 2px;
|
||||
padding: 0.5em 0;
|
||||
min-width: 160px;
|
||||
min-width: 180px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 4px 8px rgba(0, 0, 0, 0.08),
|
||||
0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: ${props => props.theme.menuShadow};
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
type Props = {
|
||||
onClick?: (SyntheticEvent<*>) => *,
|
||||
onClick?: (SyntheticEvent<>) => void | Promise<void>,
|
||||
children?: React.Node,
|
||||
disabled?: boolean,
|
||||
};
|
||||
@@ -23,13 +23,13 @@ const MenuItem = styled.a`
|
||||
height: 32px;
|
||||
|
||||
color: ${props =>
|
||||
props.disabled ? props.theme.slate : props.theme.slateDark};
|
||||
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
|
||||
justify-content: left;
|
||||
align-items: center;
|
||||
font-size: 15px;
|
||||
cursor: default;
|
||||
|
||||
svg {
|
||||
svg:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import { lighten } from 'polished';
|
||||
import styled, { withTheme, createGlobalStyle } from 'styled-components';
|
||||
import RichMarkdownEditor from 'rich-markdown-editor';
|
||||
import Placeholder from 'rich-markdown-editor/lib/components/Placeholder';
|
||||
import { uploadFile } from 'utils/uploadFile';
|
||||
import isInternalUrl from 'utils/isInternalUrl';
|
||||
import Tooltip from 'components/Tooltip';
|
||||
import UiStore from 'stores/UiStore';
|
||||
import Embed from './Embed';
|
||||
import embeds from '../../embeds';
|
||||
|
||||
type Props = {
|
||||
titlePlaceholder?: string,
|
||||
bodyPlaceholder?: string,
|
||||
defaultValue?: string,
|
||||
readOnly?: boolean,
|
||||
disableEmbeds?: boolean,
|
||||
forwardedRef: *,
|
||||
history: *,
|
||||
ui: *,
|
||||
forwardedRef: React.Ref<RichMarkdownEditor>,
|
||||
ui: UiStore,
|
||||
};
|
||||
|
||||
@observer
|
||||
class Editor extends React.Component<Props> {
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
onUploadImage = async (file: File) => {
|
||||
const result = await uploadFile(file);
|
||||
return result.url;
|
||||
@@ -44,14 +52,14 @@ class Editor extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
this.props.history.push(navigateTo);
|
||||
this.redirectTo = navigateTo;
|
||||
} else {
|
||||
window.open(href, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
onShowToast = (message: string) => {
|
||||
this.props.ui.showToast(message, 'success');
|
||||
this.props.ui.showToast(message);
|
||||
};
|
||||
|
||||
getLinkComponent = node => {
|
||||
@@ -71,20 +79,206 @@ class Editor extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
return (
|
||||
<RichMarkdownEditor
|
||||
ref={this.props.forwardedRef}
|
||||
uploadImage={this.onUploadImage}
|
||||
onClickLink={this.onClickLink}
|
||||
onShowToast={this.onShowToast}
|
||||
getLinkComponent={this.getLinkComponent}
|
||||
{...this.props}
|
||||
/>
|
||||
<React.Fragment>
|
||||
<PrismStyles />
|
||||
<StyledEditor
|
||||
ref={this.props.forwardedRef}
|
||||
uploadImage={this.onUploadImage}
|
||||
onClickLink={this.onClickLink}
|
||||
onShowToast={this.onShowToast}
|
||||
getLinkComponent={this.getLinkComponent}
|
||||
tooltip={EditorTooltip}
|
||||
{...this.props}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// $FlowIssue - https://github.com/facebook/flow/issues/6103
|
||||
export default React.forwardRef((props, ref) => (
|
||||
<Editor {...props} forwardedRef={ref} />
|
||||
));
|
||||
const StyledEditor = styled(RichMarkdownEditor)`
|
||||
justify-content: start;
|
||||
|
||||
> div {
|
||||
transition: ${props => props.theme.backgroundTransition};
|
||||
}
|
||||
|
||||
p {
|
||||
${Placeholder} {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
p:nth-child(2):last-child {
|
||||
${Placeholder} {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
a {
|
||||
color: ${props => props.theme.link};
|
||||
border-bottom: 1px solid ${props => lighten(0.5, props.theme.link)};
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
border-bottom: 1px solid ${props => props.theme.link};
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/*
|
||||
Based on Prism template by Bram de Haan (http://atelierbram.github.io/syntax-highlighting/prism/)
|
||||
Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16)
|
||||
*/
|
||||
const PrismStyles = createGlobalStyle`
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
-webkit-font-smoothing: initial;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.375;
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
-webkit-hyphens: none;
|
||||
-moz-hyphens: none;
|
||||
-ms-hyphens: none;
|
||||
hyphens: none;
|
||||
color: #24292e;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre[class*="language-"] {
|
||||
padding: 1em;
|
||||
margin: .5em 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
:not(pre) > code[class*="language-"] {
|
||||
padding: .1em;
|
||||
border-radius: .3em;
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: #6a737d;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: #5e6687;
|
||||
}
|
||||
|
||||
.token.namespace {
|
||||
opacity: .7;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.boolean,
|
||||
.token.number {
|
||||
color: #d73a49;
|
||||
}
|
||||
|
||||
.token.property {
|
||||
color: #c08b30;
|
||||
}
|
||||
|
||||
.token.tag {
|
||||
color: #3d8fd1;
|
||||
}
|
||||
|
||||
.token.string {
|
||||
color: #032f62;
|
||||
}
|
||||
|
||||
.token.selector {
|
||||
color: #6679cc;
|
||||
}
|
||||
|
||||
.token.attr-name {
|
||||
color: #c76b29;
|
||||
}
|
||||
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: #22a2c9;
|
||||
}
|
||||
|
||||
.token.attr-value,
|
||||
.token.keyword,
|
||||
.token.control,
|
||||
.token.directive,
|
||||
.token.unit {
|
||||
color: #d73a49;
|
||||
}
|
||||
|
||||
.token.function {
|
||||
color: #6f42c1;
|
||||
}
|
||||
|
||||
.token.statement,
|
||||
.token.regex,
|
||||
.token.atrule {
|
||||
color: #22a2c9;
|
||||
}
|
||||
|
||||
.token.placeholder,
|
||||
.token.variable {
|
||||
color: #3d8fd1;
|
||||
}
|
||||
|
||||
.token.deleted {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.token.inserted {
|
||||
border-bottom: 1px dotted #202746;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.important,
|
||||
.token.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.token.important {
|
||||
color: #c94922;
|
||||
}
|
||||
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
pre > code.highlight {
|
||||
outline: 0.4em solid #c94922;
|
||||
outline-offset: .4em;
|
||||
}
|
||||
`;
|
||||
|
||||
const EditorTooltip = ({ children, ...props }) => (
|
||||
<Tooltip offset="0, 16" delay={150} {...props}>
|
||||
<span>{children}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
export default withTheme(
|
||||
// $FlowIssue - https://github.com/facebook/flow/issues/6103
|
||||
React.forwardRef((props, ref) => <Editor {...props} forwardedRef={ref} />)
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ export default class Embed extends React.Component<*> {
|
||||
return this.props.node.data.get('href');
|
||||
}
|
||||
|
||||
get matches(): ?{ component: *, matches: string[] } {
|
||||
getMatchResults(): ?{ component: *, matches: string[] } {
|
||||
const keys = Object.keys(embeds);
|
||||
|
||||
for (const key of keys) {
|
||||
@@ -23,10 +23,10 @@ export default class Embed extends React.Component<*> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const result = this.matches;
|
||||
const result = this.getMatchResults();
|
||||
if (!result) return null;
|
||||
|
||||
const { attributes, isSelected } = this.props;
|
||||
const { attributes, isSelected, children } = this.props;
|
||||
const { component, matches } = result;
|
||||
const EmbedComponent = component;
|
||||
|
||||
@@ -37,6 +37,7 @@ export default class Embed extends React.Component<*> {
|
||||
{...attributes}
|
||||
>
|
||||
<EmbedComponent matches={matches} url={this.url} />
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -48,5 +49,5 @@ const Container = styled.div`
|
||||
|
||||
border-radius: 3px;
|
||||
box-shadow: ${props =>
|
||||
props.isSelected ? `0 0 0 2px ${props.theme.selected}` : 'none'};
|
||||
props.isSelected ? `0 0 0 2px ${props.theme.primary}` : 'none'};
|
||||
`;
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
type Props = {
|
||||
children: string,
|
||||
children: React.Node,
|
||||
};
|
||||
|
||||
const Empty = (props: Props) => {
|
||||
|
||||
@@ -56,11 +56,11 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
<p>
|
||||
<Button onClick={this.handleReload}>Reload</Button>{' '}
|
||||
{this.showDetails ? (
|
||||
<Button onClick={this.handleReportBug} light>
|
||||
<Button onClick={this.handleReportBug} neutral>
|
||||
Report a Bug…
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={this.handleShowDetails} light>
|
||||
<Button onClick={this.handleShowDetails} neutral>
|
||||
Show Details…
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import styled from 'styled-components';
|
||||
|
||||
const HelpText = styled.p`
|
||||
margin-top: 0;
|
||||
color: ${props => props.theme.slateDark};
|
||||
color: ${props => props.theme.textSecondary};
|
||||
font-size: ${props => (props.small ? '13px' : 'inherit')};
|
||||
`;
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ function Highlight({
|
||||
|
||||
const Mark = styled.mark`
|
||||
background: ${props => props.theme.yellow};
|
||||
border-radius: 2px;
|
||||
padding: 0 4px;
|
||||
`;
|
||||
|
||||
export default Highlight;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { observable } from 'mobx';
|
||||
import styled from 'styled-components';
|
||||
import VisuallyHidden from 'components/VisuallyHidden';
|
||||
import Flex from 'shared/components/Flex';
|
||||
|
||||
const RealTextarea = styled.textarea`
|
||||
@@ -9,10 +12,11 @@ const RealTextarea = styled.textarea`
|
||||
padding: 8px 12px;
|
||||
outline: none;
|
||||
background: none;
|
||||
color: ${props => props.theme.text};
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${props => props.theme.slate};
|
||||
color: ${props => props.theme.placeholder};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -22,10 +26,11 @@ const RealInput = styled.input`
|
||||
padding: 8px 12px;
|
||||
outline: none;
|
||||
background: none;
|
||||
color: ${props => props.theme.text};
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${props => props.theme.slate};
|
||||
color: ${props => props.theme.placeholder};
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
@@ -34,9 +39,10 @@ const RealInput = styled.input`
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
flex: ${props => (props.flex ? '1' : '0')};
|
||||
max-width: ${props => (props.short ? '350px' : '100%')};
|
||||
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : '0')};
|
||||
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : 'auto')};
|
||||
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : 'initial')};
|
||||
`;
|
||||
|
||||
export const Outline = styled(Flex)`
|
||||
@@ -46,13 +52,14 @@ export const Outline = styled(Flex)`
|
||||
color: inherit;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: ${props => (props.hasError ? 'red' : props.theme.slateLight)};
|
||||
border-color: ${props =>
|
||||
props.hasError
|
||||
? 'red'
|
||||
: props.focused
|
||||
? props.theme.inputBorderFocused
|
||||
: props.theme.inputBorder};
|
||||
border-radius: 4px;
|
||||
font-weight: normal;
|
||||
|
||||
&:focus {
|
||||
border-color: ${props => props.theme.slate};
|
||||
}
|
||||
`;
|
||||
|
||||
export const LabelText = styled.div`
|
||||
@@ -65,29 +72,58 @@ export type Props = {
|
||||
value?: string,
|
||||
label?: string,
|
||||
className?: string,
|
||||
labelHidden?: boolean,
|
||||
flex?: boolean,
|
||||
short?: boolean,
|
||||
};
|
||||
|
||||
export default function Input({
|
||||
type = 'text',
|
||||
label,
|
||||
className,
|
||||
short,
|
||||
...rest
|
||||
}: Props) {
|
||||
const InputComponent = type === 'textarea' ? RealTextarea : RealInput;
|
||||
@observer
|
||||
class Input extends React.Component<Props> {
|
||||
@observable focused: boolean = false;
|
||||
|
||||
return (
|
||||
<Wrapper className={className} short={short}>
|
||||
<label>
|
||||
{label && <LabelText>{label}</LabelText>}
|
||||
<Outline>
|
||||
<InputComponent
|
||||
type={type === 'textarea' ? undefined : type}
|
||||
{...rest}
|
||||
/>
|
||||
</Outline>
|
||||
</label>
|
||||
</Wrapper>
|
||||
);
|
||||
handleBlur = () => {
|
||||
this.focused = false;
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
this.focused = true;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
type = 'text',
|
||||
label,
|
||||
className,
|
||||
short,
|
||||
flex,
|
||||
labelHidden,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
const InputComponent = type === 'textarea' ? RealTextarea : RealInput;
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
|
||||
return (
|
||||
<Wrapper className={className} short={short} flex={flex}>
|
||||
<label>
|
||||
{label &&
|
||||
(labelHidden ? (
|
||||
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
<Outline focused={this.focused}>
|
||||
<InputComponent
|
||||
onBlur={this.handleBlur}
|
||||
onFocus={this.handleFocus}
|
||||
type={type === 'textarea' ? undefined : type}
|
||||
{...rest}
|
||||
/>
|
||||
</Outline>
|
||||
</label>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Input;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { observer } from 'mobx-react';
|
||||
import styled, { withTheme } from 'styled-components';
|
||||
import Input, { LabelText, Outline } from 'components/Input';
|
||||
|
||||
type Props = {
|
||||
@@ -11,18 +10,25 @@ type Props = {
|
||||
minHeight?: number,
|
||||
maxHeight?: number,
|
||||
readOnly?: boolean,
|
||||
history: *,
|
||||
ui: *,
|
||||
};
|
||||
|
||||
@observer
|
||||
class InputRich extends React.Component<Props> {
|
||||
@observable editorComponent;
|
||||
@observable editorComponent: React.ComponentType<any>;
|
||||
@observable focused: boolean = false;
|
||||
|
||||
componentDidMount() {
|
||||
this.loadEditor();
|
||||
}
|
||||
|
||||
handleBlur = () => {
|
||||
this.focused = false;
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
this.focused = true;
|
||||
};
|
||||
|
||||
loadEditor = async () => {
|
||||
const EditorImport = await import('./Editor');
|
||||
this.editorComponent = EditorImport.default;
|
||||
@@ -36,8 +42,16 @@ class InputRich extends React.Component<Props> {
|
||||
<React.Fragment>
|
||||
<LabelText>{label}</LabelText>
|
||||
{Editor ? (
|
||||
<StyledOutline maxHeight={maxHeight} minHeight={minHeight}>
|
||||
<Editor {...rest} />
|
||||
<StyledOutline
|
||||
maxHeight={maxHeight}
|
||||
minHeight={minHeight}
|
||||
focused={this.focused}
|
||||
>
|
||||
<Editor
|
||||
onBlur={this.handleBlur}
|
||||
onFocus={this.handleFocus}
|
||||
{...rest}
|
||||
/>
|
||||
</StyledOutline>
|
||||
) : (
|
||||
<Input
|
||||
@@ -63,4 +77,4 @@ const StyledOutline = styled(Outline)`
|
||||
}
|
||||
`;
|
||||
|
||||
export default inject('ui')(withRouter(InputRich));
|
||||
export default withTheme(InputRich);
|
||||
|
||||
@@ -7,7 +7,7 @@ const Key = styled.kbd`
|
||||
font: 11px 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier,
|
||||
monospace;
|
||||
line-height: 10px;
|
||||
color: ${props => props.theme.text};
|
||||
color: ${props => props.theme.almostBlack};
|
||||
vertical-align: middle;
|
||||
background-color: ${props => props.theme.smokeLight};
|
||||
border: solid 1px ${props => props.theme.slateLight};
|
||||
|
||||
@@ -21,7 +21,7 @@ export const Label = styled(Flex)`
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
color: #9fa6ab;
|
||||
color: ${props => props.theme.textTertiary};
|
||||
letter-spacing: 0.04em;
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { Switch, Route, withRouter } from 'react-router-dom';
|
||||
import type { Location } from 'react-router-dom';
|
||||
import { Switch, Route, Redirect } from 'react-router-dom';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import styled from 'styled-components';
|
||||
import styled, { withTheme } from 'styled-components';
|
||||
import breakpoint from 'styled-components-breakpoint';
|
||||
import { observable } from 'mobx';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import keydown from 'react-keydown';
|
||||
import Analytics from 'components/Analytics';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { documentEditUrl, homeUrl, searchUrl } from 'utils/routeHelpers';
|
||||
import {
|
||||
homeUrl,
|
||||
searchUrl,
|
||||
matchDocumentSlug as slug,
|
||||
} from 'utils/routeHelpers';
|
||||
|
||||
import { LoadingIndicatorBar } from 'components/LoadingIndicator';
|
||||
import { GlobalStyles } from 'components/DropToImport';
|
||||
import Sidebar from 'components/Sidebar';
|
||||
import SettingsSidebar from 'components/Sidebar/Settings';
|
||||
import Modals from 'components/Modals';
|
||||
import DocumentHistory from 'components/DocumentHistory';
|
||||
import Modal from 'components/Modal';
|
||||
import KeyboardShortcuts from 'scenes/KeyboardShortcuts';
|
||||
import ErrorSuspended from 'scenes/ErrorSuspended';
|
||||
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import UiStore from 'stores/UiStore';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
|
||||
type Props = {
|
||||
history: Object,
|
||||
location: Location,
|
||||
documents: DocumentsStore,
|
||||
children?: ?React.Node,
|
||||
actions?: ?React.Node,
|
||||
@@ -31,37 +36,51 @@ type Props = {
|
||||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
notifications?: React.Node,
|
||||
theme: Object,
|
||||
};
|
||||
|
||||
@observer
|
||||
class Layout extends React.Component<Props> {
|
||||
scrollable: ?HTMLDivElement;
|
||||
@observable redirectTo: ?string;
|
||||
@observable keyboardShortcutsOpen: boolean = false;
|
||||
|
||||
componentWillMount() {
|
||||
this.updateBackground();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.updateBackground();
|
||||
|
||||
if (this.redirectTo) {
|
||||
this.redirectTo = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@keydown('shift+/')
|
||||
handleOpenKeyboardShortcuts() {
|
||||
this.keyboardShortcutsOpen = true;
|
||||
}
|
||||
|
||||
handleCloseKeyboardShortcuts = () => {
|
||||
this.keyboardShortcutsOpen = false;
|
||||
};
|
||||
|
||||
updateBackground() {
|
||||
// ensure the wider page color always matches the theme
|
||||
window.document.body.style.background = this.props.theme.background;
|
||||
}
|
||||
|
||||
@keydown(['/', 't', 'meta+k'])
|
||||
goToSearch(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.history.push(searchUrl());
|
||||
this.redirectTo = searchUrl();
|
||||
}
|
||||
|
||||
@keydown('d')
|
||||
goToDashboard() {
|
||||
this.props.history.push(homeUrl());
|
||||
}
|
||||
|
||||
@keydown('e')
|
||||
goToEdit(ev) {
|
||||
const activeDocument = this.props.documents.active;
|
||||
if (!activeDocument) return;
|
||||
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.history.push(documentEditUrl(activeDocument));
|
||||
}
|
||||
|
||||
@keydown('shift+/')
|
||||
openKeyboardShortcuts() {
|
||||
this.props.ui.setActiveModal('keyboard-shortcuts');
|
||||
this.redirectTo = homeUrl();
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -70,6 +89,7 @@ class Layout extends React.Component<Props> {
|
||||
const showSidebar = auth.authenticated && user && team;
|
||||
|
||||
if (auth.isSuspended) return <ErrorSuspended />;
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
return (
|
||||
<Container column auto>
|
||||
@@ -85,7 +105,7 @@ class Layout extends React.Component<Props> {
|
||||
{this.props.ui.progressBarVisible && <LoadingIndicatorBar />}
|
||||
{this.props.notifications}
|
||||
|
||||
<Flex auto>
|
||||
<Container auto>
|
||||
{showSidebar && (
|
||||
<Switch>
|
||||
<Route path="/settings" component={SettingsSidebar} />
|
||||
@@ -96,17 +116,34 @@ class Layout extends React.Component<Props> {
|
||||
<Content auto justify="center" editMode={ui.editMode}>
|
||||
{this.props.children}
|
||||
</Content>
|
||||
</Flex>
|
||||
|
||||
<Switch>
|
||||
<Route
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={DocumentHistory}
|
||||
/>
|
||||
</Switch>
|
||||
</Container>
|
||||
<Modals ui={ui} />
|
||||
<Modal
|
||||
isOpen={this.keyboardShortcutsOpen}
|
||||
onRequestClose={this.handleCloseKeyboardShortcuts}
|
||||
title="Keyboard shortcuts"
|
||||
>
|
||||
<KeyboardShortcuts />
|
||||
</Modal>
|
||||
<GlobalStyles />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Container = styled(Flex)`
|
||||
background: ${props => props.theme.background};
|
||||
transition: ${props => props.theme.backgroundTransition};
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100vw;
|
||||
min-height: 100%;
|
||||
`;
|
||||
|
||||
const Content = styled(Flex)`
|
||||
@@ -122,4 +159,4 @@ const Content = styled(Flex)`
|
||||
`};
|
||||
`;
|
||||
|
||||
export default withRouter(inject('auth', 'ui', 'documents')(Layout));
|
||||
export default inject('auth', 'ui', 'documents')(withTheme(Layout));
|
||||
|
||||
@@ -5,7 +5,7 @@ import Flex from 'shared/components/Flex';
|
||||
|
||||
type Props = {
|
||||
image?: React.Node,
|
||||
title: string,
|
||||
title: React.Node,
|
||||
subtitle?: React.Node,
|
||||
actions?: React.Node,
|
||||
};
|
||||
@@ -29,7 +29,7 @@ const Wrapper = styled.li`
|
||||
display: flex;
|
||||
padding: ${props => (props.compact ? '8px' : '12px')} 0;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid ${props => props.theme.smokeDark};
|
||||
border-bottom: 1px solid ${props => props.theme.divider};
|
||||
`;
|
||||
|
||||
const Image = styled(Flex)`
|
||||
@@ -37,6 +37,7 @@ const Image = styled(Flex)`
|
||||
max-height: 40px;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Heading = styled.p`
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import UiStore from 'stores/UiStore';
|
||||
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
};
|
||||
|
||||
@observer
|
||||
class LoadingIndicator extends React.Component<*> {
|
||||
class LoadingIndicator extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
this.props.ui.enableProgressBar();
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ import { pulsate } from 'shared/styles/animations';
|
||||
import { randomInteger } from 'shared/random';
|
||||
import Flex from 'shared/components/Flex';
|
||||
|
||||
class Mask extends React.Component<*> {
|
||||
type Props = {
|
||||
header?: boolean,
|
||||
height?: number,
|
||||
};
|
||||
|
||||
class Mask extends React.Component<Props> {
|
||||
width: number;
|
||||
|
||||
shouldComponentUpdate() {
|
||||
@@ -25,7 +30,7 @@ const Redacted = styled(Flex)`
|
||||
width: ${props => (props.header ? props.width / 2 : props.width)}%;
|
||||
height: ${props => (props.height ? props.height : props.header ? 24 : 18)}px;
|
||||
margin-bottom: 6px;
|
||||
background-color: ${props => props.theme.smokeDark};
|
||||
background-color: ${props => props.theme.divider};
|
||||
animation: ${pulsate} 1.3s infinite;
|
||||
|
||||
&:last-child {
|
||||
|
||||
@@ -4,19 +4,24 @@ import { observer } from 'mobx-react';
|
||||
import styled, { createGlobalStyle } from 'styled-components';
|
||||
import breakpoint from 'styled-components-breakpoint';
|
||||
import ReactModal from 'react-modal';
|
||||
import { transparentize } from 'polished';
|
||||
import { CloseIcon } from 'outline-icons';
|
||||
import { fadeAndScaleIn } from 'shared/styles/animations';
|
||||
import Flex from 'shared/components/Flex';
|
||||
|
||||
ReactModal.setAppElement('#root');
|
||||
|
||||
type Props = {
|
||||
children?: React.Node,
|
||||
isOpen: boolean,
|
||||
title?: string,
|
||||
onRequestClose: () => *,
|
||||
onRequestClose: () => void,
|
||||
};
|
||||
|
||||
const GlobalStyles = createGlobalStyle`
|
||||
.ReactModal__Overlay {
|
||||
background-color: ${props =>
|
||||
transparentize(0.25, props.theme.background)} !important;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@@ -46,7 +51,7 @@ const Modal = ({
|
||||
<Content column>
|
||||
{title && <h1>{title}</h1>}
|
||||
<Close onClick={onRequestClose}>
|
||||
<CloseIcon size={40} />
|
||||
<CloseIcon size={40} color="currentColor" />
|
||||
<Esc>esc</Esc>
|
||||
</Close>
|
||||
{children}
|
||||
@@ -76,7 +81,8 @@ const StyledModal = styled(ReactModal)`
|
||||
align-items: flex-start;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
background: white;
|
||||
background: ${props => props.theme.background};
|
||||
transition: ${props => props.theme.backgroundTransition};
|
||||
padding: 13vh 2rem 2rem;
|
||||
outline: none;
|
||||
`;
|
||||
@@ -92,7 +98,7 @@ const Close = styled.a`
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
opacity: 0.5;
|
||||
opacity: 0.75;
|
||||
color: ${props => props.theme.text};
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -9,7 +9,6 @@ import CollectionDelete from 'scenes/CollectionDelete';
|
||||
import CollectionExport from 'scenes/CollectionExport';
|
||||
import DocumentDelete from 'scenes/DocumentDelete';
|
||||
import DocumentShare from 'scenes/DocumentShare';
|
||||
import KeyboardShortcuts from 'scenes/KeyboardShortcuts';
|
||||
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
@@ -55,9 +54,6 @@ class Modals extends React.Component<Props> {
|
||||
<Modal name="document-delete" title="Delete document">
|
||||
<DocumentDelete onSubmit={this.handleClose} />
|
||||
</Modal>
|
||||
<Modal name="keyboard-shortcuts" title="Keyboard shortcuts">
|
||||
<KeyboardShortcuts />
|
||||
</Modal>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
26
app/components/NudeButton.js
Normal file
26
app/components/NudeButton.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { lighten } from 'polished';
|
||||
|
||||
const Button = styled.button`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
line-height: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
|
||||
&:focus {
|
||||
transition-duration: 0.05s;
|
||||
box-shadow: ${props => lighten(0.4, props.theme.buttonBackground)} 0px 0px
|
||||
0px 3px;
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
// $FlowFixMe - need to upgrade to get forwardRef
|
||||
export default React.forwardRef((props, ref) => (
|
||||
<Button {...props} ref={ref} />
|
||||
));
|
||||
@@ -10,21 +10,24 @@ import DocumentList from 'components/DocumentList';
|
||||
import { ListPlaceholder } from 'components/LoadingPlaceholder';
|
||||
|
||||
type Props = {
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
documents: Document[],
|
||||
fetch: (options: ?Object) => Promise<*>,
|
||||
fetch: (options: ?Object) => Promise<void>,
|
||||
options?: Object,
|
||||
heading?: React.Node,
|
||||
empty?: React.Node,
|
||||
};
|
||||
|
||||
@observer
|
||||
class PaginatedDocumentList extends React.Component<Props> {
|
||||
isInitiallyLoaded: boolean = false;
|
||||
@observable isLoaded: boolean = false;
|
||||
@observable isFetchingMore: boolean = false;
|
||||
@observable isFetching: boolean = false;
|
||||
@observable offset: number = 0;
|
||||
@observable allowLoadMore: boolean = true;
|
||||
|
||||
componentDidMount() {
|
||||
this.isInitiallyLoaded = !!this.props.documents.length;
|
||||
this.fetchResults();
|
||||
}
|
||||
|
||||
@@ -55,31 +58,39 @@ class PaginatedDocumentList extends React.Component<Props> {
|
||||
|
||||
this.isLoaded = true;
|
||||
this.isFetching = false;
|
||||
this.isFetchingMore = false;
|
||||
};
|
||||
|
||||
@action
|
||||
loadMoreResults = async () => {
|
||||
// Don't paginate if there aren't more results or we’re in the middle of fetching
|
||||
if (!this.allowLoadMore || this.isFetching) return;
|
||||
|
||||
this.isFetchingMore = true;
|
||||
await this.fetchResults();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { showCollection, showPublished, documents } = this.props;
|
||||
const { empty, heading, documents, fetch, options, ...rest } = this.props;
|
||||
const showLoading =
|
||||
this.isFetching && !this.isFetchingMore && !this.isInitiallyLoaded;
|
||||
const showEmpty = !documents.length || showLoading;
|
||||
const showList = (this.isLoaded || this.isInitiallyLoaded) && !showLoading;
|
||||
|
||||
return this.isLoaded || documents.length ? (
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DocumentList
|
||||
documents={documents}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
/>
|
||||
{this.allowLoadMore && (
|
||||
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
|
||||
{showEmpty && empty}
|
||||
{showList && (
|
||||
<React.Fragment>
|
||||
{heading}
|
||||
<DocumentList documents={documents} {...rest} />
|
||||
{this.allowLoadMore && (
|
||||
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
{showLoading && <ListPlaceholder count={5} />}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<ListPlaceholder count={5} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,86 +2,58 @@
|
||||
import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import styled from 'styled-components';
|
||||
import { GoToIcon } from 'outline-icons';
|
||||
import { GoToIcon, CollectionIcon, PrivateCollectionIcon } from 'outline-icons';
|
||||
import Flex from 'shared/components/Flex';
|
||||
|
||||
import Document from 'models/Document';
|
||||
import Collection from 'models/Collection';
|
||||
import type { DocumentPath } from 'stores/CollectionsStore';
|
||||
|
||||
const StyledGoToIcon = styled(GoToIcon)``;
|
||||
|
||||
const ResultWrapper = styled.div`
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
|
||||
color: ${props => props.theme.text};
|
||||
cursor: default;
|
||||
`;
|
||||
|
||||
const ResultWrapperLink = styled(ResultWrapper.withComponent('a'))`
|
||||
height: 32px;
|
||||
padding-top: 3px;
|
||||
padding-left: 5px;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
margin-left: 0px;
|
||||
border-radius: 2px;
|
||||
background: ${props => props.theme.black};
|
||||
color: ${props => props.theme.smokeLight};
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
|
||||
${StyledGoToIcon} {
|
||||
fill: ${props => props.theme.white};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
result: DocumentPath,
|
||||
document?: Document,
|
||||
onSuccess?: *,
|
||||
ref?: *,
|
||||
document?: ?Document,
|
||||
collection: ?Collection,
|
||||
onSuccess?: () => void,
|
||||
ref?: (?React.ElementRef<'div'>) => void,
|
||||
};
|
||||
|
||||
@observer
|
||||
class PathToDocument extends React.Component<Props> {
|
||||
handleClick = async (ev: SyntheticEvent<*>) => {
|
||||
handleClick = async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { document, result, onSuccess } = this.props;
|
||||
if (!document) return;
|
||||
|
||||
if (result.type === 'document') {
|
||||
await document.move(result.id);
|
||||
} else if (
|
||||
result.type === 'collection' &&
|
||||
result.id === document.collection.id
|
||||
) {
|
||||
await document.move(null);
|
||||
await document.move(result.collectionId, result.id);
|
||||
} else {
|
||||
throw new Error('Not implemented yet');
|
||||
await document.move(result.collectionId, null);
|
||||
}
|
||||
|
||||
if (onSuccess) onSuccess();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { result, document, ref } = this.props;
|
||||
const { result, collection, document, ref } = this.props;
|
||||
const Component = document ? ResultWrapperLink : ResultWrapper;
|
||||
|
||||
if (!result) return <div />;
|
||||
|
||||
return (
|
||||
<Component ref={ref} onClick={this.handleClick} href="" selectable>
|
||||
{collection &&
|
||||
(collection.private ? (
|
||||
<PrivateCollectionIcon color={collection.color} />
|
||||
) : (
|
||||
<CollectionIcon color={collection.color} />
|
||||
))}
|
||||
{result.path
|
||||
.map(doc => <span key={doc.id}>{doc.title}</span>)
|
||||
.map(doc => <Title key={doc.id}>{doc.title}</Title>)
|
||||
.reduce((prev, curr) => [prev, <StyledGoToIcon />, curr])}
|
||||
{document && (
|
||||
<Flex>
|
||||
{' '}
|
||||
<StyledGoToIcon /> {document.title}
|
||||
<StyledGoToIcon /> <Title>{document.title}</Title>
|
||||
</Flex>
|
||||
)}
|
||||
</Component>
|
||||
@@ -89,4 +61,37 @@ class PathToDocument extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
const Title = styled.span`
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const StyledGoToIcon = styled(GoToIcon)`
|
||||
opacity: 0.25;
|
||||
`;
|
||||
|
||||
const ResultWrapper = styled.div`
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
margin-left: -4px;
|
||||
user-select: none;
|
||||
|
||||
color: ${props => props.theme.text};
|
||||
cursor: default;
|
||||
`;
|
||||
|
||||
const ResultWrapperLink = styled(ResultWrapper.withComponent('a'))`
|
||||
margin: 0 -8px;
|
||||
padding: 8px 4px;
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background: ${props => props.theme.listItemHoverBackground};
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default PathToDocument;
|
||||
|
||||
102
app/components/PublishingInfo.js
Normal file
102
app/components/PublishingInfo.js
Normal file
@@ -0,0 +1,102 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { inject } from 'mobx-react';
|
||||
import styled from 'styled-components';
|
||||
import Document from 'models/Document';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import Time from 'shared/components/Time';
|
||||
import Breadcrumb from 'shared/components/Breadcrumb';
|
||||
import CollectionsStore from 'stores/CollectionsStore';
|
||||
|
||||
const Container = styled(Flex)`
|
||||
color: ${props => props.theme.textTertiary};
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Modified = styled.span`
|
||||
color: ${props =>
|
||||
props.highlight ? props.theme.text : props.theme.textTertiary};
|
||||
font-weight: ${props => (props.highlight ? '600' : '400')};
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
collections: CollectionsStore,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
document: Document,
|
||||
views?: number,
|
||||
};
|
||||
|
||||
function PublishingInfo({
|
||||
collections,
|
||||
showPublished,
|
||||
showCollection,
|
||||
document,
|
||||
}: Props) {
|
||||
const {
|
||||
modifiedSinceViewed,
|
||||
updatedAt,
|
||||
updatedBy,
|
||||
publishedAt,
|
||||
archivedAt,
|
||||
deletedAt,
|
||||
isDraft,
|
||||
} = document;
|
||||
|
||||
const neverUpdated = publishedAt === updatedAt;
|
||||
let content;
|
||||
|
||||
if (deletedAt) {
|
||||
content = (
|
||||
<span>
|
||||
deleted <Time dateTime={deletedAt} /> ago
|
||||
</span>
|
||||
);
|
||||
} else if (archivedAt) {
|
||||
content = (
|
||||
<span>
|
||||
archived <Time dateTime={archivedAt} /> ago
|
||||
</span>
|
||||
);
|
||||
} else if (publishedAt && (neverUpdated || showPublished)) {
|
||||
content = (
|
||||
<span>
|
||||
published <Time dateTime={publishedAt} /> ago
|
||||
</span>
|
||||
);
|
||||
} else if (isDraft) {
|
||||
content = (
|
||||
<span>
|
||||
saved <Time dateTime={updatedAt} /> ago
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<Modified highlight={modifiedSinceViewed}>
|
||||
updated <Time dateTime={updatedAt} /> ago
|
||||
</Modified>
|
||||
);
|
||||
}
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
|
||||
return (
|
||||
<Container align="center">
|
||||
{updatedBy.name}
|
||||
{content}
|
||||
{showCollection &&
|
||||
collection && (
|
||||
<span>
|
||||
in
|
||||
<strong>
|
||||
{isDraft ? 'Drafts' : <Breadcrumb document={document} onlyText />}
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default inject('collections')(PublishingInfo);
|
||||
@@ -6,7 +6,7 @@ import UiStore from 'stores/UiStore';
|
||||
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
component: *,
|
||||
component: React.ComponentType<any>,
|
||||
};
|
||||
|
||||
class RouteSidebarHidden extends React.Component<Props> {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
class ScrollToAnchor extends React.Component<*> {
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.location.hash === prevProps.location.hash) return;
|
||||
if (window.location.hash === '') return;
|
||||
|
||||
// Delay on timeout to ensure that the DOM is updated first
|
||||
setImmediate(() => {
|
||||
const id = window.location.hash.replace('#', '');
|
||||
const element = document.getElementById(id);
|
||||
if (element) element.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(ScrollToAnchor);
|
||||
@@ -2,8 +2,14 @@
|
||||
// based on: https://reacttraining.com/react-router/web/guides/scroll-restoration
|
||||
import * as React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import type { Location } from 'react-router-dom';
|
||||
|
||||
class ScrollToTop extends React.Component<*> {
|
||||
type Props = {
|
||||
location: Location,
|
||||
children: React.Node,
|
||||
};
|
||||
|
||||
class ScrollToTop extends React.Component<Props> {
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.location.pathname === prevProps.location.pathname) return;
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ type Props = {
|
||||
class Scrollable extends React.Component<Props> {
|
||||
@observable shadow: boolean = false;
|
||||
|
||||
handleScroll = (ev: SyntheticMouseEvent<*>) => {
|
||||
handleScroll = (ev: SyntheticMouseEvent<HTMLDivElement>) => {
|
||||
this.shadow = !!(this.props.shadow && ev.currentTarget.scrollTop > 0);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import type { Location } from 'react-router-dom';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import { HomeIcon, EditIcon, SearchIcon, StarredIcon } from 'outline-icons';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
ArchiveIcon,
|
||||
HomeIcon,
|
||||
EditIcon,
|
||||
SearchIcon,
|
||||
StarredIcon,
|
||||
PlusIcon,
|
||||
} from 'outline-icons';
|
||||
|
||||
import Flex from 'shared/components/Flex';
|
||||
import Modal from 'components/Modal';
|
||||
import Invite from 'scenes/Invite';
|
||||
import AccountMenu from 'menus/AccountMenu';
|
||||
import Sidebar from './Sidebar';
|
||||
import Scrollable from 'components/Scrollable';
|
||||
@@ -17,18 +25,21 @@ import Bubble from './components/Bubble';
|
||||
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
import PoliciesStore from 'stores/PoliciesStore';
|
||||
import UiStore from 'stores/UiStore';
|
||||
import { observable } from 'mobx';
|
||||
|
||||
type Props = {
|
||||
history: Object,
|
||||
location: Location,
|
||||
auth: AuthStore,
|
||||
documents: DocumentsStore,
|
||||
policies: PoliciesStore,
|
||||
ui: UiStore,
|
||||
};
|
||||
|
||||
@observer
|
||||
class MainSidebar extends React.Component<Props> {
|
||||
@observable inviteModalOpen: boolean = false;
|
||||
|
||||
componentDidMount() {
|
||||
this.props.documents.fetchDrafts();
|
||||
}
|
||||
@@ -37,12 +48,21 @@ class MainSidebar extends React.Component<Props> {
|
||||
this.props.ui.setActiveModal('collection-new');
|
||||
};
|
||||
|
||||
handleInviteModalOpen = () => {
|
||||
this.inviteModalOpen = true;
|
||||
};
|
||||
|
||||
handleInviteModalClose = () => {
|
||||
this.inviteModalOpen = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { auth, documents } = this.props;
|
||||
const { auth, documents, policies } = this.props;
|
||||
const { user, team } = auth;
|
||||
if (!user || !team) return null;
|
||||
|
||||
const draftDocumentsCount = documents.drafts.length;
|
||||
const can = policies.abilties(team.id);
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
@@ -65,7 +85,15 @@ class MainSidebar extends React.Component<Props> {
|
||||
exact={false}
|
||||
label="Home"
|
||||
/>
|
||||
<SidebarLink to="/search" icon={<SearchIcon />} label="Search" />
|
||||
<SidebarLink
|
||||
to={{
|
||||
pathname: '/search',
|
||||
state: { fromMenu: true },
|
||||
}}
|
||||
icon={<SearchIcon />}
|
||||
label="Search"
|
||||
exact={false}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/starred"
|
||||
icon={<StarredIcon />}
|
||||
@@ -74,31 +102,56 @@ class MainSidebar extends React.Component<Props> {
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/drafts"
|
||||
icon={
|
||||
draftDocumentsCount > 0 && draftDocumentsCount < 10 ? (
|
||||
<Bubble count={draftDocumentsCount} />
|
||||
) : (
|
||||
<EditIcon />
|
||||
)
|
||||
icon={<EditIcon />}
|
||||
label={
|
||||
<Drafts align="center">
|
||||
Drafts{draftDocumentsCount > 0 && (
|
||||
<Bubble count={draftDocumentsCount} />
|
||||
)}
|
||||
</Drafts>
|
||||
}
|
||||
label="Drafts"
|
||||
active={
|
||||
documents.active ? !documents.active.publishedAt : undefined
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<Collections
|
||||
history={this.props.history}
|
||||
location={this.props.location}
|
||||
onCreateCollection={this.handleCreateCollection}
|
||||
<Collections onCreateCollection={this.handleCreateCollection} />
|
||||
</Section>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to="/archive"
|
||||
icon={<ArchiveIcon />}
|
||||
exact={false}
|
||||
label="Archive"
|
||||
active={
|
||||
documents.active ? documents.active.isArchived : undefined
|
||||
}
|
||||
/>
|
||||
{can.invite && (
|
||||
<SidebarLink
|
||||
onClick={this.handleInviteModalOpen}
|
||||
icon={<PlusIcon />}
|
||||
label="Invite people…"
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Scrollable>
|
||||
</Flex>
|
||||
<Modal
|
||||
title="Invite people"
|
||||
onRequestClose={this.handleInviteModalClose}
|
||||
isOpen={this.inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={this.handleInviteModalClose} />
|
||||
</Modal>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(inject('documents', 'auth', 'ui')(MainSidebar));
|
||||
const Drafts = styled(Flex)`
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export default inject('documents', 'policies', 'auth', 'ui')(MainSidebar);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import type { RouterHistory } from 'react-router-dom';
|
||||
import {
|
||||
DocumentIcon,
|
||||
EmailIcon,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
UserIcon,
|
||||
LinkIcon,
|
||||
TeamIcon,
|
||||
BulletedListIcon,
|
||||
} from 'outline-icons';
|
||||
import ZapierIcon from './icons/Zapier';
|
||||
import SlackIcon from './icons/Slack';
|
||||
@@ -21,10 +23,12 @@ import Section from './components/Section';
|
||||
import Header from './components/Header';
|
||||
import SidebarLink from './components/SidebarLink';
|
||||
import HeaderBlock from './components/HeaderBlock';
|
||||
import PoliciesStore from 'stores/PoliciesStore';
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
|
||||
type Props = {
|
||||
history: Object,
|
||||
history: RouterHistory,
|
||||
policies: PoliciesStore,
|
||||
auth: AuthStore,
|
||||
};
|
||||
|
||||
@@ -35,8 +39,11 @@ class SettingsSidebar extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { team, user } = this.props.auth;
|
||||
if (!team || !user) return null;
|
||||
const { policies, auth } = this.props;
|
||||
const { team } = auth;
|
||||
if (!team) return null;
|
||||
|
||||
const can = policies.abilties(team.id);
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
@@ -69,14 +76,14 @@ class SettingsSidebar extends React.Component<Props> {
|
||||
</Section>
|
||||
<Section>
|
||||
<Header>Team</Header>
|
||||
{user.isAdmin && (
|
||||
{can.update && (
|
||||
<SidebarLink
|
||||
to="/settings/details"
|
||||
icon={<TeamIcon />}
|
||||
label="Details"
|
||||
/>
|
||||
)}
|
||||
{user.isAdmin && (
|
||||
{can.update && (
|
||||
<SidebarLink
|
||||
to="/settings/security"
|
||||
icon={<PadlockIcon />}
|
||||
@@ -94,7 +101,14 @@ class SettingsSidebar extends React.Component<Props> {
|
||||
icon={<LinkIcon />}
|
||||
label="Share Links"
|
||||
/>
|
||||
{user.isAdmin && (
|
||||
{can.auditLog && (
|
||||
<SidebarLink
|
||||
to="/settings/events"
|
||||
icon={<BulletedListIcon />}
|
||||
label="Audit Log"
|
||||
/>
|
||||
)}
|
||||
{can.export && (
|
||||
<SidebarLink
|
||||
to="/settings/export"
|
||||
icon={<DocumentIcon />}
|
||||
@@ -102,7 +116,7 @@ class SettingsSidebar extends React.Component<Props> {
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
{user.isAdmin && (
|
||||
{can.update && (
|
||||
<Section>
|
||||
<Header>Integrations</Header>
|
||||
<SidebarLink
|
||||
@@ -124,4 +138,4 @@ class SettingsSidebar extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default inject('auth')(SettingsSidebar);
|
||||
export default inject('auth', 'policies')(SettingsSidebar);
|
||||
|
||||
@@ -6,13 +6,14 @@ import styled from 'styled-components';
|
||||
import breakpoint from 'styled-components-breakpoint';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import { CloseIcon, MenuIcon } from 'outline-icons';
|
||||
import Fade from 'components/Fade';
|
||||
import Flex from 'shared/components/Flex';
|
||||
|
||||
import UiStore from 'stores/UiStore';
|
||||
|
||||
let firstRender = true;
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
history: Object,
|
||||
location: Location,
|
||||
ui: UiStore,
|
||||
};
|
||||
@@ -31,8 +32,7 @@ class Sidebar extends React.Component<Props> {
|
||||
|
||||
render() {
|
||||
const { children, ui } = this.props;
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<Container
|
||||
editMode={ui.editMode}
|
||||
mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
@@ -47,6 +47,14 @@ class Sidebar extends React.Component<Props> {
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
|
||||
// Fade in the sidebar on first render after page load
|
||||
if (firstRender) {
|
||||
firstRender = false;
|
||||
return <Fade>{content}</Fade>;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,8 +64,8 @@ const Container = styled(Flex)`
|
||||
bottom: 0;
|
||||
left: ${props => (props.editMode ? `-${props.theme.sidebarWidth}` : 0)};
|
||||
width: 100%;
|
||||
background: ${props => props.theme.smoke};
|
||||
transition: left 100ms ease-out;
|
||||
background: ${props => props.theme.sidebarBackground};
|
||||
transition: left 100ms ease-out, ${props => props.theme.backgroundTransition};
|
||||
margin-left: ${props => (props.mobileSidebarVisible ? 0 : '-100%')};
|
||||
z-index: 2;
|
||||
|
||||
@@ -69,7 +77,7 @@ const Container = styled(Flex)`
|
||||
&:before,
|
||||
&:after {
|
||||
content: '';
|
||||
background: ${props => props.theme.smoke};
|
||||
background: ${props => props.theme.sidebarBackground};
|
||||
position: absolute;
|
||||
top: -50vh;
|
||||
left: 0;
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { fadeAndScaleIn } from 'shared/styles/animations';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { bounceIn } from 'shared/styles/animations';
|
||||
|
||||
type Props = {
|
||||
count: number,
|
||||
};
|
||||
|
||||
const Bubble = ({ count }: Props) => {
|
||||
return (
|
||||
<Wrapper align="center" justify="center">
|
||||
<Count>{count}</Count>
|
||||
</Wrapper>
|
||||
);
|
||||
return <Count>{count}</Count>;
|
||||
};
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
const Count = styled.div`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
animation: ${bounceIn} 600ms;
|
||||
transform-origin: center center;
|
||||
border-radius: 100%;
|
||||
color: ${props => props.theme.white};
|
||||
background: ${props => props.theme.slateDark};
|
||||
@@ -31,10 +22,14 @@ const Count = styled.div`
|
||||
font-weight: 600;
|
||||
font-size: 9px;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
min-width: 16px;
|
||||
min-height: 16px;
|
||||
text-align: center;
|
||||
padding: 0 4px;
|
||||
margin-left: 8px;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
export default Bubble;
|
||||
|
||||
@@ -7,17 +7,18 @@ import Collection from 'models/Collection';
|
||||
import Document from 'models/Document';
|
||||
import CollectionMenu from 'menus/CollectionMenu';
|
||||
import UiStore from 'stores/UiStore';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
import SidebarLink from './SidebarLink';
|
||||
import DocumentLink from './DocumentLink';
|
||||
import DropToImport from 'components/DropToImport';
|
||||
import Flex from 'shared/components/Flex';
|
||||
|
||||
type Props = {
|
||||
history: Object,
|
||||
collection: Collection,
|
||||
ui: UiStore,
|
||||
documents: DocumentsStore,
|
||||
activeDocument: ?Document,
|
||||
prefetchDocument: (id: string) => *,
|
||||
prefetchDocument: (id: string) => Promise<void>,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -26,8 +27,8 @@ class CollectionLink extends React.Component<Props> {
|
||||
|
||||
render() {
|
||||
const {
|
||||
history,
|
||||
collection,
|
||||
documents,
|
||||
activeDocument,
|
||||
prefetchDocument,
|
||||
ui,
|
||||
@@ -37,7 +38,6 @@ class CollectionLink extends React.Component<Props> {
|
||||
return (
|
||||
<DropToImport
|
||||
key={collection.id}
|
||||
history={history}
|
||||
collectionId={collection.id}
|
||||
activeClassName="activeDropZone"
|
||||
>
|
||||
@@ -62,7 +62,7 @@ class CollectionLink extends React.Component<Props> {
|
||||
exact={false}
|
||||
menu={
|
||||
<CollectionMenu
|
||||
history={history}
|
||||
position="left"
|
||||
collection={collection}
|
||||
onOpen={() => (this.menuOpen = true)}
|
||||
onClose={() => (this.menuOpen = false)}
|
||||
@@ -70,11 +70,12 @@ class CollectionLink extends React.Component<Props> {
|
||||
}
|
||||
>
|
||||
<Flex column>
|
||||
{collection.documents.map(document => (
|
||||
{collection.documents.map(node => (
|
||||
<DocumentLink
|
||||
key={document.id}
|
||||
history={history}
|
||||
document={document}
|
||||
key={node.id}
|
||||
node={node}
|
||||
documents={documents}
|
||||
collection={collection}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
depth={1.5}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import type { Location } from 'react-router-dom';
|
||||
import { withRouter, type RouterHistory } from 'react-router-dom';
|
||||
import keydown from 'react-keydown';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { PlusIcon } from 'outline-icons';
|
||||
import { newDocumentUrl } from 'utils/routeHelpers';
|
||||
|
||||
import Header from './Header';
|
||||
import SidebarLink from './SidebarLink';
|
||||
@@ -15,8 +17,7 @@ import UiStore from 'stores/UiStore';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
|
||||
type Props = {
|
||||
history: Object,
|
||||
location: Location,
|
||||
history: RouterHistory,
|
||||
collections: CollectionsStore,
|
||||
documents: DocumentsStore,
|
||||
onCreateCollection: () => void,
|
||||
@@ -28,19 +29,31 @@ class Collections extends React.Component<Props> {
|
||||
isPreloaded: boolean = !!this.props.collections.orderedData.length;
|
||||
|
||||
componentDidMount() {
|
||||
this.props.collections.fetchPage({ limit: 100 });
|
||||
const { collections } = this.props;
|
||||
|
||||
if (!collections.isFetching && !collections.isLoaded) {
|
||||
collections.fetchPage({ limit: 100 });
|
||||
}
|
||||
}
|
||||
|
||||
@keydown('n')
|
||||
goToNewDocument() {
|
||||
const { activeCollectionId } = this.props.ui;
|
||||
if (!activeCollectionId) return;
|
||||
|
||||
this.props.history.push(newDocumentUrl(activeCollectionId));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { history, location, collections, ui, documents } = this.props;
|
||||
const { collections, ui, documents } = this.props;
|
||||
|
||||
const content = (
|
||||
<Flex column>
|
||||
<Header>Collections</Header>
|
||||
{collections.orderedData.map(collection => (
|
||||
<CollectionLink
|
||||
key={collection.id}
|
||||
history={history}
|
||||
location={location}
|
||||
documents={documents}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
@@ -62,4 +75,6 @@ class Collections extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default inject('collections', 'ui', 'documents')(Collections);
|
||||
export default inject('collections', 'ui', 'documents')(
|
||||
withRouter(Collections)
|
||||
);
|
||||
|
||||
@@ -1,81 +1,105 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { observable } from 'mobx';
|
||||
import styled from 'styled-components';
|
||||
import Document from 'models/Document';
|
||||
import DocumentMenu from 'menus/DocumentMenu';
|
||||
import SidebarLink from './SidebarLink';
|
||||
import DropToImport from 'components/DropToImport';
|
||||
import Fade from 'components/Fade';
|
||||
import Collection from 'models/Collection';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { type NavigationNode } from 'types';
|
||||
|
||||
type Props = {
|
||||
document: NavigationNode,
|
||||
history: Object,
|
||||
node: NavigationNode,
|
||||
documents: DocumentsStore,
|
||||
collection?: Collection,
|
||||
activeDocument: ?Document,
|
||||
activeDocumentRef?: (?HTMLElement) => *,
|
||||
activeDocumentRef?: (?HTMLElement) => void,
|
||||
prefetchDocument: (documentId: string) => Promise<void>,
|
||||
depth: number,
|
||||
};
|
||||
|
||||
@observer
|
||||
class DocumentLink extends React.Component<Props> {
|
||||
handleMouseEnter = (ev: SyntheticEvent<*>) => {
|
||||
const { document, prefetchDocument } = this.props;
|
||||
@observable menuOpen = false;
|
||||
|
||||
handleMouseEnter = (ev: SyntheticEvent<>) => {
|
||||
const { node, prefetchDocument } = this.props;
|
||||
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
prefetchDocument(document.id);
|
||||
prefetchDocument(node.id);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
document,
|
||||
node,
|
||||
documents,
|
||||
collection,
|
||||
activeDocument,
|
||||
activeDocumentRef,
|
||||
prefetchDocument,
|
||||
depth,
|
||||
history,
|
||||
} = this.props;
|
||||
|
||||
const isActiveDocument =
|
||||
activeDocument && activeDocument.id === document.id;
|
||||
const isActiveDocument = activeDocument && activeDocument.id === node.id;
|
||||
const showChildren = !!(
|
||||
activeDocument &&
|
||||
(activeDocument.pathToDocument
|
||||
collection &&
|
||||
(collection
|
||||
.pathToDocument(activeDocument)
|
||||
.map(entry => entry.id)
|
||||
.includes(document.id) ||
|
||||
.includes(node.id) ||
|
||||
isActiveDocument)
|
||||
);
|
||||
const hasChildren = !!document.children.length;
|
||||
const hasChildren = !!node.children.length;
|
||||
const document = documents.get(node.id);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
column
|
||||
key={document.id}
|
||||
key={node.id}
|
||||
ref={isActiveDocument ? activeDocumentRef : undefined}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
>
|
||||
<DropToImport
|
||||
history={history}
|
||||
documentId={document.id}
|
||||
activeClassName="activeDropZone"
|
||||
>
|
||||
<DropToImport documentId={node.id} activeClassName="activeDropZone">
|
||||
<SidebarLink
|
||||
to={{
|
||||
pathname: document.url,
|
||||
state: { title: document.title },
|
||||
pathname: node.url,
|
||||
state: { title: node.title },
|
||||
}}
|
||||
expanded={showChildren}
|
||||
label={document.title}
|
||||
label={node.title}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
menuOpen={this.menuOpen}
|
||||
menu={
|
||||
document ? (
|
||||
<Fade>
|
||||
<DocumentMenu
|
||||
position="left"
|
||||
document={document}
|
||||
onOpen={() => (this.menuOpen = true)}
|
||||
onClose={() => (this.menuOpen = false)}
|
||||
/>
|
||||
</Fade>
|
||||
) : (
|
||||
undefined
|
||||
)
|
||||
}
|
||||
>
|
||||
{hasChildren && (
|
||||
<DocumentChildren column>
|
||||
{document.children.map(childDocument => (
|
||||
{node.children.map(childNode => (
|
||||
<DocumentLink
|
||||
key={childDocument.id}
|
||||
history={history}
|
||||
document={childDocument}
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
node={childNode}
|
||||
documents={documents}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
depth={depth + 1}
|
||||
|
||||
@@ -6,7 +6,7 @@ const Header = styled(Flex)`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: ${props => props.theme.slateDark};
|
||||
color: ${props => props.theme.sidebarText};
|
||||
letter-spacing: 0.04em;
|
||||
margin: 4px 16px;
|
||||
`;
|
||||
|
||||
@@ -46,7 +46,7 @@ const Subheading = styled.div`
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
color: ${props => props.theme.slateDark};
|
||||
color: ${props => props.theme.sidebarText};
|
||||
`;
|
||||
|
||||
const TeamName = styled.div`
|
||||
|
||||
@@ -9,7 +9,7 @@ import Flex from 'shared/components/Flex';
|
||||
|
||||
type Props = {
|
||||
to?: string | Object,
|
||||
onClick?: (SyntheticEvent<*>) => *,
|
||||
onClick?: (SyntheticEvent<>) => void,
|
||||
children?: React.Node,
|
||||
icon?: React.Node,
|
||||
expanded?: boolean,
|
||||
@@ -32,13 +32,6 @@ class SidebarLink extends React.Component<Props> {
|
||||
paddingLeft: `${(this.props.depth || 0) * 16 + 16}px`,
|
||||
};
|
||||
|
||||
activeStyle = {
|
||||
color: this.props.theme.text,
|
||||
background: 'rgba(0, 0, 0, 0.05)',
|
||||
fontWeight: 600,
|
||||
...this.style,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.expanded) this.handleExpand();
|
||||
}
|
||||
@@ -50,7 +43,7 @@ class SidebarLink extends React.Component<Props> {
|
||||
}
|
||||
|
||||
@action
|
||||
handleClick = (ev: SyntheticEvent<*>) => {
|
||||
handleClick = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.expanded = !this.expanded;
|
||||
@@ -75,12 +68,18 @@ class SidebarLink extends React.Component<Props> {
|
||||
exact,
|
||||
} = this.props;
|
||||
const showDisclosure = !!children && !hideDisclosure;
|
||||
const activeStyle = {
|
||||
color: this.props.theme.text,
|
||||
background: this.props.theme.sidebarItemBackground,
|
||||
fontWeight: 600,
|
||||
...this.style,
|
||||
};
|
||||
|
||||
return (
|
||||
<Wrapper menuOpen={menuOpen} column>
|
||||
<Wrapper column>
|
||||
<StyledNavLink
|
||||
activeStyle={this.activeStyle}
|
||||
style={active ? this.activeStyle : this.style}
|
||||
activeStyle={activeStyle}
|
||||
style={active ? activeStyle : this.style}
|
||||
onClick={onClick}
|
||||
exact={exact !== false}
|
||||
to={to}
|
||||
@@ -93,9 +92,9 @@ class SidebarLink extends React.Component<Props> {
|
||||
)}
|
||||
{label}
|
||||
</Label>
|
||||
{menu && <Action menuOpen={menuOpen}>{menu}</Action>}
|
||||
</StyledNavLink>
|
||||
{this.expanded && children}
|
||||
{menu && <Action>{menu}</Action>}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -108,27 +107,12 @@ const IconWrapper = styled.span`
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
const StyledNavLink = styled(NavLink)`
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 4px 16px;
|
||||
border-radius: 4px;
|
||||
color: ${props => props.theme.slateDark};
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: ${props => props.theme.text};
|
||||
}
|
||||
`;
|
||||
|
||||
const Action = styled.span`
|
||||
display: ${props => (props.menuOpen ? 'inline' : 'none')};
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
color: ${props => props.theme.slate};
|
||||
color: ${props => props.theme.textTertiary};
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
@@ -141,11 +125,25 @@ const Action = styled.span`
|
||||
}
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
const StyledNavLink = styled(NavLink)`
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 4px 16px;
|
||||
border-radius: 4px;
|
||||
color: ${props => props.theme.sidebarText};
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
|
||||
> ${Action} {
|
||||
display: ${props => (props.menuOpen ? 'inline' : 'none')};
|
||||
&:hover {
|
||||
color: ${props => props.theme.text};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
color: ${props => props.theme.text};
|
||||
background: ${props => props.theme.sidebarItemBackground};
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@@ -155,6 +153,10 @@ const Wrapper = styled(Flex)`
|
||||
}
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Label = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
|
||||
export default function SlackIcon(props: *) {
|
||||
export default function SlackIcon() {
|
||||
return (
|
||||
<svg
|
||||
fill="#4E5C6E"
|
||||
@@ -9,9 +9,8 @@ export default function SlackIcon(props: *) {
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
{...props}
|
||||
>
|
||||
<path d="M15.9018268,8.96000576 C16.2381704,9.99629846 16.5967585,11.1014349 16.9335817,12.1400859 L18.4745653,11.6377075 C19.26999,11.3865207 20.1212341,11.8191201 20.3863757,12.6145449 C20.6375624,13.4099697 20.204963,14.2612137 19.4095382,14.5263553 C19.4095382,14.5263553 18.8135221,14.7204231 17.886368,15.0222122 C17.8870669,15.0243862 17.8877652,15.0265585 17.8884628,15.0287289 C17.8827569,15.0305629 17.8770383,15.0324014 17.8713072,15.0342444 C18.1785729,15.9839578 18.3768815,16.599336 18.3768815,16.6056236 C18.6420231,17.3870935 18.1536044,18.2383376 17.3442248,18.4755695 C16.5767097,18.6988466 15.7673301,18.280202 15.5021885,17.5405965 L14.9897096,15.9648608 C13.9505623,16.3029433 12.8424387,16.6633667 11.8014153,17.001772 C11.8023317,17.0046222 11.8032469,17.0074694 11.8041611,17.0103134 C12.1094193,17.9552905 12.3065346,18.5669846 12.3065346,18.5732533 C12.5716762,19.3547232 12.0832575,20.2059672 11.2738779,20.4431992 C10.5063628,20.6664763 9.69698319,20.2478317 9.43184161,19.5082262 L8.92276608,17.9429548 C8.9203462,17.9437328 8.91792859,17.94451 8.91551325,17.9452863 L8.91369431,17.9396666 C7.96180947,18.2483792 7.34491029,18.4476599 7.33861854,18.4476599 C6.55714859,18.7128015 5.70590454,18.2243827 5.46867259,17.4150032 C5.24539547,16.647488 5.66404008,15.8381084 6.40364557,15.5729669 L7.98119682,15.0586669 L7.98054028,15.0566385 L7.98371132,15.0556121 L6.94355227,11.8574006 C5.99316621,12.1648847 5.37727905,12.3633581 5.37098885,12.3633581 C4.58951891,12.6284997 3.73827486,12.140081 3.50104291,11.3307014 C3.27776578,10.5631863 3.6964104,9.75380672 4.43601588,9.48866513 L6.0069939,8.97773362 L5.51053706,7.45126134 C5.25935029,6.65583658 5.69194972,5.80459253 6.48737449,5.53945094 C7.28279925,5.28826417 8.1340433,5.72086361 8.39918489,6.51628837 C8.39918489,6.51628837 8.59272947,7.11212918 8.89366761,8.03889643 L12.0753884,7.0041007 L11.580884,5.48363166 C11.3296972,4.68820689 11.7622966,3.83696284 12.5577214,3.57182126 C13.3531462,3.32063449 14.2043902,3.75323392 14.4695318,4.54865869 C14.4695318,4.54865869 14.6622407,5.14192682 14.9620649,6.06526261 L16.4929808,5.56736058 C17.2884055,5.31617381 18.1396496,5.74877325 18.4047912,6.54419801 C18.6559779,7.33962278 18.2233785,8.19086683 17.4279537,8.45600842 C17.4279537,8.45600842 16.831726,8.64967867 15.904443,8.95078425 C15.9052547,8.95331644 15.9060665,8.95584901 15.9068783,8.95838195 C15.9051956,8.95892283 15.9035117,8.9594641 15.9018268,8.96000576 Z M9.83147852,10.9276312 C10.168933,11.9673468 10.5287794,13.0763618 10.8665705,14.1180017 L14.051371,13.0797199 L13.013726,9.88923839 C11.9776926,10.2254976 10.8729922,10.583944 9.83480241,10.9206174 C9.8353787,10.9224153 9.83595503,10.9242134 9.8365314,10.9260116 C9.83484694,10.9265515 9.83316265,10.9270914 9.83147852,10.9276312 Z" />
|
||||
<path d="M7.36156352,14.1107492 C7.36156352,15.0358306 6.60586319,15.7915309 5.68078176,15.7915309 C4.75570033,15.7915309 4,15.0358306 4,14.1107492 C4,13.1856678 4.75570033,12.4299674 5.68078176,12.4299674 L7.36156352,12.4299674 L7.36156352,14.1107492 Z M8.20846906,14.1107492 C8.20846906,13.1856678 8.96416938,12.4299674 9.88925081,12.4299674 C10.8143322,12.4299674 11.5700326,13.1856678 11.5700326,14.1107492 L11.5700326,18.3192182 C11.5700326,19.2442997 10.8143322,20 9.88925081,20 C8.96416938,20 8.20846906,19.2442997 8.20846906,18.3192182 C8.20846906,18.3192182 8.20846906,14.1107492 8.20846906,14.1107492 Z M9.88925081,7.36156352 C8.96416938,7.36156352 8.20846906,6.60586319 8.20846906,5.68078176 C8.20846906,4.75570033 8.96416938,4 9.88925081,4 C10.8143322,4 11.5700326,4.75570033 11.5700326,5.68078176 L11.5700326,7.36156352 L9.88925081,7.36156352 Z M9.88925081,8.20846906 C10.8143322,8.20846906 11.5700326,8.96416938 11.5700326,9.88925081 C11.5700326,10.8143322 10.8143322,11.5700326 9.88925081,11.5700326 L5.68078176,11.5700326 C4.75570033,11.5700326 4,10.8143322 4,9.88925081 C4,8.96416938 4.75570033,8.20846906 5.68078176,8.20846906 C5.68078176,8.20846906 9.88925081,8.20846906 9.88925081,8.20846906 Z M16.6384365,9.88925081 C16.6384365,8.96416938 17.3941368,8.20846906 18.3192182,8.20846906 C19.2442997,8.20846906 20,8.96416938 20,9.88925081 C20,10.8143322 19.2442997,11.5700326 18.3192182,11.5700326 L16.6384365,11.5700326 L16.6384365,9.88925081 Z M15.7915309,9.88925081 C15.7915309,10.8143322 15.0358306,11.5700326 14.1107492,11.5700326 C13.1856678,11.5700326 12.4299674,10.8143322 12.4299674,9.88925081 L12.4299674,5.68078176 C12.4299674,4.75570033 13.1856678,4 14.1107492,4 C15.0358306,4 15.7915309,4.75570033 15.7915309,5.68078176 L15.7915309,9.88925081 Z M14.1107492,16.6384365 C15.0358306,16.6384365 15.7915309,17.3941368 15.7915309,18.3192182 C15.7915309,19.2442997 15.0358306,20 14.1107492,20 C13.1856678,20 12.4299674,19.2442997 12.4299674,18.3192182 L12.4299674,16.6384365 L14.1107492,16.6384365 Z M14.1107492,15.7915309 C13.1856678,15.7915309 12.4299674,15.0358306 12.4299674,14.1107492 C12.4299674,13.1856678 13.1856678,12.4299674 14.1107492,12.4299674 L18.3192182,12.4299674 C19.2442997,12.4299674 20,13.1856678 20,14.1107492 C20,15.0358306 19.2442997,15.7915309 18.3192182,15.7915309 L14.1107492,15.7915309 Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
|
||||
export default function ZapierIcon(props: *) {
|
||||
export default function ZapierIcon() {
|
||||
return (
|
||||
<svg
|
||||
fill="#4E5C6E"
|
||||
@@ -9,7 +9,6 @@ export default function ZapierIcon(props: *) {
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
{...props}
|
||||
>
|
||||
<path d="M14,12.00348 C13.9996,12.59796 13.89092,13.16708 13.6928,13.69244 C13.16752,13.89072 12.59816,13.99964 12.00344,14.00004 L11.99656,14.00004 C11.40216,13.99964 10.83296,13.89104 10.30768,13.69284 C10.10952,13.16764 10.0004,12.59828 10,12.00364 L10,11.99672 C10.0004,11.40224 10.10928,10.83312 10.30712,10.3078 C10.83264,10.10964 11.40192,10.0006 11.99656,10.0002 L12.00344,10.0002 C12.59816,10.0006 13.16752,10.10964 13.69276,10.3078 C13.89076,10.83316 13.99956,11.40228 13.99996,11.99676 L13.99996,12.00368 L13.99996,12.0036 L14,12.00348 Z M19.8888,10.66668 L15.21896,10.66668 L18.52096,7.36468 C18.2617173,7.00059444 17.9725547,6.65876921 17.65648,6.34276 L17.65632,6.34244 C17.340564,6.02673022 16.9990638,5.73786527 16.63536,5.47884 L13.33336,8.78084 L13.33336,4.11128 C12.894135,4.03747693 12.4495423,4.00025584 12.00416,4 L11.99568,4 C11.5503533,4.00023789 11.1058145,4.03743219 10.66664,4.1112 L10.66664,8.78108 L7.36464,5.47908 C7.00075543,5.7381962 6.65910565,6.02719699 6.34324,6.34308 L6.34204,6.34416 C6.02660762,6.65978768 5.73799769,7.00112635 5.4792,7.36464 L8.7812,10.66664 L4.1112,10.66664 C4.1112,10.66664 4.00016,11.54384 4,11.99704 L4,12.00284 C4.00016664,12.4487002 4.03736113,12.8937765 4.1112,13.33348 L8.78104,13.33348 L5.47904,16.63548 C5.99830995,17.3645251 6.63559493,18.0018101 7.36464,18.52108 L10.66664,15.21916 L10.66664,19.8888 C11.1053956,19.962476 11.5495017,19.9996699 11.9944,20 L12.00584,20 C12.4506842,19.999644 12.8947349,19.9624502 13.33344,19.8888 L13.33344,15.21892 L16.63544,18.52092 C16.9992044,18.2618792 17.3407586,17.972987 17.65656,17.65724 L17.65736,17.65664 C17.9730579,17.3408301 18.2619218,16.9992911 18.52096,16.63556 L15.21896,13.33348 L19.8888,13.33348 C19.9624671,12.8947771 19.999661,12.4507249 20,12.00588 L20,11.9944 C19.999644,11.5495558 19.9624502,11.1055051 19.8888,10.6668 L19.8888,10.66668 Z" />
|
||||
</svg>
|
||||
|
||||
108
app/components/SocketProvider.js
Normal file
108
app/components/SocketProvider.js
Normal file
@@ -0,0 +1,108 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { inject } from 'mobx-react';
|
||||
import io from 'socket.io-client';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
import CollectionsStore from 'stores/CollectionsStore';
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import UiStore from 'stores/UiStore';
|
||||
|
||||
const SocketContext = React.createContext();
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
documents: DocumentsStore,
|
||||
collections: CollectionsStore,
|
||||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
};
|
||||
|
||||
class SocketProvider extends React.Component<Props> {
|
||||
socket;
|
||||
|
||||
componentDidMount() {
|
||||
if (!process.env.WEBSOCKETS_ENABLED) return;
|
||||
|
||||
this.socket = io(window.location.origin, {
|
||||
path: '/realtime',
|
||||
});
|
||||
|
||||
const { auth, ui, documents, collections } = this.props;
|
||||
if (!auth.token) return;
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
this.socket.emit('authentication', {
|
||||
token: auth.token,
|
||||
});
|
||||
this.socket.on('unauthorized', err => {
|
||||
ui.showToast(err.message);
|
||||
throw err;
|
||||
});
|
||||
this.socket.on('entities', event => {
|
||||
if (event.documents) {
|
||||
event.documents.forEach(doc => {
|
||||
if (doc.deletedAt) {
|
||||
documents.remove(doc.id);
|
||||
} else {
|
||||
documents.add(doc);
|
||||
}
|
||||
|
||||
// TODO: Move this to the document scene once data loading
|
||||
// has been refactored to be friendlier there.
|
||||
if (
|
||||
auth.user &&
|
||||
doc.id === ui.activeDocumentId &&
|
||||
doc.updatedBy.id !== auth.user.id
|
||||
) {
|
||||
ui.showToast(`Document updated by ${doc.updatedBy.name}`, {
|
||||
timeout: 30 * 1000,
|
||||
action: {
|
||||
text: 'Refresh',
|
||||
onClick: () => window.location.reload(),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
if (event.collections) {
|
||||
event.collections.forEach(collection => {
|
||||
if (collection.deletedAt) {
|
||||
collections.remove(collection.id);
|
||||
documents.removeCollectionDocuments(collection.id);
|
||||
} else {
|
||||
collections.add(collection);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
this.socket.on('documents.star', event => {
|
||||
documents.starredIds.set(event.documentId, true);
|
||||
});
|
||||
this.socket.on('documents.unstar', event => {
|
||||
documents.starredIds.set(event.documentId, false);
|
||||
});
|
||||
|
||||
// received a message from the API server that we should request
|
||||
// to join a specific room. Forward that to the ws server.
|
||||
this.socket.on('join', event => {
|
||||
this.socket.emit('join', event);
|
||||
});
|
||||
|
||||
// received a message from the API server that we should request
|
||||
// to leave a specific room. Forward that to the ws server.
|
||||
this.socket.on('leave', event => {
|
||||
this.socket.emit('leave', event);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SocketContext.Provider value={this.socket}>
|
||||
{this.props.children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default inject('auth', 'ui', 'documents', 'collections')(SocketProvider);
|
||||
@@ -1,16 +1,37 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Subheading = styled.h3`
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
color: ${props => props.theme.slate};
|
||||
letter-spacing: 0.04em;
|
||||
border-bottom: 1px solid ${props => props.theme.slateLight};
|
||||
padding-bottom: 8px;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 10px;
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
};
|
||||
|
||||
const H3 = styled.h3`
|
||||
border-bottom: 1px solid ${props => props.theme.divider};
|
||||
margin-top: 22px;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1;
|
||||
`;
|
||||
|
||||
const Underline = styled('span')`
|
||||
position: relative;
|
||||
top: 1px;
|
||||
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: ${props => props.theme.textSecondary};
|
||||
border-bottom: 3px solid ${props => props.theme.textSecondary};
|
||||
padding-bottom: 5px;
|
||||
`;
|
||||
|
||||
const Subheading = ({ children, ...rest }: Props) => {
|
||||
return (
|
||||
<H3 {...rest}>
|
||||
<Underline>{children}</Underline>
|
||||
</H3>
|
||||
);
|
||||
};
|
||||
|
||||
export default Subheading;
|
||||
|
||||
@@ -2,30 +2,45 @@
|
||||
import * as React from 'react';
|
||||
import styled, { withTheme } from 'styled-components';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { lighten } from 'polished';
|
||||
|
||||
type Props = {
|
||||
theme: Object,
|
||||
};
|
||||
|
||||
const StyledNavLink = styled(NavLink)`
|
||||
position: relative;
|
||||
top: 1px;
|
||||
|
||||
const NavItem = styled(NavLink)`
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
color: ${props => props.theme.slate};
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 14px;
|
||||
color: ${props => props.theme.textTertiary};
|
||||
margin-right: 24px;
|
||||
padding-bottom: 8px;
|
||||
|
||||
&:hover {
|
||||
color: ${props => props.theme.slateDark};
|
||||
color: ${props => props.theme.textSecondary};
|
||||
border-bottom: 3px solid ${props => props.theme.divider};
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-bottom: 3px solid
|
||||
${props => lighten(0.4, props.theme.buttonBackground)};
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
`;
|
||||
|
||||
function Tab(props: *) {
|
||||
function Tab(props: Props) {
|
||||
const activeStyle = {
|
||||
paddingBottom: '5px',
|
||||
borderBottom: `3px solid ${props.theme.slateLight}`,
|
||||
color: props.theme.slate,
|
||||
borderBottom: `3px solid ${props.theme.textSecondary}`,
|
||||
color: props.theme.textSecondary,
|
||||
};
|
||||
|
||||
return <NavItem {...props} activeStyle={activeStyle} />;
|
||||
return <StyledNavLink {...props} activeStyle={activeStyle} />;
|
||||
}
|
||||
|
||||
export default withTheme(Tab);
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Tabs = styled.nav`
|
||||
border-bottom: 1px solid ${props => props.theme.slateLight};
|
||||
border-bottom: 1px solid ${props => props.theme.divider};
|
||||
margin-top: 22px;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
|
||||
export default Tabs;
|
||||
|
||||
25
app/components/Theme.js
Normal file
25
app/components/Theme.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import { dark, light } from 'shared/styles/theme';
|
||||
import GlobalStyles from 'shared/styles/globals';
|
||||
import UiStore from 'stores/UiStore';
|
||||
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
children: React.Node,
|
||||
};
|
||||
|
||||
function Theme({ children, ui }: Props) {
|
||||
return (
|
||||
<ThemeProvider theme={ui.theme === 'dark' ? dark : light}>
|
||||
<React.Fragment>
|
||||
<GlobalStyles />
|
||||
{children}
|
||||
</React.Fragment>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default inject('ui')(observer(Theme));
|
||||
@@ -1,58 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import styled from 'styled-components';
|
||||
import { CloseIcon } from 'outline-icons';
|
||||
import Tooltip from './Tooltip';
|
||||
import Flex from 'shared/components/Flex';
|
||||
|
||||
type Props = {
|
||||
id: string,
|
||||
children: React.Node,
|
||||
disabled?: boolean,
|
||||
};
|
||||
|
||||
@observer
|
||||
class Tip extends React.Component<Props> {
|
||||
@observable
|
||||
isHidden: boolean = window.localStorage.getItem(this.storageId) === 'hidden';
|
||||
|
||||
get storageId() {
|
||||
return `tip-${this.props.id}`;
|
||||
}
|
||||
|
||||
hide = () => {
|
||||
window.localStorage.setItem(this.storageId, 'hidden');
|
||||
this.isHidden = true;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { children } = this.props;
|
||||
if (this.props.disabled || this.isHidden) return null;
|
||||
|
||||
return (
|
||||
<Wrapper align="flex-start">
|
||||
<span>{children}</span>
|
||||
|
||||
<Tooltip tooltip="Hide this message" placement="bottom">
|
||||
<Close type="close" size={32} color="#000" onClick={this.hide} />
|
||||
</Tooltip>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Close = styled(CloseIcon)`
|
||||
margin-top: 8px;
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
background: ${props => props.theme.primary};
|
||||
color: ${props => props.theme.text};
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-weight: 400;
|
||||
`;
|
||||
|
||||
export default Tip;
|
||||
@@ -1,59 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import styled from 'styled-components';
|
||||
import Tip from './Tip';
|
||||
import CopyToClipboard from './CopyToClipboard';
|
||||
import Team from '../models/Team';
|
||||
|
||||
type Props = {
|
||||
team: Team,
|
||||
};
|
||||
|
||||
@observer
|
||||
class TipInvite extends React.Component<Props> {
|
||||
@observable linkCopied: boolean = false;
|
||||
|
||||
handleCopy = () => {
|
||||
this.linkCopied = true;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { team } = this.props;
|
||||
|
||||
return (
|
||||
<Tip id="subdomain-invite">
|
||||
<Heading>Looking to invite your team?</Heading>
|
||||
<Paragraph>
|
||||
Your teammates can sign in with{' '}
|
||||
{team.slackConnected ? 'Slack' : 'Google'} to join this knowledgebase
|
||||
at your team’s own subdomain ({team.url.replace(/^https?:\/\//, '')})
|
||||
–{' '}
|
||||
<CopyToClipboard text={team.url} onCopy={this.handleCopy}>
|
||||
<a>
|
||||
{this.linkCopied
|
||||
? 'link copied to clipboard!'
|
||||
: 'copy a link to share.'}
|
||||
</a>
|
||||
</CopyToClipboard>
|
||||
</Paragraph>
|
||||
</Tip>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Heading = styled.h3`
|
||||
margin: 0.25em 0 0.5em 0;
|
||||
`;
|
||||
|
||||
const Paragraph = styled.p`
|
||||
margin: 0.25em 0;
|
||||
|
||||
a {
|
||||
color: ${props => props.theme.text};
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
export default TipInvite;
|
||||
@@ -10,20 +10,16 @@ type Props = {
|
||||
};
|
||||
@observer
|
||||
class Toasts extends React.Component<Props> {
|
||||
handleClose = (index: number) => {
|
||||
this.props.ui.removeToast(index);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { ui } = this.props;
|
||||
|
||||
return (
|
||||
<List>
|
||||
{ui.toasts.map((toast, index) => (
|
||||
{ui.orderedToasts.map(toast => (
|
||||
<Toast
|
||||
key={index}
|
||||
onRequestClose={this.handleClose.bind(this, index)}
|
||||
key={toast.id}
|
||||
toast={toast}
|
||||
onRequestClose={() => ui.removeToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
|
||||
@@ -21,7 +21,7 @@ class Toast extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
this.timeout = setTimeout(
|
||||
this.props.onRequestClose,
|
||||
this.props.closeAfterMs
|
||||
this.props.toast.timeout || this.props.closeAfterMs
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,25 +31,51 @@ class Toast extends React.Component<Props> {
|
||||
|
||||
render() {
|
||||
const { toast, onRequestClose } = this.props;
|
||||
const { action } = toast;
|
||||
const message =
|
||||
typeof toast.message === 'string'
|
||||
? toast.message
|
||||
: toast.message.toString();
|
||||
|
||||
return (
|
||||
<Container onClick={onRequestClose} type={toast.type}>
|
||||
<Message>{message}</Message>
|
||||
</Container>
|
||||
<li>
|
||||
<Container
|
||||
onClick={action ? undefined : onRequestClose}
|
||||
type={toast.type || 'success'}
|
||||
>
|
||||
<Message>{message}</Message>
|
||||
{action && (
|
||||
<Action type={toast.type || 'success'} onClick={action.onClick}>
|
||||
{action.text}
|
||||
</Action>
|
||||
)}
|
||||
</Container>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Container = styled.li`
|
||||
display: flex;
|
||||
const Action = styled.span`
|
||||
display: inline-block;
|
||||
padding: 10px 12px;
|
||||
height: 100%;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
color: ${props => props.theme.white};
|
||||
background: ${props => darken(0.05, props.theme[props.type])};
|
||||
border-top-right-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
|
||||
&:hover {
|
||||
background: ${props => darken(0.1, props.theme[props.type])};
|
||||
}
|
||||
`;
|
||||
|
||||
const Container = styled.div`
|
||||
display: inline-block;
|
||||
align-items: center;
|
||||
animation: ${fadeAndScaleIn} 100ms ease;
|
||||
margin: 8px 0;
|
||||
padding: 10px 12px;
|
||||
color: ${props => props.theme.white};
|
||||
background: ${props => props.theme[props.type]};
|
||||
font-size: 15px;
|
||||
@@ -62,7 +88,8 @@ const Container = styled.li`
|
||||
`;
|
||||
|
||||
const Message = styled.div`
|
||||
padding-left: 5px;
|
||||
display: inline-block;
|
||||
padding: 10px 12px;
|
||||
`;
|
||||
|
||||
export default Toast;
|
||||
|
||||
@@ -1,19 +1,69 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { TooltipTrigger } from 'pui-react-tooltip';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
import styled from 'styled-components';
|
||||
import Tippy from '@tippy.js/react';
|
||||
|
||||
const GlobalStyles = createGlobalStyle`
|
||||
.tooltip:hover .tooltip-container:not(.tooltip-container-hidden){visibility:visible;opacity:1}.tooltip-container{visibility:hidden;-webkit-transition:opacity ease-out 0.2s;transition:opacity ease-out 0.2s;z-index:10;position:absolute;bottom:100%;left:50%;-webkit-transform:translateX(-50%);transform:translateX(-50%);margin:0 0 8px 0;text-align:left}.tooltip-container.tooltip-container-visible{visibility:visible}.tooltip-container.tooltip-hoverable:after{content:"";position:absolute;width:calc(100% + 16px);height:calc(100% + 16px);top:50%;left:50%;-webkit-transform:translateX(-50%) translateY(-50%);transform:translateX(-50%) translateY(-50%)}.tooltip-container .tooltip-content{white-space:nowrap;padding:4px 8px;font-size:12px;line-height:16px;font-weight:400;letter-spacing:0;text-transform:none;background-color:#243641;color:#fff;border-radius:2px;border:1px solid #243641;box-shadow:0px 2px 2px 0px rgba(36, 54, 65, .1),0px 0px 2px 0px rgba(36, 54, 65, .1)}.tooltip-container .tooltip-content:before{content:"";z-index:1;position:absolute;bottom:-4px;left:50%;-webkit-transform:translateX(-50%) rotateZ(45deg);transform:translateX(-50%) rotateZ(45deg);background-color:#243641;border-bottom:1px solid #243641;border-right:1px solid #243641;width:8px;height:8px}.tooltip-container .tooltip-content:after{content:"";box-sizing:content-box;z-index:-1;position:absolute;bottom:-4px;left:50%;-webkit-transform:translateX(-50%) rotateZ(45deg);transform:translateX(-50%) rotateZ(45deg);background-color:#243641;box-shadow:0px 2px 2px 0px rgba(36, 54, 65, .1),0px 0px 2px 0px rgba(36, 54, 65, .1);width:8px;height:8px}.tooltip{position:relative;display:inline-block}.tooltip.tooltip-light .tooltip-content{background-color:#fff;color:#243641;border:1px solid #DFE5E8}.tooltip.tooltip-light .tooltip-content:before{background-color:#fff;border-bottom:1px solid #DFE5E8;border-right:1px solid #DFE5E8}.tooltip.tooltip-light .tooltip-content:after{background-color:#fff}.tooltip.tooltip-bottom .tooltip-container{top:100%;bottom:auto;left:50%;-webkit-transform:translateX(-50%);transform:translateX(-50%);margin:8px 0 0 0}.tooltip.tooltip-bottom .tooltip-container .tooltip-content:before{bottom:auto;top:-4px;border-top:1px solid #243641;border-right:none;border-bottom:none;border-left:1px solid #243641}.tooltip.tooltip-bottom .tooltip-container .tooltip-content:after{bottom:auto;top:-4px}.tooltip.tooltip-bottom.tooltip-light .tooltip-content:before{border-top:1px solid #DFE5E8;border-left:1px solid #DFE5E8}.tooltip.tooltip-right .tooltip-container{top:50%;bottom:auto;left:100%;-webkit-transform:translatey(-50%);transform:translatey(-50%);margin:0 0 0 8px}.tooltip.tooltip-right .tooltip-container .tooltip-content:before{bottom:auto;left:-4px;top:50%;-webkit-transform:translatey(-50%) rotateZ(45deg);transform:translatey(-50%) rotateZ(45deg);border-top:none;border-right:none;border-bottom:1px solid #243641;border-left:1px solid #243641}.tooltip.tooltip-right .tooltip-container .tooltip-content:after{bottom:auto;left:-4px;top:50%;-webkit-transform:translatey(-50%) rotateZ(45deg);transform:translatey(-50%) rotateZ(45deg)}.tooltip.tooltip-right.tooltip-light .tooltip-content:before{border-bottom:1px solid #DFE5E8;border-left:1px solid #DFE5E8}.tooltip.tooltip-left .tooltip-container{top:50%;bottom:auto;right:100%;left:auto;-webkit-transform:translatey(-50%);transform:translatey(-50%);margin:0 8px 0 0}.tooltip.tooltip-left .tooltip-container .tooltip-content:before{bottom:auto;right:-4px;left:auto;top:50%;-webkit-transform:translatey(-50%) rotateZ(45deg);transform:translatey(-50%) rotateZ(45deg);border-top:1px solid #243641;border-right:1px solid #243641;border-bottom:none;border-left:none}.tooltip.tooltip-left .tooltip-container .tooltip-content:after{bottom:auto;right:-4px;left:auto;top:50%;-webkit-transform:translatey(-50%) rotateZ(45deg);transform:translatey(-50%) rotateZ(45deg)}.tooltip.tooltip-left.tooltip-light .tooltip-content:before{border-top:1px solid #DFE5E8;border-right:1px solid #DFE5E8}.tooltip-sm.tooltip-container{width:120px}.tooltip-sm.tooltip-container .tooltip-content{white-space:normal}.tooltip-md.tooltip-container{width:240px}.tooltip-md.tooltip-container .tooltip-content{white-space:normal}.tooltip-lg.tooltip-container{width:360px}.tooltip-lg.tooltip-container .tooltip-content{white-space:normal}.tether-element{z-index:99}.overlay-trigger{color:#1B78B3;-webkit-transition:all 300ms ease-out;transition:all 300ms ease-out;-webkit-transition-property:background-color, color, opacity;transition-property:background-color, color, opacity}.overlay-trigger:hover,.overlay-trigger:focus{color:#1f8ace;cursor:pointer;outline:none;text-decoration:none}.overlay-trigger:active,.overlay-trigger.active{color:#176698}
|
||||
`;
|
||||
|
||||
const Tooltip = function(props: *) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<GlobalStyles />
|
||||
<TooltipTrigger {...props} />
|
||||
</React.Fragment>
|
||||
);
|
||||
type Props = {
|
||||
tooltip: React.Node,
|
||||
shortcut?: React.Node,
|
||||
placement?: 'top' | 'bottom' | 'left' | 'right',
|
||||
children: React.Node,
|
||||
delay?: number,
|
||||
className?: string,
|
||||
};
|
||||
|
||||
class Tooltip extends React.Component<Props> {
|
||||
render() {
|
||||
const { shortcut, tooltip, delay = 50, className, ...rest } = this.props;
|
||||
|
||||
let content = tooltip;
|
||||
|
||||
if (shortcut) {
|
||||
content = (
|
||||
<React.Fragment>
|
||||
{tooltip} · <Shortcut>{shortcut}</Shortcut>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledTippy
|
||||
arrow
|
||||
arrowType="round"
|
||||
animation="shift-away"
|
||||
content={content}
|
||||
delay={delay}
|
||||
duration={[200, 150]}
|
||||
inertia
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Shortcut = styled.kbd`
|
||||
position: relative;
|
||||
top: -2px;
|
||||
|
||||
display: inline-block;
|
||||
padding: 2px 4px;
|
||||
font: 10px 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier,
|
||||
monospace;
|
||||
line-height: 10px;
|
||||
color: ${props => props.theme.tooltipBackground};
|
||||
vertical-align: middle;
|
||||
background-color: ${props => props.theme.tooltipText};
|
||||
border-radius: 3px;
|
||||
`;
|
||||
|
||||
const StyledTippy = styled(Tippy)`
|
||||
font-size: 13px;
|
||||
background-color: ${props => props.theme.tooltipBackground};
|
||||
color: ${props => props.theme.tooltipText};
|
||||
|
||||
svg {
|
||||
fill: ${props => props.theme.tooltipBackground};
|
||||
}
|
||||
`;
|
||||
|
||||
export default Tooltip;
|
||||
|
||||
13
app/components/VisuallyHidden.js
Normal file
13
app/components/VisuallyHidden.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
|
||||
const VisuallyHidden = styled('span')`
|
||||
position: absolute !important;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
`;
|
||||
|
||||
export default VisuallyHidden;
|
||||
27
app/embeds/Abstract.js
Normal file
27
app/embeds/Abstract.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import Frame from './components/Frame';
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
matches: string[],
|
||||
};
|
||||
|
||||
export default class Abstract extends React.Component<Props> {
|
||||
static ENABLED = [
|
||||
new RegExp('https?://share.(?:go)?abstract.com/(.*)$'),
|
||||
new RegExp('https?://app.(?:go)?abstract.com/(?:share|embed)/(.*)$'),
|
||||
];
|
||||
|
||||
render() {
|
||||
const { matches } = this.props;
|
||||
const shareId = matches[1];
|
||||
|
||||
return (
|
||||
<Frame
|
||||
src={`https://app.goabstract.com/embed/${shareId}`}
|
||||
title={`Abstract (${shareId})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
60
app/embeds/Abstract.test.js
Normal file
60
app/embeds/Abstract.test.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import embeds from '.';
|
||||
|
||||
const { Abstract } = embeds;
|
||||
|
||||
describe('Abstract', () => {
|
||||
const match = Abstract.ENABLED[0];
|
||||
const match2 = Abstract.ENABLED[1];
|
||||
test('to be enabled on share subdomain link', () => {
|
||||
expect(
|
||||
'https://share.goabstract.com/aaec8bba-f473-4f64-96e7-bff41c70ff8a'.match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
|
||||
expect(
|
||||
'https://share.abstract.com/aaec8bba-f473-4f64-96e7-bff41c70ff8a'.match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to be enabled on share link', () => {
|
||||
expect(
|
||||
'https://app.goabstract.com/share/aaec8bba-f473-4f64-96e7-bff41c70ff8a'.match(
|
||||
match2
|
||||
)
|
||||
).toBeTruthy();
|
||||
|
||||
expect(
|
||||
'https://app.abstract.com/share/aaec8bba-f473-4f64-96e7-bff41c70ff8a'.match(
|
||||
match2
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to be enabled on embed link', () => {
|
||||
expect(
|
||||
'https://app.goabstract.com/embed/aaec8bba-f473-4f64-96e7-bff41c70ff8a'.match(
|
||||
match2
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
'https://app.abstract.com/embed/aaec8bba-f473-4f64-96e7-bff41c70ff8a'.match(
|
||||
match2
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to not be enabled elsewhere', () => {
|
||||
expect('https://abstract.com'.match(match)).toBe(null);
|
||||
expect('https://goabstract.com'.match(match)).toBe(null);
|
||||
expect('https://app.goabstract.com'.match(match)).toBe(null);
|
||||
expect('https://abstract.com/features'.match(match)).toBe(null);
|
||||
expect('https://app.abstract.com/dashboard'.match(match)).toBe(null);
|
||||
expect('https://abstract.com/pricing'.match(match)).toBe(null);
|
||||
expect('https://goabstract.com/pricing'.match(match)).toBe(null);
|
||||
expect('https://www.goabstract.com/pricing'.match(match)).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
import * as React from 'react';
|
||||
import Frame from './components/Frame';
|
||||
|
||||
const URL_REGEX = /^https:\/\/(www\.)?useloom.com\/(embed|share)\/(.*)$/;
|
||||
const URL_REGEX = /^https:\/\/(www\.)?(use)?loom.com\/(embed|share)\/(.*)$/;
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
|
||||
@@ -6,6 +6,9 @@ const { Loom } = embeds;
|
||||
describe('Loom', () => {
|
||||
const match = Loom.ENABLED[0];
|
||||
test('to be enabled on share link', () => {
|
||||
expect(
|
||||
'https://www.loom.com/share/55327cbb265743f39c2c442c029277e0'.match(match)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
'https://www.useloom.com/share/55327cbb265743f39c2c442c029277e0'.match(
|
||||
match
|
||||
@@ -14,6 +17,9 @@ describe('Loom', () => {
|
||||
});
|
||||
|
||||
test('to be enabled on embed link', () => {
|
||||
expect(
|
||||
'https://www.loom.com/embed/55327cbb265743f39c2c442c029277e0'.match(match)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
'https://www.useloom.com/embed/55327cbb265743f39c2c442c029277e0'.match(
|
||||
match
|
||||
|
||||
28
app/embeds/Mindmeister.js
Normal file
28
app/embeds/Mindmeister.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import Frame from './components/Frame';
|
||||
|
||||
const URL_REGEX = new RegExp(
|
||||
'^https://([w.-]+.)?(mindmeister.com|mm.tt)(/maps/public_map_shell)?/(\\d+)(\\?t=.*)?(/.*)?$'
|
||||
);
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
matches: string[],
|
||||
};
|
||||
|
||||
export default class Mindmeister extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
const chartId = this.props.matches[4] + this.props.matches[6];
|
||||
|
||||
return (
|
||||
<Frame
|
||||
src={`https://www.mindmeister.com/maps/public_map_shell/${chartId}`}
|
||||
title="Mindmeister Embed"
|
||||
border
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
49
app/embeds/Mindmeister.test.js
Normal file
49
app/embeds/Mindmeister.test.js
Normal file
@@ -0,0 +1,49 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import embeds from '.';
|
||||
|
||||
const { Mindmeister } = embeds;
|
||||
|
||||
describe('Mindmeister', () => {
|
||||
const match = Mindmeister.ENABLED[0];
|
||||
|
||||
test('to be enabled on mm.tt link', () => {
|
||||
expect('https://mm.tt/326377934'.match(match)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to be enabled on mm.tt link with token parameter', () => {
|
||||
expect('https://mm.tt/326377934?t=r9NcnTRr18'.match(match)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to be enabled on embed link', () => {
|
||||
expect(
|
||||
'https://www.mindmeister.com/maps/public_map_shell/326377934/paper-digital-or-online-mind-mapping'.match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to be enabled on public link', () => {
|
||||
expect(
|
||||
'https://www.mindmeister.com/326377934/paper-digital-or-online-mind-mapping'.match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to be enabled without www', () => {
|
||||
expect(
|
||||
'https://mindmeister.com/326377934/paper-digital-or-online-mind-mapping'.match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to be enabled without slug', () => {
|
||||
expect('https://mindmeister.com/326377934'.match(match)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to not be enabled elsewhere', () => {
|
||||
expect('https://mindmeister.com'.match(match)).toBe(null);
|
||||
expect('https://www.mindmeister.com/pricing'.match(match)).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
import * as React from 'react';
|
||||
import Frame from './components/Frame';
|
||||
|
||||
const URL_REGEX = /^https:\/\/realtimeboard.com\/app\/board\/(.*)$/;
|
||||
const URL_REGEX = /^https:\/\/(?:realtimeboard|miro).com\/app\/board\/(.*)$/;
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
@@ -18,7 +18,7 @@ export default class RealtimeBoard extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<Frame
|
||||
src={`http://realtimeboard.com/app/embed/${boardId}`}
|
||||
src={`https://realtimeboard.com/app/embed/${boardId}`}
|
||||
title={`RealtimeBoard (${boardId})`}
|
||||
/>
|
||||
);
|
||||
@@ -1,17 +1,22 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import embeds from '.';
|
||||
|
||||
const { RealtimeBoard } = embeds;
|
||||
const { Miro } = embeds;
|
||||
|
||||
describe('RealtimeBoard', () => {
|
||||
const match = RealtimeBoard.ENABLED[0];
|
||||
test('to be enabled on share link', () => {
|
||||
describe('Miro', () => {
|
||||
const match = Miro.ENABLED[0];
|
||||
test('to be enabled on old domain share link', () => {
|
||||
expect(
|
||||
'https://realtimeboard.com/app/board/o9J_k0fwiss='.match(match)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to be enabled on share link', () => {
|
||||
expect('https://miro.com/app/board/o9J_k0fwiss='.match(match)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to not be enabled elsewhere', () => {
|
||||
expect('https://miro.com'.match(match)).toBe(null);
|
||||
expect('https://realtimeboard.com'.match(match)).toBe(null);
|
||||
expect('https://realtimeboard.com/features'.match(match)).toBe(null);
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import * as React from 'react';
|
||||
import Frame from './components/Frame';
|
||||
|
||||
const URL_REGEX = new RegExp(
|
||||
'https://([w.-]+.)?modeanalytics.com/(.*)/reports/(.*)$'
|
||||
'^https://([w.-]+.)?modeanalytics.com/(.*)/reports/(.*)$'
|
||||
);
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -18,7 +18,7 @@ export default class Vimeo extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<Frame
|
||||
src={`http://player.vimeo.com/video/${videoId}?byline=0`}
|
||||
src={`https://player.vimeo.com/video/${videoId}?byline=0`}
|
||||
title={`Vimeo Embed (${videoId})`}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -39,7 +39,7 @@ class Frame extends React.Component<Props> {
|
||||
forwardedRef,
|
||||
...rest
|
||||
} = this.props;
|
||||
const Component = border ? Iframe : 'iframe';
|
||||
const Component = border ? StyledIframe : 'iframe';
|
||||
|
||||
return (
|
||||
<Rounded width={width} height={height}>
|
||||
@@ -52,6 +52,7 @@ class Frame extends React.Component<Props> {
|
||||
type="text/html"
|
||||
frameBorder="0"
|
||||
title="embed"
|
||||
loading="lazy"
|
||||
allowFullScreen
|
||||
{...rest}
|
||||
/>
|
||||
@@ -68,9 +69,13 @@ const Rounded = styled.div`
|
||||
height: ${props => props.height};
|
||||
`;
|
||||
|
||||
const Iframe = styled.iframe`
|
||||
// This wrapper allows us to pass non-standard HTML attributes through to the DOM element
|
||||
// https://www.styled-components.com/docs/basics#passed-props
|
||||
const Iframe = props => <iframe {...props} />;
|
||||
|
||||
const StyledIframe = styled(Iframe)`
|
||||
border: 1px solid;
|
||||
border-color: #ddd #ddd #ccc;
|
||||
border-color: ${props => props.theme.embedBorder};
|
||||
border-radius: 3px;
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @flow
|
||||
import Abstract from './Abstract';
|
||||
import Airtable from './Airtable';
|
||||
import Codepen from './Codepen';
|
||||
import Figma from './Figma';
|
||||
@@ -9,10 +10,11 @@ import InVision from './InVision';
|
||||
import Loom from './Loom';
|
||||
import Lucidchart from './Lucidchart';
|
||||
import Marvel from './Marvel';
|
||||
import Mindmeister from './Mindmeister';
|
||||
import Miro from './Miro';
|
||||
import ModeAnalytics from './ModeAnalytics';
|
||||
import Numeracy from './Numeracy';
|
||||
import Prezi from './Prezi';
|
||||
import RealtimeBoard from './RealtimeBoard';
|
||||
import Spotify from './Spotify';
|
||||
import Trello from './Trello';
|
||||
import Typeform from './Typeform';
|
||||
@@ -20,6 +22,7 @@ import Vimeo from './Vimeo';
|
||||
import YouTube from './YouTube';
|
||||
|
||||
export default {
|
||||
Abstract,
|
||||
Airtable,
|
||||
Codepen,
|
||||
Figma,
|
||||
@@ -30,10 +33,11 @@ export default {
|
||||
Loom,
|
||||
Lucidchart,
|
||||
Marvel,
|
||||
Mindmeister,
|
||||
Miro,
|
||||
ModeAnalytics,
|
||||
Numeracy,
|
||||
Prezi,
|
||||
RealtimeBoard,
|
||||
Spotify,
|
||||
Trello,
|
||||
Typeform,
|
||||
|
||||
27
app/index.js
27
app/index.js
@@ -2,18 +2,13 @@
|
||||
import * as React from 'react';
|
||||
import { render } from 'react-dom';
|
||||
import { Provider } from 'mobx-react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
|
||||
import stores from 'stores';
|
||||
import theme from 'shared/styles/theme';
|
||||
import GlobalStyles from 'shared/styles/globals';
|
||||
import 'shared/styles/prism.css';
|
||||
|
||||
import ErrorBoundary from 'components/ErrorBoundary';
|
||||
import ScrollToTop from 'components/ScrollToTop';
|
||||
import ScrollToAnchor from 'components/ScrollToAnchor';
|
||||
import Toasts from 'components/Toasts';
|
||||
import Theme from 'components/Theme';
|
||||
import Routes from './routes';
|
||||
|
||||
let DevTools;
|
||||
@@ -26,23 +21,20 @@ const element = document.getElementById('root');
|
||||
if (element) {
|
||||
render(
|
||||
<React.Fragment>
|
||||
<GlobalStyles />
|
||||
<ThemeProvider theme={theme}>
|
||||
<ErrorBoundary>
|
||||
<Provider {...stores}>
|
||||
<ErrorBoundary>
|
||||
<Provider {...stores}>
|
||||
<Theme>
|
||||
<Router>
|
||||
<React.Fragment>
|
||||
<ScrollToTop>
|
||||
<ScrollToAnchor>
|
||||
<Routes />
|
||||
</ScrollToAnchor>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
</React.Fragment>
|
||||
</Router>
|
||||
</Provider>
|
||||
</ErrorBoundary>
|
||||
</ThemeProvider>
|
||||
</Theme>
|
||||
</Provider>
|
||||
</ErrorBoundary>
|
||||
{DevTools && <DevTools position={{ bottom: 0, right: 0 }} />}
|
||||
</React.Fragment>,
|
||||
element
|
||||
@@ -59,4 +51,7 @@ window.addEventListener('load', async () => {
|
||||
|
||||
window.ga('require', 'outboundLinkTracker');
|
||||
window.ga('require', 'urlChangeTracker');
|
||||
window.ga('require', 'eventTracker', {
|
||||
attributePrefix: 'data-',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,72 +1,108 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { observable } from 'mobx';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import { MoonIcon } from 'outline-icons';
|
||||
import styled, { withTheme } from 'styled-components';
|
||||
import UiStore from 'stores/UiStore';
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||
import Modal from 'components/Modal';
|
||||
import KeyboardShortcuts from 'scenes/KeyboardShortcuts';
|
||||
import {
|
||||
developers,
|
||||
changelog,
|
||||
githubIssuesUrl,
|
||||
mailToUrl,
|
||||
spectrumUrl,
|
||||
settings,
|
||||
} from '../../shared/utils/routeHelpers';
|
||||
|
||||
type Props = {
|
||||
label: React.Node,
|
||||
history: Object,
|
||||
ui: UiStore,
|
||||
auth: AuthStore,
|
||||
theme: Object,
|
||||
};
|
||||
|
||||
@observer
|
||||
class AccountMenu extends React.Component<Props> {
|
||||
handleOpenKeyboardShortcuts = () => {
|
||||
this.props.ui.setActiveModal('keyboard-shortcuts');
|
||||
};
|
||||
|
||||
handleOpenSettings = () => {
|
||||
this.props.history.push('/settings');
|
||||
};
|
||||
@observable keyboardShortcutsOpen: boolean = false;
|
||||
|
||||
handleLogout = () => {
|
||||
this.props.auth.logout();
|
||||
};
|
||||
|
||||
handleOpenKeyboardShortcuts = () => {
|
||||
this.keyboardShortcutsOpen = true;
|
||||
};
|
||||
|
||||
handleCloseKeyboardShortcuts = () => {
|
||||
this.keyboardShortcutsOpen = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { ui, theme } = this.props;
|
||||
const isLightTheme = ui.theme === 'light';
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
style={{ marginRight: 10, marginTop: -10 }}
|
||||
label={this.props.label}
|
||||
>
|
||||
<DropdownMenuItem onClick={this.handleOpenSettings}>
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.handleOpenKeyboardShortcuts}>
|
||||
Keyboard shortcuts
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem href={developers()} target="_blank">
|
||||
API documentation
|
||||
</DropdownMenuItem>
|
||||
<hr />
|
||||
<DropdownMenuItem href={changelog()} target="_blank">
|
||||
Changelog
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem href={spectrumUrl()} target="_blank">
|
||||
Community
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem href={mailToUrl()} target="_blank">
|
||||
Send us feedback
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem href={githubIssuesUrl()} target="_blank">
|
||||
Report a bug
|
||||
</DropdownMenuItem>
|
||||
<hr />
|
||||
<DropdownMenuItem onClick={this.handleLogout}>Logout</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
<React.Fragment>
|
||||
<Modal
|
||||
isOpen={this.keyboardShortcutsOpen}
|
||||
onRequestClose={this.handleCloseKeyboardShortcuts}
|
||||
title="Keyboard shortcuts"
|
||||
>
|
||||
<KeyboardShortcuts />
|
||||
</Modal>
|
||||
<DropdownMenu
|
||||
style={{ marginRight: 10, marginTop: -10 }}
|
||||
label={this.props.label}
|
||||
>
|
||||
<DropdownMenuItem as={Link} to={settings()}>
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.handleOpenKeyboardShortcuts}>
|
||||
Keyboard shortcuts
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem href={developers()} target="_blank">
|
||||
API documentation
|
||||
</DropdownMenuItem>
|
||||
<hr />
|
||||
<DropdownMenuItem href={changelog()} target="_blank">
|
||||
Changelog
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem href={spectrumUrl()} target="_blank">
|
||||
Community
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem href={mailToUrl()} target="_blank">
|
||||
Send us feedback
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem href={githubIssuesUrl()} target="_blank">
|
||||
Report a bug
|
||||
</DropdownMenuItem>
|
||||
<hr />
|
||||
<DropdownMenuItem onClick={ui.toggleDarkMode}>
|
||||
<NightMode justify="space-between">
|
||||
Night Mode{' '}
|
||||
<MoonIcon
|
||||
color={isLightTheme ? theme.textSecondary : theme.primary}
|
||||
/>
|
||||
</NightMode>
|
||||
</DropdownMenuItem>
|
||||
<hr />
|
||||
<DropdownMenuItem onClick={this.handleLogout}>
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(inject('ui', 'auth')(AccountMenu));
|
||||
const NightMode = styled(Flex)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export default inject('ui', 'auth')(withTheme(AccountMenu));
|
||||
|
||||
@@ -2,47 +2,51 @@
|
||||
import * as React from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import { withRouter, type RouterHistory } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { MoreIcon } from 'outline-icons';
|
||||
import Modal from 'components/Modal';
|
||||
import CollectionPermissions from 'scenes/CollectionPermissions';
|
||||
|
||||
import { newDocumentUrl } from 'utils/routeHelpers';
|
||||
import getDataTransferFiles from 'utils/getDataTransferFiles';
|
||||
import importFile from 'utils/importFile';
|
||||
import Collection from 'models/Collection';
|
||||
import UiStore from 'stores/UiStore';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||
import NudeButton from 'components/NudeButton';
|
||||
|
||||
type Props = {
|
||||
label?: React.Node,
|
||||
onOpen?: () => *,
|
||||
onClose?: () => *,
|
||||
history: Object,
|
||||
position?: 'left' | 'right' | 'center',
|
||||
ui: UiStore,
|
||||
documents: DocumentsStore,
|
||||
collection: Collection,
|
||||
history: RouterHistory,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
};
|
||||
|
||||
@observer
|
||||
class CollectionMenu extends React.Component<Props> {
|
||||
file: ?HTMLInputElement;
|
||||
@observable permissionsModalOpen: boolean = false;
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
onNewDocument = (ev: SyntheticEvent<*>) => {
|
||||
onNewDocument = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { collection, history } = this.props;
|
||||
history.push(`${collection.url}/new`);
|
||||
const { collection } = this.props;
|
||||
this.props.history.push(newDocumentUrl(collection.id));
|
||||
};
|
||||
|
||||
onImportDocument = (ev: SyntheticEvent<*>) => {
|
||||
onImportDocument = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
|
||||
// simulate a click on the file upload input element
|
||||
if (this.file) this.file.click();
|
||||
};
|
||||
|
||||
onFilePicked = async (ev: SyntheticEvent<*>) => {
|
||||
onFilePicked = async (ev: SyntheticEvent<>) => {
|
||||
const files = getDataTransferFiles(ev);
|
||||
|
||||
try {
|
||||
@@ -57,25 +61,25 @@ class CollectionMenu extends React.Component<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
onEdit = (ev: SyntheticEvent<*>) => {
|
||||
onEdit = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { collection } = this.props;
|
||||
this.props.ui.setActiveModal('collection-edit', { collection });
|
||||
};
|
||||
|
||||
onDelete = (ev: SyntheticEvent<*>) => {
|
||||
onDelete = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { collection } = this.props;
|
||||
this.props.ui.setActiveModal('collection-delete', { collection });
|
||||
};
|
||||
|
||||
onExport = (ev: SyntheticEvent<*>) => {
|
||||
onExport = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { collection } = this.props;
|
||||
this.props.ui.setActiveModal('collection-export', { collection });
|
||||
};
|
||||
|
||||
onPermissions = (ev: SyntheticEvent<*>) => {
|
||||
onPermissions = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.permissionsModalOpen = true;
|
||||
};
|
||||
@@ -85,7 +89,7 @@ class CollectionMenu extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collection, label, onOpen, onClose } = this.props;
|
||||
const { collection, position, onOpen, onClose } = this.props;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
@@ -106,9 +110,14 @@ class CollectionMenu extends React.Component<Props> {
|
||||
/>
|
||||
</Modal>
|
||||
<DropdownMenu
|
||||
label={label || <MoreIcon />}
|
||||
label={
|
||||
<NudeButton>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
position={position}
|
||||
>
|
||||
{collection && (
|
||||
<React.Fragment>
|
||||
@@ -142,4 +151,4 @@ const HiddenInput = styled.input`
|
||||
visibility: hidden;
|
||||
`;
|
||||
|
||||
export default inject('ui', 'documents')(CollectionMenu);
|
||||
export default inject('ui', 'documents')(withRouter(CollectionMenu));
|
||||
|
||||
@@ -1,74 +1,106 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { observable } from 'mobx';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import { MoreIcon } from 'outline-icons';
|
||||
|
||||
import Document from 'models/Document';
|
||||
import UiStore from 'stores/UiStore';
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import { documentMoveUrl, documentHistoryUrl } from 'utils/routeHelpers';
|
||||
import CollectionStore from 'stores/CollectionsStore';
|
||||
import {
|
||||
documentMoveUrl,
|
||||
documentEditUrl,
|
||||
documentHistoryUrl,
|
||||
newDocumentUrl,
|
||||
} from 'utils/routeHelpers';
|
||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||
import NudeButton from 'components/NudeButton';
|
||||
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
auth: AuthStore,
|
||||
label?: React.Node,
|
||||
history: Object,
|
||||
position?: 'left' | 'right' | 'center',
|
||||
document: Document,
|
||||
collections: CollectionStore,
|
||||
className: string,
|
||||
showPrint?: boolean,
|
||||
showToggleEmbeds?: boolean,
|
||||
showPin?: boolean,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
};
|
||||
|
||||
@observer
|
||||
class DocumentMenu extends React.Component<Props> {
|
||||
handleNewChild = (ev: SyntheticEvent<*>) => {
|
||||
const { history, document } = this.props;
|
||||
history.push(
|
||||
`${document.collection.url}/new?parentDocument=${document.id}`
|
||||
);
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
componentDidUpdate() {
|
||||
this.redirectTo = undefined;
|
||||
}
|
||||
|
||||
handleNewChild = (ev: SyntheticEvent<>) => {
|
||||
const { document } = this.props;
|
||||
this.redirectTo = newDocumentUrl(document.collectionId, document.id);
|
||||
};
|
||||
|
||||
handleDelete = (ev: SyntheticEvent<*>) => {
|
||||
handleDelete = (ev: SyntheticEvent<>) => {
|
||||
const { document } = this.props;
|
||||
this.props.ui.setActiveModal('document-delete', { document });
|
||||
};
|
||||
|
||||
handleDocumentHistory = () => {
|
||||
this.props.history.push(documentHistoryUrl(this.props.document));
|
||||
this.redirectTo = documentHistoryUrl(this.props.document);
|
||||
};
|
||||
|
||||
handleMove = (ev: SyntheticEvent<*>) => {
|
||||
this.props.history.push(documentMoveUrl(this.props.document));
|
||||
handleMove = (ev: SyntheticEvent<>) => {
|
||||
this.redirectTo = documentMoveUrl(this.props.document);
|
||||
};
|
||||
|
||||
handleDuplicate = async (ev: SyntheticEvent<*>) => {
|
||||
handleEdit = (ev: SyntheticEvent<>) => {
|
||||
this.redirectTo = documentEditUrl(this.props.document);
|
||||
};
|
||||
|
||||
handleDuplicate = async (ev: SyntheticEvent<>) => {
|
||||
const duped = await this.props.document.duplicate();
|
||||
this.props.history.push(duped.url);
|
||||
|
||||
// when duplicating, go straight to the duplicated document content
|
||||
this.redirectTo = duped.url;
|
||||
this.props.ui.showToast('Document duplicated');
|
||||
};
|
||||
|
||||
handlePin = (ev: SyntheticEvent<*>) => {
|
||||
handleArchive = async (ev: SyntheticEvent<>) => {
|
||||
await this.props.document.archive();
|
||||
this.props.ui.showToast('Document archived');
|
||||
};
|
||||
|
||||
handleRestore = async (ev: SyntheticEvent<>) => {
|
||||
await this.props.document.restore();
|
||||
this.props.ui.showToast('Document restored');
|
||||
};
|
||||
|
||||
handlePin = (ev: SyntheticEvent<>) => {
|
||||
this.props.document.pin();
|
||||
};
|
||||
|
||||
handleUnpin = (ev: SyntheticEvent<*>) => {
|
||||
handleUnpin = (ev: SyntheticEvent<>) => {
|
||||
this.props.document.unpin();
|
||||
};
|
||||
|
||||
handleStar = (ev: SyntheticEvent<*>) => {
|
||||
handleStar = (ev: SyntheticEvent<>) => {
|
||||
this.props.document.star();
|
||||
};
|
||||
|
||||
handleUnstar = (ev: SyntheticEvent<*>) => {
|
||||
handleUnstar = (ev: SyntheticEvent<>) => {
|
||||
this.props.document.unstar();
|
||||
};
|
||||
|
||||
handleExport = (ev: SyntheticEvent<*>) => {
|
||||
handleExport = (ev: SyntheticEvent<>) => {
|
||||
this.props.document.download();
|
||||
};
|
||||
|
||||
handleShareLink = async (ev: SyntheticEvent<*>) => {
|
||||
handleShareLink = async (ev: SyntheticEvent<>) => {
|
||||
const { document } = this.props;
|
||||
if (!document.shareUrl) await document.share();
|
||||
|
||||
@@ -76,21 +108,65 @@ class DocumentMenu extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { document, label, className, showPrint, auth } = this.props;
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
const {
|
||||
document,
|
||||
position,
|
||||
className,
|
||||
showPrint,
|
||||
showPin,
|
||||
auth,
|
||||
onOpen,
|
||||
onClose,
|
||||
} = this.props;
|
||||
const canShareDocuments = auth.team && auth.team.sharing;
|
||||
|
||||
if (document.isArchived) {
|
||||
return (
|
||||
<DropdownMenu
|
||||
label={
|
||||
<NudeButton>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
}
|
||||
className={className}
|
||||
>
|
||||
<DropdownMenuItem onClick={this.handleRestore}>
|
||||
Restore
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.handleDelete}>
|
||||
Delete…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu label={label || <MoreIcon />} className={className}>
|
||||
{!document.isDraft && (
|
||||
<DropdownMenu
|
||||
label={
|
||||
<NudeButton>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
}
|
||||
className={className}
|
||||
position={position}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
{!document.isDraft ? (
|
||||
<React.Fragment>
|
||||
{document.pinned ? (
|
||||
<DropdownMenuItem onClick={this.handleUnpin}>
|
||||
Unpin
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={this.handlePin}>Pin</DropdownMenuItem>
|
||||
)}
|
||||
{document.starred ? (
|
||||
{showPin &&
|
||||
(document.pinned ? (
|
||||
<DropdownMenuItem onClick={this.handleUnpin}>
|
||||
Unpin
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={this.handlePin}>
|
||||
Pin to collection
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{document.isStarred ? (
|
||||
<DropdownMenuItem onClick={this.handleUnstar}>
|
||||
Unstar
|
||||
</DropdownMenuItem>
|
||||
@@ -117,13 +193,33 @@ class DocumentMenu extends React.Component<Props> {
|
||||
>
|
||||
New child document
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.handleEdit}>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.handleDuplicate}>
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.handleArchive}>
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.handleDelete}>
|
||||
Delete…
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.handleMove}>Move…</DropdownMenuItem>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
{canShareDocuments && (
|
||||
<DropdownMenuItem
|
||||
onClick={this.handleShareLink}
|
||||
title="Create a public share link"
|
||||
>
|
||||
Share link…
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={this.handleDelete}>
|
||||
Delete…
|
||||
</DropdownMenuItem>
|
||||
</React.Fragment>
|
||||
)}
|
||||
<DropdownMenuItem onClick={this.handleDelete}>Delete…</DropdownMenuItem>
|
||||
<hr />
|
||||
<DropdownMenuItem onClick={this.handleExport}>
|
||||
Download
|
||||
@@ -136,4 +232,4 @@ class DocumentMenu extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(inject('ui', 'auth')(DocumentMenu));
|
||||
export default inject('ui', 'auth', 'collections')(DocumentMenu);
|
||||
|
||||
@@ -1,48 +1,59 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { observable } from 'mobx';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import { MoreIcon } from 'outline-icons';
|
||||
|
||||
import { newDocumentUrl } from 'utils/routeHelpers';
|
||||
import Document from 'models/Document';
|
||||
import CollectionsStore from 'stores/CollectionsStore';
|
||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||
|
||||
type Props = {
|
||||
label?: React.Node,
|
||||
history: Object,
|
||||
document: Document,
|
||||
collections: CollectionsStore,
|
||||
};
|
||||
|
||||
@observer
|
||||
class NewChildDocumentMenu extends React.Component<Props> {
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
componentDidUpdate() {
|
||||
this.redirectTo = undefined;
|
||||
}
|
||||
|
||||
handleNewDocument = () => {
|
||||
const { history, document } = this.props;
|
||||
history.push(newDocumentUrl(document.collection));
|
||||
const { document } = this.props;
|
||||
this.redirectTo = newDocumentUrl(document.collectionId);
|
||||
};
|
||||
|
||||
handleNewChild = () => {
|
||||
const { history, document } = this.props;
|
||||
history.push(
|
||||
`${document.collection.url}/new?parentDocument=${document.id}`
|
||||
);
|
||||
const { document } = this.props;
|
||||
this.redirectTo = newDocumentUrl(document.collectionId, document.id);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { label, document, history, ...rest } = this.props;
|
||||
const { collection } = document;
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
const { label, document, collections, ...rest } = this.props;
|
||||
const collection = collections.get(document.collectionId);
|
||||
|
||||
return (
|
||||
<DropdownMenu label={label || <MoreIcon />} {...rest}>
|
||||
<DropdownMenuItem onClick={this.handleNewChild}>
|
||||
New child document
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.handleNewDocument}>
|
||||
<span>
|
||||
New document in <strong>{collection.name}</strong>
|
||||
New document in{' '}
|
||||
<strong>{collection ? collection.name : 'collection'}</strong>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.handleNewChild}>
|
||||
New child document
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(NewChildDocumentMenu);
|
||||
export default inject('collections')(NewChildDocumentMenu);
|
||||
|
||||
@@ -1,38 +1,54 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { inject } from 'mobx-react';
|
||||
import { MoreIcon, CollectionIcon, PrivateCollectionIcon } from 'outline-icons';
|
||||
import { observable } from 'mobx';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { PlusIcon, CollectionIcon, PrivateCollectionIcon } from 'outline-icons';
|
||||
|
||||
import { newDocumentUrl } from 'utils/routeHelpers';
|
||||
import CollectionsStore from 'stores/CollectionsStore';
|
||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||
import Button from 'components/Button';
|
||||
|
||||
type Props = {
|
||||
label?: React.Node,
|
||||
history: Object,
|
||||
collections: CollectionsStore,
|
||||
};
|
||||
|
||||
@observer
|
||||
class NewDocumentMenu extends React.Component<Props> {
|
||||
handleNewDocument = collection => {
|
||||
this.props.history.push(newDocumentUrl(collection));
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
componentDidUpdate() {
|
||||
this.redirectTo = undefined;
|
||||
}
|
||||
|
||||
handleNewDocument = (collectionId: string) => {
|
||||
this.redirectTo = newDocumentUrl(collectionId);
|
||||
};
|
||||
|
||||
onOpen = () => {
|
||||
const { collections } = this.props;
|
||||
|
||||
if (collections.orderedData.length === 1) {
|
||||
this.handleNewDocument(collections.orderedData[0]);
|
||||
this.handleNewDocument(collections.orderedData[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collections, label, history, ...rest } = this.props;
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
const { collections, label, ...rest } = this.props;
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
label={label || <MoreIcon />}
|
||||
label={
|
||||
label || (
|
||||
<Button icon={<PlusIcon />} small>
|
||||
New doc
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
onOpen={this.onOpen}
|
||||
{...rest}
|
||||
>
|
||||
@@ -40,7 +56,7 @@ class NewDocumentMenu extends React.Component<Props> {
|
||||
{collections.orderedData.map(collection => (
|
||||
<DropdownMenuItem
|
||||
key={collection.id}
|
||||
onClick={() => this.handleNewDocument(collection)}
|
||||
onClick={() => this.handleNewDocument(collection.id)}
|
||||
>
|
||||
{collection.private ? (
|
||||
<PrivateCollectionIcon color={collection.color} />
|
||||
@@ -55,4 +71,4 @@ class NewDocumentMenu extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(inject('collections')(NewDocumentMenu));
|
||||
export default inject('collections')(NewDocumentMenu);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withRouter, type RouterHistory } from 'react-router-dom';
|
||||
import { inject } from 'mobx-react';
|
||||
import { MoreIcon } from 'outline-icons';
|
||||
|
||||
import NudeButton from 'components/NudeButton';
|
||||
import CopyToClipboard from 'components/CopyToClipboard';
|
||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||
import { documentHistoryUrl } from 'utils/routeHelpers';
|
||||
@@ -12,10 +13,9 @@ import Document from 'models/Document';
|
||||
import UiStore from 'stores/UiStore';
|
||||
|
||||
type Props = {
|
||||
label?: React.Node,
|
||||
onOpen?: () => *,
|
||||
onClose: () => *,
|
||||
history: Object,
|
||||
onOpen?: () => void,
|
||||
onClose: () => void,
|
||||
history: RouterHistory,
|
||||
document: Document,
|
||||
revision: Revision,
|
||||
className?: string,
|
||||
@@ -23,19 +23,19 @@ type Props = {
|
||||
};
|
||||
|
||||
class RevisionMenu extends React.Component<Props> {
|
||||
handleRestore = async (ev: SyntheticEvent<*>) => {
|
||||
handleRestore = async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
await this.props.document.restore(this.props.revision);
|
||||
this.props.ui.showToast('Document restored', 'success');
|
||||
this.props.ui.showToast('Document restored');
|
||||
this.props.history.push(this.props.document.url);
|
||||
};
|
||||
|
||||
handleCopy = () => {
|
||||
this.props.ui.showToast('Link copied', 'success');
|
||||
this.props.ui.showToast('Link copied');
|
||||
};
|
||||
|
||||
render() {
|
||||
const { label, className, onOpen, onClose } = this.props;
|
||||
const { className, onOpen, onClose } = this.props;
|
||||
const url = `${window.location.origin}${documentHistoryUrl(
|
||||
this.props.document,
|
||||
this.props.revision.id
|
||||
@@ -43,7 +43,11 @@ class RevisionMenu extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
label={label || <MoreIcon />}
|
||||
label={
|
||||
<NudeButton>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
className={className}
|
||||
|
||||
@@ -1,48 +1,60 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { inject } from 'mobx-react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import { observable } from 'mobx';
|
||||
import { MoreIcon } from 'outline-icons';
|
||||
|
||||
import NudeButton from 'components/NudeButton';
|
||||
import CopyToClipboard from 'components/CopyToClipboard';
|
||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||
|
||||
import SharesStore from 'stores/SharesStore';
|
||||
import UiStore from 'stores/UiStore';
|
||||
import Share from 'models/Share';
|
||||
|
||||
type Props = {
|
||||
label?: React.Node,
|
||||
onOpen?: () => *,
|
||||
onClose: () => *,
|
||||
history: Object,
|
||||
onOpen?: () => void,
|
||||
onClose: () => void,
|
||||
shares: SharesStore,
|
||||
ui: UiStore,
|
||||
share: Share,
|
||||
};
|
||||
|
||||
@observer
|
||||
class ShareMenu extends React.Component<Props> {
|
||||
handleGoToDocument = (ev: SyntheticEvent<*>) => {
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
componentDidUpdate() {
|
||||
this.redirectTo = undefined;
|
||||
}
|
||||
|
||||
handleGoToDocument = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.props.history.push(this.props.share.documentUrl);
|
||||
this.redirectTo = this.props.share.documentUrl;
|
||||
};
|
||||
|
||||
handleRevoke = (ev: SyntheticEvent<*>) => {
|
||||
handleRevoke = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.props.shares.revoke(this.props.share);
|
||||
this.props.ui.showToast('Share link revoked', 'success');
|
||||
this.props.ui.showToast('Share link revoked');
|
||||
};
|
||||
|
||||
handleCopy = () => {
|
||||
this.props.ui.showToast('Share link copied', 'success');
|
||||
this.props.ui.showToast('Share link copied');
|
||||
};
|
||||
|
||||
render() {
|
||||
const { share, label, onOpen, onClose } = this.props;
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
const { share, onOpen, onClose } = this.props;
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
label={label || <MoreIcon />}
|
||||
label={
|
||||
<NudeButton>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
@@ -61,4 +73,4 @@ class ShareMenu extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(inject('shares', 'ui')(ShareMenu));
|
||||
export default inject('shares', 'ui')(ShareMenu);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { inject, observer } from 'mobx-react';
|
||||
import { MoreIcon } from 'outline-icons';
|
||||
|
||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||
import NudeButton from 'components/NudeButton';
|
||||
import UsersStore from 'stores/UsersStore';
|
||||
import User from 'models/User';
|
||||
|
||||
@@ -14,7 +15,7 @@ type Props = {
|
||||
|
||||
@observer
|
||||
class UserMenu extends React.Component<Props> {
|
||||
handlePromote = (ev: SyntheticEvent<*>) => {
|
||||
handlePromote = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { user, users } = this.props;
|
||||
if (
|
||||
@@ -29,7 +30,7 @@ class UserMenu extends React.Component<Props> {
|
||||
users.promote(user);
|
||||
};
|
||||
|
||||
handleDemote = (ev: SyntheticEvent<*>) => {
|
||||
handleDemote = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { user, users } = this.props;
|
||||
if (!window.confirm(`Are you want to make ${user.name} a member?`)) {
|
||||
@@ -38,7 +39,7 @@ class UserMenu extends React.Component<Props> {
|
||||
users.demote(user);
|
||||
};
|
||||
|
||||
handleSuspend = (ev: SyntheticEvent<*>) => {
|
||||
handleSuspend = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { user, users } = this.props;
|
||||
if (
|
||||
@@ -51,7 +52,7 @@ class UserMenu extends React.Component<Props> {
|
||||
users.suspend(user);
|
||||
};
|
||||
|
||||
handleActivate = (ev: SyntheticEvent<*>) => {
|
||||
handleActivate = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { user, users } = this.props;
|
||||
users.activate(user);
|
||||
@@ -61,7 +62,13 @@ class UserMenu extends React.Component<Props> {
|
||||
const { user } = this.props;
|
||||
|
||||
return (
|
||||
<DropdownMenu label={<MoreIcon />}>
|
||||
<DropdownMenu
|
||||
label={
|
||||
<NudeButton>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
}
|
||||
>
|
||||
{!user.isSuspended &&
|
||||
(user.isAdmin ? (
|
||||
<DropdownMenuItem onClick={this.handleDemote}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user