Compare commits

..

3 Commits

Author SHA1 Message Date
Tom Moor 2aaad03270 refactor 2020-08-20 19:05:48 -07:00
Tom Moor 9252683260 fix: SocketPresence account for socket changing 2020-08-20 00:06:51 -07:00
Tom Moor f5748eb5e7 check connection on page visibility change 2020-08-19 22:51:18 -07:00
311 changed files with 5968 additions and 14682 deletions
+3 -3
View File
@@ -3,7 +3,7 @@ jobs:
build:
working_directory: ~/outline
docker:
- image: circleci/node:14
- image: circleci/node:12
- image: circleci/redis:latest
- image: circleci/postgres:9.6.5-alpine-ram
environment:
@@ -39,5 +39,5 @@ jobs:
name: test
command: yarn test
- run:
name: build-webpack
command: yarn build:webpack
name: build
command: yarn build
-19
View File
@@ -1,19 +0,0 @@
__mocks__
.git
.vscode
.github
.circleci
.DS_Store
.env*
.eslint*
.flowconfig
.log
Makefile
Procfile
app.json
build
docker-compose.yml
fakes3
flow-typed
node_modules
setupJest.js
+2 -10
View File
@@ -14,21 +14,16 @@ URL=http://localhost:3000
PORT=3000
# enforce (auto redirect to) https in production, (optional) default is true.
# set to false if your SSL is terminated at a loadbalancer, for example
# set to false if your SSL is terminated at a loadbalancer, for example
FORCE_HTTPS=true
ENABLE_UPDATES=true
DEBUG=cache,presenters,events,emails,mailer,utils,multiplayer,server,services
DEBUG=cache,presenters,events
# Third party signin credentials (at least one is required)
SLACK_KEY=get_a_key_from_slack
SLACK_SECRET=get_the_secret_of_above_key
# To configure Google auth, you'll need to create an OAuth Client ID at
# => https://console.cloud.google.com/apis/credentials
#
# When configuring the Client ID, add an Authorized redirect URI:
# https://<your Outline URL>/auth/google.callback
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
@@ -50,7 +45,6 @@ AWS_REGION=xx-xxxx-x
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
AWS_S3_UPLOAD_MAX_SIZE=26214400
AWS_S3_FORCE_PATH_STYLE=true
# uploaded s3 objects permission level, default is private
# set to "public-read" to allow public access
AWS_S3_ACL=private
@@ -65,5 +59,3 @@ SMTP_REPLY_EMAIL=
# Custom logo that displays on the authentication screen, scaled to height: 60px
# TEAM_LOGO=https://example.com/images/logo.png
DEFAULT_LANGUAGE=en_US
+1 -2
View File
@@ -4,8 +4,7 @@
"react-app",
"plugin:import/errors",
"plugin:import/warnings",
"plugin:flowtype/recommended",
"plugin:react-hooks/recommended"
"plugin:flowtype/recommended"
],
"plugins": [
"prettier",
-10
View File
@@ -1,10 +0,0 @@
# Set to true to add reviewers to pull requests
addReviewers: true
# A list of reviewers to be added to pull requests (GitHub user name)
reviewers:
- tommoor
# A list of keywords to be skipped the process that add reviewers if pull requests include it
skipKeywords:
- wip
-2
View File
@@ -1,5 +1,4 @@
dist
build
node_modules/*
server/scripts
.env
@@ -8,4 +7,3 @@ npm-debug.log
stats.json
.DS_Store
fakes3/*
.idea
+7 -13
View File
@@ -1,23 +1,17 @@
FROM node:14-alpine
FROM node:12-alpine
ENV PATH /opt/outline/node_modules/.bin:/opt/node_modules/.bin:$PATH
ENV NODE_PATH /opt/outline/node_modules:/opt/node_modules
ENV APP_PATH /opt/outline
RUN mkdir -p $APP_PATH
WORKDIR $APP_PATH
COPY . $APP_PATH
COPY package.json ./
COPY yarn.lock ./
RUN yarn install --pure-lockfile
RUN yarn build
RUN cp -r /opt/outline/node_modules /opt/node_modules
RUN yarn --pure-lockfile
COPY . .
RUN yarn build && \
yarn --production --ignore-scripts --prefer-offline && \
rm -rf shared && \
rm -rf app
ENV NODE_ENV production
CMD yarn start
EXPOSE 3000
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.51.0
Licensed Work: Outline 0.46.0
The Licensed Work is (c) 2020 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2023-12-13
Change Date: 2023-08-12
Change License: Apache License, Version 2.0
-6
View File
@@ -9,16 +9,10 @@ build:
test:
docker-compose up -d redis postgres s3
yarn sequelize db:drop --env=test
yarn sequelize db:create --env=test
yarn sequelize db:migrate --env=test
yarn test
watch:
docker-compose up -d redis postgres s3
yarn sequelize db:drop --env=test
yarn sequelize db:create --env=test
yarn sequelize db:migrate --env=test
yarn test:watch
destroy:
+1 -1
View File
@@ -1 +1 @@
web: node ./build/server/index.js
web: node index.js
+31 -40
View File
@@ -12,7 +12,6 @@
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield&amp;circle-token=c0c4c2f39990e277385d5c1ae96169c409eb887a"></a>
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat"></a>
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg"></a>
<a href="https://translate.getoutline.com/project/outline"><img src="https://badges.crowdin.net/outline/localized.svg"></a>
</p>
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com).
@@ -23,38 +22,13 @@ If you'd like to run your own copy of Outline or contribute to development then
Outline requires the following dependencies:
- [Node.js](https://nodejs.org/) >= 12
- [Yarn](https://yarnpkg.com)
- [Postgres](https://www.postgresql.org/download/) >=9.5
- [Redis](https://redis.io/) >= 4
- AWS S3 bucket or compatible API for file storage
- Node.js >= 12
- Postgres >=9.5
- Redis >= 4
- AWS S3 storage bucket for media and other attachments
- Slack or Google developer application for authentication
### Production
For a manual self-hosted production installation these are the suggested steps:
1. Clone this repo and install dependencies with `yarn install`
1. Build the source code with `yarn build`
1. Using the `.env.sample` as a reference, set the required variables in your production environment. The following are required as a minimum:
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
1. `DATABASE_URL` (run your own local copy of Postgres, or use a cloud service)
1. `REDIS_URL` (run your own local copy of Redis, or use a cloud service)
1. `URL` (the public facing URL of your installation)
1. `AWS_` (all of the keys beginning with AWS)
1. Migrate database schema with `yarn sequelize:migrate`. Production assumes an SSL connection, if
Postgres is on the same machine and is not SSL you can migrate with `yarn sequelize:migrate --env=production-ssl-disabled`.
1. Start the service with any daemon tools you prefer. Take PM2 for example, `NODE_ENV=production pm2 start ./build/server/index.js --name outline `
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
> Port number can be changed using the `PORT` environment variable
1. (Optional) You can add an `nginx` reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc.
### Development
In development you can quickly get an environment running using Docker by following these steps:
@@ -76,6 +50,32 @@ In development you can quickly get an environment running using Docker by follow
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
### Production
For a self-hosted production installation there is more flexibility, but these are the suggested steps:
1. Clone this repo and install dependencies with `yarn` or `npm install`
> Requires [Node.js](https://nodejs.org/) and [yarn](https://yarnpkg.com) installed
1. Build the web app with `yarn build:webpack` or `npm run build:webpack`
1. Using the `.env.sample` as a reference, set the required variables in your production environment. The following are required as a minimum:
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
1. `DATABASE_URL` (run your own local copy of Postgres, or use a cloud service)
1. `REDIS_URL` (run your own local copy of Redis, or use a cloud service)
1. `URL` (the public facing URL of your installation)
1. `AWS_` (all of the keys beginning with AWS)
1. Migrate database schema with `yarn sequelize:migrate` or `npm run sequelize:migrate `
1. Start the service with any daemon tools you prefer. Take PM2 for example, `NODE_ENV=production pm2 start index.js --name outline `
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
> Port number can be changed in the `.env` file
1. (Optional) You can add an `nginx` reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc.
## Development
### Server
@@ -140,16 +140,8 @@ To add new tests, write your tests with [Jest](https://facebook.github.io/jest/)
```shell
# To run all tests
make test
yarn test
# To run backend tests in watch mode
make watch
```
Once the test database is created with `make test` you may individually run
frontend and backend tests directly.
```shell
# To run backend tests
yarn test:server
@@ -165,7 +157,6 @@ However, before working on a pull request please let the core team know by creat
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
* [Translation](TRANSLATION.md) into other languages
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
* Performance improvements, both on server and frontend
* Developer happiness and documentation
-34
View File
@@ -1,34 +0,0 @@
# Translation
Outline is localized through community contributions. The text in Outline's user interface is in American English by default, we're very thankful for all help that the community provides bringing the app to different languages.
## Externalizing strings
Before a string can be translated, it must be externalized. This is the process where English strings in the source code are wrapped in a function that retrieves the translated string for the users language.
For externalization we use [react-i18next](https://react.i18next.com/), this provides the hooks [useTranslation](https://react.i18next.com/latest/usetranslation-hook) and the [Trans](https://react.i18next.com/latest/trans-component) component for wrapping English text.
PR's are accepted for wrapping English strings in the codebase that were not previously externalized.
## Translating strings
To manage the translation process we use [CrowdIn](https://translate.getoutline.com/), it keeps track of which strings in which languages still need translating, synchronizes with the codebase automatically, and provides a great editor interface.
You'll need to create a free account to use CrowdIn. Once you have joined, you can provide translations by following these steps:
1. Select the language for which you want to contribute (or vote for) a translation (below the language you can see the progress of the translation)
![CrowdIn UI](https://i.imgur.com/AkbDY60.png)
2. Please choose the translation.json file from your desired language
3. Once a file is selected, all the strings associated with the version are displayed on the left side. To display the untranslated strings first, select the filter icon next to the search bar and select “All, Untranslated First”.The red square next to an English string shows that a string has not been translated yet. To provide a translation, select a string on the left side, provide a translation in the target language in the text box in the right side (singular and plural) and press the save button. As soon as a translation has been provided by another user (green square next to string), you can also vote on a translation provided by another user. The translation with the most votes is used unless a different translation has been approved by a proof reader. ![Editor UI](https://i.imgur.com/pldZCRs.png)
## Proofreading
Once a translation has been provided, a proof reader can approve the translation and mark it for use in Outline.
If you are interested in becoming a proof reader, please contact one of the project managers in the Outline CrowdIn project or contact [@tommoor](https://github.com/tommoor). Similarly, if your language is not listed in the list of CrowdIn languages, please contact our project managers or [send us an email](https://www.getoutline.com/contact) so we can add your language.
## Release
Updated translations are automatically PR'd against the codebase by a bot and will be merged regularly so that new translations appear in the next release of Outline.
-18
View File
@@ -1,18 +0,0 @@
/* eslint-disable flowtype/require-valid-file-annotation */
export default class Queue {
name;
constructor(name) {
this.name = name;
}
process = (fn) => {
console.log(`Registered function ${this.name}`);
this.processFn = fn;
};
add = (data) => {
console.log(`Running ${this.name}`);
return this.processFn({ data });
};
}
-5
View File
@@ -92,11 +92,6 @@
"value": "26214400",
"required": false
},
"AWS_S3_FORCE_PATH_STYLE": {
"description": "Use path-style URL's for connecting to S3 instead of subdomain. This is useful for S3-compatible storage.",
"value": "true",
"required": false
},
"AWS_REGION": {
"value": "us-east-1",
"description": "Region in which the above S3 bucket exists",
+10 -27
View File
@@ -1,30 +1,18 @@
// @flow
import { observer } from "mobx-react";
import { observer, inject } 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 AuthStore from "stores/AuthStore";
import LoadingIndicator from "components/LoadingIndicator";
import useStores from "../hooks/useStores";
import env from "env";
type Props = {
children: React.Node,
auth: AuthStore,
children?: React.Node,
};
const Authenticated = ({ children }: Props) => {
const { auth } = useStores();
const { i18n } = useTranslation();
const language = auth.user && auth.user.language;
// Watching for language changes here as this is the earliest point we have
// the user available and means we can start loading translations faster
React.useEffect(() => {
if (i18n.language !== language) {
i18n.changeLanguage(language);
}
}, [i18n, language]);
const Authenticated = observer(({ auth, children }: Props) => {
if (auth.authenticated) {
const { user, team } = auth;
const { hostname } = window.location;
@@ -33,14 +21,9 @@ const Authenticated = ({ children }: Props) => {
return <LoadingIndicator />;
}
// If we're authenticated but viewing a domain that doesn't match the
// current team then kick the user to the teams correct domain.
if (team.domain) {
if (team.domain !== hostname) {
window.location.href = `${team.url}${window.location.pathname}`;
return <LoadingIndicator />;
}
} else if (
// If we're authenticated but viewing a subdomain that doesn't match the
// currently authenticated team then kick the user to the teams subdomain.
if (
env.SUBDOMAINS_ENABLED &&
team.subdomain &&
isCustomSubdomain(hostname) &&
@@ -55,6 +38,6 @@ const Authenticated = ({ children }: Props) => {
auth.logout(true);
return <Redirect to="/" />;
};
});
export default observer(Authenticated);
export default inject("auth")(Authenticated);
+7 -14
View File
@@ -4,7 +4,6 @@ import { observable } from "mobx";
import { observer } from "mobx-react";
import { EditIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import styled from "styled-components";
import User from "models/User";
import UserProfile from "scenes/UserProfile";
@@ -17,7 +16,6 @@ type Props = {
isEditing: boolean,
isCurrentUser: boolean,
lastViewedAt: string,
t: TFunction,
};
@observer
@@ -39,25 +37,20 @@ class AvatarWithPresence extends React.Component<Props> {
isPresent,
isEditing,
isCurrentUser,
t,
} = this.props;
const action = isPresent
? isEditing
? t("currently editing")
: t("currently viewing")
: t("viewed {{ timeAgo }} ago", {
timeAgo: distanceInWordsToNow(new Date(lastViewedAt)),
});
return (
<>
<Tooltip
tooltip={
<Centered>
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
<strong>{user.name}</strong> {isCurrentUser && "(You)"}
<br />
{action}
{isPresent
? isEditing
? "currently editing"
: "currently viewing"
: `viewed ${distanceInWordsToNow(new Date(lastViewedAt))} ago`}
</Centered>
}
placement="bottom"
@@ -90,4 +83,4 @@ const AvatarWrapper = styled.div`
transition: opacity 250ms ease-in-out;
`;
export default withTranslation()<AvatarWithPresence>(AvatarWithPresence);
export default AvatarWithPresence;
+3 -4
View File
@@ -4,10 +4,9 @@ import styled from "styled-components";
const Badge = styled.span`
margin-left: 10px;
padding: 2px 6px 3px;
background-color: ${({ yellow, primary, theme }) =>
yellow ? theme.yellow : primary ? theme.primary : theme.textTertiary};
color: ${({ primary, yellow, theme }) =>
primary ? theme.white : yellow ? theme.almostBlack : theme.background};
background-color: ${({ primary, theme }) =>
primary ? theme.primary : theme.textTertiary};
color: ${({ primary, theme }) => (primary ? theme.white : theme.background)};
border-radius: 4px;
font-size: 11px;
font-weight: 500;
+35 -85
View File
@@ -1,16 +1,13 @@
// @flow
import { observer } from "mobx-react";
import { observer, inject } from "mobx-react";
import {
ArchiveIcon,
EditIcon,
PadlockIcon,
GoToIcon,
MoreIcon,
PadlockIcon,
ShapesIcon,
TrashIcon,
EditIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -20,7 +17,6 @@ import Document from "models/Document";
import CollectionIcon from "components/CollectionIcon";
import Flex from "components/Flex";
import BreadcrumbMenu from "./BreadcrumbMenu";
import useStores from "hooks/useStores";
import { collectionUrl } from "utils/routeHelpers";
type Props = {
@@ -29,78 +25,11 @@ type Props = {
onlyText: boolean,
};
function Icon({ document }) {
const { t } = useTranslation();
const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
const collection = collections.get(document.collectionId);
if (!collection) return <div />;
if (document.isDeleted) {
return (
<>
<CollectionName to="/trash">
<TrashIcon color="currentColor" />
&nbsp;
<span>{t("Trash")}</span>
</CollectionName>
<Slash />
</>
);
}
if (document.isArchived) {
return (
<>
<CollectionName to="/archive">
<ArchiveIcon color="currentColor" />
&nbsp;
<span>{t("Archive")}</span>
</CollectionName>
<Slash />
</>
);
}
if (document.isDraft) {
return (
<>
<CollectionName to="/drafts">
<EditIcon color="currentColor" />
&nbsp;
<span>{t("Drafts")}</span>
</CollectionName>
<Slash />
</>
);
}
if (document.isTemplate) {
return (
<>
<CollectionName to="/templates">
<ShapesIcon color="currentColor" />
&nbsp;
<span>{t("Templates")}</span>
</CollectionName>
<Slash />
</>
);
}
return null;
}
const Breadcrumb = ({ document, onlyText }: Props) => {
const { collections } = useStores();
const { t } = useTranslation();
let collection = collections.get(document.collectionId);
if (!collection) {
if (!document.deletedAt) return <div />;
collection = {
id: document.collectionId,
name: t("Deleted Collection"),
color: "currentColor",
};
}
const path = collection.pathToDocument
? collection.pathToDocument(document.id).slice(0, -1)
: [];
const path = collection.pathToDocument(document).slice(0, -1);
if (onlyText === true) {
return (
@@ -121,13 +50,34 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
);
}
const isTemplate = document.isTemplate;
const isDraft = !document.publishedAt && !isTemplate;
const isNestedDocument = path.length > 1;
const lastPath = path.length ? path[path.length - 1] : undefined;
const menuPath = isNestedDocument ? path.slice(0, -1) : [];
return (
<Wrapper justify="flex-start" align="center">
<Icon document={document} />
{isTemplate && (
<>
<CollectionName to="/templates">
<ShapesIcon color="currentColor" />
&nbsp;
<span>Templates</span>
</CollectionName>
<Slash />
</>
)}
{isDraft && (
<>
<CollectionName to="/drafts">
<EditIcon color="currentColor" />
&nbsp;
<span>Drafts</span>
</CollectionName>
<Slash />
</>
)}
<CollectionName to={collectionUrl(collection.id)}>
<CollectionIcon collection={collection} expanded />
&nbsp;
@@ -148,7 +98,7 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
)}
</Wrapper>
);
};
});
const Wrapper = styled(Flex)`
display: none;
@@ -177,12 +127,12 @@ export const Slash = styled(GoToIcon)`
const Overflow = styled(MoreIcon)`
flex-shrink: 0;
opacity: 0.25;
transition: opacity 100ms ease-in-out;
fill: ${(props) => props.theme.divider};
&:active,
&:hover {
fill: ${(props) => props.theme.text};
&:hover,
&:active {
opacity: 1;
}
`;
@@ -209,4 +159,4 @@ const CollectionName = styled(Link)`
overflow: hidden;
`;
export default observer(Breadcrumb);
export default inject("collections")(Breadcrumb);
+16 -13
View File
@@ -1,22 +1,25 @@
// @flow
import * as React from "react";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import { Link } from "react-router-dom";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
type Props = {
label: React.Node,
path: Array<any>,
};
export default function BreadcrumbMenu({ label, path }: Props) {
return (
<DropdownMenu label={label} position="center">
<DropdownMenuItems
items={path.map((item) => ({
title: item.title,
to: item.url,
}))}
/>
</DropdownMenu>
);
export default class BreadcrumbMenu extends React.Component<Props> {
render() {
const { path } = this.props;
return (
<DropdownMenu label={this.props.label} position="center">
{path.map((item) => (
<DropdownMenuItem as={Link} to={item.url} key={item.id}>
{item.title}
</DropdownMenuItem>
))}
</DropdownMenu>
);
}
}
+22 -1
View File
@@ -1,6 +1,6 @@
// @flow
import { ExpandedIcon } from "outline-icons";
import { darken } from "polished";
import { darken, lighten } from "polished";
import * as React from "react";
import styled from "styled-components";
@@ -19,6 +19,7 @@ const RealButton = styled.button`
height: 32px;
text-decoration: none;
flex-shrink: 0;
outline: none;
cursor: pointer;
user-select: none;
@@ -35,6 +36,13 @@ const RealButton = styled.button`
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
}
&:focus {
transition-duration: 0.05s;
box-shadow: ${(props) => lighten(0.4, props.theme.buttonBackground)} 0px 0px
0px 3px;
outline: none;
}
&:disabled {
cursor: default;
pointer-events: none;
@@ -62,6 +70,13 @@ const RealButton = styled.button`
border: 1px solid ${props.theme.buttonNeutralBorder};
}
&:focus {
transition-duration: 0.05s;
border: 1px solid ${lighten(0.4, props.theme.buttonBackground)};
box-shadow: ${lighten(0.4, props.theme.buttonBackground)} 0px 0px
0px 2px;
}
&:disabled {
color: ${props.theme.textTertiary};
}
@@ -74,6 +89,12 @@ const RealButton = styled.button`
&:hover {
background: ${darken(0.05, props.theme.danger)};
}
&:focus {
transition-duration: 0.05s;
box-shadow: ${lighten(0.4, props.theme.danger)} 0px 0px
0px 3px;
}
`};
`;
+1 -3
View File
@@ -20,9 +20,7 @@ type Props = {
@observer
class Collaborators extends React.Component<Props> {
componentDidMount() {
if (!this.props.document.isDeleted) {
this.props.views.fetchPage({ documentId: this.props.document.id });
}
this.props.views.fetchPage({ documentId: this.props.document.id });
}
render() {
+1 -1
View File
@@ -18,7 +18,7 @@ function ResolvedCollectionIcon({ collection, expanded, size, ui }: Props) {
// If the chosen icon color is very dark then we invert it in dark mode
// otherwise it will be impossible to see against the dark background.
const color =
ui.resolvedTheme === "dark" && collection.color !== "currentColor"
ui.resolvedTheme === "dark"
? getLuminance(collection.color) > 0.12
? collection.color
: "currentColor"
+1 -1
View File
@@ -14,7 +14,7 @@ export default function DelayedMount({ delay = 250, children }: Props) {
return () => {
clearTimeout(timeout);
};
}, [delay]);
}, []);
if (!isShowing) {
return null;
@@ -1,23 +1,20 @@
// @flow
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { action, observable } from "mobx";
import { inject, observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import { observable, action } from "mobx";
import { observer, inject } from "mobx-react";
import * as React from "react";
import { type Match, Redirect, type RouterHistory } from "react-router-dom";
import { type RouterHistory, type Match } 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";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {
match: Match,
@@ -32,7 +29,6 @@ class DocumentHistory extends React.Component<Props> {
@observable isFetching: boolean = false;
@observable offset: number = 0;
@observable allowLoadMore: boolean = true;
@observable redirectTo: ?string;
async componentDidMount() {
await this.loadMoreResults();
@@ -90,34 +86,15 @@ class DocumentHistory extends React.Component<Props> {
return this.props.revisions.getDocumentRevisions(document.id);
}
onCloseHistory = () => {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
this.redirectTo = documentUrl(document);
};
render() {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
const showLoading = (!this.isLoaded && this.isFetching) || !document;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
return (
<Sidebar>
<Wrapper column>
<Header>
<Title>History</Title>
<Button
icon={<CloseIcon />}
onClick={this.onCloseHistory}
borderOnHover
neutral
/>
</Header>
{showLoading ? (
<Loading>
<ListPlaceholder count={5} />
@@ -163,37 +140,10 @@ const Wrapper = styled(Flex)`
`;
const Sidebar = styled(Flex)`
display: none;
background: ${(props) => props.theme.background};
min-width: ${(props) => props.theme.sidebarWidth};
border-left: 1px solid ${(props) => props.theme.divider};
z-index: 1;
${breakpoint("tablet")`
display: flex;
`};
`;
const Title = styled(Flex)`
font-size: 16px;
font-weight: 600;
text-align: center;
align-items: center;
justify-content: flex-start;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: 0;
flex-grow: 1;
`;
const Header = styled(Flex)`
align-items: center;
position: relative;
padding: 12px;
border-bottom: 1px solid ${(props) => props.theme.divider};
color: ${(props) => props.theme.text};
flex-shrink: 0;
`;
export default inject("documents", "revisions")(DocumentHistory);
@@ -11,12 +11,11 @@ import Avatar from "components/Avatar";
import Flex from "components/Flex";
import Time from "components/Time";
import RevisionMenu from "menus/RevisionMenu";
import { type Theme } from "types";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {
theme: Theme,
theme: Object,
showMenu: boolean,
selected: boolean,
document: Document,
@@ -37,7 +36,7 @@ class RevisionListItem extends React.Component<Props> {
{revision.createdBy.name}
</Author>
<Meta>
<Time dateTime={revision.createdAt} tooltipDelay={250}>
<Time dateTime={revision.createdAt}>
{format(revision.createdAt, "MMMM Do, YYYY h:mm a")}
</Time>
</Meta>
+18 -38
View File
@@ -1,14 +1,14 @@
// @flow
import { observer } from "mobx-react";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import AuthStore from "stores/AuthStore";
import CollectionsStore from "stores/CollectionsStore";
import Document from "models/Document";
import Breadcrumb from "components/Breadcrumb";
import Flex from "components/Flex";
import Time from "components/Time";
import useStores from "hooks/useStores";
const Container = styled(Flex)`
color: ${(props) => props.theme.textTertiary};
@@ -18,30 +18,31 @@ const Container = styled(Flex)`
`;
const Modified = styled.span`
color: ${(props) => props.theme.textTertiary};
color: ${(props) =>
props.highlight ? props.theme.text : props.theme.textTertiary};
font-weight: ${(props) => (props.highlight ? "600" : "400")};
`;
type Props = {
collections: CollectionsStore,
auth: AuthStore,
showCollection?: boolean,
showPublished?: boolean,
showLastViewed?: boolean,
document: Document,
children: React.Node,
to?: string,
};
function DocumentMeta({
auth,
collections,
showPublished,
showCollection,
showLastViewed,
document,
children,
to,
...rest
}: Props) {
const { t } = useTranslation();
const { collections, auth } = useStores();
const {
modifiedSinceViewed,
updatedAt,
@@ -51,7 +52,6 @@ function DocumentMeta({
archivedAt,
deletedAt,
isDraft,
lastViewedAt,
} = document;
// Prevent meta information from displaying if updatedBy is not available.
@@ -65,37 +65,37 @@ function DocumentMeta({
if (deletedAt) {
content = (
<span>
{t("deleted")} <Time dateTime={deletedAt} addSuffix />
deleted <Time dateTime={deletedAt} /> ago
</span>
);
} else if (archivedAt) {
content = (
<span>
{t("archived")} <Time dateTime={archivedAt} addSuffix />
archived <Time dateTime={archivedAt} /> ago
</span>
);
} else if (createdAt === updatedAt) {
content = (
<span>
{t("created")} <Time dateTime={updatedAt} addSuffix />
created <Time dateTime={updatedAt} /> ago
</span>
);
} else if (publishedAt && (publishedAt === updatedAt || showPublished)) {
content = (
<span>
{t("published")} <Time dateTime={publishedAt} addSuffix />
published <Time dateTime={publishedAt} /> ago
</span>
);
} else if (isDraft) {
content = (
<span>
{t("saved")} <Time dateTime={updatedAt} addSuffix />
saved <Time dateTime={updatedAt} /> ago
</span>
);
} else {
content = (
<Modified highlight={modifiedSinceViewed}>
{t("updated")} <Time dateTime={updatedAt} addSuffix />
updated <Time dateTime={updatedAt} /> ago
</Modified>
);
}
@@ -103,41 +103,21 @@ function DocumentMeta({
const collection = collections.get(document.collectionId);
const updatedByMe = auth.user && auth.user.id === updatedBy.id;
const timeSinceNow = () => {
if (isDraft || !showLastViewed) {
return null;
}
if (!lastViewedAt) {
return (
<>
&nbsp;<Modified highlight>{t("Never viewed")}</Modified>
</>
);
}
return (
<span>
&nbsp;{t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
</span>
);
};
return (
<Container align="center" {...rest}>
{updatedByMe ? t("You") : updatedBy.name}&nbsp;
{updatedByMe ? "You" : updatedBy.name}&nbsp;
{to ? <Link to={to}>{content}</Link> : content}
{showCollection && collection && (
<span>
&nbsp;{t("in")}&nbsp;
&nbsp;in&nbsp;
<strong>
<Breadcrumb document={document} onlyText />
</strong>
</span>
)}
&nbsp;{timeSinceNow()}
{children}
</Container>
);
}
export default observer(DocumentMeta);
export default inject("collections", "auth")(observer(DocumentMeta));
+9 -13
View File
@@ -1,31 +1,27 @@
// @flow
import { useObserver } from "mobx-react";
import { inject } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import ViewsStore from "stores/ViewsStore";
import Document from "models/Document";
import DocumentMeta from "components/DocumentMeta";
import useStores from "../hooks/useStores";
type Props = {|
views: ViewsStore,
document: Document,
isDraft: boolean,
to?: string,
|};
function DocumentMetaWithViews({ to, isDraft, document }: Props) {
const { views } = useStores();
const documentViews = useObserver(() => views.inDocument(document.id));
const totalViewers = documentViews.length;
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
function DocumentMetaWithViews({ views, to, isDraft, document }: Props) {
const totalViews = views.countForDocument(document.id);
return (
<Meta document={document} to={to}>
{totalViewers && !isDraft ? (
{totalViews && !isDraft ? (
<>
&nbsp;&middot; Viewed by{" "}
{onlyYou
? "only you"
: `${totalViewers} ${totalViewers === 1 ? "person" : "people"}`}
&nbsp;&middot; Viewed{" "}
{totalViews === 1 ? "once" : `${totalViews} times`}
</>
) : null}
</Meta>
@@ -49,4 +45,4 @@ const Meta = styled(DocumentMeta)`
}
`;
export default DocumentMetaWithViews;
export default inject("views")(DocumentMetaWithViews);
@@ -1,16 +1,13 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import { StarredIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { Link, Redirect } from "react-router-dom";
import { Link, withRouter, type RouterHistory } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import Document from "models/Document";
import Badge from "components/Badge";
import Button from "components/Button";
import DocumentMeta from "components/DocumentMeta";
import EventBoundary from "components/EventBoundary";
import Flex from "components/Flex";
import Highlight from "components/Highlight";
import Tooltip from "components/Tooltip";
@@ -18,6 +15,7 @@ import DocumentMenu from "menus/DocumentMenu";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {
history: RouterHistory,
document: Document,
highlight?: ?string,
context?: ?string,
@@ -26,15 +24,12 @@ type Props = {
showPin?: boolean,
showDraft?: boolean,
showTemplate?: boolean,
t: TFunction,
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@observer
class DocumentPreview extends React.Component<Props> {
@observable redirectTo: ?string;
handleStar = (ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
@@ -53,15 +48,17 @@ class DocumentPreview extends React.Component<Props> {
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
};
handleNewFromTemplate = (event: SyntheticEvent<>) => {
handleNewFromTemplate = (event) => {
event.preventDefault();
event.stopPropagation();
const { document } = this.props;
this.redirectTo = newDocumentUrl(document.collectionId, {
templateId: document.id,
});
this.props.history.push(
newDocumentUrl(document.collectionId, {
templateId: document.id,
})
);
};
render() {
@@ -74,13 +71,8 @@ class DocumentPreview extends React.Component<Props> {
showTemplate,
highlight,
context,
t,
} = this.props;
if (this.redirectTo) {
return <Redirect to={this.redirectTo} push />;
}
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
@@ -94,7 +86,6 @@ class DocumentPreview extends React.Component<Props> {
>
<Heading>
<Title text={document.titleWithDefault} highlight={highlight} />
{document.isNew && <Badge yellow>{t("New")}</Badge>}
{!document.isDraft &&
!document.isArchived &&
!document.isTemplate && (
@@ -107,16 +98,12 @@ class DocumentPreview extends React.Component<Props> {
</Actions>
)}
{document.isDraft && showDraft && (
<Tooltip
tooltip={t("Only visible to you")}
delay={500}
placement="top"
>
<Badge>{t("Draft")}</Badge>
<Tooltip tooltip="Only visible to you" delay={500} placement="top">
<Badge>Draft</Badge>
</Tooltip>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
<Badge primary>Template</Badge>
)}
<SecondaryActions>
{document.isTemplate &&
@@ -127,13 +114,11 @@ class DocumentPreview extends React.Component<Props> {
icon={<PlusIcon />}
neutral
>
{t("New doc")}
New doc
</Button>
)}
&nbsp;
<EventBoundary>
<DocumentMenu document={document} showPin={showPin} />
</EventBoundary>
<DocumentMenu document={document} showPin={showPin} />
</SecondaryActions>
</Heading>
@@ -148,7 +133,6 @@ class DocumentPreview extends React.Component<Props> {
document={document}
showCollection={showCollection}
showPublished={showPublished}
showLastViewed
/>
</DocumentLink>
);
@@ -197,6 +181,7 @@ const DocumentLink = styled(Link)`
&:active,
&:focus {
background: ${(props) => props.theme.listItemHoverBackground};
outline: none;
${SecondaryActions} {
opacity: 1;
@@ -244,4 +229,4 @@ const ResultContext = styled(Highlight)`
margin-bottom: 0.25em;
`;
export default withTranslation()<DocumentPreview>(DocumentPreview);
export default withRouter(DocumentPreview);
+37 -41
View File
@@ -5,10 +5,10 @@ import { observer, inject } from "mobx-react";
import * as React from "react";
import Dropzone from "react-dropzone";
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
import styled, { css } from "styled-components";
import { createGlobalStyle } from "styled-components";
import DocumentsStore from "stores/DocumentsStore";
import UiStore from "stores/UiStore";
import LoadingIndicator from "components/LoadingIndicator";
import importFile from "utils/importFile";
const EMPTY_OBJECT = {};
let importingLock = false;
@@ -17,7 +17,8 @@ type Props = {
children: React.Node,
collectionId: string,
documentId?: string,
ui: UiStore,
activeClassName?: string,
rejectClassName?: string,
documents: DocumentsStore,
disabled: boolean,
location: Object,
@@ -26,6 +27,18 @@ type Props = {
staticContext: Object,
};
export const GlobalStyles = createGlobalStyle`
.activeDropZone {
border-radius: 4px;
background: ${(props) => props.theme.slateDark};
svg { fill: ${(props) => props.theme.white}; }
}
.activeDropZone a {
color: ${(props) => props.theme.white} !important;
}
`;
@observer
class DropToImport extends React.Component<Props> {
@observable isImporting: boolean = false;
@@ -48,19 +61,17 @@ class DropToImport extends React.Component<Props> {
}
for (const file of files) {
const doc = await this.props.documents.import(
const doc = await importFile({
documents: this.props.documents,
file,
documentId,
collectionId,
{ publish: true }
);
});
if (redirect) {
this.props.history.push(doc.url);
}
}
} catch (err) {
this.props.ui.showToast(`Could not import file. ${err.message}`);
} finally {
this.isImporting = false;
importingLock = false;
@@ -68,50 +79,35 @@ class DropToImport extends React.Component<Props> {
};
render() {
const { documents } = this.props;
const {
documentId,
collectionId,
documents,
disabled,
location,
match,
history,
staticContext,
...rest
} = this.props;
if (this.props.disabled) return this.props.children;
return (
<Dropzone
accept={documents.importFileTypes.join(", ")}
accept="text/markdown, text/plain"
onDropAccepted={this.onDropAccepted}
style={EMPTY_OBJECT}
noClick
disableClick
disablePreview
multiple
{...rest}
>
{({
getRootProps,
getInputProps,
isDragActive,
isDragAccept,
isDragReject,
}) => (
<DropzoneContainer {...getRootProps()} {...{ isDragActive }}>
<input {...getInputProps()} />
{this.isImporting && <LoadingIndicator />}
{this.props.children}
</DropzoneContainer>
)}
{this.isImporting && <LoadingIndicator />}
{this.props.children}
</Dropzone>
);
}
}
const DropzoneContainer = styled("div")`
border-radius: 4px;
${({ isDragActive, theme }) =>
isDragActive &&
css`
background: ${theme.slateDark};
a {
color: ${theme.white} !important;
}
svg {
fill: ${theme.white};
}
`}
`;
export default inject("documents", "ui")(withRouter(DropToImport));
export default inject("documents")(withRouter(DropToImport));
+4 -7
View File
@@ -5,7 +5,6 @@ import { observer } from "mobx-react";
import { MoreIcon } from "outline-icons";
import { rgba } from "polished";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { PortalWithState } from "react-portal";
import styled from "styled-components";
import { fadeAndScaleIn } from "shared/styles/animations";
@@ -19,7 +18,7 @@ type Children =
| React.Node
| ((options: { closePortal: () => void }) => React.Node);
type Props = {|
type Props = {
label?: React.Node,
onOpen?: () => void,
onClose?: () => void,
@@ -28,8 +27,7 @@ type Props = {|
hover?: boolean,
style?: Object,
position?: "left" | "right" | "center",
t: TFunction,
|};
};
@observer
class DropdownMenu extends React.Component<Props> {
@@ -152,7 +150,7 @@ class DropdownMenu extends React.Component<Props> {
};
render() {
const { className, hover, label, children, t } = this.props;
const { className, hover, label, children } = this.props;
return (
<div className={className}>
@@ -179,7 +177,6 @@ class DropdownMenu extends React.Component<Props> {
{label || (
<NudeButton
id={`${this.id}button`}
aria-label={t("More options")}
aria-haspopup="true"
aria-expanded={isOpen ? "true" : "false"}
aria-controls={this.id}
@@ -286,4 +283,4 @@ export const Header = styled.h3`
margin: 1em 12px 0.5em;
`;
export default withTranslation()<DropdownMenu>(DropdownMenu);
export default DropdownMenu;
@@ -80,6 +80,7 @@ const MenuItem = styled.a`
&:focus {
color: ${props.theme.white};
background: ${props.theme.primary};
outline: none;
}
`};
`;
@@ -1,128 +0,0 @@
// @flow
import * as React from "react";
import { Link } from "react-router-dom";
import DropdownMenu from "./DropdownMenu";
import DropdownMenuItem from "./DropdownMenuItem";
type MenuItem =
| {|
title: React.Node,
to: string,
visible?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
onClick: (event: SyntheticEvent<>) => void | Promise<void>,
visible?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
href: string,
visible?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
visible?: boolean,
disabled?: boolean,
style?: Object,
hover?: boolean,
items: MenuItem[],
|}
| {|
type: "separator",
visible?: boolean,
|}
| {|
type: "heading",
visible?: boolean,
title: React.Node,
|};
type Props = {|
items: MenuItem[],
|};
export default function DropdownMenuItems({ items }: Props): React.Node {
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unneccessary separators
filtered = filtered.reduce((acc, item, index) => {
// trim separators from start / end
if (item.type === "separator" && index === 0) return acc;
if (item.type === "separator" && index === filtered.length - 1) return acc;
// trim double separators looking ahead / behind
const prev = filtered[index - 1];
if (prev && prev.type === "separator" && item.type === "separator")
return acc;
// otherwise, continue
return [...acc, item];
}, []);
return filtered.map((item, index) => {
if (item.to) {
return (
<DropdownMenuItem
as={Link}
to={item.to}
key={index}
disabled={item.disabled}
>
{item.title}
</DropdownMenuItem>
);
}
if (item.href) {
return (
<DropdownMenuItem
href={item.href}
key={index}
disabled={item.disabled}
target="_blank"
>
{item.title}
</DropdownMenuItem>
);
}
if (item.onClick) {
return (
<DropdownMenuItem
onClick={item.onClick}
disabled={item.disabled}
key={index}
>
{item.title}
</DropdownMenuItem>
);
}
if (item.items) {
return (
<DropdownMenu
style={item.style}
label={
<DropdownMenuItem disabled={item.disabled}>
{item.title}
</DropdownMenuItem>
}
hover={item.hover}
key={index}
>
<DropdownMenuItems items={item.items} />
</DropdownMenu>
);
}
if (item.type === "separator") {
return <hr key={index} />;
}
return null;
});
}
+46 -142
View File
@@ -1,7 +1,6 @@
// @flow
import { lighten } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import UiStore from "stores/UiStore";
@@ -29,129 +28,60 @@ type PropsWithRef = Props & {
history: RouterHistory,
};
function Editor(props: PropsWithRef) {
const { id, ui, history } = props;
const { t } = useTranslation();
class Editor extends React.Component<PropsWithRef> {
onUploadImage = async (file: File) => {
const result = await uploadFile(file, { documentId: this.props.id });
return result.url;
};
const onUploadImage = React.useCallback(
async (file: File) => {
const result = await uploadFile(file, { documentId: id });
return result.url;
},
[id]
);
onClickLink = (href: string) => {
// on page hash
if (href[0] === "#") {
window.location.href = href;
return;
}
const onClickLink = React.useCallback(
(href: string, event: MouseEvent) => {
// on page hash
if (href[0] === "#") {
window.location.href = href;
return;
}
if (isInternalUrl(href)) {
// relative
let navigateTo = href;
if (isInternalUrl(href) && !event.metaKey && !event.shiftKey) {
// relative
let navigateTo = href;
// probably absolute
if (href[0] !== "/") {
try {
const url = new URL(href);
navigateTo = url.pathname + url.hash;
} catch (err) {
navigateTo = href;
}
// probably absolute
if (href[0] !== "/") {
try {
const url = new URL(href);
navigateTo = url.pathname + url.hash;
} catch (err) {
navigateTo = href;
}
history.push(navigateTo);
} else if (href) {
window.open(href, "_blank");
}
},
[history]
);
const onShowToast = React.useCallback(
(message: string) => {
if (ui) {
ui.showToast(message);
}
},
[ui]
);
this.props.history.push(navigateTo);
} else {
window.open(href, "_blank");
}
};
const dictionary = React.useMemo(() => {
return {
addColumnAfter: t("Insert column after"),
addColumnBefore: t("Insert column before"),
addRowAfter: t("Insert row after"),
addRowBefore: t("Insert row before"),
alignCenter: t("Align center"),
alignLeft: t("Align left"),
alignRight: t("Align right"),
bulletList: t("Bulleted list"),
checkboxList: t("Todo list"),
codeBlock: t("Code block"),
codeCopied: t("Copied to clipboard"),
codeInline: t("Code"),
createLink: t("Create link"),
createLinkError: t("Sorry, an error occurred creating the link"),
createNewDoc: t("Create a new doc"),
deleteColumn: t("Delete column"),
deleteRow: t("Delete row"),
deleteTable: t("Delete table"),
em: t("Italic"),
embedInvalidLink: t("Sorry, that link wont work for this embed type"),
findOrCreateDoc: `${t("Find or create a doc")}`,
h1: t("Big heading"),
h2: t("Medium heading"),
h3: t("Small heading"),
heading: t("Heading"),
hr: t("Divider"),
image: t("Image"),
imageUploadError: t("Sorry, an error occurred uploading the image"),
info: t("Info"),
infoNotice: t("Info notice"),
link: t("Link"),
linkCopied: t("Link copied to clipboard"),
mark: t("Highlight"),
newLineEmpty: `${t("Type '/' to insert")}`,
newLineWithSlash: `${t("Keep typing to filter")}`,
noResults: t("No results"),
openLink: t("Open link"),
orderedList: t("Ordered list"),
pasteLink: `${t("Paste a link")}`,
pasteLinkWithTitle: (service: string) =>
t("Paste a {{service}} link…", { service }),
placeholder: t("Placeholder"),
quote: t("Quote"),
removeLink: t("Remove link"),
searchOrPasteLink: `${t("Search or paste a link")}`,
strikethrough: t("Strikethrough"),
strong: t("Bold"),
subheading: t("Subheading"),
table: t("Table"),
tip: t("Tip"),
tipNotice: t("Tip notice"),
warning: t("Warning"),
warningNotice: t("Warning notice"),
};
}, [t]);
onShowToast = (message: string) => {
if (this.props.ui) {
this.props.ui.showToast(message);
}
};
return (
<ErrorBoundary reloadOnChunkMissing>
<StyledEditor
ref={props.forwardedRef}
uploadImage={onUploadImage}
onClickLink={onClickLink}
onShowToast={onShowToast}
embeds={props.disableEmbeds ? EMPTY_ARRAY : embeds}
tooltip={EditorTooltip}
dictionary={dictionary}
{...props}
/>
</ErrorBoundary>
);
render() {
return (
<ErrorBoundary reloadOnChunkMissing>
<StyledEditor
ref={this.props.forwardedRef}
uploadImage={this.onUploadImage}
onClickLink={this.onClickLink}
onShowToast={this.onShowToast}
embeds={this.props.disableEmbeds ? EMPTY_ARRAY : embeds}
tooltip={EditorTooltip}
{...this.props}
/>
</ErrorBoundary>
);
}
}
const StyledEditor = styled(RichMarkdownEditor)`
@@ -162,37 +92,11 @@ const StyledEditor = styled(RichMarkdownEditor)`
transition: ${(props) => props.theme.backgroundTransition};
}
& * {
box-sizing: content-box;
}
.notice-block.tip,
.notice-block.warning {
font-weight: 500;
}
.heading-name {
pointer-events: none;
}
/* pseudo element allows us to add spacing for fixed header */
/* ref: https://stackoverflow.com/a/28824157 */
.heading-name::before {
content: "";
display: ${(props) => (props.readOnly ? "block" : "none")};
height: 72px;
margin: -72px 0 0;
}
.heading-name:first-child {
& + h1,
& + h2,
& + h3,
& + h4 {
margin-top: 0;
}
}
p {
a {
color: ${(props) => props.theme.text};
+1 -20
View File
@@ -55,26 +55,7 @@ class ErrorBoundary extends React.Component<Props> {
render() {
if (this.error) {
const error = this.error;
const isReported = !!window.Sentry && env.DEPLOYMENT === "hosted";
const isChunkError = this.error.message.match(/chunk/);
if (isChunkError) {
return (
<CenteredContent>
<PageTitle title="Module failed to load" />
<h1>Loading Failed</h1>
<HelpText>
Sorry, part of the application failed to load. This may be because
it was updated since you opened the tab or because of a failed
network request. Please try reloading.
</HelpText>
<p>
<Button onClick={this.handleReload}>Reload</Button>
</p>
</CenteredContent>
);
}
return (
<CenteredContent>
@@ -85,7 +66,7 @@ class ErrorBoundary extends React.Component<Props> {
{isReported && " our engineers have been notified"}. Please try
reloading the page, it may have been a temporary glitch.
</HelpText>
{this.showDetails && <Pre>{error.toString()}</Pre>}
{this.showDetails && <Pre>{this.error.toString()}</Pre>}
<p>
<Button onClick={this.handleReload}>Reload</Button>{" "}
{this.showDetails ? (
-16
View File
@@ -1,16 +0,0 @@
// @flow
import * as React from "react";
type Props = {
children: React.Node,
};
export default function EventBoundary({ children }: Props) {
const handleClick = React.useCallback((event: SyntheticEvent<>) => {
event.preventDefault();
event.stopPropagation();
}, []);
return <span onClick={handleClick}>{children}</span>;
}
+3 -6
View File
@@ -18,7 +18,6 @@ function Highlight({
...rest
}: Props) {
let regex;
let index = 0;
if (highlight instanceof RegExp) {
regex = highlight;
} else {
@@ -30,10 +29,8 @@ function Highlight({
return (
<span {...rest}>
{highlight
? replace(text, regex, (tag) => (
<Mark key={index++}>
{processResult ? processResult(tag) : tag}
</Mark>
? replace(text, regex, (tag, index) => (
<Mark key={index}>{processResult ? processResult(tag) : tag}</Mark>
))
: text}
</span>
@@ -41,7 +38,7 @@ function Highlight({
}
const Mark = styled.mark`
background: ${(props) => props.theme.searchHighlight};
background: ${(props) => props.theme.yellow};
border-radius: 2px;
padding: 0 4px;
`;
+8 -13
View File
@@ -5,7 +5,7 @@ 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 { parseDocumentSlugFromUrl } from "shared/utils/parseDocumentSlug";
import DocumentsStore from "stores/DocumentsStore";
import HoverPreviewDocument from "components/HoverPreviewDocument";
import isInternalUrl from "utils/isInternalUrl";
@@ -20,8 +20,13 @@ type Props = {
onClose: () => void,
};
function HoverPreviewInternal({ node, documents, onClose, event }: Props) {
const slug = parseDocumentSlug(node.href);
function HoverPreview({ node, documents, onClose, event }: Props) {
// previews only work for internal doc links for now
if (!isInternalUrl(node.href)) {
return null;
}
const slug = parseDocumentSlugFromUrl(node.href);
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef();
@@ -126,15 +131,6 @@ function HoverPreviewInternal({ node, documents, onClose, event }: Props) {
);
}
function HoverPreview({ node, ...rest }: Props) {
// previews only work for internal doc links for now
if (!isInternalUrl(node.href)) {
return null;
}
return <HoverPreviewInternal {...rest} node={node} />;
}
const Animate = styled.div`
animation: ${fadeAndSlideIn} 150ms ease;
@@ -215,7 +211,6 @@ const Pointer = styled.div`
height: 22px;
position: absolute;
transform: translateX(-50%);
pointer-events: none;
&:before,
&:after {
+7 -7
View File
@@ -1,21 +1,21 @@
// @flow
import { observer } from "mobx-react";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
import { parseDocumentSlugFromUrl } from "shared/utils/parseDocumentSlug";
import DocumentsStore from "stores/DocumentsStore";
import DocumentMetaWithViews from "components/DocumentMetaWithViews";
import Editor from "components/Editor";
import useStores from "hooks/useStores";
type Props = {
url: string,
documents: DocumentsStore,
children: (React.Node) => React.Node,
};
function HoverPreviewDocument({ url, children }: Props) {
const { documents } = useStores();
const slug = parseDocumentSlug(url);
function HoverPreviewDocument({ url, documents, children }: Props) {
const slug = parseDocumentSlugFromUrl(url);
documents.prefetchDocument(slug, {
prefetch: true,
@@ -50,4 +50,4 @@ const Heading = styled.h2`
color: ${(props) => props.theme.text};
`;
export default observer(HoverPreviewDocument);
export default inject("documents")(observer(HoverPreviewDocument));
+3 -6
View File
@@ -22,7 +22,6 @@ import {
VehicleIcon,
} from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import styled from "styled-components";
import { DropdownMenu } from "components/DropdownMenu";
import Flex from "components/Flex";
@@ -127,7 +126,6 @@ type Props = {
onChange: (color: string, icon: string) => void,
icon: string,
color: string,
t: TFunction,
};
function preventEventBubble(event) {
@@ -169,13 +167,12 @@ class IconPicker extends React.Component<Props> {
};
render() {
const { t } = this.props;
const Component = icons[this.props.icon || "collection"].component;
return (
<Wrapper ref={(ref) => (this.node = ref)}>
<label>
<LabelText>{t("Icon")}</LabelText>
<LabelText>Icon</LabelText>
</label>
<DropdownMenu
onOpen={this.handleOpen}
@@ -200,7 +197,7 @@ class IconPicker extends React.Component<Props> {
})}
</Icons>
<Flex onClick={preventEventBubble}>
<React.Suspense fallback={<Loading>{t("Loading")}</Loading>}>
<React.Suspense fallback={<Loading>Loading</Loading>}>
<ColorPicker
color={this.props.color}
onChange={(color) =>
@@ -249,4 +246,4 @@ const Wrapper = styled("div")`
position: relative;
`;
export default withTranslation()<IconPicker>(IconPicker);
export default IconPicker;
+4
View File
@@ -33,6 +33,10 @@ const RealInput = styled.input`
&::placeholder {
color: ${(props) => props.theme.placeholder};
}
&::-webkit-search-cancel-button {
-webkit-appearance: searchfield-cancel-button;
}
`;
const Wrapper = styled.div`
+6 -16
View File
@@ -3,21 +3,17 @@ import { observable } from "mobx";
import { observer } from "mobx-react";
import { SearchIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import keydown from "react-keydown";
import { withRouter, type RouterHistory } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import Input from "./Input";
import { type Theme } from "types";
import { searchUrl } from "utils/routeHelpers";
type Props = {
history: RouterHistory,
theme: Theme,
source: string,
theme: Object,
placeholder?: string,
collectionId?: string,
t: TFunction,
};
@observer
@@ -26,7 +22,7 @@ class InputSearch extends React.Component<Props> {
@observable focused: boolean = false;
@keydown("meta+f")
focus(ev: SyntheticEvent<>) {
focus(ev) {
ev.preventDefault();
if (this.input) {
@@ -34,13 +30,10 @@ class InputSearch extends React.Component<Props> {
}
}
handleSearchInput = (ev: SyntheticInputEvent<>) => {
handleSearchInput = (ev) => {
ev.preventDefault();
this.props.history.push(
searchUrl(ev.target.value, {
collectionId: this.props.collectionId,
ref: this.props.source,
})
searchUrl(ev.target.value, this.props.collectionId)
);
};
@@ -53,8 +46,7 @@ class InputSearch extends React.Component<Props> {
};
render() {
const { t } = this.props;
const { theme, placeholder = `${t("Search")}` } = this.props;
const { theme, placeholder = "Search…" } = this.props;
return (
<InputMaxWidth
@@ -79,6 +71,4 @@ const InputMaxWidth = styled(Input)`
max-width: 30vw;
`;
export default withTranslation()<InputSearch>(
withTheme(withRouter(InputSearch))
);
export default withTheme(withRouter(InputSearch));
+3 -16
View File
@@ -20,17 +20,11 @@ const Select = styled.select`
}
`;
const Wrapper = styled.label`
display: block;
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
type Option = { label: string, value: string };
export type Props = {
value?: string,
label?: string,
short?: boolean,
className?: string,
labelHidden?: boolean,
options: Option[],
@@ -49,19 +43,12 @@ class InputSelect extends React.Component<Props> {
};
render() {
const {
label,
className,
labelHidden,
options,
short,
...rest
} = this.props;
const { label, className, labelHidden, options, ...rest } = this.props;
const wrappedLabel = <LabelText>{label}</LabelText>;
return (
<Wrapper short={short}>
<label>
{label &&
(labelHidden ? (
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
@@ -77,7 +64,7 @@ class InputSelect extends React.Component<Props> {
))}
</Select>
</Outline>
</Wrapper>
</label>
);
}
}
-90
View File
@@ -1,90 +0,0 @@
// @flow
import { find } from "lodash";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import { languages, languageOptions } from "shared/i18n";
import Flex from "components/Flex";
import NoticeTip from "components/NoticeTip";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import { detectLanguage } from "utils/language";
function Icon(props) {
return (
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M21 18H16L14 16V6C14 4.89543 14.8954 4 16 4H28C29.1046 4 30 4.89543 30 6V16C30 17.1046 29.1046 18 28 18H27L25.4142 19.5858C24.6332 20.3668 23.3668 20.3668 22.5858 19.5858L21 18ZM16 15.1716V6H28V16H27H26.1716L25.5858 16.5858L24 18.1716L22.4142 16.5858L21.8284 16H21H16.8284L16 15.1716Z"
fill="#2B2F35"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M16 13H4C2.89543 13 2 13.8954 2 15V25C2 26.1046 2.89543 27 4 27H5L6.58579 28.5858C7.36684 29.3668 8.63316 29.3668 9.41421 28.5858L11 27H16C17.1046 27 18 26.1046 18 25V15C18 13.8954 17.1046 13 16 13ZM9 17L6 16.9681C6 16.9681 5 17.016 5 18C5 18.984 6 19 6 19H8.5H10C10 19 9.57627 20.1885 8.38983 21.0831C7.20339 21.9777 5.7197 23 5.7197 23C5.7197 23 4.99153 23.6054 5.5 24.5C6.00847 25.3946 7 24.8403 7 24.8403L9.74576 22.8722L11.9492 24.6614C11.9492 24.6614 12.6271 25.3771 13.3051 24.4825C13.9831 23.5879 13.3051 23.0512 13.3051 23.0512L11.1017 21.262C11.1017 21.262 11.5 21 12 20L12.5 19H14C14 19 15 19.0319 15 18C15 16.9681 14 16.9681 14 16.9681L11 17V16C11 16 11.0169 15 10 15C8.98305 15 9 16 9 16V17Z"
fill="#2B2F35"
/>
<path
d="M23.6672 12.5221L23.5526 12.1816H23.1934H20.8818H20.5215L20.4075 12.5235L20.082 13.5H19.2196L21.2292 8.10156H21.8774L21.5587 9.06116L20.7633 11.4562L20.5449 12.1138H21.2378H22.8374H23.5327L23.3114 11.4546L22.5072 9.05959L22.1855 8.10156H22.768L24.7887 13.5H23.9964L23.6672 12.5221Z"
fill="#2B2F35"
stroke="#2B2F35"
/>
</svg>
);
}
export default function LanguagePrompt() {
const { auth, ui } = useStores();
const { t } = useTranslation();
const user = useCurrentUser();
const language = detectLanguage();
if (language === "en_US" || language === user.language) {
return null;
}
if (!languages.includes(language)) {
return null;
}
const option = find(languageOptions, (o) => o.value === language);
const optionLabel = option ? option.label : "";
return (
<NoticeTip>
<Flex align="center">
<LanguageIcon />
<span>
<Trans>
Outline is available in your language {{ optionLabel }}, would you
like to change?
</Trans>
<br />
<a
onClick={() => {
auth.updateUser({
language,
});
ui.setLanguagePromptDismissed();
}}
>
{t("Change Language")}
</a>{" "}
&middot; <a onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</a>
</span>
</Flex>
</NoticeTip>
);
}
const LanguageIcon = styled(Icon)`
margin-right: 12px;
`;
+14 -31
View File
@@ -3,7 +3,6 @@ import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import * as React from "react";
import { Helmet } from "react-helmet";
import { withTranslation, type TFunction } from "react-i18next";
import keydown from "react-keydown";
import { Switch, Route, Redirect } from "react-router-dom";
import styled, { withTheme } from "styled-components";
@@ -15,13 +14,13 @@ import ErrorSuspended from "scenes/ErrorSuspended";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import Analytics from "components/Analytics";
import DocumentHistory from "components/DocumentHistory";
import { GlobalStyles } from "components/DropToImport";
import Flex from "components/Flex";
import { LoadingIndicatorBar } from "components/LoadingIndicator";
import Modal from "components/Modal";
import Sidebar from "components/Sidebar";
import SettingsSidebar from "components/Sidebar/Settings";
import { type Theme } from "types";
import {
homeUrl,
searchUrl,
@@ -36,9 +35,7 @@ type Props = {
auth: AuthStore,
ui: UiStore,
notifications?: React.Node,
theme: Theme,
i18n: Object,
t: TFunction,
theme: Object,
};
@observer
@@ -47,27 +44,21 @@ class Layout extends React.Component<Props> {
@observable redirectTo: ?string;
@observable keyboardShortcutsOpen: boolean = false;
constructor(props: Props) {
super();
this.updateBackground(props);
componentWillMount() {
this.updateBackground();
}
componentDidUpdate() {
this.updateBackground(this.props);
this.updateBackground();
if (this.redirectTo) {
this.redirectTo = undefined;
}
}
updateBackground(props: Props) {
updateBackground() {
// ensure the wider page color always matches the theme
window.document.body.style.background = props.theme.background;
}
@keydown("meta+.")
handleToggleSidebar() {
this.props.ui.toggleCollapsedSidebar();
window.document.body.style.background = this.props.theme.background;
}
@keydown("shift+/")
@@ -81,7 +72,7 @@ class Layout extends React.Component<Props> {
};
@keydown(["t", "/", "meta+k"])
goToSearch(ev: SyntheticEvent<>) {
goToSearch(ev) {
if (this.props.ui.editMode) return;
ev.preventDefault();
ev.stopPropagation();
@@ -95,7 +86,7 @@ class Layout extends React.Component<Props> {
}
render() {
const { auth, t, ui } = this.props;
const { auth, ui } = this.props;
const { user, team } = auth;
const showSidebar = auth.authenticated && user && team;
@@ -124,11 +115,7 @@ class Layout extends React.Component<Props> {
</Switch>
)}
<Content
auto
justify="center"
sidebarCollapsed={ui.editMode || ui.sidebarCollapsed}
>
<Content auto justify="center" editMode={ui.editMode}>
{this.props.children}
</Content>
@@ -142,10 +129,11 @@ class Layout extends React.Component<Props> {
<Modal
isOpen={this.keyboardShortcutsOpen}
onRequestClose={this.handleCloseKeyboardShortcuts}
title={t("Keyboard shortcuts")}
title="Keyboard shortcuts"
>
<KeyboardShortcuts />
</Modal>
<GlobalStyles />
</Container>
);
}
@@ -168,13 +156,8 @@ const Content = styled(Flex)`
}
${breakpoint("tablet")`
margin-left: ${(props) =>
props.sidebarCollapsed
? props.theme.sidebarCollapsedWidth
: props.theme.sidebarWidth};
margin-left: ${(props) => (props.editMode ? 0 : props.theme.sidebarWidth)};
`};
`;
export default withTranslation()<Layout>(
inject("auth", "ui", "documents")(withTheme(Layout))
);
export default inject("auth", "ui", "documents")(withTheme(Layout));
+1 -2
View File
@@ -17,8 +17,7 @@ class Mask extends React.Component<Props> {
return false;
}
constructor() {
super();
componentWillMount() {
this.width = randomInteger(75, 100);
}
+16 -24
View File
@@ -9,7 +9,6 @@ import breakpoint from "styled-components-breakpoint";
import { fadeAndScaleIn } from "shared/styles/animations";
import Flex from "components/Flex";
import NudeButton from "components/NudeButton";
import Scrollable from "components/Scrollable";
ReactModal.setAppElement("#root");
@@ -28,8 +27,7 @@ const GlobalStyles = createGlobalStyle`
}
${breakpoint("tablet")`
.ReactModalPortal + .ReactModalPortal,
.ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
.ReactModalPortal + .ReactModalPortal {
.ReactModal__Overlay {
margin-left: 12px;
box-shadow: 0 -2px 10px ${(props) => props.theme.shadow};
@@ -38,15 +36,13 @@ const GlobalStyles = createGlobalStyle`
}
}
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal,
.ReactModalPortal + .ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal {
.ReactModal__Overlay {
margin-left: 24px;
}
}
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + .ReactModalPortal,
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + .ReactModalPortal {
.ReactModal__Overlay {
margin-left: 36px;
}
@@ -76,11 +72,10 @@ const Modal = ({
isOpen={isOpen}
{...rest}
>
<Content>
<Centered onClick={(ev) => ev.stopPropagation()} column>
{title && <h1>{title}</h1>}
{children}
</Centered>
<Content onClick={(ev) => ev.stopPropagation()} column>
{title && <h1>{title}</h1>}
{children}
</Content>
<Back onClick={onRequestClose}>
<BackIcon size={32} color="currentColor" />
@@ -94,20 +89,10 @@ const Modal = ({
);
};
const Content = styled(Scrollable)`
width: 100%;
padding: 8vh 2rem 2rem;
${breakpoint("tablet")`
padding-top: 13vh;
`};
`;
const Centered = styled(Flex)`
const Content = styled(Flex)`
width: 640px;
max-width: 100%;
position: relative;
margin: 0 auto;
`;
const StyledModal = styled(ReactModal)`
@@ -122,9 +107,16 @@ const StyledModal = styled(ReactModal)`
display: flex;
justify-content: center;
align-items: flex-start;
overflow-x: hidden;
overflow-y: auto;
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
padding: 8vh 2rem 2rem;
outline: none;
${breakpoint("tablet")`
padding-top: 13vh;
`};
`;
const Text = styled.span`
@@ -155,7 +147,7 @@ const Close = styled(NudeButton)`
`;
const Back = styled(NudeButton)`
position: absolute;
position: fixed;
display: none;
align-items: center;
top: 2rem;
-22
View File
@@ -1,22 +0,0 @@
// @flow
import styled from "styled-components";
const Notice = styled.p`
background: ${(props) => props.theme.brand.marine};
color: ${(props) => props.theme.almostBlack};
padding: 10px 12px;
margin-top: 24px;
border-radius: 4px;
position: relative;
a {
color: ${(props) => props.theme.almostBlack};
font-weight: 500;
}
a:hover {
text-decoration: underline;
}
`;
export default Notice;
+8
View File
@@ -1,4 +1,5 @@
// @flow
import { lighten } from "polished";
import * as React from "react";
import styled from "styled-components";
@@ -10,6 +11,13 @@ const Button = styled.button`
line-height: 0;
border: 0;
padding: 0;
&:focus {
transition-duration: 0.05s;
box-shadow: ${(props) => lighten(0.4, props.theme.buttonBackground)} 0px 0px
0px 3px;
outline: none;
}
`;
export default React.forwardRef<any, typeof Button>((props, ref) => (
-4
View File
@@ -1,6 +1,5 @@
// @flow
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { isEqual } from "lodash";
import { observable, action } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
@@ -41,9 +40,6 @@ class PaginatedList extends React.Component<Props> {
if (prevProps.fetch !== this.props.fetch) {
this.fetchResults();
}
if (!isEqual(prevProps.options, this.props.options)) {
this.fetchResults();
}
}
fetchResults = async () => {
+8 -32
View File
@@ -14,7 +14,6 @@ type Props = {
document?: ?Document,
collection: ?Collection,
onSuccess?: () => void,
style?: Object,
ref?: (?React.ElementRef<"div">) => void,
};
@@ -35,38 +34,28 @@ class PathToDocument extends React.Component<Props> {
};
render() {
const { result, collection, document, ref, style } = this.props;
const { result, collection, document, ref } = this.props;
const Component = document ? ResultWrapperLink : ResultWrapper;
if (!result) return <div />;
return (
<Component
ref={ref}
onClick={this.handleClick}
href=""
style={style}
role="option"
selectable
>
<Component ref={ref} onClick={this.handleClick} href="" selectable>
{collection && <CollectionIcon collection={collection} />}
&nbsp;
{result.path
.map((doc) => <Title key={doc.id}>{doc.title}</Title>)
.reduce((prev, curr) => [prev, <StyledGoToIcon />, curr])}
{document && (
<DocumentTitle>
<Flex>
{" "}
<StyledGoToIcon /> <Title>{document.title}</Title>
</DocumentTitle>
</Flex>
)}
</Component>
);
}
}
const DocumentTitle = styled(Flex)``;
const Title = styled.span`
white-space: nowrap;
overflow: hidden;
@@ -74,42 +63,29 @@ const Title = styled.span`
`;
const StyledGoToIcon = styled(GoToIcon)`
fill: ${(props) => props.theme.divider};
opacity: 0.25;
`;
const ResultWrapper = styled.div`
display: flex;
margin-bottom: 10px;
margin-left: -4px;
user-select: none;
color: ${(props) => props.theme.text};
cursor: default;
svg {
flex-shrink: 0;
}
`;
const ResultWrapperLink = styled(ResultWrapper.withComponent("a"))`
margin: 0 -8px;
padding: 8px 4px;
${DocumentTitle} {
display: none;
}
svg {
flex-shrink: 0;
}
border-radius: 8px;
&:hover,
&:active,
&:focus {
background: ${(props) => props.theme.listItemHoverBackground};
outline: none;
${DocumentTitle} {
display: flex;
}
}
`;
+15 -18
View File
@@ -12,7 +12,6 @@ import {
PlusIcon,
} from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import styled from "styled-components";
import AuthStore from "stores/AuthStore";
@@ -35,7 +34,6 @@ type Props = {
auth: AuthStore,
documents: DocumentsStore,
policies: PoliciesStore,
t: TFunction,
};
@observer
@@ -67,10 +65,11 @@ class MainSidebar extends React.Component<Props> {
};
render() {
const { auth, documents, policies, t } = this.props;
const { auth, documents, policies } = this.props;
const { user, team } = auth;
if (!user || !team) return null;
const draftDocumentsCount = documents.drafts.length;
const can = policies.abilities(team.id);
return (
@@ -92,7 +91,7 @@ class MainSidebar extends React.Component<Props> {
to="/home"
icon={<HomeIcon color="currentColor" />}
exact={false}
label={t("Home")}
label="Home"
/>
<SidebarLink
to={{
@@ -100,20 +99,20 @@ class MainSidebar extends React.Component<Props> {
state: { fromMenu: true },
}}
icon={<SearchIcon color="currentColor" />}
label={t("Search")}
label="Search"
exact={false}
/>
<SidebarLink
to="/starred"
icon={<StarredIcon color="currentColor" />}
exact={false}
label={t("Starred")}
label="Starred"
/>
<SidebarLink
to="/templates"
icon={<ShapesIcon color="currentColor" />}
exact={false}
label={t("Templates")}
label="Templates"
active={
documents.active ? documents.active.template : undefined
}
@@ -123,9 +122,9 @@ class MainSidebar extends React.Component<Props> {
icon={<EditIcon color="currentColor" />}
label={
<Drafts align="center">
{t("Drafts")}
{documents.totalDrafts > 0 && (
<Bubble count={documents.totalDrafts} />
Drafts
{draftDocumentsCount > 0 && (
<Bubble count={draftDocumentsCount} />
)}
</Drafts>
}
@@ -148,7 +147,7 @@ class MainSidebar extends React.Component<Props> {
to="/archive"
icon={<ArchiveIcon color="currentColor" />}
exact={false}
label={t("Archive")}
label="Archive"
active={
documents.active
? documents.active.isArchived && !documents.active.isDeleted
@@ -159,7 +158,7 @@ class MainSidebar extends React.Component<Props> {
to="/trash"
icon={<TrashIcon color="currentColor" />}
exact={false}
label={t("Trash")}
label="Trash"
active={
documents.active ? documents.active.isDeleted : undefined
}
@@ -169,21 +168,21 @@ class MainSidebar extends React.Component<Props> {
to="/settings/people"
onClick={this.handleInviteModalOpen}
icon={<PlusIcon color="currentColor" />}
label={t("Invite people…")}
label="Invite people…"
/>
)}
</Section>
</Scrollable>
</Flex>
<Modal
title={t("Invite people")}
title="Invite people"
onRequestClose={this.handleInviteModalClose}
isOpen={this.inviteModalOpen}
>
<Invite onSubmit={this.handleInviteModalClose} />
</Modal>
<Modal
title={t("Create a collection")}
title="Create a collection"
onRequestClose={this.handleCreateCollectionModalClose}
isOpen={this.createCollectionModalOpen}
>
@@ -198,6 +197,4 @@ const Drafts = styled(Flex)`
height: 24px;
`;
export default withTranslation()<MainSidebar>(
inject("documents", "policies", "auth")(MainSidebar)
);
export default inject("documents", "policies", "auth")(MainSidebar);
+28 -28
View File
@@ -10,10 +10,10 @@ import {
GroupIcon,
LinkIcon,
TeamIcon,
BulletedListIcon,
ExpandedIcon,
} from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import type { RouterHistory } from "react-router-dom";
import styled from "styled-components";
import AuthStore from "stores/AuthStore";
@@ -31,13 +31,10 @@ import SlackIcon from "./icons/Slack";
import ZapierIcon from "./icons/Zapier";
import env from "env";
const isHosted = env.DEPLOYMENT === "hosted";
type Props = {
history: RouterHistory,
policies: PoliciesStore,
auth: AuthStore,
t: TFunction,
};
@observer
@@ -47,7 +44,7 @@ class SettingsSidebar extends React.Component<Props> {
};
render() {
const { policies, t, auth } = this.props;
const { policies, auth } = this.props;
const { team } = auth;
if (!team) return null;
@@ -58,7 +55,7 @@ class SettingsSidebar extends React.Component<Props> {
<HeaderBlock
subheading={
<ReturnToApp align="center">
<BackIcon color="currentColor" /> {t("Return to App")}
<BackIcon color="currentColor" /> Return to App
</ReturnToApp>
}
teamName={team.name}
@@ -73,17 +70,17 @@ class SettingsSidebar extends React.Component<Props> {
<SidebarLink
to="/settings"
icon={<ProfileIcon color="currentColor" />}
label={t("Profile")}
label="Profile"
/>
<SidebarLink
to="/settings/notifications"
icon={<EmailIcon color="currentColor" />}
label={t("Notifications")}
label="Notifications"
/>
<SidebarLink
to="/settings/tokens"
icon={<CodeIcon color="currentColor" />}
label={t("API Tokens")}
label="API Tokens"
/>
</Section>
<Section>
@@ -92,61 +89,66 @@ class SettingsSidebar extends React.Component<Props> {
<SidebarLink
to="/settings/details"
icon={<TeamIcon color="currentColor" />}
label={t("Details")}
label="Details"
/>
)}
{can.update && (
<SidebarLink
to="/settings/security"
icon={<PadlockIcon color="currentColor" />}
label={t("Security")}
label="Security"
/>
)}
<SidebarLink
to="/settings/people"
icon={<UserIcon color="currentColor" />}
exact={false}
label={t("People")}
label="People"
/>
<SidebarLink
to="/settings/groups"
icon={<GroupIcon color="currentColor" />}
exact={false}
label={t("Groups")}
label="Groups"
/>
<SidebarLink
to="/settings/shares"
icon={<LinkIcon color="currentColor" />}
label={t("Share Links")}
label="Share Links"
/>
{can.auditLog && (
<SidebarLink
to="/settings/events"
icon={<BulletedListIcon color="currentColor" />}
label="Audit Log"
/>
)}
{can.export && (
<SidebarLink
to="/settings/export"
icon={<DocumentIcon color="currentColor" />}
label={t("Export Data")}
label="Export Data"
/>
)}
</Section>
{can.update && (
<Section>
<Header>{t("Integrations")}</Header>
<Header>Integrations</Header>
<SidebarLink
to="/settings/integrations/slack"
icon={<SlackIcon color="currentColor" />}
label="Slack"
/>
{isHosted && (
<SidebarLink
to="/settings/integrations/zapier"
icon={<ZapierIcon color="currentColor" />}
label="Zapier"
/>
)}
<SidebarLink
to="/settings/integrations/zapier"
icon={<ZapierIcon color="currentColor" />}
label="Zapier"
/>
</Section>
)}
{can.update && !isHosted && (
{can.update && env.DEPLOYMENT !== "hosted" && (
<Section>
<Header>{t("Installation")}</Header>
<Header>Installation</Header>
<Version />
</Section>
)}
@@ -166,6 +168,4 @@ const ReturnToApp = styled(Flex)`
height: 16px;
`;
export default withTranslation()<SettingsSidebar>(
inject("auth", "policies")(SettingsSidebar)
);
export default inject("auth", "policies")(SettingsSidebar);
+42 -65
View File
@@ -1,65 +1,65 @@
// @flow
import { observer } from "mobx-react";
import { observer, inject } from "mobx-react";
import { CloseIcon, MenuIcon } from "outline-icons";
import * as React from "react";
import { withRouter } from "react-router-dom";
import type { Location } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import UiStore from "stores/UiStore";
import Fade from "components/Fade";
import Flex from "components/Flex";
import CollapseToggle, { Button } from "./components/CollapseToggle";
import usePrevious from "hooks/usePrevious";
import useStores from "hooks/useStores";
let firstRender = true;
type Props = {
children: React.Node,
location: Location,
ui: UiStore,
};
function Sidebar({ location, children }: Props) {
const { ui } = useStores();
const previousLocation = usePrevious(location);
React.useEffect(() => {
if (location !== previousLocation) {
ui.hideMobileSidebar();
@observer
class Sidebar extends React.Component<Props> {
componentWillReceiveProps = (nextProps: Props) => {
if (this.props.location !== nextProps.location) {
this.props.ui.hideMobileSidebar();
}
}, [ui, location, previousLocation]);
};
const content = (
<Container
mobileSidebarVisible={ui.mobileSidebarVisible}
collapsed={ui.editMode || ui.sidebarCollapsed}
column
>
<CollapseToggle
collapsed={ui.sidebarCollapsed}
onClick={ui.toggleCollapsedSidebar}
/>
<Toggle
onClick={ui.toggleMobileSidebar}
toggleSidebar = () => {
this.props.ui.toggleMobileSidebar();
};
render() {
const { children, ui } = this.props;
const content = (
<Container
editMode={ui.editMode}
mobileSidebarVisible={ui.mobileSidebarVisible}
column
>
{ui.mobileSidebarVisible ? (
<CloseIcon size={32} />
) : (
<MenuIcon size={32} />
)}
</Toggle>
{children}
</Container>
);
<Toggle
onClick={this.toggleSidebar}
mobileSidebarVisible={ui.mobileSidebarVisible}
>
{ui.mobileSidebarVisible ? (
<CloseIcon size={32} />
) : (
<MenuIcon size={32} />
)}
</Toggle>
{children}
</Container>
);
// Fade in the sidebar on first render after page load
if (firstRender) {
firstRender = false;
return <Fade>{content}</Fade>;
// Fade in the sidebar on first render after page load
if (firstRender) {
firstRender = false;
return <Fade>{content}</Fade>;
}
return content;
}
return content;
}
const Container = styled(Flex)`
@@ -68,7 +68,7 @@ const Container = styled(Flex)`
bottom: 0;
width: 100%;
background: ${(props) => props.theme.sidebarBackground};
transition: box-shadow, 100ms, ease-in-out, left 100ms ease-out,
transition: left 100ms ease-out,
${(props) => props.theme.backgroundTransition};
margin-left: ${(props) => (props.mobileSidebarVisible ? 0 : "-100%")};
z-index: ${(props) => props.theme.depths.sidebar};
@@ -95,33 +95,10 @@ const Container = styled(Flex)`
}
${breakpoint("tablet")`
left: ${(props) =>
props.collapsed
? `calc(-${props.theme.sidebarWidth} + ${props.theme.sidebarCollapsedWidth})`
: 0};
left: ${(props) => (props.editMode ? `-${props.theme.sidebarWidth}` : 0)};
width: ${(props) => props.theme.sidebarWidth};
margin: 0;
z-index: 3;
&:hover,
&:focus-within {
left: 0;
box-shadow: ${(props) =>
props.collapsed ? "rgba(0, 0, 0, 0.2) 1px 0 4px" : "none"};
& ${Button} {
opacity: .75;
}
& ${Button}:hover {
opacity: 1;
}
}
&:not(:hover):not(:focus-within) > div {
opacity: ${(props) => (props.collapsed ? "0" : "1")};
transition: opacity 100ms ease-in-out;
}
`};
`;
@@ -140,4 +117,4 @@ const Toggle = styled.a`
`};
`;
export default withRouter(observer(Sidebar));
export default withRouter(inject("ui")(Sidebar));
@@ -1,59 +0,0 @@
// @flow
import { NextIcon, BackIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Tooltip from "components/Tooltip";
import { meta } from "utils/keyboard";
type Props = {|
collapsed: boolean,
onClick?: () => void,
|};
function CollapseToggle({ collapsed, ...rest }: Props) {
const { t } = useTranslation();
return (
<Tooltip
tooltip={collapsed ? t("Expand") : t("Collapse")}
shortcut={`${meta}+.`}
delay={500}
placement="bottom"
>
<Button {...rest} aria-hidden>
{collapsed ? (
<NextIcon color="currentColor" />
) : (
<BackIcon color="currentColor" />
)}
</Button>
</Tooltip>
);
}
export const Button = styled.button`
display: block;
position: absolute;
top: 28px;
right: 8px;
border: 0;
width: 24px;
height: 24px;
z-index: 1;
font-weight: 600;
color: ${(props) => props.theme.sidebarText};
background: ${(props) => props.theme.sidebarItemBackground};
transition: opacity 100ms ease-in-out;
border-radius: 4px;
opacity: 0;
cursor: pointer;
padding: 0;
&:hover {
color: ${(props) => props.theme.white};
background: ${(props) => props.theme.primary};
}
`;
export default CollapseToggle;
@@ -1,109 +1,82 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { useDrop } from "react-dnd";
import DocumentsStore from "stores/DocumentsStore";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import Document from "models/Document";
import CollectionIcon from "components/CollectionIcon";
import DropToImport from "components/DropToImport";
import Flex from "components/Flex";
import DocumentLink from "./DocumentLink";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import useStores from "hooks/useStores";
import CollectionMenu from "menus/CollectionMenu";
type Props = {|
type Props = {
collection: Collection,
ui: UiStore,
canUpdate: boolean,
documents: DocumentsStore,
activeDocument: ?Document,
prefetchDocument: (id: string) => Promise<void>,
|};
};
function CollectionLink({
collection,
activeDocument,
prefetchDocument,
canUpdate,
ui,
}: Props) {
const [menuOpen, setMenuOpen] = React.useState(false);
@observer
class CollectionLink extends React.Component<Props> {
@observable menuOpen = false;
const handleTitleChange = React.useCallback(
async (name: string) => {
await collection.save({ name });
},
[collection]
);
render() {
const {
collection,
documents,
activeDocument,
prefetchDocument,
ui,
} = this.props;
const expanded = collection.id === ui.activeCollectionId;
const { documents, policies } = useStores();
const expanded = collection.id === ui.activeCollectionId;
// Droppable
const [{ isOver, canDrop }, drop] = useDrop({
accept: "document",
drop: (item, monitor) => {
if (!collection) return;
documents.move(item.id, collection.id);
},
canDrop: (item, monitor) => {
return policies.abilities(collection.id).update;
},
collect: (monitor) => ({
isOver: !!monitor.isOver(),
canDrop: monitor.canDrop(),
}),
});
return (
<>
<div ref={drop}>
<DropToImport key={collection.id} collectionId={collection.id}>
<SidebarLink
key={collection.id}
to={collection.url}
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
iconColor={collection.color}
expanded={expanded}
menuOpen={menuOpen}
isActiveDrop={isOver && canDrop}
label={
<EditableTitle
title={collection.name}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
/>
}
exact={false}
menu={
<CollectionMenu
position="right"
return (
<DropToImport
key={collection.id}
collectionId={collection.id}
activeClassName="activeDropZone"
>
<SidebarLink
key={collection.id}
to={collection.url}
icon={<CollectionIcon collection={collection} expanded={expanded} />}
iconColor={collection.color}
expanded={expanded}
hideDisclosure
menuOpen={this.menuOpen}
label={collection.name}
exact={false}
menu={
<CollectionMenu
position="right"
collection={collection}
onOpen={() => (this.menuOpen = true)}
onClose={() => (this.menuOpen = false)}
/>
}
>
<Flex column>
{collection.documents.map((node) => (
<DocumentLink
key={node.id}
node={node}
documents={documents}
collection={collection}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
depth={1.5}
/>
}
></SidebarLink>
</DropToImport>
</div>
{expanded &&
collection.documents.map((node) => (
<DocumentLink
key={node.id}
node={node}
collection={collection}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
canUpdate={canUpdate}
depth={1.5}
/>
))}
</>
);
))}
</Flex>
</SidebarLink>
</DropToImport>
);
}
}
export default observer(CollectionLink);
export default CollectionLink;
@@ -2,7 +2,6 @@
import { observer, inject } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import keydown from "react-keydown";
import { withRouter, type RouterHistory } from "react-router-dom";
@@ -25,7 +24,6 @@ type Props = {
documents: DocumentsStore,
onCreateCollection: () => void,
ui: UiStore,
t: TFunction,
};
@observer
@@ -54,17 +52,17 @@ class Collections extends React.Component<Props> {
}
render() {
const { collections, ui, policies, documents, t } = this.props;
const { collections, ui, documents } = this.props;
const content = (
<>
{collections.orderedData.map((collection) => (
<CollectionLink
key={collection.id}
documents={documents}
collection={collection}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
canUpdate={policies.abilities(collection.id).update}
ui={ui}
/>
))}
@@ -72,7 +70,7 @@ class Collections extends React.Component<Props> {
to="/collections"
onClick={this.props.onCreateCollection}
icon={<PlusIcon color="currentColor" />}
label={`${t("New collection")}`}
label="New collection"
exact
/>
</>
@@ -80,7 +78,7 @@ class Collections extends React.Component<Props> {
return (
<Flex column>
<Header>{t("Collections")}</Header>
<Header>Collections</Header>
{collections.isLoaded ? (
this.isPreloaded ? (
content
@@ -95,6 +93,9 @@ class Collections extends React.Component<Props> {
}
}
export default withTranslation()<Collections>(
inject("collections", "ui", "documents", "policies")(withRouter(Collections))
);
export default inject(
"collections",
"ui",
"documents",
"policies"
)(withRouter(Collections));
+104 -187
View File
@@ -1,223 +1,140 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useDrag, useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import DocumentsStore from "stores/DocumentsStore";
import Collection from "models/Collection";
import Document from "models/Document";
import DropToImport from "components/DropToImport";
import Fade from "components/Fade";
import EditableTitle from "./EditableTitle";
import Flex from "components/Flex";
import SidebarLink from "./SidebarLink";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu";
import { type NavigationNode } from "types";
type Props = {|
type Props = {
node: NavigationNode,
canUpdate: boolean,
documents: DocumentsStore,
collection?: Collection,
activeDocument: ?Document,
activeDocumentRef?: (?HTMLElement) => void,
prefetchDocument: (documentId: string) => Promise<void>,
depth: number,
|};
};
function DocumentLink({
node,
collection,
activeDocument,
activeDocumentRef,
prefetchDocument,
depth,
canUpdate,
}: Props) {
const { documents, policies } = useStores();
const { t } = useTranslation();
@observer
class DocumentLink extends React.Component<Props> {
@observable menuOpen = false;
const isActiveDocument = activeDocument && activeDocument.id === node.id;
const hasChildDocuments = !!node.children.length;
const document = documents.get(node.id);
const { fetchChildDocuments } = documents;
React.useEffect(() => {
if (isActiveDocument && hasChildDocuments) {
fetchChildDocuments(node.id);
componentDidMount() {
if (this.isActiveDocument() && this.hasChildDocuments()) {
this.props.documents.fetchChildDocuments(this.props.node.id);
}
}, [fetchChildDocuments, node, hasChildDocuments, isActiveDocument]);
}
const pathToNode = React.useMemo(
() =>
collection && collection.pathToDocument(node.id).map((entry) => entry.id),
[collection, node]
);
componentDidUpdate(prevProps: Props) {
if (prevProps.activeDocument !== this.props.activeDocument) {
if (this.isActiveDocument() && this.hasChildDocuments()) {
this.props.documents.fetchChildDocuments(this.props.node.id);
}
}
}
const showChildren = React.useMemo(() => {
return !!(
hasChildDocuments &&
handleMouseEnter = (ev: SyntheticEvent<>) => {
const { node, prefetchDocument } = this.props;
ev.stopPropagation();
ev.preventDefault();
prefetchDocument(node.id);
};
isActiveDocument = () => {
return (
this.props.activeDocument &&
this.props.activeDocument.id === this.props.node.id
);
};
hasChildDocuments = () => {
return !!this.props.node.children.length;
};
render() {
const {
node,
documents,
collection,
activeDocument,
activeDocumentRef,
prefetchDocument,
depth,
} = this.props;
const showChildren = !!(
activeDocument &&
collection &&
(collection
.pathToDocument(activeDocument.id)
.pathToDocument(activeDocument)
.map((entry) => entry.id)
.includes(node.id) ||
isActiveDocument)
this.isActiveDocument())
);
}, [hasChildDocuments, activeDocument, isActiveDocument, node, collection]);
const document = documents.get(node.id);
const [expanded, setExpanded] = React.useState(showChildren);
React.useEffect(() => {
if (showChildren) {
setExpanded(showChildren);
}
}, [showChildren]);
const handleDisclosureClick = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
setExpanded(!expanded);
},
[expanded]
);
const handleMouseEnter = React.useCallback(
(ev: SyntheticEvent<>) => {
prefetchDocument(node.id);
},
[prefetchDocument, node]
);
const handleTitleChange = React.useCallback(
async (title: string) => {
if (!document) return;
await documents.update({
id: document.id,
lastRevision: document.revision,
text: document.text,
title,
});
},
[documents, document]
);
const [menuOpen, setMenuOpen] = React.useState(false);
const isMoving = documents.movingDocumentId === node.id;
// Draggable
const [{ isDragging }, drag] = useDrag({
item: { type: "document", ...node, depth, active: isActiveDocument },
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
canDrag: (monitor) => {
return policies.abilities(node.id).move;
},
});
// Droppable
const [{ isOver, canDrop }, drop] = useDrop({
accept: "document",
drop: async (item, monitor) => {
if (!collection) return;
documents.move(item.id, collection.id, node.id);
},
canDrop: (item, monitor) =>
pathToNode && !pathToNode.includes(monitor.getItem().id),
collect: (monitor) => ({
isOver: !!monitor.isOver(),
canDrop: monitor.canDrop(),
}),
});
return (
<>
<Draggable
return (
<Flex
column
key={node.id}
ref={drag}
$isDragging={isDragging}
$isMoving={isMoving}
ref={this.isActiveDocument() ? activeDocumentRef : undefined}
onMouseEnter={this.handleMouseEnter}
>
<div ref={drop}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
<SidebarLink
innerRef={isActiveDocument ? activeDocumentRef : undefined}
onMouseEnter={handleMouseEnter}
to={{
pathname: node.url,
state: { title: node.title },
}}
label={
<>
{hasChildDocuments && (
<Disclosure
expanded={expanded && !isDragging}
onClick={handleDisclosureClick}
/>
)}
<EditableTitle
title={node.title || t("Untitled")}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
<DropToImport documentId={node.id} activeClassName="activeDropZone">
<SidebarLink
to={{
pathname: node.url,
state: { title: node.title },
}}
expanded={showChildren ? true : undefined}
label={node.title || "Untitled"}
depth={depth}
exact={false}
menuOpen={this.menuOpen}
menu={
document ? (
<Fade>
<DocumentMenu
position="right"
document={document}
onOpen={() => (this.menuOpen = true)}
onClose={() => (this.menuOpen = false)}
/>
</>
}
isActiveDrop={isOver && canDrop}
depth={depth}
exact={false}
menuOpen={menuOpen}
menu={
document && !isMoving ? (
<Fade>
<DocumentMenu
position="right"
document={document}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</Fade>
) : undefined
}
/>
</DropToImport>
</div>
</Draggable>
{expanded && !isDragging && (
<>
{node.children.map((childNode) => (
<ObservedDocumentLink
key={childNode.id}
collection={collection}
node={childNode}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
depth={depth + 1}
canUpdate={canUpdate}
/>
))}
</>
)}
</>
);
</Fade>
) : undefined
}
>
{this.hasChildDocuments() && (
<DocumentChildren column>
{node.children.map((childNode) => (
<DocumentLink
key={childNode.id}
collection={collection}
node={childNode}
documents={documents}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
depth={depth + 1}
/>
))}
</DocumentChildren>
)}
</SidebarLink>
</DropToImport>
</Flex>
);
}
}
const Draggable = styled("div")`
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)};
pointer-events: ${(props) => (props.$isMoving ? "none" : "all")};
`;
const DocumentChildren = styled(Flex)``;
const Disclosure = styled(CollapsedIcon)`
position: absolute;
left: -24px;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
const ObservedDocumentLink = observer(DocumentLink);
export default ObservedDocumentLink;
export default DocumentLink;
@@ -1,99 +0,0 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import useStores from "hooks/useStores";
type Props = {|
onSubmit: (title: string) => Promise<void>,
title: string,
canUpdate: boolean,
|};
function EditableTitle({ title, onSubmit, canUpdate }: Props) {
const [isEditing, setIsEditing] = React.useState(false);
const [originalValue, setOriginalValue] = React.useState(title);
const [value, setValue] = React.useState(title);
const { ui } = useStores();
React.useEffect(() => {
setValue(title);
}, [title]);
const handleChange = React.useCallback((event) => {
setValue(event.target.value);
}, []);
const handleDoubleClick = React.useCallback((event) => {
event.preventDefault();
event.stopPropagation();
setIsEditing(true);
}, []);
const handleKeyDown = React.useCallback(
(event) => {
if (event.key === "Escape") {
setIsEditing(false);
setValue(originalValue);
}
},
[originalValue]
);
const handleSave = React.useCallback(async () => {
setIsEditing(false);
if (value === originalValue) {
return;
}
if (document) {
try {
await onSubmit(value);
setOriginalValue(value);
} catch (error) {
setValue(originalValue);
ui.showToast(error.message);
throw error;
}
}
}, [ui, originalValue, value, onSubmit]);
return (
<>
{isEditing ? (
<form onSubmit={handleSave}>
<Input
type="text"
value={value}
onKeyDown={handleKeyDown}
onChange={handleChange}
onBlur={handleSave}
autoFocus
/>
</form>
) : (
<span onDoubleClick={canUpdate ? handleDoubleClick : undefined}>
{value}
</span>
)}
</>
);
}
const Input = styled.input`
margin-left: -4px;
color: ${(props) => props.theme.sidebarText};
background: ${(props) => props.theme.background};
width: calc(100% - 10px);
border-radius: 3px;
border: 1px solid ${(props) => props.theme.inputBorderFocused};
padding: 5px 6px;
margin: -4px;
height: 32px;
&:focus {
outline-color: ${(props) => props.theme.primary};
}
`;
export default EditableTitle;
@@ -21,7 +21,7 @@ function HeaderBlock({
}: Props) {
return (
<Header justify="flex-start" align="center" {...rest}>
<TeamLogo alt={`${teamName} logo`} src={logoUrl} size="38px" />
<TeamLogo alt={`${teamName} logo`} src={logoUrl} />
<Flex align="flex-start" column>
<TeamName showDisclosure>
{teamName}{" "}
@@ -57,16 +57,10 @@ const TeamName = styled.div`
font-size: 16px;
`;
const Header = styled.button`
display: flex;
align-items: center;
const Header = styled(Flex)`
flex-shrink: 0;
padding: 20px 24px;
padding: 16px 24px;
position: relative;
background: none;
line-height: inherit;
border: 0;
margin: 0;
cursor: pointer;
width: 100%;
@@ -1,77 +1,103 @@
// @flow
import { observable, action } from "mobx";
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { withRouter, NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import { type Theme } from "types";
import Flex from "components/Flex";
type Props = {
to?: string | Object,
href?: string | Object,
innerRef?: (?HTMLElement) => void,
onClick?: (SyntheticEvent<>) => void,
onMouseEnter?: (SyntheticEvent<>) => void,
children?: React.Node,
icon?: React.Node,
expanded?: boolean,
label?: React.Node,
menu?: React.Node,
menuOpen?: boolean,
hideDisclosure?: boolean,
iconColor?: string,
active?: boolean,
isActiveDrop?: boolean,
theme: Theme,
theme: Object,
exact?: boolean,
depth?: number,
};
function SidebarLink({
icon,
children,
onClick,
onMouseEnter,
to,
label,
active,
isActiveDrop,
menu,
menuOpen,
theme,
exact,
href,
innerRef,
depth,
...rest
}: Props) {
const style = React.useMemo(() => {
return {
paddingLeft: `${(depth || 0) * 16 + 16}px`,
};
}, [depth]);
@observer
class SidebarLink extends React.Component<Props> {
@observable expanded: ?boolean = this.props.expanded;
const activeStyle = {
color: theme.text,
fontWeight: 600,
background: theme.sidebarItemBackground,
...style,
style = {
paddingLeft: `${(this.props.depth || 0) * 16 + 16}px`,
};
return (
<StyledNavLink
$isActiveDrop={isActiveDrop}
activeStyle={isActiveDrop ? undefined : activeStyle}
style={active ? activeStyle : style}
onClick={onClick}
onMouseEnter={onMouseEnter}
exact={exact !== false}
to={to}
as={to ? undefined : href ? "a" : "div"}
href={href}
ref={innerRef}
>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
{menu && <Action menuOpen={menuOpen}>{menu}</Action>}
</StyledNavLink>
);
componentWillReceiveProps(nextProps: Props) {
if (nextProps.expanded !== undefined) {
this.expanded = nextProps.expanded;
}
}
@action
handleClick = (ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
this.expanded = !this.expanded;
};
@action
handleExpand = () => {
this.expanded = true;
};
render() {
const {
icon,
children,
onClick,
to,
label,
active,
menu,
menuOpen,
hideDisclosure,
exact,
href,
} = this.props;
const showDisclosure = !!children && !hideDisclosure;
const activeStyle = {
color: this.props.theme.text,
background: this.props.theme.sidebarItemBackground,
fontWeight: 600,
...this.style,
};
return (
<Wrapper column>
<StyledNavLink
activeStyle={activeStyle}
style={active ? activeStyle : this.style}
onClick={onClick}
exact={exact !== false}
to={to}
as={to ? undefined : href ? "a" : "div"}
href={href}
>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label onClick={this.handleExpand}>
{showDisclosure && (
<Disclosure expanded={this.expanded} onClick={this.handleClick} />
)}
{label}
</Label>
{menu && <Action menuOpen={menuOpen}>{menu}</Action>}
</StyledNavLink>
{this.expanded && children}
</Wrapper>
);
}
}
// accounts for whitespace around icon
@@ -106,25 +132,18 @@ const StyledNavLink = styled(NavLink)`
text-overflow: ellipsis;
padding: 4px 16px;
border-radius: 4px;
background: ${(props) =>
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.sidebarText};
color: ${(props) => props.theme.sidebarText};
font-size: 15px;
cursor: pointer;
svg {
${(props) => (props.$isActiveDrop ? `fill: ${props.theme.white};` : "")}
}
&:hover {
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.text};
color: ${(props) => props.theme.text};
}
&:focus {
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.black05};
outline: none;
}
&:hover {
@@ -134,6 +153,10 @@ const StyledNavLink = styled(NavLink)`
}
`;
const Wrapper = styled(Flex)`
position: relative;
`;
const Label = styled.div`
position: relative;
width: 100%;
@@ -141,4 +164,11 @@ const Label = styled.div`
line-height: 1.6;
`;
const Disclosure = styled(CollapsedIcon)`
position: absolute;
left: -24px;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
export default withRouter(withTheme(SidebarLink));
+1 -18
View File
@@ -125,8 +125,6 @@ class SocketProvider extends React.Component<Props> {
if (document) {
document.deletedAt = documentDescriptor.updatedAt;
}
policies.remove(documentId);
continue;
}
@@ -174,21 +172,7 @@ class SocketProvider extends React.Component<Props> {
const collection = collections.get(collectionId) || {};
if (event.event === "collections.delete") {
const collection = collections.get(collectionId);
if (collection) {
collection.deletedAt = collectionDescriptor.updatedAt;
}
const deletedDocuments = documents.inCollection(collectionId);
deletedDocuments.forEach((doc) => {
doc.deletedAt = collectionDescriptor.updatedAt;
policies.remove(doc.id);
});
documents.removeCollectionDocuments(collectionId);
memberships.removeCollectionMemberships(collectionId);
collections.remove(collectionId);
policies.remove(collectionId);
continue;
}
@@ -203,10 +187,9 @@ class SocketProvider extends React.Component<Props> {
await collections.fetch(collectionId, { force: true });
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 403) {
collections.remove(collectionId);
documents.removeCollectionDocuments(collectionId);
memberships.removeCollectionMemberships(collectionId);
collections.remove(collectionId);
policies.remove(collectionId);
return;
}
}
+3 -2
View File
@@ -11,11 +11,12 @@ const H3 = styled.h3`
margin-top: 22px;
margin-bottom: 12px;
line-height: 1;
position: relative;
`;
const Underline = styled("span")`
margin-top: -1px;
position: relative;
top: 1px;
display: inline-block;
font-weight: 500;
font-size: 14px;
+10 -2
View File
@@ -1,15 +1,16 @@
// @flow
import { lighten } from "polished";
import * as React from "react";
import { NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import { type Theme } from "types";
type Props = {
theme: Theme,
theme: Object,
};
const StyledNavLink = styled(NavLink)`
position: relative;
bottom: -1px;
display: inline-block;
font-weight: 500;
@@ -23,6 +24,13 @@ const StyledNavLink = styled(NavLink)`
border-bottom: 3px solid ${(props) => props.theme.divider};
padding-bottom: 5px;
}
&:focus {
outline: none;
border-bottom: 3px solid
${(props) => lighten(0.4, props.theme.buttonBackground)};
padding-bottom: 5px;
}
`;
function Tab({ theme, ...rest }: Props) {
+2 -3
View File
@@ -2,12 +2,11 @@
import styled from "styled-components";
const TeamLogo = styled.img`
width: ${(props) => props.size || "auto"};
height: ${(props) => props.size || "38px"};
width: auto;
height: 38px;
border-radius: 4px;
background: ${(props) => props.theme.background};
border: 1px solid ${(props) => props.theme.divider};
overflow: hidden;
`;
export default TeamLogo;
+1 -17
View File
@@ -23,9 +23,6 @@ function eachMinute(fn) {
type Props = {
dateTime: string,
children?: React.Node,
tooltipDelay?: number,
addSuffix?: boolean,
shorten?: boolean,
};
class Time extends React.Component<Props> {
@@ -42,26 +39,13 @@ class Time extends React.Component<Props> {
}
render() {
const { shorten, addSuffix } = this.props;
let content = distanceInWordsToNow(this.props.dateTime, {
addSuffix,
});
if (shorten) {
content = content
.replace("about", "")
.replace("less than a minute ago", "just now")
.replace("minute", "min");
}
return (
<Tooltip
tooltip={format(this.props.dateTime, "MMMM Do, YYYY h:mm a")}
delay={this.props.tooltipDelay}
placement="bottom"
>
<time dateTime={this.props.dateTime}>
{this.props.children || content}
{this.props.children || distanceInWordsToNow(this.props.dateTime)}
</time>
</Tooltip>
);
-1
View File
@@ -21,7 +21,6 @@ export default class Abstract extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://app.goabstract.com/embed/${shareId}`}
title={`Abstract (${shareId})`}
/>
-1
View File
@@ -20,7 +20,6 @@ export default class Airtable extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://airtable.com/embed/${shareId}`}
title={`Airtable (${shareId})`}
border
-28
View File
@@ -1,28 +0,0 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp(
"^https?://share.clickup.com/[a-z]/[a-z]/(.*)/(.*)$"
);
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class ClickUp extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="ClickUp Embed"
/>
);
}
}
-17
View File
@@ -1,17 +0,0 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import ClickUp from "./ClickUp";
describe("ClickUp", () => {
const match = ClickUp.ENABLED[0];
test("to be enabled on share link", () => {
expect(
"https://share.clickup.com/b/h/6-9310960-2/c9d837d74182317".match(match)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://share.clickup.com".match(match)).toBe(null);
expect("https://clickup.com/".match(match)).toBe(null);
expect("https://clickup.com/features".match(match)).toBe(null);
});
});
+1 -1
View File
@@ -17,6 +17,6 @@ export default class Codepen extends React.Component<Props> {
render() {
const normalizedUrl = this.props.attrs.href.replace(/\/pen\//, "/embed/");
return <Frame {...this.props} src={normalizedUrl} title="Codepen Embed" />;
return <Frame src={normalizedUrl} title="Codepen Embed" />;
}
}
-1
View File
@@ -19,7 +19,6 @@ export default class Figma extends React.Component<Props> {
render() {
return (
<Frame
{...this.props}
src={`https://www.figma.com/embed?embed_host=outline&url=${this.props.attrs.href}`}
title="Figma Embed"
border
+1 -8
View File
@@ -15,13 +15,6 @@ export default class Framer extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="Framer Embed"
border
/>
);
return <Frame src={this.props.attrs.href} title="Framer Embed" border />;
}
}
+1 -3
View File
@@ -2,11 +2,10 @@
import * as React from "react";
const URL_REGEX = new RegExp(
"^https://gist.github.com/([a-z\\d](?:[a-z\\d]|-(?=[a-z\\d])){0,38})/(.*)$"
"^https://gist.github.com/([a-zd](?:[a-zd]|-(?=[a-zd])){0,38})/(.*)$"
);
type Props = {|
isSelected: boolean,
attrs: {|
href: string,
matches: string[],
@@ -49,7 +48,6 @@ class Gist extends React.Component<Props> {
return (
<iframe
className={this.props.isSelected ? "ProseMirror-selectednode" : ""}
ref={this.updateIframeContent}
type="text/html"
frameBorder="0"
-6
View File
@@ -9,12 +9,6 @@ describe("Gist", () => {
match
)
).toBeTruthy();
expect(
"https://gist.github.com/n3n/eb51ada6308b539d388c8ff97711adfa".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
+4 -16
View File
@@ -2,7 +2,9 @@
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp("^https?://docs.google.com/document/(.*)$");
const URL_REGEX = new RegExp(
"^https?://docs.google.com/document/d/(.*)/pub(.*)$"
);
type Props = {|
attrs: {|
@@ -16,21 +18,7 @@ export default class GoogleDocs extends React.Component<Props> {
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href.replace("/edit", "/preview")}
icon={
<img
src="/images/google-docs.png"
alt="Google Docs Icon"
width={16}
height={16}
/>
}
canonicalUrl={this.props.attrs.href}
title="Google Docs"
border
/>
<Frame src={this.props.attrs.href} title="Google Docs Embed" border />
);
}
}
+5 -10
View File
@@ -14,19 +14,14 @@ describe("GoogleDocs", () => {
match
)
).toBeTruthy();
expect(
"https://docs.google.com/document/d/1SsDfWzFFTjZM2LanvpyUzjKhqVQpwpTMeiPeYxhVqOg/edit".match(
match
)
).toBeTruthy();
expect(
"https://docs.google.com/document/d/1SsDfWzFFTjZM2LanvpyUzjKhqVQpwpTMeiPeYxhVqOg/preview".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect(
"https://docs.google.com/document/d/e/2PACX-1vTdddHPoZ5M_47wmSHCoigR/edit".match(
match
)
).toBe(null);
expect("https://docs.google.com/document".match(match)).toBe(null);
expect("https://docs.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
+4 -16
View File
@@ -2,7 +2,9 @@
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp("^https?://docs.google.com/spreadsheets/d/(.*)$");
const URL_REGEX = new RegExp(
"^https?://docs.google.com/spreadsheets/d/(.*)/pub(.*)$"
);
type Props = {|
attrs: {|
@@ -16,21 +18,7 @@ export default class GoogleSlides extends React.Component<Props> {
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href.replace("/edit", "/preview")}
icon={
<img
src="/images/google-sheets.png"
alt="Google Sheets Icon"
width={16}
height={16}
/>
}
canonicalUrl={this.props.attrs.href}
title="Google Sheets"
border
/>
<Frame src={this.props.attrs.href} title="Google Sheets Embed" border />
);
}
}
+4 -4
View File
@@ -9,14 +9,14 @@ describe("GoogleSheets", () => {
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect(
"https://docs.google.com/spreadsheets/d/e/2PACX-1vTdddHPoZ5M_47wmSHCoigR/edit".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
).toBe(null);
expect("https://docs.google.com/spreadsheets".match(match)).toBe(null);
expect("https://docs.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
+5 -15
View File
@@ -2,7 +2,9 @@
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp("^https?://docs.google.com/presentation/d/(.*)$");
const URL_REGEX = new RegExp(
"^https?://docs.google.com/presentation/d/(.*)/pub(.*)$"
);
type Props = {|
attrs: {|
@@ -17,20 +19,8 @@ export default class GoogleSlides extends React.Component<Props> {
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href
.replace("/edit", "/preview")
.replace("/pub", "/embed")}
icon={
<img
src="/images/google-slides.png"
alt="Google Slides Icon"
width={16}
height={16}
/>
}
canonicalUrl={this.props.attrs.href}
title="Google Slides"
src={this.props.attrs.href.replace("/pub", "/embed")}
title="Google Slides Embed"
border
/>
);
+4 -4
View File
@@ -14,14 +14,14 @@ describe("GoogleSlides", () => {
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect(
"https://docs.google.com/presentation/d/e/2PACX-1vTdddHPoZ5M_47wmSHCoigR/edit".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
).toBe(null);
expect("https://docs.google.com/presentation".match(match)).toBe(null);
expect("https://docs.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
+1 -9
View File
@@ -12,7 +12,6 @@ const IMAGE_REGEX = new RegExp(
);
type Props = {|
isSelected: boolean,
attrs: {|
href: string,
matches: string[],
@@ -26,7 +25,6 @@ export default class InVision extends React.Component<Props> {
if (IMAGE_REGEX.test(this.props.attrs.href)) {
return (
<ImageZoom
className={this.props.isSelected ? "ProseMirror-selectednode" : ""}
image={{
src: this.props.attrs.href,
alt: "InVision Embed",
@@ -39,12 +37,6 @@ export default class InVision extends React.Component<Props> {
/>
);
}
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="InVision Embed"
/>
);
return <Frame src={this.props.attrs.href} title="InVision Embed" />;
}
}
+1 -1
View File
@@ -17,6 +17,6 @@ export default class Loom extends React.Component<Props> {
render() {
const normalizedUrl = this.props.attrs.href.replace("share", "embed");
return <Frame {...this.props} src={normalizedUrl} title="Loom Embed" />;
return <Frame src={normalizedUrl} title="Loom Embed" />;
}
}
-1
View File
@@ -20,7 +20,6 @@ export default class Lucidchart extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://lucidchart.com/documents/embeddedchart/${chartId}`}
title="Lucidchart Embed"
/>
+1 -8
View File
@@ -15,13 +15,6 @@ export default class Marvel extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="Marvel Embed"
border
/>
);
return <Frame src={this.props.attrs.href} title="Marvel Embed" border />;
}
}
-1
View File
@@ -21,7 +21,6 @@ export default class Mindmeister extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://www.mindmeister.com/maps/public_map_shell/${chartId}`}
title="Mindmeister Embed"
border
-1
View File
@@ -20,7 +20,6 @@ export default class RealtimeBoard extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://realtimeboard.com/app/embed/${boardId}`}
title={`RealtimeBoard (${boardId})`}
/>
+1 -5
View File
@@ -21,11 +21,7 @@ export default class ModeAnalytics extends React.Component<Props> {
const normalizedUrl = this.props.attrs.href.replace(/\/embed$/, "");
return (
<Frame
{...this.props}
src={`${normalizedUrl}/embed`}
title="Mode Analytics Embed"
/>
<Frame src={`${normalizedUrl}/embed`} title="Mode Analytics Embed" />
);
}
}
+1 -3
View File
@@ -17,8 +17,6 @@ export default class Prezi extends React.Component<Props> {
render() {
const url = this.props.attrs.href.replace(/\/embed$/, "");
return (
<Frame {...this.props} src={`${url}/embed`} title="Prezi Embed" border />
);
return <Frame src={`${url}/embed`} title="Prezi Embed" border />;
}
}
+2 -13
View File
@@ -25,21 +25,10 @@ export default class Spotify extends React.Component<Props> {
render() {
const normalizedPath = this.pathname.replace(/^\/embed/, "/");
var height;
if (normalizedPath.includes("episode") || normalizedPath.includes("show")) {
height = 232;
} else if (normalizedPath.includes("track")) {
height = 80;
} else {
height = 380;
}
return (
<Frame
{...this.props}
width="100%"
height={`${height}px`}
width="300px"
height="380px"
src={`https://open.spotify.com/embed${normalizedPath}`}
title="Spotify Embed"
allow="encrypted-media"
-1
View File
@@ -31,7 +31,6 @@ export default class Trello extends React.Component<Props> {
return (
<Frame
{...this.props}
width="248px"
height="185px"
src={`https://trello.com/embed/board?id=${objectId}`}
+1 -7
View File
@@ -17,12 +17,6 @@ export default class Typeform extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="Typeform Embed"
/>
);
return <Frame src={this.props.attrs.href} title="Typeform Embed" />;
}
}
-1
View File
@@ -20,7 +20,6 @@ export default class Vimeo extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://player.vimeo.com/video/${videoId}?byline=0`}
title={`Vimeo Embed (${videoId})`}
/>
-2
View File
@@ -5,7 +5,6 @@ import Frame from "./components/Frame";
const URL_REGEX = /(?:https?:\/\/)?(?:www\.)?youtu\.?be(?:\.com)?\/?.*(?:watch|embed)?(?:.*v=|v\/|\/)([a-zA-Z0-9_-]{11})$/i;
type Props = {|
isSelected: boolean,
attrs: {|
href: string,
matches: string[],
@@ -21,7 +20,6 @@ export default class YouTube extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://www.youtube.com/embed/${videoId}?modestbranding=1`}
title={`YouTube (${videoId})`}
/>
+6 -64
View File
@@ -1,18 +1,12 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import { OpenIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import Flex from "components/Flex";
type Props = {
src?: string,
border?: boolean,
title?: string,
icon?: React.Node,
canonicalUrl?: string,
isSelected?: boolean,
width?: string,
height?: string,
};
@@ -46,26 +40,15 @@ class Frame extends React.Component<PropsWithRef> {
width = "100%",
height = "400px",
forwardedRef,
icon,
title,
canonicalUrl,
isSelected,
src,
...rest
} = this.props;
const Component = border ? StyledIframe : "iframe";
const withBar = !!(icon || canonicalUrl);
return (
<Rounded
width={width}
height={height}
withBar={withBar}
className={isSelected ? "ProseMirror-selectednode" : ""}
>
<Rounded width={width} height={height}>
{this.isLoaded && (
<Component
ref={forwardedRef}
withBar={withBar}
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
width={width}
height={height}
@@ -73,60 +56,20 @@ class Frame extends React.Component<PropsWithRef> {
frameBorder="0"
title="embed"
loading="lazy"
src={src}
allowFullScreen
{...rest}
/>
)}
{withBar && (
<Bar align="center">
{icon} <Title>{title}</Title>
{canonicalUrl && (
<Open
href={canonicalUrl}
target="_blank"
rel="noopener noreferrer"
>
<OpenIcon color="currentColor" size={18} /> Open
</Open>
)}
</Bar>
)}
</Rounded>
);
}
}
const Rounded = styled.div`
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
border-radius: 3px;
overflow: hidden;
width: ${(props) => props.width};
height: ${(props) => (props.withBar ? props.height + 28 : props.height)};
`;
const Open = styled.a`
color: ${(props) => props.theme.textSecondary} !important;
font-size: 13px;
font-weight: 500;
align-items: center;
display: flex;
position: absolute;
right: 0;
padding: 0 8px;
`;
const Title = styled.span`
font-size: 13px;
font-weight: 500;
padding-left: 4px;
`;
const Bar = styled(Flex)`
background: ${(props) => props.theme.secondaryBackground};
color: ${(props) => props.theme.textSecondary};
padding: 0 8px;
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
user-select: none;
height: ${(props) => props.height};
`;
// This wrapper allows us to pass non-standard HTML attributes through to the DOM element
@@ -136,8 +79,7 @@ const Iframe = (props) => <iframe {...props} />;
const StyledIframe = styled(Iframe)`
border: 1px solid;
border-color: ${(props) => props.theme.embedBorder};
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
display: block;
border-radius: 3px;
`;
export default React.forwardRef<Props, typeof Frame>((props, ref) => (
-8
View File
@@ -3,7 +3,6 @@ import * as React from "react";
import styled from "styled-components";
import Abstract from "./Abstract";
import Airtable from "./Airtable";
import ClickUp from "./ClickUp";
import Codepen from "./Codepen";
import Figma from "./Figma";
import Framer from "./Framer";
@@ -58,13 +57,6 @@ export default [
component: Airtable,
matcher: matcher(Airtable),
},
{
title: "ClickUp",
keywords: "project",
icon: () => <Img src="/images/clickup.png" />,
component: ClickUp,
matcher: matcher(ClickUp),
},
{
title: "Codepen",
keywords: "code editor",
-9
View File
@@ -1,9 +0,0 @@
// @flow
import invariant from "invariant";
import useStores from "./useStores";
export default function useCurrentUser() {
const { auth } = useStores();
invariant(auth.user, "user required");
return auth.user;
}
-10
View File
@@ -1,10 +0,0 @@
// @flow
import * as React from "react";
export default function usePrevious(value: any) {
const ref = React.useRef();
React.useEffect(() => {
ref.current = value;
});
return ref.current;
}
-8
View File
@@ -1,8 +0,0 @@
// @flow
import { MobXProviderContext } from "mobx-react";
import * as React from "react";
import RootStore from "stores";
export default function useStores(): typeof RootStore {
return React.useContext(MobXProviderContext);
}
+14 -14
View File
@@ -1,13 +1,9 @@
// @flow
import "mobx-react-lite/batchingForReactDom";
import "focus-visible";
import { Provider } from "mobx-react";
import * as React from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { render } from "react-dom";
import { BrowserRouter as Router } from "react-router-dom";
import { initI18n } from "shared/i18n";
import stores from "stores";
import ErrorBoundary from "components/ErrorBoundary";
import ScrollToTop from "components/ScrollToTop";
@@ -16,16 +12,19 @@ import Toasts from "components/Toasts";
import Routes from "./routes";
import env from "env";
initI18n();
let DevTools;
if (process.env.NODE_ENV !== "production") {
DevTools = require("mobx-react-devtools").default; // eslint-disable-line global-require
}
const element = document.getElementById("root");
if (element) {
render(
<ErrorBoundary>
<Provider {...stores}>
<Theme>
<DndProvider backend={HTML5Backend}>
<>
<ErrorBoundary>
<Provider {...stores}>
<Theme>
<Router>
<>
<ScrollToTop>
@@ -34,10 +33,11 @@ if (element) {
<Toasts />
</>
</Router>
</DndProvider>
</Theme>
</Provider>
</ErrorBoundary>,
</Theme>
</Provider>
</ErrorBoundary>
{DevTools && <DevTools position={{ bottom: 0, right: 0 }} />}
</>,
element
);
}
+14 -18
View File
@@ -3,7 +3,6 @@ import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { SunIcon, MoonIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import AuthStore from "stores/AuthStore";
@@ -24,7 +23,6 @@ type Props = {
label: React.Node,
ui: UiStore,
auth: AuthStore,
t: TFunction,
};
@observer
@@ -44,14 +42,14 @@ class AccountMenu extends React.Component<Props> {
};
render() {
const { ui, t } = this.props;
const { ui } = this.props;
return (
<>
<Modal
isOpen={this.keyboardShortcutsOpen}
onRequestClose={this.handleCloseKeyboardShortcuts}
title={t("Keyboard shortcuts")}
title="Keyboard shortcuts"
>
<KeyboardShortcuts />
</Modal>
@@ -60,23 +58,23 @@ class AccountMenu extends React.Component<Props> {
label={this.props.label}
>
<DropdownMenuItem as={Link} to={settings()}>
{t("Settings")}
Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={this.handleOpenKeyboardShortcuts}>
{t("Keyboard shortcuts")}
Keyboard shortcuts
</DropdownMenuItem>
<DropdownMenuItem href={developers()} target="_blank">
{t("API documentation")}
API documentation
</DropdownMenuItem>
<hr />
<DropdownMenuItem href={changelog()} target="_blank">
{t("Changelog")}
Changelog
</DropdownMenuItem>
<DropdownMenuItem href={mailToUrl()} target="_blank">
{t("Send us feedback")}
Send us feedback
</DropdownMenuItem>
<DropdownMenuItem href={githubIssuesUrl()} target="_blank">
{t("Report a bug")}
Report a bug
</DropdownMenuItem>
<hr />
<DropdownMenu
@@ -89,7 +87,7 @@ class AccountMenu extends React.Component<Props> {
label={
<DropdownMenuItem>
<ChangeTheme justify="space-between">
{t("Appearance")}
Appearance
{ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />}
</ChangeTheme>
</DropdownMenuItem>
@@ -100,24 +98,24 @@ class AccountMenu extends React.Component<Props> {
onClick={() => ui.setTheme("system")}
selected={ui.theme === "system"}
>
{t("System")}
System
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => ui.setTheme("light")}
selected={ui.theme === "light"}
>
{t("Light")}
Light
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => ui.setTheme("dark")}
selected={ui.theme === "dark"}
>
{t("Dark")}
Dark
</DropdownMenuItem>
</DropdownMenu>
<hr />
<DropdownMenuItem onClick={this.handleLogout}>
{t("Log out")}
Log out
</DropdownMenuItem>
</DropdownMenu>
</>
@@ -129,6 +127,4 @@ const ChangeTheme = styled(Flex)`
width: 100%;
`;
export default withTranslation()<AccountMenu>(
inject("ui", "auth")(AccountMenu)
);
export default inject("ui", "auth")(AccountMenu);

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