Compare commits

..

3 Commits

Author SHA1 Message Date
Translate-O-Tron 481605d017 New Crowdin updates (#1876)
* fix: New Russian translations from Crowdin [ci skip]

* fix: New Russian translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]
2021-02-18 18:48:53 -08:00
Translate-O-Tron 985ba9be29 fix: New Chinese Simplified translations from Crowdin [ci skip] (#1869) 2021-02-07 10:32:28 -08:00
Translate-O-Tron 8412efcd0c New Crowdin updates (#1864)
* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]
2021-02-04 18:19:29 -08:00
308 changed files with 4881 additions and 12355 deletions
+29 -74
View File
@@ -1,40 +1,31 @@
# 👋 Welcome, we're glad you're setting up an installation of Outline. Copy this
# file to .env or set the variables in your local environment manually. For
# development with docker this should mostly work out of the box other than
# setting the Slack keys and the SECRET_KEY.
# –––––––––––––––– REQUIRED ––––––––––––––––
# Generate a unique random key, you can use `openssl rand -hex 32` in terminal
# DO NOT LEAVE UNSET
# Copy this file to .env, remove this comment and change the keys. For development
# with docker this should mostly work out of the box other than setting the Slack
# keys (for auth) and the SECRET_KEY.
#
# Please use `openssl rand -hex 32` to create SECRET_KEY
SECRET_KEY=generate_a_new_key
# Generate a unique random key, you can use `openssl rand -hex 32` in terminal
# DO NOT LEAVE UNSET
UTILS_SECRET=generate_a_new_key
# For production point these at your databases, in development the default
# should work out of the box.
DATABASE_URL=postgres://user:pass@localhost:5532/outline
DATABASE_URL_TEST=postgres://user:pass@localhost:5532/outline-test
REDIS_URL=redis://localhost:6479
# URL should point to the fully qualified, publicly accessible URL. If using a
# proxy the port in URL and PORT may be different.
# Must point to the publicly accessible URL for the installation
URL=http://localhost:3000
PORT=3000
# Third party signin credentials, at least one of EITHER Google OR Slack is
# required for a working installation or you'll have no sign-in options.
# Optional. If using a Cloudfront distribution or similar the origin server
# should be set to the same as URL.
CDN_URL=
# To configure Slack auth, you'll need to create an Application at
# => https://api.slack.com/apps
#
# When configuring the Client ID, add a redirect URL under "OAuth & Permissions":
# https://<URL>/auth/slack.callback
# enforce (auto redirect to) https in production, (optional) default is true.
# set to false if your SSL is terminated at a loadbalancer, for example
FORCE_HTTPS=true
ENABLE_UPDATES=true
DEBUG=cache,presenters,events,emails,mailer,utils,multiplayer,server,services
# Third party signin credentials (at least one is required)
SLACK_KEY=get_a_key_from_slack
SLACK_SECRET=get_the_secret_of_above_key
@@ -42,59 +33,22 @@ SLACK_SECRET=get_the_secret_of_above_key
# => https://console.cloud.google.com/apis/credentials
#
# When configuring the Client ID, add an Authorized redirect URI:
# https://<URL>/auth/google.callback
# https://<your Outline URL>/auth/google.callback
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Comma separated list of domains to be allowed (optional)
# If not set, all Google apps domains are allowed by default
GOOGLE_ALLOWED_DOMAINS=
# –––––––––––––––– OPTIONAL ––––––––––––––––
# If using a Cloudfront/Cloudflare distribution or similar it can be set below.
# This will cause paths to javascript, stylesheets, and images to be updated to
# the hostname defined in CDN_URL. In your CDN configuration the origin server
# should be set to the same as URL.
CDN_URL=
# Auto-redirect to https in production. The default is true but you may set to
# false if you can be sure that SSL is terminated at an external loadbalancer.
FORCE_HTTPS=true
# Have the installation check for updates by sending anonymized statistics to
# the maintainers
ENABLE_UPDATES=true
# You may enable or disable debugging categories to increase the noisiness of
# logs. The default is a good balance
DEBUG=cache,presenters,events,emails,mailer,utils,multiplayer,server,services
# Comma separated list of domains to be allowed to signin to the wiki. If not
# set, all domains are allowed by default when using Google OAuth to signin
ALLOWED_DOMAINS=
# For a complete Slack integration with search and posting to channels the
# following configs are also needed, some more details
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
#
SLACK_VERIFICATION_TOKEN=your_token
# Third party credentials (optional)
SLACK_VERIFICATION_TOKEN=PLxk6OlXXXXXVj3YYYY
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# Optionally enable google analytics to track pageviews in the knowledge base
GOOGLE_ANALYTICS_ID=
# Optionally enable Sentry (sentry.io) to track errors and performance
SENTRY_DSN=
# To support uploading of images for avatars and document attachments an
# s3-compatible storage must be provided. AWS S3 is recommended for redundency
# however if you want to keep all file storage local an alternative such as
# minio (https://github.com/minio/minio) can be used.
# A more detailed guide on setting up S3 is available here:
# => https://wiki.generaloutline.com/share/125de1cc-9ff6-424b-8415-0d58c809a40f
#
# AWS credentials (optional in development)
AWS_ACCESS_KEY_ID=get_a_key_from_aws
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
AWS_REGION=xx-xxxx-x
@@ -102,10 +56,11 @@ 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
# To support sending outgoing transactional emails such as "document updated" or
# "you've been invited" you'll need to provide authentication for an SMTP server
# Emails configuration (optional)
SMTP_HOST=
SMTP_PORT=
SMTP_USERNAME=
@@ -116,6 +71,6 @@ SMTP_REPLY_EMAIL=
# Custom logo that displays on the authentication screen, scaled to height: 60px
# TEAM_LOGO=https://example.com/images/logo.png
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
# See translate.getoutline.com for a list of available language codes and their
# percentage translated.
DEFAULT_LANGUAGE=en_US
-3
View File
@@ -1,3 +0,0 @@
# These are supported funding model platforms
github: [outline]
+1
View File
@@ -1,6 +1,7 @@
dist
build
node_modules/*
server/scripts
.env
.log
npm-debug.log
-67
View File
@@ -1,67 +0,0 @@
# Architecture
Outline is composed of a backend and frontend codebase in this monorepo. As both are written in Javascript, they share some code where possible. We utilize the latest ES6 language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier formatting and ESLint are enforced by CI.
## Frontend
Outline's frontend is a React application compiled with [Webpack](https://webpack.js.org/). It uses [MobX](https://mobx.js.org/) for state management and [Styled Components](https://www.styled-components.com/) for component styles. Unless global, state logic and styles are always co-located with React components together with their subcomponents to make the component tree easier to manage.
> Important Note: The Outline editor is built on [Prosemirror](https://github.com/prosemirror) and managed in a separate open source repository to encourage re-use: [rich-markdown-editor](https://github.com/outline/rich-markdown-editor).
```
app
├── components - React components reusable across scenes
├── embeds - Embed definitions that represent rich interactive embeds in the editor
├── hooks - Reusable React hooks
├── menus - Context menus, often appear in multiple places in the UI
├── models - State models using MobX observables
├── routes - Route definitions, note that chunks are async loaded with suspense
├── scenes - A scene represents a full-page view that contains several components
├── stores - Collections of models and associated fetch logic
├── types - Flow types
└── utils - Utility methods specific to the frontend
```
## Backend
The API server is driven by [Koa](http://koajs.com/), it uses [Sequelize](http://docs.sequelizejs.com/) as the ORM and Redis with [Bull](https://github.com/OptimalBits/bull) for queues and async event management. Authorization logic
is contained in [cancan](https://www.npmjs.com/package/cancan) policies under the "policies" directory.
Interested in more documentation on the API routes? Check out the [API documentation](https://getoutline.com/developers).
```
server
├── api - All API routes are contained within here
│ └── middlewares - Koa middlewares specific to the API
├── auth - Authentication logic
│ └── providers - Authentication providers export passport.js strategies and config
├── commands - We are gradually moving to the command pattern for new write logic
├── config - Database configuration
├── emails - Transactional email templates
│ └── components - Shared React components for email templates
├── middlewares - Koa middlewares
├── migrations - Database migrations
├── models - Sequelize models
├── onboarding - Markdown templates for onboarding documents
├── policies - Authorization logic based on cancan
├── presenters - JSON presenters for database models, the interface between backend -> frontend
├── services - Service definitions are triggered for events and perform async jobs
├── static - Static assets
├── test - Test helpers and fixtures, tests themselves are colocated
└── utils - Utility methods specific to the backend
```
## Shared
Where logic is shared between the client and server it is placed in this directory. This is generally
small utilities.
```
shared
├── i18n - Internationalization confiuration
│ └── locales - Language specific translation files
├── styles - Styles, colors and other global aesthetics
├── utils - Shared utility methods
└── constants - Shared constants
```
+93 -78
View File
@@ -6,7 +6,7 @@
<p align="center">
<i>An open, extensible, wiki for your team built using React and Node.js.<br/>Try out Outline using our hosted version at <a href="https://www.getoutline.com">www.getoutline.com</a>.</i>
<br/>
<img width="1640" alt="screenshot" src="https://user-images.githubusercontent.com/380914/110356468-26374600-7fef-11eb-9f6a-f2cc2c8c6590.png">
<img src="https://user-images.githubusercontent.com/380914/78513257-153ae080-775f-11ea-9b49-1e1939451a3e.png" alt="Outline" width="800" />
</p>
<p align="center">
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield&amp;circle-token=c0c4c2f39990e277385d5c1ae96169c409eb887a"></a>
@@ -19,7 +19,7 @@ This is the source code that runs [**Outline**](https://www.getoutline.com) and
If you'd like to run your own copy of Outline or contribute to development then this is the place for you.
# Installation
## Installation
Outline requires the following dependencies:
@@ -31,63 +31,38 @@ Outline requires the following dependencies:
- Slack or Google developer application for authentication
## Self-Hosted Production
### Production
### Docker
For a manual self-hosted production installation these are the suggested steps:
For a manual self-hosted production installation these are the recommended steps:
1. First setup Redis and Postgres servers, this is outside the scope of the guide.
1. Download the latest official Docker image, new releases are available around the middle of every month:
`docker pull outlinewiki/outline`
1. Using the [.env.sample](.env.sample) as a reference, set the required variables in your production environment. You can export the environment variables directly, or create a `.env` file and pass it to the docker image like so:
`docker run --env-file=.env outlinewiki/outline`
1. Setup the database with `yarn db:migrate`. Production assumes an SSL connection to the database by default, if
Postgres is on the same machine and is not SSL you can migrate with `yarn db:migrate --env=production-ssl-disabled`, for example:
`docker run --rm outlinewiki/outline yarn db:migrate`
1. Start the container:
`docker run outlinewiki/outline`
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` or other reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc.
### Terraform
Alternatively a community member maintains a script to deploy Outline on Google Cloud Platform with [Terraform & Ansible](https://github.com/rjsgn/outline-terraform-ansible).
### Upgrading
#### Docker
If you're running Outline with Docker you'll need to run migrations within the docker container after updating the image. The command will be something like:
```shell
docker run --rm outlinewiki/outline:latest yarn db:migrate
```
#### Git
If you're running Outline by cloning this repository, run the following command to upgrade:
```shell
yarn run upgrade
```
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.
## Local Development
### Development
For contributing features and fixes you can quickly get an environment running using Docker by following these steps:
In development you can quickly get an environment running using Docker by following these steps:
1. Install these dependencies if you don't already have them
1. [Docker for Desktop](https://www.docker.com)
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
1. [Yarn](https://yarnpkg.com)
1. [Docker for Desktop](https://www.docker.com)
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
1. [Yarn](https://yarnpkg.com)
1. Clone this repo
1. Register a Slack app at https://api.slack.com/apps
1. Copy the file `.env.sample` to `.env`
@@ -100,36 +75,77 @@ For contributing features and fixes you can quickly get an environment running u
1. Ensure that the bot token scope contains at least `users:read`
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
### Upgrade
# Contributing
#### Docker
Outline is built and maintained by a small team we'd love your help to fix bugs and add features!
If you're running Outline with Docker you'll need to run migrations within the docker container after updating the image. The command will be something like:
```
docker run --rm outlinewiki/outline:latest yarn sequelize:migrate
```
#### Yarn
Before submitting a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and we'd also love to hear from you in the [Discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher liklihood of code being accepted.
If you're running Outline by cloning this repository, run the following command to upgrade:
```
yarn run upgrade
```
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
## Development
* [Translation](TRANSLATION.md) into other languages
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
* Performance improvements, both on server and frontend
* Developer happiness and documentation
* Bugs and other issues listed on GitHub
## Architecture
If you're interested in contributing or learning more about the Outline codebase
please refer to the [architecture document](ARCHITECTURE.md) first for a high level overview of how the application is put together.
## Debugging
### Server
Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging output, the following categories are available:
```
DEBUG=sql,cache,presenters,events,importer,exporter,emails,mailer
DEBUG=sql,cache,presenters,events,logistics,emails,mailer
```
## Migrations
Sequelize is used to create and run migrations, for example:
```
yarn sequelize migration:generate --name my-migration
yarn sequelize db:migrate
```
Or to run migrations on test database:
```
yarn sequelize db:migrate --env test
```
## Structure
Outline is composed of separate backend and frontend application which are both driven by the same Node process. As both are written in Javascript, they share some code but are mostly separate. We utilize the latest language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier and ESLint are enforced by CI.
### Frontend
Outline's frontend is a React application compiled with [Webpack](https://webpack.js.org/). It uses [Mobx](https://mobx.js.org/) for state management and [Styled Components](https://www.styled-components.com/) for component styles. Unless global, state logic and styles are always co-located with React components together with their subcomponents to make the component tree easier to manage.
The editor itself is built on [Prosemirror](https://github.com/prosemirror) and hosted in a separate repository to encourage reuse: [rich-markdown-editor](https://github.com/outline/rich-markdown-editor)
- `app/` - Frontend React application
- `app/scenes` - Full page views
- `app/components` - Reusable React components
- `app/stores` - Global state stores
- `app/models` - State models
- `app/types` - Flow types for non-models
### Backend
Backend is driven by [Koa](http://koajs.com/) (API, web server), [Sequelize](http://docs.sequelizejs.com/) (database) and React for public pages and emails.
- `server/api` - API endpoints
- `server/commands` - Domain logic, currently being refactored from /models
- `server/emails` - React rendered email templates
- `server/models` - Database models
- `server/policies` - Authorization logic
- `server/presenters` - API responses for database models
- `server/test` - Test helps and support
- `server/utils` - Utility methods
- `shared` - Code shared between frontend and backend applications
## Tests
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
@@ -155,21 +171,20 @@ yarn test:server
yarn test:app
```
## Migrations
## Contributing
Sequelize is used to create and run migrations, for example:
Outline is built and maintained by a small team we'd love your help to fix bugs and add features!
```
yarn sequelize migration:generate --name my-migration
yarn sequelize db:migrate
```
However, before working on a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and we'd also love to hear from you in the [Discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and will hopefully help to get your contributions integrated faster!
Or to run migrations on test database:
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
```
yarn sequelize db:migrate --env test
```
* [Translation](TRANSLATION.md) into other languages
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
* Performance improvements, both on server and frontend
* Developer happiness and documentation
* Bugs and other issues listed on GitHub
## License
Outline is [BSL 1.1 licensed](LICENSE).
Outline is [BSL 1.1 licensed](https://github.com/outline/outline/blob/master/LICENSE).
+1 -5
View File
@@ -30,10 +30,6 @@
"postdeploy": "yarn sequelize db:migrate"
},
"env": {
"NODE_ENV": {
"value": "production",
"required": true
},
"SECRET_KEY": {
"description": "A secret key",
"generator": "secret",
@@ -55,7 +51,7 @@
"description": "",
"required": false
},
"ALLOWED_DOMAINS": {
"GOOGLE_ALLOWED_DOMAINS": {
"description": "Comma separated list of domains to be allowed (optional). If not set, all Google apps domains are allowed by default",
"required": false
},
-5
View File
@@ -29,11 +29,6 @@ export default class Analytics extends React.Component<Props> {
script.src = "https://www.google-analytics.com/analytics.js";
script.async = true;
// Track PWA install event
window.addEventListener("appinstalled", () => {
ga("send", "event", "pwa", "install");
});
if (document.body) {
document.body.appendChild(script);
}
-23
View File
@@ -1,23 +0,0 @@
// @flow
import * as React from "react";
export default function Arrow() {
return (
<svg
width="13"
height="30"
viewBox="0 0 13 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="currentColor"
d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z"
/>
<path
fill="currentColor"
d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z"
/>
</svg>
);
}
-19
View File
@@ -1,19 +0,0 @@
// @flow
import * as React from "react";
import SlackLogo from "../SlackLogo";
import GoogleLogo from "./GoogleLogo";
type Props = {|
providerName: string,
|};
export default function AuthLogo({ providerName }: Props) {
switch (providerName) {
case "slack":
return <SlackLogo size={16} />;
case "google":
return <GoogleLogo size={16} />;
default:
return null;
}
}
+6 -8
View File
@@ -3,17 +3,15 @@ import styled from "styled-components";
const Badge = styled.span`
margin-left: 10px;
padding: 1px 5px 2px;
padding: 2px 6px 3px;
background-color: ${({ yellow, primary, theme }) =>
yellow ? theme.yellow : primary ? theme.primary : "transparent"};
yellow ? theme.yellow : primary ? theme.primary : theme.textTertiary};
color: ${({ primary, yellow, theme }) =>
primary ? theme.white : yellow ? theme.almostBlack : theme.textTertiary};
border: 1px solid
${({ primary, yellow, theme }) =>
primary || yellow ? "transparent" : theme.textTertiary};
border-radius: 8px;
font-size: 12px;
primary ? theme.white : yellow ? theme.almostBlack : theme.background};
border-radius: 4px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
user-select: none;
`;
+29 -18
View File
@@ -4,6 +4,7 @@ import {
ArchiveIcon,
EditIcon,
GoToIcon,
PadlockIcon,
ShapesIcon,
TrashIcon,
} from "outline-icons";
@@ -11,6 +12,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Document from "models/Document";
import CollectionIcon from "components/CollectionIcon";
import Flex from "components/Flex";
@@ -18,11 +20,10 @@ import useStores from "hooks/useStores";
import BreadcrumbMenu from "menus/BreadcrumbMenu";
import { collectionUrl } from "utils/routeHelpers";
type Props = {|
type Props = {
document: Document,
children?: React.Node,
onlyText: boolean,
|};
};
function Icon({ document }) {
const { t } = useTranslation();
@@ -78,14 +79,10 @@ function Icon({ document }) {
return null;
}
const Breadcrumb = ({ document, children, onlyText }: Props) => {
const Breadcrumb = ({ document, onlyText }: Props) => {
const { collections } = useStores();
const { t } = useTranslation();
if (!collections.isLoaded) {
return;
}
let collection = collections.get(document.collectionId);
if (!collection) {
collection = {
@@ -102,6 +99,11 @@ const Breadcrumb = ({ document, children, onlyText }: Props) => {
if (onlyText === true) {
return (
<>
{collection.private && (
<>
<SmallPadlockIcon color="currentColor" size={16} />{" "}
</>
)}
{collection.name}
{path.map((n) => (
<React.Fragment key={n.id}>
@@ -118,7 +120,7 @@ const Breadcrumb = ({ document, children, onlyText }: Props) => {
const menuPath = isNestedDocument ? path.slice(0, -1) : [];
return (
<Flex justify="flex-start" align="center">
<Wrapper justify="flex-start" align="center">
<Icon document={document} />
<CollectionName to={collectionUrl(collection.id)}>
<CollectionIcon collection={collection} expanded />
@@ -138,8 +140,7 @@ const Breadcrumb = ({ document, children, onlyText }: Props) => {
</Crumb>
</>
)}
{children}
</Flex>
</Wrapper>
);
};
@@ -148,14 +149,24 @@ export const Slash = styled(GoToIcon)`
fill: ${(props) => props.theme.divider};
`;
const SmallSlash = styled(GoToIcon)`
width: 12px;
height: 12px;
vertical-align: middle;
flex-shrink: 0;
const Wrapper = styled(Flex)`
display: none;
fill: ${(props) => props.theme.slate};
opacity: 0.5;
${breakpoint("tablet")`
display: flex;
`};
`;
const SmallPadlockIcon = styled(PadlockIcon)`
display: inline-block;
vertical-align: sub;
`;
const SmallSlash = styled(GoToIcon)`
width: 15px;
height: 10px;
flex-shrink: 0;
opacity: 0.25;
`;
const Crumb = styled(Link)`
+4 -5
View File
@@ -3,18 +3,17 @@ import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
type Props = {|
type Props = {
children?: React.Node,
withStickyHeader?: boolean,
|};
};
const Container = styled.div`
width: 100%;
max-width: 100vw;
padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
padding: 60px 20px;
${breakpoint("tablet")`
padding: ${(props) => (props.withStickyHeader ? "4px 60px" : "60px")};
padding: 60px;
`};
`;
+1 -2
View File
@@ -6,7 +6,7 @@ import HelpText from "components/HelpText";
export type Props = {|
checked?: boolean,
label?: React.Node,
label?: string,
labelHidden?: boolean,
className?: string,
name?: string,
@@ -26,7 +26,6 @@ const LabelText = styled.span`
const Wrapper = styled.div`
padding-bottom: 8px;
${(props) => (props.small ? "font-size: 14px" : "")};
width: 100%;
`;
const Label = styled.label`
+2 -9
View File
@@ -2,9 +2,8 @@
import { sortBy, keyBy } from "lodash";
import { observer, inject } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { MAX_AVATAR_DISPLAY } from "shared/constants";
import DocumentPresenceStore from "stores/DocumentPresenceStore";
import ViewsStore from "stores/ViewsStore";
import Document from "models/Document";
@@ -52,7 +51,7 @@ class Collaborators extends React.Component<Props> {
const overflow = documentViews.length - mostRecentViewers.length;
return (
<FacepileHiddenOnMobile
<Facepile
users={mostRecentViewers.map((v) => v.user)}
overflow={overflow}
renderAvatar={(user) => {
@@ -76,10 +75,4 @@ class Collaborators extends React.Component<Props> {
}
}
const FacepileHiddenOnMobile = styled(Facepile)`
${breakpoint("mobile", "tablet")`
display: none;
`};
`;
export default inject("views", "presence")(Collaborators);
-211
View File
@@ -1,211 +0,0 @@
// @flow
import { observer } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Collection from "models/Collection";
import Arrow from "components/Arrow";
import ButtonLink from "components/ButtonLink";
import Editor from "components/Editor";
import LoadingIndicator from "components/LoadingIndicator";
import NudeButton from "components/NudeButton";
import useDebouncedCallback from "hooks/useDebouncedCallback";
import useStores from "hooks/useStores";
type Props = {|
collection: Collection,
|};
function CollectionDescription({ collection }: Props) {
const { collections, ui, policies } = useStores();
const { t } = useTranslation();
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
const [isDirty, setDirty] = React.useState(false);
const can = policies.abilities(collection.id);
const handleStartEditing = React.useCallback(() => {
setEditing(true);
}, []);
const handleStopEditing = React.useCallback(() => {
setEditing(false);
}, []);
const handleClickDisclosure = React.useCallback(
(event) => {
event.preventDefault();
if (isExpanded && document.activeElement) {
document.activeElement.blur();
}
setExpanded(!isExpanded);
},
[isExpanded]
);
const handleSave = useDebouncedCallback(async (getValue) => {
try {
await collection.save({
description: getValue(),
});
setDirty(false);
} catch (err) {
ui.showToast(
t("Sorry, an error occurred saving the collection", {
type: "error",
})
);
throw err;
}
}, 1000);
const handleChange = React.useCallback(
(getValue) => {
setDirty(true);
handleSave(getValue);
},
[handleSave]
);
React.useEffect(() => {
setEditing(false);
}, [collection.id]);
const placeholder = `${t("Add a description")}`;
const key = isEditing || isDirty ? "draft" : collection.updatedAt;
return (
<MaxHeight data-editing={isEditing} data-expanded={isExpanded}>
<Input
$isEditable={can.update}
data-editing={isEditing}
data-expanded={isExpanded}
>
<span onClick={can.update ? handleStartEditing : undefined}>
{collections.isSaving && <LoadingIndicator />}
{collection.hasDescription || isEditing || isDirty ? (
<React.Suspense fallback={<Placeholder>Loading</Placeholder>}>
<Editor
key={key}
defaultValue={collection.description || ""}
onChange={handleChange}
placeholder={placeholder}
readOnly={!isEditing}
autoFocus={isEditing}
onBlur={handleStopEditing}
maxLength={1000}
disableEmbeds
readOnlyWriteCheckboxes
grow
/>
</React.Suspense>
) : (
can.update && <Placeholder>{placeholder}</Placeholder>
)}
</span>
</Input>
{!isEditing && (
<Disclosure
onClick={handleClickDisclosure}
aria-label={isExpanded ? t("Collapse") : t("Expand")}
size={30}
>
<Arrow />
</Disclosure>
)}
</MaxHeight>
);
}
const Disclosure = styled(NudeButton)`
opacity: 0;
color: ${(props) => props.theme.divider};
position: absolute;
top: calc(25vh - 50px);
left: 50%;
z-index: 1;
transform: rotate(-90deg) translateX(-50%);
transition: opacity 100ms ease-in-out;
&:focus,
&:hover {
opacity: 1;
}
&:active {
color: ${(props) => props.theme.sidebarText};
}
`;
const Placeholder = styled(ButtonLink)`
color: ${(props) => props.theme.placeholder};
cursor: text;
min-height: 27px;
`;
const MaxHeight = styled.div`
position: relative;
max-height: 25vh;
overflow: hidden;
margin: -12px -8px -8px;
padding: 8px;
&[data-editing="true"],
&[data-expanded="true"] {
max-height: initial;
overflow: initial;
${Disclosure} {
top: initial;
bottom: 0;
transform: rotate(90deg) translateX(-50%);
}
}
&:hover ${Disclosure} {
opacity: 1;
}
`;
const Input = styled.div`
margin: -8px;
padding: 8px;
border-radius: 8px;
transition: ${(props) => props.theme.backgroundTransition};
&:after {
content: "";
position: absolute;
top: calc(25vh - 50px);
left: 0;
right: 0;
height: 50px;
pointer-events: none;
background: linear-gradient(
180deg,
${(props) => transparentize(1, props.theme.background)} 0%,
${(props) => props.theme.background} 100%
);
}
&[data-editing="true"],
&[data-expanded="true"] {
&:after {
background: transparent;
}
}
&[data-editing="true"] {
background: ${(props) => props.theme.secondaryBackground};
}
.block-menu-trigger,
.heading-anchor {
display: none !important;
}
`;
export default observer(CollectionDescription);
+12 -7
View File
@@ -1,21 +1,20 @@
// @flow
import { observer } from "mobx-react";
import { CollectionIcon } from "outline-icons";
import { inject, observer } from "mobx-react";
import { PrivateCollectionIcon, CollectionIcon } from "outline-icons";
import { getLuminance } from "polished";
import * as React from "react";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import { icons } from "components/IconPicker";
import useStores from "hooks/useStores";
type Props = {
collection: Collection,
expanded?: boolean,
size?: number,
ui: UiStore,
};
function ResolvedCollectionIcon({ collection, expanded, size }: Props) {
const { ui } = useStores();
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 =
@@ -34,7 +33,13 @@ function ResolvedCollectionIcon({ collection, expanded, size }: Props) {
}
}
if (collection.private) {
return (
<PrivateCollectionIcon color={color} expanded={expanded} size={size} />
);
}
return <CollectionIcon color={color} expanded={expanded} size={size} />;
}
export default observer(ResolvedCollectionIcon);
export default inject("ui")(observer(ResolvedCollectionIcon));
+2 -21
View File
@@ -13,7 +13,6 @@ type Props = {|
href?: string,
target?: "_blank",
as?: string | React.ComponentType<*>,
hide?: () => void,
|};
const MenuItem = ({
@@ -22,34 +21,16 @@ const MenuItem = ({
selected,
disabled,
as,
hide,
...rest
}: Props) => {
const handleClick = React.useCallback(
(ev) => {
if (onClick) {
onClick(ev);
}
if (hide) {
hide();
}
},
[hide, onClick]
);
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
disabled={disabled}
hide={hide}
{...rest}
>
{(props) => (
<MenuAnchor
{...props}
as={onClick ? "button" : as}
onClick={handleClick}
>
<MenuAnchor as={onClick ? "button" : as} {...props}>
{selected !== undefined && (
<>
{selected ? <CheckmarkIcon /> : <Spacer />}
@@ -85,7 +66,7 @@ export const MenuAnchor = styled.a`
user-select: none;
svg:not(:last-child) {
margin-right: 4px;
margin-right: 8px;
}
svg {
+1 -2
View File
@@ -59,8 +59,7 @@ type Props = {|
const Disclosure = styled(ExpandedIcon)`
transform: rotate(270deg);
position: absolute;
right: 8px;
justify-self: flex-end;
`;
const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
+3 -3
View File
@@ -37,7 +37,7 @@ export default function ContextMenu({
}, [onOpen, onClose, previousVisible, rest.visible]);
return (
<Menu hideOnClickOutside preventBodyScroll {...rest}>
<Menu {...rest}>
{(props) => (
<Position {...props}>
<Background>
@@ -60,8 +60,8 @@ const Background = styled.div`
background: ${(props) => rgba(props.theme.menuBackground, 0.95)};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
border-radius: 6px;
padding: 6px 0;
border-radius: 2px;
padding: 0.5em 0;
min-width: 180px;
overflow: hidden;
overflow-y: auto;
-11
View File
@@ -1,11 +0,0 @@
// @flow
import styled from "styled-components";
const Divider = styled.hr`
border: 0;
border-bottom: 1px solid ${(props) => props.theme.divider};
margin: 0;
padding: 0;
`;
export default Divider;
+2 -5
View File
@@ -163,11 +163,8 @@ const DocumentLink = styled(Link)`
padding: 6px 8px;
border-radius: 8px;
max-height: 50vh;
width: calc(100vw - 8px);
${breakpoint("tablet")`
width: auto;
`};
min-width: 100%;
max-width: calc(100vw - 40px);
${Actions} {
opacity: 0;
+4 -9
View File
@@ -18,11 +18,6 @@ const Container = styled(Flex)`
min-width: 0;
`;
const Viewed = styled.span`
text-overflow: ellipsis;
overflow: hidden;
`;
const Modified = styled.span`
color: ${(props) => props.theme.textTertiary};
font-weight: ${(props) => (props.highlight ? "600" : "400")};
@@ -117,16 +112,16 @@ function DocumentMeta({
}
if (!lastViewedAt) {
return (
<Viewed>
<>
&nbsp;<Modified highlight>{t("Never viewed")}</Modified>
</Viewed>
</>
);
}
return (
<Viewed>
<span>
&nbsp;{t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
</Viewed>
</span>
);
};
+123
View File
@@ -0,0 +1,123 @@
// @flow
import invariant from "invariant";
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import * as React from "react";
import Dropzone from "react-dropzone";
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
import styled, { css } from "styled-components";
import DocumentsStore from "stores/DocumentsStore";
import UiStore from "stores/UiStore";
import LoadingIndicator from "components/LoadingIndicator";
const EMPTY_OBJECT = {};
let importingLock = false;
type Props = {
children: React.Node,
collectionId: string,
documentId?: string,
ui: UiStore,
documents: DocumentsStore,
disabled: boolean,
location: Object,
match: Match,
history: RouterHistory,
staticContext: Object,
};
@observer
class DropToImport extends React.Component<Props> {
@observable isImporting: boolean = false;
onDropAccepted = async (files = []) => {
if (importingLock) return;
this.isImporting = true;
importingLock = true;
try {
let collectionId = this.props.collectionId;
const documentId = this.props.documentId;
const redirect = files.length === 1;
if (documentId && !collectionId) {
const document = await this.props.documents.fetch(documentId);
invariant(document, "Document not available");
collectionId = document.collectionId;
}
for (const file of files) {
const doc = await this.props.documents.import(
file,
documentId,
collectionId,
{ publish: true }
);
if (redirect) {
this.props.history.push(doc.url);
}
}
} catch (err) {
this.props.ui.showToast(`Could not import file. ${err.message}`, {
type: "error",
});
} finally {
this.isImporting = false;
importingLock = false;
}
};
render() {
const { documents } = this.props;
if (this.props.disabled) return this.props.children;
return (
<Dropzone
accept={documents.importFileTypes.join(", ")}
onDropAccepted={this.onDropAccepted}
style={EMPTY_OBJECT}
noClick
multiple
>
{({
getRootProps,
getInputProps,
isDragActive,
isDragAccept,
isDragReject,
}) => (
<DropzoneContainer
{...getRootProps()}
{...{ isDragActive }}
tabIndex="-1"
>
<input {...getInputProps()} />
{this.isImporting && <LoadingIndicator />}
{this.props.children}
</DropzoneContainer>
)}
</Dropzone>
);
}
}
const DropzoneContainer = styled("div")`
border-radius: 4px;
${({ isDragActive, theme }) =>
isDragActive &&
css`
background: ${theme.slateDark};
a {
color: ${theme.white} !important;
}
svg {
fill: ${theme.white};
}
`}
`;
export default inject("documents", "ui")(withRouter(DropToImport));
+1 -4
View File
@@ -27,16 +27,13 @@ export type Props = {|
autoFocus?: boolean,
template?: boolean,
placeholder?: string,
maxLength?: number,
scrollTo?: string,
handleDOMEvents?: Object,
readOnlyWriteCheckboxes?: boolean,
onBlur?: (event: SyntheticEvent<>) => any,
onFocus?: (event: SyntheticEvent<>) => any,
onPublish?: (event: SyntheticEvent<>) => any,
onSave?: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
onCancel?: () => any,
onDoubleClick?: () => any,
onChange?: (getValue: () => string) => any,
onSearchLink?: (title: string) => any,
onHoverLink?: (event: MouseEvent) => any,
@@ -180,7 +177,7 @@ const StyledEditor = styled(RichMarkdownEditor)`
justify-content: start;
> div {
background: transparent;
transition: ${(props) => props.theme.backgroundTransition};
}
& * {
+1 -1
View File
@@ -107,7 +107,7 @@ class ErrorBoundary extends React.Component<Props> {
}
const Pre = styled.pre`
background: ${(props) => props.theme.secondaryBackground};
background: ${(props) => props.theme.smoke};
padding: 16px;
border-radius: 4px;
font-size: 12px;
+5 -11
View File
@@ -16,7 +16,7 @@ type AlignValues =
| "flex-start"
| "flex-end";
type Props = {|
type Props = {
column?: ?boolean,
shrink?: ?boolean,
align?: AlignValues,
@@ -24,18 +24,12 @@ type Props = {|
auto?: ?boolean,
className?: string,
children?: React.Node,
role?: string,
|};
};
const Flex = React.forwardRef<Props, HTMLDivElement>((props: Props, ref) => {
const Flex = (props: Props) => {
const { children, ...restProps } = props;
return (
<Container ref={ref} {...restProps}>
{children}
</Container>
);
});
return <Container {...restProps}>{children}</Container>;
};
const Container = styled.div`
display: flex;
+1 -17
View File
@@ -1,7 +1,6 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { GroupIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { MAX_AVATAR_DISPLAY } from "shared/constants";
@@ -18,8 +17,7 @@ type Props = {
group: Group,
groupMemberships: GroupMembershipsStore,
membership?: CollectionGroupMembership,
showFacepile?: boolean,
showAvatar?: boolean,
showFacepile: boolean,
renderActions: ({ openMembersModal: () => void }) => React.Node,
};
@@ -50,11 +48,6 @@ class GroupListItem extends React.Component<Props> {
return (
<>
<ListItem
image={
<Image>
<GroupIcon size={24} />
</Image>
}
title={
<Title onClick={this.handleMembersModalOpen}>{group.name}</Title>
}
@@ -91,15 +84,6 @@ class GroupListItem extends React.Component<Props> {
}
}
const Image = styled(Flex)`
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: ${(props) => props.theme.secondaryBackground};
border-radius: 32px;
`;
const Title = styled.span`
&:hover {
text-decoration: underline;
-126
View File
@@ -1,126 +0,0 @@
// @flow
import { throttle } from "lodash";
import { observer } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Fade from "components/Fade";
import Flex from "components/Flex";
type Props = {|
breadcrumb?: React.Node,
title: React.Node,
actions?: React.Node,
|};
function Header({ breadcrumb, title, actions }: Props) {
const [isScrolled, setScrolled] = React.useState(false);
const handleScroll = React.useCallback(
throttle(() => setScrolled(window.scrollY > 75), 50),
[]
);
React.useEffect(() => {
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
const handleClickTitle = React.useCallback(() => {
window.scrollTo({
top: 0,
behavior: "smooth",
});
}, []);
return (
<Wrapper align="center" isCompact={isScrolled} shrink={false}>
{breadcrumb ? <Breadcrumbs>{breadcrumb}</Breadcrumbs> : null}
{isScrolled ? (
<Title align="center" justify="flex-start" onClick={handleClickTitle}>
<Fade>{title}</Fade>
</Title>
) : (
<div />
)}
{actions && (
<Actions align="center" justify="flex-end">
{actions}
</Actions>
)}
</Wrapper>
);
}
const Breadcrumbs = styled("div")`
flex-grow: 1;
flex-basis: 0;
align-items: center;
padding-right: 8px;
/* Don't show breadcrumbs on mobile */
display: none;
${breakpoint("tablet")`
display: flex;
`};
`;
const Actions = styled(Flex)`
flex-grow: 1;
flex-basis: 0;
min-width: auto;
padding-left: 8px;
`;
const Wrapper = styled(Flex)`
position: sticky;
top: 0;
z-index: 2;
background: ${(props) => transparentize(0.2, props.theme.background)};
padding: 12px;
transition: all 100ms ease-out;
transform: translate3d(0, 0, 0);
backdrop-filter: blur(20px);
min-height: 56px;
@media print {
display: none;
}
justify-content: flex-start;
${breakpoint("tablet")`
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
justify-content: "center";
`};
`;
const Title = styled("div")`
font-size: 16px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
cursor: pointer;
min-width: 0;
/* on mobile, there's always a floating menu button in the top left
add some padding here to offset
*/
padding-left: 40px;
${breakpoint("tablet")`
padding-left: 0;
`};
svg {
vertical-align: bottom;
}
@media (display-mode: standalone) {
overflow: hidden;
flex-grow: 0 !important;
}
`;
export default observer(Header);
+4 -12
View File
@@ -4,7 +4,6 @@ import { observer } from "mobx-react";
import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Flex from "components/Flex";
const RealTextarea = styled.textarea`
@@ -34,15 +33,10 @@ const RealInput = styled.input`
&::placeholder {
color: ${(props) => props.theme.placeholder};
}
${breakpoint("mobile", "tablet")`
font-size: 16px;
`};
`;
const Wrapper = styled.div`
flex: ${(props) => (props.flex ? "1" : "0")};
width: ${(props) => (props.short ? "49%" : "auto")};
max-width: ${(props) => (props.short ? "350px" : "100%")};
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : "0")};
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : "initial")};
@@ -56,6 +50,7 @@ const IconWrapper = styled.span`
`;
export const Outline = styled(Flex)`
display: flex;
flex: 1;
margin: ${(props) =>
props.margin !== undefined ? props.margin : "0 0 16px"};
@@ -64,7 +59,7 @@ export const Outline = styled(Flex)`
border-style: solid;
border-color: ${(props) =>
props.hasError
? props.theme.danger
? "red"
: props.focused
? props.theme.inputBorderFocused
: props.theme.inputBorder};
@@ -81,7 +76,7 @@ export const LabelText = styled.div`
`;
export type Props = {|
type?: "text" | "email" | "checkbox" | "search" | "textarea",
type?: "text" | "email" | "checkbox" | "search",
value?: string,
label?: string,
className?: string,
@@ -97,11 +92,8 @@ export type Props = {|
autoComplete?: boolean | string,
readOnly?: boolean,
required?: boolean,
disabled?: boolean,
placeholder?: string,
onChange?: (
ev: SyntheticInputEvent<HTMLInputElement | HTMLTextAreaElement>
) => mixed,
onChange?: (ev: SyntheticInputEvent<HTMLInputElement>) => mixed,
onFocus?: (ev: SyntheticEvent<>) => void,
onBlur?: (ev: SyntheticEvent<>) => void,
|};
+2 -4
View File
@@ -3,12 +3,10 @@ import styled from "styled-components";
import Input from "./Input";
const InputLarge = styled(Input)`
height: 38px;
flex-grow: 1;
margin-right: 8px;
height: 40px;
input {
height: 38px;
height: 40px;
}
`;
+2 -2
View File
@@ -12,7 +12,7 @@ const Select = styled.select`
padding: 4px 0;
margin: 0 12px;
outline: none;
background: ${(props) => props.theme.background};
background: none;
color: ${(props) => props.theme.text};
height: 30px;
@@ -27,7 +27,7 @@ const Wrapper = styled.label`
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
export type Option = { label: string, value: string };
type Option = { label: string, value: string };
export type Props = {
value?: string,
-22
View File
@@ -1,22 +0,0 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import InputSelect, { type Props, type Option } from "./InputSelect";
export default function InputSelectPermission(
props: $Rest<Props, { options: Array<Option> }>
) {
const { t } = useTranslation();
return (
<InputSelect
label={t("Default access")}
options={[
{ label: t("View and edit"), value: "read_write" },
{ label: t("View only"), value: "read" },
{ label: t("No access"), value: "" },
]}
{...props}
/>
);
}
+5 -3
View File
@@ -17,10 +17,12 @@ const Labeled = ({ label, children, ...props }: Props) => (
);
export const Label = styled(Flex)`
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
padding-bottom: 4px;
display: inline-block;
color: ${(props) => props.theme.text};
text-transform: uppercase;
color: ${(props) => props.theme.textTertiary};
letter-spacing: 0.04em;
`;
export default observer(Labeled);
+23 -25
View File
@@ -6,21 +6,15 @@ import * as React from "react";
import { Helmet } from "react-helmet";
import { withTranslation, type TFunction } from "react-i18next";
import keydown from "react-keydown";
import {
Switch,
Route,
Redirect,
withRouter,
type RouterHistory,
} from "react-router-dom";
import styled from "styled-components";
import { Switch, Route, Redirect } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import AuthStore from "stores/AuthStore";
import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import ErrorSuspended from "scenes/ErrorSuspended";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import Analytics from "components/Analytics";
import Button from "components/Button";
import DocumentHistory from "components/DocumentHistory";
import Flex from "components/Flex";
@@ -30,12 +24,12 @@ import Sidebar from "components/Sidebar";
import SettingsSidebar from "components/Sidebar/Settings";
import SkipNavContent from "components/SkipNavContent";
import SkipNavLink from "components/SkipNavLink";
import { type Theme } from "types";
import { meta } from "utils/keyboard";
import {
homeUrl,
searchUrl,
matchDocumentSlug as slug,
newDocumentUrl,
} from "utils/routeHelpers";
type Props = {
@@ -45,9 +39,8 @@ type Props = {
title?: ?React.Node,
auth: AuthStore,
ui: UiStore,
history: RouterHistory,
policies: PoliciesStore,
notifications?: React.Node,
theme: Theme,
i18n: Object,
t: TFunction,
};
@@ -58,12 +51,24 @@ class Layout extends React.Component<Props> {
@observable redirectTo: ?string;
@observable keyboardShortcutsOpen: boolean = false;
constructor(props: Props) {
super();
this.updateBackground(props);
}
componentDidUpdate() {
this.updateBackground(this.props);
if (this.redirectTo) {
this.redirectTo = undefined;
}
}
updateBackground(props: Props) {
// ensure the wider page color always matches the theme
window.document.body.style.background = props.theme.background;
}
@keydown(`${meta}+.`)
handleToggleSidebar() {
this.props.ui.toggleCollapsedSidebar();
@@ -71,6 +76,7 @@ class Layout extends React.Component<Props> {
@keydown("shift+/")
handleOpenKeyboardShortcuts() {
if (this.props.ui.editMode) return;
this.keyboardShortcutsOpen = true;
}
@@ -80,6 +86,7 @@ class Layout extends React.Component<Props> {
@keydown(["t", "/", `${meta}+k`])
goToSearch(ev: SyntheticEvent<>) {
if (this.props.ui.editMode) return;
ev.preventDefault();
ev.stopPropagation();
this.redirectTo = searchUrl();
@@ -87,25 +94,15 @@ class Layout extends React.Component<Props> {
@keydown("d")
goToDashboard() {
if (this.props.ui.editMode) return;
this.redirectTo = homeUrl();
}
@keydown("n")
goToNewDocument() {
const { activeCollectionId } = this.props.ui;
if (!activeCollectionId) return;
const can = this.props.policies.abilities(activeCollectionId);
if (!can.update) return;
this.props.history.push(newDocumentUrl(activeCollectionId));
}
render() {
const { auth, t, ui } = this.props;
const { user, team } = auth;
const showSidebar = auth.authenticated && user && team;
const sidebarCollapsed = ui.isEditing || ui.sidebarCollapsed;
const sidebarCollapsed = ui.editMode || ui.sidebarCollapsed;
if (auth.isSuspended) return <ErrorSuspended />;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
@@ -120,6 +117,7 @@ class Layout extends React.Component<Props> {
/>
</Helmet>
<SkipNavLink />
<Analytics />
{this.props.ui.progressBarVisible && <LoadingIndicatorBar />}
{this.props.notifications}
@@ -217,5 +215,5 @@ const Content = styled(Flex)`
`;
export default withTranslation()<Layout>(
inject("auth", "ui", "documents", "policies")(withRouter(Layout))
inject("auth", "ui", "documents")(withTheme(Layout))
);
+4 -4
View File
@@ -27,7 +27,7 @@ const ListItem = ({ image, title, subtitle, actions }: Props) => {
const Wrapper = styled.li`
display: flex;
padding: 10px 0;
padding: ${(props) => (props.compact ? "8px" : "12px")} 0;
margin: 0;
border-bottom: 1px solid ${(props) => props.theme.divider};
@@ -38,17 +38,17 @@ const Wrapper = styled.li`
const Image = styled(Flex)`
padding: 0 8px 0 0;
max-height: 32px;
max-height: 40px;
align-items: center;
user-select: none;
flex-shrink: 0;
align-self: center;
align-self: flex-start;
`;
const Heading = styled.p`
font-size: 16px;
font-weight: 500;
line-height: 1.1;
line-height: 1.2;
margin: 0;
`;
-1
View File
@@ -14,7 +14,6 @@ const locales = {
ko: require(`date-fns/locale/ko`),
pt: require(`date-fns/locale/pt`),
zh: require(`date-fns/locale/zh_cn`),
ru: require(`date-fns/locale/ru`),
};
let callbacks = [];
+1 -1
View File
@@ -23,7 +23,7 @@ class Mask extends React.Component<Props> {
}
render() {
return <Redacted width={this.width} height={this.props.height} />;
return <Redacted width={this.width} />;
}
}
+81 -106
View File
@@ -3,18 +3,15 @@ import { observer } from "mobx-react";
import { CloseIcon, BackIcon } from "outline-icons";
import { transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
import styled from "styled-components";
import ReactModal from "react-modal";
import styled, { createGlobalStyle } from "styled-components";
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";
import usePrevious from "hooks/usePrevious";
import useUnmount from "hooks/useUnmount";
let openModals = 0;
ReactModal.setAppElement("#root");
type Props = {|
children?: React.Node,
@@ -23,6 +20,44 @@ type Props = {|
onRequestClose: () => void,
|};
const GlobalStyles = createGlobalStyle`
.ReactModal__Overlay {
background-color: ${(props) =>
transparentize(0.25, props.theme.background)} !important;
z-index: ${(props) => props.theme.depths.modalOverlay};
}
${breakpoint("tablet")`
.ReactModalPortal + .ReactModalPortal,
.ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
.ReactModal__Overlay {
margin-left: 12px;
box-shadow: 0 -2px 10px ${(props) => props.theme.shadow};
border-radius: 8px 0 0 8px;
overflow: hidden;
}
}
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal,
.ReactModalPortal + .ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
.ReactModal__Overlay {
margin-left: 24px;
}
}
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + .ReactModalPortal,
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
.ReactModal__Overlay {
margin-left: 36px;
}
}
`};
.ReactModal__Body--open {
overflow: hidden;
}
`;
const Modal = ({
children,
isOpen,
@@ -30,112 +65,35 @@ const Modal = ({
onRequestClose,
...rest
}: Props) => {
const dialog = useDialogState({ animated: 250 });
const [depth, setDepth] = React.useState(0);
const wasOpen = usePrevious(isOpen);
const { t } = useTranslation();
React.useEffect(() => {
if (!wasOpen && isOpen) {
setDepth(openModals++);
dialog.show();
}
if (wasOpen && !isOpen) {
setDepth(openModals--);
dialog.hide();
}
}, [dialog, wasOpen, isOpen]);
useUnmount(() => {
if (isOpen) {
openModals--;
}
});
if (!isOpen) return null;
return (
<DialogBackdrop {...dialog}>
{(props) => (
<Backdrop {...props}>
<Dialog
{...dialog}
aria-label={title}
preventBodyScrollhideOnEsc
hide={onRequestClose}
>
{(props) => (
<Scene
$nested={!!depth}
style={{ marginLeft: `${depth * 12}px` }}
{...props}
>
<Content>
<Centered onClick={(ev) => ev.stopPropagation()} column>
{title && <h1>{title}</h1>}
{children}
</Centered>
</Content>
<Back onClick={onRequestClose}>
<BackIcon size={32} color="currentColor" />
<Text>{t("Back")}</Text>
</Back>
<Close onClick={onRequestClose}>
<CloseIcon size={32} color="currentColor" />
</Close>
</Scene>
)}
</Dialog>
</Backdrop>
)}
</DialogBackdrop>
<>
<GlobalStyles />
<StyledModal
contentLabel={title}
onRequestClose={onRequestClose}
isOpen={isOpen}
{...rest}
>
<Content>
<Centered onClick={(ev) => ev.stopPropagation()} column>
{title && <h1>{title}</h1>}
{children}
</Centered>
</Content>
<Back onClick={onRequestClose}>
<BackIcon size={32} color="currentColor" />
<Text>Back</Text>
</Back>
<Close onClick={onRequestClose}>
<CloseIcon size={32} color="currentColor" />
</Close>
</StyledModal>
</>
);
};
const Backdrop = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: ${(props) =>
transparentize(0.25, props.theme.background)} !important;
z-index: ${(props) => props.theme.depths.modalOverlay};
transition: opacity 50ms ease-in-out;
opacity: 0;
&[data-enter] {
opacity: 1;
}
`;
const Scene = styled.div`
animation: ${fadeAndScaleIn} 250ms ease;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: ${(props) => props.theme.depths.modal};
display: flex;
justify-content: center;
align-items: flex-start;
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
outline: none;
${breakpoint("tablet")`
${(props) =>
props.$nested &&
`
box-shadow: 0 -2px 10px ${props.theme.shadow};
border-radius: 8px 0 0 8px;
overflow: hidden;
`}
`}
`;
const Content = styled(Scrollable)`
width: 100%;
padding: 8vh 2rem 2rem;
@@ -152,6 +110,23 @@ const Centered = styled(Flex)`
margin: 0 auto;
`;
const StyledModal = styled(ReactModal)`
animation: ${fadeAndScaleIn} 250ms ease;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
z-index: ${(props) => props.theme.depths.modal};
display: flex;
justify-content: center;
align-items: flex-start;
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
outline: none;
`;
const Text = styled.span`
font-size: 16px;
font-weight: 500;
+1 -1
View File
@@ -11,7 +11,7 @@ export default function AlertNotice({ children }: { children: React.Node }) {
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{ position: "relative", top: "2px", marginRight: "4px" }}
style={{ position: "relative", top: "2px" }}
>
<path
d="M15.6676 11.5372L10.0155 1.14735C9.10744 -0.381434 6.89378 -0.383465 5.98447 1.14735L0.332715 11.5372C-0.595598 13.0994 0.528309 15.0776 2.34778 15.0776H13.652C15.47 15.0776 16.5959 13.101 15.6676 11.5372ZM8 13.2026C7.48319 13.2026 7.0625 12.7819 7.0625 12.2651C7.0625 11.7483 7.48319 11.3276 8 11.3276C8.51681 11.3276 8.9375 11.7483 8.9375 12.2651C8.9375 12.7819 8.51681 13.2026 8 13.2026ZM8.9375 9.45257C8.9375 9.96938 8.51681 10.3901 8 10.3901C7.48319 10.3901 7.0625 9.96938 7.0625 9.45257V4.76507C7.0625 4.24826 7.48319 3.82757 8 3.82757C8.51681 3.82757 8.9375 4.24826 8.9375 4.76507V9.45257Z"
-41
View File
@@ -1,41 +0,0 @@
// @flow
import * as React from "react";
import { useTheme } from "styled-components";
import useStores from "hooks/useStores";
export default function PageTheme() {
const { ui } = useStores();
const theme = useTheme();
React.useEffect(() => {
// wider page background beyond the React root
if (document.body) {
document.body.style.background = theme.background;
}
// theme-color adjusts the title bar color for desktop PWA
const themeElement = document.querySelector('meta[name="theme-color"]');
if (themeElement) {
themeElement.setAttribute("content", theme.background);
}
// status bar color for iOS PWA
const statusElement = document.querySelector(
'meta[name="apple-mobile-web-app-status-bar-style"]'
);
if (statusElement) {
statusElement.setAttribute(
"content",
ui.resolvedTheme === "dark" ? "black-translucent" : "default"
);
}
// user-agent controls and scrollbars
const csElement = document.querySelector('meta[name="color-scheme"]');
if (csElement) {
csElement.setAttribute("content", ui.resolvedTheme);
}
}, [theme, ui.resolvedTheme]);
return null;
}
+66
View File
@@ -0,0 +1,66 @@
// @flow
import BoundlessPopover from "boundless-popover";
import * as React from "react";
import styled, { keyframes } from "styled-components";
const fadeIn = keyframes`
from {
opacity: 0;
}
50% {
opacity: 1;
}
`;
const StyledPopover = styled(BoundlessPopover)`
animation: ${fadeIn} 150ms ease-in-out;
display: flex;
flex-direction: column;
line-height: 0;
position: absolute;
top: 0;
left: 0;
z-index: ${(props) => props.theme.depths.popover};
svg {
height: 16px;
width: 16px;
position: absolute;
polygon:first-child {
fill: rgba(0, 0, 0, 0.075);
}
polygon {
fill: #fff;
}
}
`;
const Dialog = styled.div`
outline: none;
background: #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 8px 16px rgba(0, 0, 0, 0.1),
0 2px 4px rgba(0, 0, 0, 0.1);
border-radius: 4px;
line-height: 1.5;
padding: 16px;
margin-top: 14px;
min-width: 200px;
min-height: 150px;
`;
export const Preset = BoundlessPopover.preset;
export default function Popover(props: Object) {
return (
<StyledPopover
dialogComponent={Dialog}
closeOnOutsideScroll
closeOnOutsideFocus
closeOnEscKey
{...props}
/>
);
}
-56
View File
@@ -1,56 +0,0 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import CenteredContent from "components/CenteredContent";
import Header from "components/Header";
import PageTitle from "components/PageTitle";
type Props = {|
icon?: React.Node,
title: React.Node,
textTitle?: string,
children: React.Node,
breadcrumb?: React.Node,
actions?: React.Node,
centered?: boolean,
|};
function Scene({
title,
icon,
textTitle,
actions,
breadcrumb,
children,
centered,
}: Props) {
return (
<FillWidth>
<PageTitle title={textTitle || title} />
<Header
title={
icon ? (
<>
{icon}&nbsp;{title}
</>
) : (
title
)
}
actions={actions}
breadcrumb={breadcrumb}
/>
{centered !== false ? (
<CenteredContent withStickyHeader>{children}</CenteredContent>
) : (
children
)}
</FillWidth>
);
}
const FillWidth = styled.div`
width: 100%;
`;
export default Scene;
+1 -5
View File
@@ -8,10 +8,9 @@ type Props = {|
shadow?: boolean,
topShadow?: boolean,
bottomShadow?: boolean,
flex?: boolean,
|};
function Scrollable({ shadow, topShadow, bottomShadow, flex, ...rest }: Props) {
function Scrollable({ shadow, topShadow, bottomShadow, ...rest }: Props) {
const ref = React.useRef<?HTMLDivElement>();
const [topShadowVisible, setTopShadow] = React.useState(false);
const [bottomShadowVisible, setBottomShadow] = React.useState(false);
@@ -43,7 +42,6 @@ function Scrollable({ shadow, topShadow, bottomShadow, flex, ...rest }: Props) {
<Wrapper
ref={ref}
onScroll={updateShadows}
$flex={flex}
$topShadowVisible={topShadowVisible}
$bottomShadowVisible={bottomShadowVisible}
{...rest}
@@ -52,8 +50,6 @@ function Scrollable({ shadow, topShadow, bottomShadow, flex, ...rest }: Props) {
}
const Wrapper = styled.div`
display: ${(props) => (props.$flex ? "flex" : "block")};
flex-direction: column;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
+123 -132
View File
@@ -12,21 +12,19 @@ import {
SettingsIcon,
} from "outline-icons";
import * as React from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import CollectionNew from "scenes/CollectionNew";
import Invite from "scenes/Invite";
import Bubble from "components/Bubble";
import Flex from "components/Flex";
import Modal from "components/Modal";
import Scrollable from "components/Scrollable";
import Sidebar from "./Sidebar";
import Bubble from "./components/Bubble";
import Collections from "./components/Collections";
import HeaderBlock from "./components/HeaderBlock";
import Section from "./components/Section";
import SidebarLink from "./components/SidebarLink";
import TeamButton from "./components/TeamButton";
import useStores from "hooks/useStores";
import AccountMenu from "menus/AccountMenu";
@@ -65,146 +63,139 @@ function MainSidebar() {
setInviteModalOpen(false);
}, []);
const [dndArea, setDndArea] = React.useState();
const handleSidebarRef = React.useCallback((node) => setDndArea(node), []);
const html5Options = React.useMemo(() => ({ rootElement: dndArea }), [
dndArea,
]);
const { user, team } = auth;
if (!user || !team) return null;
const can = policies.abilities(team.id);
return (
<Sidebar ref={handleSidebarRef}>
{dndArea && (
<DndProvider backend={HTML5Backend} options={html5Options}>
<AccountMenu>
{(props) => (
<TeamButton
{...props}
subheading={user.name}
teamName={team.name}
logoUrl={team.avatarUrl}
showDisclosure
<Sidebar>
<AccountMenu>
{(props) => (
<HeaderBlock
{...props}
subheading={user.name}
teamName={team.name}
logoUrl={team.avatarUrl}
showDisclosure
/>
)}
</AccountMenu>
<Flex auto column>
<Scrollable shadow>
<Section>
<SidebarLink
to="/home"
icon={<HomeIcon color="currentColor" />}
exact={false}
label={t("Home")}
/>
<SidebarLink
to={{
pathname: "/search",
state: { fromMenu: true },
}}
icon={<SearchIcon color="currentColor" />}
label={t("Search")}
exact={false}
/>
<SidebarLink
to="/starred"
icon={<StarredIcon color="currentColor" />}
exact={false}
label={t("Starred")}
/>
<SidebarLink
to="/templates"
icon={<ShapesIcon color="currentColor" />}
exact={false}
label={t("Templates")}
active={documents.active ? documents.active.template : undefined}
/>
<SidebarLink
to="/drafts"
icon={<EditIcon color="currentColor" />}
label={
<Drafts align="center">
{t("Drafts")}
{documents.totalDrafts > 0 && (
<Bubble count={documents.totalDrafts} />
)}
</Drafts>
}
active={
documents.active
? !documents.active.publishedAt &&
!documents.active.isDeleted &&
!documents.active.isTemplate
: undefined
}
/>
</Section>
<Section>
<Collections onCreateCollection={handleCreateCollectionModalOpen} />
</Section>
</Scrollable>
<Secondary>
<Section>
<SidebarLink
to="/archive"
icon={<ArchiveIcon color="currentColor" />}
exact={false}
label={t("Archive")}
active={
documents.active
? documents.active.isArchived && !documents.active.isDeleted
: undefined
}
/>
<SidebarLink
to="/trash"
icon={<TrashIcon color="currentColor" />}
exact={false}
label={t("Trash")}
active={documents.active ? documents.active.isDeleted : undefined}
/>
<SidebarLink
to="/settings"
icon={<SettingsIcon color="currentColor" />}
exact={false}
label={t("Settings")}
/>
{can.invite && (
<SidebarLink
to="/settings/people"
onClick={handleInviteModalOpen}
icon={<PlusIcon color="currentColor" />}
label={`${t("Invite people")}`}
/>
)}
</AccountMenu>
<Scrollable flex topShadow>
<Section>
<SidebarLink
to="/home"
icon={<HomeIcon color="currentColor" />}
exact={false}
label={t("Home")}
/>
<SidebarLink
to={{
pathname: "/search",
state: { fromMenu: true },
}}
icon={<SearchIcon color="currentColor" />}
label={t("Search")}
exact={false}
/>
<SidebarLink
to="/starred"
icon={<StarredIcon color="currentColor" />}
exact={false}
label={t("Starred")}
/>
<SidebarLink
to="/templates"
icon={<ShapesIcon color="currentColor" />}
exact={false}
label={t("Templates")}
active={
documents.active ? documents.active.template : undefined
}
/>
<SidebarLink
to="/drafts"
icon={<EditIcon color="currentColor" />}
label={
<Drafts align="center">
{t("Drafts")}
<Bubble count={documents.totalDrafts} />
</Drafts>
}
active={
documents.active
? !documents.active.publishedAt &&
!documents.active.isDeleted &&
!documents.active.isTemplate
: undefined
}
/>
</Section>
<Section auto>
<Collections
onCreateCollection={handleCreateCollectionModalOpen}
/>
</Section>
<Section>
<SidebarLink
to="/archive"
icon={<ArchiveIcon color="currentColor" />}
exact={false}
label={t("Archive")}
active={
documents.active
? documents.active.isArchived && !documents.active.isDeleted
: undefined
}
/>
<SidebarLink
to="/trash"
icon={<TrashIcon color="currentColor" />}
exact={false}
label={t("Trash")}
active={
documents.active ? documents.active.isDeleted : undefined
}
/>
<SidebarLink
to="/settings"
icon={<SettingsIcon color="currentColor" />}
exact={false}
label={t("Settings")}
/>
{can.inviteUser && (
<SidebarLink
to="/settings/people"
onClick={handleInviteModalOpen}
icon={<PlusIcon color="currentColor" />}
label={`${t("Invite people")}`}
/>
)}
</Section>
</Scrollable>
{can.inviteUser && (
<Modal
title={t("Invite people")}
onRequestClose={handleInviteModalClose}
isOpen={inviteModalOpen}
>
<Invite onSubmit={handleInviteModalClose} />
</Modal>
)}
<Modal
title={t("Create a collection")}
onRequestClose={handleCreateCollectionModalClose}
isOpen={createCollectionModalOpen}
>
<CollectionNew onSubmit={handleCreateCollectionModalClose} />
</Modal>
</DndProvider>
)}
</Section>
</Secondary>
</Flex>
<Modal
title={t("Invite people")}
onRequestClose={handleInviteModalClose}
isOpen={inviteModalOpen}
>
<Invite onSubmit={handleInviteModalClose} />
</Modal>
<Modal
title={t("Create a collection")}
onRequestClose={handleCreateCollectionModalClose}
isOpen={createCollectionModalOpen}
>
<CollectionNew onSubmit={handleCreateCollectionModalClose} />
</Modal>
</Sidebar>
);
}
const Secondary = styled.div`
overflow-x: hidden;
flex-shrink: 0;
`;
const Drafts = styled(Flex)`
height: 24px;
`;
+4 -4
View File
@@ -21,9 +21,9 @@ import Scrollable from "components/Scrollable";
import Sidebar from "./Sidebar";
import Header from "./components/Header";
import HeaderBlock from "./components/HeaderBlock";
import Section from "./components/Section";
import SidebarLink from "./components/SidebarLink";
import TeamButton from "./components/TeamButton";
import Version from "./components/Version";
import SlackIcon from "./icons/Slack";
import ZapierIcon from "./icons/Zapier";
@@ -46,7 +46,7 @@ function SettingsSidebar() {
return (
<Sidebar>
<TeamButton
<HeaderBlock
subheading={
<ReturnToApp align="center">
<BackIcon color="currentColor" /> {t("Return to App")}
@@ -112,9 +112,9 @@ function SettingsSidebar() {
/>
{can.export && (
<SidebarLink
to="/settings/import-export"
to="/settings/export"
icon={<DocumentIcon color="currentColor" />}
label={`${t("Import")} / ${t("Export")}`}
label={t("Export Data")}
/>
)}
</Section>
+152 -184
View File
@@ -3,205 +3,180 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import { useLocation } from "react-router-dom";
import { withRouter } from "react-router-dom";
import type { Location } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Fade from "components/Fade";
import Flex from "components/Flex";
import CollapseToggle, {
Button as CollapseButton,
} from "./components/CollapseToggle";
import ResizeBorder from "./components/ResizeBorder";
import Toggle, { ToggleButton, Positioner } from "./components/Toggle";
import ResizeHandle from "./components/ResizeHandle";
import usePrevious from "hooks/usePrevious";
import useStores from "hooks/useStores";
let ANIMATION_MS = 250;
let isFirstRender = true;
let firstRender = true;
let BOUNCE_ANIMATION_MS = 250;
type Props = {|
type Props = {
children: React.Node,
|};
location: Location,
};
const Sidebar = React.forwardRef<Props, HTMLButtonElement>(
({ children }: Props, ref) => {
const [isCollapsing, setCollapsing] = React.useState(false);
const theme = useTheme();
const { t } = useTranslation();
const { ui } = useStores();
const location = useLocation();
const previousLocation = usePrevious(location);
const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
const [offset, setOffset] = React.useState(0);
const [isAnimating, setAnimating] = React.useState(false);
const [isResizing, setResizing] = React.useState(false);
const isSmallerThanMinimum = width < minWidth;
const width = ui.sidebarWidth;
const collapsed = ui.isEditing || ui.sidebarCollapsed;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const setWidth = ui.setSidebarWidth;
const handleDrag = React.useCallback(
(event: MouseEvent) => {
// suppresses text selection
event.preventDefault();
const [offset, setOffset] = React.useState(0);
const [isAnimating, setAnimating] = React.useState(false);
const [isResizing, setResizing] = React.useState(false);
const isSmallerThanMinimum = width < minWidth;
// this is simple because the sidebar is always against the left edge
const width = Math.min(event.pageX - offset, maxWidth);
setWidth(width);
},
[offset, maxWidth, setWidth]
);
const handleDrag = React.useCallback(
(event: MouseEvent) => {
// suppresses text selection
event.preventDefault();
const handleStopDrag = React.useCallback(() => {
setResizing(false);
// this is simple because the sidebar is always against the left edge
const width = Math.min(event.pageX - offset, maxWidth);
const isSmallerThanCollapsePoint = width < minWidth / 2;
if (isSmallerThanMinimum) {
setWidth(minWidth);
setAnimating(true);
} else {
setWidth(width);
}
}, [isSmallerThanMinimum, minWidth, width, setWidth]);
if (isSmallerThanCollapsePoint) {
setWidth(theme.sidebarCollapsedWidth);
} else {
setWidth(width);
}
},
[theme, offset, minWidth, maxWidth, setWidth]
);
const handleStartDrag = React.useCallback(
(event) => {
setOffset(event.pageX - width);
setResizing(true);
setAnimating(false);
},
[width]
);
const handleStopDrag = React.useCallback(
(event: MouseEvent) => {
setResizing(false);
React.useEffect(() => {
if (isAnimating) {
setTimeout(() => setAnimating(false), BOUNCE_ANIMATION_MS);
}
}, [isAnimating]);
if (document.activeElement) {
document.activeElement.blur();
}
React.useEffect(() => {
if (isResizing) {
document.addEventListener("mousemove", handleDrag);
document.addEventListener("mouseup", handleStopDrag);
}
if (isSmallerThanMinimum) {
const isSmallerThanCollapsePoint = width < minWidth / 2;
return () => {
document.removeEventListener("mousemove", handleDrag);
document.removeEventListener("mouseup", handleStopDrag);
};
}, [isResizing, handleDrag, handleStopDrag]);
if (isSmallerThanCollapsePoint) {
setAnimating(false);
setCollapsing(true);
ui.collapseSidebar();
} else {
setWidth(minWidth);
setAnimating(true);
}
} else {
setWidth(width);
}
},
[ui, isSmallerThanMinimum, minWidth, width, setWidth]
);
return { isAnimating, isSmallerThanMinimum, isResizing, handleStartDrag };
};
const handleMouseDown = React.useCallback(
(event: MouseEvent) => {
setOffset(event.pageX - width);
setResizing(true);
setAnimating(false);
},
[width]
);
function Sidebar({ location, children }: Props) {
const theme = useTheme();
const { t } = useTranslation();
const { ui } = useStores();
const previousLocation = usePrevious(location);
React.useEffect(() => {
if (isAnimating) {
setTimeout(() => setAnimating(false), ANIMATION_MS);
}
}, [isAnimating]);
const width = ui.sidebarWidth;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const collapsed = ui.editMode || ui.sidebarCollapsed;
React.useEffect(() => {
if (isCollapsing) {
setTimeout(() => {
setWidth(minWidth);
setCollapsing(false);
}, ANIMATION_MS);
}
}, [setWidth, minWidth, isCollapsing]);
const {
isAnimating,
isSmallerThanMinimum,
isResizing,
handleStartDrag,
} = useResize({
width,
minWidth,
maxWidth,
setWidth: ui.setSidebarWidth,
});
React.useEffect(() => {
if (isResizing) {
document.addEventListener("mousemove", handleDrag);
document.addEventListener("mouseup", handleStopDrag);
}
const handleReset = React.useCallback(() => {
ui.setSidebarWidth(theme.sidebarWidth);
}, [ui, theme.sidebarWidth]);
return () => {
document.removeEventListener("mousemove", handleDrag);
document.removeEventListener("mouseup", handleStopDrag);
};
}, [isResizing, handleDrag, handleStopDrag]);
React.useEffect(() => {
ui.setSidebarResizing(isResizing);
}, [ui, isResizing]);
const handleReset = React.useCallback(() => {
ui.setSidebarWidth(theme.sidebarWidth);
}, [ui, theme.sidebarWidth]);
React.useEffect(() => {
if (location !== previousLocation) {
ui.hideMobileSidebar();
}
}, [ui, location, previousLocation]);
React.useEffect(() => {
ui.setSidebarResizing(isResizing);
}, [ui, isResizing]);
const style = React.useMemo(
() => ({
width: `${width}px`,
left:
collapsed && !ui.mobileSidebarVisible
? `${-width + theme.sidebarCollapsedWidth}px`
: 0,
}),
[width, collapsed, theme.sidebarCollapsedWidth, ui.mobileSidebarVisible]
);
React.useEffect(() => {
if (location !== previousLocation) {
isFirstRender = false;
ui.hideMobileSidebar();
}
}, [ui, location, previousLocation]);
const style = React.useMemo(
() => ({
width: `${width}px`,
}),
[width]
);
const toggleStyle = React.useMemo(
() => ({
right: "auto",
marginLeft: `${collapsed ? theme.sidebarCollapsedWidth : width}px`,
}),
[width, theme.sidebarCollapsedWidth, collapsed]
);
const content = (
<>
{ui.mobileSidebarVisible && (
<Portal>
<Fade>
<Background onClick={ui.toggleMobileSidebar} />
</Fade>
</Portal>
)}
{children}
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={ui.sidebarCollapsed ? undefined : handleReset}
$isResizing={isResizing}
const content = (
<Container
style={style}
$sidebarWidth={ui.sidebarWidth}
$isAnimating={isAnimating}
$isSmallerThanMinimum={isSmallerThanMinimum}
$mobileSidebarVisible={ui.mobileSidebarVisible}
$collapsed={collapsed}
column
>
{!isResizing && (
<CollapseToggle
collapsed={ui.sidebarCollapsed}
onClick={ui.toggleCollapsedSidebar}
/>
{ui.sidebarCollapsed && !ui.isEditing && (
<Toggle
onClick={ui.toggleCollapsedSidebar}
direction={"right"}
aria-label={t("Expand")}
/>
)}
</>
);
)}
{ui.mobileSidebarVisible && (
<Portal>
<Fade>
<Background onClick={ui.toggleMobileSidebar} />
</Fade>
</Portal>
)}
return (
<>
<Container
ref={ref}
style={style}
$sidebarWidth={ui.sidebarWidth}
$isCollapsing={isCollapsing}
$isAnimating={isAnimating}
$isSmallerThanMinimum={isSmallerThanMinimum}
$mobileSidebarVisible={ui.mobileSidebarVisible}
$collapsed={collapsed}
column
{children}
{!ui.sidebarCollapsed && (
<ResizeBorder
onMouseDown={handleStartDrag}
onDoubleClick={handleReset}
$isResizing={isResizing}
>
{isFirstRender ? <Fade>{content}</Fade> : content}
</Container>
{!ui.isEditing && (
<Toggle
style={toggleStyle}
onClick={ui.toggleCollapsedSidebar}
direction={ui.sidebarCollapsed ? "right" : "left"}
aria-label={ui.sidebarCollapsed ? t("Expand") : t("Collapse")}
/>
)}
</>
);
<ResizeHandle aria-label={t("Resize sidebar")} />
</ResizeBorder>
)}
</Container>
);
// Fade in the sidebar on first render after page load
if (firstRender) {
firstRender = false;
return <Fade>{content}</Fade>;
}
);
return content;
}
const Background = styled.a`
position: fixed;
@@ -220,36 +195,29 @@ const Container = styled(Flex)`
bottom: 0;
width: 100%;
background: ${(props) => props.theme.sidebarBackground};
transition: box-shadow 100ms ease-in-out, transform 100ms ease-out,
transition: box-shadow, 100ms, ease-in-out, margin-left 100ms ease-out,
left 100ms ease-out,
${(props) => props.theme.backgroundTransition}
${(props) =>
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
transform: translateX(
${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")}
);
props.$isAnimating ? `,width ${BOUNCE_ANIMATION_MS}ms ease-out` : ""};
margin-left: ${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")};
z-index: ${(props) => props.theme.depths.sidebar};
max-width: 70%;
min-width: 280px;
${Positioner} {
display: none;
}
@media print {
display: none;
transform: none;
left: 0;
}
${breakpoint("tablet")`
margin: 0;
z-index: 3;
min-width: 0;
transform: translateX(${(props) =>
props.$collapsed ? "calc(-100% + 16px)" : 0});
&:hover,
&:focus-within {
transform: none;
left: 0 !important;
box-shadow: ${(props) =>
props.$collapsed
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
@@ -257,11 +225,11 @@ const Container = styled(Flex)`
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
: "none"};
${Positioner} {
display: block;
& ${CollapseButton} {
opacity: .75;
}
${ToggleButton} {
& ${CollapseButton}:hover {
opacity: 1;
}
}
@@ -273,4 +241,4 @@ const Container = styled(Flex)`
`};
`;
export default observer(Sidebar);
export default withRouter(observer(Sidebar));
@@ -3,15 +3,11 @@ import * as React from "react";
import styled from "styled-components";
import { bounceIn } from "shared/styles/animations";
type Props = {|
type Props = {
count: number,
|};
};
const Bubble = ({ count }: Props) => {
if (!count) {
return null;
}
return <Count>{count}</Count>;
};
@@ -0,0 +1,59 @@
// @flow
import { NextIcon, BackIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Tooltip from "components/Tooltip";
import { meta } from "utils/keyboard";
type Props = {|
collapsed: boolean,
onClick?: (event: SyntheticEvent<>) => void,
|};
function CollapseToggle({ collapsed, ...rest }: Props) {
const { t } = useTranslation();
return (
<Tooltip
tooltip={collapsed ? t("Expand") : t("Collapse")}
shortcut={`${meta}+.`}
delay={500}
placement="bottom"
>
<Button {...rest} tabIndex="-1" aria-hidden>
{collapsed ? (
<NextIcon color="currentColor" />
) : (
<BackIcon color="currentColor" />
)}
</Button>
</Tooltip>
);
}
export const Button = styled.button`
display: block;
position: absolute;
top: 28px;
right: 8px;
border: 0;
width: 24px;
height: 24px;
z-index: 1;
font-weight: 600;
color: ${(props) => props.theme.sidebarText};
background: transparent;
transition: opacity 100ms ease-in-out;
border-radius: 4px;
opacity: 0;
cursor: pointer;
padding: 0;
&:hover {
color: ${(props) => props.theme.white};
background: ${(props) => props.theme.primary};
}
`;
export default CollapseToggle;
@@ -1,15 +1,15 @@
// @flow
import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import * as React from "react";
import { useDrop, useDrag } from "react-dnd";
import { useDrop } from "react-dnd";
import styled from "styled-components";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import Document from "models/Document";
import CollectionIcon from "components/CollectionIcon";
import DropToImport from "components/DropToImport";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import useStores from "hooks/useStores";
@@ -18,12 +18,10 @@ import CollectionSortMenu from "menus/CollectionSortMenu";
type Props = {|
collection: Collection,
ui: UiStore,
canUpdate: boolean,
activeDocument: ?Document,
prefetchDocument: (id: string) => Promise<void>,
belowCollection: Collection | void,
isDraggingAnyCollection: boolean,
onChangeDragging: (dragging: boolean) => void,
|};
function CollectionLink({
@@ -31,9 +29,7 @@ function CollectionLink({
activeDocument,
prefetchDocument,
canUpdate,
belowCollection,
isDraggingAnyCollection,
onChangeDragging,
ui,
}: Props) {
const [menuOpen, setMenuOpen] = React.useState(false);
@@ -44,23 +40,10 @@ function CollectionLink({
[collection]
);
const { ui, documents, policies, collections } = useStores();
const [expanded, setExpanded] = React.useState(
collection.id === ui.activeCollectionId
);
React.useEffect(() => {
if (isDraggingAnyCollection) {
setExpanded(false);
} else {
setExpanded(collection.id === ui.activeCollectionId);
}
}, [isDraggingAnyCollection, collection.id, ui.activeCollectionId]);
const { documents, policies } = useStores();
const expanded = collection.id === ui.activeCollectionId;
const manualSort = collection.sort.field === "index";
const can = policies.abilities(collection.id);
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
// Drop to re-parent
const [{ isOver, canDrop }, drop] = useDrop({
@@ -91,101 +74,49 @@ function CollectionLink({
}),
});
// Drop to reorder Collection
const [{ isCollectionDropping }, dropToReorderCollection] = useDrop({
accept: "collection",
drop: async (item, monitor) => {
collections.move(
item.id,
fractionalIndex(collection.index, belowCollectionIndex)
);
},
canDrop: (item, monitor) => {
return (
collection.id !== item.id &&
(!belowCollection || item.id !== belowCollection.id)
);
},
collect: (monitor) => ({
isCollectionDropping: monitor.isOver(),
}),
});
// Drag to reorder Collection
const [{ isCollectionDragging }, dragToReorderCollection] = useDrag({
type: "collection",
item: () => {
onChangeDragging(true);
return {
id: collection.id,
};
},
collect: (monitor) => ({
isCollectionDragging: monitor.isDragging(),
}),
canDrag: (monitor) => {
return can.move;
},
end: (monitor) => {
onChangeDragging(false);
},
});
return (
<>
<div ref={drop} style={{ position: "relative" }}>
<Draggable
key={collection.id}
ref={dragToReorderCollection}
$isDragging={isCollectionDragging}
$isMoving={isCollectionDragging}
>
<DropToImport collectionId={collection.id}>
<SidebarLinkWithPadding
to={collection.url}
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
iconColor={collection.color}
expanded={expanded}
showActions={menuOpen || expanded}
isActiveDrop={isOver && canDrop}
label={
<EditableTitle
title={collection.name}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
/>
}
exact={false}
menu={
<>
{can.update && (
<CollectionSortMenuWithMargin
collection={collection}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
)}
<CollectionMenu
<DropToImport key={collection.id} collectionId={collection.id}>
<SidebarLinkWithPadding
key={collection.id}
to={collection.url}
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
iconColor={collection.color}
expanded={expanded}
showActions={menuOpen || expanded}
isActiveDrop={isOver && canDrop}
label={
<EditableTitle
title={collection.name}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
/>
}
exact={false}
menu={
<>
{can.update && (
<CollectionSortMenuWithMargin
collection={collection}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</>
}
/>
</DropToImport>
</Draggable>
)}
<CollectionMenu
collection={collection}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</>
}
/>
</DropToImport>
{expanded && manualSort && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
{isDraggingAnyCollection && (
<DropCursor
isActiveDrop={isCollectionDropping}
innerRef={dropToReorderCollection}
/>
)}
</div>
{expanded &&
@@ -205,11 +136,6 @@ function CollectionLink({
);
}
const Draggable = styled("div")`
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)};
pointer-events: ${(props) => (props.$isMoving ? "none" : "auto")};
`;
const SidebarLinkWithPadding = styled(SidebarLink)`
padding-right: 60px;
`;
@@ -1,107 +1,100 @@
// @flow
import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import { observer, inject } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import { withTranslation, type TFunction } from "react-i18next";
import keydown from "react-keydown";
import { withRouter, type RouterHistory } from "react-router-dom";
import CollectionsStore from "stores/CollectionsStore";
import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import Fade from "components/Fade";
import Flex from "components/Flex";
import useStores from "../../../hooks/useStores";
import CollectionLink from "./CollectionLink";
import CollectionsLoading from "./CollectionsLoading";
import DropCursor from "./DropCursor";
import Header from "./Header";
import SidebarLink from "./SidebarLink";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {
history: RouterHistory,
policies: PoliciesStore,
collections: CollectionsStore,
documents: DocumentsStore,
onCreateCollection: () => void,
ui: UiStore,
t: TFunction,
};
function Collections({ onCreateCollection }: Props) {
const [isFetching, setFetching] = React.useState(false);
const { ui, policies, documents, collections } = useStores();
const isPreloaded: boolean = !!collections.orderedData.length;
const { t } = useTranslation();
const orderedCollections = collections.orderedData;
const [isDraggingAnyCollection, setIsDraggingAnyCollection] = React.useState(
false
);
@observer
class Collections extends React.Component<Props> {
isPreloaded: boolean = !!this.props.collections.orderedData.length;
React.useEffect(() => {
async function load() {
if (!collections.isLoaded && !isFetching) {
try {
setFetching(true);
await collections.fetchPage({ limit: 100 });
} finally {
setFetching(false);
}
}
componentDidMount() {
const { collections } = this.props;
if (!collections.isFetching && !collections.isLoaded) {
collections.fetchPage({ limit: 100 });
}
load();
}, [collections, isFetching]);
}
const [{ isCollectionDropping }, dropToReorderCollection] = useDrop({
accept: "collection",
drop: async (item, monitor) => {
collections.move(
item.id,
fractionalIndex(null, orderedCollections[0].index)
);
},
canDrop: (item, monitor) => {
return item.id !== orderedCollections[0].id;
},
collect: (monitor) => ({
isCollectionDropping: monitor.isOver(),
}),
});
@keydown("n")
goToNewDocument() {
if (this.props.ui.editMode) return;
const content = (
<>
<DropCursor
isActiveDrop={isCollectionDropping}
innerRef={dropToReorderCollection}
from="collections"
/>
{orderedCollections.map((collection, index) => (
<CollectionLink
key={collection.id}
collection={collection}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
canUpdate={policies.abilities(collection.id).update}
ui={ui}
isDraggingAnyCollection={isDraggingAnyCollection}
onChangeDragging={setIsDraggingAnyCollection}
belowCollection={orderedCollections[index + 1]}
const { activeCollectionId } = this.props.ui;
if (!activeCollectionId) return;
const can = this.props.policies.abilities(activeCollectionId);
if (!can.update) return;
this.props.history.push(newDocumentUrl(activeCollectionId));
}
render() {
const { collections, ui, policies, documents, t } = this.props;
const content = (
<>
{collections.orderedData.map((collection) => (
<CollectionLink
key={collection.id}
collection={collection}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
canUpdate={policies.abilities(collection.id).update}
ui={ui}
/>
))}
<SidebarLink
to="/collections"
onClick={this.props.onCreateCollection}
icon={<PlusIcon color="currentColor" />}
label={`${t("New collection")}`}
exact
/>
))}
<SidebarLink
to="/collections"
onClick={onCreateCollection}
icon={<PlusIcon color="currentColor" />}
label={`${t("New collection")}`}
exact
/>
</>
);
</>
);
if (!collections.isLoaded) {
return (
<Flex column>
<Header>{t("Collections")}</Header>
<CollectionsLoading />
{collections.isLoaded ? (
this.isPreloaded ? (
content
) : (
<Fade>{content}</Fade>
)
) : (
<CollectionsLoading />
)}
</Flex>
);
}
return (
<Flex column>
<Header>{t("Collections")}</Header>
{isPreloaded ? content : <Fade>{content}</Fade>}
</Flex>
);
}
export default observer(Collections);
export default withTranslation()<Collections>(
inject("collections", "ui", "documents", "policies")(withRouter(Collections))
);
@@ -7,9 +7,9 @@ import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Collection from "models/Collection";
import Document from "models/Document";
import DropToImport from "components/DropToImport";
import Fade from "components/Fade";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import useStores from "hooks/useStores";
@@ -123,8 +123,7 @@ function DocumentLink({
// Draggable
const [{ isDragging }, drag] = useDrag({
type: "document",
item: () => ({ ...node, depth, active: isActiveDocument }),
item: { type: "document", ...node, depth, active: isActiveDocument },
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
@@ -147,7 +146,7 @@ function DocumentLink({
// Drop to re-parent
const [{ isOverReparent, canDropToReparent }, dropToReparent] = useDrop({
accept: "document",
drop: (item, monitor) => {
drop: async (item, monitor) => {
if (monitor.didDrop()) return;
if (!collection) return;
documents.move(item.id, collection.id, node.id);
@@ -184,7 +183,7 @@ function DocumentLink({
// Drop to reorder
const [{ isOverReorder }, dropToReorder] = useDrop({
accept: "document",
drop: (item, monitor) => {
drop: async (item, monitor) => {
if (!collection) return;
if (item.id === node.id) return;
@@ -7,14 +7,12 @@ function DropCursor({
isActiveDrop,
innerRef,
theme,
from,
}: {
isActiveDrop: boolean,
innerRef: React.Ref<any>,
theme: Theme,
from: string,
}) {
return <Cursor isOver={isActiveDrop} ref={innerRef} from={from} />;
return <Cursor isOver={isActiveDrop} ref={innerRef} />;
}
// transparent hover zone with a thin visible band vertically centered
@@ -27,7 +25,7 @@ const Cursor = styled("div")`
width: 100%;
height: 14px;
${(props) => (props.from === "collections" ? "top: 15px;" : "bottom: -7px;")}
bottom: -7px;
background: transparent;
::after {
@@ -1,83 +0,0 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import Dropzone from "react-dropzone";
import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import LoadingIndicator from "components/LoadingIndicator";
import useImportDocument from "hooks/useImportDocument";
import useStores from "hooks/useStores";
type Props = {|
children: React.Node,
collectionId: string,
documentId?: string,
disabled: boolean,
staticContext: Object,
|};
function DropToImport({ disabled, children, collectionId, documentId }: Props) {
const { t } = useTranslation();
const { ui, documents } = useStores();
const { handleFiles, isImporting } = useImportDocument(
collectionId,
documentId
);
const handleRejection = React.useCallback(() => {
ui.showToast(
t("Document not supported try Markdown, Plain text, HTML, or Word"),
{ type: "error" }
);
}, [t, ui]);
if (disabled) {
return children;
}
return (
<Dropzone
accept={documents.importFileTypes.join(", ")}
onDropAccepted={handleFiles}
onDropRejected={handleRejection}
noClick
multiple
>
{({
getRootProps,
getInputProps,
isDragActive,
isDragAccept,
isDragReject,
}) => (
<DropzoneContainer
{...getRootProps()}
{...{ isDragActive }}
tabIndex="-1"
>
<input {...getInputProps()} />
{isImporting && <LoadingIndicator />}
{children}
</DropzoneContainer>
)}
</Dropzone>
);
}
const DropzoneContainer = styled.div`
border-radius: 4px;
${({ isDragActive, theme }) =>
isDragActive &&
css`
background: ${theme.slateDark};
a {
color: ${theme.white} !important;
}
svg {
fill: ${theme.white};
}
`}
`;
export default observer(DropToImport);
@@ -13,7 +13,7 @@ type Props = {|
logoUrl: string,
|};
const TeamButton = React.forwardRef<Props, any>(
const HeaderBlock = React.forwardRef<Props, any>(
({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => (
<Wrapper>
<Header justify="flex-start" align="center" ref={ref} {...rest}>
@@ -25,7 +25,8 @@ const TeamButton = React.forwardRef<Props, any>(
/>
<Flex align="flex-start" column>
<TeamName showDisclosure>
{teamName} {showDisclosure && <Disclosure color="currentColor" />}
{teamName}{" "}
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
</TeamName>
<Subheading>{subheading}</Subheading>
</Flex>
@@ -34,7 +35,7 @@ const TeamButton = React.forwardRef<Props, any>(
)
);
const Disclosure = styled(ExpandedIcon)`
const StyledExpandedIcon = styled(ExpandedIcon)`
position: absolute;
right: 0;
top: 0;
@@ -83,4 +84,4 @@ const Header = styled.button`
}
`;
export default TeamButton;
export default HeaderBlock;
@@ -1,5 +1,6 @@
// @flow
import styled from "styled-components";
import ResizeHandle from "./ResizeHandle";
const ResizeBorder = styled.div`
position: absolute;
@@ -8,6 +9,20 @@ const ResizeBorder = styled.div`
right: -6px;
width: 12px;
cursor: ew-resize;
${(props) =>
props.$isResizing &&
`
${ResizeHandle} {
opacity: 1;
}
`}
&:hover {
${ResizeHandle} {
opacity: 1;
}
}
`;
export default ResizeBorder;
@@ -0,0 +1,39 @@
// @flow
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
const ResizeHandle = styled.button`
opacity: 0;
transition: opacity 100ms ease-in-out;
transform: translateY(-50%);
position: absolute;
top: 50%;
height: 40px;
right: -10px;
width: 8px;
padding: 0;
border: 0;
background: ${(props) => props.theme.sidebarBackground};
border-radius: 8px;
pointer-events: none;
&:after {
content: "";
position: absolute;
top: -24px;
bottom: -24px;
left: -12px;
right: -12px;
}
&:active {
background: ${(props) => props.theme.sidebarText};
}
${breakpoint("tablet")`
pointer-events: all;
cursor: ew-resize;
`}
`;
export default ResizeHandle;
+1 -5
View File
@@ -5,13 +5,9 @@ import Flex from "components/Flex";
const Section = styled(Flex)`
position: relative;
flex-direction: column;
margin: 0 8px 20px;
margin: 20px 8px;
min-width: ${(props) => props.theme.sidebarMinWidth}px;
flex-shrink: 0;
&:first-child {
margin-top: 20px;
}
`;
export default Section;
@@ -1,66 +0,0 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Arrow from "components/Arrow";
type Props = {
direction: "left" | "right",
style?: Object,
onClick?: () => any,
};
const Toggle = React.forwardRef<Props, HTMLButtonElement>(
({ direction = "left", onClick, style }: Props, ref) => {
return (
<Positioner style={style}>
<ToggleButton ref={ref} $direction={direction} onClick={onClick}>
<Arrow />
</ToggleButton>
</Positioner>
);
}
);
export const ToggleButton = styled.button`
opacity: 0;
background: none;
transition: opacity 100ms ease-in-out;
transform: translateY(-50%)
scaleX(${(props) => (props.$direction === "left" ? 1 : -1)});
position: fixed;
top: 50vh;
padding: 8px;
border: 0;
pointer-events: none;
color: ${(props) => props.theme.divider};
&:active {
color: ${(props) => props.theme.sidebarText};
}
${breakpoint("tablet")`
pointer-events: all;
cursor: pointer;
`}
`;
export const Positioner = styled.div`
display: none;
z-index: 2;
position: absolute;
top: 0;
bottom: 0;
right: -30px;
width: 30px;
&:hover ${ToggleButton}, &:focus-within ${ToggleButton} {
opacity: 1;
}
${breakpoint("tablet")`
display: block;
`}
`;
export default Toggle;
-7
View File
@@ -272,13 +272,6 @@ class SocketProvider extends React.Component<Props> {
}
});
this.socket.on("collections.update_index", (event) => {
const collection = collections.get(event.collectionId);
if (collection) {
collection.updateIndex(event.index);
}
});
// received a message from the API server that we should request
// to join a specific room. Forward that to the ws server.
this.socket.on("join", (event) => {
+11 -25
View File
@@ -2,18 +2,19 @@
import * as React from "react";
import styled from "styled-components";
type Props = {|
type Props = {
children: React.Node,
sticky?: boolean,
|};
};
const H3 = styled.h3`
border-bottom: 1px solid ${(props) => props.theme.divider};
margin: 12px 0;
margin-top: 22px;
margin-bottom: 12px;
line-height: 1;
position: relative;
`;
const Underline = styled.div`
const Underline = styled("span")`
margin-top: -1px;
display: inline-block;
font-weight: 500;
@@ -21,29 +22,14 @@ const Underline = styled.div`
line-height: 1.5;
color: ${(props) => props.theme.textSecondary};
border-bottom: 3px solid ${(props) => props.theme.textSecondary};
padding-top: 6px;
padding-bottom: 4px;
padding-bottom: 5px;
`;
// When sticky we need extra background coverage around the sides otherwise
// items that scroll past can "stick out" the sides of the heading
const Background = styled.div`
position: ${(props) => (props.sticky ? "sticky" : "relative")};
${(props) => (props.sticky ? "top: 54px;" : "")}
margin: 0 -8px;
padding: 0 8px;
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
z-index: 1;
`;
const Subheading = ({ children, sticky, ...rest }: Props) => {
const Subheading = ({ children, ...rest }: Props) => {
return (
<Background sticky={sticky}>
<H3 {...rest}>
<Underline>{children}</Underline>
</H3>
</Background>
<H3 {...rest}>
<Underline>{children}</Underline>
</H3>
);
};
+3 -16
View File
@@ -13,23 +13,17 @@ type Props = {|
id?: string,
|};
function Switch({ width = 38, height = 20, label, disabled, ...props }: Props) {
function Switch({ width = 38, height = 20, label, ...props }: Props) {
const component = (
<Wrapper width={width} height={height}>
<HiddenInput
type="checkbox"
width={width}
height={height}
disabled={disabled}
{...props}
/>
<HiddenInput type="checkbox" width={width} height={height} {...props} />
<Slider width={width} height={height} />
</Wrapper>
);
if (label) {
return (
<Label disabled={disabled} htmlFor={props.id}>
<Label htmlFor={props.id}>
{component}
<LabelText>{label}</LabelText>
</Label>
@@ -42,8 +36,6 @@ function Switch({ width = 38, height = 20, label, disabled, ...props }: Props) {
const Label = styled.label`
display: flex;
align-items: center;
${(props) => (props.disabled ? `opacity: 0.75;` : "")}
`;
const Wrapper = styled.label`
@@ -87,11 +79,6 @@ const HiddenInput = styled.input`
height: 0;
visibility: hidden;
&:disabled + ${Slider} {
opacity: 0.75;
cursor: default;
}
&:checked + ${Slider} {
background-color: ${(props) => props.theme.primary};
}
+5 -5
View File
@@ -8,15 +8,15 @@ type Props = {
theme: Theme,
};
const TabLink = styled(NavLink)`
const StyledNavLink = styled(NavLink)`
position: relative;
display: inline-flex;
align-items: center;
display: inline-block;
font-weight: 500;
font-size: 14px;
color: ${(props) => props.theme.textTertiary};
margin-right: 24px;
padding: 6px 0;
padding-bottom: 8px;
&:hover {
color: ${(props) => props.theme.textSecondary};
@@ -32,7 +32,7 @@ function Tab({ theme, ...rest }: Props) {
color: theme.textSecondary,
};
return <TabLink {...rest} activeStyle={activeStyle} />;
return <StyledNavLink {...rest} activeStyle={activeStyle} />;
}
export default withTheme(Tab);
+4 -24
View File
@@ -1,25 +1,13 @@
// @flow
import * as React from "react";
import styled from "styled-components";
const Nav = styled.nav`
const Tabs = styled.nav`
position: relative;
border-bottom: 1px solid ${(props) => props.theme.divider};
margin: 12px 0;
margin-top: 22px;
margin-bottom: 12px;
overflow-y: auto;
white-space: nowrap;
transition: opacity 250ms ease-out;
`;
// When sticky we need extra background coverage around the sides otherwise
// items that scroll past can "stick out" the sides of the heading
const Sticky = styled.div`
position: sticky;
top: 54px;
margin: 0 -8px;
padding: 0 8px;
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
z-index: 1;
`;
export const Separator = styled.span`
@@ -30,12 +18,4 @@ export const Separator = styled.span`
margin-top: 6px;
`;
const Tabs = (props: {}) => {
return (
<Sticky>
<Nav {...props}></Nav>
</Sticky>
);
};
export default Tabs;
@@ -2,7 +2,7 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import Toast from "components/Toast";
import Toast from "./components/Toast";
import useStores from "hooks/useStores";
function Toasts() {
+3
View File
@@ -0,0 +1,3 @@
// @flow
import Toasts from "./Toasts";
export default Toasts;
-31
View File
@@ -1,31 +0,0 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp("https?://cawemo.com/(?:share|embed)/(.*)$");
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class Cawemo extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
const { matches } = this.props.attrs;
const shareId = matches[1];
return (
<Frame
{...this.props}
src={`https://cawemo.com/embed/${shareId}`}
title={"Cawemo Embed"}
border
allowfullscreen
/>
);
}
}
-22
View File
@@ -1,22 +0,0 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import Cawemo from "./Cawemo";
describe("Cawemo", () => {
const match = Cawemo.ENABLED[0];
test("to be enabled on embed link", () => {
expect(
"https://cawemo.com/embed/a82e9f22-e283-4253-8d11".match(match)
).toBeTruthy();
});
test("to be enabled on share link", () => {
expect(
"https://cawemo.com/embed/a82e9f22-e283-4253-8d11".match(match)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://cawemo.com/".match(match)).toBe(null);
expect("https://cawemo.com/diagrams".match(match)).toBe(null);
});
});
+2 -15
View File
@@ -15,23 +15,13 @@ type Props = {|
class Gist extends React.Component<Props> {
static ENABLED = [URL_REGEX];
ref = React.createRef<HTMLIFrameElement>();
get id() {
const gistUrl = new URL(this.props.attrs.href);
return gistUrl.pathname.split("/")[2];
}
componentDidMount() {
this.updateIframeContent();
}
componentDidUpdate() {
this.updateIframeContent();
}
updateIframeContent = () => {
const iframe = this.ref.current;
updateIframeContent = (iframe: ?HTMLIFrameElement) => {
if (!iframe) return;
const id = this.id;
@@ -49,8 +39,6 @@ class Gist extends React.Component<Props> {
"<style>*{ font-size:12px; } body { margin: 0; } .gist .blob-wrapper.data { max-height:150px; overflow:auto; }</style>";
const iframeHtml = `<html><head><base target="_parent">${styles}</head><body>${gistScript}</body></html>`;
if (!doc) return;
doc.open();
doc.writeln(iframeHtml);
doc.close();
@@ -62,14 +50,13 @@ class Gist extends React.Component<Props> {
return (
<iframe
className={this.props.isSelected ? "ProseMirror-selectednode" : ""}
ref={this.ref}
ref={this.updateIframeContent}
type="text/html"
frameBorder="0"
width="100%"
height="200px"
id={`gist-${id}`}
title={`Github Gist (${id})`}
onLoad={this.updateIframeContent}
/>
);
}
+1 -1
View File
@@ -12,7 +12,7 @@ type Props = {|
|},
|};
export default class GoogleSheets extends React.Component<Props> {
export default class GoogleSlides extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
-12
View File
@@ -4,7 +4,6 @@ import styled from "styled-components";
import Image from "components/Image";
import Abstract from "./Abstract";
import Airtable from "./Airtable";
import Cawemo from "./Cawemo";
import ClickUp from "./ClickUp";
import Codepen from "./Codepen";
import Figma from "./Figma";
@@ -62,18 +61,9 @@ export default [
component: Airtable,
matcher: matcher(Airtable),
},
{
title: "Cawemo",
keywords: "bpmn process",
defaultHidden: true,
icon: () => <Img src="/images/cawemo.png" />,
component: Cawemo,
matcher: matcher(Cawemo),
},
{
title: "ClickUp",
keywords: "project",
defaultHidden: true,
icon: () => <Img src="/images/clickup.png" />,
component: ClickUp,
matcher: matcher(ClickUp),
@@ -143,7 +133,6 @@ export default [
{
title: "InVision",
keywords: "design prototype",
defaultHidden: true,
icon: () => <Img src="/images/invision.png" />,
component: InVision,
matcher: matcher(InVision),
@@ -186,7 +175,6 @@ export default [
{
title: "Mode",
keywords: "analytics",
defaultHidden: true,
icon: () => <Img src="/images/mode-analytics.png" />,
component: ModeAnalytics,
matcher: matcher(ModeAnalytics),
-31
View File
@@ -1,31 +0,0 @@
// @flow
import * as React from "react";
export default function useDebouncedCallback(
callback: (any) => mixed,
wait: number
) {
// track args & timeout handle between calls
const argsRef = React.useRef();
const timeout = React.useRef();
function cleanup() {
if (timeout.current) {
clearTimeout(timeout.current);
}
}
// make sure our timeout gets cleared if consuming component gets unmounted
React.useEffect(() => cleanup, []);
return function (...args: any) {
argsRef.current = args;
cleanup();
timeout.current = setTimeout(() => {
if (argsRef.current) {
callback(...argsRef.current);
}
}, wait);
};
}
-69
View File
@@ -1,69 +0,0 @@
// @flow
import invariant from "invariant";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import useStores from "hooks/useStores";
let importingLock = false;
export default function useImportDocument(
collectionId: string,
documentId?: string
): {| handleFiles: (files: File[]) => Promise<void>, isImporting: boolean |} {
const { documents, ui } = useStores();
const [isImporting, setImporting] = React.useState(false);
const { t } = useTranslation();
const history = useHistory();
const handleFiles = React.useCallback(
async (files = []) => {
if (importingLock) {
return;
}
// Because this is the onChange handler it's possible for the change to be
// from previously selecting a file to not selecting a file aka empty
if (!files.length) {
return;
}
setImporting(true);
importingLock = true;
try {
let cId = collectionId;
const redirect = files.length === 1;
if (documentId && !collectionId) {
const document = await documents.fetch(documentId);
invariant(document, "Document not available");
cId = document.collectionId;
}
for (const file of files) {
const doc = await documents.import(file, documentId, cId, {
publish: true,
});
if (redirect) {
history.push(doc.url);
}
}
} catch (err) {
ui.showToast(`${t("Could not import file")}. ${err.message}`, {
type: "error",
});
} finally {
setImporting(false);
importingLock = false;
}
},
[t, ui, documents, history, collectionId, documentId]
);
return {
handleFiles,
isImporting,
};
}
-16
View File
@@ -1,16 +0,0 @@
// @flow
import * as React from "react";
const useUnmount = (callback: Function) => {
const ref = React.useRef(callback);
ref.current = callback;
React.useEffect(() => {
return () => {
ref.current();
};
}, []);
};
export default useUnmount;
+9 -25
View File
@@ -3,13 +3,13 @@ import "focus-visible";
import { createBrowserHistory } from "history";
import { Provider } from "mobx-react";
import * as React from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { render } from "react-dom";
import { Router } from "react-router-dom";
import { initI18n } from "shared/i18n";
import stores from "stores";
import Analytics from "components/Analytics";
import ErrorBoundary from "components/ErrorBoundary";
import PageTheme from "components/PageTheme";
import ScrollToTop from "components/ScrollToTop";
import Theme from "components/Theme";
import Toasts from "components/Toasts";
@@ -19,46 +19,30 @@ import { initSentry } from "utils/sentry";
initI18n();
const element = window.document.getElementById("root");
const element = document.getElementById("root");
const history = createBrowserHistory();
if (env.SENTRY_DSN) {
initSentry(history);
}
if ("serviceWorker" in window.navigator) {
window.addEventListener("load", () => {
window.navigator.serviceWorker
.register("/static/service-worker.js", {
scope: "/",
})
.then((registration) => {
console.log("SW registered: ", registration);
})
.catch((registrationError) => {
console.log("SW registration failed: ", registrationError);
});
});
}
if (element) {
render(
<Provider {...stores}>
<Analytics>
<Theme>
<ErrorBoundary>
<Theme>
<ErrorBoundary>
<DndProvider backend={HTML5Backend}>
<Router history={history}>
<>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
</>
</Router>
</ErrorBoundary>
</Theme>
</Analytics>
</DndProvider>
</ErrorBoundary>
</Theme>
</Provider>,
element
);
+1 -9
View File
@@ -19,7 +19,6 @@ import MenuItem, { MenuAnchor } from "components/ContextMenu/MenuItem";
import Separator from "components/ContextMenu/Separator";
import Flex from "components/Flex";
import Modal from "components/Modal";
import usePrevious from "hooks/usePrevious";
import useStores from "hooks/useStores";
type Props = {|
@@ -75,19 +74,12 @@ function AccountMenu(props: Props) {
placement: "bottom-start",
modal: true,
});
const { auth, ui } = useStores();
const previousTheme = usePrevious(ui.theme);
const { auth } = useStores();
const { t } = useTranslation();
const [keyboardShortcutsOpen, setKeyboardShortcutsOpen] = React.useState(
false
);
React.useEffect(() => {
if (ui.theme !== previousTheme) {
menu.hide();
}
}, [menu, ui.theme, previousTheme]);
return (
<>
<Modal
+23 -26
View File
@@ -9,7 +9,7 @@ import Collection from "models/Collection";
import CollectionDelete from "scenes/CollectionDelete";
import CollectionEdit from "scenes/CollectionEdit";
import CollectionExport from "scenes/CollectionExport";
import CollectionPermissions from "scenes/CollectionPermissions";
import CollectionMembers from "scenes/CollectionMembers";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
@@ -42,10 +42,9 @@ function CollectionMenu({
const history = useHistory();
const file = React.useRef<?HTMLInputElement>();
const [
showCollectionPermissions,
setShowCollectionPermissions,
] = React.useState(false);
const [showCollectionMembers, setShowCollectionMembers] = React.useState(
false
);
const [showCollectionEdit, setShowCollectionEdit] = React.useState(false);
const [showCollectionDelete, setShowCollectionDelete] = React.useState(false);
const [showCollectionExport, setShowCollectionExport] = React.useState(false);
@@ -65,10 +64,6 @@ function CollectionMenu({
[history, collection.id]
);
const stopPropagation = React.useCallback((ev: SyntheticEvent<>) => {
ev.stopPropagation();
}, []);
const handleImportDocument = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
@@ -86,27 +81,22 @@ function CollectionMenu({
async (ev: SyntheticEvent<>) => {
const files = getDataTransferFiles(ev);
// Because this is the onChange handler it's possible for the change to be
// from previously selecting a file to not selecting a file aka empty
if (!files.length) {
return;
}
try {
const file = files[0];
const document = await documents.import(file, null, collection.id, {
publish: true,
});
const document = await documents.import(
file,
null,
this.props.collection.id,
{ publish: true }
);
history.push(document.url);
} catch (err) {
ui.showToast(err.message, {
type: "error",
});
throw err;
}
},
[history, ui, collection.id, documents]
[history, ui, documents]
);
const can = policies.abilities(collection.id);
@@ -118,7 +108,7 @@ function CollectionMenu({
type="file"
ref={file}
onChange={handleFilePicked}
onClick={stopPropagation}
onClick={(ev) => ev.stopPropagation()}
accept={documents.importFileTypes.join(", ")}
tabIndex="-1"
/>
@@ -158,7 +148,7 @@ function CollectionMenu({
{
title: `${t("Permissions")}`,
visible: can.update,
onClick: () => setShowCollectionPermissions(true),
onClick: () => setShowCollectionMembers(true),
},
{
title: `${t("Export")}`,
@@ -168,6 +158,9 @@ function CollectionMenu({
{
type: "separator",
},
{
type: "separator",
},
{
title: `${t("Delete")}`,
visible: !!(collection && can.delete),
@@ -180,10 +173,14 @@ function CollectionMenu({
<>
<Modal
title={t("Collection permissions")}
onRequestClose={() => setShowCollectionPermissions(false)}
isOpen={showCollectionPermissions}
onRequestClose={() => setShowCollectionMembers(false)}
isOpen={showCollectionMembers}
>
<CollectionPermissions collection={collection} />
<CollectionMembers
collection={collection}
onSubmit={() => setShowCollectionMembers(false)}
onEdit={() => setShowCollectionEdit(true)}
/>
</Modal>
<Modal
title={t("Edit collection")}
+6 -97
View File
@@ -4,11 +4,9 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { useMenuState, MenuButton } from "reakit/Menu";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import Document from "models/Document";
import DocumentDelete from "scenes/DocumentDelete";
import DocumentMove from "scenes/DocumentMove";
import DocumentShare from "scenes/DocumentShare";
import DocumentTemplatize from "scenes/DocumentTemplatize";
import CollectionIcon from "components/CollectionIcon";
@@ -17,11 +15,10 @@ import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
import Flex from "components/Flex";
import Modal from "components/Modal";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import getDataTransferFiles from "utils/getDataTransferFiles";
import {
documentHistoryUrl,
documentMoveUrl,
documentUrl,
editDocumentUrl,
newDocumentUrl,
@@ -52,22 +49,14 @@ function DocumentMenu({
onOpen,
onClose,
}: Props) {
const team = useCurrentTeam();
const { policies, collections, ui, documents } = useStores();
const menu = useMenuState({
modal,
unstable_preventOverflow: true,
unstable_fixed: true,
unstable_flip: true,
});
const { policies, collections, auth, ui } = useStores();
const menu = useMenuState({ modal });
const history = useHistory();
const { t } = useTranslation();
const [renderModals, setRenderModals] = React.useState(false);
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
const [showMoveModal, setShowMoveModal] = React.useState(false);
const [showTemplateModal, setShowTemplateModal] = React.useState(false);
const [showShareModal, setShowShareModal] = React.useState(false);
const file = React.useRef<?HTMLInputElement>();
const handleOpen = React.useCallback(() => {
setRenderModals(true);
@@ -141,76 +130,13 @@ function DocumentMenu({
[document]
);
const collection = collections.get(document.collectionId);
const can = policies.abilities(document.id);
const canShareDocuments = !!(can.share && team.sharing);
const canShareDocuments = !!(can.share && auth.team && auth.team.sharing);
const canViewHistory = can.read && !can.restore;
const stopPropagation = React.useCallback((ev: SyntheticEvent<>) => {
ev.stopPropagation();
}, []);
const handleImportDocument = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
// simulate a click on the file upload input element
if (file.current) {
file.current.click();
}
},
[file]
);
const handleFilePicked = React.useCallback(
async (ev: SyntheticEvent<>) => {
const files = getDataTransferFiles(ev);
// Because this is the onChange handler it's possible for the change to be
// from previously selecting a file to not selecting a file aka empty
if (!files.length) {
return;
}
if (!collection) {
return;
}
try {
const file = files[0];
const importedDocument = await documents.import(
file,
document.id,
collection.id,
{
publish: true,
}
);
history.push(importedDocument.url);
} catch (err) {
ui.showToast(err.message, {
type: "error",
});
throw err;
}
},
[history, ui, collection, documents, document.id]
);
const collection = collections.get(document.collectionId);
return (
<>
<VisuallyHidden>
<input
type="file"
ref={file}
onChange={handleFilePicked}
onClick={stopPropagation}
accept={documents.importFileTypes.join(", ")}
tabIndex="-1"
/>
</VisuallyHidden>
{label ? (
<MenuButton {...menu}>{label}</MenuButton>
) : (
@@ -315,11 +241,6 @@ function DocumentMenu({
}),
visible: !!can.createChildDocument,
},
{
title: t("Import document"),
visible: can.createChildDocument,
onClick: handleImportDocument,
},
{
title: `${t("Create template")}`,
onClick: () => setShowTemplateModal(true),
@@ -352,7 +273,7 @@ function DocumentMenu({
},
{
title: `${t("Move")}`,
onClick: () => setShowMoveModal(true),
to: documentMoveUrl(document),
visible: !!can.move,
},
{
@@ -380,18 +301,6 @@ function DocumentMenu({
</ContextMenu>
{renderModals && (
<>
<Modal
title={t("Move {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowMoveModal(false)}
isOpen={showMoveModal}
>
<DocumentMove
document={document}
onRequestClose={() => setShowMoveModal(false)}
/>
</Modal>
<Modal
title={t("Delete {{ documentName }}", {
documentName: document.noun,
+2 -1
View File
@@ -1,4 +1,5 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
@@ -32,4 +33,4 @@ function MemberMenu({ onRemove }: Props) {
);
}
export default MemberMenu;
export default observer(MemberMenu);
+3 -4
View File
@@ -14,10 +14,9 @@ type Props = {|
|};
function UserMenu({ user }: Props) {
const { users, policies } = useStores();
const { users } = useStores();
const { t } = useTranslation();
const menu = useMenuState({ modal: true });
const can = policies.abilities(user.id);
const handlePromote = React.useCallback(
(ev: SyntheticEvent<>) => {
@@ -99,14 +98,14 @@ function UserMenu({ user }: Props) {
userName: user.name,
}),
onClick: handleDemote,
visible: can.demote,
visible: user.isAdmin,
},
{
title: t("Make {{ userName }} an admin…", {
userName: user.name,
}),
onClick: handlePromote,
visible: can.promote,
visible: !user.isAdmin && !user.isSuspended,
},
{
type: "separator",
+9 -13
View File
@@ -15,16 +15,19 @@ export default class Collection extends BaseModel {
description: string;
icon: string;
color: string;
permission: "read" | "read_write" | void;
sharing: boolean;
index: string;
private: boolean;
documents: NavigationNode[];
createdAt: string;
updatedAt: string;
createdAt: ?string;
updatedAt: ?string;
deletedAt: ?string;
sort: { field: string, direction: "asc" | "desc" };
url: string;
@computed
get isPrivate(): boolean {
return this.private;
}
@computed
get isEmpty(): boolean {
return this.documents.length === 0;
@@ -63,11 +66,6 @@ export default class Collection extends BaseModel {
travelDocuments(this.documents);
}
@action
updateIndex(index: string) {
this.index = index;
}
getDocumentChildren(documentId: string): NavigationNode[] {
let result = [];
const traveler = (nodes) => {
@@ -114,11 +112,9 @@ export default class Collection extends BaseModel {
"name",
"color",
"description",
"sharing",
"icon",
"permission",
"private",
"sort",
"index",
]);
};
+7 -1
View File
@@ -6,6 +6,8 @@ class Team extends BaseModel {
id: string;
name: string;
avatarUrl: string;
slackConnected: boolean;
googleConnected: boolean;
sharing: boolean;
documentEmbeds: boolean;
guestSignin: boolean;
@@ -15,7 +17,11 @@ class Team extends BaseModel {
@computed
get signinMethods(): string {
return "SSO";
if (this.slackConnected && this.googleConnected) {
return "Slack or Google";
}
if (this.slackConnected) return "Slack";
return "Google";
}
}
+3 -3
View File
@@ -3,11 +3,11 @@ import * as React from "react";
import { Switch, Redirect, type Match } from "react-router-dom";
import Archive from "scenes/Archive";
import Collection from "scenes/Collection";
import Dashboard from "scenes/Dashboard";
import KeyedDocument from "scenes/Document/KeyedDocument";
import DocumentNew from "scenes/DocumentNew";
import Drafts from "scenes/Drafts";
import Error404 from "scenes/Error404";
import Home from "scenes/Home";
import Search from "scenes/Search";
import Starred from "scenes/Starred";
import Templates from "scenes/Templates";
@@ -37,8 +37,8 @@ export default function AuthenticatedRoutes() {
<Layout>
<Switch>
<Redirect from="/dashboard" to="/home" />
<Route path="/home/:tab" component={Home} />
<Route path="/home" component={Home} />
<Route path="/home/:tab" component={Dashboard} />
<Route path="/home" component={Dashboard} />
<Route exact path="/starred" component={Starred} />
<Route exact path="/starred/:sort" component={Starred} />
<Route exact path="/templates" component={Templates} />
-2
View File
@@ -9,7 +9,6 @@ const Authenticated = React.lazy(() => import("components/Authenticated"));
const AuthenticatedRoutes = React.lazy(() => import("./authenticated"));
const KeyedDocument = React.lazy(() => import("scenes/Document/KeyedDocument"));
const Login = React.lazy(() => import("scenes/Login"));
const Logout = React.lazy(() => import("scenes/Logout"));
export default function Routes() {
return (
@@ -23,7 +22,6 @@ export default function Routes() {
<Switch>
<Route exact path="/" component={Login} />
<Route exact path="/create" component={Login} />
<Route exact path="/logout" component={Logout} />
<Route exact path="/share/:shareId" component={KeyedDocument} />
<Authenticated>
<AuthenticatedRoutes />
+2 -2
View File
@@ -3,8 +3,8 @@ import * as React from "react";
import { Switch } from "react-router-dom";
import Settings from "scenes/Settings";
import Details from "scenes/Settings/Details";
import Export from "scenes/Settings/Export";
import Groups from "scenes/Settings/Groups";
import ImportExport from "scenes/Settings/ImportExport";
import Notifications from "scenes/Settings/Notifications";
import People from "scenes/Settings/People";
import Security from "scenes/Settings/Security";
@@ -28,7 +28,7 @@ export default function SettingsRoutes() {
<Route exact path="/settings/notifications" component={Notifications} />
<Route exact path="/settings/integrations/slack" component={Slack} />
<Route exact path="/settings/integrations/zapier" component={Zapier} />
<Route exact path="/settings/import-export" component={ImportExport} />
<Route exact path="/settings/export" component={Export} />
</Switch>
);
}
+16 -10
View File
@@ -1,34 +1,40 @@
// @flow
import { observer } from "mobx-react";
import { ArchiveIcon } from "outline-icons";
import { observer, inject } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import DocumentsStore from "stores/DocumentsStore";
import CenteredContent from "components/CenteredContent";
import Empty from "components/Empty";
import Heading from "components/Heading";
import PageTitle from "components/PageTitle";
import PaginatedDocumentList from "components/PaginatedDocumentList";
import Scene from "components/Scene";
import Subheading from "components/Subheading";
import useStores from "hooks/useStores";
function Archive() {
type Props = {
documents: DocumentsStore,
};
function Archive(props: Props) {
const { t } = useTranslation();
const { documents } = useStores();
const { documents } = props;
return (
<Scene icon={<ArchiveIcon color="currentColor" />} title={t("Archive")}>
<CenteredContent column auto>
<PageTitle title={t("Archive")} />
<Heading>{t("Archive")}</Heading>
<PaginatedDocumentList
documents={documents.archived}
fetch={documents.fetchArchived}
heading={<Subheading sticky>{t("Documents")}</Subheading>}
heading={<Subheading>{t("Documents")}</Subheading>}
empty={
<Empty>{t("The document archive is empty at the moment.")}</Empty>
}
showCollection
showTemplate
/>
</Scene>
</CenteredContent>
);
}
export default observer(Archive);
export default inject("documents")(observer(Archive));
+351 -361
View File
@@ -1,399 +1,382 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { NewDocumentIcon, PlusIcon, PinIcon, MoreIcon } from "outline-icons";
import * as React from "react";
import Dropzone from "react-dropzone";
import { useTranslation, Trans } from "react-i18next";
import { useParams, Redirect, Link, Switch, Route } from "react-router-dom";
import styled, { css } from "styled-components";
import CollectionPermissions from "scenes/CollectionPermissions";
import { withTranslation, Trans, type TFunction } from "react-i18next";
import { Redirect, Link, Switch, Route, type Match } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import CollectionsStore from "stores/CollectionsStore";
import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import CollectionEdit from "scenes/CollectionEdit";
import CollectionMembers from "scenes/CollectionMembers";
import Search from "scenes/Search";
import { Action, Separator } from "components/Actions";
import Badge from "components/Badge";
import Actions, { Action, Separator } from "components/Actions";
import Button from "components/Button";
import CenteredContent from "components/CenteredContent";
import CollectionDescription from "components/CollectionDescription";
import CollectionIcon from "components/CollectionIcon";
import DocumentList from "components/DocumentList";
import Editor from "components/Editor";
import Flex from "components/Flex";
import Heading from "components/Heading";
import HelpText from "components/HelpText";
import InputSearch from "components/InputSearch";
import LoadingIndicator from "components/LoadingIndicator";
import { ListPlaceholder } from "components/LoadingPlaceholder";
import Mask from "components/Mask";
import Modal from "components/Modal";
import PageTitle from "components/PageTitle";
import PaginatedDocumentList from "components/PaginatedDocumentList";
import Scene from "components/Scene";
import Subheading from "components/Subheading";
import Tab from "components/Tab";
import Tabs from "components/Tabs";
import Tooltip from "components/Tooltip";
import useImportDocument from "hooks/useImportDocument";
import useStores from "hooks/useStores";
import useUnmount from "hooks/useUnmount";
import CollectionMenu from "menus/CollectionMenu";
import { type Theme } from "types";
import { AuthorizationError } from "utils/errors";
import { newDocumentUrl, collectionUrl } from "utils/routeHelpers";
function CollectionScene() {
const params = useParams();
const { t } = useTranslation();
const { documents, policies, collections, ui } = useStores();
const [isFetching, setFetching] = React.useState();
const [error, setError] = React.useState();
const [permissionsModalOpen, setPermissionsModalOpen] = React.useState(false);
type Props = {
ui: UiStore,
documents: DocumentsStore,
collections: CollectionsStore,
policies: PoliciesStore,
match: Match,
theme: Theme,
t: TFunction,
};
const collectionId = params.id || "";
const collection = collections.get(collectionId);
const can = policies.abilities(collectionId || "");
const { handleFiles, isImporting } = useImportDocument(collectionId);
@observer
class CollectionScene extends React.Component<Props> {
@observable collection: ?Collection;
@observable isFetching: boolean = true;
@observable permissionsModalOpen: boolean = false;
@observable editModalOpen: boolean = false;
@observable redirectTo: ?string;
React.useEffect(() => {
if (collection) {
ui.setActiveCollection(collection);
componentDidMount() {
const { id } = this.props.match.params;
if (id) {
this.loadContent(id);
}
}, [ui, collection]);
React.useEffect(() => {
setError(null);
documents.fetchPinned({ collectionId });
}, [documents, collectionId]);
React.useEffect(() => {
async function load() {
if ((!can || !collection) && !error && !isFetching) {
try {
setError(null);
setFetching(true);
await collections.fetch(collectionId);
} catch (err) {
setError(err);
} finally {
setFetching(false);
}
}
}
load();
}, [collections, isFetching, collection, error, collectionId, can]);
useUnmount(ui.clearActiveCollection);
const handlePermissionsModalOpen = React.useCallback(() => {
setPermissionsModalOpen(true);
}, []);
const handlePermissionsModalClose = React.useCallback(() => {
setPermissionsModalOpen(false);
}, []);
const handleRejection = React.useCallback(() => {
ui.showToast(
t("Document not supported try Markdown, Plain text, HTML, or Word"),
{ type: "error" }
);
}, [t, ui]);
if (!collection && error) {
return <Search notFound />;
}
const pinnedDocuments = collection
? documents.pinnedInCollection(collection.id)
: [];
const collectionName = collection ? collection.name : "";
const hasPinnedDocuments = !!pinnedDocuments.length;
componentDidUpdate(prevProps: Props) {
const { id } = this.props.match.params;
return collection ? (
<Scene
centered={false}
textTitle={collection.name}
title={
<>
<CollectionIcon collection={collection} expanded />
&nbsp;
{collection.name}
</>
if (this.collection) {
const { collection } = this;
const policy = this.props.policies.get(collection.id);
if (!policy) {
this.loadContent(collection.id);
}
actions={
<>
{can.update && (
<>
<Action>
<InputSearch
source="collection"
placeholder={`${t("Search in collection")}`}
label={`${t("Search in collection")}`}
labelHidden
collectionId={collectionId}
/>
</Action>
<Action>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button
as={Link}
to={collection ? newDocumentUrl(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
>
{t("New doc")}
</Button>
</Tooltip>
</Action>
<Separator />
</>
)}
<Action>
<CollectionMenu
collection={collection}
placement="bottom-end"
modal={false}
label={(props) => (
<Button
icon={<MoreIcon />}
{...props}
borderOnHover
neutral
small
/>
)}
/>
</Action>
</>
}
if (id && id !== prevProps.match.params.id) {
this.loadContent(id);
}
}
componentWillUnmount() {
this.props.ui.clearActiveCollection();
}
loadContent = async (id: string) => {
try {
const collection = await this.props.collections.fetch(id);
if (collection) {
this.props.ui.setActiveCollection(collection);
this.collection = collection;
await this.props.documents.fetchPinned({
collectionId: id,
});
}
>
<Dropzone
accept={documents.importFileTypes.join(", ")}
onDropAccepted={handleFiles}
onDropRejected={handleRejection}
noClick
multiple
>
{({
getRootProps,
getInputProps,
isDragActive,
isDragAccept,
isDragReject,
}) => (
<DropzoneContainer
{...getRootProps()}
isDragActive={isDragActive}
tabIndex="-1"
>
<input {...getInputProps()} />
{isImporting && <LoadingIndicator />}
} catch (error) {
if (error instanceof AuthorizationError) {
this.collection = null;
}
} finally {
this.isFetching = false;
}
};
<CenteredContent withStickyHeader>
{collection.isEmpty ? (
<Centered column>
<HelpText>
<Trans
defaults="<em>{{ collectionName }}</em> doesnt contain any
documents yet."
values={{ collectionName }}
components={{ em: <strong /> }}
/>
<br />
<Trans>Get started by creating a new one!</Trans>
</HelpText>
<Empty>
<Link to={newDocumentUrl(collection.id)}>
<Button icon={<NewDocumentIcon color="currentColor" />}>
{t("Create a document")}
</Button>
</Link>
&nbsp;&nbsp;
<Button onClick={handlePermissionsModalOpen} neutral>
{t("Manage permissions")}
</Button>
</Empty>
<Modal
title={t("Collection permissions")}
onRequestClose={handlePermissionsModalClose}
isOpen={permissionsModalOpen}
>
<CollectionPermissions collection={collection} />
</Modal>
</Centered>
) : (
<>
<Heading>
<CollectionIcon
collection={collection}
size={40}
expanded
/>{" "}
{collection.name}{" "}
{!collection.permission && (
<Tooltip
tooltip={t(
"This collection is only visible to those given access"
)}
placement="bottom"
>
<Badge>{t("Private")}</Badge>
</Tooltip>
)}
</Heading>
<CollectionDescription collection={collection} />
onNewDocument = (ev: SyntheticEvent<>) => {
ev.preventDefault();
{hasPinnedDocuments && (
<>
<Subheading sticky>
<TinyPinIcon size={18} color="currentColor" />{" "}
{t("Pinned")}
</Subheading>
<DocumentList documents={pinnedDocuments} showPin />
</>
)}
if (this.collection) {
this.redirectTo = newDocumentUrl(this.collection.id);
}
};
<Tabs>
<Tab to={collectionUrl(collection.id)} exact>
{t("Documents")}
</Tab>
<Tab to={collectionUrl(collection.id, "updated")} exact>
{t("Recently updated")}
</Tab>
<Tab to={collectionUrl(collection.id, "published")} exact>
{t("Recently published")}
</Tab>
<Tab to={collectionUrl(collection.id, "old")} exact>
{t("Least recently updated")}
</Tab>
<Tab
to={collectionUrl(collection.id, "alphabetical")}
exact
>
{t("AZ")}
</Tab>
</Tabs>
<Switch>
<Route path={collectionUrl(collection.id, "alphabetical")}>
<PaginatedDocumentList
key="alphabetical"
documents={documents.alphabeticalInCollection(
collection.id
)}
fetch={documents.fetchAlphabetical}
options={{ collectionId: collection.id }}
showPin
/>
</Route>
<Route path={collectionUrl(collection.id, "old")}>
<PaginatedDocumentList
key="old"
documents={documents.leastRecentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchLeastRecentlyUpdated}
options={{ collectionId: collection.id }}
showPin
/>
</Route>
<Route path={collectionUrl(collection.id, "recent")}>
<Redirect
to={collectionUrl(collection.id, "published")}
/>
</Route>
<Route path={collectionUrl(collection.id, "published")}>
<PaginatedDocumentList
key="published"
documents={documents.recentlyPublishedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyPublished}
options={{ collectionId: collection.id }}
showPublished
showPin
/>
</Route>
<Route path={collectionUrl(collection.id, "updated")}>
<PaginatedDocumentList
key="updated"
documents={documents.recentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyUpdated}
options={{ collectionId: collection.id }}
showPin
/>
</Route>
<Route path={collectionUrl(collection.id)} exact>
<PaginatedDocumentList
documents={documents.rootInCollection(collection.id)}
fetch={documents.fetchPage}
options={{
collectionId: collection.id,
parentDocumentId: null,
sort: collection.sort.field,
direction: "ASC",
}}
showNestedDocuments
showPin
/>
</Route>
</Switch>
</>
)}
<DropMessage>{t("Drop documents to import")}</DropMessage>
</CenteredContent>
</DropzoneContainer>
onPermissions = (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.permissionsModalOpen = true;
};
handlePermissionsModalClose = () => {
this.permissionsModalOpen = false;
};
handleEditModalOpen = () => {
this.editModalOpen = true;
};
handleEditModalClose = () => {
this.editModalOpen = false;
};
renderActions() {
const { match, policies, t } = this.props;
const can = policies.abilities(match.params.id || "");
return (
<Actions align="center" justify="flex-end">
{can.update && (
<>
<Action>
<InputSearch
source="collection"
placeholder={`${t("Search in collection")}`}
label={`${t("Search in collection")}`}
labelHidden
collectionId={match.params.id}
/>
</Action>
<Action>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button onClick={this.onNewDocument} icon={<PlusIcon />}>
{t("New doc")}
</Button>
</Tooltip>
</Action>
<Separator />
</>
)}
</Dropzone>
</Scene>
) : (
<CenteredContent>
<Heading>
<Mask height={35} />
</Heading>
<ListPlaceholder count={5} />
</CenteredContent>
);
<Action>
<CollectionMenu
collection={this.collection}
placement="bottom-end"
modal={false}
label={(props) => (
<Button
icon={<MoreIcon />}
{...props}
borderOnHover
neutral
small
/>
)}
/>
</Action>
</Actions>
);
}
render() {
const { documents, theme, t } = this.props;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
if (!this.isFetching && !this.collection) return <Search notFound />;
const pinnedDocuments = this.collection
? documents.pinnedInCollection(this.collection.id)
: [];
const collection = this.collection;
const collectionName = collection ? collection.name : "";
const hasPinnedDocuments = !!pinnedDocuments.length;
const hasDescription = collection ? collection.hasDescription : false;
return (
<CenteredContent>
{collection ? (
<>
<PageTitle title={collection.name} />
{collection.isEmpty ? (
<Centered column>
<HelpText>
<Trans
defaults="<em>{{ collectionName }}</em> doesnt contain any
documents yet."
values={{ collectionName }}
components={{ em: <strong /> }}
/>
<br />
<Trans>Get started by creating a new one!</Trans>
</HelpText>
<Wrapper>
<Link to={newDocumentUrl(collection.id)}>
<Button icon={<NewDocumentIcon color={theme.buttonText} />}>
{t("Create a document")}
</Button>
</Link>
&nbsp;&nbsp;
{collection.private && (
<Button onClick={this.onPermissions} neutral>
{t("Manage members")}
</Button>
)}
</Wrapper>
<Modal
title={t("Collection permissions")}
onRequestClose={this.handlePermissionsModalClose}
isOpen={this.permissionsModalOpen}
>
<CollectionMembers
collection={this.collection}
onSubmit={this.handlePermissionsModalClose}
onEdit={this.handleEditModalOpen}
/>
</Modal>
<Modal
title={t("Edit collection")}
onRequestClose={this.handleEditModalClose}
isOpen={this.editModalOpen}
>
<CollectionEdit
collection={this.collection}
onSubmit={this.handleEditModalClose}
/>
</Modal>
</Centered>
) : (
<>
<Heading>
<CollectionIcon collection={collection} size={40} expanded />{" "}
{collection.name}
</Heading>
{hasDescription && (
<React.Suspense fallback={<p>Loading</p>}>
<Editor
id={collection.id}
key={collection.description}
defaultValue={collection.description}
readOnly
/>
</React.Suspense>
)}
{hasPinnedDocuments && (
<>
<Subheading>
<TinyPinIcon size={18} /> {t("Pinned")}
</Subheading>
<DocumentList documents={pinnedDocuments} showPin />
</>
)}
<Tabs>
<Tab to={collectionUrl(collection.id)} exact>
{t("Documents")}
</Tab>
<Tab to={collectionUrl(collection.id, "updated")} exact>
{t("Recently updated")}
</Tab>
<Tab to={collectionUrl(collection.id, "published")} exact>
{t("Recently published")}
</Tab>
<Tab to={collectionUrl(collection.id, "old")} exact>
{t("Least recently updated")}
</Tab>
<Tab to={collectionUrl(collection.id, "alphabetical")} exact>
{t("AZ")}
</Tab>
</Tabs>
<Switch>
<Route path={collectionUrl(collection.id, "alphabetical")}>
<PaginatedDocumentList
key="alphabetical"
documents={documents.alphabeticalInCollection(
collection.id
)}
fetch={documents.fetchAlphabetical}
options={{ collectionId: collection.id }}
showPin
/>
</Route>
<Route path={collectionUrl(collection.id, "old")}>
<PaginatedDocumentList
key="old"
documents={documents.leastRecentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchLeastRecentlyUpdated}
options={{ collectionId: collection.id }}
showPin
/>
</Route>
<Route path={collectionUrl(collection.id, "recent")}>
<Redirect to={collectionUrl(collection.id, "published")} />
</Route>
<Route path={collectionUrl(collection.id, "published")}>
<PaginatedDocumentList
key="published"
documents={documents.recentlyPublishedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyPublished}
options={{ collectionId: collection.id }}
showPublished
showPin
/>
</Route>
<Route path={collectionUrl(collection.id, "updated")}>
<PaginatedDocumentList
key="updated"
documents={documents.recentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyUpdated}
options={{ collectionId: collection.id }}
showPin
/>
</Route>
<Route path={collectionUrl(collection.id)} exact>
<PaginatedDocumentList
documents={documents.rootInCollection(collection.id)}
fetch={documents.fetchPage}
options={{
collectionId: collection.id,
parentDocumentId: null,
sort: collection.sort.field,
direction: "ASC",
}}
showNestedDocuments
showPin
/>
</Route>
</Switch>
</>
)}
{this.renderActions()}
</>
) : (
<>
<Heading>
<Mask height={35} />
</Heading>
<ListPlaceholder count={5} />
</>
)}
</CenteredContent>
);
}
}
const DropMessage = styled(HelpText)`
opacity: 0;
pointer-events: none;
`;
const DropzoneContainer = styled.div`
min-height: calc(100% - 56px);
position: relative;
${({ isDragActive, theme }) =>
isDragActive &&
css`
&:after {
display: block;
content: "";
position: absolute;
top: 24px;
right: 24px;
bottom: 24px;
left: 24px;
background: ${theme.background};
border-radius: 8px;
border: 1px dashed ${theme.divider};
z-index: 1;
}
${DropMessage} {
opacity: 1;
z-index: 2;
position: absolute;
text-align: center;
top: 50%;
left: 50%;
transform: translateX(-50%);
}
`}
`;
const Centered = styled(Flex)`
text-align: center;
margin: 40vh auto 0;
@@ -407,9 +390,16 @@ const TinyPinIcon = styled(PinIcon)`
opacity: 0.8;
`;
const Empty = styled(Flex)`
const Wrapper = styled(Flex)`
justify-content: center;
margin: 10px 0;
`;
export default observer(CollectionScene);
export default withTranslation()<CollectionScene>(
inject(
"collections",
"policies",
"documents",
"ui"
)(withTheme(CollectionScene))
);
+29 -26
View File
@@ -3,7 +3,6 @@ import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { withTranslation, Trans, type TFunction } from "react-i18next";
import AuthStore from "stores/AuthStore";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import Button from "components/Button";
@@ -11,13 +10,13 @@ import Flex from "components/Flex";
import HelpText from "components/HelpText";
import IconPicker from "components/IconPicker";
import Input from "components/Input";
import InputRich from "components/InputRich";
import InputSelect from "components/InputSelect";
import Switch from "components/Switch";
type Props = {
collection: Collection,
ui: UiStore,
auth: AuthStore,
onSubmit: () => void,
t: TFunction,
};
@@ -25,9 +24,10 @@ type Props = {
@observer
class CollectionEdit extends React.Component<Props> {
@observable name: string = this.props.collection.name;
@observable sharing: boolean = this.props.collection.sharing;
@observable description: string = this.props.collection.description;
@observable icon: string = this.props.collection.icon;
@observable color: string = this.props.collection.color || "#4E5C6E";
@observable private: boolean = this.props.collection.private;
@observable sort: { field: string, direction: "asc" | "desc" } = this.props
.collection.sort;
@observable isSaving: boolean;
@@ -40,9 +40,10 @@ class CollectionEdit extends React.Component<Props> {
try {
await this.props.collection.save({
name: this.name,
description: this.description,
icon: this.icon,
color: this.color,
sharing: this.sharing,
private: this.private,
sort: this.sort,
});
this.props.onSubmit();
@@ -64,6 +65,10 @@ class CollectionEdit extends React.Component<Props> {
}
};
handleDescriptionChange = (getValue: () => string) => {
this.description = getValue();
};
handleNameChange = (ev: SyntheticInputEvent<*>) => {
this.name = ev.target.value;
};
@@ -73,13 +78,12 @@ class CollectionEdit extends React.Component<Props> {
this.icon = icon;
};
handleSharingChange = (ev: SyntheticInputEvent<*>) => {
this.sharing = ev.target.checked;
handlePrivateChange = (ev: SyntheticInputEvent<*>) => {
this.private = ev.target.checked;
};
render() {
const { auth, t } = this.props;
const teamSharingEnabled = !!auth.team && auth.team.sharing;
const { t } = this.props;
return (
<Flex column>
@@ -107,6 +111,15 @@ class CollectionEdit extends React.Component<Props> {
icon={this.icon}
/>
</Flex>
<InputRich
id={this.props.collection.id}
label={t("Description")}
onChange={this.handleDescriptionChange}
defaultValue={this.description || ""}
placeholder={t("More details about this collection…")}
minHeight={68}
maxHeight={200}
/>
<InputSelect
label={t("Sort in sidebar")}
options={[
@@ -117,23 +130,15 @@ class CollectionEdit extends React.Component<Props> {
onChange={this.handleSortChange}
/>
<Switch
id="sharing"
label={t("Public document sharing")}
onChange={this.handleSharingChange}
checked={this.sharing && teamSharingEnabled}
disabled={!teamSharingEnabled}
id="private"
label={t("Private collection")}
onChange={this.handlePrivateChange}
checked={this.private}
/>
<HelpText>
{teamSharingEnabled ? (
<Trans>
When enabled, documents can be shared publicly on the internet.
</Trans>
) : (
<Trans>
Public sharing is currently disabled in the team security
settings.
</Trans>
)}
<Trans>
A private collection will only be visible to invited team members.
</Trans>
</HelpText>
<Button
type="submit"
@@ -147,6 +152,4 @@ class CollectionEdit extends React.Component<Props> {
}
}
export default withTranslation()<CollectionEdit>(
inject("ui", "auth")(CollectionEdit)
);
export default withTranslation()<CollectionEdit>(inject("ui")(CollectionEdit));
@@ -45,7 +45,7 @@ class AddGroupsToCollection extends React.Component<Props> {
this.newGroupModalOpen = false;
};
handleFilter = (ev: SyntheticInputEvent<>) => {
handleFilter = (ev: SyntheticInputEvent<HTMLInputElement>) => {
this.query = ev.target.value;
this.debouncedFetch();
};
@@ -43,7 +43,7 @@ class AddPeopleToCollection extends React.Component<Props> {
this.inviteModalOpen = false;
};
handleFilter = (ev: SyntheticInputEvent<>) => {
handleFilter = (ev: SyntheticInputEvent<HTMLInputElement>) => {
this.query = ev.target.value;
this.debouncedFetch();
};
@@ -0,0 +1,268 @@
// @flow
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import AuthStore from "stores/AuthStore";
import CollectionGroupMembershipsStore from "stores/CollectionGroupMembershipsStore";
import GroupsStore from "stores/GroupsStore";
import MembershipsStore from "stores/MembershipsStore";
import UiStore from "stores/UiStore";
import UsersStore from "stores/UsersStore";
import Collection from "models/Collection";
import Button from "components/Button";
import ButtonLink from "components/ButtonLink";
import Empty from "components/Empty";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import Modal from "components/Modal";
import PaginatedList from "components/PaginatedList";
import Subheading from "components/Subheading";
import AddGroupsToCollection from "./AddGroupsToCollection";
import AddPeopleToCollection from "./AddPeopleToCollection";
import CollectionGroupMemberListItem from "./components/CollectionGroupMemberListItem";
import MemberListItem from "./components/MemberListItem";
type Props = {
ui: UiStore,
auth: AuthStore,
collection: Collection,
users: UsersStore,
memberships: MembershipsStore,
collectionGroupMemberships: CollectionGroupMembershipsStore,
groups: GroupsStore,
onEdit: () => void,
};
@observer
class CollectionMembers extends React.Component<Props> {
@observable addGroupModalOpen: boolean = false;
@observable addMemberModalOpen: boolean = false;
handleAddGroupModalOpen = () => {
this.addGroupModalOpen = true;
};
handleAddGroupModalClose = () => {
this.addGroupModalOpen = false;
};
handleAddMemberModalOpen = () => {
this.addMemberModalOpen = true;
};
handleAddMemberModalClose = () => {
this.addMemberModalOpen = false;
};
handleRemoveUser = (user) => {
try {
this.props.memberships.delete({
collectionId: this.props.collection.id,
userId: user.id,
});
this.props.ui.showToast(`${user.name} was removed from the collection`, {
type: "success",
});
} catch (err) {
this.props.ui.showToast("Could not remove user", { type: "error" });
}
};
handleUpdateUser = (user, permission) => {
try {
this.props.memberships.create({
collectionId: this.props.collection.id,
userId: user.id,
permission,
});
this.props.ui.showToast(`${user.name} permissions were updated`, {
type: "success",
});
} catch (err) {
this.props.ui.showToast("Could not update user", { type: "error" });
}
};
handleRemoveGroup = (group) => {
try {
this.props.collectionGroupMemberships.delete({
collectionId: this.props.collection.id,
groupId: group.id,
});
this.props.ui.showToast(`${group.name} was removed from the collection`, {
type: "success",
});
} catch (err) {
this.props.ui.showToast("Could not remove group", { type: "error" });
}
};
handleUpdateGroup = (group, permission) => {
try {
this.props.collectionGroupMemberships.create({
collectionId: this.props.collection.id,
groupId: group.id,
permission,
});
this.props.ui.showToast(`${group.name} permissions were updated`, {
type: "success",
});
} catch (err) {
this.props.ui.showToast("Could not update user", { type: "error" });
}
};
render() {
const {
collection,
users,
groups,
memberships,
collectionGroupMemberships,
auth,
} = this.props;
const { user } = auth;
if (!user) return null;
const key = memberships.orderedData
.map((m) => m.permission)
.concat(collection.private)
.join("-");
return (
<Flex column>
{collection.private ? (
<>
<HelpText>
Choose which groups and team members have access to view and edit
documents in the private <strong>{collection.name}</strong>{" "}
collection. You can make this collection visible to the entire
team by{" "}
<ButtonLink onClick={this.props.onEdit}>
changing the visibility
</ButtonLink>
.
</HelpText>
<span>
<Button
type="button"
onClick={this.handleAddGroupModalOpen}
icon={<PlusIcon />}
neutral
>
Add groups
</Button>
</span>
</>
) : (
<HelpText>
The <strong>{collection.name}</strong> collection is accessible by
everyone on the team. If you want to limit who can view the
collection,{" "}
<ButtonLink onClick={this.props.onEdit}>make it private</ButtonLink>
.
</HelpText>
)}
{collection.private && (
<GroupsWrap>
<Subheading>Groups</Subheading>
<PaginatedList
key={key}
items={groups.inCollection(collection.id)}
fetch={collectionGroupMemberships.fetchPage}
options={collection.private ? { id: collection.id } : undefined}
empty={<Empty>This collection has no groups.</Empty>}
renderItem={(group) => (
<CollectionGroupMemberListItem
key={group.id}
group={group}
collectionGroupMembership={collectionGroupMemberships.get(
`${group.id}-${collection.id}`
)}
onRemove={() => this.handleRemoveGroup(group)}
onUpdate={(permission) =>
this.handleUpdateGroup(group, permission)
}
/>
)}
/>
<Modal
title={`Add groups to ${collection.name}`}
onRequestClose={this.handleAddGroupModalClose}
isOpen={this.addGroupModalOpen}
>
<AddGroupsToCollection
collection={collection}
onSubmit={this.handleAddGroupModalClose}
/>
</Modal>
</GroupsWrap>
)}
{collection.private ? (
<>
<span>
<Button
type="button"
onClick={this.handleAddMemberModalOpen}
icon={<PlusIcon />}
neutral
>
Add individual members
</Button>
</span>
<Subheading>Individual Members</Subheading>
</>
) : (
<Subheading>Members</Subheading>
)}
<PaginatedList
key={key}
items={
collection.private
? users.inCollection(collection.id)
: users.active
}
fetch={collection.private ? memberships.fetchPage : users.fetchPage}
options={collection.private ? { id: collection.id } : undefined}
renderItem={(item) => (
<MemberListItem
key={item.id}
user={item}
membership={memberships.get(`${item.id}-${collection.id}`)}
canEdit={collection.private && item.id !== user.id}
onRemove={() => this.handleRemoveUser(item)}
onUpdate={(permission) => this.handleUpdateUser(item, permission)}
/>
)}
/>
<Modal
title={`Add people to ${collection.name}`}
onRequestClose={this.handleAddMemberModalClose}
isOpen={this.addMemberModalOpen}
>
<AddPeopleToCollection
collection={collection}
onSubmit={this.handleAddMemberModalClose}
/>
</Modal>
</Flex>
);
}
}
const GroupsWrap = styled.div`
margin-bottom: 50px;
`;
export default inject(
"auth",
"users",
"memberships",
"collectionGroupMemberships",
"groups",
"ui"
)(CollectionMembers);
@@ -8,14 +8,14 @@ import GroupListItem from "components/GroupListItem";
import InputSelect from "components/InputSelect";
import CollectionGroupMemberMenu from "menus/CollectionGroupMemberMenu";
type Props = {|
type Props = {
group: Group,
collectionGroupMembership: ?CollectionGroupMembership,
onUpdate: (permission: string) => any,
onRemove: () => any,
|};
onUpdate: (permission: string) => void,
onRemove: () => void,
};
const CollectionGroupMemberListItem = ({
const MemberListItem = ({
group,
collectionGroupMembership,
onUpdate,
@@ -25,8 +25,8 @@ const CollectionGroupMemberListItem = ({
const PERMISSIONS = React.useMemo(
() => [
{ label: t("View only"), value: "read" },
{ label: t("View and edit"), value: "read_write" },
{ label: t("Read only"), value: "read" },
{ label: t("Read & Edit"), value: "read_write" },
],
[t]
);
@@ -36,7 +36,6 @@ const CollectionGroupMemberListItem = ({
group={group}
onRemove={onRemove}
onUpdate={onUpdate}
showAvatar
renderActions={({ openMembersModal }) => (
<>
<Select
@@ -50,29 +49,25 @@ const CollectionGroupMemberListItem = ({
onChange={(ev) => onUpdate(ev.target.value)}
labelHidden
/>
<Spacer />
<CollectionGroupMemberMenu
onMembers={openMembersModal}
onRemove={onRemove}
/>
<ButtonWrap>
<CollectionGroupMemberMenu
onMembers={openMembersModal}
onRemove={onRemove}
/>
</ButtonWrap>
</>
)}
/>
);
};
const Spacer = styled.div`
width: 8px;
`;
const Select = styled(InputSelect)`
margin: 0;
font-size: 14px;
border-color: transparent;
select {
margin: 0;
}
`;
export default CollectionGroupMemberListItem;
const ButtonWrap = styled.div`
margin-left: 6px;
`;
export default MemberListItem;
@@ -17,9 +17,9 @@ type Props = {
user: User,
membership?: ?Membership,
canEdit: boolean,
onAdd?: () => any,
onRemove?: () => any,
onUpdate?: (permission: string) => any,
onAdd?: () => void,
onRemove?: () => void,
onUpdate?: (permission: string) => void,
};
const MemberListItem = ({
@@ -34,8 +34,8 @@ const MemberListItem = ({
const PERMISSIONS = React.useMemo(
() => [
{ label: t("View only"), value: "read" },
{ label: t("View and edit"), value: "read_write" },
{ label: t("Read only"), value: "read" },
{ label: t("Read & Edit"), value: "read_write" },
],
[t]
);
@@ -56,29 +56,24 @@ const MemberListItem = ({
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
</>
}
image={<Avatar src={user.avatarUrl} size={32} />}
image={<Avatar src={user.avatarUrl} size={40} />}
actions={
<Flex align="center">
{onUpdate && (
{canEdit && onUpdate && (
<Select
label={t("Permissions")}
options={PERMISSIONS}
value={membership ? membership.permission : undefined}
onChange={(ev) => onUpdate(ev.target.value)}
disabled={!canEdit}
labelHidden
/>
)}
{canEdit && (
<>
<Spacer />
{onRemove && <MemberMenu onRemove={onRemove} />}
{onAdd && (
<Button onClick={onAdd} neutral>
{t("Add")}
</Button>
)}
</>
&nbsp;&nbsp;
{canEdit && onRemove && <MemberMenu onRemove={onRemove} />}
{canEdit && onAdd && (
<Button onClick={onAdd} neutral>
{t("Add")}
</Button>
)}
</Flex>
}
@@ -86,18 +81,9 @@ const MemberListItem = ({
);
};
const Spacer = styled.div`
width: 8px;
`;
const Select = styled(InputSelect)`
margin: 0;
font-size: 14px;
border-color: transparent;
select {
margin: 0;
}
`;
export default MemberListItem;
@@ -12,7 +12,7 @@ import Time from "components/Time";
type Props = {
user: User,
canEdit: boolean,
onAdd: () => any,
onAdd: () => void,
};
const UserListItem = ({ user, onAdd, canEdit }: Props) => {
@@ -21,7 +21,7 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
return (
<ListItem
title={user.name}
image={<Avatar src={user.avatarUrl} size={32} />}
image={<Avatar src={user.avatarUrl} size={40} />}
subtitle={
<>
{user.lastActiveAt ? (
+3
View File
@@ -0,0 +1,3 @@
// @flow
import CollectionMembers from "./CollectionMembers";
export default CollectionMembers;
+32 -45
View File
@@ -3,9 +3,8 @@ import { intersection } from "lodash";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction, Trans } from "react-i18next";
import { withTranslation, type TFunction } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import AuthStore from "stores/AuthStore";
import CollectionsStore from "stores/CollectionsStore";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
@@ -14,12 +13,11 @@ import Flex from "components/Flex";
import HelpText from "components/HelpText";
import IconPicker, { icons } from "components/IconPicker";
import Input from "components/Input";
import InputSelectPermission from "components/InputSelectPermission";
import InputRich from "components/InputRich";
import Switch from "components/Switch";
type Props = {
history: RouterHistory,
auth: AuthStore,
ui: UiStore,
collections: CollectionsStore,
onSubmit: () => void,
@@ -29,10 +27,10 @@ type Props = {
@observer
class CollectionNew extends React.Component<Props> {
@observable name: string = "";
@observable description: string = "";
@observable icon: string = "";
@observable color: string = "#4E5C6E";
@observable sharing: boolean = true;
@observable permission: string = "read_write";
@observable private: boolean = false;
@observable isSaving: boolean;
hasOpenedIconPicker: boolean = false;
@@ -42,10 +40,10 @@ class CollectionNew extends React.Component<Props> {
const collection = new Collection(
{
name: this.name,
sharing: this.sharing,
description: this.description,
icon: this.icon,
color: this.color,
permission: this.permission,
private: this.private,
},
this.props.collections
);
@@ -61,7 +59,7 @@ class CollectionNew extends React.Component<Props> {
}
};
handleNameChange = (ev: SyntheticInputEvent<>) => {
handleNameChange = (ev: SyntheticInputEvent<*>) => {
this.name = ev.target.value;
// If the user hasn't picked an icon yet, go ahead and suggest one based on
@@ -88,12 +86,12 @@ class CollectionNew extends React.Component<Props> {
this.hasOpenedIconPicker = true;
};
handlePermissionChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
this.permission = ev.target.value;
handleDescriptionChange = (getValue: () => string) => {
this.description = getValue();
};
handleSharingChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
this.sharing = ev.target.checked;
handlePrivateChange = (ev: SyntheticInputEvent<*>) => {
this.private = ev.target.checked;
};
handleChange = (color: string, icon: string) => {
@@ -102,17 +100,14 @@ class CollectionNew extends React.Component<Props> {
};
render() {
const { t, auth } = this.props;
const teamSharingEnabled = !!auth.team && auth.team.sharing;
const { t } = this.props;
return (
<form onSubmit={this.handleSubmit}>
<HelpText>
<Trans>
Collections are for grouping your documents. They work best when
organized around a topic or internal team Product or Engineering
for example.
</Trans>
{t(
"Collections are for grouping your knowledge base. They work best when organized around a topic or internal team — Product or Engineering for example."
)}
</HelpText>
<Flex>
<Input
@@ -132,33 +127,25 @@ class CollectionNew extends React.Component<Props> {
icon={this.icon}
/>
</Flex>
<InputSelectPermission
value={this.permission}
onChange={this.handlePermissionChange}
short
<InputRich
label={t("Description")}
onChange={this.handleDescriptionChange}
defaultValue={this.description || ""}
placeholder={t("More details about this collection…")}
minHeight={68}
maxHeight={200}
/>
<Switch
id="private"
label={t("Private collection")}
onChange={this.handlePrivateChange}
checked={this.private}
/>
<HelpText>
<Trans>
This is the default level of access given to team members, you can
give specific users or groups more access once the collection is
created.
</Trans>
{t(
"A private collection will only be visible to invited team members."
)}
</HelpText>
{teamSharingEnabled && (
<>
<Switch
id="sharing"
label={t("Public document sharing")}
onChange={this.handleSharingChange}
checked={this.sharing}
/>
<HelpText>
<Trans>
When enabled, documents can be shared publicly on the internet.
</Trans>
</HelpText>
</>
)}
<Button type="submit" disabled={this.isSaving || !this.name}>
{this.isSaving ? `${t("Creating")}` : t("Create")}
@@ -169,5 +156,5 @@ class CollectionNew extends React.Component<Props> {
}
export default withTranslation()<CollectionNew>(
inject("collections", "ui", "auth")(withRouter(CollectionNew))
inject("collections", "ui")(withRouter(CollectionNew))
);
-284
View File
@@ -1,284 +0,0 @@
// @flow
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import Collection from "models/Collection";
import Button from "components/Button";
import Divider from "components/Divider";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import InputSelectPermission from "components/InputSelectPermission";
import Labeled from "components/Labeled";
import Modal from "components/Modal";
import PaginatedList from "components/PaginatedList";
import AddGroupsToCollection from "./AddGroupsToCollection";
import AddPeopleToCollection from "./AddPeopleToCollection";
import CollectionGroupMemberListItem from "./components/CollectionGroupMemberListItem";
import MemberListItem from "./components/MemberListItem";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
type Props = {|
collection: Collection,
|};
function CollectionPermissions({ collection }: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
const {
ui,
memberships,
collectionGroupMemberships,
users,
groups,
} = useStores();
const [addGroupModalOpen, setAddGroupModalOpen] = React.useState(false);
const [addMemberModalOpen, setAddMemberModalOpen] = React.useState(false);
const handleRemoveUser = React.useCallback(
async (user) => {
try {
await memberships.delete({
collectionId: collection.id,
userId: user.id,
});
ui.showToast(
t(`{{ userName }} was removed from the collection`, {
userName: user.name,
}),
{
type: "success",
}
);
} catch (err) {
ui.showToast(t("Could not remove user"), { type: "error" });
}
},
[memberships, ui, collection, t]
);
const handleUpdateUser = React.useCallback(
async (user, permission) => {
try {
await memberships.create({
collectionId: collection.id,
userId: user.id,
permission,
});
ui.showToast(
t(`{{ userName }} permissions were updated`, { userName: user.name }),
{
type: "success",
}
);
} catch (err) {
ui.showToast(t("Could not update user"), { type: "error" });
}
},
[memberships, ui, collection, t]
);
const handleRemoveGroup = React.useCallback(
async (group) => {
try {
await collectionGroupMemberships.delete({
collectionId: collection.id,
groupId: group.id,
});
ui.showToast(
t(`The {{ groupName }} group was removed from the collection`, {
groupName: group.name,
}),
{
type: "success",
}
);
} catch (err) {
ui.showToast(t("Could not remove group"), { type: "error" });
}
},
[collectionGroupMemberships, ui, collection, t]
);
const handleUpdateGroup = React.useCallback(
async (group, permission) => {
try {
await collectionGroupMemberships.create({
collectionId: collection.id,
groupId: group.id,
permission,
});
ui.showToast(
t(`{{ groupName }} permissions were updated`, {
groupName: group.name,
}),
{
type: "success",
}
);
} catch (err) {
ui.showToast(t("Could not update user"), { type: "error" });
}
},
[collectionGroupMemberships, ui, collection, t]
);
const handleChangePermission = React.useCallback(
async (ev) => {
try {
await collection.save({ permission: ev.target.value });
ui.showToast(t("Default access permissions were updated"), {
type: "success",
});
} catch (err) {
ui.showToast(t("Could not update permissions"), { type: "error" });
}
},
[collection, ui, t]
);
const fetchOptions = React.useMemo(() => ({ id: collection.id }), [
collection.id,
]);
const collectionName = collection.name;
const collectionGroups = groups.inCollection(collection.id);
const collectionUsers = users.inCollection(collection.id);
const isEmpty = !collectionGroups.length && !collectionUsers.length;
return (
<Flex column>
<InputSelectPermission
onChange={handleChangePermission}
value={collection.permission || ""}
short
/>
<PermissionExplainer>
{!collection.permission && (
<Trans
defaults="The <em>{{ collectionName }}</em> collection is private. Team members have no access to it by default."
values={{ collectionName }}
components={{ em: <strong /> }}
/>
)}
{collection.permission === "read" && (
<Trans
defaults="Team members can view documents in the <em>{{ collectionName }}</em> collection by default."
values={{ collectionName }}
components={{ em: <strong /> }}
/>
)}
{collection.permission === "read_write" && (
<Trans
defaults="Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by
default."
values={{ collectionName }}
components={{ em: <strong /> }}
/>
)}
</PermissionExplainer>
<Labeled label={t("Additional access")}>
<Actions>
<Button
type="button"
onClick={() => setAddGroupModalOpen(true)}
icon={<PlusIcon />}
neutral
>
{t("Add groups")}
</Button>{" "}
<Button
type="button"
onClick={() => setAddMemberModalOpen(true)}
icon={<PlusIcon />}
neutral
>
{t("Add people")}
</Button>
</Actions>
</Labeled>
<Divider />
{isEmpty && (
<Empty>
<Trans>
Add specific access for individual groups and team members
</Trans>
</Empty>
)}
<PaginatedList
items={collectionGroups}
fetch={collectionGroupMemberships.fetchPage}
options={fetchOptions}
renderItem={(group) => (
<CollectionGroupMemberListItem
key={group.id}
group={group}
collectionGroupMembership={collectionGroupMemberships.get(
`${group.id}-${collection.id}`
)}
onRemove={() => handleRemoveGroup(group)}
onUpdate={(permission) => handleUpdateGroup(group, permission)}
/>
)}
/>
{collectionGroups.length ? <Divider /> : null}
<PaginatedList
key={`collection-users-${collection.permission || "none"}`}
items={collectionUsers}
fetch={memberships.fetchPage}
options={fetchOptions}
renderItem={(item) => (
<MemberListItem
key={item.id}
user={item}
membership={memberships.get(`${item.id}-${collection.id}`)}
canEdit={item.id !== user.id}
onRemove={() => handleRemoveUser(item)}
onUpdate={(permission) => handleUpdateUser(item, permission)}
/>
)}
/>
<Modal
title={t(`Add groups to {{ collectionName }}`, {
collectionName: collection.name,
})}
onRequestClose={() => setAddGroupModalOpen(false)}
isOpen={addGroupModalOpen}
>
<AddGroupsToCollection
collection={collection}
onSubmit={() => setAddGroupModalOpen(false)}
/>
</Modal>
<Modal
title={t(`Add people to {{ collectionName }}`, {
collectionName: collection.name,
})}
onRequestClose={() => setAddMemberModalOpen(false)}
isOpen={addMemberModalOpen}
>
<AddPeopleToCollection
collection={collection}
onSubmit={() => setAddMemberModalOpen(false)}
/>
</Modal>
</Flex>
);
}
const Empty = styled(HelpText)`
margin-top: 8px;
`;
const PermissionExplainer = styled(HelpText)`
margin-top: -8px;
margin-bottom: 24px;
`;
const Actions = styled.div`
margin-bottom: 12px;
`;
export default observer(CollectionPermissions);
+22 -26
View File
@@ -1,21 +1,21 @@
// @flow
import { observer } from "mobx-react";
import { HomeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Switch, Route } from "react-router-dom";
import { Action } from "components/Actions";
import Heading from "components/Heading";
import Actions, { Action } from "components/Actions";
import CenteredContent from "components/CenteredContent";
import InputSearch from "components/InputSearch";
import LanguagePrompt from "components/LanguagePrompt";
import Scene from "components/Scene";
import PageTitle from "components/PageTitle";
import Tab from "components/Tab";
import Tabs from "components/Tabs";
import PaginatedDocumentList from "../components/PaginatedDocumentList";
import useStores from "../hooks/useStores";
import NewDocumentMenu from "menus/NewDocumentMenu";
function Home() {
function Dashboard() {
const { documents, ui, auth } = useStores();
const { t } = useTranslation();
@@ -23,26 +23,10 @@ function Home() {
const user = auth.user.id;
return (
<Scene
icon={<HomeIcon color="currentColor" />}
title={t("Home")}
actions={
<>
<Action>
<InputSearch
source="dashboard"
label={t("Search documents")}
labelHidden
/>
</Action>
<Action>
<NewDocumentMenu />
</Action>
</>
}
>
<CenteredContent>
<PageTitle title={t("Home")} />
{!ui.languagePromptDismissed && <LanguagePrompt />}
<Heading>{t("Home")}</Heading>
<h1>{t("Home")}</h1>
<Tabs>
<Tab to="/home" exact>
{t("Recently updated")}
@@ -78,8 +62,20 @@ function Home() {
/>
</Route>
</Switch>
</Scene>
<Actions align="center" justify="flex-end">
<Action>
<InputSearch
source="dashboard"
label={t("Search documents")}
labelHidden
/>
</Action>
<Action>
<NewDocumentMenu />
</Action>
</Actions>
</CenteredContent>
);
}
export default observer(Home);
export default observer(Dashboard);
@@ -4,6 +4,7 @@ import Flex from "components/Flex";
const Container = styled(Flex)`
position: relative;
margin-top: ${(props) => (props.isShare ? "50px" : "0")};
`;
export default Container;

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