Compare commits

..

6 Commits

Author SHA1 Message Date
Tom Moor 9398348f19 wip 2021-07-09 18:39:03 -04:00
Tom Moor 5a68f542e8 wip 2021-07-09 17:09:52 -04:00
Tom Moor 891c487a31 wip 2021-07-09 15:54:39 -04:00
Tom Moor cbd4a3a01d wip 2021-07-04 16:58:56 -04:00
Tom Moor 06a7b828a0 chore: Install @types packages 2021-07-04 16:42:15 -04:00
Tom Moor f20b53e66c flowts 2021-07-04 16:06:23 -04:00
767 changed files with 11171 additions and 57748 deletions
+1
View File
@@ -2,6 +2,7 @@
"presets": [
"@babel/preset-react",
"@babel/preset-flow",
"@babel/preset-typescript",
[
"@babel/preset-env",
{
+2 -94
View File
@@ -1,12 +1,4 @@
version: 2.1
executors:
docker-publisher:
environment:
IMAGE_NAME: outlinewiki/outline
docker:
- image: circleci/buildpack-deps:stretch
version: 2
jobs:
build:
working_directory: ~/outline
@@ -48,88 +40,4 @@ jobs:
command: yarn test
- run:
name: build-webpack
command: yarn build:webpack
build-image:
executor: docker-publisher
steps:
- checkout
- setup_remote_docker:
version: 20.10.6
- run:
name: Build Docker image
command: docker build -t $IMAGE_NAME:latest .
- run:
name: Archive Docker image
command: docker save -o image.tar $IMAGE_NAME
- persist_to_workspace:
root: .
paths:
- ./image.tar
publish-latest:
executor: docker-publisher
steps:
- attach_workspace:
at: /tmp/workspace
- setup_remote_docker:
version: 20.10.6
- run:
name: Load archived Docker image
command: docker load -i /tmp/workspace/image.tar
- run:
name: Publish Docker Image to Docker Hub
command: |
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
IMAGE_TAG=${CIRCLE_TAG/v/''}
docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
docker push $IMAGE_NAME:latest
docker push $IMAGE_NAME:$IMAGE_TAG
publish-tag:
executor: docker-publisher
steps:
- attach_workspace:
at: /tmp/workspace
- setup_remote_docker:
version: 20.10.6
- run:
name: Load archived Docker image
command: docker load -i /tmp/workspace/image.tar
- run:
name: Publish Docker Image to Docker Hub
command: |
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
IMAGE_TAG=${CIRCLE_TAG/v/''}
docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
docker push $IMAGE_NAME:$IMAGE_TAG
workflows:
version: 2
build-and-test:
jobs:
- build:
filters:
tags:
ignore: /^v.*/
build-docker:
jobs:
- build-image:
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
- publish-latest:
requires:
- build-image
filters:
tags:
only: /^v\d+\.\d+\.\d+$/
branches:
ignore: /.*/
- publish-tag:
requires:
- build-image
filters:
tags:
only: /^v\d+\.\d+\.\d+-.*$/
branches:
ignore: /.*/
command: yarn build:webpack
+15 -37
View File
@@ -1,6 +1,6 @@
# 👋 Welcome, we're glad you're setting up an installation of Outline. Copy this
# file to .env or set the variables in your local environment manually. For
# development with docker this should mostly work out of the box other than
# 👋 Welcome, we're glad you're setting up an installation of Outline. Copy this
# file to .env or set the variables in your local environment manually. For
# development with docker this should mostly work out of the box other than
# setting the Slack keys and the SECRET_KEY.
@@ -12,7 +12,7 @@
# in your terminal to generate a random value.
SECRET_KEY=generate_a_new_key
# Generate a unique random key. The format is not important but you could still use
# Generate a unique random key. The format is not important but you could still use
# `openssl rand -hex 32` in your terminal to produce this.
UTILS_SECRET=generate_a_new_key
@@ -29,13 +29,9 @@ REDIS_URL=redis://localhost:6479
URL=http://localhost:3000
PORT=3000
# ALPHA See [documentation](docs/SERVICES.md) on running the alpha version of
# the collaboration server.
COLLABORATION_URL=
# To support uploading of images for avatars and document attachments an
# s3-compatible storage must be provided. AWS S3 is recommended for redundency
# however if you want to keep all file storage local an alternative such as
# however if you want to keep all file storage local an alternative such as
# minio (https://github.com/minio/minio) can be used.
# A more detailed guide on setting up S3 is available here:
@@ -73,38 +69,24 @@ SLACK_SECRET=get_the_secret_of_above_key
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# To configure Microsoft/Azure auth, you'll need to create an OAuth Client. See
# To configure Microsoft/Azure auth, you'll need to create an OAuth Client. See
# the guide for details on setting up your Azure App:
# => https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4
AZURE_CLIENT_ID=
AZURE_CLIENT_ID=
AZURE_CLIENT_SECRET=
AZURE_RESOURCE_APP_ID=
# To configure generic OIDC auth, you'll need some kind of identity provider.
# See documentation for whichever IdP you use to acquire the following info:
# Redirect URI is https://<URL>/auth/oidc.callback
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_AUTH_URI=
OIDC_TOKEN_URI=
OIDC_USERINFO_URI=
# Display name for OIDC authentication
OIDC_DISPLAY_NAME=OpenID Connect
# Space separated auth scopes.
OIDC_SCOPES=openid profile email
# –––––––––––––––– OPTIONAL ––––––––––––––––
# If using a Cloudfront/Cloudflare distribution or similar it can be set below.
# This will cause paths to javascript, stylesheets, and images to be updated to
# the hostname defined in CDN_URL. In your CDN configuration the origin server
# This will cause paths to javascript, stylesheets, and images to be updated to
# the hostname defined in CDN_URL. In your CDN configuration the origin server
# should be set to the same as URL.
CDN_URL=
# Auto-redirect to https in production. The default is true but you may set to
# Auto-redirect to https in production. The default is true but you may set to
# false if you can be sure that SSL is terminated at an external loadbalancer.
FORCE_HTTPS=true
@@ -112,23 +94,19 @@ FORCE_HTTPS=true
# the maintainers
ENABLE_UPDATES=true
# How many processes should be spawned. As a reasonable rule divide your servers
# available memory by 512 for a rough estimate
WEB_CONCURRENCY=1
# Override the maxium size of document imports, could be required if you have
# especially large Word documents with embedded imagery
MAXIMUM_IMPORT_SIZE=5120000
# You may enable or disable debugging categories to increase the noisiness of
# logs. The default is a good balance
DEBUG=cache,presenters,events,emails,mailer,utils,http,server,processors
DEBUG=cache,presenters,events,emails,mailer,utils,http,server,services
# Comma separated list of domains to be allowed to signin to the wiki. If not
# set, all domains are allowed by default when using Google OAuth to signin
ALLOWED_DOMAINS=
# For a complete Slack integration with search and posting to channels the
# For a complete Slack integration with search and posting to channels the
# following configs are also needed, some more details
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
#
@@ -136,13 +114,13 @@ SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# Optionally enable google analytics to track pageviews in the knowledge base
# Optionally enable google analytics to track pageviews in the knowledge base
GOOGLE_ANALYTICS_ID=
# Optionally enable Sentry (sentry.io) to track errors and performance
SENTRY_DSN=
# To support sending outgoing transactional emails such as "document updated" or
# To support sending outgoing transactional emails such as "document updated" or
# "you've been invited" you'll need to provide authentication for an SMTP server
SMTP_HOST=
SMTP_PORT=
@@ -156,6 +134,6 @@ SMTP_SECURE=true
# Custom logo that displays on the authentication screen, scaled to height: 60px
# TEAM_LOGO=https://example.com/images/logo.png
# The default interface language. See translate.getoutline.com for a list of
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
+11 -21
View File
@@ -4,12 +4,11 @@
"react-app",
"plugin:import/errors",
"plugin:import/warnings",
"plugin:flowtype/recommended",
"plugin:import/typescript",
"plugin:react-hooks/recommended"
],
"plugins": [
"prettier",
"flowtype"
"prettier"
],
"rules": {
"eqeqeq": 2,
@@ -55,21 +54,6 @@
]
}
],
"flowtype/require-valid-file-annotation": [
2,
"always",
{
"annotationStyle": "line"
}
],
"flowtype/space-after-type-colon": [
2,
"always"
],
"flowtype/space-before-type-colon": [
2,
"never"
],
"prettier/prettier": [
"error",
{
@@ -84,16 +68,22 @@
"pragma": "React",
"version": "detect"
},
"import/parsers": {
"@typescript-eslint/parser": [
".ts",
".tsx"
]
},
"import/resolver": {
"typescript": {
"alwaysTryTypes": true
},
"node": {
"paths": [
"app",
"."
]
}
},
"flowtype": {
"onlyFilesWithFlowAnnotation": false
}
},
"env": {
-5
View File
@@ -11,11 +11,6 @@
.*/node_modules/react-side-effect/.*
.*/node_modules/fbjs/.*
.*/node_modules/config-chain/.*
.*/node_modules/yjs/.*
.*/node_modules/y-prosemirror/.*
.*/node_modules/y-protocols/.*
.*/node_modules/y-indexeddb/.*
.*/node_modules/lib0/.*
.*/server/scripts/.*
*.test.js
+2 -2
View File
@@ -1,7 +1,7 @@
{
"javascript.validate.enable": false,
"javascript.format.enable": false,
"typescript.validate.enable": false,
"typescript.format.enable": false,
"typescript.validate.enable": true,
"typescript.format.enable": true,
"editor.formatOnSave": true,
}
+3 -4
View File
@@ -1,3 +1,4 @@
# Architecture
Outline is composed of a backend and frontend codebase in this monorepo. As both are written in Javascript, they share some code where possible. We utilize the latest ES6 language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier formatting and ESLint are enforced by CI.
@@ -45,9 +46,7 @@ server
├── onboarding - Markdown templates for onboarding documents
├── policies - Authorization logic based on cancan
├── presenters - JSON presenters for database models, the interface between backend -> frontend
├── queues - Async queue definitions
│ └── processors - Processors perform async jobs, usually working on events from the event bus
├── services - Services start distinct portions of the application eg api, worker
├── services - Service definitions are triggered for events and perform async jobs
├── static - Static assets
├── test - Test helpers and fixtures, tests themselves are colocated
└── utils - Utility methods specific to the backend
@@ -65,4 +64,4 @@ shared
├── styles - Styles, colors and other global aesthetics
├── utils - Shared utility methods
└── constants - Shared constants
```
```
+12 -39
View File
@@ -1,50 +1,23 @@
# syntax=docker/dockerfile:1.2
ARG APP_PATH=/opt/outline
FROM node:14-alpine AS deps-common
FROM node:14-alpine
ENV APP_PATH /opt/outline
RUN mkdir -p $APP_PATH
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
# ---
FROM deps-common AS deps-dev
RUN yarn install --no-optional --frozen-lockfile && \
yarn cache clean
COPY package.json ./
COPY yarn.lock ./
# ---
FROM deps-common AS deps-prod
RUN yarn install --production=true --frozen-lockfile && \
yarn cache clean
# ---
FROM node:14-alpine AS builder
ARG APP_PATH
WORKDIR $APP_PATH
RUN yarn --pure-lockfile
COPY . .
COPY --from=deps-dev $APP_PATH/node_modules ./node_modules
RUN yarn build
# ---
FROM node:14-alpine AS runner
RUN yarn build && \
yarn --production --ignore-scripts --prefer-offline && \
rm -rf shared && \
rm -rf app
ARG APP_PATH
WORKDIR $APP_PATH
ENV NODE_ENV production
COPY --from=builder $APP_PATH/build ./build
COPY --from=builder $APP_PATH/server ./server
COPY --from=builder $APP_PATH/public ./public
COPY --from=builder $APP_PATH/.sequelizerc ./.sequelizerc
COPY --from=deps-prod $APP_PATH/node_modules ./node_modules
COPY --from=builder $APP_PATH/package.json ./package.json
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
chown -R nodejs:nodejs $APP_PATH/build
USER nodejs
CMD yarn start
EXPOSE 3000
CMD ["yarn", "start"]
+1 -1
View File
@@ -2,7 +2,7 @@ up:
docker-compose up -d redis postgres s3
yarn install --pure-lockfile
yarn sequelize db:migrate
yarn dev:watch
yarn dev
build:
docker-compose build --pull outline
+1 -2
View File
@@ -1,2 +1 @@
web: yarn start --services=web,websockets
worker: yarn start --services=worker
web: node ./build/server/index.js
+25 -22
View File
@@ -1,3 +1,5 @@
<p align="center">
<img src="https://user-images.githubusercontent.com/31465/34380645-bd67f474-eb0b-11e7-8d03-0151c1730654.png" height="29" />
</p>
@@ -28,6 +30,7 @@ Outline requires the following dependencies:
- AWS S3 bucket or compatible API for file storage
- Slack or Google developer application for authentication
## Self-Hosted Production
### Docker
@@ -38,20 +41,16 @@ For a manual self-hosted production installation these are the recommended steps
1. Download the latest official Docker image, new releases are available around the middle of every month:
`docker pull outlinewiki/outline`
1. Using the [.env.sample](.env.sample) as a reference, set the required variables in your production environment. You can export the environment variables directly, or create a `.env` file and pass it to the docker image like so:
`docker run --env-file=.env outlinewiki/outline`
1. Setup the database with `yarn db:migrate`. Production assumes an SSL connection to the database by default, if
Postgres is on the same machine and is not SSL you can migrate with `yarn db:migrate --env=production-ssl-disabled`, for example:
Postgres is on the same machine and is not SSL you can migrate with `yarn db:migrate --env=production-ssl-disabled`, for example:
`docker run --rm outlinewiki/outline yarn db:migrate`
1. Start the container:
`docker run outlinewiki/outline`
1. 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
@@ -80,27 +79,29 @@ If you're running Outline by cloning this repository, run the following command
yarn run upgrade
```
## Local Development
For contributing features and fixes you can quickly get an environment running using Docker by following these steps:
1. Install these dependencies if you don't already have them
1. [Docker for Desktop](https://www.docker.com)
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
1. [Yarn](https://yarnpkg.com)
1. [Docker for Desktop](https://www.docker.com)
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
1. [Yarn](https://yarnpkg.com)
1. Clone this repo
1. Register a Slack app at https://api.slack.com/apps
1. Copy the file `.env.sample` to `.env`
1. Fill out the following fields:
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. Configure your Slack app's Oauth & Permissions settings
1. Slack recently prevented the use of `http` protocol for localhost. For local development, you can use a tool like [ngrok](https://ngrok.com) or a package like `mkcert`. ([How to use HTTPS for local development](https://web.dev/how-to-use-local-https/))
1. Add `https://my_ngrok_address/auth/slack.callback` as an Oauth redirect URL
1. Ensure that the bot token scope contains at least `users:read`
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. Configure your Slack app's Oauth & Permissions settings
1. Slack recently prevented the use of `http` protocol for localhost. For local development, you can use a tool like [ngrok](https://ngrok.com) or a package like `mkcert`. ([How to use HTTPS for local development](https://web.dev/how-to-use-local-https/))
1. Add `https://my_ngrok_address/auth/slack.callback` as an Oauth redirect URL
1. 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
# Contributing
Outline is built and maintained by a small team we'd love your help to fix bugs and add features!
@@ -109,16 +110,18 @@ Before submitting a pull request please let the core team know by creating or co
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
- [Translation](docs/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
* [Translation](TRANSLATION.md) into other languages
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
* Performance improvements, both on server and frontend
* Developer happiness and documentation
* Bugs and other issues listed on GitHub
## Architecture
If you're interested in contributing or learning more about the Outline codebase
please refer to the [architecture document](docs/ARCHITECTURE.md) first for a high level overview of how the application is put together.
please refer to the [architecture document](ARCHITECTURE.md) first for a high level overview of how the application is put together.
## Debugging
@@ -142,7 +145,7 @@ make test
make watch
```
Once the test database is created with `make test` you may individually run
Once the test database is created with `make test` you may individually run
frontend and backend tests directly.
```shell
View File
@@ -1,4 +1,3 @@
// @flow
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Flex from "components/Flex";
@@ -1,10 +1,9 @@
// @flow
/* global ga */
import * as React from "react";
import env from "env";
type Props = {
children?: React.Node,
children?: React.ReactNode;
};
export default class Analytics extends React.Component<Props> {
@@ -1,4 +1,3 @@
// @flow
import * as React from "react";
export default function Arrow() {
@@ -1,10 +1,9 @@
// @flow
import * as React from "react";
type Props = {
size?: number,
fill?: string,
className?: string,
size?: number;
fill?: string;
className?: string;
};
function GoogleLogo({ size = 34, fill = "#FFF", className }: Props) {
@@ -1,10 +1,9 @@
// @flow
import * as React from "react";
type Props = {
size?: number,
fill?: string,
className?: string,
size?: number;
fill?: string;
className?: string;
};
function MicrosoftLogo({ size = 34, fill = "#FFF", className }: Props) {
@@ -1,10 +1,9 @@
// @flow
import * as React from "react";
type Props = {
size?: number,
fill?: string,
className?: string,
size?: number;
fill?: string;
className?: string;
};
function SlackLogo({ size = 34, fill = "#FFF", className }: Props) {
@@ -1,14 +1,13 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import GoogleLogo from "./GoogleLogo";
import MicrosoftLogo from "./MicrosoftLogo";
import SlackLogo from "./SlackLogo";
type Props = {|
providerName: string,
size?: number,
|};
type Props = {
providerName: string;
size?: number;
};
function AuthLogo({ providerName, size = 16 }: Props) {
switch (providerName) {
@@ -1,16 +1,14 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom";
import { isCustomSubdomain } from "shared/utils/domains";
import LoadingIndicator from "components/LoadingIndicator";
import useStores from "../hooks/useStores";
import { changeLanguage } from "../utils/language";
import env from "env";
import useStores from "hooks/useStores";
type Props = {
children: React.Node,
children: JSX.Element;
};
const Authenticated = ({ children }: Props) => {
@@ -21,7 +19,11 @@ const Authenticated = ({ children }: Props) => {
// 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(() => {
changeLanguage(language, i18n);
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]);
if (auth.authenticated) {
@@ -1,4 +1,3 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
@@ -6,18 +5,19 @@ import styled from "styled-components";
import User from "models/User";
import placeholder from "./placeholder.png";
type Props = {|
src: string,
size: number,
icon?: React.Node,
user?: User,
onClick?: () => void,
className?: string,
|};
type Props = {
src: string;
size: number;
icon?: React.ReactNode;
user?: User;
onClick?: () => void;
className?: string;
};
@observer
class Avatar extends React.Component<Props> {
@observable error: boolean;
@observable
error: boolean;
static defaultProps = {
size: 24,
@@ -1,9 +1,9 @@
// @flow
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 { withTranslation } from "react-i18next";
import { TFunction } from "react-i18next";
import styled from "styled-components";
import User from "models/User";
import UserProfile from "scenes/UserProfile";
@@ -11,17 +11,18 @@ import Avatar from "components/Avatar";
import Tooltip from "components/Tooltip";
type Props = {
user: User,
isPresent: boolean,
isEditing: boolean,
isCurrentUser: boolean,
profileOnClick: boolean,
t: TFunction,
user: User;
isPresent: boolean;
isEditing: boolean;
isCurrentUser: boolean;
profileOnClick: boolean;
t: TFunction;
};
@observer
class AvatarWithPresence extends React.Component<Props> {
@observable isOpen: boolean = false;
@observable
isOpen: boolean = false;
handleOpenProfile = () => {
this.isOpen = true;
@@ -1,4 +1,3 @@
// @flow
import Avatar from "./Avatar";
import AvatarWithPresence from "./AvatarWithPresence";
@@ -1,4 +1,3 @@
// @flow
import styled from "styled-components";
const Badge = styled.span`
@@ -1,11 +1,10 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import OutlineLogo from "./OutlineLogo";
import env from "env";
type Props = {
href?: string,
href?: string;
};
function Branding({ href = env.URL }: Props) {
@@ -1,4 +1,3 @@
// @flow
import { GoToIcon } from "outline-icons";
import * as React from "react";
import { Link } from "react-router-dom";
@@ -6,18 +5,18 @@ import styled from "styled-components";
import Flex from "components/Flex";
import BreadcrumbMenu from "menus/BreadcrumbMenu";
type MenuItem = {|
icon?: React.Node,
title: React.Node,
to?: string,
|};
type MenuItem = {
icon?: React.ReactNode;
title: React.ReactNode;
to?: string;
};
type Props = {|
items: MenuItem[],
max?: number,
children?: React.Node,
highlightFirstItem?: boolean,
|};
type Props = {
items: MenuItem[];
max?: number;
children?: React.ReactNode;
highlightFirstItem?: boolean;
};
function Breadcrumb({ items, highlightFirstItem, children, max = 2 }: Props) {
const totalItems = items.length;
@@ -1,11 +1,10 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import { bounceIn } from "styles/animations";
import { bounceIn } from "shared/styles/animations";
type Props = {|
count: number,
|};
type Props = {
count: number;
};
const Bubble = ({ count }: Props) => {
if (!count) {
@@ -1,7 +1,7 @@
// @flow
import { ExpandedIcon } from "outline-icons";
import { darken } from "polished";
import * as React from "react";
import { SyntheticEvent } from "react";
import styled from "styled-components";
const RealButton = styled.button`
@@ -66,11 +66,7 @@ const RealButton = styled.button`
&:hover {
background: ${
props.borderOnHover
? props.theme.buttonNeutralBackground
: darken(0.05, props.theme.buttonNeutralBackground)
};
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;
@@ -112,36 +108,35 @@ export const Inner = styled.span`
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
`;
export type Props = {|
type?: "button" | "submit",
value?: string,
icon?: React.Node,
iconColor?: string,
className?: string,
children?: React.Node,
innerRef?: React.ElementRef<any>,
disclosure?: boolean,
neutral?: boolean,
danger?: boolean,
primary?: boolean,
disabled?: boolean,
fullwidth?: boolean,
autoFocus?: boolean,
style?: Object,
as?: React.ComponentType<any> | string,
to?: string,
onClick?: (event: SyntheticEvent<>) => mixed,
borderOnHover?: boolean,
href?: string,
"data-on"?: string,
"data-event-category"?: string,
"data-event-action"?: string,
|};
export type Props = {
type?: "button" | "submit";
value?: string;
icon?: React.ReactNode;
iconColor?: string;
className?: string;
children?: React.ReactNode;
innerRef?: React.RefObject<any>;
disclosure?: boolean;
neutral?: boolean;
danger?: boolean;
primary?: boolean;
disabled?: boolean;
fullwidth?: boolean;
autoFocus?: boolean;
style?: any;
as?: React.ComponentType<any>;
to?: string;
onClick?: (event: SyntheticEvent) => unknown;
borderOnHover?: boolean;
"data-on"?: string;
"data-event-category"?: string;
"data-event-action"?: string;
};
const Button = React.forwardRef<Props, HTMLButtonElement>(
const Button = React.forwardRef<HTMLButtonElement>(
(
{
type = "text",
type = "button",
icon,
children,
value,
@@ -1,4 +1,3 @@
// @flow
import styled from "styled-components";
import Button, { Inner } from "./Button";
@@ -1,17 +1,13 @@
// @flow
import * as React from "react";
import { SyntheticEvent } from "react";
import styled from "styled-components";
type Props = {
onClick: (ev: SyntheticEvent<>) => void,
children: React.Node,
onClick: (ev: SyntheticEvent) => void;
children: React.ReactNode;
};
export default function ButtonLink(props: Props) {
return <Button {...props} />;
}
const Button = styled.button`
const ButtonLink = styled.button<Props>`
margin: 0;
padding: 0;
border: 0;
@@ -21,3 +17,5 @@ const Button = styled.button`
text-decoration: none;
cursor: pointer;
`;
export default ButtonLink;
@@ -1,14 +1,13 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
type Props = {|
children?: React.Node,
withStickyHeader?: boolean,
|};
type Props = {
children?: React.ReactNode;
withStickyHeader?: boolean;
};
const Container = styled.div`
const Container = styled.div<Props>`
width: 100%;
max-width: 100vw;
padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
@@ -1,29 +1,33 @@
// @flow
import * as React from "react";
import { ChangeEvent } from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import HelpText from "components/HelpText";
export type Props = {|
checked?: boolean,
label?: React.Node,
labelHidden?: boolean,
className?: string,
name?: string,
disabled?: boolean,
onChange: (event: SyntheticInputEvent<HTMLInputElement>) => mixed,
note?: string,
short?: boolean,
small?: boolean,
|};
export type Props = {
checked?: boolean;
label?: React.ReactNode;
labelHidden?: boolean;
className?: string;
name?: string;
disabled?: boolean;
onChange: (event: ChangeEvent<HTMLInputElement>) => unknown;
note?: string;
short?: boolean;
small?: boolean;
};
const LabelText = styled.span`
type LabelTextProps = { small: boolean };
const LabelText = styled.span<LabelTextProps>`
font-weight: 500;
margin-left: ${(props) => (props.small ? "6px" : "10px")};
${(props) => (props.small ? `color: ${props.theme.textSecondary}` : "")};
`;
const Wrapper = styled.div`
type WrapperProps = { small: boolean };
const Wrapper = styled.div<WrapperProps>`
padding-bottom: 8px;
${(props) => (props.small ? "font-size: 14px" : "")};
width: 100%;
-78
View File
@@ -1,78 +0,0 @@
// @flow
import React from "react";
import styled, { useTheme } from "styled-components";
const cleanPercentage = (percentage) => {
const tooLow = !Number.isFinite(+percentage) || percentage < 0;
const tooHigh = percentage > 100;
return tooLow ? 0 : tooHigh ? 100 : +percentage;
};
const Circle = ({
color,
percentage,
offset,
}: {
color: string,
percentage?: number,
offset: number,
}) => {
const radius = offset * 0.7;
const circumference = 2 * Math.PI * radius;
let strokePercentage;
if (percentage) {
// because the circle is so small, anything greater than 85% appears like 100%
percentage = percentage > 85 && percentage < 100 ? 85 : percentage;
strokePercentage = percentage
? ((100 - percentage) * circumference) / 100
: 0;
}
return (
<circle
r={radius}
cx={offset}
cy={offset}
fill="none"
stroke={strokePercentage !== circumference ? color : ""}
strokeWidth={2.5}
strokeDasharray={circumference}
strokeDashoffset={percentage ? strokePercentage : 0}
strokeLinecap="round"
style={{ transition: "stroke-dashoffset 0.6s ease 0s" }}
></circle>
);
};
const CircularProgressBar = ({
percentage,
size = 16,
}: {
percentage: number,
size?: number,
}) => {
const theme = useTheme();
percentage = cleanPercentage(percentage);
const offset = Math.floor(size / 2);
return (
<SVG width={size} height={size}>
<g transform={`rotate(-90 ${offset} ${offset})`}>
<Circle color={theme.progressBarBackground} offset={offset} />
{percentage > 0 && (
<Circle
color={theme.primary}
percentage={percentage}
offset={offset}
/>
)}
</g>
</SVG>
);
};
const SVG = styled.svg`
flex-shrink: 0;
`;
export default CircularProgressBar;
@@ -1,7 +1,10 @@
// @flow
import styled from "styled-components";
const ClickablePadding = styled.div`
type Props = {
grow: boolean;
};
const ClickablePadding = styled.div<Props>`
min-height: 10em;
cursor: ${({ onClick }) => (onClick ? "text" : "default")};
${({ grow }) => grow && `flex-grow: 100;`};
@@ -1,4 +1,3 @@
// @flow
import { sortBy, filter, uniq } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
@@ -14,10 +13,10 @@ import NudeButton from "components/NudeButton";
import Popover from "components/Popover";
import useStores from "hooks/useStores";
type Props = {|
document: Document,
currentUserId: string,
|};
type Props = {
document: Document;
currentUserId: string;
};
function Collaborators(props: Props) {
const { t } = useTranslation();
@@ -1,4 +1,3 @@
// @flow
import { observer } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
@@ -12,15 +11,13 @@ import LoadingIndicator from "components/LoadingIndicator";
import NudeButton from "components/NudeButton";
import useDebouncedCallback from "hooks/useDebouncedCallback";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
collection: Collection,
|};
type Props = {
collection: Collection;
};
function CollectionDescription({ collection }: Props) {
const { collections, policies } = useStores();
const { showToast } = useToasts();
const { collections, ui, policies } = useStores();
const { t } = useTranslation();
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
@@ -55,7 +52,7 @@ function CollectionDescription({ collection }: Props) {
});
setDirty(false);
} catch (err) {
showToast(
ui.showToast(
t("Sorry, an error occurred saving the collection", {
type: "error",
})
@@ -1,4 +1,3 @@
// @flow
import { observer } from "mobx-react";
import { CollectionIcon } from "outline-icons";
import { getLuminance } from "polished";
@@ -8,9 +7,9 @@ import { icons } from "components/IconPicker";
import useStores from "hooks/useStores";
type Props = {
collection: Collection,
expanded?: boolean,
size?: number,
collection: Collection;
expanded?: boolean;
size?: number;
};
function ResolvedCollectionIcon({ collection, expanded, size }: Props) {
-59
View File
@@ -1,59 +0,0 @@
// @flow
import { observer } from "mobx-react";
import { DisconnectedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Fade from "components/Fade";
import NudeButton from "components/NudeButton";
import Tooltip from "components/Tooltip";
import useStores from "hooks/useStores";
function ConnectionStatus() {
const { ui } = useStores();
const theme = useTheme();
const { t } = useTranslation();
return ui.multiplayerStatus === "connecting" ||
ui.multiplayerStatus === "disconnected" ? (
<Tooltip
tooltip={
<Centered>
<strong>{t("Server connection lost")}</strong>
<br />
{t("Edits you make will sync once youre online")}
</Centered>
}
placement="bottom"
>
<Button>
<Fade>
<DisconnectedIcon color={theme.sidebarText} />
</Fade>
</Button>
</Tooltip>
) : null;
}
const Button = styled(NudeButton)`
display: none;
position: fixed;
bottom: 0;
right: 32px;
margin: 24px;
${breakpoint("tablet")`
display: block;
`};
@media print {
display: none;
}
`;
const Centered = styled.div`
text-align: center;
`;
export default observer(ConnectionStatus);
@@ -1,4 +1,3 @@
// @flow
import styled from "styled-components";
const Header = styled.h3`
@@ -1,24 +1,22 @@
// @flow
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import MenuIconWrapper from "../MenuIconWrapper";
type Props = {|
onClick?: (SyntheticEvent<>) => void | Promise<void>,
children?: React.Node,
selected?: boolean,
disabled?: boolean,
to?: string,
href?: string,
target?: "_blank",
as?: string | React.ComponentType<*>,
hide?: () => void,
level?: number,
icon?: React.Node,
|};
import { SyntheticEvent } from "react";
type Props = {
onClick?: (a: SyntheticEvent) => void | Promise<void>;
children?: React.ReactNode;
selected?: boolean;
disabled?: boolean;
to?: string;
href?: string;
target?: "_blank";
as?: string | React.ComponentType<any>;
hide?: () => void;
};
const MenuItem = ({
onClick,
@@ -27,32 +25,22 @@ const MenuItem = ({
disabled,
as,
hide,
icon,
...rest
}: Props) => {
const handleClick = React.useCallback(
// We bind to mousedown instead of onClick here as otherwise menu items do not
// work in Firefox which triggers the hideOnClickOutside handler first via
// mousedown.
const handleMouseDown = React.useCallback(
(ev) => {
if (onClick) {
ev.preventDefault();
ev.stopPropagation();
onClick(ev);
}
if (hide) {
hide();
}
},
[onClick, hide]
[onClick]
);
// Preventing default mousedown otherwise menu items do not work in Firefox,
// which triggers the hideOnClickOutside handler first via mousedown hiding
// and un-rendering the menu contents.
const handleMouseDown = React.useCallback((ev) => {
ev.preventDefault();
ev.stopPropagation();
}, []);
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
@@ -65,8 +53,8 @@ const MenuItem = ({
{...props}
$toggleable={selected !== undefined}
as={onClick ? "button" : as}
onClick={handleClick}
onMouseDown={handleMouseDown}
onClick={hide}
>
{selected !== undefined && (
<>
@@ -74,7 +62,6 @@ const MenuItem = ({
&nbsp;
</>
)}
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
{children}
</MenuAnchor>
)}
@@ -93,7 +80,6 @@ export const MenuAnchor = styled.a`
margin: 0;
border: 0;
padding: 12px;
padding-left: ${(props) => 12 + props.level * 10}px;
width: 100%;
min-height: 32px;
background: none;
@@ -134,8 +120,8 @@ export const MenuAnchor = styled.a`
`};
${breakpoint("tablet")`
padding: 4px 12px;
font-size: 14px;
padding: ${(props) => (props.$toggleable ? "4px 12px" : "6px 12px")};
font-size: 15px;
`};
`;
@@ -1,4 +1,3 @@
// @flow
import { MoreIcon } from "outline-icons";
import * as React from "react";
import { MenuButton } from "reakit/Menu";
@@ -1,4 +1,3 @@
// @flow
import * as React from "react";
import { MenuSeparator } from "reakit/Menu";
import styled from "styled-components";
@@ -1,4 +1,3 @@
// @flow
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -9,17 +8,55 @@ import {
MenuItem as BaseMenuItem,
} from "reakit/Menu";
import styled from "styled-components";
import Flex from "components/Flex";
import MenuIconWrapper from "components/MenuIconWrapper";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import Separator from "./Separator";
import ContextMenu from ".";
import { type MenuItem as TMenuItem } from "types";
type Props = {|
items: TMenuItem[],
|};
import { SyntheticEvent } from "react";
type TMenuItem =
| {
title: React.ReactNode;
to: string;
visible?: boolean;
selected?: boolean;
disabled?: boolean;
}
| {
title: React.ReactNode;
onClick: (event: SyntheticEvent) => void | Promise<void>;
visible?: boolean;
selected?: boolean;
disabled?: boolean;
}
| {
title: React.ReactNode;
href: string;
visible?: boolean;
selected?: boolean;
disabled?: boolean;
}
| {
title: React.ReactNode;
visible?: boolean;
disabled?: boolean;
style?: any;
hover?: boolean;
items: TMenuItem[];
}
| {
type: "separator";
visible?: boolean;
}
| {
type: "heading";
visible?: boolean;
title: React.ReactNode;
};
type Props = {
items: TMenuItem[];
};
const Disclosure = styled(ExpandedIcon)`
transform: rotate(270deg);
@@ -47,7 +84,7 @@ const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
);
});
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
function Template({ items, ...menu }: Props): React.ReactNode {
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unneccessary separators
@@ -65,19 +102,7 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
return [...acc, item];
}, []);
return filtered;
}
function Template({ items, ...menu }: Props): React.Node {
const filteredTemplates = filterTemplateItems(items);
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
(item) => !!item.icon
);
return filteredTemplates.map((item, index) => {
if (iconIsPresentInAnyMenuItem)
item.icon = item.icon ? item.icon : <MenuIconWrapper />;
return filtered.map((item, index) => {
if (item.to) {
return (
<MenuItem
@@ -86,7 +111,6 @@ function Template({ items, ...menu }: Props): React.Node {
key={index}
disabled={item.disabled}
selected={item.selected}
icon={item.icon}
{...menu}
>
{item.title}
@@ -101,9 +125,7 @@ function Template({ items, ...menu }: Props): React.Node {
key={index}
disabled={item.disabled}
selected={item.selected}
level={item.level}
target={item.href.startsWith("#") ? undefined : "_blank"}
icon={item.icon}
target="_blank"
{...menu}
>
{item.title}
@@ -119,7 +141,6 @@ function Template({ items, ...menu }: Props): React.Node {
disabled={item.disabled}
selected={item.selected}
key={index}
icon={item.icon}
{...menu}
>
{item.title}
@@ -133,7 +154,7 @@ function Template({ items, ...menu }: Props): React.Node {
key={index}
as={Submenu}
templateItems={item.items}
title={<Title title={item.title} icon={item.icon} />}
title={item.title}
{...menu}
/>
);
@@ -143,22 +164,8 @@ function Template({ items, ...menu }: Props): React.Node {
return <Separator key={index} />;
}
if (item.type === "heading") {
return <Header>{item.title}</Header>;
}
console.warn("Unrecognized menu item", item);
return null;
});
}
function Title({ title, icon }) {
return (
<Flex align="center">
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
{title}
</Flex>
);
}
export default React.memo<Props>(Template);
@@ -1,26 +1,23 @@
// @flow
import * as React from "react";
import { Portal } from "react-portal";
import { Menu } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import usePrevious from "hooks/usePrevious";
import {
fadeIn,
fadeAndSlideUp,
fadeAndSlideDown,
mobileContextMenu,
} from "styles/animations";
fadeAndScaleIn,
fadeAndSlideIn,
} from "shared/styles/animations";
import usePrevious from "hooks/usePrevious";
type Props = {|
"aria-label": string,
visible?: boolean,
placement?: string,
animating?: boolean,
children: React.Node,
onOpen?: () => void,
onClose?: () => void,
|};
type Props = {
"aria-label": string;
visible?: boolean;
animating?: boolean;
children: React.ReactNode;
onOpen?: () => void;
onClose?: () => void;
};
export default function ContextMenu({
children,
@@ -46,25 +43,13 @@ export default function ContextMenu({
return (
<>
<Menu hideOnClickOutside preventBodyScroll {...rest}>
{(props) => {
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
// positioning.
const topAnchor = props.style.top === "0";
const rightAnchor = props.placement === "bottom-end";
return (
<Position {...props}>
<Background
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
);
}}
{(props) => (
<Position {...props}>
<Background dir="auto">
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
)}
</Menu>
{(rest.visible || rest.animating) && (
<Portal>
@@ -105,7 +90,7 @@ const Position = styled.div`
`;
const Background = styled.div`
animation: ${mobileContextMenu} 200ms ease;
animation: ${fadeAndSlideIn} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
background: ${(props) => props.theme.menuBackground};
@@ -123,10 +108,9 @@ const Background = styled.div`
}
${breakpoint("tablet")`
animation: ${(props) =>
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: ${(props) =>
props.rightAnchor === "bottom-end" ? "75%" : "25%"} 0;
props.left !== undefined ? "25%" : "75%"} 0;
max-width: 276px;
background: ${(props) => props.theme.menuBackground};
box-shadow: ${(props) => props.theme.menuShadow};
@@ -1,16 +1,17 @@
// @flow
import copy from "copy-to-clipboard";
import * as React from "react";
import { SyntheticEvent } from "react";
type Props = {
text: string,
children?: React.Node,
onClick?: () => void,
onCopy: () => void,
text: string;
children?: React.ReactNode;
onClick?: () => void;
onCopy: () => void;
};
class CopyToClipboard extends React.PureComponent<Props> {
onClick = (ev: SyntheticEvent<>) => {
onClick = (ev: SyntheticEvent) => {
const { text, onCopy, children } = this.props;
const elem = React.Children.only(children);
copy(text, {
@@ -1,9 +1,8 @@
// @flow
import * as React from "react";
type Props = {
delay?: number,
children: React.Node,
delay?: number;
children: JSX.Element;
};
export default function DelayedMount({ delay = 250, children }: Props) {
@@ -1,4 +1,3 @@
// @flow
import styled from "styled-components";
const Divider = styled.hr`
@@ -1,4 +1,3 @@
// @flow
import { observer } from "mobx-react";
import {
ArchiveIcon,
@@ -16,11 +15,11 @@ import CollectionIcon from "components/CollectionIcon";
import useStores from "hooks/useStores";
import { collectionUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
children?: React.Node,
onlyText: boolean,
|};
type Props = {
document: Document;
children?: React.ReactNode;
onlyText: boolean;
};
function useCategory(document) {
const { t } = useTranslation();
-124
View File
@@ -1,124 +0,0 @@
// @flow
import { observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Event from "models/Event";
import Button from "components/Button";
import Empty from "components/Empty";
import Flex from "components/Flex";
import PaginatedEventList from "components/PaginatedEventList";
import Scrollable from "components/Scrollable";
import useStores from "hooks/useStores";
import { documentUrl } from "utils/routeHelpers";
const EMPTY_ARRAY = [];
function DocumentHistory() {
const { events, documents } = useStores();
const { t } = useTranslation();
const match = useRouteMatch();
const history = useHistory();
const document = documents.getByUrl(match.params.documentSlug);
const eventsInDocument = document
? events.inDocument(document.id)
: EMPTY_ARRAY;
const onCloseHistory = () => {
history.push(documentUrl(document));
};
const items = React.useMemo(() => {
if (
eventsInDocument[0] &&
document &&
eventsInDocument[0].createdAt !== document.updatedAt
) {
eventsInDocument.unshift(
new Event({
name: "documents.latest_version",
documentId: document.id,
createdAt: document.updatedAt,
actor: document.updatedBy,
})
);
}
return eventsInDocument;
}, [eventsInDocument, document]);
return (
<Sidebar>
{document ? (
<Position column>
<Header>
<Title>{t("History")}</Title>
<Button
icon={<CloseIcon />}
onClick={onCloseHistory}
borderOnHover
neutral
/>
</Header>
<Scrollable topShadow>
<PaginatedEventList
fetch={events.fetchPage}
events={items}
options={{ documentId: document.id }}
document={document}
empty={<Empty>{t("Oh weird, there's nothing here")}</Empty>}
/>
</Scrollable>
</Position>
) : null}
</Sidebar>
);
}
const Position = styled(Flex)`
position: fixed;
top: 0;
bottom: 0;
width: ${(props) => props.theme.sidebarWidth}px;
`;
const Sidebar = styled(Flex)`
display: none;
position: relative;
flex-shrink: 0;
background: ${(props) => props.theme.background};
width: ${(props) => props.theme.sidebarWidth}px;
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;
color: ${(props) => props.theme.text};
flex-shrink: 0;
`;
export default observer(DocumentHistory);
@@ -0,0 +1,204 @@
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { action, observable } from "mobx";
import { inject, observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { Redirect } from "react-router-dom";
import { Match, RouterHistory } from "react-router-dom";
import { Waypoint } from "react-waypoint";
import styled from "styled-components";
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 { ListPlaceholder } from "components/LoadingPlaceholder";
import Revision from "./components/Revision";
import { documentHistoryUrl, documentUrl } from "utils/routeHelpers";
type Props = {
match: Match;
documents: DocumentsStore;
revisions: RevisionsStore;
history: RouterHistory;
};
@observer
class DocumentHistory extends React.Component<Props> {
@observable
isLoaded: boolean = false;
@observable
isFetching: boolean = false;
@observable
offset: number = 0;
@observable
allowLoadMore: boolean = true;
@observable
redirectTo: string | undefined | null;
async componentDidMount() {
await this.loadMoreResults();
this.selectFirstRevision();
}
fetchResults = async () => {
this.isFetching = true;
const limit = DEFAULT_PAGINATION_LIMIT;
const results = await this.props.revisions.fetchPage({
limit,
offset: this.offset,
documentId: this.props.match.params.documentSlug,
});
if (
results &&
(results.length === 0 || results.length < DEFAULT_PAGINATION_LIMIT)
) {
this.allowLoadMore = false;
} else {
this.offset += DEFAULT_PAGINATION_LIMIT;
}
this.isLoaded = true;
this.isFetching = false;
};
selectFirstRevision = () => {
if (this.revisions.length) {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
if (!document) return;
this.props.history.replace(
documentHistoryUrl(document, this.revisions[0].id)
);
}
};
@action
loadMoreResults = async () => {
// Don't paginate if there aren't more results or were in the middle of fetching
if (!this.allowLoadMore || this.isFetching) return;
await this.fetchResults();
};
get revisions() {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
if (!document) return [];
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} />
</Loading>
) : (
<ArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{this.revisions.map((revision, index) => (
<Revision
key={revision.id}
revision={revision}
document={document}
showMenu={index !== 0}
selected={this.props.match.params.revisionId === revision.id}
/>
))}
</ArrowKeyNavigation>
)}
{this.allowLoadMore && (
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
)}
</Wrapper>
</Sidebar>
);
}
}
const Loading = styled.div`
margin: 0 16px;
`;
const Wrapper = styled(Flex)`
position: fixed;
top: 0;
right: 0;
z-index: 1;
min-width: ${(props) => props.theme.sidebarWidth}px;
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};
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);
@@ -0,0 +1,86 @@
import { format } from "date-fns";
import * as React from "react";
import { NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components";
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 { Theme } from "types";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {
theme: Theme;
showMenu: boolean;
selected: boolean;
document: Document;
revision: Revision;
};
class RevisionListItem extends React.Component<Props> {
render() {
const { revision, document, showMenu, selected, theme } = this.props;
return (
<StyledNavLink
to={documentHistoryUrl(document, revision.id)}
activeStyle={{ background: theme.primary, color: theme.white }}
>
<Author>
<StyledAvatar src={revision.createdBy.avatarUrl} />{" "}
{revision.createdBy.name}
</Author>
<Meta>
<Time dateTime={revision.createdAt} tooltipDelay={250}>
{format(Date.parse(revision.createdAt), "MMMM do, yyyy h:mm a")}
</Time>
</Meta>
{showMenu && (
<StyledRevisionMenu
document={document}
revision={revision}
iconColor={selected ? theme.white : theme.textTertiary}
/>
)}
</StyledNavLink>
);
}
}
const StyledAvatar = styled(Avatar)`
border-color: transparent;
margin-right: 4px;
`;
const StyledRevisionMenu = styled(RevisionMenu)`
position: absolute;
right: 16px;
top: 20px;
`;
const StyledNavLink = styled(NavLink)`
color: ${(props) => props.theme.text};
display: block;
padding: 8px 16px;
font-size: 15px;
position: relative;
`;
const Author = styled(Flex)`
font-weight: 500;
padding: 0;
margin: 0;
`;
const Meta = styled.p`
font-size: 14px;
opacity: 0.75;
margin: 0 0 2px;
padding: 0;
`;
export default withTheme(RevisionListItem);
+2
View File
@@ -0,0 +1,2 @@
import DocumentHistory from "./DocumentHistory";
export default DocumentHistory;
@@ -1,18 +1,17 @@
// @flow
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import * as React from "react";
import Document from "models/Document";
import DocumentListItem from "components/DocumentListItem";
type Props = {|
documents: Document[],
limit?: number,
showCollection?: boolean,
showPublished?: boolean,
showPin?: boolean,
showDraft?: boolean,
showTemplate?: boolean,
|};
type Props = {
documents: Document[];
limit?: number;
showCollection?: boolean;
showPublished?: boolean;
showPin?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
export default function DocumentList({ limit, documents, ...rest }: Props) {
const items = limit ? documents.splice(0, limit) : documents;
@@ -1,4 +1,3 @@
// @flow
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
@@ -15,24 +14,23 @@ import Flex from "components/Flex";
import Highlight from "components/Highlight";
import StarButton, { AnimatedStar } from "components/Star";
import Tooltip from "components/Tooltip";
import useBoolean from "hooks/useBoolean";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
highlight?: ?string,
context?: ?string,
showNestedDocuments?: boolean,
showCollection?: boolean,
showPublished?: boolean,
showPin?: boolean,
showDraft?: boolean,
showTemplate?: boolean,
|};
type Props = {
document: Document;
highlight?: string | null;
context?: string | null;
showNestedDocuments?: boolean;
showCollection?: boolean;
showPublished?: boolean;
showPin?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@@ -47,7 +45,7 @@ function DocumentListItem(props: Props, ref) {
const { policies } = useStores();
const currentUser = useCurrentUser();
const currentTeam = useCurrentTeam();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [menuOpen, setMenuOpen] = React.useState(false);
const {
document,
showNestedDocuments,
@@ -66,7 +64,6 @@ function DocumentListItem(props: Props, ref) {
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
const can = policies.abilities(currentTeam.id);
const canCollection = policies.abilities(document.collectionId);
return (
<DocumentLink
@@ -127,8 +124,7 @@ function DocumentListItem(props: Props, ref) {
{document.isTemplate &&
!document.isArchived &&
!document.isDeleted &&
can.createDocument &&
canCollection.update && (
can.createDocument && (
<>
<Button
as={Link}
@@ -146,8 +142,8 @@ function DocumentListItem(props: Props, ref) {
<DocumentMenu
document={document}
showPin={showPin}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
modal={false}
/>
</Actions>
@@ -1,4 +1,3 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -6,10 +5,8 @@ import { Link } from "react-router-dom";
import styled from "styled-components";
import Document from "models/Document";
import DocumentBreadcrumb from "components/DocumentBreadcrumb";
import DocumentTasks from "components/DocumentTasks";
import Flex from "components/Flex";
import Time from "components/Time";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
const Container = styled(Flex)`
@@ -31,15 +28,15 @@ const Modified = styled.span`
font-weight: ${(props) => (props.highlight ? "600" : "400")};
`;
type Props = {|
showCollection?: boolean,
showPublished?: boolean,
showLastViewed?: boolean,
showNestedDocuments?: boolean,
document: Document,
children: React.Node,
to?: string,
|};
type Props = {
showCollection?: boolean;
showPublished?: boolean;
showLastViewed?: boolean;
showNestedDocuments?: boolean;
document: Document;
children: React.ReactNode;
to?: string;
};
function DocumentMeta({
showPublished,
@@ -52,9 +49,7 @@ function DocumentMeta({
...rest
}: Props) {
const { t } = useTranslation();
const { collections } = useStores();
const user = useCurrentUser();
const { collections, auth } = useStores();
const {
modifiedSinceViewed,
updatedAt,
@@ -65,8 +60,6 @@ function DocumentMeta({
deletedAt,
isDraft,
lastViewedAt,
isTasks,
isTemplate,
} = document;
// Prevent meta information from displaying if updatedBy is not available.
@@ -75,8 +68,6 @@ function DocumentMeta({
return null;
}
const collection = collections.get(document.collectionId);
const lastUpdatedByCurrentUser = user.id === updatedBy.id;
let content;
if (deletedAt) {
@@ -111,16 +102,14 @@ function DocumentMeta({
);
} else {
content = (
<Modified highlight={modifiedSinceViewed && !lastUpdatedByCurrentUser}>
<Modified highlight={modifiedSinceViewed}>
{t("updated")} <Time dateTime={updatedAt} addSuffix />
</Modified>
);
}
const nestedDocumentsCount = collection
? collection.getDocumentChildren(document.id).length
: 0;
const canShowProgressBar = isTasks && !isTemplate;
const collection = collections.get(document.collectionId);
const updatedByMe = auth.user && auth.user.id === updatedBy.id;
const timeSinceNow = () => {
if (isDraft || !showLastViewed) {
@@ -141,9 +130,13 @@ function DocumentMeta({
);
};
const nestedDocumentsCount = collection
? collection.getDocumentChildren(document.id).length
: 0;
return (
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
{lastUpdatedByCurrentUser ? t("You") : updatedBy.name}&nbsp;
{updatedByMe ? t("You") : updatedBy.name}&nbsp;
{to ? <Link to={to}>{content}</Link> : content}
{showCollection && collection && (
<span>
@@ -155,17 +148,11 @@ function DocumentMeta({
)}
{showNestedDocuments && nestedDocumentsCount > 0 && (
<span>
&nbsp; {nestedDocumentsCount}{" "}
&nbsp;&middot; {nestedDocumentsCount}{" "}
{t("nested document", { count: nestedDocumentsCount })}
</span>
)}
&nbsp;{timeSinceNow()}
{canShowProgressBar && (
<>
&nbsp;&nbsp;
<DocumentTasks document={document} />
</>
)}
{children}
</Container>
);
@@ -1,4 +1,3 @@
// @flow
import { useObserver } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -8,14 +7,14 @@ import Document from "models/Document";
import DocumentMeta from "components/DocumentMeta";
import DocumentViews from "components/DocumentViews";
import Popover from "components/Popover";
import useStores from "../hooks/useStores";
import useStores from "hooks/useStores";
type Props = {|
document: Document,
isDraft: boolean,
to?: string,
rtl?: boolean,
|};
type Props = {
document: Document;
isDraft: boolean;
to?: string;
rtl?: boolean;
};
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
const { views } = useStores();
@@ -42,7 +41,7 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
<PopoverDisclosure {...popover}>
{(props) => (
<>
&nbsp;&nbsp;
&nbsp;&middot;&nbsp;
<a {...props}>
{t("Viewed by")}{" "}
{onlyYou
@@ -62,7 +61,7 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
);
}
const Meta = styled(DocumentMeta)`
const Meta = styled(DocumentMeta)<{ rtl: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
margin: -12px 0 2em 0;
font-size: 14px;
-59
View File
@@ -1,59 +0,0 @@
// @flow
import { DoneIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import CircularProgressBar from "components/CircularProgressBar";
import usePrevious from "../hooks/usePrevious";
import Document from "../models/Document";
import { bounceIn } from "styles/animations";
type Props = {|
document: Document,
|};
function getMessage(t, total, completed) {
if (completed === 0) {
return t(`{{ total }} task`, { total, count: total });
} else if (completed === total) {
return t(`{{ completed }} task done`, { completed, count: completed });
} else {
return t(`{{ completed }} of {{ total }} tasks`, {
total,
completed,
});
}
}
function DocumentTasks({ document }: Props) {
const { tasks, tasksPercentage } = document;
const { t } = useTranslation();
const theme = useTheme();
const { completed, total } = tasks;
const done = completed === total;
const previousDone = usePrevious(done);
const message = getMessage(t, total, completed);
return (
<>
{completed === total ? (
<Done
color={theme.primary}
size={20}
$animated={done && previousDone === false}
/>
) : (
<CircularProgressBar percentage={tasksPercentage} />
)}
&nbsp;{message}
</>
);
}
const Done = styled(DoneIcon)`
margin: -1px;
animation: ${(props) => (props.$animated ? bounceIn : "none")} 600ms;
transform-origin: center center;
`;
export default DocumentTasks;
@@ -1,4 +1,3 @@
// @flow
import { formatDistanceToNow } from "date-fns";
import { sortBy } from "lodash";
import { observer } from "mobx-react";
@@ -10,10 +9,10 @@ import ListItem from "components/List/Item";
import PaginatedList from "components/PaginatedList";
import useStores from "hooks/useStores";
type Props = {|
document: Document,
isOpen?: boolean,
|};
type Props = {
document: Document;
isOpen?: boolean;
};
function DocumentViews({ document, isOpen }: Props) {
const { t } = useTranslation();
@@ -36,9 +35,10 @@ function DocumentViews({ document, isOpen }: Props) {
(view) => !presentIds.includes(view.user.id)
);
const users = React.useMemo(() => sortedViews.map((v) => v.user), [
sortedViews,
]);
const users = React.useMemo(
() => sortedViews.map((v) => v.user),
[sortedViews]
);
return (
<>
@@ -1,70 +1,73 @@
// @flow
import { lighten } from "polished";
import * as React from "react";
import { SyntheticEvent } from "react";
import { useTranslation } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import { Extension } from "rich-markdown-editor";
import { withRouter, RouterHistory } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import embeds from "shared/embeds";
import { light } from "shared/theme";
import { light } from "shared/styles/theme";
import UiStore from "stores/UiStore";
import ErrorBoundary from "components/ErrorBoundary";
import Tooltip from "components/Tooltip";
import embeds from "../embeds";
import useMediaQuery from "hooks/useMediaQuery";
import useToasts from "hooks/useToasts";
import { type Theme } from "types";
import { Theme } from "types";
import { isModKey } from "utils/keyboard";
import { uploadFile } from "utils/uploadFile";
import { isInternalUrl } from "utils/urls";
const RichMarkdownEditor = React.lazy(() =>
import(/* webpackChunkName: "rich-markdown-editor" */ "rich-markdown-editor")
const RichMarkdownEditor = React.lazy(
() =>
import(
/* webpackChunkName: "rich-markdown-editor" */ "rich-markdown-editor"
)
);
const EMPTY_ARRAY = [];
export type Props = {|
id?: string,
value?: string,
defaultValue?: string,
readOnly?: boolean,
grow?: boolean,
disableEmbeds?: boolean,
ui?: UiStore,
style?: Object,
extensions?: Extension[],
shareId?: ?string,
autoFocus?: boolean,
template?: boolean,
placeholder?: string,
maxLength?: number,
scrollTo?: string,
theme?: Theme,
handleDOMEvents?: Object,
readOnlyWriteCheckboxes?: boolean,
onBlur?: (event: SyntheticEvent<>) => any,
onFocus?: (event: SyntheticEvent<>) => any,
onPublish?: (event: SyntheticEvent<>) => any,
onSave?: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
onCancel?: () => any,
onDoubleClick?: () => any,
onChange?: (getValue: () => string) => any,
onSearchLink?: (title: string) => any,
onHoverLink?: (event: MouseEvent) => any,
onCreateLink?: (title: string) => Promise<string>,
onImageUploadStart?: () => any,
onImageUploadStop?: () => any,
|};
export type Props = {
id?: string;
value?: string;
defaultValue?: string;
readOnly?: boolean;
grow?: boolean;
disableEmbeds?: boolean;
ui?: UiStore;
shareId?: string | null;
autoFocus?: boolean;
template?: boolean;
placeholder?: string;
maxLength?: number;
scrollTo?: string;
theme?: Theme;
handleDOMEvents?: any;
readOnlyWriteCheckboxes?: boolean;
onBlur?: (event: SyntheticEvent) => any;
onFocus?: (event: SyntheticEvent) => any;
onPublish?: (event: SyntheticEvent) => any;
onSave?: (a: {
done?: boolean;
autosave?: boolean;
publish?: boolean;
}) => any;
onCancel?: () => any;
onDoubleClick?: () => any;
onChange?: (getValue: () => string) => any;
onSearchLink?: (title: string) => any;
onHoverLink?: (event: MouseEvent) => any;
onCreateLink?: (title: string) => Promise<string>;
onImageUploadStart?: () => any;
onImageUploadStop?: () => any;
};
type PropsWithRef = Props & {
forwardedRef: React.Ref<any>,
history: RouterHistory,
forwardedRef: React.Ref<any>;
history: RouterHistory;
};
function Editor(props: PropsWithRef) {
const { id, shareId, history } = props;
const { id, ui, shareId, history } = props;
const { t } = useTranslation();
const { showToast } = useToasts();
const isPrinting = useMediaQuery("print");
const onUploadImage = React.useCallback(
@@ -111,9 +114,11 @@ function Editor(props: PropsWithRef) {
const onShowToast = React.useCallback(
(message: string) => {
showToast(message);
if (ui) {
ui.showToast(message);
}
},
[showToast]
[ui]
);
const dictionary = React.useMemo(() => {
@@ -151,7 +156,6 @@ function Editor(props: PropsWithRef) {
hr: t("Divider"),
image: t("Image"),
imageUploadError: t("Sorry, an error occurred uploading the image"),
imageCaptionPlaceholder: t("Write a caption"),
info: t("Info"),
infoNotice: t("Info notice"),
link: t("Link"),
@@ -249,50 +253,6 @@ const StyledEditor = styled(RichMarkdownEditor)`
}
}
}
.ProseMirror {
.ProseMirror-yjs-cursor {
position: relative;
margin-left: -1px;
margin-right: -1px;
border-left: 1px solid black;
border-right: 1px solid black;
height: 1em;
word-break: normal;
&:after {
content: "";
display: block;
position: absolute;
left: -8px;
right: -8px;
top: 0;
bottom: 0;
}
> div {
opacity: 0;
position: absolute;
top: -1.8em;
font-size: 13px;
background-color: rgb(250, 129, 0);
font-style: normal;
line-height: normal;
user-select: none;
white-space: nowrap;
color: white;
padding: 2px 6px;
font-weight: 500;
border-radius: 4px;
pointer-events: none;
left: -1px;
}
&:hover {
> div {
opacity: 1;
transition: opacity 100ms ease-in-out;
}
}
}
}
`;
const EditorTooltip = ({ children, ...props }) => (
@@ -307,6 +267,6 @@ const Span = styled.span`
const EditorWithRouterAndTheme = withRouter(withTheme(Editor));
export default React.forwardRef<Props, typeof Editor>((props, ref) => (
export default React.forwardRef<typeof Editor>((props, ref) => (
<EditorWithRouterAndTheme {...props} forwardedRef={ref} />
));
@@ -1,4 +1,3 @@
// @flow
import styled from "styled-components";
const Empty = styled.p`
@@ -1,9 +1,7 @@
// @flow
import * as Sentry from "@sentry/react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction, Trans } from "react-i18next";
import styled from "styled-components";
import Button from "components/Button";
import CenteredContent from "components/CenteredContent";
@@ -12,18 +10,19 @@ import PageTitle from "components/PageTitle";
import { githubIssuesUrl } from "../../shared/utils/routeHelpers";
import env from "env";
type Props = {|
children: React.Node,
reloadOnChunkMissing?: boolean,
t: TFunction,
|};
type Props = {
children: React.ReactNode;
reloadOnChunkMissing?: boolean;
};
@observer
class ErrorBoundary extends React.Component<Props> {
@observable error: ?Error;
@observable showDetails: boolean = false;
@observable
error: Error | undefined | null;
@observable
showDetails: boolean = false;
componentDidCatch(error: Error, info: Object) {
componentDidCatch(error: Error, info: any) {
this.error = error;
console.error(error);
@@ -57,8 +56,6 @@ class ErrorBoundary extends React.Component<Props> {
};
render() {
const { t } = this.props;
if (this.error) {
const error = this.error;
const isReported = !!env.SENTRY_DSN && env.DEPLOYMENT === "hosted";
@@ -67,21 +64,15 @@ class ErrorBoundary extends React.Component<Props> {
if (isChunkError) {
return (
<CenteredContent>
<PageTitle title={t("Module failed to load")} />
<h1>
<Trans>Loading Failed</Trans>
</h1>
<PageTitle title="Module failed to load" />
<h1>Loading Failed</h1>
<HelpText>
<Trans>
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.
</Trans>
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}>
<Trans>Reload</Trans>
</Button>
<Button onClick={this.handleReload}>Reload</Button>
</p>
</CenteredContent>
);
@@ -89,32 +80,23 @@ class ErrorBoundary extends React.Component<Props> {
return (
<CenteredContent>
<PageTitle title={t("Something Unexpected Happened")} />
<h1>
<Trans>Something Unexpected Happened</Trans>
</h1>
<PageTitle title="Something Unexpected Happened" />
<h1>Something Unexpected Happened</h1>
<HelpText>
<Trans
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
values={{
notified: isReported
? ` ${t("our engineers have been notified")}`
: undefined,
}}
/>
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>}
<p>
<Button onClick={this.handleReload}>
<Trans>Reload</Trans>
</Button>{" "}
<Button onClick={this.handleReload}>Reload</Button>{" "}
{this.showDetails ? (
<Button onClick={this.handleReportBug} neutral>
<Trans>Report a Bug</Trans>
Report a Bug
</Button>
) : (
<Button onClick={this.handleShowDetails} neutral>
<Trans>Show Detail</Trans>
Show Details
</Button>
)}
</p>
@@ -133,4 +115,4 @@ const Pre = styled.pre`
white-space: pre-wrap;
`;
export default withTranslation()<ErrorBoundary>(ErrorBoundary);
export default ErrorBoundary;
@@ -1,14 +1,14 @@
// @flow
import * as React from "react";
import { SyntheticEvent } from "react";
type Props = {
children: React.Node,
className?: string,
children: React.ReactNode;
className?: string;
};
export default function EventBoundary({ children, className }: Props) {
const handleClick = React.useCallback((event: SyntheticEvent<>) => {
const handleClick = React.useCallback((event: SyntheticEvent) => {
event.preventDefault();
event.stopPropagation();
}, []);
-163
View File
@@ -1,163 +0,0 @@
// @flow
import {
TrashIcon,
ArchiveIcon,
EditIcon,
PublishIcon,
MoveIcon,
CheckboxIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Document from "models/Document";
import Event from "models/Event";
import Avatar from "components/Avatar";
import Item, { Actions } from "components/List/Item";
import Time from "components/Time";
import RevisionMenu from "menus/RevisionMenu";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
event: Event,
latest?: boolean,
|};
const EventListItem = ({ event, latest, document }: Props) => {
const { t } = useTranslation();
const opts = { userName: event.actor.name };
const isRevision = event.name === "revisions.create";
let meta, icon, to;
switch (event.name) {
case "revisions.create":
case "documents.latest_version": {
if (latest) {
icon = <CheckboxIcon color="currentColor" size={16} checked />;
meta = t("Latest version");
to = documentHistoryUrl(document);
break;
} else {
icon = <EditIcon color="currentColor" size={16} />;
meta = t("{{userName}} edited", opts);
to = documentHistoryUrl(document, event.modelId || "");
break;
}
}
case "documents.archive":
icon = <ArchiveIcon color="currentColor" size={16} />;
meta = t("{{userName}} archived", opts);
break;
case "documents.unarchive":
meta = t("{{userName}} restored", opts);
break;
case "documents.delete":
icon = <TrashIcon color="currentColor" size={16} />;
meta = t("{{userName}} deleted", opts);
break;
case "documents.restore":
meta = t("{{userName}} moved from trash", opts);
break;
case "documents.publish":
icon = <PublishIcon color="currentColor" size={16} />;
meta = t("{{userName}} published", opts);
break;
case "documents.move":
icon = <MoveIcon color="currentColor" size={16} />;
meta = t("{{userName}} moved", opts);
break;
default:
console.warn("Unhandled event: ", event.name);
}
if (!meta) {
return null;
}
return (
<ListItem
small
exact
to={to}
title={
<Time
dateTime={event.createdAt}
tooltipDelay={250}
format="MMMM do, h:mm a"
relative={false}
addSuffix
/>
}
image={<Avatar src={event.actor?.avatarUrl} size={32} />}
subtitle={
<Subtitle>
{icon}
{meta}
</Subtitle>
}
actions={
isRevision ? (
<RevisionMenu document={document} revisionId={event.modelId} />
) : undefined
}
/>
);
};
const Subtitle = styled.span`
svg {
margin: -3px;
margin-right: 2px;
}
`;
const ListItem = styled(Item)`
border: 0;
position: relative;
margin: 8px;
padding: 8px;
border-radius: 8px;
img {
border-color: transparent;
}
&::before {
content: "";
display: block;
position: absolute;
top: -4px;
left: 23px;
width: 2px;
height: calc(100% + 8px);
background: ${(props) => props.theme.textSecondary};
opacity: 0.25;
}
&:nth-child(2)::before {
height: 50%;
top: 50%;
}
&:last-child::before {
height: 50%;
}
&:first-child:last-child::before {
display: none;
}
${Actions} {
opacity: 0.25;
transition: opacity 100ms ease-in-out;
}
&:hover {
${Actions} {
opacity: 1;
}
}
`;
export default EventListItem;
@@ -1,4 +1,3 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
@@ -6,13 +5,15 @@ import User from "models/User";
import Avatar from "components/Avatar";
import Flex from "components/Flex";
type Props = {|
users: User[],
size?: number,
overflow: number,
onClick?: (event: SyntheticEvent<>) => mixed,
renderAvatar?: (user: User) => React.Node,
|};
import { SyntheticEvent } from "react";
type Props = {
users: User[];
size?: number;
overflow: number;
onClick?: (event: SyntheticEvent) => unknown;
renderAvatar?: (user: User) => React.ReactNode;
};
function Facepile({
users,
@@ -1,6 +1,5 @@
// @flow
import styled from "styled-components";
import { fadeIn } from "styles/animations";
import { fadeIn } from "shared/styles/animations";
const Fade = styled.span`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
@@ -1,4 +1,3 @@
// @flow
import { find } from "lodash";
import * as React from "react";
import { useMenuState, MenuButton } from "reakit/Menu";
@@ -8,20 +7,20 @@ import ContextMenu from "components/ContextMenu";
import MenuItem from "components/ContextMenu/MenuItem";
import HelpText from "components/HelpText";
type TFilterOption = {|
key: string,
label: string,
note?: string,
|};
type TFilterOption = {
key: string;
label: string;
note?: string;
};
type Props = {|
options: TFilterOption[],
activeKey: ?string,
defaultLabel?: string,
selectedPrefix?: string,
className?: string,
onSelect: (key: ?string) => void,
|};
type Props = {
options: TFilterOption[];
activeKey: string | undefined | null;
defaultLabel?: string;
selectedPrefix?: string;
className?: string;
onSelect: (key?: string | null) => void;
};
const FilterOptions = ({
options,
@@ -1,4 +1,3 @@
// @flow
import * as React from "react";
import styled from "styled-components";
@@ -8,7 +7,6 @@ type JustifyValues =
| "space-between"
| "flex-start"
| "flex-end";
type AlignValues =
| "stretch"
| "center"
@@ -16,36 +14,26 @@ type AlignValues =
| "flex-start"
| "flex-end";
type Props = {|
column?: ?boolean,
shrink?: ?boolean,
align?: AlignValues,
justify?: JustifyValues,
auto?: ?boolean,
className?: string,
children?: React.Node,
role?: string,
gap?: number,
|};
type Props = {
column?: boolean | null;
shrink?: boolean | null;
align?: AlignValues;
justify?: JustifyValues;
auto?: boolean | null;
className?: string;
children?: React.ReactNode;
role?: string;
gap?: number;
};
const Flex = React.forwardRef<Props, HTMLDivElement>((props: Props, ref) => {
const { children, ...restProps } = props;
return (
<Container ref={ref} {...restProps}>
{children}
</Container>
);
});
const Container = styled.div`
const Flex = styled.div<Props>`
display: flex;
flex: ${({ auto }) => (auto ? "1 1 auto" : "initial")};
flex-direction: ${({ column }) => (column ? "column" : "row")};
align-items: ${({ align }) => align};
justify-content: ${({ justify }) => justify};
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
gap: ${({ gap }) => (gap ? `${gap}px` : "initial")};
gap: ${({ gap }) => `${gap}px` || "initial"};
min-height: 0;
min-width: 0;
`;
@@ -1,4 +1,3 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import Empty from "components/Empty";
@@ -1,10 +1,9 @@
// @flow
import * as React from "react";
type Props = {
size?: number,
fill?: string,
className?: string,
size?: number;
fill?: string;
className?: string;
};
function GithubLogo({ size = 34, fill = "#FFF", className }: Props) {
@@ -1,4 +1,3 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { GroupIcon } from "outline-icons";
@@ -15,17 +14,18 @@ import ListItem from "components/List/Item";
import Modal from "components/Modal";
type Props = {
group: Group,
groupMemberships: GroupMembershipsStore,
membership?: CollectionGroupMembership,
showFacepile?: boolean,
showAvatar?: boolean,
renderActions: ({ openMembersModal: () => void }) => React.Node,
group: Group;
groupMemberships: GroupMembershipsStore;
membership?: CollectionGroupMembership;
showFacepile?: boolean;
showAvatar?: boolean;
renderActions: (a: { openMembersModal: () => void }) => React.ReactNode;
};
@observer
class GroupListItem extends React.Component<Props> {
@observable membersModalOpen: boolean = false;
@observable
membersModalOpen: boolean = false;
handleMembersModalOpen = () => {
this.membersModalOpen = true;
@@ -1,4 +1,3 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
@@ -6,12 +5,12 @@ import styled from "styled-components";
import Scrollable from "components/Scrollable";
import usePrevious from "hooks/usePrevious";
type Props = {|
children?: React.Node,
isOpen: boolean,
title?: string,
onRequestClose: () => void,
|};
type Props = {
children?: React.ReactNode;
isOpen: boolean;
title?: string;
onRequestClose: () => void;
};
const Guide = ({
children,
@@ -1,4 +1,3 @@
// @flow
import { throttle } from "lodash";
import { observer } from "mobx-react";
import { transparentize } from "polished";
@@ -8,11 +7,11 @@ import breakpoint from "styled-components-breakpoint";
import Fade from "components/Fade";
import Flex from "components/Flex";
type Props = {|
breadcrumb?: React.Node,
title: React.Node,
actions?: React.Node,
|};
type Props = {
breadcrumb?: React.ReactNode;
title: React.ReactNode;
actions?: React.ReactNode;
};
function Header({ breadcrumb, title, actions }: Props) {
const [isScrolled, setScrolled] = React.useState(false);
@@ -36,7 +35,7 @@ function Header({ breadcrumb, title, actions }: Props) {
}, []);
return (
<Wrapper align="center" shrink={false}>
<Wrapper align="center" isCompact={isScrolled} shrink={false}>
{breadcrumb ? <Breadcrumbs>{breadcrumb}</Breadcrumbs> : null}
{isScrolled ? (
<Title align="center" justify="flex-start" onClick={handleClickTitle}>
@@ -72,10 +71,6 @@ const Actions = styled(Flex)`
flex-basis: 0;
min-width: auto;
padding-left: 8px;
${breakpoint("tablet")`
position: unset;
`};
`;
const Wrapper = styled(Flex)`
@@ -88,14 +83,14 @@ const Wrapper = styled(Flex)`
transform: translate3d(0, 0, 0);
backdrop-filter: blur(20px);
min-height: 56px;
justify-content: flex-start;
@media print {
display: none;
}
justify-content: flex-start;
${breakpoint("tablet")`
padding: 16px 16px 0;
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
justify-content: "center";
`};
`;
@@ -1,4 +1,3 @@
// @flow
import styled from "styled-components";
const Heading = styled.h1`
@@ -1,7 +1,10 @@
// @flow
import styled from "styled-components";
const HelpText = styled.p`
type Props = {
small?: boolean;
};
const HelpText = styled.p<Props>`
margin-top: 0;
color: ${(props) => props.theme.textSecondary};
font-size: ${(props) => (props.small ? "13px" : "inherit")};
@@ -1,13 +1,12 @@
// @flow
import * as React from "react";
import replace from "string-replace-to-array";
import styled from "styled-components";
type Props = {
highlight: ?string | RegExp,
processResult?: (tag: string) => string,
text: string,
caseSensitive?: boolean,
highlight: string | undefined | null | RegExp;
processResult?: (tag: string) => string;
text: string;
caseSensitive?: boolean;
};
function Highlight({
@@ -1,23 +1,22 @@
// @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 { fadeAndSlideDown } from "styles/animations";
import { isInternalUrl } from "utils/urls";
const DELAY_OPEN = 300;
const DELAY_CLOSE = 300;
type Props = {
node: HTMLAnchorElement,
event: MouseEvent,
documents: DocumentsStore,
onClose: () => void,
node: HTMLAnchorElement;
event: MouseEvent;
documents: DocumentsStore;
onClose: () => void;
};
function HoverPreviewInternal({ node, documents, onClose, event }: Props) {
@@ -26,7 +25,7 @@ function HoverPreviewInternal({ node, documents, onClose, event }: Props) {
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef();
const timerOpen = React.useRef();
const cardRef = React.useRef<?HTMLDivElement>();
const cardRef = React.useRef<HTMLDivElement | undefined | null>();
const startCloseTimer = () => {
stopOpenTimer();
@@ -136,7 +135,7 @@ function HoverPreview({ node, ...rest }: Props) {
}
const Animate = styled.div`
animation: ${fadeAndSlideDown} 150ms ease;
animation: ${fadeAndSlideIn} 150ms ease;
@media print {
display: none;
@@ -1,4 +1,3 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
@@ -9,8 +8,8 @@ import Editor from "components/Editor";
import useStores from "hooks/useStores";
type Props = {
url: string,
children: (React.Node) => React.Node,
url: string;
children: (a: React.ReactNode) => React.ReactNode;
};
function HoverPreviewDocument({ url, children }: Props) {
@@ -1,6 +1,4 @@
// @flow
import {
BookmarkedIcon,
CollectionIcon,
CoinsIcon,
AcademicCapIcon,
@@ -9,12 +7,9 @@ import {
CloudIcon,
CodeIcon,
EditIcon,
EmailIcon,
EyeIcon,
ImageIcon,
LeafIcon,
LightBulbIcon,
MathIcon,
MoonIcon,
NotepadIcon,
PadlockIcon,
@@ -22,7 +17,6 @@ import {
QuestionMarkIcon,
SunIcon,
VehicleIcon,
WarningIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -44,10 +38,6 @@ const TwitterPicker = React.lazy(() =>
);
export const icons = {
bookmark: {
component: BookmarkedIcon,
keywords: "bookmark",
},
collection: {
component: CollectionIcon,
keywords: "collection",
@@ -76,18 +66,10 @@ export const icons = {
component: CodeIcon,
keywords: "developer api code development engineering programming",
},
email: {
component: EmailIcon,
keywords: "email at",
},
eye: {
component: EyeIcon,
keywords: "eye view",
},
image: {
component: ImageIcon,
keywords: "image photo picture",
},
leaf: {
component: LeafIcon,
keywords: "leaf plant outdoors nature ecosystem climate",
@@ -96,10 +78,6 @@ export const icons = {
component: LightBulbIcon,
keywords: "lightbulb idea",
},
math: {
component: MathIcon,
keywords: "math formula",
},
moon: {
component: MoonIcon,
keywords: "night moon dark",
@@ -132,10 +110,6 @@ export const icons = {
component: VehicleIcon,
keywords: "truck car travel transport",
},
warning: {
component: WarningIcon,
keywords: "danger",
},
};
const colors = [
@@ -151,12 +125,12 @@ const colors = [
"#2F362F",
];
type Props = {|
onOpen?: () => void,
onChange: (color: string, icon: string) => void,
icon: string,
color: string,
|};
type Props = {
onOpen?: () => void;
onChange: (color: string, icon: string) => void;
icon: string;
color: string;
};
function IconPicker({ onOpen, icon, color, onChange }: Props) {
const { t } = useTranslation();
@@ -217,7 +191,7 @@ const Label = styled.label`
`;
const Icons = styled.div`
padding: 16px 8px 0 16px;
padding: 15px 9px 9px 15px;
width: 276px;
`;
+14
View File
@@ -0,0 +1,14 @@
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} />;
}
@@ -1,4 +1,3 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
@@ -7,6 +6,8 @@ import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Flex from "components/Flex";
import { SyntheticEvent, ChangeEvent, KeyboardEvent } from "react";
const RealTextarea = styled.textarea`
border: 0;
flex: 1;
@@ -29,10 +30,6 @@ const RealInput = styled.input`
background: none;
color: ${(props) => props.theme.text};
height: 30px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:disabled,
&::placeholder {
@@ -88,46 +85,47 @@ export const LabelText = styled.div`
display: inline-block;
`;
export type Props = {|
type?: "text" | "email" | "checkbox" | "search" | "textarea",
value?: string,
label?: string,
className?: string,
labelHidden?: boolean,
flex?: boolean,
short?: boolean,
margin?: string | number,
icon?: React.Node,
name?: string,
minLength?: number,
maxLength?: number,
autoFocus?: boolean,
autoComplete?: boolean | string,
readOnly?: boolean,
required?: boolean,
disabled?: boolean,
placeholder?: string,
export type Props = {
type?: "text" | "email" | "checkbox" | "search" | "textarea";
value?: string;
label?: string;
className?: string;
labelHidden?: boolean;
flex?: boolean;
short?: boolean;
margin?: string | number;
icon?: React.ReactNode;
name?: string;
minLength?: number;
maxLength?: number;
autoFocus?: boolean;
autoComplete?: boolean | string;
readOnly?: boolean;
required?: boolean;
disabled?: boolean;
placeholder?: string;
onChange?: (
ev: SyntheticInputEvent<HTMLInputElement | HTMLTextAreaElement>
) => mixed,
onKeyDown?: (ev: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
onFocus?: (ev: SyntheticEvent<>) => mixed,
onBlur?: (ev: SyntheticEvent<>) => mixed,
|};
ev: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
onKeyDown?: (ev: KeyboardEvent<HTMLInputElement>) => unknown;
onFocus?: (ev: SyntheticEvent) => unknown;
onBlur?: (ev: SyntheticEvent) => unknown;
};
@observer
class Input extends React.Component<Props> {
input: ?HTMLInputElement;
@observable focused: boolean = false;
input: HTMLInputElement | undefined | null;
@observable
focused: boolean = false;
handleBlur = (ev: SyntheticEvent<>) => {
handleBlur = (ev: SyntheticEvent) => {
this.focused = false;
if (this.props.onBlur) {
this.props.onBlur(ev);
}
};
handleFocus = (ev: SyntheticEvent<>) => {
handleFocus = (ev: SyntheticEvent) => {
this.focused = true;
if (this.props.onFocus) {
this.props.onFocus(ev);
@@ -1,4 +1,3 @@
// @flow
import styled from "styled-components";
import Input from "./Input";
-70
View File
@@ -1,70 +0,0 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { Trans } from "react-i18next";
import styled, { withTheme } from "styled-components";
import Editor from "components/Editor";
import HelpText from "components/HelpText";
import { LabelText, Outline } from "components/Input";
import useStores from "hooks/useStores";
type Props = {|
label: string,
minHeight?: number,
maxHeight?: number,
readOnly?: boolean,
|};
function InputRich({ label, minHeight, maxHeight, ...rest }: Props) {
const [focused, setFocused] = React.useState<boolean>(false);
const { ui } = useStores();
const handleBlur = React.useCallback(() => {
setFocused(false);
}, []);
const handleFocus = React.useCallback(() => {
setFocused(true);
}, []);
return (
<>
<LabelText>{label}</LabelText>
<StyledOutline
maxHeight={maxHeight}
minHeight={minHeight}
focused={focused}
>
<React.Suspense
fallback={
<HelpText>
<Trans>Loading editor</Trans>
</HelpText>
}
>
<Editor
onBlur={handleBlur}
onFocus={handleFocus}
ui={ui}
grow
{...rest}
/>
</React.Suspense>
</StyledOutline>
</>
);
}
const StyledOutline = styled(Outline)`
display: block;
padding: 8px 12px;
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : "0")};
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : "auto")};
overflow-y: auto;
> * {
display: block;
}
`;
export default observer(withTheme(InputRich));
+71
View File
@@ -0,0 +1,71 @@
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import * as React from "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 = {
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;
handleBlur = () => {
this.focused = false;
};
handleFocus = () => {
this.focused = true;
};
render() {
const { label, minHeight, maxHeight, ui, ...rest } = this.props;
return (
<>
<LabelText>{label}</LabelText>
<StyledOutline
maxHeight={maxHeight}
minHeight={minHeight}
focused={this.focused}
>
<React.Suspense fallback={<HelpText>Loading editor</HelpText>}>
<Editor
onBlur={this.handleBlur}
onFocus={this.handleFocus}
ui={ui}
grow
{...rest}
/>
</React.Suspense>
</StyledOutline>
</>
);
}
}
const StyledOutline = styled(Outline)`
display: block;
padding: 8px 12px;
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : "0")};
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : "auto")};
overflow-y: auto;
> * {
display: block;
}
`;
export default inject("ui")(withTheme(InputRich));
@@ -1,17 +1,19 @@
// @flow
import { SearchIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components";
import Input, { type Props as InputProps } from "./Input";
import Input from "./Input";
type Props = {|
...InputProps,
placeholder?: string,
value?: string,
onChange: (event: SyntheticInputEvent<>) => mixed,
onKeyDown?: (event: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
|};
import { Props as InputProps } from "./Input";
import { ChangeEvent, KeyboardEvent } from "react";
type Props = {
placeholder?: string;
value?: string;
onChange: (event: ChangeEvent) => unknown;
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => unknown;
} & InputProps;
export default function InputSearch(props: Props) {
const { t } = useTranslation();
@@ -1,38 +1,38 @@
// @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 { SyntheticEvent, ChangeEvent, KeyboardEvent } from "react";
import { withTranslation, TFunction } from "react-i18next";
import keydown from "react-keydown";
import { withRouter, type RouterHistory } from "react-router-dom";
import { withRouter, RouteComponentProps } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import Input from "./Input";
import { type Theme } from "types";
import { Theme } from "types";
import { meta } from "utils/keyboard";
import { searchUrl } from "utils/routeHelpers";
type Props = {
history: RouterHistory,
theme: Theme,
source: string,
placeholder?: string,
label?: string,
labelHidden?: boolean,
collectionId?: string,
value: string,
onChange: (event: SyntheticInputEvent<>) => mixed,
onKeyDown: (event: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
t: TFunction,
type Props = RouteComponentProps & {
theme: Theme;
source: string;
placeholder?: string;
label?: string;
labelHidden?: boolean;
collectionId?: string;
value: string;
onChange: (event: ChangeEvent) => unknown;
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => unknown;
t: TFunction;
};
@observer
class InputSearchPage extends React.Component<Props> {
input: ?Input;
@observable focused: boolean = false;
input: Input | undefined | null;
@observable
focused: boolean = false;
@keydown(`${meta}+f`)
focus(ev: SyntheticEvent<>) {
focus(ev: SyntheticEvent) {
ev.preventDefault();
if (this.input) {
@@ -40,7 +40,7 @@ class InputSearchPage extends React.Component<Props> {
}
}
handleSearchInput = (ev: SyntheticInputEvent<>) => {
handleSearchInput = (ev: ChangeEvent) => {
ev.preventDefault();
this.props.history.push(
searchUrl(ev.target.value, {
@@ -1,10 +1,8 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { Outline, LabelText } from "./Input";
const Select = styled.select`
@@ -16,7 +14,6 @@ const Select = styled.select`
background: none;
color: ${(props) => props.theme.text};
height: 30px;
font-size: 14px;
option {
background: ${(props) => props.theme.buttonNeutralBackground};
@@ -26,10 +23,6 @@ const Select = styled.select`
&::placeholder {
color: ${(props) => props.theme.placeholder};
}
${breakpoint("mobile", "tablet")`
font-size: 16px;
`};
`;
const Wrapper = styled.label`
@@ -37,22 +30,26 @@ const Wrapper = styled.label`
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
export type Option = { label: string, value: string };
export type Option = {
label: string;
value: string;
};
export type Props = {
value?: string,
label?: string,
short?: boolean,
className?: string,
labelHidden?: boolean,
options: Option[],
onBlur?: () => void,
onFocus?: () => void,
value?: string;
label?: string;
short?: boolean;
className?: string;
labelHidden?: boolean;
options: Option[];
onBlur?: () => void;
onFocus?: () => void;
};
@observer
class InputSelect extends React.Component<Props> {
@observable focused: boolean = false;
@observable
focused: boolean = false;
handleBlur = () => {
this.focused = false;
@@ -63,14 +60,8 @@ class InputSelect extends React.Component<Props> {
};
render() {
const {
label,
className,
labelHidden,
options,
short,
...rest
} = this.props;
const { label, className, labelHidden, options, short, ...rest } =
this.props;
const wrappedLabel = <LabelText>{label}</LabelText>;
@@ -1,11 +1,10 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import InputSelect, { type Props, type Option } from "./InputSelect";
import InputSelect from "./InputSelect";
export default function InputSelectPermission(
props: $Rest<Props, { options: Array<Option> }>
) {
import { Props, Option } from "./InputSelect";
export default function InputSelectPermission(props: Omit<Props, "options">) {
const { t } = useTranslation();
return (
-22
View File
@@ -1,22 +0,0 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import InputSelect, { type Props, type Option } from "components/InputSelect";
const InputSelectRole = (props: $Rest<Props, { options: Array<Option> }>) => {
const { t } = useTranslation();
return (
<InputSelect
label={t("Role")}
options={[
{ label: t("Member"), value: "member" },
{ label: t("Viewer"), value: "viewer" },
{ label: t("Admin"), value: "admin" },
]}
{...props}
/>
);
};
export default InputSelectRole;
@@ -1,4 +1,3 @@
// @flow
import styled from "styled-components";
const Key = styled.kbd`
@@ -1,13 +1,12 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import Flex from "components/Flex";
type Props = {|
label: React.Node | string,
children: React.Node,
|};
type Props = {
label: React.ReactNode | string;
children: React.ReactNode;
};
const Labeled = ({ label, children, ...props }: Props) => (
<Flex column {...props}>
@@ -1,4 +1,3 @@
// @flow
import { find } from "lodash";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
@@ -1,18 +1,20 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { MenuIcon } from "outline-icons";
import * as React from "react";
import { SyntheticEvent } from "react";
import { Helmet } from "react-helmet";
import { withTranslation, type TFunction } from "react-i18next";
import { withTranslation, TFunction } from "react-i18next";
import keydown from "react-keydown";
import {
Switch,
Route,
Redirect,
withRouter,
type RouterHistory,
RouterHistory,
} from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import AuthStore from "stores/AuthStore";
@@ -22,6 +24,7 @@ import UiStore from "stores/UiStore";
import ErrorSuspended from "scenes/ErrorSuspended";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import Button from "components/Button";
import DocumentHistory from "components/DocumentHistory";
import Flex from "components/Flex";
import Guide from "components/Guide";
import { LoadingIndicatorBar } from "components/LoadingIndicator";
@@ -37,31 +40,27 @@ import {
newDocumentUrl,
} from "utils/routeHelpers";
const DocumentHistory = React.lazy(() =>
import(
/* webpackChunkName: "document-history" */ "components/DocumentHistory"
)
);
type Props = {
documents: DocumentsStore,
children?: ?React.Node,
actions?: ?React.Node,
title?: ?React.Node,
auth: AuthStore,
ui: UiStore,
history: RouterHistory,
policies: PoliciesStore,
notifications?: React.Node,
i18n: Object,
t: TFunction,
documents: DocumentsStore;
children?: React.ReactNode | null;
actions?: React.ReactNode | null;
title?: React.ReactNode | null;
auth: AuthStore;
ui: UiStore;
history: RouterHistory;
policies: PoliciesStore;
notifications?: React.ReactNode;
i18n: any;
t: TFunction;
};
@observer
class Layout extends React.Component<Props> {
scrollable: ?HTMLDivElement;
@observable redirectTo: ?string;
@observable keyboardShortcutsOpen: boolean = false;
scrollable: HTMLDivElement | undefined | null;
@observable
redirectTo: string | undefined | null;
@observable
keyboardShortcutsOpen: boolean = false;
componentDidUpdate() {
if (this.redirectTo) {
@@ -84,7 +83,7 @@ class Layout extends React.Component<Props> {
};
@keydown(["t", "/", `${meta}+k`])
goToSearch(ev: SyntheticEvent<>) {
goToSearch(ev: SyntheticEvent) {
ev.preventDefault();
ev.stopPropagation();
this.redirectTo = searchUrl();
@@ -159,14 +158,12 @@ class Layout extends React.Component<Props> {
{this.props.children}
</Content>
<React.Suspense>
<Switch>
<Route
path={`/doc/${slug}/history/:revisionId?`}
component={DocumentHistory}
/>
</Switch>
</React.Suspense>
<Switch>
<Route
path={`/doc/${slug}/history/:revisionId?`}
component={DocumentHistory}
/>
</Switch>
</Container>
<Guide
isOpen={this.keyboardShortcutsOpen}
-113
View File
@@ -1,113 +0,0 @@
// @flow
import * as React from "react";
import styled, { useTheme } from "styled-components";
import Flex from "components/Flex";
import NavLink from "components/NavLink";
type Props = {|
image?: React.Node,
to?: string,
title: React.Node,
subtitle?: React.Node,
actions?: React.Node,
border?: boolean,
small?: boolean,
|};
const ListItem = (
{ image, title, subtitle, actions, small, border, to, ...rest }: Props,
ref
) => {
const theme = useTheme();
const compact = !subtitle;
const content = (selected) => (
<>
{image && <Image>{image}</Image>}
<Content
align={compact ? "center" : undefined}
column={!compact}
$selected={selected}
>
<Heading $small={small}>{title}</Heading>
{subtitle && (
<Subtitle $small={small} $selected={selected}>
{subtitle}
</Subtitle>
)}
</Content>
{actions && <Actions $selected={selected}>{actions}</Actions>}
</>
);
return (
<Wrapper
ref={ref}
$border={border}
activeStyle={{ background: theme.primary }}
{...rest}
as={to ? NavLink : undefined}
to={to}
>
{to ? content : content(false)}
</Wrapper>
);
};
const Wrapper = styled.div`
display: flex;
user-select: none;
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
margin: ${(props) => (props.$border === false ? "8px 0" : 0)};
border-bottom: 1px solid
${(props) =>
props.$border === false ? "transparent" : props.theme.divider};
&:last-child {
border-bottom: 0;
}
`;
const Image = styled(Flex)`
padding: 0 8px 0 0;
max-height: 32px;
align-items: center;
user-select: none;
flex-shrink: 0;
align-self: center;
`;
const Heading = styled.p`
font-size: ${(props) => (props.$small ? 14 : 16)}px;
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
line-height: ${(props) => (props.$small ? 1.3 : 1.2)};
margin: 0;
`;
const Content = styled.div`
display: flex;
flex-direction: column;
flex-grow: 1;
color: ${(props) => (props.$selected ? props.theme.white : props.theme.text)};
`;
const Subtitle = styled.p`
margin: 0;
font-size: ${(props) => (props.$small ? 13 : 14)}px;
color: ${(props) =>
props.$selected ? props.theme.white50 : props.theme.textTertiary};
margin-top: -2px;
`;
export const Actions = styled(Flex)`
align-self: center;
justify-content: center;
margin-right: 4px;
color: ${(props) =>
props.$selected ? props.theme.white : props.theme.textSecondary};
`;
export default React.forwardRef<Props, HTMLDivElement>(ListItem);
+83
View File
@@ -0,0 +1,83 @@
import * as React from "react";
import styled from "styled-components";
import Flex from "components/Flex";
type Props = {
image?: React.ReactNode;
title: React.ReactNode;
subtitle?: React.ReactNode;
actions?: React.ReactNode;
border?: boolean;
small?: boolean;
};
const ListItem = ({
image,
title,
subtitle,
actions,
small,
border,
}: Props) => {
const compact = !subtitle;
return (
<Wrapper compact={compact} $border={border}>
{image && <Image>{image}</Image>}
<Content align={compact ? "center" : undefined} column={!compact}>
<Heading $small={small}>{title}</Heading>
{subtitle && <Subtitle $small={small}>{subtitle}</Subtitle>}
</Content>
{actions && <Actions>{actions}</Actions>}
</Wrapper>
);
};
const Wrapper = styled.li`
display: flex;
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
margin: ${(props) => (props.$border === false ? "8px 0" : 0)};
border-bottom: 1px solid
${(props) =>
props.$border === false ? "transparent" : props.theme.divider};
&:last-child {
border-bottom: 0;
}
`;
const Image = styled(Flex)`
padding: 0 8px 0 0;
max-height: 32px;
align-items: center;
user-select: none;
flex-shrink: 0;
align-self: center;
`;
const Heading = styled.p`
font-size: ${(props) => (props.$small ? 15 : 16)}px;
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
line-height: 1.2;
margin: 0;
`;
const Content = styled(Flex)`
flex-grow: 1;
`;
const Subtitle = styled.p`
margin: 0;
font-size: ${(props) => (props.$small ? 13 : 14)}px;
color: ${(props) => props.theme.textTertiary};
margin-top: -2px;
`;
const Actions = styled.div`
align-self: center;
`;
export default ListItem;
@@ -1,4 +1,3 @@
// @flow
import styled from "styled-components";
const List = styled.ol`
+28
View File
@@ -0,0 +1,28 @@
import { times } from "lodash";
import * as React from "react";
import styled from "styled-components";
import Fade from "components/Fade";
import Flex from "components/Flex";
import Mask from "components/Mask";
type Props = {
count?: number;
};
const Placeholder = ({ count }: Props) => {
return (
<Fade>
{times(count || 2, (index) => (
<Item key={index} column auto>
<Mask />
</Item>
))}
</Fade>
);
};
const Item = styled(Flex)`
padding: 15px 0 16px;
`;
export default Placeholder;
@@ -1,3 +1,2 @@
// @flow
import List from "./List";
export default List;
@@ -1,25 +0,0 @@
// @flow
import { inject, observer } from "mobx-react";
import * as React from "react";
import UiStore from "stores/UiStore";
type Props = {
ui: UiStore,
};
@observer
class LoadingIndicator extends React.Component<Props> {
componentDidMount() {
this.props.ui.enableProgressBar();
}
componentWillUnmount() {
this.props.ui.disableProgressBar();
}
render() {
return null;
}
}
export default inject("ui")(LoadingIndicator);

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