Compare commits

..

2 Commits

Author SHA1 Message Date
Tom Moor e8168b52a2 chore: Cleanup related unused methods in UiStore 2020-08-11 22:23:23 -07:00
Tom Moor bfe10b1df0 refactor: Finally remove centralized Modals component 2020-08-11 22:20:13 -07:00
288 changed files with 4263 additions and 10247 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
"version": "2",
"proposals": true
},
"useBuiltIns": "usage"
"useBuiltIns": "usage",
}
]
],
+5 -8
View File
@@ -3,7 +3,7 @@ jobs:
build:
working_directory: ~/outline
docker:
- image: circleci/node:14
- image: circleci/node:12
- image: circleci/redis:latest
- image: circleci/postgres:9.6.5-alpine-ram
environment:
@@ -29,15 +29,12 @@ jobs:
- run:
name: migrate
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
- run:
name: test
command: yarn test
- run:
name: lint
command: yarn lint
- run:
name: flow
command: yarn flow check --max-workers 4
- run:
name: test
command: yarn test
- run:
name: build-webpack
command: yarn build:webpack
command: yarn flow check --max-workers 4
-19
View File
@@ -1,19 +0,0 @@
__mocks__
.git
.vscode
.github
.circleci
.DS_Store
.env*
.eslint*
.flowconfig
.log
Makefile
Procfile
app.json
build
docker-compose.yml
fakes3
flow-typed
node_modules
setupJest.js
+1 -2
View File
@@ -14,7 +14,7 @@ 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
@@ -45,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
+1 -5
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",
@@ -98,8 +97,5 @@
},
"env": {
"jest": true
},
"globals": {
"EDITOR_VERSION": true
}
}
-4
View File
@@ -11,10 +11,6 @@
.*/node_modules/react-side-effect/.*
.*/node_modules/fbjs/.*
.*/node_modules/config-chain/.*
.*/node_modules/yjs/.*
.*/node_modules/@tommoor/y-prosemirror/.*
.*/node_modules/y-protocols/.*
.*/node_modules/lib0/.*
.*/server/scripts/.*
*.test.js
-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.49.0
Licensed Work: Outline 0.44.0
The Licensed Work is (c) 2020 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2023-10-26
Change Date: 2023-07-03
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 -38
View File
@@ -22,38 +22,13 @@ If you'd like to run your own copy of Outline or contribute to development then
Outline requires the following dependencies:
- [Node.js](https://nodejs.org/) >= 12
- [Yarn](https://yarnpkg.com)
- [Postgres](https://www.postgresql.org/download/) >=9.5
- [Redis](https://redis.io/) >= 4
- AWS S3 bucket or compatible API for file storage
- Node.js >= 12
- Postgres >=9.5
- Redis >= 4
- AWS S3 storage bucket for media and other attachments
- Slack or Google developer application for authentication
### Production
For a manual self-hosted production installation these are the suggested steps:
1. Clone this repo and install dependencies with `yarn install`
1. Build the source code with `yarn build`
1. Using the `.env.sample` as a reference, set the required variables in your production environment. The following are required as a minimum:
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
1. `DATABASE_URL` (run your own local copy of Postgres, or use a cloud service)
1. `REDIS_URL` (run your own local copy of Redis, or use a cloud service)
1. `URL` (the public facing URL of your installation)
1. `AWS_` (all of the keys beginning with AWS)
1. Migrate database schema with `yarn sequelize:migrate`. Production assumes an SSL connection, if
Postgres is on the same machine and is not SSL you can migrate with `yarn sequelize:migrate --env=production-ssl-disabled`.
1. Start the service with any daemon tools you prefer. Take PM2 for example, `NODE_ENV=production pm2 start ./build/server/index.js --name outline `
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
> Port number can be changed using the `PORT` environment variable
1. (Optional) You can add an `nginx` reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc.
### Development
In development you can quickly get an environment running using Docker by following these steps:
@@ -75,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
@@ -139,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
-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",
+3 -8
View File
@@ -21,14 +21,9 @@ const Authenticated = observer(({ auth, 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) &&
+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;
+31 -74
View File
@@ -1,13 +1,11 @@
// @flow
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 { Link } from "react-router-dom";
@@ -27,73 +25,11 @@ type Props = {
onlyText: boolean,
};
function Icon({ document }) {
if (document.isDeleted) {
return (
<>
<CollectionName to="/trash">
<TrashIcon color="currentColor" />
&nbsp;
<span>Trash</span>
</CollectionName>
<Slash />
</>
);
}
if (document.isArchived) {
return (
<>
<CollectionName to="/archive">
<ArchiveIcon color="currentColor" />
&nbsp;
<span>Archive</span>
</CollectionName>
<Slash />
</>
);
}
if (document.isDraft) {
return (
<>
<CollectionName to="/drafts">
<EditIcon color="currentColor" />
&nbsp;
<span>Drafts</span>
</CollectionName>
<Slash />
</>
);
}
if (document.isTemplate) {
return (
<>
<CollectionName to="/templates">
<ShapesIcon color="currentColor" />
&nbsp;
<span>Templates</span>
</CollectionName>
<Slash />
</>
);
}
return null;
}
const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
let collection = collections.get(document.collectionId);
if (!collection) {
if (!document.deletedAt) return <div />;
const collection = collections.get(document.collectionId);
if (!collection) return <div />;
collection = {
id: document.collectionId,
name: "Deleted Collection",
color: "currentColor",
};
}
const path = collection.pathToDocument
? collection.pathToDocument(document).slice(0, -1)
: [];
const path = collection.pathToDocument(document).slice(0, -1);
if (onlyText === true) {
return (
@@ -114,13 +50,34 @@ const Breadcrumb = observer(({ document, collections, 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;
@@ -170,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;
}
`;
+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
View File
@@ -9,7 +9,6 @@ type Props = {
const Container = styled.div`
width: 100%;
max-width: 100vw;
padding: 60px 20px;
${breakpoint("tablet")`
+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"
+2 -2
View File
@@ -6,7 +6,7 @@ type Props = {
children: React.Node,
};
export default function DelayedMount({ delay = 250, children }: Props) {
export default function DelayedMount({ delay = 150, children }: Props) {
const [isShowing, setShowing] = React.useState(false);
React.useEffect(() => {
@@ -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>
+8 -30
View File
@@ -18,7 +18,8 @@ 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")};
`;
@@ -27,7 +28,6 @@ type Props = {
auth: AuthStore,
showCollection?: boolean,
showPublished?: boolean,
showLastViewed?: boolean,
document: Document,
children: React.Node,
to?: string,
@@ -38,7 +38,6 @@ function DocumentMeta({
collections,
showPublished,
showCollection,
showLastViewed,
document,
children,
to,
@@ -53,7 +52,6 @@ function DocumentMeta({
archivedAt,
deletedAt,
isDraft,
lastViewedAt,
} = document;
// Prevent meta information from displaying if updatedBy is not available.
@@ -67,37 +65,37 @@ function DocumentMeta({
if (deletedAt) {
content = (
<span>
deleted <Time dateTime={deletedAt} addSuffix />
deleted <Time dateTime={deletedAt} /> ago
</span>
);
} else if (archivedAt) {
content = (
<span>
archived <Time dateTime={archivedAt} addSuffix />
archived <Time dateTime={archivedAt} /> ago
</span>
);
} else if (createdAt === updatedAt) {
content = (
<span>
created <Time dateTime={updatedAt} addSuffix />
created <Time dateTime={updatedAt} /> ago
</span>
);
} else if (publishedAt && (publishedAt === updatedAt || showPublished)) {
content = (
<span>
published <Time dateTime={publishedAt} addSuffix />
published <Time dateTime={publishedAt} /> ago
</span>
);
} else if (isDraft) {
content = (
<span>
saved <Time dateTime={updatedAt} addSuffix />
saved <Time dateTime={updatedAt} /> ago
</span>
);
} else {
content = (
<Modified highlight={modifiedSinceViewed}>
updated <Time dateTime={updatedAt} addSuffix />
updated <Time dateTime={updatedAt} /> ago
</Modified>
);
}
@@ -105,25 +103,6 @@ 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>Never viewed</Modified>
</>
);
}
return (
<span>
&nbsp;Viewed <Time dateTime={lastViewedAt} addSuffix shorten />
</span>
);
};
return (
<Container align="center" {...rest}>
{updatedByMe ? "You" : updatedBy.name}&nbsp;
@@ -136,7 +115,6 @@ function DocumentMeta({
</strong>
</span>
)}
&nbsp;{timeSinceNow()}
{children}
</Container>
);
+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,15 +1,13 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import { StarredIcon, PlusIcon } from "outline-icons";
import * as React from "react";
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";
@@ -17,6 +15,7 @@ import DocumentMenu from "menus/DocumentMenu";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {
history: RouterHistory,
document: Document,
highlight?: ?string,
context?: ?string,
@@ -31,8 +30,6 @@ 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();
@@ -51,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,10 +73,6 @@ class DocumentPreview extends React.Component<Props> {
context,
} = this.props;
if (this.redirectTo) {
return <Redirect to={this.redirectTo} push />;
}
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
@@ -91,7 +86,6 @@ class DocumentPreview extends React.Component<Props> {
>
<Heading>
<Title text={document.titleWithDefault} highlight={highlight} />
{document.isNew && <Badge yellow>New</Badge>}
{!document.isDraft &&
!document.isArchived &&
!document.isTemplate && (
@@ -124,9 +118,7 @@ class DocumentPreview extends React.Component<Props> {
</Button>
)}
&nbsp;
<EventBoundary>
<DocumentMenu document={document} showPin={showPin} />
</EventBoundary>
<DocumentMenu document={document} showPin={showPin} />
</SecondaryActions>
</Heading>
@@ -141,7 +133,6 @@ class DocumentPreview extends React.Component<Props> {
document={document}
showCollection={showCollection}
showPublished={showPublished}
showLastViewed
/>
</DocumentLink>
);
@@ -178,7 +169,6 @@ const DocumentLink = styled(Link)`
border-radius: 8px;
max-height: 50vh;
min-width: 100%;
max-width: calc(100vw - 40px);
overflow: hidden;
position: relative;
@@ -190,6 +180,7 @@ const DocumentLink = styled(Link)`
&:active,
&:focus {
background: ${(props) => props.theme.listItemHoverBackground};
outline: none;
${SecondaryActions} {
opacity: 1;
@@ -237,4 +228,4 @@ const ResultContext = styled(Highlight)`
margin-bottom: 0.25em;
`;
export default DocumentPreview;
export default withRouter(DocumentPreview);
+6 -9
View File
@@ -7,8 +7,8 @@ import Dropzone from "react-dropzone";
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
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;
@@ -19,7 +19,6 @@ type Props = {
documentId?: string,
activeClassName?: string,
rejectClassName?: string,
ui: UiStore,
documents: DocumentsStore,
disabled: boolean,
location: Object,
@@ -62,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;
@@ -98,7 +95,7 @@ class DropToImport extends React.Component<Props> {
return (
<Dropzone
accept={documents.importFileTypes.join(", ")}
accept="text/markdown, text/plain"
onDropAccepted={this.onDropAccepted}
style={EMPTY_OBJECT}
disableClick
@@ -113,4 +110,4 @@ class DropToImport extends React.Component<Props> {
}
}
export default inject("documents", "ui")(withRouter(DropToImport));
export default inject("documents")(withRouter(DropToImport));
+4 -5
View File
@@ -18,7 +18,7 @@ type Children =
| React.Node
| ((options: { closePortal: () => void }) => React.Node);
type Props = {|
type Props = {
label?: React.Node,
onOpen?: () => void,
onClose?: () => void,
@@ -27,7 +27,7 @@ type Props = {|
hover?: boolean,
style?: Object,
position?: "left" | "right" | "center",
|};
};
@observer
class DropdownMenu extends React.Component<Props> {
@@ -177,7 +177,6 @@ class DropdownMenu extends React.Component<Props> {
{label || (
<NudeButton
id={`${this.id}button`}
aria-label="More options"
aria-haspopup="true"
aria-expanded={isOpen ? "true" : "false"}
aria-controls={this.id}
@@ -233,7 +232,7 @@ const Label = styled(Flex).attrs({
justify: "center",
align: "center",
})`
z-index: ${(props) => props.theme.depths.menu};
z-index: 1000;
cursor: pointer;
`;
@@ -245,7 +244,7 @@ const Position = styled.div`
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
${({ bottom }) => (bottom !== undefined ? `bottom: ${bottom}px` : "")};
max-height: 75%;
z-index: ${(props) => props.theme.depths.menu};
z-index: 1000;
transform: ${(props) =>
props.position === "center" ? "translateX(-50%)" : "initial"};
pointer-events: none;
@@ -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;
});
}
+14 -92
View File
@@ -2,16 +2,14 @@
import { lighten } from "polished";
import * as React from "react";
import { withRouter, type RouterHistory } from "react-router-dom";
import RichMarkdownEditor from "rich-markdown-editor";
import styled, { withTheme } from "styled-components";
import UiStore from "stores/UiStore";
import ErrorBoundary from "components/ErrorBoundary";
import Tooltip from "components/Tooltip";
import embeds from "../embeds";
import isInternalUrl from "utils/isInternalUrl";
import { uploadFile } from "utils/uploadFile";
const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));
const EMPTY_ARRAY = [];
type Props = {
@@ -24,7 +22,7 @@ type Props = {
};
type PropsWithRef = Props & {
forwardedRef: React.Ref<any>,
forwardedRef: React.Ref<RichMarkdownEditor>,
history: RouterHistory,
};
@@ -34,14 +32,14 @@ class Editor extends React.Component<PropsWithRef> {
return result.url;
};
onClickLink = (href: string, event: MouseEvent) => {
onClickLink = (href: string) => {
// on page hash
if (href[0] === "#") {
window.location.href = href;
return;
}
if (isInternalUrl(href) && !event.metaKey && !event.shiftKey) {
if (isInternalUrl(href)) {
// relative
let navigateTo = href;
@@ -56,7 +54,7 @@ class Editor extends React.Component<PropsWithRef> {
}
this.props.history.push(navigateTo);
} else if (href) {
} else {
window.open(href, "_blank");
}
};
@@ -69,17 +67,15 @@ class Editor extends React.Component<PropsWithRef> {
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>
<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}
/>
);
}
}
@@ -97,66 +93,6 @@ const StyledEditor = styled(RichMarkdownEditor)`
font-weight: 500;
}
.ProseMirror {
.ProseMirror-yjs-cursor {
position: relative;
margin-left: -1px;
margin-right: -1px;
border-left: 1px solid black;
border-right: 1px solid black;
height: 1em;
word-break: normal;
&:after {
content: "";
display: block;
position: absolute;
left: -8px;
right: -8px;
top: 0;
bottom: 0;
}
> div {
opacity: 0;
position: absolute;
top: -1.8em;
font-size: 13px;
background-color: rgb(250, 129, 0);
font-style: normal;
line-height: normal;
user-select: none;
white-space: nowrap;
color: white;
padding: 2px 6px;
font-weight: 500;
border-radius: 4px;
pointer-events: none;
left: -1px;
}
&:hover {
> div {
opacity: 1;
transition: opacity 100ms ease-in-out;
}
}
}
}
.heading-name {
pointer-events: none;
}
.heading-name:first-child {
& + h1,
& + h2,
& + h3,
& + h4 {
margin-top: 0;
}
}
p {
a {
color: ${(props) => props.theme.text};
@@ -187,17 +123,3 @@ const EditorWithRouterAndTheme = withRouter(withTheme(Editor));
export default React.forwardRef<Props, typeof Editor>((props, ref) => (
<EditorWithRouterAndTheme {...props} forwardedRef={ref} />
));
// > .ProseMirror-yjs-cursor:first-child {
// margin-top: 16px;
// }
// p:first-child,
// h1:first-child,
// h2:first-child,
// h3:first-child,
// h4:first-child,
// h5:first-child,
// h6:first-child {
// margin-top: 16px;
// }
+2 -34
View File
@@ -12,7 +12,6 @@ import env from "env";
type Props = {
children: React.Node,
reloadOnChunkMissing?: boolean,
};
@observer
@@ -24,25 +23,13 @@ class ErrorBoundary extends React.Component<Props> {
this.error = error;
console.error(error);
if (
this.props.reloadOnChunkMissing &&
error.message &&
error.message.match(/chunk/)
) {
// If the editor bundle fails to load then reload the entire window. This
// can happen if a deploy happens between the user loading the initial JS
// bundle and the async-loaded editor JS bundle as the hash will change.
window.location.reload(true);
return;
}
if (window.Sentry) {
window.Sentry.captureException(error);
}
}
handleReload = () => {
window.location.reload(true);
window.location.reload();
};
handleShowDetails = () => {
@@ -55,26 +42,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 +53,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>;
}
-24
View File
@@ -1,24 +0,0 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import Empty from "components/Empty";
import Fade from "components/Fade";
import Flex from "components/Flex";
export default function FullscreenLoading() {
return (
<Fade timing={500}>
<Centered>
<Empty>Loading</Empty>
</Centered>
</Fade>
);
}
const Centered = styled(Flex)`
text-align: center;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
`;
-1
View File
@@ -4,7 +4,6 @@ import styled from "styled-components";
const Heading = styled.h1`
display: flex;
align-items: center;
${(props) => (props.centered ? "text-align: center;" : "")}
svg {
margin-left: -6px;
+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/parseDocumentIds";
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 {
+8 -10
View File
@@ -3,7 +3,7 @@ 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/parseDocumentIds";
import DocumentsStore from "stores/DocumentsStore";
import DocumentMetaWithViews from "components/DocumentMetaWithViews";
import Editor from "components/Editor";
@@ -15,7 +15,7 @@ type Props = {
};
function HoverPreviewDocument({ url, documents, children }: Props) {
const slug = parseDocumentSlug(url);
const slug = parseDocumentSlugFromUrl(url);
documents.prefetchDocument(slug, {
prefetch: true,
@@ -29,14 +29,12 @@ function HoverPreviewDocument({ url, documents, children }: Props) {
<Heading>{document.titleWithDefault}</Heading>
<DocumentMetaWithViews isDraft={document.isDraft} document={document} />
<React.Suspense fallback={<div />}>
<Editor
key={document.id}
defaultValue={document.getSummary()}
disableEmbeds
readOnly
/>
</React.Suspense>
<Editor
key={document.id}
defaultValue={document.getSummary()}
disableEmbeds
readOnly
/>
</Content>
);
}
+9 -19
View File
@@ -22,17 +22,13 @@ import {
VehicleIcon,
} from "outline-icons";
import * as React from "react";
import { TwitterPicker } from "react-color";
import styled from "styled-components";
import { DropdownMenu } from "components/DropdownMenu";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import { LabelText } from "components/Input";
import NudeButton from "components/NudeButton";
const TwitterPicker = React.lazy(() =>
import("react-color/lib/components/twitter/Twitter")
);
export const icons = {
collection: {
component: CollectionIcon,
@@ -197,16 +193,14 @@ class IconPicker extends React.Component<Props> {
})}
</Icons>
<Flex onClick={preventEventBubble}>
<React.Suspense fallback={<Loading>Loading</Loading>}>
<ColorPicker
color={this.props.color}
onChange={(color) =>
this.props.onChange(color.hex, this.props.icon)
}
colors={colors}
triangle="hide"
/>
</React.Suspense>
<ColorPicker
color={this.props.color}
onChange={(color) =>
this.props.onChange(color.hex, this.props.icon)
}
colors={colors}
triangle="hide"
/>
</Flex>
</DropdownMenu>
</Wrapper>
@@ -232,10 +226,6 @@ const IconButton = styled(NudeButton)`
height: 30px;
`;
const Loading = styled(HelpText)`
padding: 16px;
`;
const ColorPicker = styled(TwitterPicker)`
box-shadow: none !important;
background: transparent !important;
+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`
+26 -4
View File
@@ -4,8 +4,6 @@ import { observer, inject } from "mobx-react";
import * as React from "react";
import styled, { withTheme } from "styled-components";
import UiStore from "stores/UiStore";
import Editor from "components/Editor";
import HelpText from "components/HelpText";
import { LabelText, Outline } from "components/Input";
type Props = {
@@ -21,6 +19,10 @@ class InputRich extends React.Component<Props> {
@observable editorComponent: React.ComponentType<any>;
@observable focused: boolean = false;
componentDidMount() {
this.loadEditor();
}
handleBlur = () => {
this.focused = false;
};
@@ -29,18 +31,36 @@ class InputRich extends React.Component<Props> {
this.focused = true;
};
loadEditor = async () => {
try {
const EditorImport = await import("./Editor");
this.editorComponent = EditorImport.default;
} catch (err) {
if (err.message && err.message.match(/chunk/)) {
// If the editor bundle fails to load then reload the entire window. This
// can happen if a deploy happens between the user loading the initial JS
// bundle and the async-loaded editor JS bundle as the hash will change.
window.location.reload();
return;
}
throw err;
}
};
render() {
const { label, minHeight, maxHeight, ui, ...rest } = this.props;
const Editor = this.editorComponent;
return (
<>
<LabelText>{label}</LabelText>
<StyledOutline
maxHeight={maxHeight}
minHeight={minHeight}
focused={this.focused}
>
<React.Suspense fallback={<HelpText>Loading editor</HelpText>}>
{Editor ? (
<Editor
onBlur={this.handleBlur}
onFocus={this.handleFocus}
@@ -48,7 +68,9 @@ class InputRich extends React.Component<Props> {
grow
{...rest}
/>
</React.Suspense>
) : (
"Loading…"
)}
</StyledOutline>
</>
);
+2 -7
View File
@@ -7,13 +7,11 @@ 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,
};
@@ -35,10 +33,7 @@ class InputSearch extends React.Component<Props> {
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)
);
};
+6 -8
View File
@@ -21,7 +21,6 @@ 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,7 +35,7 @@ type Props = {
auth: AuthStore,
ui: UiStore,
notifications?: React.Node,
theme: Theme,
theme: Object,
};
@observer
@@ -45,22 +44,21 @@ class Layout extends React.Component<Props> {
@observable redirectTo: ?string;
@observable keyboardShortcutsOpen: boolean = false;
constructor(props) {
super();
this.updateBackground(props);
componentWillMount() {
this.updateBackground();
}
componentDidUpdate() {
this.updateBackground(this.props);
this.updateBackground();
if (this.redirectTo) {
this.redirectTo = undefined;
}
}
updateBackground(props) {
updateBackground() {
// ensure the wider page color always matches the theme
window.document.body.style.background = props.theme.background;
window.document.body.style.background = this.props.theme.background;
}
@keydown("shift+/")
@@ -18,7 +18,7 @@ const loadingFrame = keyframes`
const Container = styled.div`
position: fixed;
top: 0;
z-index: ${(props) => props.theme.depths.loadingIndicatorBar};
z-index: 9999;
background-color: #03a9f4;
width: 100%;
+1 -2
View File
@@ -17,8 +17,7 @@ class Mask extends React.Component<Props> {
return false;
}
constructor() {
super();
componentWillMount() {
this.width = randomInteger(75, 100);
}
+18 -26
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");
@@ -24,12 +23,11 @@ const GlobalStyles = createGlobalStyle`
.ReactModal__Overlay {
background-color: ${(props) =>
transparentize(0.25, props.theme.background)} !important;
z-index: ${(props) => props.theme.depths.modalOverlay};
z-index: 100;
}
${breakpoint("tablet")`
.ReactModalPortal + .ReactModalPortal,
.ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
.ReactModalPortal + .ReactModalPortal {
.ReactModal__Overlay {
margin-left: 12px;
box-shadow: 0 -2px 10px ${(props) => props.theme.shadow};
@@ -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)`
@@ -118,13 +103,20 @@ const StyledModal = styled(ReactModal)`
left: 0;
bottom: 0;
right: 0;
z-index: ${(props) => props.theme.depths.modal};
z-index: 100;
display: flex;
justify-content: center;
align-items: flex-start;
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;
+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;
}
}
`;
+1 -1
View File
@@ -22,7 +22,7 @@ const StyledPopover = styled(BoundlessPopover)`
position: absolute;
top: 0;
left: 0;
z-index: ${(props) => props.theme.depths.popover};
z-index: 9999;
svg {
height: 16px;
+3 -2
View File
@@ -69,6 +69,7 @@ class MainSidebar extends React.Component<Props> {
const { user, team } = auth;
if (!user || !team) return null;
const draftDocumentsCount = documents.drafts.length;
const can = policies.abilities(team.id);
return (
@@ -122,8 +123,8 @@ class MainSidebar extends React.Component<Props> {
label={
<Drafts align="center">
Drafts
{documents.totalDrafts > 0 && (
<Bubble count={documents.totalDrafts} />
{draftDocumentsCount > 0 && (
<Bubble count={draftDocumentsCount} />
)}
</Drafts>
}
+15 -11
View File
@@ -10,6 +10,7 @@ import {
GroupIcon,
LinkIcon,
TeamIcon,
BulletedListIcon,
ExpandedIcon,
} from "outline-icons";
import * as React from "react";
@@ -30,8 +31,6 @@ 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,
@@ -41,7 +40,7 @@ type Props = {
@observer
class SettingsSidebar extends React.Component<Props> {
returnToDashboard = () => {
this.props.history.push("/home");
this.props.history.push("/");
};
render() {
@@ -117,6 +116,13 @@ class SettingsSidebar extends React.Component<Props> {
icon={<LinkIcon color="currentColor" />}
label="Share Links"
/>
{can.auditLog && (
<SidebarLink
to="/settings/events"
icon={<BulletedListIcon color="currentColor" />}
label="Audit Log"
/>
)}
{can.export && (
<SidebarLink
to="/settings/export"
@@ -133,16 +139,14 @@ class SettingsSidebar extends React.Component<Props> {
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>Installation</Header>
<Version />
+41 -36
View File
@@ -1,60 +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 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]);
};
const content = (
<Container
editMode={ui.editMode}
mobileSidebarVisible={ui.mobileSidebarVisible}
column
>
<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)`
@@ -66,7 +71,7 @@ const Container = styled(Flex)`
transition: left 100ms ease-out,
${(props) => props.theme.backgroundTransition};
margin-left: ${(props) => (props.mobileSidebarVisible ? 0 : "-100%")};
z-index: ${(props) => props.theme.depths.sidebar};
z-index: 1000;
@media print {
display: none;
@@ -112,4 +117,4 @@ const Toggle = styled.a`
`};
`;
export default withRouter(observer(Sidebar));
export default withRouter(inject("ui")(Sidebar));
@@ -10,34 +10,27 @@ 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 CollectionMenu from "menus/CollectionMenu";
type Props = {|
type Props = {
collection: Collection,
ui: UiStore,
canUpdate: boolean,
documents: DocumentsStore,
activeDocument: ?Document,
prefetchDocument: (id: string) => Promise<void>,
|};
};
@observer
class CollectionLink extends React.Component<Props> {
@observable menuOpen = false;
handleTitleChange = async (name: string) => {
await this.props.collection.save({ name });
};
render() {
const {
collection,
documents,
activeDocument,
prefetchDocument,
canUpdate,
ui,
} = this.props;
const expanded = collection.id === ui.activeCollectionId;
@@ -56,13 +49,7 @@ class CollectionLink extends React.Component<Props> {
expanded={expanded}
hideDisclosure
menuOpen={this.menuOpen}
label={
<EditableTitle
title={collection.name}
onSubmit={this.handleTitleChange}
canUpdate={canUpdate}
/>
}
label={collection.name}
exact={false}
menu={
<CollectionMenu
@@ -82,7 +69,6 @@ class CollectionLink extends React.Component<Props> {
collection={collection}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
canUpdate={canUpdate}
depth={1.5}
/>
))}
@@ -52,7 +52,7 @@ class Collections extends React.Component<Props> {
}
render() {
const { collections, ui, policies, documents } = this.props;
const { collections, ui, documents } = this.props;
const content = (
<>
@@ -63,7 +63,6 @@ class Collections extends React.Component<Props> {
collection={collection}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
canUpdate={policies.abilities(collection.id).update}
ui={ui}
/>
))}
@@ -9,21 +9,19 @@ import Document from "models/Document";
import DropToImport from "components/DropToImport";
import Fade from "components/Fade";
import Flex from "components/Flex";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import DocumentMenu from "menus/DocumentMenu";
import { type NavigationNode } from "types";
type Props = {|
type Props = {
node: NavigationNode,
documents: DocumentsStore,
canUpdate: boolean,
collection?: Collection,
activeDocument: ?Document,
activeDocumentRef?: (?HTMLElement) => void,
prefetchDocument: (documentId: string) => Promise<void>,
depth: number,
|};
};
@observer
class DocumentLink extends React.Component<Props> {
@@ -51,18 +49,6 @@ class DocumentLink extends React.Component<Props> {
prefetchDocument(node.id);
};
handleTitleChange = async (title: string) => {
const document = this.props.documents.get(this.props.node.id);
if (!document) return;
await this.props.documents.update({
id: document.id,
lastRevision: document.revision,
text: document.text,
title,
});
};
isActiveDocument = () => {
return (
this.props.activeDocument &&
@@ -83,7 +69,6 @@ class DocumentLink extends React.Component<Props> {
activeDocumentRef,
prefetchDocument,
depth,
canUpdate,
} = this.props;
const showChildren = !!(
@@ -96,7 +81,6 @@ class DocumentLink extends React.Component<Props> {
this.isActiveDocument())
);
const document = documents.get(node.id);
const title = node.title || "Untitled";
return (
<Flex
@@ -112,13 +96,7 @@ class DocumentLink extends React.Component<Props> {
state: { title: node.title },
}}
expanded={showChildren ? true : undefined}
label={
<EditableTitle
title={title}
onSubmit={this.handleTitleChange}
canUpdate={canUpdate}
/>
}
label={node.title || "Untitled"}
depth={depth}
exact={false}
menuOpen={this.menuOpen}
@@ -146,7 +124,6 @@ class DocumentLink extends React.Component<Props> {
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
depth={depth + 1}
canUpdate={canUpdate}
/>
))}
</DocumentChildren>
@@ -1,98 +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;
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;
@@ -1,15 +1,16 @@
// @flow
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import styled, { withTheme } from "styled-components";
import Flex from "components/Flex";
import TeamLogo from "components/TeamLogo";
type Props = {
teamName: string,
subheading: React.Node,
subheading: string,
showDisclosure?: boolean,
logoUrl: string,
theme: Object,
};
function HeaderBlock({
@@ -17,15 +18,16 @@ function HeaderBlock({
teamName,
subheading,
logoUrl,
theme,
...rest
}: 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}{" "}
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
{showDisclosure && <StyledExpandedIcon color={theme.text} />}
</TeamName>
<Subheading>{subheading}</Subheading>
</Flex>
@@ -57,16 +59,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: 16px 24px;
position: relative;
background: none;
line-height: inherit;
border: 0;
margin: 0;
cursor: pointer;
width: 100%;
@@ -77,4 +73,4 @@ const Header = styled.button`
}
`;
export default HeaderBlock;
export default withTheme(HeaderBlock);
@@ -1,11 +1,11 @@
// @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 Flex from "components/Flex";
import { type Theme } from "types";
type Props = {
to?: string | Object,
@@ -20,85 +20,84 @@ type Props = {
hideDisclosure?: boolean,
iconColor?: string,
active?: boolean,
theme: Theme,
theme: Object,
exact?: boolean,
depth?: number,
};
function SidebarLink({
icon,
children,
onClick,
to,
label,
active,
menu,
menuOpen,
hideDisclosure,
theme,
exact,
href,
depth,
...rest
}: Props) {
const [expanded, setExpanded] = React.useState(rest.expanded);
@observer
class SidebarLink extends React.Component<Props> {
@observable expanded: ?boolean = this.props.expanded;
const style = React.useMemo(() => {
return {
paddingLeft: `${(depth || 0) * 16 + 16}px`,
};
}, [depth]);
React.useEffect(() => {
if (rest.expanded !== undefined) {
setExpanded(rest.expanded);
}
}, [rest.expanded]);
const handleClick = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
setExpanded(!expanded);
},
[expanded]
);
const handleExpand = React.useCallback(() => {
setExpanded(true);
}, []);
const showDisclosure = !!children && !hideDisclosure;
const activeStyle = {
color: theme.text,
background: theme.sidebarItemBackground,
fontWeight: 600,
...style,
style = {
paddingLeft: `${(this.props.depth || 0) * 16 + 16}px`,
};
return (
<Wrapper column>
<StyledNavLink
activeStyle={activeStyle}
style={active ? activeStyle : style}
onClick={onClick}
exact={exact !== false}
to={to}
as={to ? undefined : href ? "a" : "div"}
href={href}
>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label onClick={handleExpand}>
{showDisclosure && (
<Disclosure expanded={expanded} onClick={handleClick} />
)}
{label}
</Label>
{menu && <Action menuOpen={menuOpen}>{menu}</Action>}
</StyledNavLink>
{expanded && children}
</Wrapper>
);
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
@@ -144,6 +143,7 @@ const StyledNavLink = styled(NavLink)`
&:focus {
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.black05};
outline: none;
}
&:hover {
@@ -171,4 +171,4 @@ const Disclosure = styled(CollapsedIcon)`
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
export default withRouter(withTheme(observer(SidebarLink)));
export default withRouter(withTheme(SidebarLink));
+16 -57
View File
@@ -3,7 +3,7 @@ import { find } from "lodash";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import io, { Socket } from "socket.io-client";
import io from "socket.io-client";
import AuthStore from "stores/AuthStore";
import CollectionsStore from "stores/CollectionsStore";
import DocumentPresenceStore from "stores/DocumentPresenceStore";
@@ -13,7 +13,6 @@ import MembershipsStore from "stores/MembershipsStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import ViewsStore from "stores/ViewsStore";
import { getVisibilityListener, getPageVisible } from "utils/pageVisibility";
export const SocketContext: any = React.createContext();
@@ -32,42 +31,12 @@ type Props = {
@observer
class SocketProvider extends React.Component<Props> {
@observable socket: Socket;
@observable socket;
componentDidMount() {
this.createConnection();
document.addEventListener(getVisibilityListener(), this.checkConnection);
}
componentWillUnmount() {
if (this.socket) {
this.socket.authenticated = false;
this.socket.disconnect();
}
document.removeEventListener(getVisibilityListener(), this.checkConnection);
}
checkConnection = () => {
if (this.socket && this.socket.disconnected && getPageVisible()) {
// null-ifying this reference is important, do not remove. Without it
// references to old sockets are potentially held in context
this.socket.close();
this.socket = null;
this.createConnection();
}
};
createConnection = () => {
this.socket = io(window.location.origin, {
path: "/realtime",
transports: ["websocket"],
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
});
this.socket.authenticated = false;
const {
@@ -96,12 +65,12 @@ class SocketProvider extends React.Component<Props> {
// when the socket is disconnected we need to clear all presence state as
// it's no longer reliable.
presence.clear();
});
// on reconnection, reset the transports option, as the Websocket
// connection may have failed (caused by proxy, firewall, browser, ...)
this.socket.on("reconnect_attempt", () => {
this.socket.io.opts.transports = ["polling", "websocket"];
if (reason === "io server disconnect") {
// the disconnection was initiated by the server, need to reconnect
// manually, else the socket will automatically try to reconnect
this.socket.connect();
}
});
this.socket.on("authenticated", () => {
@@ -125,8 +94,6 @@ class SocketProvider extends React.Component<Props> {
if (document) {
document.deletedAt = documentDescriptor.updatedAt;
}
policies.remove(documentId);
continue;
}
@@ -174,21 +141,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 +156,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;
}
}
@@ -308,7 +260,14 @@ class SocketProvider extends React.Component<Props> {
this.socket.on("user.presence", (event) => {
presence.touch(event.documentId, event.userId, event.isEditing);
});
};
}
componentWillUnmount() {
if (this.socket) {
this.socket.disconnect();
this.socket.authenticated = false;
}
}
render() {
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
View File
@@ -6,8 +6,6 @@ const Tabs = styled.nav`
border-bottom: 1px solid ${(props) => props.theme.divider};
margin-top: 22px;
margin-bottom: 12px;
overflow-y: auto;
white-space: nowrap;
`;
export const Separator = styled.span`
+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 -1
View File
@@ -34,7 +34,7 @@ const List = styled.ol`
list-style: none;
margin: 0;
padding: 0;
z-index: ${(props) => props.theme.depths.toasts};
z-index: 1000;
`;
export default inject("ui")(Toasts);
-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",
-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);
}
+22 -16
View File
@@ -1,6 +1,4 @@
// @flow
import "mobx-react-lite/batchingForReactDom";
import "focus-visible";
import { Provider } from "mobx-react";
import * as React from "react";
import { render } from "react-dom";
@@ -14,24 +12,32 @@ import Toasts from "components/Toasts";
import Routes from "./routes";
import env from "env";
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>
<Router>
<>
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
</>
</Router>
</Theme>
</Provider>
</ErrorBoundary>,
<>
<ErrorBoundary>
<Provider {...stores}>
<Theme>
<Router>
<>
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
</>
</Router>
</Theme>
</Provider>
</ErrorBoundary>
{DevTools && <DevTools position={{ bottom: 0, right: 0 }} />}
</>,
element
);
}
+44 -56
View File
@@ -11,11 +11,11 @@ import CollectionDelete from "scenes/CollectionDelete";
import CollectionEdit from "scenes/CollectionEdit";
import CollectionExport from "scenes/CollectionExport";
import CollectionMembers from "scenes/CollectionMembers";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import Modal from "components/Modal";
import VisuallyHidden from "components/VisuallyHidden";
import getDataTransferFiles from "utils/getDataTransferFiles";
import importFile from "utils/importFile";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {
@@ -55,13 +55,11 @@ class CollectionMenu extends React.Component<Props> {
const files = getDataTransferFiles(ev);
try {
const file = files[0];
const document = await this.props.documents.import(
file,
null,
this.props.collection.id,
{ publish: true }
);
const document = await importFile({
file: files[0],
documents: this.props.documents,
collectionId: this.props.collection.id,
});
this.props.history.push(document.url);
} catch (err) {
this.props.ui.showToast(err.message);
@@ -105,14 +103,7 @@ class CollectionMenu extends React.Component<Props> {
};
render() {
const {
policies,
documents,
collection,
position,
onOpen,
onClose,
} = this.props;
const { policies, collection, position, onOpen, onClose } = this.props;
const can = policies.abilities(collection.id);
return (
@@ -123,7 +114,7 @@ class CollectionMenu extends React.Component<Props> {
ref={(ref) => (this.file = ref)}
onChange={this.onFilePicked}
onClick={(ev) => ev.stopPropagation()}
accept={documents.importFileTypes.join(", ")}
accept="text/markdown, text/plain"
/>
</VisuallyHidden>
@@ -136,47 +127,44 @@ class CollectionMenu extends React.Component<Props> {
collection={collection}
onSubmit={this.handleMembersModalClose}
handleEditCollectionOpen={this.handleEditCollectionOpen}
onEdit={this.handleEditCollectionOpen}
/>
</Modal>
<DropdownMenu onOpen={onOpen} onClose={onClose} position={position}>
<DropdownMenuItems
items={[
{
title: "New document",
visible: !!(collection && can.update),
onClick: this.onNewDocument,
},
{
title: "Import document",
visible: !!(collection && can.update),
onClick: this.onImportDocument,
},
{
type: "separator",
},
{
title: "Edit…",
visible: !!(collection && can.update),
onClick: this.handleEditCollectionOpen,
},
{
title: "Permissions…",
visible: !!(collection && can.update),
onClick: this.handleMembersModalOpen,
},
{
title: "Export…",
visible: !!(collection && can.export),
onClick: this.handleExportCollectionOpen,
},
{
title: "Delete…",
visible: !!(collection && can.delete),
onClick: this.handleDeleteCollectionOpen,
},
]}
/>
{collection && (
<>
{can.update && (
<DropdownMenuItem onClick={this.onNewDocument}>
New document
</DropdownMenuItem>
)}
{can.update && (
<DropdownMenuItem onClick={this.onImportDocument}>
Import document
</DropdownMenuItem>
)}
{can.update && <hr />}
{can.update && (
<DropdownMenuItem onClick={this.handleEditCollectionOpen}>
Edit
</DropdownMenuItem>
)}
{can.update && (
<DropdownMenuItem onClick={this.handleMembersModalOpen}>
Permissions
</DropdownMenuItem>
)}
{can.export && (
<DropdownMenuItem onClick={this.handleExportCollectionOpen}>
Export
</DropdownMenuItem>
)}
</>
)}
{can.delete && (
<DropdownMenuItem onClick={this.handleDeleteCollectionOpen}>
Delete
</DropdownMenuItem>
)}
</DropdownMenu>
<Modal
title="Edit collection"

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