Merge pull request #1 from outline/master

merge
This commit is contained in:
Agboola Sherriff
2019-09-14 21:43:17 +01:00
committed by GitHub
351 changed files with 14621 additions and 16838 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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", "."]

View File

@@ -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

View File

@@ -1 +1 @@
yarn lint:flow
yarn flow

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"javascript.validate.enable": false
}

View File

@@ -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

View File

@@ -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:

View File

@@ -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 youre 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).

View File

@@ -1,2 +0,0 @@
import idObj from 'identity-obj-proxy';
export default idObj;

147
app.json Normal file
View 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
}
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -34,7 +34,7 @@ const Authenticated = observer(({ auth, children }: Props) => {
return children;
}
auth.logout();
auth.logout(true);
return null;
});

View File

@@ -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
View 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;

View File

@@ -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} />
));

View File

@@ -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>

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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, {

View File

@@ -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;

View File

@@ -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 were 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);

View File

@@ -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);

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>
&nbsp;saved <Time dateTime={updatedAt} /> ago
</span>
) : (
<Modified highlight={modifiedSinceViewed}>
&nbsp;updated <Time dateTime={updatedAt} /> ago
</Modified>
)}
</React.Fragment>
)}
{collection && (
<span>
&nbsp;in <strong>{isDraft ? 'Drafts' : collection.name}</strong>
</span>
)}
</Container>
);
}
export default PublishingInfo;

View File

@@ -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));

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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} />)
);

View File

@@ -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'};
`;

View File

@@ -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) => {

View File

@@ -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>
)}

View File

@@ -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')};
`;

View File

@@ -39,6 +39,8 @@ function Highlight({
const Mark = styled.mark`
background: ${props => props.theme.yellow};
border-radius: 2px;
padding: 0 4px;
`;
export default Highlight;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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};

View File

@@ -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;
`;

View File

@@ -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));

View File

@@ -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`

View File

@@ -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();
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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>
);
}

View 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} />
));

View File

@@ -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 were 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} />
);
}
}

View File

@@ -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;

View 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>
&nbsp;deleted <Time dateTime={deletedAt} /> ago
</span>
);
} else if (archivedAt) {
content = (
<span>
&nbsp;archived <Time dateTime={archivedAt} /> ago
</span>
);
} else if (publishedAt && (neverUpdated || showPublished)) {
content = (
<span>
&nbsp;published <Time dateTime={publishedAt} /> ago
</span>
);
} else if (isDraft) {
content = (
<span>
&nbsp;saved <Time dateTime={updatedAt} /> ago
</span>
);
} else {
content = (
<Modified highlight={modifiedSinceViewed}>
&nbsp;updated <Time dateTime={updatedAt} /> ago
</Modified>
);
}
const collection = collections.get(document.collectionId);
return (
<Container align="center">
{updatedBy.name}
{content}
{showCollection &&
collection && (
<span>
&nbsp;in&nbsp;
<strong>
{isDraft ? 'Drafts' : <Breadcrumb document={document} onlyText />}
</strong>
</span>
)}
</Container>
);
}
export default inject('collections')(PublishingInfo);

View File

@@ -6,7 +6,7 @@ import UiStore from 'stores/UiStore';
type Props = {
ui: UiStore,
component: *,
component: React.ComponentType<any>,
};
class RouteSidebarHidden extends React.Component<Props> {

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);
};

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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}

View File

@@ -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)
);

View File

@@ -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}

View File

@@ -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;
`;

View File

@@ -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`

View File

@@ -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%;

View File

@@ -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>
);
}

View File

@@ -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>

View 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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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
View 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));

View File

@@ -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;

View File

@@ -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 teams 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;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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} &middot; <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;

View 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
View 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})`}
/>
);
}
}

View 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);
});
});

View File

@@ -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,

View File

@@ -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
View 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
/>
);
}
}

View 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);
});
});

View File

@@ -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})`}
/>
);

View File

@@ -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);
});

View File

@@ -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 = {

View File

@@ -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})`}
/>
);

View File

@@ -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;
`;

View File

@@ -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,

View File

@@ -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-',
});
});

View File

@@ -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));

View File

@@ -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));

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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}

View File

@@ -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);

View File

@@ -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