Compare commits

..

1 Commits

Author SHA1 Message Date
Tom Moor ce566a640c first draft, showing diffs in notification emails 2020-07-11 00:26:06 -07:00
696 changed files with 25440 additions and 47198 deletions
+14 -25
View File
@@ -1,29 +1,18 @@
{
"presets": [
"@babel/preset-react",
"@babel/preset-flow",
[
"@babel/preset-env",
{
"corejs": {
"version": "2",
"proposals": true
},
"useBuiltIns": "usage"
}
]
],
"presets": ["react", "env"],
"plugins": [
"lodash",
"styled-components",
[
"@babel/plugin-proposal-decorators",
{
"legacy": true
}
],
"@babel/plugin-transform-destructuring",
"@babel/plugin-transform-regenerator",
"transform-class-properties"
]
}
"transform-decorators-legacy",
"transform-es2015-destructuring",
"transform-object-rest-spread",
"transform-regenerator",
"transform-class-properties",
"syntax-dynamic-import"
],
"env": {
"development": {
"presets": ["react-hmre"]
}
}
}
+5 -8
View File
@@ -3,7 +3,7 @@ jobs:
build:
working_directory: ~/outline
docker:
- image: circleci/node:14
- image: circleci/node:12
- image: circleci/redis:latest
- image: circleci/postgres:9.6.5-alpine-ram
environment:
@@ -29,15 +29,12 @@ jobs:
- run:
name: migrate
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
- run:
name: test
command: yarn test
- run:
name: lint
command: yarn lint
- run:
name: flow
command: yarn flow check --max-workers 4
- run:
name: test
command: yarn test
- run:
name: build-webpack
command: yarn build:webpack
command: yarn flow check --max-workers 4
-19
View File
@@ -1,19 +0,0 @@
__mocks__
.git
.vscode
.github
.circleci
.DS_Store
.env*
.eslint*
.flowconfig
.log
Makefile
Procfile
app.json
build
docker-compose.yml
fakes3
flow-typed
node_modules
setupJest.js
+3 -17
View File
@@ -10,30 +10,21 @@ DATABASE_URL=postgres://user:pass@localhost:5532/outline
DATABASE_URL_TEST=postgres://user:pass@localhost:5532/outline-test
REDIS_URL=redis://localhost:6479
# Must point to the publicly accessible URL for the installation
URL=http://localhost:3000
PORT=3000
# Optional. If using a Cloudfront distribution or similar the origin server
# should be set to the same as URL.
CDN_URL=
# enforce (auto redirect to) https in production, (optional) default is true.
# set to false if your SSL is terminated at a loadbalancer, for example
# set to false if your SSL is terminated at a loadbalancer, for example
FORCE_HTTPS=true
ENABLE_UPDATES=true
DEBUG=cache,presenters,events,emails,mailer,utils,multiplayer,server,services
WEBSOCKETS_ENABLED=true
DEBUG=cache,presenters,events
# Third party signin credentials (at least one is required)
SLACK_KEY=get_a_key_from_slack
SLACK_SECRET=get_the_secret_of_above_key
# To configure Google auth, you'll need to create an OAuth Client ID at
# => https://console.cloud.google.com/apis/credentials
#
# When configuring the Client ID, add an Authorized redirect URI:
# https://<your Outline URL>/auth/google.callback
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
@@ -55,7 +46,6 @@ AWS_REGION=xx-xxxx-x
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
AWS_S3_UPLOAD_MAX_SIZE=26214400
AWS_S3_FORCE_PATH_STYLE=true
# uploaded s3 objects permission level, default is private
# set to "public-read" to allow public access
AWS_S3_ACL=private
@@ -70,7 +60,3 @@ SMTP_REPLY_EMAIL=
# Custom logo that displays on the authentication screen, scaled to height: 60px
# TEAM_LOGO=https://example.com/images/logo.png
# See translate.getoutline.com for a list of available language codes and their
# percentage translated.
DEFAULT_LANGUAGE=en_US
+10 -44
View File
@@ -4,8 +4,7 @@
"react-app",
"plugin:import/errors",
"plugin:import/warnings",
"plugin:flowtype/recommended",
"plugin:react-hooks/recommended"
"plugin:flowtype/recommended"
],
"plugins": [
"prettier",
@@ -15,46 +14,6 @@
"eqeqeq": 2,
"no-unused-vars": 2,
"no-mixed-operators": "off",
"import/order": [
"error",
{
"alphabetize": {
"order": "asc"
},
"pathGroups": [
{
"pattern": "shared/**",
"group": "external",
"position": "after"
},
{
"pattern": "stores",
"group": "external",
"position": "after"
},
{
"pattern": "stores/**",
"group": "external",
"position": "after"
},
{
"pattern": "models/**",
"group": "external",
"position": "after"
},
{
"pattern": "scenes/**",
"group": "external",
"position": "after"
},
{
"pattern": "components/**",
"group": "external",
"position": "after"
}
]
}
],
"flowtype/require-valid-file-annotation": [
2,
"always",
@@ -82,7 +41,8 @@
"react": {
"createClass": "createReactClass",
"pragma": "React",
"version": "detect"
"version": "detect",
"flowVersion": "0.86"
},
"import/resolver": {
"node": {
@@ -100,6 +60,12 @@
"jest": true
},
"globals": {
"EDITOR_VERSION": true
"__DEV__": true,
"SLACK_KEY": true,
"DEPLOYMENT": true,
"BASE_URL": true,
"SENTRY_DSN": true,
"afterAll": true,
"Sentry": true
}
}
-3
View File
@@ -7,7 +7,6 @@
.*/node_modules/tiny-cookie/flow/.*
.*/node_modules/styled-components/.*
.*/node_modules/polished/.*
.*/node_modules/mobx/.*.flow
.*/node_modules/react-side-effect/.*
.*/node_modules/fbjs/.*
.*/node_modules/config-chain/.*
@@ -18,7 +17,6 @@
[options]
emoji=true
sharedmemory.heap_size=3221225472
module.system.node.resolve_dirname=node_modules
module.system.node.resolve_dirname=app
@@ -33,7 +31,6 @@ module.file_ext=.json
esproposal.decorators=ignore
esproposal.class_static_fields=enable
esproposal.class_instance_fields=enable
esproposal.optional_chaining=enable
suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe
suppress_comment=\\(.\\|\n\\)*\\$FlowIssue
-10
View File
@@ -1,10 +0,0 @@
# Set to true to add reviewers to pull requests
addReviewers: true
# A list of reviewers to be added to pull requests (GitHub user name)
reviewers:
- tommoor
# A list of keywords to be skipped the process that add reviewers if pull requests include it
skipKeywords:
- wip
-2
View File
@@ -1,5 +1,4 @@
dist
build
node_modules/*
server/scripts
.env
@@ -8,4 +7,3 @@ npm-debug.log
stats.json
.DS_Store
fakes3/*
.idea
+1 -5
View File
@@ -1,7 +1,3 @@
{
"javascript.validate.enable": false,
"javascript.format.enable": false,
"typescript.validate.enable": false,
"typescript.format.enable": false,
"editor.formatOnSave": true,
"javascript.validate.enable": false
}
+7 -14
View File
@@ -1,23 +1,16 @@
FROM node:14-alpine
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
ENV APP_PATH /opt/outline
RUN mkdir -p $APP_PATH
WORKDIR $APP_PATH
COPY . $APP_PATH
COPY package.json ./
COPY yarn.lock ./
RUN yarn install --pure-lockfile
RUN cp -r /opt/outline/node_modules /opt/node_modules
RUN yarn --pure-lockfile
COPY . .
RUN yarn build && \
yarn --production --ignore-scripts --prefer-offline && \
rm -rf shared && \
rm -rf app
ENV NODE_ENV production
CMD yarn start
CMD yarn build && yarn start
EXPOSE 3000
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.51.0
Licensed Work: Outline 0.44.0
The Licensed Work is (c) 2020 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2023-12-13
Change Date: 2023-07-03
Change License: Apache License, Version 2.0
+2 -10
View File
@@ -8,18 +8,10 @@ build:
docker-compose build --pull outline
test:
docker-compose up -d redis postgres s3
yarn sequelize db:drop --env=test
yarn sequelize db:create --env=test
yarn sequelize db:migrate --env=test
yarn test
docker-compose run --rm outline yarn test
watch:
docker-compose up -d redis postgres s3
yarn sequelize db:drop --env=test
yarn sequelize db:create --env=test
yarn sequelize db:migrate --env=test
yarn test:watch
docker-compose run --rm outline yarn test:watch
destroy:
docker-compose stop
+1 -1
View File
@@ -1 +1 @@
web: node ./build/server/index.js
web: node index.js
+31 -52
View File
@@ -12,7 +12,6 @@
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield&amp;circle-token=c0c4c2f39990e277385d5c1ae96169c409eb887a"></a>
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat"></a>
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg"></a>
<a href="https://translate.getoutline.com/project/outline"><img src="https://badges.crowdin.net/outline/localized.svg"></a>
</p>
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com).
@@ -23,38 +22,13 @@ If you'd like to run your own copy of Outline or contribute to development then
Outline requires the following dependencies:
- [Node.js](https://nodejs.org/) >= 12
- [Yarn](https://yarnpkg.com)
- [Postgres](https://www.postgresql.org/download/) >=9.5
- [Redis](https://redis.io/) >= 4
- AWS S3 bucket or compatible API for file storage
- Node.js >= 12
- Postgres >=9.5
- Redis >= 4
- AWS S3 storage bucket for media and other attachments
- Slack or Google developer application for authentication
### Production
For a manual self-hosted production installation these are the suggested steps:
1. Clone this repo and install dependencies with `yarn install`
1. Build the source code with `yarn build`
1. Using the `.env.sample` as a reference, set the required variables in your production environment. The following are required as a minimum:
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
1. `DATABASE_URL` (run your own local copy of Postgres, or use a cloud service)
1. `REDIS_URL` (run your own local copy of Redis, or use a cloud service)
1. `URL` (the public facing URL of your installation)
1. `AWS_` (all of the keys beginning with AWS)
1. Migrate database schema with `yarn sequelize:migrate`. Production assumes an SSL connection, if
Postgres is on the same machine and is not SSL you can migrate with `yarn sequelize:migrate --env=production-ssl-disabled`.
1. Start the service with any daemon tools you prefer. Take PM2 for example, `NODE_ENV=production pm2 start ./build/server/index.js --name outline `
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
> Port number can be changed using the `PORT` environment variable
1. (Optional) You can add an `nginx` reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc.
### Development
In development you can quickly get an environment running using Docker by following these steps:
@@ -75,20 +49,32 @@ In development you can quickly get an environment running using Docker by follow
1. Ensure that the bot token scope contains at least `users:read`
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
### Upgrade
#### Docker
### Production
If you're running Outline with Docker you'll need to run migrations within the docker container after updating the image. The command will be something like:
```
docker run --rm outlinewiki/outline:latest yarn sequelize:migrate
```
#### Yarn
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](https://nodejs.org/) and [yarn](https://yarnpkg.com) installed
1. Build the web app with `yarn build:webpack` or `npm run build:webpack`
1. Using the `.env.sample` as a reference, set the required variables in your production environment. The following are required as a minimum:
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
1. `DATABASE_URL` (run your own local copy of Postgres, or use a cloud service)
1. `REDIS_URL` (run your own local copy of Redis, or use a cloud service)
1. `URL` (the public facing URL of your installation)
1. `AWS_` (all of the keys beginning with AWS)
1. Migrate database schema with `yarn sequelize:migrate` 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.
If you're running Outline by cloning this repository, run the following command to upgrade:
```
yarn run upgrade
```
## Development
@@ -140,6 +126,7 @@ Backend is driven by [Koa](http://koajs.com/) (API, web server), [Sequelize](htt
- `server/commands` - Domain logic, currently being refactored from /models
- `server/emails` - React rendered email templates
- `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
@@ -154,16 +141,8 @@ To add new tests, write your tests with [Jest](https://facebook.github.io/jest/)
```shell
# To run all tests
make test
yarn test
# To run backend tests in watch mode
make watch
```
Once the test database is created with `make test` you may individually run
frontend and backend tests directly.
```shell
# To run backend tests
yarn test:server
@@ -175,15 +154,15 @@ yarn test:app
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 the [Discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and will hopefully help to get your contributions integrated faster!
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!
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
* [Translation](TRANSLATION.md) into other languages
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
* Performance improvements, both on server and frontend
* Developer happiness and documentation
* Bugs and other issues listed on GitHub
* Helping others on Spectrum
## License
-34
View File
@@ -1,34 +0,0 @@
# Translation
Outline is localized through community contributions. The text in Outline's user interface is in American English by default, we're very thankful for all help that the community provides bringing the app to different languages.
## Externalizing strings
Before a string can be translated, it must be externalized. This is the process where English strings in the source code are wrapped in a function that retrieves the translated string for the users language.
For externalization we use [react-i18next](https://react.i18next.com/), this provides the hooks [useTranslation](https://react.i18next.com/latest/usetranslation-hook) and the [Trans](https://react.i18next.com/latest/trans-component) component for wrapping English text.
PR's are accepted for wrapping English strings in the codebase that were not previously externalized.
## Translating strings
To manage the translation process we use [CrowdIn](https://translate.getoutline.com/), it keeps track of which strings in which languages still need translating, synchronizes with the codebase automatically, and provides a great editor interface.
You'll need to create a free account to use CrowdIn. Once you have joined, you can provide translations by following these steps:
1. Select the language for which you want to contribute (or vote for) a translation (below the language you can see the progress of the translation)
![CrowdIn UI](https://i.imgur.com/AkbDY60.png)
2. Please choose the translation.json file from your desired language
3. Once a file is selected, all the strings associated with the version are displayed on the left side. To display the untranslated strings first, select the filter icon next to the search bar and select “All, Untranslated First”.The red square next to an English string shows that a string has not been translated yet. To provide a translation, select a string on the left side, provide a translation in the target language in the text box in the right side (singular and plural) and press the save button. As soon as a translation has been provided by another user (green square next to string), you can also vote on a translation provided by another user. The translation with the most votes is used unless a different translation has been approved by a proof reader. ![Editor UI](https://i.imgur.com/pldZCRs.png)
## Proofreading
Once a translation has been provided, a proof reader can approve the translation and mark it for use in Outline.
If you are interested in becoming a proof reader, please contact one of the project managers in the Outline CrowdIn project or contact [@tommoor](https://github.com/tommoor). Similarly, if your language is not listed in the list of CrowdIn languages, please contact our project managers or [send us an email](https://www.getoutline.com/contact) so we can add your language.
## Release
Updated translations are automatically PR'd against the codebase by a bot and will be merged regularly so that new translations appear in the next release of Outline.
-18
View File
@@ -1,18 +0,0 @@
/* eslint-disable flowtype/require-valid-file-annotation */
export default class Queue {
name;
constructor(name) {
this.name = name;
}
process = (fn) => {
console.log(`Registered function ${this.name}`);
this.processFn = fn;
};
add = (data) => {
console.log(`Running ${this.name}`);
return this.processFn({ data });
};
}
+5 -5
View File
@@ -39,6 +39,11 @@
"value": "true",
"required": true
},
"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
@@ -92,11 +97,6 @@
"value": "26214400",
"required": false
},
"AWS_S3_FORCE_PATH_STYLE": {
"description": "Use path-style URL's for connecting to S3 instead of subdomain. This is useful for S3-compatible storage.",
"value": "true",
"required": false
},
"AWS_REGION": {
"value": "us-east-1",
"description": "Region in which the above S3 bucket exists",
+7 -6
View File
@@ -1,7 +1,7 @@
// @flow
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Flex from "components/Flex";
import Flex from "shared/components/Flex";
export const Action = styled(Flex)`
justify-content: center;
@@ -11,8 +11,9 @@ export const Action = styled(Flex)`
font-size: 15px;
flex-shrink: 0;
&:empty {
display: none;
a {
color: ${props => props.theme.text};
height: 24px;
}
`;
@@ -21,7 +22,7 @@ export const Separator = styled.div`
margin-left: 12px;
width: 1px;
height: 28px;
background: ${(props) => props.theme.divider};
background: ${props => props.theme.divider};
`;
const Actions = styled(Flex)`
@@ -30,8 +31,8 @@ const Actions = styled(Flex)`
right: 0;
left: 0;
border-radius: 3px;
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
background: ${props => props.theme.background};
transition: ${props => props.theme.backgroundTransition};
padding: 12px;
-webkit-backdrop-filter: blur(20px);
+36
View File
@@ -0,0 +1,36 @@
// @flow
import * as React from "react";
import { observer } from "mobx-react";
import Flex from "shared/components/Flex";
import styled from "styled-components";
type Props = {
children: React.Node,
type?: "info" | "success" | "warning" | "danger" | "offline",
};
@observer
class Alert extends React.Component<Props> {
defaultProps = {
type: "info",
};
render() {
return (
<Container align="center" justify="center" type={this.props.type}>
{this.props.children}
</Container>
);
}
}
const Container = styled(Flex)`
height: $headerHeight;
color: ${props => props.theme.white};
font-size: 14px;
line-height: 1;
background-color: ${({ theme, type }) => theme.color[type]};
`;
export default Alert;
+3 -4
View File
@@ -1,7 +1,6 @@
// @flow
/* global ga */
import * as React from "react";
import env from "env";
type Props = {
children?: React.Node,
@@ -9,19 +8,19 @@ type Props = {
export default class Analytics extends React.Component<Props> {
componentDidMount() {
if (!env.GOOGLE_ANALYTICS_ID) return;
if (!process.env.GOOGLE_ANALYTICS_ID) return;
// standard Google Analytics script
window.ga =
window.ga ||
function () {
function() {
// $FlowIssue
(ga.q = ga.q || []).push(arguments);
};
// $FlowIssue
ga.l = +new Date();
ga("create", env.GOOGLE_ANALYTICS_ID, "auto");
ga("create", process.env.GOOGLE_ANALYTICS_ID, "auto");
ga("set", { dimension1: "true" });
ga("send", "pageview");
+13 -34
View File
@@ -1,32 +1,16 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom";
import { isCustomSubdomain } from "shared/utils/domains";
import { observer, inject } from "mobx-react";
import AuthStore from "stores/AuthStore";
import LoadingIndicator from "components/LoadingIndicator";
import useStores from "../hooks/useStores";
import env from "env";
import { isCustomSubdomain } from "shared/utils/domains";
type Props = {
children: React.Node,
auth: AuthStore,
children?: React.Node,
};
const Authenticated = ({ children }: Props) => {
const { auth } = useStores();
const { i18n } = useTranslation();
const language = auth.user && auth.user.language;
// Watching for language changes here as this is the earliest point we have
// the user available and means we can start loading translations faster
React.useEffect(() => {
if (language && i18n.language !== language) {
// Languages are stored in en_US format in the database, however the
// frontend translation framework (i18next) expects en-US
i18n.changeLanguage(language.replace("_", "-"));
}
}, [i18n, language]);
const Authenticated = observer(({ auth, children }: Props) => {
if (auth.authenticated) {
const { user, team } = auth;
const { hostname } = window.location;
@@ -35,15 +19,10 @@ const Authenticated = ({ children }: Props) => {
return <LoadingIndicator />;
}
// If we're authenticated but viewing a domain that doesn't match the
// current team then kick the user to the teams correct domain.
if (team.domain) {
if (team.domain !== hostname) {
window.location.href = `${team.url}${window.location.pathname}`;
return <LoadingIndicator />;
}
} else if (
env.SUBDOMAINS_ENABLED &&
// If we're authenticated but viewing a subdomain that doesn't match the
// currently authenticated team then kick the user to the teams subdomain.
if (
process.env.SUBDOMAINS_ENABLED &&
team.subdomain &&
isCustomSubdomain(hostname) &&
!hostname.startsWith(`${team.subdomain}.`)
@@ -56,7 +35,7 @@ const Authenticated = ({ children }: Props) => {
}
auth.logout(true);
return <Redirect to="/" />;
};
return null;
});
export default observer(Authenticated);
export default inject("auth")(Authenticated);
+9 -13
View File
@@ -1,19 +1,15 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import User from "models/User";
import { observable } from "mobx";
import { observer } from "mobx-react";
import placeholder from "./placeholder.png";
type Props = {|
type Props = {
src: string,
size: number,
icon?: React.Node,
user?: User,
onClick?: () => void,
className?: string,
|};
};
@observer
class Avatar extends React.Component<Props> {
@@ -52,8 +48,8 @@ const IconWrapper = styled.div`
position: absolute;
bottom: -2px;
right: -2px;
background: ${(props) => props.theme.primary};
border: 2px solid ${(props) => props.theme.background};
background: ${props => props.theme.primary};
border: 2px solid ${props => props.theme.background};
border-radius: 100%;
width: 20px;
height: 20px;
@@ -61,10 +57,10 @@ const IconWrapper = styled.div`
const CircleImg = styled.img`
display: block;
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
width: ${props => props.size}px;
height: ${props => props.size}px;
border-radius: 50%;
border: 2px solid ${(props) => props.theme.background};
border: 2px solid ${props => props.theme.background};
flex-shrink: 0;
`;
+13 -22
View File
@@ -1,15 +1,14 @@
// @flow
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
import * as React from "react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { EditIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import styled from "styled-components";
import User from "models/User";
import UserProfile from "scenes/UserProfile";
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
import Avatar from "components/Avatar";
import Tooltip from "components/Tooltip";
import User from "models/User";
import UserProfile from "scenes/UserProfile";
import { EditIcon } from "outline-icons";
type Props = {
user: User,
@@ -17,7 +16,6 @@ type Props = {
isEditing: boolean,
isCurrentUser: boolean,
lastViewedAt: string,
t: TFunction,
};
@observer
@@ -39,25 +37,18 @@ class AvatarWithPresence extends React.Component<Props> {
isPresent,
isEditing,
isCurrentUser,
t,
} = this.props;
const action = isPresent
? isEditing
? t("currently editing")
: t("currently viewing")
: t("viewed {{ timeAgo }} ago", {
timeAgo: distanceInWordsToNow(new Date(lastViewedAt)),
});
return (
<>
<React.Fragment>
<Tooltip
tooltip={
<Centered>
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
<strong>{user.name}</strong> {isCurrentUser && "(You)"}
<br />
{action}
{isPresent
? isEditing ? "currently editing" : "currently viewing"
: `viewed ${distanceInWordsToNow(new Date(lastViewedAt))} ago`}
</Centered>
}
placement="bottom"
@@ -76,7 +67,7 @@ class AvatarWithPresence extends React.Component<Props> {
isOpen={this.isOpen}
onRequestClose={this.handleCloseProfile}
/>
</>
</React.Fragment>
);
}
}
@@ -86,8 +77,8 @@ const Centered = styled.div`
`;
const AvatarWrapper = styled.div`
opacity: ${(props) => (props.isPresent ? 1 : 0.5)};
opacity: ${props => (props.isPresent ? 1 : 0.5)};
transition: opacity 250ms ease-in-out;
`;
export default withTranslation()<AvatarWithPresence>(AvatarWithPresence);
export default AvatarWithPresence;
+3 -4
View File
@@ -4,10 +4,9 @@ import styled from "styled-components";
const Badge = styled.span`
margin-left: 10px;
padding: 2px 6px 3px;
background-color: ${({ yellow, primary, theme }) =>
yellow ? theme.yellow : primary ? theme.primary : theme.textTertiary};
color: ${({ primary, yellow, theme }) =>
primary ? theme.white : yellow ? theme.almostBlack : theme.background};
background-color: ${({ admin, theme }) =>
admin ? theme.primary : theme.textTertiary};
color: ${({ admin, theme }) => (admin ? theme.white : theme.background)};
border-radius: 4px;
font-size: 11px;
font-weight: 500;
-204
View File
@@ -1,204 +0,0 @@
// @flow
import { observer } from "mobx-react";
import {
ArchiveIcon,
EditIcon,
GoToIcon,
PadlockIcon,
ShapesIcon,
TrashIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Document from "models/Document";
import CollectionIcon from "components/CollectionIcon";
import Flex from "components/Flex";
import useStores from "hooks/useStores";
import BreadcrumbMenu from "menus/BreadcrumbMenu";
import { collectionUrl } from "utils/routeHelpers";
type Props = {
document: Document,
onlyText: boolean,
};
function Icon({ document }) {
const { t } = useTranslation();
if (document.isDeleted) {
return (
<>
<CategoryName to="/trash">
<TrashIcon color="currentColor" />
&nbsp;
<span>{t("Trash")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isArchived) {
return (
<>
<CategoryName to="/archive">
<ArchiveIcon color="currentColor" />
&nbsp;
<span>{t("Archive")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isDraft) {
return (
<>
<CategoryName to="/drafts">
<EditIcon color="currentColor" />
&nbsp;
<span>{t("Drafts")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isTemplate) {
return (
<>
<CategoryName to="/templates">
<ShapesIcon color="currentColor" />
&nbsp;
<span>{t("Templates")}</span>
</CategoryName>
<Slash />
</>
);
}
return null;
}
const Breadcrumb = ({ document, onlyText }: Props) => {
const { collections } = useStores();
const { t } = useTranslation();
let collection = collections.get(document.collectionId);
if (!collection) {
collection = {
id: document.collectionId,
name: t("Deleted Collection"),
color: "currentColor",
};
}
const path = collection.pathToDocument
? collection.pathToDocument(document.id).slice(0, -1)
: [];
if (onlyText === true) {
return (
<>
{collection.private && (
<>
<SmallPadlockIcon color="currentColor" size={16} />{" "}
</>
)}
{collection.name}
{path.map((n) => (
<React.Fragment key={n.id}>
<SmallSlash />
{n.title}
</React.Fragment>
))}
</>
);
}
const isNestedDocument = path.length > 1;
const lastPath = path.length ? path[path.length - 1] : undefined;
const menuPath = isNestedDocument ? path.slice(0, -1) : [];
return (
<Wrapper justify="flex-start" align="center">
<Icon document={document} />
<CollectionName to={collectionUrl(collection.id)}>
<CollectionIcon collection={collection} expanded />
&nbsp;
<span>{collection.name}</span>
</CollectionName>
{isNestedDocument && (
<>
<Slash /> <BreadcrumbMenu path={menuPath} />
</>
)}
{lastPath && (
<>
<Slash />{" "}
<Crumb to={lastPath.url} title={lastPath.title}>
{lastPath.title}
</Crumb>
</>
)}
</Wrapper>
);
};
export const Slash = styled(GoToIcon)`
flex-shrink: 0;
fill: ${(props) => props.theme.divider};
`;
const Wrapper = styled(Flex)`
display: none;
${breakpoint("tablet")`
display: flex;
`};
`;
const SmallPadlockIcon = styled(PadlockIcon)`
display: inline-block;
vertical-align: sub;
`;
const SmallSlash = styled(GoToIcon)`
width: 15px;
height: 10px;
flex-shrink: 0;
opacity: 0.25;
`;
const Crumb = styled(Link)`
color: ${(props) => props.theme.text};
font-size: 15px;
height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
&:hover {
text-decoration: underline;
}
`;
const CollectionName = styled(Link)`
display: flex;
flex-shrink: 1;
color: ${(props) => props.theme.text};
font-size: 15px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
min-width: 0;
svg {
flex-shrink: 0;
}
`;
const CategoryName = styled(CollectionName)`
flex-shrink: 0;
`;
export default observer(Breadcrumb);
+56 -61
View File
@@ -1,17 +1,16 @@
// @flow
import { ExpandedIcon } from "outline-icons";
import { darken } from "polished";
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: ${(props) => (props.fullwidth ? "block" : "inline-block")};
width: ${(props) => (props.fullwidth ? "100%" : "auto")};
display: inline-block;
margin: 0;
padding: 0;
border: 0;
background: ${(props) => props.theme.buttonBackground};
color: ${(props) => props.theme.buttonText};
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: 14px;
@@ -19,16 +18,13 @@ const RealButton = styled.button`
height: 32px;
text-decoration: none;
flex-shrink: 0;
outline: none;
cursor: pointer;
user-select: none;
${(props) =>
!props.borderOnHover &&
`
svg {
fill: ${props.iconColor || props.theme.buttonText};
}
`}
svg {
fill: ${props => props.iconColor || props.theme.buttonText};
}
&::-moz-focus-inner {
padding: 0;
@@ -36,54 +32,68 @@ const RealButton = styled.button`
}
&:hover {
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
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) => props.theme.white50};
color: ${props => props.theme.white50};
}
${(props) =>
props.$neutral &&
${props =>
props.neutral &&
`
background: ${props.theme.buttonNeutralBackground};
color: ${props.theme.buttonNeutralText};
box-shadow: ${
props.borderOnHover
? "none"
: `rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.buttonNeutralBorder} 0 0 0 1px inset`
props.borderOnHover ? "none" : "rgba(0, 0, 0, 0.07) 0px 1px 2px"
};
border: 1px solid ${
props.borderOnHover ? "transparent" : props.theme.buttonNeutralBorder
};
${
props.borderOnHover
? ""
: `svg {
svg {
fill: ${props.iconColor || props.theme.buttonNeutralText};
}`
}
&:hover {
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
props.theme.buttonNeutralBorder
} 0 0 0 1px inset;
border: 1px solid ${props.theme.buttonNeutralBorder};
}
&: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 &&
`
`} ${props =>
props.danger &&
`
background: ${props.theme.danger};
color: ${props.theme.white};
&:hover {
background: ${darken(0.05, props.theme.danger)};
}
&: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;
}
`};
`;
@@ -92,24 +102,23 @@ const Label = styled.span`
white-space: nowrap;
text-overflow: ellipsis;
${(props) => props.hasIcon && "padding-left: 4px;"};
${props => props.hasIcon && "padding-left: 4px;"};
`;
export const Inner = styled.span`
display: flex;
padding: 0 8px;
padding-right: ${(props) => (props.disclosure ? 2 : 8)}px;
line-height: ${(props) => (props.hasIcon ? 24 : 32)}px;
padding-right: ${props => (props.disclosure ? 2 : 8)}px;
line-height: ${props => (props.hasIcon ? 24 : 32)}px;
justify-content: center;
align-items: center;
min-height: 32px;
${(props) => props.hasIcon && props.hasText && "padding-left: 4px;"};
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
${props => props.hasIcon && props.hasText && "padding-left: 4px;"};
${props => props.hasIcon && !props.hasText && "padding: 0 4px;"};
`;
export type Props = {|
type?: "button" | "submit",
export type Props = {
type?: string,
value?: string,
icon?: React.Node,
iconColor?: string,
@@ -117,22 +126,8 @@ export type Props = {|
children?: React.Node,
innerRef?: React.ElementRef<any>,
disclosure?: boolean,
neutral?: boolean,
danger?: boolean,
primary?: boolean,
disabled?: boolean,
fullwidth?: boolean,
autoFocus?: boolean,
style?: Object,
as?: React.ComponentType<any>,
to?: string,
onClick?: (event: SyntheticEvent<>) => mixed,
borderOnHover?: boolean,
"data-on"?: string,
"data-event-category"?: string,
"data-event-action"?: string,
|};
};
function Button({
type = "text",
@@ -141,14 +136,13 @@ function Button({
value,
disclosure,
innerRef,
neutral,
...rest
}: Props) {
const hasText = children !== undefined || value !== undefined;
const hasIcon = icon !== undefined;
return (
<RealButton type={type} ref={innerRef} $neutral={neutral} {...rest}>
<RealButton type={type} ref={innerRef} {...rest}>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
{hasIcon && icon}
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
@@ -158,6 +152,7 @@ function Button({
);
}
export default React.forwardRef<Props, typeof Button>((props, ref) => (
// $FlowFixMe - need to upgrade to get forwardRef
export default React.forwardRef((props, ref) => (
<Button {...props} innerRef={ref} />
));
-13
View File
@@ -1,13 +0,0 @@
// @flow
import styled from "styled-components";
import Button, { Inner } from "./Button";
const ButtonLarge = styled(Button)`
height: 40px;
${Inner} {
padding: 4px 16px;
}
`;
export default ButtonLarge;
-23
View File
@@ -1,23 +0,0 @@
// @flow
import * as React from "react";
import styled from "styled-components";
type Props = {
onClick: (ev: SyntheticEvent<>) => void,
children: React.Node,
};
export default function ButtonLink(props: Props) {
return <Button {...props} />;
}
const Button = styled.button`
margin: 0;
padding: 0;
border: 0;
color: ${(props) => props.theme.link};
line-height: inherit;
background: none;
text-decoration: none;
cursor: pointer;
`;
-1
View File
@@ -9,7 +9,6 @@ type Props = {
const Container = styled.div`
width: 100%;
max-width: 100vw;
padding: 60px 20px;
${breakpoint("tablet")`
+8 -12
View File
@@ -1,31 +1,27 @@
// @flow
import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import HelpText from "components/HelpText";
import VisuallyHidden from "components/VisuallyHidden";
export type Props = {|
export type Props = {
checked?: boolean,
label?: string,
labelHidden?: boolean,
className?: string,
name?: string,
disabled?: boolean,
onChange: (event: SyntheticInputEvent<HTMLInputElement>) => mixed,
note?: string,
short?: boolean,
small?: boolean,
|};
};
const LabelText = styled.span`
font-weight: 500;
margin-left: ${(props) => (props.small ? "6px" : "10px")};
${(props) => (props.small ? `color: ${props.theme.textSecondary}` : "")};
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" : "")};
${props => (props.small ? "font-size: 14px" : "")};
`;
const Label = styled.label`
@@ -46,7 +42,7 @@ export default function Checkbox({
const wrappedLabel = <LabelText small={small}>{label}</LabelText>;
return (
<>
<React.Fragment>
<Wrapper small={small}>
<Label>
<input type="checkbox" {...rest} />
@@ -59,6 +55,6 @@ export default function Checkbox({
</Label>
{note && <HelpText small>{note}</HelpText>}
</Wrapper>
</>
</React.Fragment>
);
}
+13 -15
View File
@@ -1,14 +1,14 @@
// @flow
import { sortBy, keyBy } from "lodash";
import { observer, inject } from "mobx-react";
import * as React from "react";
import { observer, inject } from "mobx-react";
import { sortBy, keyBy } from "lodash";
import { MAX_AVATAR_DISPLAY } from "shared/constants";
import DocumentPresenceStore from "stores/DocumentPresenceStore";
import ViewsStore from "stores/ViewsStore";
import Document from "models/Document";
import { AvatarWithPresence } from "components/Avatar";
import Facepile from "components/Facepile";
import Document from "models/Document";
import ViewsStore from "stores/ViewsStore";
import DocumentPresenceStore from "stores/DocumentPresenceStore";
type Props = {
views: ViewsStore,
@@ -20,9 +20,7 @@ type Props = {
@observer
class Collaborators extends React.Component<Props> {
componentDidMount() {
if (!this.props.document.isDeleted) {
this.props.views.fetchPage({ documentId: this.props.document.id });
}
this.props.views.fetchPage({ documentId: this.props.document.id });
}
render() {
@@ -34,27 +32,27 @@ class Collaborators extends React.Component<Props> {
const documentViews = views.inDocument(document.id);
const presentIds = documentPresence.map((p) => p.userId);
const presentIds = documentPresence.map(p => p.userId);
const editingIds = documentPresence
.filter((p) => p.isEditing)
.map((p) => p.userId);
.filter(p => p.isEditing)
.map(p => p.userId);
// ensure currently present via websocket are always ordered first
const mostRecentViewers = sortBy(
documentViews.slice(0, MAX_AVATAR_DISPLAY),
(view) => {
view => {
return presentIds.includes(view.user.id);
}
);
const viewersKeyedByUserId = keyBy(mostRecentViewers, (v) => v.user.id);
const viewersKeyedByUserId = keyBy(mostRecentViewers, v => v.user.id);
const overflow = documentViews.length - mostRecentViewers.length;
return (
<Facepile
users={mostRecentViewers.map((v) => v.user)}
users={mostRecentViewers.map(v => v.user)}
overflow={overflow}
renderAvatar={(user) => {
renderAvatar={user => {
const isPresent = presentIds.includes(user.id);
const isEditing = editingIds.includes(user.id);
const { lastViewedAt } = viewersKeyedByUserId[user.id];
+5 -5
View File
@@ -1,11 +1,11 @@
// @flow
import { inject, observer } from "mobx-react";
import { PrivateCollectionIcon, CollectionIcon } from "outline-icons";
import { getLuminance } from "polished";
import * as React from "react";
import UiStore from "stores/UiStore";
import { inject, observer } from "mobx-react";
import { getLuminance } from "polished";
import { PrivateCollectionIcon, CollectionIcon } from "outline-icons";
import Collection from "models/Collection";
import { icons } from "components/IconPicker";
import UiStore from "stores/UiStore";
type Props = {
collection: Collection,
@@ -18,7 +18,7 @@ function ResolvedCollectionIcon({ collection, expanded, size, ui }: Props) {
// If the chosen icon color is very dark then we invert it in dark mode
// otherwise it will be impossible to see against the dark background.
const color =
ui.resolvedTheme === "dark" && collection.color !== "currentColor"
ui.resolvedTheme === "dark"
? getLuminance(collection.color) > 0.12
? collection.color
: "currentColor"
-13
View File
@@ -1,13 +0,0 @@
// @flow
import styled from "styled-components";
const Header = styled.h3`
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: ${(props) => props.theme.sidebarText};
letter-spacing: 0.04em;
margin: 1em 12px 0.5em;
`;
export default Header;
@@ -1,21 +0,0 @@
// @flow
import { MoreIcon } from "outline-icons";
import * as React from "react";
import { MenuButton } from "reakit/Menu";
import NudeButton from "components/NudeButton";
export default function OverflowMenuButton({
iconColor,
className,
...rest
}: any) {
return (
<MenuButton {...rest}>
{(props) => (
<NudeButton className={className} {...props}>
<MoreIcon color={iconColor} />
</NudeButton>
)}
</MenuButton>
);
}
-16
View File
@@ -1,16 +0,0 @@
// @flow
import * as React from "react";
import { MenuSeparator } from "reakit/Menu";
import styled from "styled-components";
export default function Separator(rest: {}) {
return (
<MenuSeparator {...rest}>
{(props) => <HorizontalRule {...props} />}
</MenuSeparator>
);
}
const HorizontalRule = styled.hr`
margin: 0.5em 12px;
`;
-169
View File
@@ -1,169 +0,0 @@
// @flow
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import {
useMenuState,
MenuButton,
MenuItem as BaseMenuItem,
} from "reakit/Menu";
import styled from "styled-components";
import MenuItem, { MenuAnchor } from "./MenuItem";
import Separator from "./Separator";
import ContextMenu from ".";
type TMenuItem =
| {|
title: React.Node,
to: string,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
onClick: (event: SyntheticEvent<>) => void | Promise<void>,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
href: string,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
visible?: boolean,
disabled?: boolean,
style?: Object,
hover?: boolean,
items: TMenuItem[],
|}
| {|
type: "separator",
visible?: boolean,
|}
| {|
type: "heading",
visible?: boolean,
title: React.Node,
|};
type Props = {|
items: TMenuItem[],
|};
const Disclosure = styled(ExpandedIcon)`
transform: rotate(270deg);
justify-self: flex-end;
`;
const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
const { t } = useTranslation();
const menu = useMenuState({ modal: true });
return (
<>
<MenuButton ref={ref} {...menu} {...rest}>
{(props) => (
<MenuAnchor {...props}>
{title} <Disclosure color="currentColor" />
</MenuAnchor>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Submenu")}>
<Template {...menu} items={templateItems} />
</ContextMenu>
</>
);
});
function Template({ items, ...menu }: Props): React.Node {
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unneccessary separators
filtered = filtered.reduce((acc, item, index) => {
// trim separators from start / end
if (item.type === "separator" && index === 0) return acc;
if (item.type === "separator" && index === filtered.length - 1) return acc;
// trim double separators looking ahead / behind
const prev = filtered[index - 1];
if (prev && prev.type === "separator" && item.type === "separator")
return acc;
// otherwise, continue
return [...acc, item];
}, []);
return filtered.map((item, index) => {
if (item.to) {
return (
<MenuItem
as={Link}
to={item.to}
key={index}
disabled={item.disabled}
selected={item.selected}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.href) {
return (
<MenuItem
href={item.href}
key={index}
disabled={item.disabled}
selected={item.selected}
target="_blank"
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.onClick) {
return (
<MenuItem
as="button"
onClick={item.onClick}
disabled={item.disabled}
selected={item.selected}
key={index}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.items) {
return (
<BaseMenuItem
key={index}
as={Submenu}
templateItems={item.items}
title={item.title}
{...menu}
/>
);
}
if (item.type === "separator") {
return <Separator key={index} />;
}
return null;
});
}
export default React.memo<Props>(Template);
-77
View File
@@ -1,77 +0,0 @@
// @flow
import { rgba } from "polished";
import * as React from "react";
import { Menu } from "reakit/Menu";
import styled from "styled-components";
import { fadeAndScaleIn } from "shared/styles/animations";
import usePrevious from "hooks/usePrevious";
type Props = {|
"aria-label": string,
visible?: boolean,
animating?: boolean,
children: React.Node,
onOpen?: () => void,
onClose?: () => void,
|};
export default function ContextMenu({
children,
onOpen,
onClose,
...rest
}: Props) {
const previousVisible = usePrevious(rest.visible);
React.useEffect(() => {
if (rest.visible && !previousVisible) {
if (onOpen) {
onOpen();
}
}
if (!rest.visible && previousVisible) {
if (onClose) {
onClose();
}
}
}, [onOpen, onClose, previousVisible, rest.visible]);
return (
<Menu {...rest}>
{(props) => (
<Position {...props}>
<Background>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
)}
</Menu>
);
}
const Position = styled.div`
position: absolute;
z-index: ${(props) => props.theme.depths.menu};
`;
const Background = styled.div`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: ${(props) => (props.left !== undefined ? "25%" : "75%")} 0;
background: ${(props) => rgba(props.theme.menuBackground, 0.95)};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
border-radius: 2px;
padding: 0.5em 0;
min-width: 180px;
overflow: hidden;
overflow-y: auto;
max-height: 75vh;
max-width: 276px;
box-shadow: ${(props) => props.theme.menuShadow};
pointer-events: all;
font-weight: normal;
@media print {
display: none;
}
`;
+2 -2
View File
@@ -1,6 +1,6 @@
// @flow
import copy from "copy-to-clipboard";
import * as React from "react";
import copy from "copy-to-clipboard";
type Props = {
text: string,
@@ -14,7 +14,7 @@ class CopyToClipboard extends React.PureComponent<Props> {
const { text, onCopy, children } = this.props;
const elem = React.Children.only(children);
copy(text, {
debug: process.env.NODE_ENV !== "production",
debug: !!__DEV__,
});
if (onCopy) onCopy();
-24
View File
@@ -1,24 +0,0 @@
// @flow
import * as React from "react";
type Props = {
delay?: number,
children: React.Node,
};
export default function DelayedMount({ delay = 250, children }: Props) {
const [isShowing, setShowing] = React.useState(false);
React.useEffect(() => {
const timeout = setTimeout(() => setShowing(true), delay);
return () => {
clearTimeout(timeout);
};
}, [delay]);
if (!isShowing) {
return null;
}
return children;
}
@@ -1,26 +1,23 @@
// @flow
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { action, observable } from "mobx";
import { inject, observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { type Match, Redirect, type RouterHistory } from "react-router-dom";
import { Waypoint } from "react-waypoint";
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 breakpoint from "styled-components-breakpoint";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DocumentsStore from "stores/DocumentsStore";
import RevisionsStore from "stores/RevisionsStore";
import Button from "components/Button";
import Flex from "components/Flex";
import Flex from "shared/components/Flex";
import { ListPlaceholder } from "components/LoadingPlaceholder";
import Revision from "./components/Revision";
import { documentHistoryUrl, documentUrl } from "utils/routeHelpers";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {
match: Match,
match: Object,
documents: DocumentsStore,
revisions: RevisionsStore,
history: RouterHistory,
@@ -32,7 +29,6 @@ class DocumentHistory extends React.Component<Props> {
@observable isFetching: boolean = false;
@observable offset: number = 0;
@observable allowLoadMore: boolean = true;
@observable redirectTo: ?string;
async componentDidMount() {
await this.loadMoreResults();
@@ -90,34 +86,15 @@ class DocumentHistory extends React.Component<Props> {
return this.props.revisions.getDocumentRevisions(document.id);
}
onCloseHistory = () => {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
this.redirectTo = documentUrl(document);
};
render() {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
const showLoading = (!this.isLoaded && this.isFetching) || !document;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
return (
<Sidebar>
<Wrapper column>
<Header>
<Title>History</Title>
<Button
icon={<CloseIcon />}
onClick={this.onCloseHistory}
borderOnHover
neutral
/>
</Header>
{showLoading ? (
<Loading>
<ListPlaceholder count={5} />
@@ -156,44 +133,17 @@ const Wrapper = styled(Flex)`
top: 0;
right: 0;
z-index: 1;
min-width: ${(props) => props.theme.sidebarWidth}px;
min-width: ${props => props.theme.sidebarWidth};
height: 100%;
overflow-y: auto;
overscroll-behavior: none;
`;
const Sidebar = styled(Flex)`
display: none;
background: ${(props) => props.theme.background};
min-width: ${(props) => props.theme.sidebarWidth}px;
border-left: 1px solid ${(props) => props.theme.divider};
background: ${props => props.theme.background};
min-width: ${props => props.theme.sidebarWidth};
border-left: 1px solid ${props => props.theme.divider};
z-index: 1;
${breakpoint("tablet")`
display: flex;
`};
`;
const Title = styled(Flex)`
font-size: 16px;
font-weight: 600;
text-align: center;
align-items: center;
justify-content: flex-start;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: 0;
flex-grow: 1;
`;
const Header = styled(Flex)`
align-items: center;
position: relative;
padding: 12px;
border-bottom: 1px solid ${(props) => props.theme.divider};
color: ${(props) => props.theme.text};
flex-shrink: 0;
`;
export default inject("documents", "revisions")(DocumentHistory);
@@ -1,21 +1,21 @@
// @flow
import format from "date-fns/format";
import * as React from "react";
import { NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import format from "date-fns/format";
import { MoreIcon } from "outline-icons";
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 Avatar from "components/Avatar";
import Flex from "components/Flex";
import Time from "components/Time";
import RevisionMenu from "menus/RevisionMenu";
import { type Theme } from "types";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {
theme: Theme,
theme: Object,
showMenu: boolean,
selected: boolean,
document: Document,
@@ -36,7 +36,7 @@ class RevisionListItem extends React.Component<Props> {
{revision.createdBy.name}
</Author>
<Meta>
<Time dateTime={revision.createdAt} tooltipDelay={250}>
<Time dateTime={revision.createdAt}>
{format(revision.createdAt, "MMMM Do, YYYY h:mm a")}
</Time>
</Meta>
@@ -44,7 +44,9 @@ class RevisionListItem extends React.Component<Props> {
<StyledRevisionMenu
document={document}
revision={revision}
iconColor={selected ? theme.white : theme.textTertiary}
label={
<MoreIcon color={selected ? theme.white : theme.textTertiary} />
}
/>
)}
</StyledNavLink>
@@ -64,7 +66,7 @@ const StyledRevisionMenu = styled(RevisionMenu)`
`;
const StyledNavLink = styled(NavLink)`
color: ${(props) => props.theme.text};
color: ${props => props.theme.text};
display: block;
padding: 8px 16px;
font-size: 15px;
+6 -11
View File
@@ -1,18 +1,13 @@
// @flow
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import * as React from "react";
import Document from "models/Document";
import DocumentListItem from "components/DocumentListItem";
import DocumentPreview from "components/DocumentPreview";
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
type Props = {|
type Props = {
documents: Document[],
limit?: number,
showCollection?: boolean,
showPublished?: boolean,
showPin?: boolean,
showDraft?: boolean,
showTemplate?: boolean,
|};
};
export default function DocumentList({ limit, documents, ...rest }: Props) {
const items = limit ? documents.splice(0, limit) : documents;
@@ -22,8 +17,8 @@ export default function DocumentList({ limit, documents, ...rest }: Props) {
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{items.map((document) => (
<DocumentListItem key={document.id} document={document} {...rest} />
{items.map(document => (
<DocumentPreview key={document.id} document={document} {...rest} />
))}
</ArrowKeyNavigation>
);
-243
View File
@@ -1,243 +0,0 @@
// @flow
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Document from "models/Document";
import Badge from "components/Badge";
import Button from "components/Button";
import DocumentMeta from "components/DocumentMeta";
import EventBoundary from "components/EventBoundary";
import Flex from "components/Flex";
import Highlight from "components/Highlight";
import StarButton, { AnimatedStar } from "components/Star";
import Tooltip from "components/Tooltip";
import useCurrentUser from "hooks/useCurrentUser";
import DocumentMenu from "menus/DocumentMenu";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
highlight?: ?string,
context?: ?string,
showNestedDocuments?: boolean,
showCollection?: boolean,
showPublished?: boolean,
showPin?: boolean,
showDraft?: boolean,
showTemplate?: boolean,
|};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
function replaceResultMarks(tag: string) {
// don't use SEARCH_RESULT_REGEX here as it causes
// an infinite loop to trigger a regex inside it's own callback
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
}
function DocumentListItem(props: Props) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const [menuOpen, setMenuOpen] = React.useState(false);
const {
document,
showNestedDocuments,
showCollection,
showPublished,
showPin,
showDraft = true,
showTemplate,
highlight,
context,
} = props;
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
return (
<DocumentLink
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
pathname: document.url,
state: { title: document.titleWithDefault },
}}
>
<Content>
<Heading>
<Title text={document.titleWithDefault} highlight={highlight} />
{document.isNew && document.createdBy.id !== currentUser.id && (
<Badge yellow>{t("New")}</Badge>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isDraft && showDraft && (
<Tooltip
tooltip={t("Only visible to you")}
delay={500}
placement="top"
>
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
</Heading>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
)}
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showNestedDocuments={showNestedDocuments}
showLastViewed
/>
</Content>
<Actions>
{document.isTemplate && !document.isArchived && !document.isDeleted && (
<>
<Button
as={Link}
to={newDocumentUrl(document.collectionId, {
templateId: document.id,
})}
icon={<PlusIcon />}
neutral
>
{t("New doc")}
</Button>
&nbsp;
</>
)}
<DocumentMenu
document={document}
showPin={showPin}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
modal={false}
/>
</Actions>
</DocumentLink>
);
}
const Content = styled.div`
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
`;
const Actions = styled(EventBoundary)`
display: none;
align-items: center;
margin: 8px;
flex-shrink: 0;
flex-grow: 0;
${breakpoint("tablet")`
display: flex;
`};
`;
const DocumentLink = styled(Link)`
display: flex;
align-items: center;
margin: 10px -8px;
padding: 6px 8px;
border-radius: 8px;
max-height: 50vh;
min-width: 100%;
max-width: calc(100vw - 40px);
${Actions} {
opacity: 0;
}
${AnimatedStar} {
opacity: ${(props) => (props.$isStarred ? "1 !important" : 0)};
}
&:hover,
&:active,
&:focus,
&:focus-within {
background: ${(props) => props.theme.listItemHoverBackground};
${Actions} {
opacity: 1;
}
${AnimatedStar} {
opacity: 0.5;
&:hover {
opacity: 1;
}
}
}
${(props) =>
props.$menuOpen &&
css`
background: ${(props) => props.theme.listItemHoverBackground};
${Actions} {
opacity: 1;
}
${AnimatedStar} {
opacity: 0.5;
}
`}
`;
const Heading = styled.h3`
display: flex;
align-items: center;
height: 24px;
margin-top: 0;
margin-bottom: 0.25em;
overflow: hidden;
white-space: nowrap;
color: ${(props) => props.theme.text};
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
`;
const StarPositioner = styled(Flex)`
margin-left: 4px;
align-items: center;
`;
const Title = styled(Highlight)`
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
`;
const ResultContext = styled(Highlight)`
display: block;
color: ${(props) => props.theme.textTertiary};
font-size: 14px;
margin-top: -0.25em;
margin-bottom: 0.25em;
`;
export default observer(DocumentListItem);
-156
View File
@@ -1,156 +0,0 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Document from "models/Document";
import Breadcrumb from "components/Breadcrumb";
import Flex from "components/Flex";
import Time from "components/Time";
import useStores from "hooks/useStores";
const Container = styled(Flex)`
color: ${(props) => props.theme.textTertiary};
font-size: 13px;
white-space: nowrap;
overflow: hidden;
min-width: 0;
`;
const Modified = styled.span`
color: ${(props) => props.theme.textTertiary};
font-weight: ${(props) => (props.highlight ? "600" : "400")};
`;
type Props = {|
showCollection?: boolean,
showPublished?: boolean,
showLastViewed?: boolean,
showNestedDocuments?: boolean,
document: Document,
children: React.Node,
to?: string,
|};
function DocumentMeta({
showPublished,
showCollection,
showLastViewed,
showNestedDocuments,
document,
children,
to,
...rest
}: Props) {
const { t } = useTranslation();
const { collections, auth } = useStores();
const {
modifiedSinceViewed,
updatedAt,
updatedBy,
createdAt,
publishedAt,
archivedAt,
deletedAt,
isDraft,
lastViewedAt,
} = document;
// Prevent meta information from displaying if updatedBy is not available.
// Currently the situation where this is true is rendering share links.
if (!updatedBy) {
return null;
}
let content;
if (deletedAt) {
content = (
<span>
{t("deleted")} <Time dateTime={deletedAt} addSuffix />
</span>
);
} else if (archivedAt) {
content = (
<span>
{t("archived")} <Time dateTime={archivedAt} addSuffix />
</span>
);
} else if (createdAt === updatedAt) {
content = (
<span>
{t("created")} <Time dateTime={updatedAt} addSuffix />
</span>
);
} else if (publishedAt && (publishedAt === updatedAt || showPublished)) {
content = (
<span>
{t("published")} <Time dateTime={publishedAt} addSuffix />
</span>
);
} else if (isDraft) {
content = (
<span>
{t("saved")} <Time dateTime={updatedAt} addSuffix />
</span>
);
} else {
content = (
<Modified highlight={modifiedSinceViewed}>
{t("updated")} <Time dateTime={updatedAt} addSuffix />
</Modified>
);
}
const collection = collections.get(document.collectionId);
const updatedByMe = auth.user && auth.user.id === updatedBy.id;
const timeSinceNow = () => {
if (isDraft || !showLastViewed) {
return null;
}
if (!lastViewedAt) {
return (
<>
&nbsp;<Modified highlight>{t("Never viewed")}</Modified>
</>
);
}
return (
<span>
&nbsp;{t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
</span>
);
};
const nestedDocumentsCount = collection
? collection.getDocumentChildren(document.id).length
: 0;
return (
<Container align="center" {...rest}>
{updatedByMe ? t("You") : updatedBy.name}&nbsp;
{to ? <Link to={to}>{content}</Link> : content}
{showCollection && collection && (
<span>
&nbsp;{t("in")}&nbsp;
<strong>
<Breadcrumb document={document} onlyText />
</strong>
</span>
)}
{showNestedDocuments && nestedDocumentsCount > 0 && (
<span>
&nbsp;&middot; {nestedDocumentsCount}{" "}
{t("nested document", { count: nestedDocumentsCount })}
</span>
)}
&nbsp;{timeSinceNow()}
{children}
</Container>
);
}
export default observer(DocumentMeta);
-54
View File
@@ -1,54 +0,0 @@
// @flow
import { useObserver } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import Document from "models/Document";
import DocumentMeta from "components/DocumentMeta";
import useStores from "../hooks/useStores";
type Props = {|
document: Document,
isDraft: boolean,
to?: string,
|};
function DocumentMetaWithViews({ to, isDraft, document }: Props) {
const { views } = useStores();
const documentViews = useObserver(() => views.inDocument(document.id));
const totalViewers = documentViews.length;
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
return (
<Meta document={document} to={to}>
{totalViewers && !isDraft ? (
<>
&nbsp;&middot; Viewed by{" "}
{onlyYou
? "only you"
: `${totalViewers} ${totalViewers === 1 ? "person" : "people"}`}
</>
) : null}
</Meta>
);
}
const Meta = styled(DocumentMeta)`
margin: -12px 0 2em 0;
font-size: 14px;
position: relative;
z-index: 1;
a {
color: inherit;
&:hover {
text-decoration: underline;
}
}
@media print {
display: none;
}
`;
export default DocumentMetaWithViews;
@@ -0,0 +1,195 @@
// @flow
import * as React from "react";
import { observer } from "mobx-react";
import { Link } from "react-router-dom";
import { StarredIcon } from "outline-icons";
import styled, { withTheme } from "styled-components";
import Flex from "shared/components/Flex";
import Badge from "components/Badge";
import Tooltip from "components/Tooltip";
import Highlight from "components/Highlight";
import PublishingInfo from "components/PublishingInfo";
import DocumentMenu from "menus/DocumentMenu";
import Document from "models/Document";
type Props = {
document: Document,
highlight?: ?string,
context?: ?string,
showCollection?: boolean,
showPublished?: boolean,
showPin?: boolean,
showDraft?: boolean,
};
const StyledStar = withTheme(styled(({ solid, theme, ...props }) => (
<StarredIcon color={theme.text} {...props} />
))`
flex-shrink: 0;
opacity: ${props => (props.solid ? "1 !important" : 0)};
transition: all 100ms ease-in-out;
&:hover {
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
`);
const StyledDocumentMenu = styled(DocumentMenu)`
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
`;
const DocumentLink = styled(Link)`
display: block;
margin: 10px -8px;
padding: 6px 8px;
border-radius: 8px;
max-height: 50vh;
min-width: 100%;
overflow: hidden;
position: relative;
${StyledDocumentMenu} {
opacity: 0;
}
&:hover,
&:active,
&:focus {
background: ${props => props.theme.listItemHoverBackground};
outline: none;
${StyledStar}, ${StyledDocumentMenu} {
opacity: 0.5;
&:hover {
opacity: 1;
}
}
}
`;
const Heading = styled.h3`
display: flex;
align-items: center;
height: 24px;
margin-top: 0;
margin-bottom: 0.25em;
overflow: hidden;
white-space: nowrap;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
`;
const Actions = styled(Flex)`
margin-left: 4px;
align-items: center;
`;
const Title = styled(Highlight)`
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
`;
const ResultContext = styled(Highlight)`
display: block;
color: ${props => props.theme.textTertiary};
font-size: 14px;
margin-top: -0.25em;
margin-bottom: 0.25em;
`;
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@observer
class DocumentPreview extends React.Component<Props> {
star = (ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
this.props.document.star();
};
unstar = (ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
this.props.document.unstar();
};
replaceResultMarks = (tag: string) => {
// don't use SEARCH_RESULT_REGEX here as it causes
// an infinite loop to trigger a regex inside it's own callback
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
};
render() {
const {
document,
showCollection,
showPublished,
showPin,
showDraft = true,
highlight,
context,
...rest
} = this.props;
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
return (
<DocumentLink
to={{
pathname: document.url,
state: { title: document.title },
}}
{...rest}
>
<Heading>
<Title text={document.title || "Untitled"} highlight={highlight} />
{!document.isDraft &&
!document.isArchived && (
<Actions>
{document.isStarred ? (
<StyledStar onClick={this.unstar} solid />
) : (
<StyledStar onClick={this.star} />
)}
</Actions>
)}
{document.isDraft &&
showDraft && (
<Tooltip
tooltip="Only visible to you"
delay={500}
placement="top"
>
<Badge>Draft</Badge>
</Tooltip>
)}
<StyledDocumentMenu document={document} showPin={showPin} />
</Heading>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={this.replaceResultMarks}
/>
)}
<PublishingInfo
document={document}
showCollection={showCollection}
showPublished={showPublished}
/>
</DocumentLink>
);
}
}
export default DocumentPreview;
+3
View File
@@ -0,0 +1,3 @@
// @flow
import DocumentPreview from "./DocumentPreview";
export default DocumentPreview;
+41 -51
View File
@@ -1,13 +1,13 @@
// @flow
import invariant from "invariant";
import * as React from "react";
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import * as React from "react";
import { withRouter, type RouterHistory } from "react-router-dom";
import { createGlobalStyle } from "styled-components";
import invariant from "invariant";
import importFile from "utils/importFile";
import Dropzone from "react-dropzone";
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
import styled, { css } from "styled-components";
import DocumentsStore from "stores/DocumentsStore";
import UiStore from "stores/UiStore";
import LoadingIndicator from "components/LoadingIndicator";
const EMPTY_OBJECT = {};
@@ -17,15 +17,28 @@ type Props = {
children: React.Node,
collectionId: string,
documentId?: string,
ui: UiStore,
activeClassName?: string,
rejectClassName?: string,
documents: DocumentsStore,
disabled: boolean,
location: Object,
match: Match,
match: Object,
history: RouterHistory,
staticContext: Object,
};
export const GlobalStyles = createGlobalStyle`
.activeDropZone {
border-radius: 4px;
background: ${props => props.theme.slateDark};
svg { fill: ${props => props.theme.white}; }
}
.activeDropZone a {
color: ${props => props.theme.white} !important;
}
`;
@observer
class DropToImport extends React.Component<Props> {
@observable isImporting: boolean = false;
@@ -48,21 +61,17 @@ class DropToImport extends React.Component<Props> {
}
for (const file of files) {
const doc = await this.props.documents.import(
const doc = await importFile({
documents: this.props.documents,
file,
documentId,
collectionId,
{ publish: true }
);
});
if (redirect) {
this.props.history.push(doc.url);
}
}
} catch (err) {
this.props.ui.showToast(`Could not import file. ${err.message}`, {
type: "error",
});
} finally {
this.isImporting = false;
importingLock = false;
@@ -70,54 +79,35 @@ class DropToImport extends React.Component<Props> {
};
render() {
const { documents } = this.props;
const {
documentId,
collectionId,
documents,
disabled,
location,
match,
history,
staticContext,
...rest
} = this.props;
if (this.props.disabled) return this.props.children;
return (
<Dropzone
accept={documents.importFileTypes.join(", ")}
accept="text/markdown, text/plain"
onDropAccepted={this.onDropAccepted}
style={EMPTY_OBJECT}
noClick
disableClick
disablePreview
multiple
{...rest}
>
{({
getRootProps,
getInputProps,
isDragActive,
isDragAccept,
isDragReject,
}) => (
<DropzoneContainer
{...getRootProps()}
{...{ isDragActive }}
tabIndex="-1"
>
<input {...getInputProps()} />
{this.isImporting && <LoadingIndicator />}
{this.props.children}
</DropzoneContainer>
)}
{this.isImporting && <LoadingIndicator />}
{this.props.children}
</Dropzone>
);
}
}
const DropzoneContainer = styled("div")`
border-radius: 4px;
${({ isDragActive, theme }) =>
isDragActive &&
css`
background: ${theme.slateDark};
a {
color: ${theme.white} !important;
}
svg {
fill: ${theme.white};
}
`}
`;
export default inject("documents", "ui")(withRouter(DropToImport));
export default inject("documents")(withRouter(DropToImport));
+277
View File
@@ -0,0 +1,277 @@
// @flow
import * as React from "react";
import invariant from "invariant";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { PortalWithState } from "react-portal";
import { MoreIcon } from "outline-icons";
import { rgba } from "polished";
import styled from "styled-components";
import Flex from "shared/components/Flex";
import { fadeAndScaleIn } from "shared/styles/animations";
import NudeButton from "components/NudeButton";
let previousClosePortal;
let counter = 0;
type Children =
| React.Node
| ((options: { closePortal: () => void }) => React.Node);
type Props = {
label?: React.Node,
onOpen?: () => void,
onClose?: () => void,
children?: Children,
className?: string,
hover?: boolean,
style?: Object,
position?: "left" | "right" | "center",
};
@observer
class DropdownMenu extends React.Component<Props> {
id: string = `menu${counter++}`;
closeTimeout: TimeoutID;
@observable top: ?number;
@observable bottom: ?number;
@observable right: ?number;
@observable left: ?number;
@observable position: "left" | "right" | "center";
@observable fixed: ?boolean;
@observable bodyRect: ClientRect;
@observable labelRect: ClientRect;
@observable dropdownRef: { current: null | HTMLElement } = React.createRef();
@observable menuRef: { current: null | HTMLElement } = React.createRef();
handleOpen = (
openPortal: (SyntheticEvent<>) => void,
closePortal: () => void
) => {
return (ev: SyntheticMouseEvent<HTMLElement>) => {
ev.preventDefault();
const currentTarget = ev.currentTarget;
invariant(document.body, "why you not here");
if (currentTarget instanceof HTMLDivElement) {
this.bodyRect = document.body.getBoundingClientRect();
this.labelRect = currentTarget.getBoundingClientRect();
this.top = this.labelRect.bottom - this.bodyRect.top;
this.bottom = undefined;
this.position = this.props.position || "left";
if (currentTarget.parentElement) {
const triggerParentStyle = getComputedStyle(
currentTarget.parentElement
);
if (triggerParentStyle.position === "static") {
this.fixed = true;
this.top = this.labelRect.bottom;
}
}
this.initPosition();
// attempt to keep only one flyout menu open at once
if (previousClosePortal && !this.props.hover) {
previousClosePortal();
}
previousClosePortal = closePortal;
openPortal(ev);
}
};
};
initPosition() {
if (this.position === "left") {
this.right =
this.bodyRect.width - this.labelRect.left - this.labelRect.width;
} else if (this.position === "center") {
this.left = this.labelRect.left + this.labelRect.width / 2;
} else {
this.left = this.labelRect.left;
}
}
onOpen = () => {
if (typeof this.props.onOpen === "function") {
this.props.onOpen();
}
this.fitOnTheScreen();
};
fitOnTheScreen() {
if (!this.dropdownRef || !this.dropdownRef.current) return;
const el = this.dropdownRef.current;
const sticksOutPastBottomEdge =
el.clientHeight + this.top > window.innerHeight;
if (sticksOutPastBottomEdge) {
this.top = undefined;
this.bottom = this.fixed ? 0 : -1 * window.pageYOffset;
} else {
this.bottom = undefined;
}
if (this.position === "left" || this.position === "right") {
const totalWidth =
Math.sign(this.position === "left" ? -1 : 1) * el.offsetLeft +
el.scrollWidth;
const isVisible = totalWidth < window.innerWidth;
if (!isVisible) {
if (this.position === "right") {
this.position = "left";
this.left = undefined;
} else if (this.position === "left") {
this.position = "right";
this.right = undefined;
}
}
}
this.initPosition();
this.forceUpdate();
}
closeAfterTimeout = (closePortal: () => void) => () => {
if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
}
this.closeTimeout = setTimeout(closePortal, 500);
};
clearCloseTimeout = () => {
if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
}
};
render() {
const { className, hover, label, children } = this.props;
return (
<div className={className}>
<PortalWithState
onOpen={this.onOpen}
onClose={this.props.onClose}
closeOnOutsideClick
closeOnEsc
>
{({ closePortal, openPortal, isOpen, portal }) => (
<React.Fragment>
<Label
onMouseMove={hover ? this.clearCloseTimeout : undefined}
onMouseOut={
hover ? this.closeAfterTimeout(closePortal) : undefined
}
onMouseEnter={
hover ? this.handleOpen(openPortal, closePortal) : undefined
}
onClick={
hover ? undefined : this.handleOpen(openPortal, closePortal)
}
>
{label || (
<NudeButton
id={`${this.id}button`}
aria-haspopup="true"
aria-expanded={isOpen ? "true" : "false"}
aria-controls={this.id}
>
<MoreIcon />
</NudeButton>
)}
</Label>
{portal(
<Position
ref={this.dropdownRef}
position={this.position}
fixed={this.fixed}
top={this.top}
bottom={this.bottom}
left={this.left}
right={this.right}
>
<Menu
ref={this.menuRef}
onMouseMove={hover ? this.clearCloseTimeout : undefined}
onMouseOut={
hover ? this.closeAfterTimeout(closePortal) : undefined
}
onClick={
typeof children === "function"
? undefined
: ev => {
ev.stopPropagation();
closePortal();
}
}
style={this.props.style}
id={this.id}
aria-labelledby={`${this.id}button`}
role="menu"
>
{typeof children === "function"
? children({ closePortal })
: children}
</Menu>
</Position>
)}
</React.Fragment>
)}
</PortalWithState>
</div>
);
}
}
const Label = styled(Flex).attrs({
justify: "center",
align: "center",
})`
z-index: 1000;
cursor: pointer;
`;
const Position = styled.div`
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
display: flex;
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
${({ right }) => (right !== undefined ? `right: ${right}px` : "")};
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
${({ bottom }) => (bottom !== undefined ? `bottom: ${bottom}px` : "")};
max-height: 75%;
z-index: 1000;
transform: ${props =>
props.position === "center" ? "translateX(-50%)" : "initial"};
pointer-events: none;
`;
const Menu = styled.div`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: ${props => (props.left !== undefined ? "25%" : "75%")} 0;
backdrop-filter: blur(10px);
background: ${props => rgba(props.theme.menuBackground, 0.8)};
border: ${props =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
border-radius: 2px;
padding: 0.5em 0;
min-width: 180px;
overflow: hidden;
overflow-y: auto;
box-shadow: ${props => props.theme.menuShadow};
pointer-events: all;
hr {
margin: 0.5em 12px;
}
@media print {
display: none;
}
`;
export default DropdownMenu;
@@ -1,63 +1,50 @@
// @flow
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import { CheckmarkIcon } from "outline-icons";
import styled from "styled-components";
type Props = {|
type Props = {
onClick?: (SyntheticEvent<>) => void | Promise<void>,
children?: React.Node,
selected?: boolean,
disabled?: boolean,
to?: string,
href?: string,
target?: "_blank",
as?: string | React.ComponentType<*>,
|};
};
const MenuItem = ({
const DropdownMenuItem = ({
onClick,
children,
selected,
disabled,
as,
...rest
}: Props) => {
return (
<BaseMenuItem
<MenuItem
onClick={disabled ? undefined : onClick}
disabled={disabled}
role="menuitem"
tabIndex="-1"
{...rest}
>
{(props) => (
<MenuAnchor as={onClick ? "button" : as} {...props}>
{selected !== undefined && (
<>
{selected ? <CheckmarkIcon /> : <Spacer />}
&nbsp;
</>
)}
{children}
</MenuAnchor>
{selected !== undefined && (
<React.Fragment>
<CheckmarkIcon
color={selected === false ? "transparent" : undefined}
/>&nbsp;
</React.Fragment>
)}
</BaseMenuItem>
{children}
</MenuItem>
);
};
const Spacer = styled.div`
width: 24px;
height: 24px;
`;
export const MenuAnchor = styled.a`
const MenuItem = styled.a`
display: flex;
margin: 0;
border: 0;
padding: 6px 12px;
width: 100%;
min-height: 32px;
background: none;
color: ${(props) =>
height: 32px;
color: ${props =>
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
justify-content: left;
align-items: center;
@@ -70,17 +57,15 @@ export const MenuAnchor = styled.a`
}
svg {
flex-shrink: 0;
opacity: ${(props) => (props.disabled ? ".5" : 1)};
opacity: ${props => (props.disabled ? ".5" : 1)};
}
${(props) =>
${props =>
props.disabled
? "pointer-events: none;"
: `
&:hover,
&.focus-visible {
&:hover {
color: ${props.theme.white};
background: ${props.theme.primary};
box-shadow: none;
@@ -94,8 +79,9 @@ export const MenuAnchor = styled.a`
&:focus {
color: ${props.theme.white};
background: ${props.theme.primary};
outline: none;
}
`};
`;
export default MenuItem;
export default DropdownMenuItem;
+3
View File
@@ -0,0 +1,3 @@
// @flow
export { default as DropdownMenu } from "./DropdownMenu";
export { default as DropdownMenuItem } from "./DropdownMenuItem";
-242
View File
@@ -1,242 +0,0 @@
// @flow
import { lighten } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import UiStore from "stores/UiStore";
import ErrorBoundary from "components/ErrorBoundary";
import Tooltip from "components/Tooltip";
import embeds from "../embeds";
import { isModKey } from "utils/keyboard";
import { uploadFile } from "utils/uploadFile";
import { isInternalUrl } from "utils/urls";
const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));
const EMPTY_ARRAY = [];
export type Props = {|
id?: string,
value?: string,
defaultValue?: string,
readOnly?: boolean,
grow?: boolean,
disableEmbeds?: boolean,
ui?: UiStore,
autoFocus?: boolean,
template?: boolean,
placeholder?: string,
scrollTo?: string,
readOnlyWriteCheckboxes?: boolean,
onBlur?: (event: SyntheticEvent<>) => any,
onFocus?: (event: SyntheticEvent<>) => any,
onPublish?: (event: SyntheticEvent<>) => any,
onSave?: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
onCancel?: () => any,
onChange?: (getValue: () => string) => any,
onSearchLink?: (title: string) => any,
onHoverLink?: (event: MouseEvent) => any,
onCreateLink?: (title: string) => Promise<string>,
onImageUploadStart?: () => any,
onImageUploadStop?: () => any,
|};
type PropsWithRef = Props & {
forwardedRef: React.Ref<any>,
history: RouterHistory,
};
function Editor(props: PropsWithRef) {
const { id, ui, history } = props;
const { t } = useTranslation();
const onUploadImage = React.useCallback(
async (file: File) => {
const result = await uploadFile(file, { documentId: id });
return result.url;
},
[id]
);
const onClickLink = React.useCallback(
(href: string, event: MouseEvent) => {
// on page hash
if (href[0] === "#") {
window.location.href = href;
return;
}
if (isInternalUrl(href) && !isModKey(event) && !event.shiftKey) {
// relative
let navigateTo = href;
// probably absolute
if (href[0] !== "/") {
try {
const url = new URL(href);
navigateTo = url.pathname + url.hash;
} catch (err) {
navigateTo = href;
}
}
history.push(navigateTo);
} else if (href) {
window.open(href, "_blank");
}
},
[history]
);
const onShowToast = React.useCallback(
(message: string) => {
if (ui) {
ui.showToast(message);
}
},
[ui]
);
const dictionary = React.useMemo(() => {
return {
addColumnAfter: t("Insert column after"),
addColumnBefore: t("Insert column before"),
addRowAfter: t("Insert row after"),
addRowBefore: t("Insert row before"),
alignCenter: t("Align center"),
alignLeft: t("Align left"),
alignRight: t("Align right"),
bulletList: t("Bulleted list"),
checkboxList: t("Todo list"),
codeBlock: t("Code block"),
codeCopied: t("Copied to clipboard"),
codeInline: t("Code"),
createLink: t("Create link"),
createLinkError: t("Sorry, an error occurred creating the link"),
createNewDoc: t("Create a new doc"),
deleteColumn: t("Delete column"),
deleteRow: t("Delete row"),
deleteTable: t("Delete table"),
em: t("Italic"),
embedInvalidLink: t("Sorry, that link wont work for this embed type"),
findOrCreateDoc: `${t("Find or create a doc")}`,
h1: t("Big heading"),
h2: t("Medium heading"),
h3: t("Small heading"),
heading: t("Heading"),
hr: t("Divider"),
image: t("Image"),
imageUploadError: t("Sorry, an error occurred uploading the image"),
info: t("Info"),
infoNotice: t("Info notice"),
link: t("Link"),
linkCopied: t("Link copied to clipboard"),
mark: t("Highlight"),
newLineEmpty: `${t("Type '/' to insert")}`,
newLineWithSlash: `${t("Keep typing to filter")}`,
noResults: t("No results"),
openLink: t("Open link"),
orderedList: t("Ordered list"),
pasteLink: `${t("Paste a link")}`,
pasteLinkWithTitle: (service: string) =>
t("Paste a {{service}} link…", { service }),
placeholder: t("Placeholder"),
quote: t("Quote"),
removeLink: t("Remove link"),
searchOrPasteLink: `${t("Search or paste a link")}`,
strikethrough: t("Strikethrough"),
strong: t("Bold"),
subheading: t("Subheading"),
table: t("Table"),
tip: t("Tip"),
tipNotice: t("Tip notice"),
warning: t("Warning"),
warningNotice: t("Warning notice"),
};
}, [t]);
return (
<ErrorBoundary reloadOnChunkMissing>
<StyledEditor
ref={props.forwardedRef}
uploadImage={onUploadImage}
onClickLink={onClickLink}
onShowToast={onShowToast}
embeds={props.disableEmbeds ? EMPTY_ARRAY : embeds}
tooltip={EditorTooltip}
dictionary={dictionary}
{...props}
/>
</ErrorBoundary>
);
}
const StyledEditor = styled(RichMarkdownEditor)`
flex-grow: ${(props) => (props.grow ? 1 : 0)};
justify-content: start;
> div {
transition: ${(props) => props.theme.backgroundTransition};
}
& * {
box-sizing: content-box;
}
.notice-block.tip,
.notice-block.warning {
font-weight: 500;
}
.heading-anchor {
box-sizing: border-box;
}
.heading-name {
pointer-events: none;
display: block;
position: relative;
top: -60px;
visibility: hidden;
}
.heading-name:first-child {
& + h1,
& + h2,
& + h3,
& + h4 {
margin-top: 0;
}
}
p {
a {
color: ${(props) => props.theme.text};
border-bottom: 1px solid ${(props) => lighten(0.5, props.theme.text)};
text-decoration: none !important;
font-weight: 500;
&:hover {
border-bottom: 1px solid ${(props) => props.theme.text};
text-decoration: none;
}
}
}
`;
const EditorTooltip = ({ children, ...props }) => (
<Tooltip offset="0, 16" delay={150} {...props}>
<Span>{children}</Span>
</Tooltip>
);
const Span = styled.span`
outline: none;
`;
const EditorWithRouterAndTheme = withRouter(withTheme(Editor));
export default React.forwardRef<Props, typeof Editor>((props, ref) => (
<EditorWithRouterAndTheme {...props} forwardedRef={ref} />
));
+116
View File
@@ -0,0 +1,116 @@
// @flow
import * as React from "react";
import { withRouter, type RouterHistory } from "react-router-dom";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { lighten } from "polished";
import styled, { withTheme } from "styled-components";
import RichMarkdownEditor from "rich-markdown-editor";
import { uploadFile } from "utils/uploadFile";
import isInternalUrl from "utils/isInternalUrl";
import Tooltip from "components/Tooltip";
import UiStore from "stores/UiStore";
import embeds from "../../embeds";
const EMPTY_ARRAY = [];
type Props = {
id: string,
defaultValue?: string,
readOnly?: boolean,
grow?: boolean,
disableEmbeds?: boolean,
history: RouterHistory,
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, { documentId: this.props.id });
return result.url;
};
onClickLink = (href: string) => {
// on page hash
if (href[0] === "#") {
window.location.href = href;
return;
}
if (isInternalUrl(href)) {
// relative
let navigateTo = href;
// probably absolute
if (href[0] !== "/") {
try {
const url = new URL(href);
navigateTo = url.pathname + url.hash;
} catch (err) {
navigateTo = href;
}
}
this.props.history.push(navigateTo);
} else {
window.open(href, "_blank");
}
};
onShowToast = (message: string) => {
this.props.ui.showToast(message);
};
render() {
return (
<StyledEditor
ref={this.props.forwardedRef}
uploadImage={this.onUploadImage}
onClickLink={this.onClickLink}
onShowToast={this.onShowToast}
embeds={this.props.disableEmbeds ? EMPTY_ARRAY : embeds}
tooltip={EditorTooltip}
{...this.props}
/>
);
}
}
const StyledEditor = styled(RichMarkdownEditor)`
flex-grow: ${props => (props.grow ? 1 : 0)};
justify-content: start;
> div {
transition: ${props => props.theme.backgroundTransition};
}
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;
}
}
}
`;
const EditorTooltip = ({ children, ...props }) => (
<Tooltip offset="0, 16" delay={150} {...props}>
<span>{children}</span>
</Tooltip>
);
const EditorWithRouterAndTheme = withRouter(withTheme(Editor));
// $FlowIssue - https://github.com/facebook/flow/issues/6103
export default React.forwardRef((props, ref) => (
<EditorWithRouterAndTheme {...props} forwardedRef={ref} />
));
+3
View File
@@ -0,0 +1,3 @@
// @flow
import Editor from "./Editor";
export default Editor;
+14 -2
View File
@@ -1,8 +1,20 @@
// @flow
import * as React from "react";
import styled from "styled-components";
const Empty = styled.p`
color: ${(props) => props.theme.textTertiary};
type Props = {
children: React.Node,
};
const Empty = (props: Props) => {
const { children, ...rest } = props;
return <Container {...rest}>{children}</Container>;
};
const Container = styled.div`
display: flex;
color: ${props => props.theme.slate};
text-align: center;
`;
export default Empty;
+11 -45
View File
@@ -1,19 +1,16 @@
// @flow
import * as Sentry from "@sentry/react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import { observer } from "mobx-react";
import { observable } from "mobx";
import HelpText from "components/HelpText";
import Button from "components/Button";
import CenteredContent from "components/CenteredContent";
import HelpText from "components/HelpText";
import PageTitle from "components/PageTitle";
import { githubIssuesUrl } from "../../shared/utils/routeHelpers";
import env from "env";
type Props = {
children: React.Node,
reloadOnChunkMissing?: boolean,
};
@observer
@@ -25,25 +22,13 @@ class ErrorBoundary extends React.Component<Props> {
this.error = error;
console.error(error);
if (
this.props.reloadOnChunkMissing &&
error.message &&
error.message.match(/chunk/)
) {
// If the editor bundle fails to load then reload the entire window. This
// can happen if a deploy happens between the user loading the initial JS
// bundle and the async-loaded editor JS bundle as the hash will change.
window.location.reload(true);
return;
}
if (env.SENTRY_DSN) {
if (window.Sentry) {
Sentry.captureException(error);
}
}
handleReload = () => {
window.location.reload(true);
window.location.reload();
};
handleShowDetails = () => {
@@ -56,37 +41,18 @@ class ErrorBoundary extends React.Component<Props> {
render() {
if (this.error) {
const error = this.error;
const isReported = !!env.SENTRY_DSN && env.DEPLOYMENT === "hosted";
const isChunkError = this.error.message.match(/chunk/);
if (isChunkError) {
return (
<CenteredContent>
<PageTitle title="Module failed to load" />
<h1>Loading Failed</h1>
<HelpText>
Sorry, part of the application failed to load. This may be because
it was updated since you opened the tab or because of a failed
network request. Please try reloading.
</HelpText>
<p>
<Button onClick={this.handleReload}>Reload</Button>
</p>
</CenteredContent>
);
}
const isReported = !!window.Sentry;
return (
<CenteredContent>
<PageTitle title="Something Unexpected Happened" />
<h1>Something Unexpected Happened</h1>
<HelpText>
Sorry, an unrecoverable error occurred
{isReported && " our engineers have been notified"}. Please try
reloading the page, it may have been a temporary glitch.
Sorry, an unrecoverable error occurred{isReported &&
" our engineers have been notified"}. Please try reloading the
page, it may have been a temporary glitch.
</HelpText>
{this.showDetails && <Pre>{error.toString()}</Pre>}
{this.showDetails && <Pre>{this.error.toString()}</Pre>}
<p>
<Button onClick={this.handleReload}>Reload</Button>{" "}
{this.showDetails ? (
@@ -107,7 +73,7 @@ class ErrorBoundary extends React.Component<Props> {
}
const Pre = styled.pre`
background: ${(props) => props.theme.smoke};
background: ${props => props.theme.smoke};
padding: 16px;
border-radius: 4px;
font-size: 12px;
-21
View File
@@ -1,21 +0,0 @@
// @flow
import * as React from "react";
type Props = {
children: React.Node,
className?: string,
};
export default function EventBoundary({ children, className }: Props) {
const handleClick = React.useCallback((event: SyntheticEvent<>) => {
event.preventDefault();
event.stopPropagation();
}, []);
return (
<span onClick={handleClick} className={className}>
{children}
</span>
);
}
+9 -9
View File
@@ -1,10 +1,10 @@
// @flow
import { observer, inject } from "mobx-react";
import * as React from "react";
import { observer, inject } from "mobx-react";
import styled, { withTheme } from "styled-components";
import User from "models/User";
import Flex from "shared/components/Flex";
import Avatar from "components/Avatar";
import Flex from "components/Flex";
import User from "models/User";
type Props = {
users: User[],
@@ -31,7 +31,7 @@ class Facepile extends React.Component<Props> {
<span>+{overflow}</span>
</More>
)}
{users.map((user) => (
{users.map(user => (
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
))}
</Avatars>
@@ -56,12 +56,12 @@ const More = styled.div`
flex-direction: column;
align-items: center;
justify-content: center;
min-width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
min-width: ${props => props.size}px;
height: ${props => props.size}px;
border-radius: 100%;
background: ${(props) => props.theme.slate};
color: ${(props) => props.theme.text};
border: 2px solid ${(props) => props.theme.background};
background: ${props => props.theme.slate};
color: ${props => props.theme.text};
border: 2px solid ${props => props.theme.background};
text-align: center;
font-size: 11px;
font-weight: 600;
+1 -1
View File
@@ -3,7 +3,7 @@ import styled from "styled-components";
import { fadeIn } from "shared/styles/animations";
const Fade = styled.span`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
animation: ${fadeIn} ${props => props.timing || "250ms"} ease-in-out;
`;
export default Fade;
-24
View File
@@ -1,24 +0,0 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import Empty from "components/Empty";
import Fade from "components/Fade";
import Flex from "components/Flex";
export default function FullscreenLoading() {
return (
<Fade timing={500}>
<Centered>
<Empty>Loading</Empty>
</Centered>
</Fade>
);
}
const Centered = styled(Flex)`
text-align: center;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
`;
+14 -14
View File
@@ -1,17 +1,17 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { MAX_AVATAR_DISPLAY } from "shared/constants";
import GroupMembershipsStore from "stores/GroupMembershipsStore";
import CollectionGroupMembership from "models/CollectionGroupMembership";
import Group from "models/Group";
import GroupMembers from "scenes/GroupMembers";
import Facepile from "components/Facepile";
import Flex from "components/Flex";
import ListItem from "components/List/Item";
import Modal from "components/Modal";
import Flex from "shared/components/Flex";
import Facepile from "components/Facepile";
import GroupMembers from "scenes/GroupMembers";
import ListItem from "components/List/Item";
import Group from "models/Group";
import CollectionGroupMembership from "models/CollectionGroupMembership";
import GroupMembershipsStore from "stores/GroupMembershipsStore";
type Props = {
group: Group,
@@ -41,20 +41,20 @@ class GroupListItem extends React.Component<Props> {
const membershipsInGroup = groupMemberships.inGroup(group.id);
const users = membershipsInGroup
.slice(0, MAX_AVATAR_DISPLAY)
.map((gm) => gm.user);
.map(gm => gm.user);
const overflow = memberCount - users.length;
return (
<>
<React.Fragment>
<ListItem
title={
<Title onClick={this.handleMembersModalOpen}>{group.name}</Title>
}
subtitle={
<>
<React.Fragment>
{memberCount} member{memberCount === 1 ? "" : "s"}
</>
</React.Fragment>
}
actions={
<Flex align="center">
@@ -79,7 +79,7 @@ class GroupListItem extends React.Component<Props> {
>
<GroupMembers group={group} onSubmit={this.handleMembersModalClose} />
</Modal>
</>
</React.Fragment>
);
}
}
-4
View File
@@ -4,14 +4,10 @@ import styled from "styled-components";
const Heading = styled.h1`
display: flex;
align-items: center;
${(props) => (props.centered ? "text-align: center;" : "")}
svg {
margin-top: 4px;
margin-left: -6px;
margin-right: 2px;
align-self: flex-start;
flex-shrink: 0;
}
`;
+2 -2
View File
@@ -3,8 +3,8 @@ import styled from "styled-components";
const HelpText = styled.p`
margin-top: 0;
color: ${(props) => props.theme.textSecondary};
font-size: ${(props) => (props.small ? "13px" : "inherit")};
color: ${props => props.theme.textSecondary};
font-size: ${props => (props.small ? "13px" : "inherit")};
`;
export default HelpText;
+3 -6
View File
@@ -18,7 +18,6 @@ function Highlight({
...rest
}: Props) {
let regex;
let index = 0;
if (highlight instanceof RegExp) {
regex = highlight;
} else {
@@ -30,10 +29,8 @@ function Highlight({
return (
<span {...rest}>
{highlight
? replace(text, regex, (tag) => (
<Mark key={index++}>
{processResult ? processResult(tag) : tag}
</Mark>
? replace(text, regex, (tag, index) => (
<Mark key={index}>{processResult ? processResult(tag) : tag}</Mark>
))
: text}
</span>
@@ -41,7 +38,7 @@ function Highlight({
}
const Mark = styled.mark`
background: ${(props) => props.theme.searchHighlight};
background: ${props => props.theme.yellow};
border-radius: 2px;
padding: 0 4px;
`;
-242
View File
@@ -1,242 +0,0 @@
// @flow
import { inject } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
import { fadeAndSlideIn } from "shared/styles/animations";
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
import DocumentsStore from "stores/DocumentsStore";
import HoverPreviewDocument from "components/HoverPreviewDocument";
import { isInternalUrl } from "utils/urls";
const DELAY_OPEN = 300;
const DELAY_CLOSE = 300;
type Props = {
node: HTMLAnchorElement,
event: MouseEvent,
documents: DocumentsStore,
onClose: () => void,
};
function HoverPreviewInternal({ node, documents, onClose, event }: Props) {
const slug = parseDocumentSlug(node.href);
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef();
const timerOpen = React.useRef();
const cardRef = React.useRef<?HTMLDivElement>();
const startCloseTimer = () => {
stopOpenTimer();
timerClose.current = setTimeout(() => {
if (isVisible) setVisible(false);
onClose();
}, DELAY_CLOSE);
};
const stopCloseTimer = () => {
if (timerClose.current) {
clearTimeout(timerClose.current);
}
};
const startOpenTimer = () => {
timerOpen.current = setTimeout(() => setVisible(true), DELAY_OPEN);
};
const stopOpenTimer = () => {
if (timerOpen.current) {
clearTimeout(timerOpen.current);
}
};
React.useEffect(() => {
if (slug) {
documents.prefetchDocument(slug, {
prefetch: true,
});
}
startOpenTimer();
if (cardRef.current) {
cardRef.current.addEventListener("mouseenter", stopCloseTimer);
}
if (cardRef.current) {
cardRef.current.addEventListener("mouseleave", startCloseTimer);
}
node.addEventListener("mouseout", startCloseTimer);
node.addEventListener("mouseover", stopCloseTimer);
node.addEventListener("mouseover", startOpenTimer);
return () => {
node.removeEventListener("mouseout", startCloseTimer);
node.removeEventListener("mouseover", stopCloseTimer);
node.removeEventListener("mouseover", startOpenTimer);
if (cardRef.current) {
cardRef.current.removeEventListener("mouseenter", stopCloseTimer);
}
if (cardRef.current) {
cardRef.current.removeEventListener("mouseleave", startCloseTimer);
}
if (timerClose.current) {
clearTimeout(timerClose.current);
}
};
}, [node]);
const anchorBounds = node.getBoundingClientRect();
const cardBounds = cardRef.current
? cardRef.current.getBoundingClientRect()
: undefined;
const left = cardBounds
? Math.min(anchorBounds.left, window.innerWidth - 16 - 350)
: anchorBounds.left;
const leftOffset = anchorBounds.left - left;
return (
<Portal>
<Position
top={anchorBounds.bottom + window.scrollY}
left={left}
aria-hidden
>
<div ref={cardRef}>
<HoverPreviewDocument url={node.href}>
{(content) =>
isVisible ? (
<Animate>
<Card>
<Margin />
<CardContent>{content}</CardContent>
</Card>
<Pointer offset={leftOffset + anchorBounds.width / 2} />
</Animate>
) : null
}
</HoverPreviewDocument>
</div>
</Position>
</Portal>
);
}
function HoverPreview({ node, ...rest }: Props) {
// previews only work for internal doc links for now
if (!isInternalUrl(node.href)) {
return null;
}
return <HoverPreviewInternal {...rest} node={node} />;
}
const Animate = styled.div`
animation: ${fadeAndSlideIn} 150ms ease;
@media print {
display: none;
}
`;
// fills the gap between the card and pointer to avoid a dead zone
const Margin = styled.div`
position: absolute;
top: -11px;
left: 0;
right: 0;
height: 11px;
`;
const CardContent = styled.div`
overflow: hidden;
max-height: 350px;
user-select: none;
`;
// &:after — gradient mask for overflow text
const Card = styled.div`
backdrop-filter: blur(10px);
background: ${(props) => props.theme.background};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
border-radius: 4px;
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
0 0 1px 1px rgba(0, 0, 0, 0.05);
padding: 16px;
width: 350px;
font-size: 0.9em;
position: relative;
.placeholder,
.heading-anchor {
display: none;
}
&:after {
content: "";
display: block;
position: absolute;
pointer-events: none;
background: linear-gradient(
90deg,
${(props) => transparentize(1, props.theme.background)} 0%,
${(props) => transparentize(1, props.theme.background)} 75%,
${(props) => props.theme.background} 90%
);
bottom: 0;
left: 0;
right: 0;
height: 1.7em;
border-bottom: 16px solid ${(props) => props.theme.background};
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
`;
const Position = styled.div`
margin-top: 10px;
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
display: flex;
max-height: 75%;
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
`;
const Pointer = styled.div`
top: -22px;
left: ${(props) => props.offset}px;
width: 22px;
height: 22px;
position: absolute;
transform: translateX(-50%);
pointer-events: none;
&:before,
&:after {
content: "";
display: inline-block;
position: absolute;
bottom: 0;
right: 0;
}
&:before {
border: 8px solid transparent;
border-bottom-color: ${(props) =>
props.theme.menuBorder || "rgba(0, 0, 0, 0.1)"};
right: -1px;
}
&:after {
border: 7px solid transparent;
border-bottom-color: ${(props) => props.theme.background};
}
`;
export default inject("documents")(HoverPreview);
-53
View File
@@ -1,53 +0,0 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
import DocumentMetaWithViews from "components/DocumentMetaWithViews";
import Editor from "components/Editor";
import useStores from "hooks/useStores";
type Props = {
url: string,
children: (React.Node) => React.Node,
};
function HoverPreviewDocument({ url, children }: Props) {
const { documents } = useStores();
const slug = parseDocumentSlug(url);
documents.prefetchDocument(slug, {
prefetch: true,
});
const document = slug ? documents.getByUrl(slug) : undefined;
if (!document) return null;
return children(
<Content to={document.url}>
<Heading>{document.titleWithDefault}</Heading>
<DocumentMetaWithViews isDraft={document.isDraft} document={document} />
<React.Suspense fallback={<div />}>
<Editor
key={document.id}
defaultValue={document.getSummary()}
disableEmbeds
readOnly
/>
</React.Suspense>
</Content>
);
}
const Content = styled(Link)`
cursor: pointer;
`;
const Heading = styled.h2`
margin: 0 0 0.75em;
color: ${(props) => props.theme.text};
`;
export default observer(HoverPreviewDocument);
+86 -71
View File
@@ -1,4 +1,8 @@
// @flow
import * as React from "react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { TwitterPicker } from "react-color";
import {
CollectionIcon,
CoinsIcon,
@@ -19,21 +23,11 @@ import {
SunIcon,
VehicleIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
import styled from "styled-components";
import ContextMenu from "components/ContextMenu";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import { LabelText } from "components/Input";
import { DropdownMenu } from "components/DropdownMenu";
import NudeButton from "components/NudeButton";
const style = { width: 30, height: 30 };
const TwitterPicker = React.lazy(() =>
import("react-color/lib/components/twitter/Twitter")
);
import Flex from "shared/components/Flex";
export const icons = {
collection: {
@@ -123,78 +117,103 @@ const colors = [
"#2F362F",
];
type Props = {|
type Props = {
onOpen?: () => void,
onChange: (color: string, icon: string) => void,
icon: string,
color: string,
|};
};
function IconPicker({ onOpen, icon, color, onChange }: Props) {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
placement: "bottom-end",
});
const Component = icons[icon || "collection"].component;
function preventEventBubble(event) {
event.stopPropagation();
}
return (
<Wrapper>
<Label>
<LabelText>{t("Icon")}</LabelText>
</Label>
<MenuButton {...menu}>
{(props) => (
<Button aria-label={t("Show menu")} {...props}>
<Component color={color} size={30} />
</Button>
)}
</MenuButton>
<ContextMenu {...menu} onOpen={onOpen} aria-label={t("Choose icon")}>
<Icons>
{Object.keys(icons).map((name) => {
const Component = icons[name].component;
return (
<MenuItem
key={name}
onClick={() => onChange(color, name)}
{...menu}
>
{(props) => (
<IconButton style={style} {...props}>
<Component color={color} size={30} />
</IconButton>
)}
</MenuItem>
);
})}
</Icons>
<Flex>
<React.Suspense fallback={<Loading>{t("Loading")}</Loading>}>
@observer
class IconPicker extends React.Component<Props> {
@observable isOpen: boolean = false;
node: ?HTMLElement;
componentDidMount() {
window.addEventListener("click", this.handleClickOutside);
}
componentWillUnmount() {
window.removeEventListener("click", this.handleClickOutside);
}
handleClose = () => {
this.isOpen = false;
};
handleOpen = () => {
this.isOpen = true;
if (this.props.onOpen) {
this.props.onOpen();
}
};
handleClickOutside = (ev: SyntheticMouseEvent<>) => {
// $FlowFixMe
if (ev.target && this.node && this.node.contains(ev.target)) {
return;
}
this.handleClose();
};
render() {
const Component = icons[this.props.icon || "collection"].component;
return (
<Wrapper ref={ref => (this.node = ref)}>
<label>
<LabelText>Icon</LabelText>
</label>
<DropdownMenu
label={
<LabelButton>
<Component role="button" color={this.props.color} size={30} />
</LabelButton>
}
>
<Icons onClick={preventEventBubble}>
{Object.keys(icons).map(name => {
const Component = icons[name].component;
return (
<IconButton
key={name}
onClick={() => this.props.onChange(this.props.color, name)}
style={{ width: 30, height: 30 }}
>
<Component color={this.props.color} size={30} />
</IconButton>
);
})}
</Icons>
<Flex onClick={preventEventBubble}>
<ColorPicker
color={color}
onChange={(color) => onChange(color.hex, icon)}
color={this.props.color}
onChange={color =>
this.props.onChange(color.hex, this.props.icon)
}
colors={colors}
triangle="hide"
/>
</React.Suspense>
</Flex>
</ContextMenu>
</Wrapper>
);
</Flex>
</DropdownMenu>
</Wrapper>
);
}
}
const Label = styled.label`
display: block;
`;
const Icons = styled.div`
padding: 15px 9px 9px 15px;
width: 276px;
`;
const Button = styled(NudeButton)`
border: 1px solid ${(props) => props.theme.inputBorder};
const LabelButton = styled(NudeButton)`
border: 1px solid ${props => props.theme.inputBorder};
width: 32px;
height: 32px;
`;
@@ -206,10 +225,6 @@ const IconButton = styled(NudeButton)`
height: 30px;
`;
const Loading = styled(HelpText)`
padding: 16px;
`;
const ColorPicker = styled(TwitterPicker)`
box-shadow: none !important;
background: transparent !important;
-15
View File
@@ -1,15 +0,0 @@
// @flow
import * as React from "react";
import { cdnPath } from "utils/urls";
type Props = {|
alt: string,
src: string,
title?: string,
width?: number,
height?: number,
|};
export default function Image({ src, alt, ...rest }: Props) {
return <img src={cdnPath(src)} alt={alt} {...rest} />;
}
+24 -31
View File
@@ -1,43 +1,47 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import { observer } from "mobx-react";
import { observable } from "mobx";
import styled from "styled-components";
import Flex from "components/Flex";
import VisuallyHidden from "components/VisuallyHidden";
import Flex from "shared/components/Flex";
const RealTextarea = styled.textarea`
border: 0;
flex: 1;
padding: 8px 12px 8px ${(props) => (props.hasIcon ? "8px" : "12px")};
padding: 8px 12px 8px ${props => (props.hasIcon ? "8px" : "12px")};
outline: none;
background: none;
color: ${(props) => props.theme.text};
color: ${props => props.theme.text};
&:disabled,
&::placeholder {
color: ${(props) => props.theme.placeholder};
color: ${props => props.theme.placeholder};
}
`;
const RealInput = styled.input`
border: 0;
flex: 1;
padding: 8px 12px 8px ${(props) => (props.hasIcon ? "8px" : "12px")};
padding: 8px 12px 8px ${props => (props.hasIcon ? "8px" : "12px")};
outline: none;
background: none;
color: ${(props) => props.theme.text};
color: ${props => props.theme.text};
height: 30px;
&:disabled,
&::placeholder {
color: ${(props) => props.theme.placeholder};
color: ${props => props.theme.placeholder};
}
&::-webkit-search-cancel-button {
-webkit-appearance: searchfield-cancel-button;
}
`;
const Wrapper = styled.div`
flex: ${(props) => (props.flex ? "1" : "0")};
max-width: ${(props) => (props.short ? "350px" : "100%")};
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` : "initial")};
`;
@@ -52,17 +56,16 @@ const IconWrapper = styled.span`
export const Outline = styled(Flex)`
display: flex;
flex: 1;
margin: ${(props) =>
props.margin !== undefined ? props.margin : "0 0 16px"};
margin: ${props => (props.margin !== undefined ? props.margin : "0 0 16px")};
color: inherit;
border-width: 1px;
border-style: solid;
border-color: ${(props) =>
border-color: ${props =>
props.hasError
? "red"
: props.focused
? props.theme.inputBorderFocused
: props.theme.inputBorder};
? props.theme.inputBorderFocused
: props.theme.inputBorder};
border-radius: 4px;
font-weight: normal;
align-items: center;
@@ -72,11 +75,10 @@ export const Outline = styled(Flex)`
export const LabelText = styled.div`
font-weight: 500;
padding-bottom: 4px;
display: inline-block;
`;
export type Props = {|
type?: "text" | "email" | "checkbox" | "search",
export type Props = {
type?: string,
value?: string,
label?: string,
className?: string,
@@ -85,18 +87,9 @@ export type Props = {|
short?: boolean,
margin?: string | number,
icon?: React.Node,
name?: string,
minLength?: number,
maxLength?: number,
autoFocus?: boolean,
autoComplete?: boolean | string,
readOnly?: boolean,
required?: boolean,
placeholder?: string,
onChange?: (ev: SyntheticInputEvent<HTMLInputElement>) => mixed,
onFocus?: (ev: SyntheticEvent<>) => void,
onBlur?: (ev: SyntheticEvent<>) => void,
|};
};
@observer
class Input extends React.Component<Props> {
@@ -153,7 +146,7 @@ class Input extends React.Component<Props> {
<Outline focused={this.focused} margin={margin}>
{icon && <IconWrapper>{icon}</IconWrapper>}
<InputComponent
ref={(ref) => (this.input = ref)}
ref={ref => (this.input = ref)}
onBlur={this.handleBlur}
onFocus={this.handleFocus}
type={type === "textarea" ? undefined : type}
-13
View File
@@ -1,13 +0,0 @@
// @flow
import styled from "styled-components";
import Input from "./Input";
const InputLarge = styled(Input)`
height: 40px;
input {
height: 40px;
}
`;
export default InputLarge;
+32 -16
View File
@@ -1,26 +1,26 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import * as React from "react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import styled, { withTheme } from "styled-components";
import UiStore from "stores/UiStore";
import Editor from "components/Editor";
import HelpText from "components/HelpText";
import { LabelText, Outline } from "components/Input";
type Props = {|
type Props = {
label: string,
minHeight?: number,
maxHeight?: number,
readOnly?: boolean,
ui: UiStore,
|};
};
@observer
class InputRich extends React.Component<Props> {
@observable editorComponent: React.ComponentType<any>;
@observable focused: boolean = false;
componentDidMount() {
this.loadEditor();
}
handleBlur = () => {
this.focused = false;
};
@@ -29,34 +29,50 @@ class InputRich extends React.Component<Props> {
this.focused = true;
};
loadEditor = async () => {
try {
const EditorImport = await import("./Editor");
this.editorComponent = EditorImport.default;
} catch (err) {
console.error(err);
// If the editor bundle fails to load then reload the entire window. This
// can happen if a deploy happens between the user loading the initial JS
// bundle and the async-loaded editor JS bundle as the hash will change.
window.location.reload();
}
};
render() {
const { label, minHeight, maxHeight, ui, ...rest } = this.props;
const { label, minHeight, maxHeight, ...rest } = this.props;
const Editor = this.editorComponent;
return (
<>
<React.Fragment>
<LabelText>{label}</LabelText>
<StyledOutline
maxHeight={maxHeight}
minHeight={minHeight}
focused={this.focused}
>
<React.Suspense fallback={<HelpText>Loading editor</HelpText>}>
{Editor ? (
<Editor
onBlur={this.handleBlur}
onFocus={this.handleFocus}
ui={ui}
grow
{...rest}
/>
</React.Suspense>
) : (
"Loading…"
)}
</StyledOutline>
</>
</React.Fragment>
);
}
}
const StyledOutline = styled(Outline)`
display: block;
padding: 8px 12px;
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : "0")};
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : "auto")};
@@ -67,4 +83,4 @@ const StyledOutline = styled(Outline)`
}
`;
export default inject("ui")(withTheme(InputRich));
export default withTheme(InputRich);
+12 -27
View File
@@ -1,26 +1,19 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import { SearchIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import keydown from "react-keydown";
import { observer } from "mobx-react";
import { observable } from "mobx";
import { withRouter, type RouterHistory } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import Input from "./Input";
import { type Theme } from "types";
import { meta } from "utils/keyboard";
import { SearchIcon } from "outline-icons";
import { searchUrl } from "utils/routeHelpers";
import Input from "./Input";
type Props = {
history: RouterHistory,
theme: Theme,
source: string,
theme: Object,
placeholder?: string,
label?: string,
labelHidden?: boolean,
collectionId?: string,
t: TFunction,
};
@observer
@@ -28,8 +21,8 @@ class InputSearch extends React.Component<Props> {
input: ?Input;
@observable focused: boolean = false;
@keydown(`${meta}+f`)
focus(ev: SyntheticEvent<>) {
@keydown("meta+f")
focus(ev) {
ev.preventDefault();
if (this.input) {
@@ -37,13 +30,10 @@ class InputSearch extends React.Component<Props> {
}
}
handleSearchInput = (ev: SyntheticInputEvent<>) => {
handleSearchInput = ev => {
ev.preventDefault();
this.props.history.push(
searchUrl(ev.target.value, {
collectionId: this.props.collectionId,
ref: this.props.source,
})
searchUrl(ev.target.value, this.props.collectionId)
);
};
@@ -56,12 +46,11 @@ class InputSearch extends React.Component<Props> {
};
render() {
const { t } = this.props;
const { theme, placeholder = `${t("Search")}` } = this.props;
const { theme, placeholder = "Search…" } = this.props;
return (
<InputMaxWidth
ref={(ref) => (this.input = ref)}
ref={ref => (this.input = ref)}
type="search"
placeholder={placeholder}
onInput={this.handleSearchInput}
@@ -70,8 +59,6 @@ class InputSearch extends React.Component<Props> {
color={this.focused ? theme.inputBorderFocused : theme.inputBorder}
/>
}
label={this.props.label}
labelHidden={this.props.labelHidden}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
margin={0}
@@ -84,6 +71,4 @@ const InputMaxWidth = styled(Input)`
max-width: 30vw;
`;
export default withTranslation()<InputSearch>(
withTheme(withRouter(InputSearch))
);
export default withTheme(withRouter(InputSearch));
+10 -27
View File
@@ -1,43 +1,33 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import { observer } from "mobx-react";
import { observable } from "mobx";
import styled from "styled-components";
import VisuallyHidden from "components/VisuallyHidden";
import { Outline, LabelText } from "./Input";
const Select = styled.select`
border: 0;
flex: 1;
padding: 4px 0;
margin: 0 12px;
padding: 8px 12px;
outline: none;
background: none;
color: ${(props) => props.theme.text};
height: 30px;
color: ${props => props.theme.text};
&:disabled,
&::placeholder {
color: ${(props) => props.theme.placeholder};
color: ${props => props.theme.placeholder};
}
`;
const Wrapper = styled.label`
display: block;
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
type Option = { label: string, value: string };
export type Props = {
value?: string,
label?: string,
short?: boolean,
className?: string,
labelHidden?: boolean,
options: Option[],
onBlur?: () => void,
onFocus?: () => void,
};
@observer
@@ -53,19 +43,12 @@ class InputSelect extends React.Component<Props> {
};
render() {
const {
label,
className,
labelHidden,
options,
short,
...rest
} = this.props;
const { label, className, labelHidden, options, ...rest } = this.props;
const wrappedLabel = <LabelText>{label}</LabelText>;
return (
<Wrapper short={short}>
<label>
{label &&
(labelHidden ? (
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
@@ -74,14 +57,14 @@ class InputSelect extends React.Component<Props> {
))}
<Outline focused={this.focused} className={className}>
<Select onBlur={this.handleBlur} onFocus={this.handleFocus} {...rest}>
{options.map((option) => (
{options.map(option => (
<option value={option.value} key={option.value}>
{option.label}
</option>
))}
</Select>
</Outline>
</Wrapper>
</label>
);
}
}
+5 -5
View File
@@ -7,13 +7,13 @@ const Key = styled.kbd`
font: 11px "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier,
monospace;
line-height: 10px;
color: ${(props) => props.theme.almostBlack};
color: ${props => props.theme.almostBlack};
vertical-align: middle;
background-color: ${(props) => props.theme.smokeLight};
border: solid 1px ${(props) => props.theme.slateLight};
border-bottom-color: ${(props) => props.theme.slate};
background-color: ${props => props.theme.smokeLight};
border: solid 1px ${props => props.theme.slateLight};
border-bottom-color: ${props => props.theme.slate};
border-radius: 3px;
box-shadow: inset 0 -1px 0 ${(props) => props.theme.slate};
box-shadow: inset 0 -1px 0 ${props => props.theme.slate};
`;
export default Key;
+5 -5
View File
@@ -1,13 +1,13 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { observer } from "mobx-react";
import Flex from "shared/components/Flex";
import styled from "styled-components";
import Flex from "components/Flex";
type Props = {|
type Props = {
label: React.Node | string,
children: React.Node,
|};
};
const Labeled = ({ label, children, ...props }: Props) => (
<Flex column {...props}>
@@ -21,7 +21,7 @@ export const Label = styled(Flex)`
font-size: 13px;
font-weight: 500;
text-transform: uppercase;
color: ${(props) => props.theme.textTertiary};
color: ${props => props.theme.textTertiary};
letter-spacing: 0.04em;
`;
-101
View File
@@ -1,101 +0,0 @@
// @flow
import { find } from "lodash";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import { languages, languageOptions } from "shared/i18n";
import ButtonLink from "components/ButtonLink";
import Flex from "components/Flex";
import NoticeTip from "components/NoticeTip";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import { detectLanguage } from "utils/language";
function Icon(props) {
return (
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M21 18H16L14 16V6C14 4.89543 14.8954 4 16 4H28C29.1046 4 30 4.89543 30 6V16C30 17.1046 29.1046 18 28 18H27L25.4142 19.5858C24.6332 20.3668 23.3668 20.3668 22.5858 19.5858L21 18ZM16 15.1716V6H28V16H27H26.1716L25.5858 16.5858L24 18.1716L22.4142 16.5858L21.8284 16H21H16.8284L16 15.1716Z"
fill="#2B2F35"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M16 13H4C2.89543 13 2 13.8954 2 15V25C2 26.1046 2.89543 27 4 27H5L6.58579 28.5858C7.36684 29.3668 8.63316 29.3668 9.41421 28.5858L11 27H16C17.1046 27 18 26.1046 18 25V15C18 13.8954 17.1046 13 16 13ZM9 17L6 16.9681C6 16.9681 5 17.016 5 18C5 18.984 6 19 6 19H8.5H10C10 19 9.57627 20.1885 8.38983 21.0831C7.20339 21.9777 5.7197 23 5.7197 23C5.7197 23 4.99153 23.6054 5.5 24.5C6.00847 25.3946 7 24.8403 7 24.8403L9.74576 22.8722L11.9492 24.6614C11.9492 24.6614 12.6271 25.3771 13.3051 24.4825C13.9831 23.5879 13.3051 23.0512 13.3051 23.0512L11.1017 21.262C11.1017 21.262 11.5 21 12 20L12.5 19H14C14 19 15 19.0319 15 18C15 16.9681 14 16.9681 14 16.9681L11 17V16C11 16 11.0169 15 10 15C8.98305 15 9 16 9 16V17Z"
fill="#2B2F35"
/>
<path
d="M23.6672 12.5221L23.5526 12.1816H23.1934H20.8818H20.5215L20.4075 12.5235L20.082 13.5H19.2196L21.2292 8.10156H21.8774L21.5587 9.06116L20.7633 11.4562L20.5449 12.1138H21.2378H22.8374H23.5327L23.3114 11.4546L22.5072 9.05959L22.1855 8.10156H22.768L24.7887 13.5H23.9964L23.6672 12.5221Z"
fill="#2B2F35"
stroke="#2B2F35"
/>
</svg>
);
}
export default function LanguagePrompt() {
const { auth, ui } = useStores();
const { t } = useTranslation();
const user = useCurrentUser();
const language = detectLanguage();
if (language === "en_US" || language === user.language) {
return null;
}
if (!languages.includes(language)) {
return null;
}
const option = find(languageOptions, (o) => o.value === language);
const optionLabel = option ? option.label : "";
return (
<NoticeTip>
<Flex align="center">
<LanguageIcon />
<span>
<Trans>
Outline is available in your language {{ optionLabel }}, would you
like to change?
</Trans>
<br />
<Link
onClick={() => {
auth.updateUser({
language,
});
ui.setLanguagePromptDismissed();
}}
>
{t("Change Language")}
</Link>{" "}
&middot;{" "}
<Link onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</Link>
</span>
</Flex>
</NoticeTip>
);
}
const Link = styled(ButtonLink)`
color: ${(props) => props.theme.almostBlack};
font-weight: 500;
&:hover {
text-decoration: underline;
}
`;
const LanguageIcon = styled(Icon)`
margin-right: 12px;
`;
+37 -91
View File
@@ -1,37 +1,33 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { MenuIcon } from "outline-icons";
import * as React from "react";
import { Helmet } from "react-helmet";
import { withTranslation, type TFunction } from "react-i18next";
import keydown from "react-keydown";
import { Switch, Route, Redirect } from "react-router-dom";
import { Helmet } from "react-helmet";
import styled, { withTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import AuthStore from "stores/AuthStore";
import DocumentsStore from "stores/DocumentsStore";
import UiStore from "stores/UiStore";
import ErrorSuspended from "scenes/ErrorSuspended";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import keydown from "react-keydown";
import Analytics from "components/Analytics";
import Button from "components/Button";
import DocumentHistory from "components/DocumentHistory";
import Flex from "components/Flex";
import { LoadingIndicatorBar } from "components/LoadingIndicator";
import Modal from "components/Modal";
import Sidebar from "components/Sidebar";
import SettingsSidebar from "components/Sidebar/Settings";
import SkipNavContent from "components/SkipNavContent";
import SkipNavLink from "components/SkipNavLink";
import { type Theme } from "types";
import { meta } from "utils/keyboard";
import Flex from "shared/components/Flex";
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 = {
documents: DocumentsStore,
children?: ?React.Node,
@@ -40,9 +36,7 @@ type Props = {
auth: AuthStore,
ui: UiStore,
notifications?: React.Node,
theme: Theme,
i18n: Object,
t: TFunction,
theme: Object,
};
@observer
@@ -51,27 +45,21 @@ class Layout extends React.Component<Props> {
@observable redirectTo: ?string;
@observable keyboardShortcutsOpen: boolean = false;
constructor(props: Props) {
super();
this.updateBackground(props);
componentWillMount() {
this.updateBackground();
}
componentDidUpdate() {
this.updateBackground(this.props);
this.updateBackground();
if (this.redirectTo) {
this.redirectTo = undefined;
}
}
updateBackground(props: Props) {
updateBackground() {
// ensure the wider page color always matches the theme
window.document.body.style.background = props.theme.background;
}
@keydown(`${meta}+.`)
handleToggleSidebar() {
this.props.ui.toggleCollapsedSidebar();
window.document.body.style.background = this.props.theme.background;
}
@keydown("shift+/")
@@ -84,8 +72,8 @@ class Layout extends React.Component<Props> {
this.keyboardShortcutsOpen = false;
};
@keydown(["t", "/", `${meta}+k`])
goToSearch(ev: SyntheticEvent<>) {
@keydown(["t", "/", "meta+k"])
goToSearch(ev) {
if (this.props.ui.editMode) return;
ev.preventDefault();
ev.stopPropagation();
@@ -99,10 +87,9 @@ class Layout extends React.Component<Props> {
}
render() {
const { auth, t, ui } = this.props;
const { auth, ui } = this.props;
const { user, team } = auth;
const showSidebar = auth.authenticated && user && team;
const sidebarCollapsed = ui.editMode || ui.sidebarCollapsed;
if (auth.isSuspended) return <ErrorSuspended />;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
@@ -110,25 +97,17 @@ class Layout extends React.Component<Props> {
return (
<Container column auto>
<Helmet>
<title>{team && team.name ? team.name : "Outline"}</title>
<title>Outline</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
</Helmet>
<SkipNavLink />
<Analytics />
{this.props.ui.progressBarVisible && <LoadingIndicatorBar />}
{this.props.notifications}
<MobileMenuButton
onClick={ui.toggleMobileSidebar}
icon={<MenuIcon />}
iconColor="currentColor"
neutral
/>
<Container auto>
{showSidebar && (
<Switch>
@@ -137,18 +116,7 @@ class Layout extends React.Component<Props> {
</Switch>
)}
<SkipNavContent />
<Content
auto
justify="center"
$isResizing={ui.sidebarIsResizing}
$sidebarCollapsed={sidebarCollapsed}
style={
sidebarCollapsed
? undefined
: { marginLeft: `${ui.sidebarWidth}px` }
}
>
<Content auto justify="center" editMode={ui.editMode}>
{this.props.children}
</Content>
@@ -159,61 +127,39 @@ class Layout extends React.Component<Props> {
/>
</Switch>
</Container>
<Modals ui={ui} />
<Modal
isOpen={this.keyboardShortcutsOpen}
onRequestClose={this.handleCloseKeyboardShortcuts}
title={t("Keyboard shortcuts")}
title="Keyboard shortcuts"
>
<KeyboardShortcuts />
</Modal>
<GlobalStyles />
</Container>
);
}
}
const Container = styled(Flex)`
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
background: ${props => props.theme.background};
transition: ${props => props.theme.backgroundTransition};
position: relative;
width: 100%;
min-height: 100%;
`;
const MobileMenuButton = styled(Button)`
position: fixed;
top: 12px;
left: 12px;
z-index: ${(props) => props.theme.depths.sidebar - 1};
${breakpoint("tablet")`
display: none;
`};
@media print {
display: none;
}
`;
const Content = styled(Flex)`
margin: 0;
transition: ${(props) =>
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
transition: margin-left 100ms ease-out;
@media print {
margin: 0;
}
${breakpoint("mobile", "tablet")`
margin-left: 0 !important;
`}
${breakpoint("tablet")`
${(props) =>
props.$sidebarCollapsed &&
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
margin-left: ${props => (props.editMode ? 0 : props.theme.sidebarWidth)};
`};
`;
export default withTranslation()<Layout>(
inject("auth", "ui", "documents")(withTheme(Layout))
);
export default inject("auth", "ui", "documents")(withTheme(Layout));
+4 -4
View File
@@ -1,7 +1,7 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import Flex from "components/Flex";
import Flex from "shared/components/Flex";
type Props = {
image?: React.Node,
@@ -27,9 +27,9 @@ const ListItem = ({ image, title, subtitle, actions }: Props) => {
const Wrapper = styled.li`
display: flex;
padding: ${(props) => (props.compact ? "8px" : "12px")} 0;
padding: ${props => (props.compact ? "8px" : "12px")} 0;
margin: 0;
border-bottom: 1px solid ${(props) => props.theme.divider};
border-bottom: 1px solid ${props => props.theme.divider};
&:last-child {
border-bottom: 0;
@@ -59,7 +59,7 @@ const Content = styled(Flex)`
const Subtitle = styled.p`
margin: 0;
font-size: 14px;
color: ${(props) => props.theme.slate};
color: ${props => props.theme.slate};
`;
const Actions = styled.div`
+4 -4
View File
@@ -1,10 +1,10 @@
// @flow
import { times } from "lodash";
import * as React from "react";
import { times } from "lodash";
import styled from "styled-components";
import Fade from "components/Fade";
import Flex from "components/Flex";
import Mask from "components/Mask";
import Fade from "components/Fade";
import Flex from "shared/components/Flex";
type Props = {
count?: number,
@@ -13,7 +13,7 @@ type Props = {
const Placeholder = ({ count }: Props) => {
return (
<Fade>
{times(count || 2, (index) => (
{times(count || 2, index => (
<Item key={index} column auto>
<Mask />
</Item>
@@ -1,6 +1,6 @@
// @flow
import { inject, observer } from "mobx-react";
import * as React from "react";
import { inject, observer } from "mobx-react";
import UiStore from "stores/UiStore";
type Props = {
@@ -18,7 +18,7 @@ const loadingFrame = keyframes`
const Container = styled.div`
position: fixed;
top: 0;
z-index: ${(props) => props.theme.depths.loadingIndicatorBar};
z-index: 9999;
background-color: #03a9f4;
width: 100%;
@@ -1,10 +1,10 @@
// @flow
import { times } from "lodash";
import * as React from "react";
import { times } from "lodash";
import styled from "styled-components";
import Fade from "components/Fade";
import Flex from "components/Flex";
import Mask from "components/Mask";
import Fade from "components/Fade";
import Flex from "shared/components/Flex";
type Props = {
count?: number,
@@ -13,7 +13,7 @@ type Props = {
const ListPlaceHolder = ({ count }: Props) => {
return (
<Fade>
{times(count || 2, (index) => (
{times(count || 2, index => (
<Item key={index} column auto>
<Mask header />
<Mask />
@@ -1,24 +1,21 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import DelayedMount from "components/DelayedMount";
import Fade from "components/Fade";
import Flex from "components/Flex";
import Mask from "components/Mask";
import Fade from "components/Fade";
import Flex from "shared/components/Flex";
export default function LoadingPlaceholder(props: Object) {
return (
<DelayedMount>
<Wrapper>
<Flex column auto {...props}>
<Mask height={34} />
<br />
<Mask />
<Mask />
<Mask />
</Flex>
</Wrapper>
</DelayedMount>
<Wrapper>
<Flex column auto {...props}>
<Mask height={34} />
<br />
<Mask />
<Mask />
<Mask />
</Flex>
</Wrapper>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
// @flow
import ListPlaceholder from "./ListPlaceholder";
import LoadingPlaceholder from "./LoadingPlaceholder";
import ListPlaceholder from "./ListPlaceholder";
export default LoadingPlaceholder;
export { ListPlaceholder };
-89
View File
@@ -1,89 +0,0 @@
// @flow
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
import format from "date-fns/format";
import * as React from "react";
import Tooltip from "components/Tooltip";
import useUserLocale from "hooks/useUserLocale";
const locales = {
en: require(`date-fns/locale/en`),
de: require(`date-fns/locale/de`),
es: require(`date-fns/locale/es`),
fr: require(`date-fns/locale/fr`),
it: require(`date-fns/locale/it`),
ko: require(`date-fns/locale/ko`),
pt: require(`date-fns/locale/pt`),
zh: require(`date-fns/locale/zh_cn`),
};
let callbacks = [];
// This is a shared timer that fires every minute, used for
// updating all Time components across the page all at once.
setInterval(() => {
callbacks.forEach((cb) => cb());
}, 1000 * 60);
function eachMinute(fn) {
callbacks.push(fn);
return () => {
callbacks = callbacks.filter((cb) => cb !== fn);
};
}
type Props = {
dateTime: string,
children?: React.Node,
tooltipDelay?: number,
addSuffix?: boolean,
shorten?: boolean,
};
function LocaleTime({
addSuffix,
children,
dateTime,
shorten,
tooltipDelay,
}: Props) {
const userLocale = useUserLocale();
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line no-unused-vars
const callback = React.useRef();
React.useEffect(() => {
callback.current = eachMinute(() => {
setMinutesMounted((state) => ++state);
});
return () => {
if (callback.current) {
callback.current();
}
};
}, []);
let content = distanceInWordsToNow(dateTime, {
addSuffix,
locale: userLocale ? locales[userLocale] : undefined,
});
if (shorten) {
content = content
.replace("about", "")
.replace("less than a minute ago", "just now")
.replace("minute", "min");
}
return (
<Tooltip
tooltip={format(dateTime, "MMMM Do, YYYY h:mm a")}
delay={tooltipDelay}
placement="bottom"
>
<time dateTime={dateTime}>{children || content}</time>
</Tooltip>
);
}
export default LocaleTime;
+9 -11
View File
@@ -1,14 +1,14 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import { randomInteger } from "shared/random";
import { pulsate } from "shared/styles/animations";
import Flex from "components/Flex";
import { randomInteger } from "shared/random";
import Flex from "shared/components/Flex";
type Props = {|
type Props = {
header?: boolean,
height?: number,
|};
};
class Mask extends React.Component<Props> {
width: number;
@@ -17,22 +17,20 @@ class Mask extends React.Component<Props> {
return false;
}
constructor() {
super();
componentWillMount() {
this.width = randomInteger(75, 100);
}
render() {
return <Redacted width={this.width} />;
return <Redacted width={this.width} {...this.props} />;
}
}
const Redacted = styled(Flex)`
width: ${(props) => (props.header ? props.width / 2 : props.width)}%;
height: ${(props) =>
props.height ? props.height : props.header ? 24 : 18}px;
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.divider};
background-color: ${props => props.theme.divider};
animation: ${pulsate} 1.3s infinite;
&:last-child {
+34 -42
View File
@@ -1,52 +1,48 @@
// @flow
import { observer } from "mobx-react";
import { CloseIcon, BackIcon } from "outline-icons";
import { transparentize } from "polished";
import * as React from "react";
import ReactModal from "react-modal";
import { observer } from "mobx-react";
import styled, { createGlobalStyle } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { fadeAndScaleIn } from "shared/styles/animations";
import Flex from "components/Flex";
import ReactModal from "react-modal";
import { transparentize } from "polished";
import { CloseIcon, BackIcon } from "outline-icons";
import NudeButton from "components/NudeButton";
import Scrollable from "components/Scrollable";
import { fadeAndScaleIn } from "shared/styles/animations";
import Flex from "shared/components/Flex";
ReactModal.setAppElement("#root");
type Props = {|
type Props = {
children?: React.Node,
isOpen: boolean,
title?: string,
onRequestClose: () => void,
|};
};
const GlobalStyles = createGlobalStyle`
.ReactModal__Overlay {
background-color: ${(props) =>
background-color: ${props =>
transparentize(0.25, props.theme.background)} !important;
z-index: ${(props) => props.theme.depths.modalOverlay};
z-index: 100;
}
${breakpoint("tablet")`
.ReactModalPortal + .ReactModalPortal,
.ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
.ReactModalPortal + .ReactModalPortal {
.ReactModal__Overlay {
margin-left: 12px;
box-shadow: 0 -2px 10px ${(props) => props.theme.shadow};
box-shadow: 0 -2px 10px ${props => props.theme.shadow};
border-radius: 8px 0 0 8px;
overflow: hidden;
}
}
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal,
.ReactModalPortal + .ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal {
.ReactModal__Overlay {
margin-left: 24px;
}
}
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + .ReactModalPortal,
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + .ReactModalPortal {
.ReactModal__Overlay {
margin-left: 36px;
}
@@ -68,7 +64,7 @@ const Modal = ({
if (!isOpen) return null;
return (
<>
<React.Fragment>
<GlobalStyles />
<StyledModal
contentLabel={title}
@@ -76,11 +72,10 @@ const Modal = ({
isOpen={isOpen}
{...rest}
>
<Content>
<Centered onClick={(ev) => ev.stopPropagation()} column>
{title && <h1>{title}</h1>}
{children}
</Centered>
<Content onClick={ev => ev.stopPropagation()} column>
{title && <h1>{title}</h1>}
{children}
</Content>
<Back onClick={onRequestClose}>
<BackIcon size={32} color="currentColor" />
@@ -90,24 +85,14 @@ const Modal = ({
<CloseIcon size={32} color="currentColor" />
</Close>
</StyledModal>
</>
</React.Fragment>
);
};
const Content = styled(Scrollable)`
width: 100%;
padding: 8vh 2rem 2rem;
${breakpoint("tablet")`
padding-top: 13vh;
`};
`;
const Centered = styled(Flex)`
const Content = styled(Flex)`
width: 640px;
max-width: 100%;
position: relative;
margin: 0 auto;
`;
const StyledModal = styled(ReactModal)`
@@ -118,13 +103,20 @@ const StyledModal = styled(ReactModal)`
left: 0;
bottom: 0;
right: 0;
z-index: ${(props) => props.theme.depths.modal};
z-index: 100;
display: flex;
justify-content: center;
align-items: flex-start;
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
overflow-x: hidden;
overflow-y: auto;
background: ${props => props.theme.background};
transition: ${props => props.theme.backgroundTransition};
padding: 8vh 2rem 2rem;
outline: none;
${breakpoint("tablet")`
padding-top: 13vh;
`};
`;
const Text = styled.span`
@@ -141,7 +133,7 @@ const Close = styled(NudeButton)`
right: 0;
margin: 12px;
opacity: 0.75;
color: ${(props) => props.theme.text};
color: ${props => props.theme.text};
width: auto;
height: auto;
@@ -155,13 +147,13 @@ const Close = styled(NudeButton)`
`;
const Back = styled(NudeButton)`
position: absolute;
position: fixed;
display: none;
align-items: center;
top: 2rem;
left: 2rem;
opacity: 0.75;
color: ${(props) => props.theme.text};
color: ${props => props.theme.text};
width: auto;
height: auto;
+62
View File
@@ -0,0 +1,62 @@
// @flow
import * as React from "react";
import { observer } from "mobx-react";
import BaseModal from "components/Modal";
import UiStore from "stores/UiStore";
import CollectionNew from "scenes/CollectionNew";
import CollectionEdit from "scenes/CollectionEdit";
import CollectionDelete from "scenes/CollectionDelete";
import CollectionExport from "scenes/CollectionExport";
import DocumentDelete from "scenes/DocumentDelete";
import DocumentShare from "scenes/DocumentShare";
type Props = {
ui: UiStore,
};
@observer
class Modals extends React.Component<Props> {
handleClose = () => {
this.props.ui.clearActiveModal();
};
render() {
const { activeModalName, activeModalProps } = this.props.ui;
const Modal = ({ name, children, ...rest }) => {
return (
<BaseModal
isOpen={activeModalName === name}
onRequestClose={this.handleClose}
{...rest}
>
{React.cloneElement(children, activeModalProps)}
</BaseModal>
);
};
return (
<span>
<Modal name="collection-new" title="Create a collection">
<CollectionNew onSubmit={this.handleClose} />
</Modal>
<Modal name="collection-edit" title="Edit collection">
<CollectionEdit onSubmit={this.handleClose} />
</Modal>
<Modal name="collection-delete" title="Delete collection">
<CollectionDelete onSubmit={this.handleClose} />
</Modal>
<Modal name="collection-export" title="Export collection">
<CollectionExport onSubmit={this.handleClose} />
</Modal>
<Modal name="document-share" title="Share document">
<DocumentShare onSubmit={this.handleClose} />
</Modal>
<Modal name="document-delete" title="Delete document">
<DocumentDelete onSubmit={this.handleClose} />
</Modal>
</span>
);
}
}
export default Modals;
-12
View File
@@ -1,12 +0,0 @@
// @flow
import styled from "styled-components";
const Notice = styled.p`
background: ${(props) => props.theme.sidebarBackground};
color: ${(props) => props.theme.sidebarText};
padding: 10px 12px;
border-radius: 4px;
position: relative;
`;
export default Notice;
-24
View File
@@ -1,24 +0,0 @@
// @flow
import * as React from "react";
import Notice from "components/Notice";
export default function AlertNotice({ children }: { children: React.Node }) {
return (
<Notice muted>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{ position: "relative", top: "2px" }}
>
<path
d="M15.6676 11.5372L10.0155 1.14735C9.10744 -0.381434 6.89378 -0.383465 5.98447 1.14735L0.332715 11.5372C-0.595598 13.0994 0.528309 15.0776 2.34778 15.0776H13.652C15.47 15.0776 16.5959 13.101 15.6676 11.5372ZM8 13.2026C7.48319 13.2026 7.0625 12.7819 7.0625 12.2651C7.0625 11.7483 7.48319 11.3276 8 11.3276C8.51681 11.3276 8.9375 11.7483 8.9375 12.2651C8.9375 12.7819 8.51681 13.2026 8 13.2026ZM8.9375 9.45257C8.9375 9.96938 8.51681 10.3901 8 10.3901C7.48319 10.3901 7.0625 9.96938 7.0625 9.45257V4.76507C7.0625 4.24826 7.48319 3.82757 8 3.82757C8.51681 3.82757 8.9375 4.24826 8.9375 4.76507V9.45257Z"
fill="currentColor"
/>
</svg>{" "}
{children}
</Notice>
);
}
-22
View File
@@ -1,22 +0,0 @@
// @flow
import styled from "styled-components";
const Notice = styled.p`
background: ${(props) => props.theme.brand.marine};
color: ${(props) => props.theme.almostBlack};
padding: 10px 12px;
margin-top: 24px;
border-radius: 4px;
position: relative;
a {
color: ${(props) => props.theme.almostBlack};
font-weight: 500;
}
a:hover {
text-decoration: underline;
}
`;
export default Notice;
+14 -7
View File
@@ -1,19 +1,26 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import { lighten } from "polished";
const Button = styled.button`
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
width: 24px;
height: 24px;
background: none;
border-radius: 4px;
line-height: 0;
border: 0;
padding: 0;
cursor: pointer;
user-select: none;
&:focus {
transition-duration: 0.05s;
box-shadow: ${props => lighten(0.4, props.theme.buttonBackground)} 0px 0px
0px 3px;
outline: none;
}
`;
export default React.forwardRef<any, typeof Button>(
({ size = 24, ...props }, ref) => <Button size={size} {...props} ref={ref} />
);
// $FlowFixMe - need to upgrade to get forwardRef
export default React.forwardRef((props, ref) => (
<Button {...props} ref={ref} />
));
+15 -25
View File
@@ -1,33 +1,23 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { Helmet } from "react-helmet";
import useStores from "hooks/useStores";
import { cdnPath } from "utils/urls";
type Props = {|
type Props = {
title: string,
favicon?: string,
|};
const PageTitle = ({ title, favicon }: Props) => {
const { auth } = useStores();
const { team } = auth;
return (
<Helmet>
<title>
{team && team.name ? `${title} - ${team.name}` : `${title} - Outline`}
</title>
<link
rel="shortcut icon"
type="image/png"
href={favicon || cdnPath("/favicon-32.png")}
sizes="32x32"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</Helmet>
);
};
export default observer(PageTitle);
const PageTitle = ({ title, favicon }: Props) => (
<Helmet>
<title>{`${title} - Outline`}</title>
<link
rel="shortcut icon"
type="image/png"
href={favicon || "/favicon-32.png"}
sizes="32x32"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</Helmet>
);
export default PageTitle;
+6 -12
View File
@@ -1,23 +1,17 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { observer } from "mobx-react";
import Document from "models/Document";
import DocumentListItem from "components/DocumentListItem";
import DocumentPreview from "components/DocumentPreview";
import PaginatedList from "components/PaginatedList";
type Props = {|
type Props = {
documents: Document[],
fetch: (options: ?Object) => Promise<void>,
options?: Object,
heading?: React.Node,
empty?: React.Node,
showNestedDocuments?: boolean,
showCollection?: boolean,
showPublished?: boolean,
showPin?: boolean,
showDraft?: boolean,
showTemplate?: boolean,
|};
};
@observer
class PaginatedDocumentList extends React.Component<Props> {
@@ -31,8 +25,8 @@ class PaginatedDocumentList extends React.Component<Props> {
heading={heading}
fetch={fetch}
options={options}
renderItem={(item) => (
<DocumentListItem key={item.id} document={item} {...rest} />
renderItem={item => (
<DocumentPreview key={item.id} document={item} {...rest} />
)}
/>
);
+9 -17
View File
@@ -1,12 +1,11 @@
// @flow
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { isEqual } from "lodash";
import * as React from "react";
import { observable, action } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { Waypoint } from "react-waypoint";
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DelayedMount from "components/DelayedMount";
import { ListPlaceholder } from "components/LoadingPlaceholder";
type Props = {
@@ -15,7 +14,7 @@ type Props = {
heading?: React.Node,
empty?: React.Node,
items: any[],
renderItem: (any) => React.Node,
renderItem: any => React.Node,
};
@observer
@@ -41,9 +40,6 @@ class PaginatedList extends React.Component<Props> {
if (prevProps.fetch !== this.props.fetch) {
this.fetchResults();
}
if (!isEqual(prevProps.options, this.props.options)) {
this.fetchResults();
}
}
fetchResults = async () => {
@@ -100,10 +96,10 @@ class PaginatedList extends React.Component<Props> {
(this.isLoaded || this.isInitiallyLoaded) && !showLoading && !showEmpty;
return (
<>
<React.Fragment>
{showEmpty && empty}
{showList && (
<>
<React.Fragment>
{heading}
<ArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
@@ -114,14 +110,10 @@ class PaginatedList extends React.Component<Props> {
{this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
)}
</>
</React.Fragment>
)}
{showLoading && (
<DelayedMount>
<ListPlaceholder count={5} />
</DelayedMount>
)}
</>
{showLoading && <ListPlaceholder count={5} />}
</React.Fragment>
);
}
}
+17 -40
View File
@@ -1,20 +1,20 @@
// @flow
import { observer } from "mobx-react";
import { GoToIcon } from "outline-icons";
import * as React from "react";
import { observer } from "mobx-react";
import styled from "styled-components";
import type { DocumentPath } from "stores/CollectionsStore";
import Collection from "models/Collection";
import { GoToIcon } 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";
import CollectionIcon from "components/CollectionIcon";
import Flex from "components/Flex";
type Props = {
result: DocumentPath,
document?: ?Document,
collection: ?Collection,
onSuccess?: () => void,
style?: Object,
ref?: (?React.ElementRef<"div">) => void,
};
@@ -35,38 +35,28 @@ class PathToDocument extends React.Component<Props> {
};
render() {
const { result, collection, document, ref, style } = 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=""
style={style}
role="option"
selectable
>
<Component ref={ref} onClick={this.handleClick} href="" selectable>
{collection && <CollectionIcon collection={collection} />}
&nbsp;
{result.path
.map((doc) => <Title key={doc.id}>{doc.title}</Title>)
.map(doc => <Title key={doc.id}>{doc.title}</Title>)
.reduce((prev, curr) => [prev, <StyledGoToIcon />, curr])}
{document && (
<DocumentTitle>
<Flex>
{" "}
<StyledGoToIcon /> <Title>{document.title}</Title>
</DocumentTitle>
</Flex>
)}
</Component>
);
}
}
const DocumentTitle = styled(Flex)``;
const Title = styled.span`
white-space: nowrap;
overflow: hidden;
@@ -74,42 +64,29 @@ const Title = styled.span`
`;
const StyledGoToIcon = styled(GoToIcon)`
fill: ${(props) => props.theme.divider};
opacity: 0.25;
`;
const ResultWrapper = styled.div`
display: flex;
margin-bottom: 10px;
margin-left: -4px;
user-select: none;
color: ${(props) => props.theme.text};
color: ${props => props.theme.text};
cursor: default;
svg {
flex-shrink: 0;
}
`;
const ResultWrapperLink = styled(ResultWrapper.withComponent("a"))`
margin: 0 -8px;
padding: 8px 4px;
${DocumentTitle} {
display: none;
}
svg {
flex-shrink: 0;
}
border-radius: 8px;
&:hover,
&:active,
&:focus {
background: ${(props) => props.theme.listItemHoverBackground};
background: ${props => props.theme.listItemHoverBackground};
outline: none;
${DocumentTitle} {
display: flex;
}
}
`;
+2 -2
View File
@@ -1,6 +1,6 @@
// @flow
import BoundlessPopover from "boundless-popover";
import * as React from "react";
import BoundlessPopover from "boundless-popover";
import styled, { keyframes } from "styled-components";
const fadeIn = keyframes`
@@ -22,7 +22,7 @@ const StyledPopover = styled(BoundlessPopover)`
position: absolute;
top: 0;
left: 0;
z-index: ${(props) => props.theme.depths.popover};
z-index: 9999;
svg {
height: 16px;
-12
View File
@@ -1,12 +0,0 @@
// @flow
import * as Sentry from "@sentry/react";
import { Route } from "react-router-dom";
import env from "env";
let Component = Route;
if (env.SENTRY_DSN) {
Component = Sentry.withSentryRouting(Route);
}
export default Component;
+121
View File
@@ -0,0 +1,121 @@
// @flow
import * as React from "react";
import { inject, observer } 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";
import AuthStore from "stores/AuthStore";
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,
auth: AuthStore,
showCollection?: boolean,
showPublished?: boolean,
document: Document,
children: React.Node,
};
function PublishingInfo({
auth,
collections,
showPublished,
showCollection,
document,
children,
...rest
}: Props) {
const {
modifiedSinceViewed,
updatedAt,
updatedBy,
createdAt,
publishedAt,
archivedAt,
deletedAt,
isDraft,
} = document;
// Prevent meta information from displaying if updatedBy is not available.
// Currently the situation where this is true is rendering share links.
if (!updatedBy) {
return null;
}
let content;
if (deletedAt) {
content = (
<span>
deleted <Time dateTime={deletedAt} /> ago
</span>
);
} else if (archivedAt) {
content = (
<span>
archived <Time dateTime={archivedAt} /> ago
</span>
);
} else if (createdAt === updatedAt) {
content = (
<span>
created <Time dateTime={updatedAt} /> ago
</span>
);
} else if (publishedAt && (publishedAt === updatedAt || showPublished)) {
content = (
<span>
published <Time dateTime={publishedAt} /> ago
</span>
);
} else if (isDraft) {
content = (
<span>
saved <Time dateTime={updatedAt} /> ago
</span>
);
} else {
content = (
<Modified highlight={modifiedSinceViewed}>
updated <Time dateTime={updatedAt} /> ago
</Modified>
);
}
const collection = collections.get(document.collectionId);
const updatedByMe = auth.user && auth.user.id === updatedBy.id;
return (
<Container align="center" {...rest}>
{updatedByMe ? "You" : updatedBy.name}&nbsp;
{content}
{showCollection &&
collection && (
<span>
&nbsp;in&nbsp;
<strong>
<Breadcrumb document={document} onlyText />
</strong>
</span>
)}
{children}
</Container>
);
}
export default inject("collections", "auth")(observer(PublishingInfo));
+1 -1
View File
@@ -13,7 +13,7 @@ class ScrollToTop extends React.Component<Props> {
componentDidUpdate(prevProps) {
if (this.props.location.pathname === prevProps.location.pathname) return;
// exception for when entering or exiting document edit, scroll position should not reset
// exception for when entering or exiting document edit, scroll postion should not reset
if (
this.props.location.pathname.match(/\/edit\/?$/) ||
prevProps.location.pathname.match(/\/edit\/?$/)

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