mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27ab73b0d3 | |||
| c3cd72451d | |||
| 3f8b5b4be9 | |||
| 2251439dec | |||
| 4c22d167bd | |||
| 6732cfca76 | |||
| 431617d4bd | |||
| 01b1ff65ff | |||
| f57b066b25 | |||
| f32a61f193 | |||
| 6cc9b1a109 | |||
| 24a4f12095 | |||
| 590f1481e2 | |||
| af0be5bea6 | |||
| fa9edf5025 | |||
| a74c16fb31 | |||
| fd03582951 | |||
| 80d74b44ad | |||
| ccd947c6e8 | |||
| 4e05728218 | |||
| 40e09dd829 | |||
| 99381d10ff | |||
| 36c73051b4 | |||
| 81718c8ee1 | |||
| be905a6993 | |||
| b39d4aade7 | |||
| c5fb5f875f | |||
| 552755dace | |||
| e61c71766f | |||
| df5dc2f691 | |||
| 28097835d0 | |||
| 3de51c1a67 | |||
| 223a47af95 | |||
| 7c8675ce17 | |||
| 157c3ce80f | |||
| 0ed7286fc6 | |||
| 78464f315c | |||
| 79790de9b0 | |||
| 252459f1cf | |||
| 20a72481dc | |||
| 765c7cdc27 | |||
| 6f136e342f | |||
| 9545113d9e | |||
| c00001086a | |||
| 95dbc8168c | |||
| 0021553518 | |||
| bcca4b91ee | |||
| c1bd30aac8 | |||
| fd7dd83a4b | |||
| 26f02cdd05 | |||
| fec2baf361 | |||
| e1601fbe72 | |||
| a88b54d26d | |||
| 88cc964d69 | |||
| b8efe772fe | |||
| b2f00d71d3 | |||
| c2edfca6e5 | |||
| 9c3c0fe418 | |||
| 313067ff7b | |||
| be64c2b206 | |||
| d576ce1734 | |||
| 0f624958bc | |||
| 162da9a3ad | |||
| d7e9ad4f13 | |||
| bcf773a1d6 | |||
| 97082e8cba | |||
| bc3f2e4876 | |||
| 49a9b91708 | |||
| 01cea549a5 | |||
| a9df3f64cf | |||
| e6cc8f5550 | |||
| f6c2a95a55 | |||
| 27736f66ef | |||
| cde2909296 | |||
| 1f6e1a71f9 | |||
| 15ef8f7dff | |||
| 83a61b87ed | |||
| 6c605cf720 | |||
| fb335887cb | |||
| 88e7d4c539 | |||
| 400e32da70 | |||
| a699dea286 | |||
| 2aca760ee0 | |||
| f1c9c6fdf9 | |||
| 801f6681ba |
+11
-3
@@ -29,6 +29,10 @@ REDIS_URL=redis://localhost:6479
|
||||
URL=http://localhost:3000
|
||||
PORT=3000
|
||||
|
||||
# ALPHA – See [documentation](docs/SERVICES.md) on running the alpha version of
|
||||
# the collaboration server.
|
||||
COLLABORATION_URL=
|
||||
|
||||
# To support uploading of images for avatars and document attachments an
|
||||
# s3-compatible storage must be provided. AWS S3 is recommended for redundency
|
||||
# however if you want to keep all file storage local an alternative such as
|
||||
@@ -85,6 +89,10 @@ OIDC_AUTH_URI=
|
||||
OIDC_TOKEN_URI=
|
||||
OIDC_USERINFO_URI=
|
||||
|
||||
# Specify which claims to derive user information from
|
||||
# Supports any valid JSON path with the JWT payload
|
||||
OIDC_USERNAME_CLAIM=preferred_username
|
||||
|
||||
# Display name for OIDC authentication
|
||||
OIDC_DISPLAY_NAME=OpenID Connect
|
||||
|
||||
@@ -116,9 +124,9 @@ WEB_CONCURRENCY=1
|
||||
# especially large Word documents with embedded imagery
|
||||
MAXIMUM_IMPORT_SIZE=5120000
|
||||
|
||||
# You may enable or disable debugging categories to increase the noisiness of
|
||||
# logs. The default is a good balance
|
||||
DEBUG=cache,presenters,events,emails,mailer,utils,http,server,processors
|
||||
# You can remove this line if your reverse proxy already logs incoming http
|
||||
# requests and this ends up being duplicative
|
||||
DEBUG=http
|
||||
|
||||
# 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
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
.*/node_modules/react-side-effect/.*
|
||||
.*/node_modules/fbjs/.*
|
||||
.*/node_modules/config-chain/.*
|
||||
.*/node_modules/yjs/.*
|
||||
.*/node_modules/y-prosemirror/.*
|
||||
.*/node_modules/y-protocols/.*
|
||||
.*/node_modules/y-indexeddb/.*
|
||||
.*/node_modules/lib0/.*
|
||||
.*/server/scripts/.*
|
||||
*.test.js
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.55.0
|
||||
Licensed Work: Outline 0.59.0
|
||||
The Licensed Work is (c) 2020 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2024-04-22
|
||||
Change Date: 2025-09-07
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ up:
|
||||
docker-compose up -d redis postgres s3
|
||||
yarn install --pure-lockfile
|
||||
yarn sequelize db:migrate
|
||||
yarn dev
|
||||
yarn dev:watch
|
||||
|
||||
build:
|
||||
docker-compose build --pull outline
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
web: node ./build/server/index.js --services=web,websockets
|
||||
worker: node ./build/server/index.js --services=worker
|
||||
web: yarn start --services=web,websockets
|
||||
worker: yarn start --services=worker
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img src="https://user-images.githubusercontent.com/31465/34380645-bd67f474-eb0b-11e7-8d03-0151c1730654.png" height="29" />
|
||||
</p>
|
||||
@@ -30,7 +28,6 @@ Outline requires the following dependencies:
|
||||
- AWS S3 bucket or compatible API for file storage
|
||||
- Slack or Google developer application for authentication
|
||||
|
||||
|
||||
## Self-Hosted Production
|
||||
|
||||
### Docker
|
||||
@@ -41,16 +38,20 @@ For a manual self-hosted production installation these are the recommended steps
|
||||
1. Download the latest official Docker image, new releases are available around the middle of every month:
|
||||
|
||||
`docker pull outlinewiki/outline`
|
||||
|
||||
1. Using the [.env.sample](.env.sample) as a reference, set the required variables in your production environment. You can export the environment variables directly, or create a `.env` file and pass it to the docker image like so:
|
||||
|
||||
`docker run --env-file=.env outlinewiki/outline`
|
||||
|
||||
1. Setup the database with `yarn db:migrate`. Production assumes an SSL connection to the database by default, if
|
||||
Postgres is on the same machine and is not SSL you can migrate with `yarn db:migrate --env=production-ssl-disabled`, for example:
|
||||
Postgres is on the same machine and is not SSL you can migrate with `yarn db:migrate --env=production-ssl-disabled`, for example:
|
||||
|
||||
`docker run --rm outlinewiki/outline yarn db:migrate`
|
||||
|
||||
1. Start the container:
|
||||
|
||||
`docker run outlinewiki/outline`
|
||||
|
||||
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
|
||||
|
||||
> Port number can be changed using the `PORT` environment variable
|
||||
@@ -79,29 +80,27 @@ If you're running Outline by cloning this repository, run the following command
|
||||
yarn run upgrade
|
||||
```
|
||||
|
||||
|
||||
## Local Development
|
||||
|
||||
For contributing features and fixes you can quickly get an environment running using Docker by following these steps:
|
||||
|
||||
1. Install these dependencies if you don't already have them
|
||||
1. [Docker for Desktop](https://www.docker.com)
|
||||
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
|
||||
1. [Yarn](https://yarnpkg.com)
|
||||
1. [Docker for Desktop](https://www.docker.com)
|
||||
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
|
||||
1. [Yarn](https://yarnpkg.com)
|
||||
1. Clone this repo
|
||||
1. Register a Slack app at https://api.slack.com/apps
|
||||
1. Copy the file `.env.sample` to `.env`
|
||||
1. Fill out the following fields:
|
||||
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
|
||||
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
|
||||
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
|
||||
1. Configure your Slack app's Oauth & Permissions settings
|
||||
1. Slack recently prevented the use of `http` protocol for localhost. For local development, you can use a tool like [ngrok](https://ngrok.com) or a package like `mkcert`. ([How to use HTTPS for local development](https://web.dev/how-to-use-local-https/))
|
||||
1. Add `https://my_ngrok_address/auth/slack.callback` as an Oauth redirect URL
|
||||
1. Ensure that the bot token scope contains at least `users:read`
|
||||
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
|
||||
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
|
||||
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
|
||||
1. Configure your Slack app's Oauth & Permissions settings
|
||||
1. Slack recently prevented the use of `http` protocol for localhost. For local development, you can use a tool like [ngrok](https://ngrok.com) or a package like `mkcert`. ([How to use HTTPS for local development](https://web.dev/how-to-use-local-https/))
|
||||
1. Add `https://my_ngrok_address/auth/slack.callback` as an Oauth redirect URL
|
||||
1. Ensure that the bot token scope contains at least `users:read`
|
||||
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
|
||||
|
||||
|
||||
# Contributing
|
||||
|
||||
Outline is built and maintained by a small team – we'd love your help to fix bugs and add features!
|
||||
@@ -110,26 +109,22 @@ Before submitting a pull request please let the core team know by creating or co
|
||||
|
||||
If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
|
||||
|
||||
* [Translation](TRANSLATION.md) into other languages
|
||||
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
|
||||
* Performance improvements, both on server and frontend
|
||||
* Developer happiness and documentation
|
||||
* Bugs and other issues listed on GitHub
|
||||
|
||||
- [Translation](docs/TRANSLATION.md) into other languages
|
||||
- Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
|
||||
- Performance improvements, both on server and frontend
|
||||
- Developer happiness and documentation
|
||||
- Bugs and other issues listed on GitHub
|
||||
|
||||
## 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.
|
||||
|
||||
please refer to the [architecture document](docs/ARCHITECTURE.md) first for a high level overview of how the application is put together.
|
||||
|
||||
## Debugging
|
||||
|
||||
Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging output, the following categories are available:
|
||||
In development Outline outputs simple logging to the console, prefixed by categories. In production it outputs JSON logs, these can be easily parsed by your preferred log ingestion pipeline.
|
||||
|
||||
```
|
||||
DEBUG=sql,cache,presenters,events,importer,exporter,emails,mailer
|
||||
```
|
||||
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -145,7 +140,7 @@ make test
|
||||
make watch
|
||||
```
|
||||
|
||||
Once the test database is created with `make test` you may individually run
|
||||
Once the test database is created with `make test` you may individually run
|
||||
frontend and backend tests directly.
|
||||
|
||||
```shell
|
||||
|
||||
@@ -35,7 +35,7 @@ const RealButton = styled.button`
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:hover:not(:disabled) {
|
||||
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@ const RealButton = styled.button`
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
color: ${(props) => props.theme.white50};
|
||||
|
||||
svg {
|
||||
fill: ${(props) => props.theme.white50};
|
||||
}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
@@ -65,7 +69,7 @@ const RealButton = styled.button`
|
||||
}
|
||||
|
||||
|
||||
&:hover {
|
||||
&:hover:not(:disabled) {
|
||||
background: ${
|
||||
props.borderOnHover
|
||||
? props.theme.buttonNeutralBackground
|
||||
@@ -78,6 +82,10 @@ const RealButton = styled.button`
|
||||
|
||||
&:disabled {
|
||||
color: ${props.theme.textTertiary};
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.textTertiary};
|
||||
}
|
||||
}
|
||||
`} ${(props) =>
|
||||
props.danger &&
|
||||
|
||||
@@ -12,17 +12,19 @@ import DocumentViews from "components/DocumentViews";
|
||||
import Facepile from "components/Facepile";
|
||||
import NudeButton from "components/NudeButton";
|
||||
import Popover from "components/Popover";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
currentUserId: string,
|
||||
|};
|
||||
|
||||
function Collaborators(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const currentUserId = user?.id;
|
||||
const { users, presence } = useStores();
|
||||
const { document, currentUserId } = props;
|
||||
const { document } = props;
|
||||
|
||||
let documentPresence = presence.get(document.id);
|
||||
documentPresence = documentPresence
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { DisconnectedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Fade from "components/Fade";
|
||||
import NudeButton from "components/NudeButton";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
function ConnectionStatus() {
|
||||
const { ui } = useStores();
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return ui.multiplayerStatus === "connecting" ||
|
||||
ui.multiplayerStatus === "disconnected" ? (
|
||||
<Tooltip
|
||||
tooltip={
|
||||
<Centered>
|
||||
<strong>{t("Server connection lost")}</strong>
|
||||
<br />
|
||||
{t("Edits you make will sync once you’re online")}
|
||||
</Centered>
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button>
|
||||
<Fade>
|
||||
<DisconnectedIcon color={theme.sidebarText} />
|
||||
</Fade>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null;
|
||||
}
|
||||
|
||||
const Button = styled(NudeButton)`
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 32px;
|
||||
margin: 24px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: block;
|
||||
`};
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Centered = styled.div`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
export default observer(ConnectionStatus);
|
||||
@@ -2,7 +2,7 @@
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import MenuIconWrapper from "../MenuIconWrapper";
|
||||
|
||||
@@ -88,12 +88,12 @@ const Spacer = styled.svg`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export const MenuAnchor = styled.a`
|
||||
export const MenuAnchorCSS = css`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 12px;
|
||||
padding-left: ${(props) => 12 + props.level * 10}px;
|
||||
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
background: none;
|
||||
@@ -138,5 +138,8 @@ export const MenuAnchor = styled.a`
|
||||
font-size: 14px;
|
||||
`};
|
||||
`;
|
||||
export const MenuAnchor = styled.a`
|
||||
${MenuAnchorCSS}
|
||||
`;
|
||||
|
||||
export default MenuItem;
|
||||
|
||||
@@ -71,12 +71,13 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
function Template({ items, ...menu }: Props): React.Node {
|
||||
const filteredTemplates = filterTemplateItems(items);
|
||||
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
|
||||
(item) => !!item.icon
|
||||
(item) => !item.type && !!item.icon
|
||||
);
|
||||
|
||||
return filteredTemplates.map((item, index) => {
|
||||
if (iconIsPresentInAnyMenuItem)
|
||||
item.icon = item.icon ? item.icon : <MenuIconWrapper />;
|
||||
if (iconIsPresentInAnyMenuItem && !item.type) {
|
||||
item.icon = item.icon || <MenuIconWrapper />;
|
||||
}
|
||||
|
||||
if (item.to) {
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Portal } from "react-portal";
|
||||
import { Menu } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import useMenuHeight from "hooks/useMenuHeight";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
import {
|
||||
fadeIn,
|
||||
@@ -18,8 +19,12 @@ type Props = {|
|
||||
placement?: string,
|
||||
animating?: boolean,
|
||||
children: React.Node,
|
||||
unstable_disclosureRef?: {
|
||||
current: null | React.ElementRef<"button">,
|
||||
},
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
hide?: () => void,
|
||||
|};
|
||||
|
||||
export default function ContextMenu({
|
||||
@@ -29,6 +34,8 @@ export default function ContextMenu({
|
||||
...rest
|
||||
}: Props) {
|
||||
const previousVisible = usePrevious(rest.visible);
|
||||
const maxHeight = useMenuHeight(rest.visible, rest.unstable_disclosureRef);
|
||||
const backgroundRef = React.useRef();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (rest.visible && !previousVisible) {
|
||||
@@ -43,6 +50,8 @@ export default function ContextMenu({
|
||||
}
|
||||
}, [onOpen, onClose, previousVisible, rest.visible]);
|
||||
|
||||
// sets the menu height based on the available space between the disclosure/
|
||||
// trigger and the bottom of the window
|
||||
return (
|
||||
<>
|
||||
<Menu hideOnClickOutside preventBodyScroll {...rest}>
|
||||
@@ -59,6 +68,8 @@ export default function ContextMenu({
|
||||
dir="auto"
|
||||
topAnchor={topAnchor}
|
||||
rightAnchor={rightAnchor}
|
||||
ref={backgroundRef}
|
||||
style={maxHeight && topAnchor ? { maxHeight } : undefined}
|
||||
>
|
||||
{rest.visible || rest.animating ? children : null}
|
||||
</Background>
|
||||
@@ -68,14 +79,14 @@ export default function ContextMenu({
|
||||
</Menu>
|
||||
{(rest.visible || rest.animating) && (
|
||||
<Portal>
|
||||
<Backdrop />
|
||||
<Backdrop onClick={rest.hide} />
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Backdrop = styled.div`
|
||||
export const Backdrop = styled.div`
|
||||
animation: ${fadeIn} 200ms ease-in-out;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -90,7 +101,7 @@ const Backdrop = styled.div`
|
||||
`};
|
||||
`;
|
||||
|
||||
const Position = styled.div`
|
||||
export const Position = styled.div`
|
||||
position: absolute;
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
|
||||
@@ -104,7 +115,7 @@ const Position = styled.div`
|
||||
`};
|
||||
`;
|
||||
|
||||
const Background = styled.div`
|
||||
export const Background = styled.div`
|
||||
animation: ${mobileContextMenu} 200ms ease;
|
||||
transform-origin: 50% 100%;
|
||||
max-width: 100%;
|
||||
@@ -125,8 +136,7 @@ const Background = styled.div`
|
||||
${breakpoint("tablet")`
|
||||
animation: ${(props) =>
|
||||
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
|
||||
transform-origin: ${(props) =>
|
||||
props.rightAnchor === "bottom-end" ? "75%" : "25%"} 0;
|
||||
transform-origin: ${(props) => (props.rightAnchor ? "75%" : "25%")} 0;
|
||||
max-width: 276px;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
|
||||
@@ -3,12 +3,13 @@ import { lighten } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import { Extension } from "rich-markdown-editor";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import embeds from "shared/embeds";
|
||||
import { light } from "shared/theme";
|
||||
import UiStore from "stores/UiStore";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import embeds from "../embeds";
|
||||
import useMediaQuery from "hooks/useMediaQuery";
|
||||
import useToasts from "hooks/useToasts";
|
||||
import { type Theme } from "types";
|
||||
@@ -30,6 +31,8 @@ export type Props = {|
|
||||
grow?: boolean,
|
||||
disableEmbeds?: boolean,
|
||||
ui?: UiStore,
|
||||
style?: Object,
|
||||
extensions?: Extension[],
|
||||
shareId?: ?string,
|
||||
autoFocus?: boolean,
|
||||
template?: boolean,
|
||||
@@ -246,6 +249,50 @@ const StyledEditor = styled(RichMarkdownEditor)`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
.ProseMirror-yjs-cursor {
|
||||
position: relative;
|
||||
margin-left: -1px;
|
||||
margin-right: -1px;
|
||||
border-left: 1px solid black;
|
||||
border-right: 1px solid black;
|
||||
height: 1em;
|
||||
word-break: normal;
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
right: -8px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
> div {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: -1.8em;
|
||||
font-size: 13px;
|
||||
background-color: rgb(250, 129, 0);
|
||||
font-style: normal;
|
||||
line-height: normal;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
left: -1px;
|
||||
}
|
||||
&:hover {
|
||||
> div {
|
||||
opacity: 1;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const EditorTooltip = ({ children, ...props }) => (
|
||||
|
||||
@@ -76,11 +76,6 @@ const FilterOptions = ({
|
||||
);
|
||||
};
|
||||
|
||||
const LabelWithNote = styled.div`
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
`;
|
||||
|
||||
const Note = styled(HelpText)`
|
||||
margin-top: 2px;
|
||||
margin-bottom: 0;
|
||||
@@ -90,6 +85,15 @@ const Note = styled(HelpText)`
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
`;
|
||||
|
||||
const LabelWithNote = styled.div`
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
|
||||
&:hover ${Note} {
|
||||
color: ${(props) => props.theme.white50};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
box-shadow: none;
|
||||
text-transform: none;
|
||||
|
||||
@@ -39,7 +39,8 @@ const Guide = ({
|
||||
<Dialog
|
||||
{...dialog}
|
||||
aria-label={title}
|
||||
preventBodyScrollhideOnEsc
|
||||
preventBodyScroll
|
||||
hideOnEsc
|
||||
hide={onRequestClose}
|
||||
>
|
||||
{(props) => (
|
||||
|
||||
@@ -36,7 +36,7 @@ function Header({ breadcrumb, title, actions }: Props) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Wrapper align="center" isCompact={isScrolled} shrink={false}>
|
||||
<Wrapper align="center" shrink={false}>
|
||||
{breadcrumb ? <Breadcrumbs>{breadcrumb}</Breadcrumbs> : null}
|
||||
{isScrolled ? (
|
||||
<Title align="center" justify="flex-start" onClick={handleClickTitle}>
|
||||
@@ -95,7 +95,7 @@ const Wrapper = styled(Flex)`
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
|
||||
padding: 16px 16px 0;
|
||||
justify-content: "center";
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -6,11 +6,14 @@ import {
|
||||
AcademicCapIcon,
|
||||
BeakerIcon,
|
||||
BuildingBlocksIcon,
|
||||
CameraIcon,
|
||||
CloudIcon,
|
||||
CodeIcon,
|
||||
EditIcon,
|
||||
EmailIcon,
|
||||
EyeIcon,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
ImageIcon,
|
||||
LeafIcon,
|
||||
LightBulbIcon,
|
||||
@@ -19,8 +22,12 @@ import {
|
||||
NotepadIcon,
|
||||
PadlockIcon,
|
||||
PaletteIcon,
|
||||
PromoteIcon,
|
||||
QuestionMarkIcon,
|
||||
SportIcon,
|
||||
SunIcon,
|
||||
TargetIcon,
|
||||
ToolsIcon,
|
||||
VehicleIcon,
|
||||
WarningIcon,
|
||||
} from "outline-icons";
|
||||
@@ -28,6 +35,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
@@ -56,6 +64,10 @@ export const icons = {
|
||||
component: CoinsIcon,
|
||||
keywords: "coins money finance sales income revenue cash",
|
||||
},
|
||||
camera: {
|
||||
component: CameraIcon,
|
||||
keywords: "photo picture",
|
||||
},
|
||||
academicCap: {
|
||||
component: AcademicCapIcon,
|
||||
keywords: "learn teach lesson guide tutorial onboarding training",
|
||||
@@ -84,6 +96,14 @@ export const icons = {
|
||||
component: EyeIcon,
|
||||
keywords: "eye view",
|
||||
},
|
||||
globe: {
|
||||
component: GlobeIcon,
|
||||
keywords: "world translate",
|
||||
},
|
||||
info: {
|
||||
component: InfoIcon,
|
||||
keywords: "info information",
|
||||
},
|
||||
image: {
|
||||
component: ImageIcon,
|
||||
keywords: "image photo picture",
|
||||
@@ -120,6 +140,10 @@ export const icons = {
|
||||
component: EditIcon,
|
||||
keywords: "copy writing post blog",
|
||||
},
|
||||
promote: {
|
||||
component: PromoteIcon,
|
||||
keywords: "marketing promotion",
|
||||
},
|
||||
question: {
|
||||
component: QuestionMarkIcon,
|
||||
keywords: "question help support faq",
|
||||
@@ -128,13 +152,25 @@ export const icons = {
|
||||
component: SunIcon,
|
||||
keywords: "day sun weather",
|
||||
},
|
||||
sport: {
|
||||
component: SportIcon,
|
||||
keywords: "sport outdoor racket game",
|
||||
},
|
||||
target: {
|
||||
component: TargetIcon,
|
||||
keywords: "target goal sales",
|
||||
},
|
||||
tools: {
|
||||
component: ToolsIcon,
|
||||
keywords: "tool settings",
|
||||
},
|
||||
vehicle: {
|
||||
component: VehicleIcon,
|
||||
keywords: "truck car travel transport",
|
||||
},
|
||||
warning: {
|
||||
component: WarningIcon,
|
||||
keywords: "danger",
|
||||
keywords: "warning alert error",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -153,18 +189,18 @@ const colors = [
|
||||
|
||||
type Props = {|
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
onChange: (color: string, icon: string) => void,
|
||||
icon: string,
|
||||
color: string,
|
||||
|};
|
||||
|
||||
function IconPicker({ onOpen, icon, color, onChange }: Props) {
|
||||
function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
placement: "bottom-end",
|
||||
});
|
||||
const Component = icons[icon || "collection"].component;
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
@@ -174,14 +210,22 @@ function IconPicker({ onOpen, icon, color, onChange }: Props) {
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<Button aria-label={t("Show menu")} {...props}>
|
||||
<Component color={color} size={30} />
|
||||
<Icon
|
||||
as={icons[icon || "collection"].component}
|
||||
color={color}
|
||||
size={30}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} onOpen={onOpen} aria-label={t("Choose icon")}>
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
aria-label={t("Choose icon")}
|
||||
>
|
||||
<Icons>
|
||||
{Object.keys(icons).map((name) => {
|
||||
const Component = icons[name].component;
|
||||
return (
|
||||
<MenuItem
|
||||
key={name}
|
||||
@@ -190,7 +234,7 @@ function IconPicker({ onOpen, icon, color, onChange }: Props) {
|
||||
>
|
||||
{(props) => (
|
||||
<IconButton style={style} {...props}>
|
||||
<Component color={color} size={30} />
|
||||
<Icon as={icons[name].component} color={color} size={30} />
|
||||
</IconButton>
|
||||
)}
|
||||
</MenuItem>
|
||||
@@ -212,13 +256,20 @@ function IconPicker({ onOpen, icon, color, onChange }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = styled.svg`
|
||||
transition: fill 150ms ease-in-out;
|
||||
`;
|
||||
|
||||
const Label = styled.label`
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const Icons = styled.div`
|
||||
padding: 16px 8px 0 16px;
|
||||
width: 276px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: 276px;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Button = styled(NudeButton)`
|
||||
@@ -241,6 +292,11 @@ const Loading = styled(HelpText)`
|
||||
const ColorPicker = styled(TwitterPicker)`
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
width: auto !important;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: 276px;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Wrapper = styled("div")`
|
||||
|
||||
@@ -40,14 +40,20 @@ class InputSearchPage extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
handleSearchInput = (ev: SyntheticInputEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.props.history.push(
|
||||
searchUrl(ev.target.value, {
|
||||
collectionId: this.props.collectionId,
|
||||
ref: this.props.source,
|
||||
})
|
||||
);
|
||||
handleKeyDown = (ev: SyntheticKeyboardEvent<HTMLInputElement>) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
this.props.history.push(
|
||||
searchUrl(ev.currentTarget.value, {
|
||||
collectionId: this.props.collectionId,
|
||||
ref: this.props.source,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.onKeyDown) {
|
||||
this.props.onKeyDown(ev);
|
||||
}
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
@@ -59,7 +65,7 @@ class InputSearchPage extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t, value, onChange, onKeyDown } = this.props;
|
||||
const { t, value, onChange } = this.props;
|
||||
const { theme, placeholder = `${t("Search")}…` } = this.props;
|
||||
|
||||
return (
|
||||
@@ -67,10 +73,9 @@ class InputSearchPage extends React.Component<Props> {
|
||||
ref={(ref) => (this.input = ref)}
|
||||
type="search"
|
||||
placeholder={placeholder}
|
||||
onInput={this.handleSearchInput}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
icon={
|
||||
<SearchIcon
|
||||
color={this.focused ? theme.inputBorderFocused : theme.inputBorder}
|
||||
|
||||
+234
-68
@@ -1,80 +1,120 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
Select,
|
||||
SelectOption,
|
||||
useSelectState,
|
||||
useSelectPopover,
|
||||
SelectPopover,
|
||||
} from "@renderlesskit/react";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { Outline, LabelText } from "./Input";
|
||||
|
||||
const Select = styled.select`
|
||||
border: 0;
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
margin: 0 12px;
|
||||
outline: none;
|
||||
background: none;
|
||||
color: ${(props) => props.theme.text};
|
||||
height: 30px;
|
||||
font-size: 14px;
|
||||
|
||||
option {
|
||||
background: ${(props) => props.theme.buttonNeutralBackground};
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
font-size: 16px;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Wrapper = styled.label`
|
||||
display: block;
|
||||
max-width: ${(props) => (props.short ? "350px" : "100%")};
|
||||
`;
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import styled, { css } from "styled-components";
|
||||
import Button, { Inner } from "components/Button";
|
||||
import { Position, Background, Backdrop } from "./ContextMenu";
|
||||
import { MenuAnchorCSS } from "./ContextMenu/MenuItem";
|
||||
import { LabelText } from "./Input";
|
||||
import useMenuHeight from "hooks/useMenuHeight";
|
||||
|
||||
export type Option = { label: string, value: string };
|
||||
|
||||
export type Props = {
|
||||
value?: string,
|
||||
label?: string,
|
||||
nude?: boolean,
|
||||
ariaLabel: string,
|
||||
short?: boolean,
|
||||
disabled?: boolean,
|
||||
className?: string,
|
||||
labelHidden?: boolean,
|
||||
options: Option[],
|
||||
onBlur?: () => void,
|
||||
onFocus?: () => void,
|
||||
onChange: (string) => Promise<void> | void,
|
||||
};
|
||||
|
||||
@observer
|
||||
class InputSelect extends React.Component<Props> {
|
||||
@observable focused: boolean = false;
|
||||
const getOptionFromValue = (options: Option[], value) => {
|
||||
return options.find((option) => option.value === value) || {};
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
this.focused = false;
|
||||
};
|
||||
const InputSelect = (props: Props) => {
|
||||
const {
|
||||
value,
|
||||
label,
|
||||
className,
|
||||
labelHidden,
|
||||
options,
|
||||
short,
|
||||
ariaLabel,
|
||||
onChange,
|
||||
disabled,
|
||||
nude,
|
||||
} = props;
|
||||
|
||||
handleFocus = () => {
|
||||
this.focused = true;
|
||||
};
|
||||
const select = useSelectState({
|
||||
gutter: 0,
|
||||
modal: true,
|
||||
selectedValue: value,
|
||||
animated: 200,
|
||||
});
|
||||
|
||||
render() {
|
||||
const {
|
||||
label,
|
||||
className,
|
||||
labelHidden,
|
||||
options,
|
||||
short,
|
||||
...rest
|
||||
} = this.props;
|
||||
const popOver = useSelectPopover({
|
||||
...select,
|
||||
hideOnClickOutside: true,
|
||||
preventBodyScroll: true,
|
||||
disabled,
|
||||
});
|
||||
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
const previousValue = React.useRef(value);
|
||||
const contentRef = React.useRef();
|
||||
const selectedRef = React.useRef();
|
||||
const buttonRef = React.useRef();
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const minWidth = buttonRef.current?.offsetWidth || 0;
|
||||
|
||||
return (
|
||||
const maxHeight = useMenuHeight(
|
||||
select.visible,
|
||||
select.unstable_disclosureRef
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (previousValue.current === select.selectedValue) return;
|
||||
|
||||
previousValue.current = select.selectedValue;
|
||||
async function load() {
|
||||
await onChange(select.selectedValue);
|
||||
}
|
||||
load();
|
||||
}, [onChange, select.selectedValue]);
|
||||
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
|
||||
const selectedValueIndex = options.findIndex(
|
||||
(option) => option.value === select.selectedValue
|
||||
);
|
||||
|
||||
// Ensure selected option is visible when opening the input
|
||||
React.useEffect(() => {
|
||||
if (!select.animating && selectedRef.current) {
|
||||
scrollIntoView(selectedRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "instant",
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
}, [select.animating]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (select.visible) {
|
||||
const offset = Math.round(
|
||||
(selectedRef.current?.getBoundingClientRect().top || 0) -
|
||||
(contentRef.current?.getBoundingClientRect().top || 0)
|
||||
);
|
||||
setOffset(offset);
|
||||
}
|
||||
}, [select.visible]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Wrapper short={short}>
|
||||
{label &&
|
||||
(labelHidden ? (
|
||||
@@ -82,18 +122,144 @@ class InputSelect extends React.Component<Props> {
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
<Outline focused={this.focused} className={className}>
|
||||
<Select onBlur={this.handleBlur} onFocus={this.handleFocus} {...rest}>
|
||||
{options.map((option) => (
|
||||
<option value={option.value} key={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Outline>
|
||||
<Select
|
||||
{...select}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
ref={buttonRef}
|
||||
>
|
||||
{(props) => (
|
||||
<StyledButton
|
||||
neutral
|
||||
disclosure
|
||||
className={className}
|
||||
nude={nude}
|
||||
{...props}
|
||||
>
|
||||
{getOptionFromValue(options, select.selectedValue).label ||
|
||||
`Select a ${ariaLabel}`}
|
||||
</StyledButton>
|
||||
)}
|
||||
</Select>
|
||||
<SelectPopover {...select} {...popOver}>
|
||||
{(props) => {
|
||||
const topAnchor = props.style.top === "0";
|
||||
const rightAnchor = props.placement === "bottom-end";
|
||||
|
||||
// offset top of select to place selected item under the cursor
|
||||
if (selectedValueIndex !== -1) {
|
||||
props.style.top = `-${offset + 32}px`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Positioner {...props}>
|
||||
<Background
|
||||
dir="auto"
|
||||
ref={contentRef}
|
||||
topAnchor={topAnchor}
|
||||
rightAnchor={rightAnchor}
|
||||
style={
|
||||
maxHeight && topAnchor
|
||||
? { maxHeight, minWidth }
|
||||
: { minWidth }
|
||||
}
|
||||
>
|
||||
{select.visible || select.animating
|
||||
? options.map((option) => (
|
||||
<StyledSelectOption
|
||||
{...select}
|
||||
value={option.value}
|
||||
key={option.value}
|
||||
animating={select.animating}
|
||||
ref={
|
||||
select.selectedValue === option.value
|
||||
? selectedRef
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{select.selectedValue !== undefined && (
|
||||
<>
|
||||
{select.selectedValue === option.value ? (
|
||||
<CheckmarkIcon color="currentColor" />
|
||||
) : (
|
||||
<Spacer />
|
||||
)}
|
||||
|
||||
</>
|
||||
)}
|
||||
{option.label}
|
||||
</StyledSelectOption>
|
||||
))
|
||||
: null}
|
||||
</Background>
|
||||
</Positioner>
|
||||
);
|
||||
}}
|
||||
</SelectPopover>
|
||||
</Wrapper>
|
||||
);
|
||||
{(select.visible || select.animating) && <Backdrop />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Spacer = styled.svg`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
font-weight: normal;
|
||||
text-transform: none;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
${(props) =>
|
||||
props.nude &&
|
||||
css`
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
`}
|
||||
|
||||
${Inner} {
|
||||
line-height: 28px;
|
||||
padding-left: 16px;
|
||||
padding-right: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledSelectOption = styled(SelectOption)`
|
||||
${MenuAnchorCSS}
|
||||
|
||||
${(props) =>
|
||||
props.animating &&
|
||||
css`
|
||||
pointer-events: none;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Wrapper = styled.label`
|
||||
display: block;
|
||||
max-width: ${(props) => (props.short ? "350px" : "100%")};
|
||||
`;
|
||||
|
||||
const Positioner = styled(Position)`
|
||||
&.focus-visible {
|
||||
${StyledSelectOption} {
|
||||
&[aria-selected="true"] {
|
||||
color: ${(props) => props.theme.white};
|
||||
background: ${(props) => props.theme.primary};
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
fill: ${(props) => props.theme.white};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default InputSelect;
|
||||
|
||||
@@ -4,19 +4,33 @@ import { useTranslation } from "react-i18next";
|
||||
import InputSelect, { type Props, type Option } from "./InputSelect";
|
||||
|
||||
export default function InputSelectPermission(
|
||||
props: $Rest<Props, { options: Array<Option> }>
|
||||
props: $Rest<$Exact<Props>, {| options: Array<Option>, ariaLabel: string |}>
|
||||
) {
|
||||
const { value, onChange, ...rest } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(value) => {
|
||||
if (value === "no_access") {
|
||||
value = "";
|
||||
}
|
||||
onChange(value);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
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: "" },
|
||||
{ label: t("No access"), value: "no_access" },
|
||||
]}
|
||||
{...props}
|
||||
ariaLabel={t("Default access")}
|
||||
value={value || "no_access"}
|
||||
onChange={handleChange}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import InputSelect, { type Props, type Option } from "components/InputSelect";
|
||||
|
||||
const InputSelectRole = (props: $Rest<Props, { options: Array<Option> }>) => {
|
||||
const InputSelectRole = (
|
||||
props: $Rest<$Exact<Props>, {| options: Array<Option>, ariaLabel: string |}>
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -14,6 +16,7 @@ const InputSelectRole = (props: $Rest<Props, { options: Array<Option> }>) => {
|
||||
{ label: t("Viewer"), value: "viewer" },
|
||||
{ label: t("Admin"), value: "admin" },
|
||||
]}
|
||||
ariaLabel={t("Role")}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -36,7 +36,11 @@ const ListItem = (
|
||||
</Subtitle>
|
||||
)}
|
||||
</Content>
|
||||
{actions && <Actions $selected={selected}>{actions}</Actions>}
|
||||
{actions && (
|
||||
<Actions $selected={selected} gap={4}>
|
||||
{actions}
|
||||
</Actions>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -56,7 +60,6 @@ const ListItem = (
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: flex;
|
||||
user-select: none;
|
||||
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
|
||||
margin: ${(props) => (props.$border === false ? "8px 0" : 0)};
|
||||
border-bottom: 1px solid
|
||||
@@ -105,7 +108,6 @@ const Subtitle = styled.p`
|
||||
export const Actions = styled(Flex)`
|
||||
align-self: center;
|
||||
justify-content: center;
|
||||
margin-right: 4px;
|
||||
color: ${(props) =>
|
||||
props.$selected ? props.theme.white : props.theme.textSecondary};
|
||||
`;
|
||||
|
||||
@@ -61,7 +61,8 @@ const Modal = ({
|
||||
<Dialog
|
||||
{...dialog}
|
||||
aria-label={title}
|
||||
preventBodyScrollhideOnEsc
|
||||
preventBodyScroll
|
||||
hideOnEsc
|
||||
hide={onRequestClose}
|
||||
>
|
||||
{(props) => (
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { cdnPath } from "../../shared/utils/urls";
|
||||
import useStores from "hooks/useStores";
|
||||
import { cdnPath } from "utils/urls";
|
||||
|
||||
type Props = {|
|
||||
title: string,
|
||||
|
||||
@@ -6,18 +6,45 @@ import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import PlaceholderText from "components/PlaceholderText";
|
||||
|
||||
export default function PlaceholderDocument(props: Object) {
|
||||
export default function PlaceholderDocument({
|
||||
includeTitle,
|
||||
delay,
|
||||
}: {
|
||||
includeTitle?: boolean,
|
||||
delay?: number,
|
||||
}) {
|
||||
const content = (
|
||||
<>
|
||||
<PlaceholderText delay={0.2} />
|
||||
<PlaceholderText delay={0.4} />
|
||||
<PlaceholderText delay={0.6} />
|
||||
</>
|
||||
);
|
||||
|
||||
if (includeTitle === false) {
|
||||
return (
|
||||
<DelayedMount delay={delay}>
|
||||
<Fade>
|
||||
<Flex column auto>
|
||||
{content}
|
||||
</Flex>
|
||||
</Fade>
|
||||
</DelayedMount>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DelayedMount>
|
||||
<DelayedMount delay={delay}>
|
||||
<Wrapper>
|
||||
<Flex column auto {...props}>
|
||||
<PlaceholderText height={34} maxWidth={70} />
|
||||
<PlaceholderText delay={0.2} maxWidth={40} />
|
||||
<br />
|
||||
<PlaceholderText delay={0.2} />
|
||||
<PlaceholderText delay={0.4} />
|
||||
<PlaceholderText delay={0.6} />
|
||||
</Flex>
|
||||
<Fade>
|
||||
<Flex column auto>
|
||||
<PlaceholderText height={34} maxWidth={70} />
|
||||
<PlaceholderText delay={0.2} maxWidth={40} />
|
||||
<br />
|
||||
|
||||
{content}
|
||||
</Flex>
|
||||
</Fade>
|
||||
</Wrapper>
|
||||
</DelayedMount>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
LinkIcon,
|
||||
TeamIcon,
|
||||
ExpandedIcon,
|
||||
BeakerIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -95,6 +96,15 @@ function SettingsSidebar() {
|
||||
label={t("Security")}
|
||||
/>
|
||||
)}
|
||||
{can.update &&
|
||||
env.COLLABORATION_URL &&
|
||||
env.DEPLOYMENT !== "hosted" && (
|
||||
<SidebarLink
|
||||
to="/settings/features"
|
||||
icon={<BeakerIcon color="currentColor" />}
|
||||
label={t("Features")}
|
||||
/>
|
||||
)}
|
||||
<SidebarLink
|
||||
to="/settings/members"
|
||||
icon={<UserIcon color="currentColor" />}
|
||||
|
||||
@@ -3,10 +3,14 @@ import fractionalIndex from "fractional-index";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useDrop, useDrag } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Collection from "models/Collection";
|
||||
import Document from "models/Document";
|
||||
import DocumentReparent from "scenes/DocumentReparent";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import Modal from "components/Modal";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import DropToImport from "./DropToImport";
|
||||
@@ -36,7 +40,15 @@ function CollectionLink({
|
||||
isDraggingAnyCollection,
|
||||
onChangeDragging,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { search } = useLocation();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const [
|
||||
permissionOpen,
|
||||
handlePermissionOpen,
|
||||
handlePermissionClose,
|
||||
] = useBoolean();
|
||||
const itemRef = React.useRef();
|
||||
|
||||
const handleTitleChange = React.useCallback(
|
||||
async (name: string) => {
|
||||
@@ -52,12 +64,17 @@ function CollectionLink({
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
// If we're viewing a starred document through the starred menu then don't
|
||||
// touch the expanded / collapsed state of the collections
|
||||
if (search === "?starred") {
|
||||
return;
|
||||
}
|
||||
if (isDraggingAnyCollection) {
|
||||
setExpanded(false);
|
||||
} else {
|
||||
setExpanded(collection.id === ui.activeCollectionId);
|
||||
}
|
||||
}, [isDraggingAnyCollection, collection.id, ui.activeCollectionId]);
|
||||
}, [isDraggingAnyCollection, collection.id, ui.activeCollectionId, search]);
|
||||
|
||||
const manualSort = collection.sort.field === "index";
|
||||
const can = policies.abilities(collection.id);
|
||||
@@ -67,9 +84,22 @@ function CollectionLink({
|
||||
const [{ isOver, canDrop }, drop] = useDrop({
|
||||
accept: "document",
|
||||
drop: (item, monitor) => {
|
||||
const { id, collectionId } = item;
|
||||
if (monitor.didDrop()) return;
|
||||
if (!collection) return;
|
||||
documents.move(item.id, collection.id);
|
||||
if (collection.id === collectionId) return;
|
||||
const prevCollection = collections.get(collectionId);
|
||||
|
||||
if (
|
||||
prevCollection &&
|
||||
prevCollection.permission === null &&
|
||||
prevCollection.permission !== collection.permission
|
||||
) {
|
||||
itemRef.current = item;
|
||||
handlePermissionOpen();
|
||||
} else {
|
||||
documents.move(id, collection.id);
|
||||
}
|
||||
},
|
||||
canDrop: (item, monitor) => {
|
||||
return policies.abilities(collection.id).update;
|
||||
@@ -203,6 +233,18 @@ function CollectionLink({
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
<Modal
|
||||
title={t("Move document")}
|
||||
onRequestClose={handlePermissionClose}
|
||||
isOpen={permissionOpen}
|
||||
>
|
||||
<DocumentReparent
|
||||
item={itemRef.current}
|
||||
collection={collection}
|
||||
onSubmit={handlePermissionClose}
|
||||
onCancel={handlePermissionClose}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,7 +128,12 @@ function DocumentLink(
|
||||
// Draggable
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: "document",
|
||||
item: () => ({ ...node, depth, active: isActiveDocument }),
|
||||
item: () => ({
|
||||
...node,
|
||||
depth,
|
||||
active: isActiveDocument,
|
||||
collectionId: collection?.id || "",
|
||||
}),
|
||||
collect: (monitor) => ({
|
||||
isDragging: !!monitor.isDragging(),
|
||||
}),
|
||||
@@ -240,6 +245,9 @@ function DocumentLink(
|
||||
/>
|
||||
</>
|
||||
}
|
||||
isActive={(match, location) =>
|
||||
match && location.search !== "?starred"
|
||||
}
|
||||
isActiveDrop={isOverReparent && canDropToReparent}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
|
||||
@@ -8,7 +8,7 @@ import EventBoundary from "components/EventBoundary";
|
||||
import NavLink from "./NavLink";
|
||||
import { type Theme } from "types";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
to?: string | Object,
|
||||
href?: string | Object,
|
||||
innerRef?: (?HTMLElement) => void,
|
||||
@@ -29,7 +29,7 @@ type Props = {
|
||||
exact?: boolean,
|
||||
depth?: number,
|
||||
scrollIntoViewIfNeeded?: boolean,
|
||||
};
|
||||
|};
|
||||
|
||||
function SidebarLink(
|
||||
{
|
||||
@@ -51,6 +51,7 @@ function SidebarLink(
|
||||
match,
|
||||
className,
|
||||
scrollIntoViewIfNeeded,
|
||||
...rest
|
||||
}: Props,
|
||||
ref
|
||||
) {
|
||||
@@ -86,6 +87,7 @@ function SidebarLink(
|
||||
href={href}
|
||||
className={className}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label>{label}</Label>
|
||||
|
||||
@@ -69,7 +69,10 @@ function StarredLink({ depth, title, to, documentId, collectionId }: Props) {
|
||||
<Relative>
|
||||
<SidebarLink
|
||||
depth={depth}
|
||||
to={to}
|
||||
to={`${to}?starred`}
|
||||
isActive={(match, location) =>
|
||||
match && location.search === "?starred"
|
||||
}
|
||||
label={
|
||||
<>
|
||||
{hasChildDocuments && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
@@ -84,4 +85,4 @@ const Header = styled.button`
|
||||
}
|
||||
`;
|
||||
|
||||
export default TeamButton;
|
||||
export default observer(TeamButton);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import useStores from "./useStores";
|
||||
|
||||
export default function useCurrentToken() {
|
||||
const { auth } = useStores();
|
||||
invariant(auth.token, "token is required");
|
||||
return auth.token;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
const activityEvents = [
|
||||
"click",
|
||||
"mousemove",
|
||||
"keydown",
|
||||
"DOMMouseScroll",
|
||||
"mousewheel",
|
||||
"mousedown",
|
||||
"touchstart",
|
||||
"touchmove",
|
||||
"focus",
|
||||
];
|
||||
|
||||
/**
|
||||
* Hook to detect user idle state.
|
||||
*
|
||||
* @param {number} timeToIdle
|
||||
* @returns boolean if the user is idle
|
||||
*/
|
||||
export default function useIdle(timeToIdle: number = 3 * 60 * 1000) {
|
||||
const [isIdle, setIsIdle] = React.useState(false);
|
||||
const timeout = React.useRef();
|
||||
|
||||
const onActivity = React.useCallback(() => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
|
||||
timeout.current = setTimeout(() => {
|
||||
setIsIdle(true);
|
||||
}, timeToIdle);
|
||||
}, [timeToIdle]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleUserActivityEvent = () => {
|
||||
setIsIdle(false);
|
||||
onActivity();
|
||||
};
|
||||
|
||||
activityEvents.forEach((eventName) =>
|
||||
window.addEventListener(eventName, handleUserActivityEvent)
|
||||
);
|
||||
|
||||
return () => {
|
||||
activityEvents.forEach((eventName) =>
|
||||
window.removeEventListener(eventName, handleUserActivityEvent)
|
||||
);
|
||||
};
|
||||
}, [onActivity]);
|
||||
|
||||
return isIdle;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { type ElementRef } from "reakit";
|
||||
import useMobile from "hooks/useMobile";
|
||||
import useWindowSize from "hooks/useWindowSize";
|
||||
|
||||
const useMenuHeight = (
|
||||
visible: void | boolean,
|
||||
unstable_disclosureRef: void | { current: null | ElementRef<"button"> }
|
||||
) => {
|
||||
const [maxHeight, setMaxHeight] = React.useState(undefined);
|
||||
const isMobile = useMobile();
|
||||
const { height: windowHeight } = useWindowSize();
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const padding = 8;
|
||||
|
||||
if (visible && !isMobile) {
|
||||
setMaxHeight(
|
||||
unstable_disclosureRef?.current
|
||||
? windowHeight -
|
||||
unstable_disclosureRef.current.getBoundingClientRect().bottom -
|
||||
padding
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
}, [visible, unstable_disclosureRef, windowHeight, isMobile]);
|
||||
|
||||
return maxHeight;
|
||||
};
|
||||
|
||||
export default useMenuHeight;
|
||||
@@ -0,0 +1,22 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Hook to return page visibility state.
|
||||
*
|
||||
* @returns boolean if the page is visible
|
||||
*/
|
||||
export default function usePageVisibility(): boolean {
|
||||
const [visible, setVisible] = React.useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleVisibilityChange = () => setVisible(!document.hidden);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return visible;
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import * as React from "react";
|
||||
|
||||
export default function useWindowSize() {
|
||||
const [windowSize, setWindowSize] = React.useState({
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
width: parseInt(window.innerWidth),
|
||||
height: parseInt(window.innerHeight),
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -13,8 +13,8 @@ export default function useWindowSize() {
|
||||
const handleResize = debounce(() => {
|
||||
// Set window width/height to state
|
||||
setWindowSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
width: parseInt(window.innerWidth),
|
||||
height: parseInt(window.innerHeight),
|
||||
});
|
||||
}, 100);
|
||||
|
||||
|
||||
+1
-1
@@ -15,8 +15,8 @@ import ScrollToTop from "components/ScrollToTop";
|
||||
import Theme from "components/Theme";
|
||||
import Toasts from "components/Toasts";
|
||||
import Routes from "./routes";
|
||||
import { initSentry } from "./utils/sentry";
|
||||
import env from "env";
|
||||
import { initSentry } from "utils/sentry";
|
||||
|
||||
initI18n();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { MoonIcon, SunIcon } from "outline-icons";
|
||||
import { MoonIcon, SunIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
@@ -16,11 +16,14 @@ import KeyboardShortcuts from "scenes/KeyboardShortcuts";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import Guide from "components/Guide";
|
||||
import env from "env";
|
||||
import useBoolean from "hooks/useBoolean";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
import useSessions from "hooks/useSessions";
|
||||
import useStores from "hooks/useStores";
|
||||
import useToasts from "hooks/useToasts";
|
||||
import { deleteAllDatabases } from "utils/developer";
|
||||
|
||||
type Props = {|
|
||||
children: (props: any) => React.Node,
|
||||
@@ -33,11 +36,13 @@ function AccountMenu(props: Props) {
|
||||
placement: "bottom-start",
|
||||
modal: true,
|
||||
});
|
||||
const { showToast } = useToasts();
|
||||
const { auth, ui } = useStores();
|
||||
const { theme, resolvedTheme } = ui;
|
||||
const team = useCurrentTeam();
|
||||
const previousTheme = usePrevious(theme);
|
||||
const { t } = useTranslation();
|
||||
const [includeAlt, setIncludeAlt] = React.useState(false);
|
||||
const [
|
||||
keyboardShortcutsOpen,
|
||||
handleKeyboardShortcutsOpen,
|
||||
@@ -50,6 +55,16 @@ function AccountMenu(props: Props) {
|
||||
}
|
||||
}, [menu, theme, previousTheme]);
|
||||
|
||||
const handleDeleteAllDatabases = React.useCallback(async () => {
|
||||
await deleteAllDatabases();
|
||||
showToast("IndexedDB cache deleted");
|
||||
menu.hide();
|
||||
}, [showToast, menu]);
|
||||
|
||||
const handleOpenMenu = React.useCallback((event) => {
|
||||
setIncludeAlt(event.altKey);
|
||||
}, []);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const otherSessions = sessions.filter(
|
||||
(session) => session.teamId !== team.id && session.url !== team.url
|
||||
@@ -83,6 +98,20 @@ function AccountMenu(props: Props) {
|
||||
title: t("Report a bug"),
|
||||
href: githubIssuesUrl(),
|
||||
},
|
||||
...(includeAlt || env.ENVIRONMENT === "development"
|
||||
? [
|
||||
{
|
||||
title: t("Development"),
|
||||
items: [
|
||||
{
|
||||
title: "Delete IndexedDB cache",
|
||||
icon: <TrashIcon />,
|
||||
onClick: handleDeleteAllDatabases,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: t("Appearance"),
|
||||
icon: resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
|
||||
@@ -130,7 +159,9 @@ function AccountMenu(props: Props) {
|
||||
team.url,
|
||||
sessions,
|
||||
handleKeyboardShortcutsOpen,
|
||||
handleDeleteAllDatabases,
|
||||
resolvedTheme,
|
||||
includeAlt,
|
||||
theme,
|
||||
t,
|
||||
ui,
|
||||
@@ -145,7 +176,9 @@ function AccountMenu(props: Props) {
|
||||
>
|
||||
<KeyboardShortcuts />
|
||||
</Guide>
|
||||
<MenuButton {...menu}>{props.children}</MenuButton>
|
||||
<MenuButton {...menu} onClick={handleOpenMenu}>
|
||||
{props.children}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Account")}>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
|
||||
@@ -37,6 +37,7 @@ 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 useToasts from "hooks/useToasts";
|
||||
import getDataTransferFiles from "utils/getDataTransferFiles";
|
||||
@@ -72,6 +73,7 @@ function DocumentMenu({
|
||||
onOpen,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const team = useCurrentTeam();
|
||||
const { policies, collections, documents } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const menu = useMenuState({
|
||||
@@ -385,13 +387,19 @@ function DocumentMenu({
|
||||
{
|
||||
title: t("Enable embeds"),
|
||||
onClick: document.enableEmbeds,
|
||||
visible: !!showToggleEmbeds && document.embedsDisabled,
|
||||
visible:
|
||||
!!showToggleEmbeds &&
|
||||
document.embedsDisabled &&
|
||||
!team.collaborativeEditing,
|
||||
icon: <BuildingBlocksIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Disable embeds"),
|
||||
onClick: document.disableEmbeds,
|
||||
visible: !!showToggleEmbeds && !document.embedsDisabled,
|
||||
visible:
|
||||
!!showToggleEmbeds &&
|
||||
!document.embedsDisabled &&
|
||||
!team.collaborativeEditing,
|
||||
icon: <BuildingBlocksIcon />,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
|
||||
type Props = {|
|
||||
id: string,
|
||||
onDelete: (ev: SyntheticEvent<>) => Promise<void>,
|
||||
|};
|
||||
|
||||
function FileOperationMenu({ id, onDelete }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({ modal: true });
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Export options")}>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
title: t("Download"),
|
||||
href: "/api/fileOperations.redirect?id=" + id,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: t("Delete"),
|
||||
onClick: onDelete,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileOperationMenu;
|
||||
@@ -12,6 +12,7 @@ import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||
import Separator from "components/ContextMenu/Separator";
|
||||
import CopyToClipboard from "components/CopyToClipboard";
|
||||
import MenuIconWrapper from "components/MenuIconWrapper";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useToasts from "hooks/useToasts";
|
||||
import { documentHistoryUrl } from "utils/routeHelpers";
|
||||
|
||||
@@ -23,6 +24,7 @@ type Props = {|
|
||||
|
||||
function RevisionMenu({ document, revisionId, className }: Props) {
|
||||
const { showToast } = useToasts();
|
||||
const team = useCurrentTeam();
|
||||
const menu = useMenuState({ modal: true });
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
@@ -55,7 +57,11 @@ function RevisionMenu({ document, revisionId, className }: Props) {
|
||||
{...menu}
|
||||
/>
|
||||
<ContextMenu {...menu} aria-label={t("Revision options")}>
|
||||
<MenuItem {...menu} onClick={handleRestore}>
|
||||
<MenuItem
|
||||
{...menu}
|
||||
onClick={handleRestore}
|
||||
disabled={team.collaborativeEditing}
|
||||
>
|
||||
<MenuIconWrapper>
|
||||
<RestoreIcon />
|
||||
</MenuIconWrapper>
|
||||
|
||||
@@ -14,9 +14,10 @@ import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
onSelectTemplate: (template: Document) => void,
|
||||
|};
|
||||
|
||||
function TemplatesMenu({ document }: Props) {
|
||||
function TemplatesMenu({ onSelectTemplate, document }: Props) {
|
||||
const menu = useMenuState({ modal: true });
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
@@ -36,7 +37,7 @@ function TemplatesMenu({ document }: Props) {
|
||||
const renderTemplate = (template) => (
|
||||
<MenuItem
|
||||
key={template.id}
|
||||
onClick={() => document.updateFromTemplate(template)}
|
||||
onClick={() => onSelectTemplate(template)}
|
||||
icon={<DocumentIcon />}
|
||||
{...menu}
|
||||
>
|
||||
|
||||
+29
-17
@@ -10,17 +10,16 @@ import BaseModel from "models/BaseModel";
|
||||
import User from "models/User";
|
||||
import View from "./View";
|
||||
|
||||
type SaveOptions = {
|
||||
type SaveOptions = {|
|
||||
publish?: boolean,
|
||||
done?: boolean,
|
||||
autosave?: boolean,
|
||||
lastRevision?: number,
|
||||
};
|
||||
|};
|
||||
|
||||
export default class Document extends BaseModel {
|
||||
@observable isSaving: boolean = false;
|
||||
@observable embedsDisabled: boolean = false;
|
||||
@observable injectTemplate: boolean = false;
|
||||
@observable lastViewedAt: ?string;
|
||||
store: DocumentsStore;
|
||||
|
||||
@@ -254,15 +253,28 @@ export default class Document extends BaseModel {
|
||||
};
|
||||
|
||||
@action
|
||||
updateFromTemplate = async (template: Document) => {
|
||||
this.templateId = template.id;
|
||||
this.title = template.title;
|
||||
this.text = template.text;
|
||||
this.injectTemplate = true;
|
||||
update = async (options: {| ...SaveOptions, title: string |}) => {
|
||||
if (this.isSaving) return this;
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
if (options.lastRevision) {
|
||||
return await this.store.update({
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
lastRevision: options.lastRevision,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error("Attempting to update without a lastRevision");
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
save = async (options: SaveOptions = {}) => {
|
||||
save = async (options: ?SaveOptions) => {
|
||||
if (this.isSaving) return this;
|
||||
|
||||
const isCreating = !this.id;
|
||||
@@ -275,22 +287,22 @@ export default class Document extends BaseModel {
|
||||
collectionId: this.collectionId,
|
||||
title: this.title,
|
||||
text: this.text,
|
||||
publish: options.publish,
|
||||
done: options.done,
|
||||
autosave: options.autosave,
|
||||
publish: options?.publish,
|
||||
done: options?.done,
|
||||
autosave: options?.autosave,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.lastRevision) {
|
||||
if (options?.lastRevision) {
|
||||
return await this.store.update({
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
text: this.text,
|
||||
templateId: this.templateId,
|
||||
lastRevision: options.lastRevision,
|
||||
publish: options.publish,
|
||||
done: options.done,
|
||||
autosave: options.autosave,
|
||||
lastRevision: options?.lastRevision,
|
||||
publish: options?.publish,
|
||||
done: options?.done,
|
||||
autosave: options?.autosave,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ import BaseModel from "./BaseModel";
|
||||
class Group extends BaseModel {
|
||||
id: string;
|
||||
name: string;
|
||||
isPrivate: boolean;
|
||||
memberCount: number;
|
||||
updatedAt: string;
|
||||
|
||||
toJS = () => {
|
||||
return {
|
||||
name: this.name,
|
||||
isPrivate: this.isPrivate,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ class Team extends BaseModel {
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
sharing: boolean;
|
||||
collaborativeEditing: boolean;
|
||||
documentEmbeds: boolean;
|
||||
guestSignin: boolean;
|
||||
subdomain: ?string;
|
||||
|
||||
@@ -8,6 +8,7 @@ class User extends BaseModel {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
color: string;
|
||||
isAdmin: boolean;
|
||||
isViewer: boolean;
|
||||
lastActiveAt: string;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// @flow
|
||||
import { keymap } from "prosemirror-keymap";
|
||||
import { Extension } from "rich-markdown-editor";
|
||||
import {
|
||||
ySyncPlugin,
|
||||
yCursorPlugin,
|
||||
yUndoPlugin,
|
||||
undo,
|
||||
redo,
|
||||
} from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
|
||||
export default class MultiplayerExtension extends Extension {
|
||||
get name() {
|
||||
return "multiplayer";
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const { user, provider, document: doc } = this.options;
|
||||
const type = doc.get("default", Y.XmlFragment);
|
||||
|
||||
const assignUser = (tr) => {
|
||||
const clientIds = Array.from(doc.store.clients.keys());
|
||||
|
||||
if (
|
||||
tr.local &&
|
||||
tr.changed.size > 0 &&
|
||||
!clientIds.includes(doc.clientID)
|
||||
) {
|
||||
const permanentUserData = new Y.PermanentUserData(doc);
|
||||
permanentUserData.setUserMapping(doc, doc.clientID, user.id);
|
||||
doc.off("afterTransaction", assignUser);
|
||||
}
|
||||
};
|
||||
|
||||
// only once we have authenticated successfully do we initalize awareness.
|
||||
// we could send this earlier, but getting authenticated faster is more important
|
||||
provider.on("authenticated", () => {
|
||||
provider.setAwarenessField("user", user);
|
||||
});
|
||||
|
||||
// only once an actual change has been made do we add the userId <> clientId
|
||||
// mapping, this avoids stored mappings for clients that never made a change
|
||||
doc.on("afterTransaction", assignUser);
|
||||
|
||||
return [
|
||||
ySyncPlugin(type),
|
||||
yCursorPlugin(provider.awareness),
|
||||
yUndoPlugin(),
|
||||
keymap({
|
||||
"Mod-z": undo,
|
||||
"Mod-y": redo,
|
||||
"Mod-Shift-z": redo,
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,8 @@ import { matchDocumentSlug as slug } from "utils/routeHelpers";
|
||||
const SettingsRoutes = React.lazy(() =>
|
||||
import(/* webpackChunkName: "settings" */ "./settings")
|
||||
);
|
||||
const KeyedDocument = React.lazy(() =>
|
||||
import(
|
||||
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
|
||||
)
|
||||
const Document = React.lazy(() =>
|
||||
import(/* webpackChunkName: "document" */ "scenes/Document")
|
||||
);
|
||||
const NotFound = () => <Search notFound />;
|
||||
const RedirectDocument = ({ match }: { match: Match }) => (
|
||||
@@ -64,10 +62,10 @@ export default function AuthenticatedRoutes() {
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={KeyedDocument}
|
||||
component={Document}
|
||||
/>
|
||||
<Route exact path={`/doc/${slug}/edit`} component={KeyedDocument} />
|
||||
<Route path={`/doc/${slug}`} component={KeyedDocument} />
|
||||
<Route exact path={`/doc/${slug}/edit`} component={Document} />
|
||||
<Route path={`/doc/${slug}`} component={Document} />
|
||||
<Route exact path="/search" component={Search} />
|
||||
<Route exact path="/search/:term" component={Search} />
|
||||
<Route path="/404" component={Error404} />
|
||||
|
||||
+4
-6
@@ -12,10 +12,8 @@ const Authenticated = React.lazy(() =>
|
||||
const AuthenticatedRoutes = React.lazy(() =>
|
||||
import(/* webpackChunkName: "authenticated-routes" */ "./authenticated")
|
||||
);
|
||||
const KeyedDocument = React.lazy(() =>
|
||||
import(
|
||||
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
|
||||
)
|
||||
const SharedDocument = React.lazy(() =>
|
||||
import(/* webpackChunkName: "shared-document" */ "scenes/Document/Shared")
|
||||
);
|
||||
const Login = React.lazy(() =>
|
||||
import(/* webpackChunkName: "login" */ "scenes/Login")
|
||||
@@ -37,11 +35,11 @@ export default function Routes() {
|
||||
<Route exact path="/" component={Login} />
|
||||
<Route exact path="/create" component={Login} />
|
||||
<Route exact path="/logout" component={Logout} />
|
||||
<Route exact path="/share/:shareId" component={KeyedDocument} />
|
||||
<Route exact path="/share/:shareId" component={SharedDocument} />
|
||||
<Route
|
||||
exact
|
||||
path={`/share/:shareId/doc/${slug}`}
|
||||
component={KeyedDocument}
|
||||
component={SharedDocument}
|
||||
/>
|
||||
<Authenticated>
|
||||
<AuthenticatedRoutes />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import * as React from "react";
|
||||
import { Switch, Redirect } from "react-router-dom";
|
||||
import Details from "scenes/Settings/Details";
|
||||
import Features from "scenes/Settings/Features";
|
||||
import Groups from "scenes/Settings/Groups";
|
||||
import ImportExport from "scenes/Settings/ImportExport";
|
||||
import Notifications from "scenes/Settings/Notifications";
|
||||
@@ -21,6 +22,7 @@ export default function SettingsRoutes() {
|
||||
<Route exact path="/settings/details" component={Details} />
|
||||
<Route exact path="/settings/security" component={Security} />
|
||||
<Route exact path="/settings/members" component={People} />
|
||||
<Route exact path="/settings/features" component={Features} />
|
||||
<Route exact path="/settings/groups" component={Groups} />
|
||||
<Route exact path="/settings/shares" component={Shares} />
|
||||
<Route exact path="/settings/tokens" component={Tokens} />
|
||||
|
||||
+16
-12
@@ -19,19 +19,23 @@ function APITokenNew({ onSubmit }: Props) {
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = React.useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await apiKeys.create({ name });
|
||||
showToast(t("API token created", { type: "success" }));
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [t, showToast, name, onSubmit, apiKeys]);
|
||||
try {
|
||||
await apiKeys.create({ name });
|
||||
showToast(t("API token created", { type: "success" }));
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[t, showToast, name, onSubmit, apiKeys]
|
||||
);
|
||||
|
||||
const handleNameChange = React.useCallback((event) => {
|
||||
setName(event.target.value);
|
||||
|
||||
@@ -50,7 +50,7 @@ function CollectionDelete({ collection, onSubmit }: Props) {
|
||||
/>
|
||||
</HelpText>
|
||||
<Button type="submit" disabled={isDeleting} autoFocus danger>
|
||||
{isDeleting ? `${t("Deleting")}…` : t("I’m sure – Delete")}
|
||||
{isDeleting ? `${t("Deleting")}…` : t("I’m sure – Delete")}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
|
||||
@@ -54,8 +54,8 @@ const CollectionEdit = ({ collection, onSubmit }: Props) => {
|
||||
[collection, color, icon, name, onSubmit, showToast, sort, t]
|
||||
);
|
||||
|
||||
const handleSortChange = (ev: SyntheticInputEvent<HTMLSelectElement>) => {
|
||||
const [field, direction] = ev.target.value.split(".");
|
||||
const handleSortChange = (value: string) => {
|
||||
const [field, direction] = value.split(".");
|
||||
|
||||
if (direction === "asc" || direction === "desc") {
|
||||
setSort({ field, direction });
|
||||
@@ -101,6 +101,7 @@ const CollectionEdit = ({ collection, onSubmit }: Props) => {
|
||||
]}
|
||||
value={`${sort.field}.${sort.direction}`}
|
||||
onChange={handleSortChange}
|
||||
ariaLabel={t("Sort")}
|
||||
/>
|
||||
<Button type="submit" disabled={isSaving || !collection.name}>
|
||||
{isSaving ? `${t("Saving")}…` : t("Save")}
|
||||
|
||||
@@ -88,8 +88,8 @@ class CollectionNew extends React.Component<Props> {
|
||||
this.hasOpenedIconPicker = true;
|
||||
};
|
||||
|
||||
handlePermissionChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
|
||||
this.permission = ev.target.value;
|
||||
handlePermissionChange = (newPermission: string) => {
|
||||
this.permission = newPermission;
|
||||
};
|
||||
|
||||
handleSharingChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
// @flow
|
||||
import { debounce } from "lodash";
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import CollectionGroupMembershipsStore from "stores/CollectionGroupMembershipsStore";
|
||||
import GroupsStore from "stores/GroupsStore";
|
||||
import ToastsStore from "stores/ToastsStore";
|
||||
import Collection from "models/Collection";
|
||||
import Group from "models/Group";
|
||||
import GroupNew from "scenes/GroupNew";
|
||||
@@ -21,132 +16,123 @@ import HelpText from "components/HelpText";
|
||||
import Input from "components/Input";
|
||||
import Modal from "components/Modal";
|
||||
import PaginatedList from "components/PaginatedList";
|
||||
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
import useToasts from "hooks/useToasts";
|
||||
type Props = {
|
||||
toasts: ToastsStore,
|
||||
auth: AuthStore,
|
||||
collection: Collection,
|
||||
collectionGroupMemberships: CollectionGroupMembershipsStore,
|
||||
groups: GroupsStore,
|
||||
onSubmit: () => void,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
class AddGroupsToCollection extends React.Component<Props> {
|
||||
@observable newGroupModalOpen: boolean = false;
|
||||
@observable query: string = "";
|
||||
const AddGroupsToCollection = ({ collection, onSubmit }: Props) => {
|
||||
const [newGroupModalOpen, setNewGroupModalOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const { groups, collectionGroupMemberships, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToasts();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
handleNewGroupModalOpen = () => {
|
||||
this.newGroupModalOpen = true;
|
||||
};
|
||||
const can = policies.abilities(team.id);
|
||||
const groupsExist = !!groups.orderedData.length;
|
||||
|
||||
handleNewGroupModalClose = () => {
|
||||
this.newGroupModalOpen = false;
|
||||
};
|
||||
const debouncedFetch = React.useMemo(
|
||||
() =>
|
||||
debounce(async (query) => {
|
||||
await groups.fetchPage({
|
||||
query,
|
||||
});
|
||||
}, 250),
|
||||
[groups]
|
||||
);
|
||||
|
||||
handleFilter = (ev: SyntheticInputEvent<>) => {
|
||||
this.query = ev.target.value;
|
||||
this.debouncedFetch();
|
||||
};
|
||||
|
||||
debouncedFetch = debounce(() => {
|
||||
this.props.groups.fetchPage({
|
||||
query: this.query,
|
||||
});
|
||||
}, 250);
|
||||
|
||||
handleAddGroup = (group: Group) => {
|
||||
const { t } = this.props;
|
||||
const handleFilter = React.useCallback(
|
||||
(ev: SyntheticInputEvent<>) => {
|
||||
setQuery(ev.target.value);
|
||||
debouncedFetch(ev.target.value);
|
||||
},
|
||||
[debouncedFetch]
|
||||
);
|
||||
|
||||
const handleAddGroup = async (group: Group) => {
|
||||
try {
|
||||
this.props.collectionGroupMemberships.create({
|
||||
collectionId: this.props.collection.id,
|
||||
await collectionGroupMemberships.create({
|
||||
collectionId: collection.id,
|
||||
groupId: group.id,
|
||||
permission: "read_write",
|
||||
});
|
||||
this.props.toasts.showToast(
|
||||
showToast(
|
||||
t("{{ groupName }} was added to the collection", {
|
||||
groupName: group.name,
|
||||
}),
|
||||
{ type: "success" }
|
||||
);
|
||||
} catch (err) {
|
||||
this.props.toasts.showToast(t("Could not add user"), { type: "error" });
|
||||
showToast(t("Could not add group"), { type: "error" });
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { groups, collection, auth, t } = this.props;
|
||||
const { user, team } = auth;
|
||||
if (!user || !team) return null;
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
return (
|
||||
<Flex column>
|
||||
{can.createGroup && (
|
||||
<HelpText>
|
||||
{t("Can’t find the group you’re looking for?")}{" "}
|
||||
<ButtonLink onClick={this.handleNewGroupModalOpen}>
|
||||
<ButtonLink onClick={() => setNewGroupModalOpen(true)}>
|
||||
{t("Create a group")}
|
||||
</ButtonLink>
|
||||
.
|
||||
</HelpText>
|
||||
|
||||
)}
|
||||
{groupsExist && (
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={`${t("Search by group name")}…`}
|
||||
value={this.query}
|
||||
onChange={this.handleFilter}
|
||||
value={query}
|
||||
onChange={handleFilter}
|
||||
label={t("Search groups")}
|
||||
labelHidden
|
||||
flex
|
||||
/>
|
||||
<PaginatedList
|
||||
empty={
|
||||
this.query ? (
|
||||
<Empty>{t("No groups matching your search")}</Empty>
|
||||
) : (
|
||||
<Empty>{t("No groups left to add")}</Empty>
|
||||
)
|
||||
}
|
||||
items={groups.notInCollection(collection.id, this.query)}
|
||||
fetch={this.query ? undefined : groups.fetchPage}
|
||||
renderItem={(item) => (
|
||||
<GroupListItem
|
||||
key={item.id}
|
||||
group={item}
|
||||
showFacepile
|
||||
renderActions={() => (
|
||||
<ButtonWrap>
|
||||
<Button onClick={() => this.handleAddGroup(item)} neutral>
|
||||
{t("Add")}
|
||||
</Button>
|
||||
</ButtonWrap>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Modal
|
||||
title={t("Create a group")}
|
||||
onRequestClose={this.handleNewGroupModalClose}
|
||||
isOpen={this.newGroupModalOpen}
|
||||
>
|
||||
<GroupNew onSubmit={this.handleNewGroupModalClose} />
|
||||
</Modal>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
)}
|
||||
<PaginatedList
|
||||
empty={
|
||||
query ? (
|
||||
<Empty>{t("No groups matching your search")}</Empty>
|
||||
) : groupsExist ? (
|
||||
<Empty>{t("No groups left to add")}</Empty>
|
||||
) : (
|
||||
<Empty>{t("No groups found to add")}</Empty>
|
||||
)
|
||||
}
|
||||
items={groups.notInCollection(collection.id, query)}
|
||||
fetch={query ? undefined : groups.fetchPage}
|
||||
renderItem={(item) => (
|
||||
<GroupListItem
|
||||
key={item.id}
|
||||
group={item}
|
||||
showFacepile
|
||||
renderActions={() => (
|
||||
<ButtonWrap>
|
||||
<Button onClick={() => handleAddGroup(item)} neutral>
|
||||
{t("Add")}
|
||||
</Button>
|
||||
</ButtonWrap>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Modal
|
||||
title={t("Create a group")}
|
||||
onRequestClose={() => setNewGroupModalOpen(false)}
|
||||
isOpen={newGroupModalOpen}
|
||||
>
|
||||
<GroupNew onSubmit={() => setNewGroupModalOpen(false)} />
|
||||
</Modal>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const ButtonWrap = styled.div`
|
||||
margin-left: 6px;
|
||||
`;
|
||||
|
||||
export default withTranslation()<AddGroupsToCollection>(
|
||||
inject(
|
||||
"auth",
|
||||
"groups",
|
||||
"collectionGroupMemberships",
|
||||
"toasts"
|
||||
)(AddGroupsToCollection)
|
||||
);
|
||||
export default observer(AddGroupsToCollection);
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
// @flow
|
||||
import { debounce } from "lodash";
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import MembershipsStore from "stores/MembershipsStore";
|
||||
import ToastsStore from "stores/ToastsStore";
|
||||
import UsersStore from "stores/UsersStore";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Collection from "models/Collection";
|
||||
import User from "models/User";
|
||||
import Invite from "scenes/Invite";
|
||||
@@ -19,116 +14,109 @@ import Input from "components/Input";
|
||||
import Modal from "components/Modal";
|
||||
import PaginatedList from "components/PaginatedList";
|
||||
import MemberListItem from "./components/MemberListItem";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
import useToasts from "hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
toasts: ToastsStore,
|
||||
auth: AuthStore,
|
||||
collection: Collection,
|
||||
memberships: MembershipsStore,
|
||||
users: UsersStore,
|
||||
onSubmit: () => void,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
class AddPeopleToCollection extends React.Component<Props> {
|
||||
@observable inviteModalOpen: boolean = false;
|
||||
@observable query: string = "";
|
||||
const AddPeopleToCollection = ({ collection, onSubmit }: Props) => {
|
||||
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const team = useCurrentTeam();
|
||||
const { users, memberships, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToasts();
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
handleInviteModalOpen = () => {
|
||||
this.inviteModalOpen = true;
|
||||
};
|
||||
const debouncedFetch = React.useMemo(
|
||||
() =>
|
||||
debounce(async (query) => {
|
||||
await users.fetchPage({
|
||||
query,
|
||||
});
|
||||
}, 250),
|
||||
[users]
|
||||
);
|
||||
|
||||
handleInviteModalClose = () => {
|
||||
this.inviteModalOpen = false;
|
||||
};
|
||||
const handleFilter = React.useCallback(
|
||||
(ev: SyntheticInputEvent<>) => {
|
||||
setQuery(ev.target.value);
|
||||
debouncedFetch(ev.target.value);
|
||||
},
|
||||
[debouncedFetch]
|
||||
);
|
||||
|
||||
handleFilter = (ev: SyntheticInputEvent<>) => {
|
||||
this.query = ev.target.value;
|
||||
this.debouncedFetch();
|
||||
};
|
||||
|
||||
debouncedFetch = debounce(() => {
|
||||
this.props.users.fetchPage({
|
||||
query: this.query,
|
||||
});
|
||||
}, 250);
|
||||
|
||||
handleAddUser = (user: User) => {
|
||||
const { t } = this.props;
|
||||
const handleAddUser = (user: User) => {
|
||||
try {
|
||||
this.props.memberships.create({
|
||||
collectionId: this.props.collection.id,
|
||||
memberships.create({
|
||||
collectionId: collection.id,
|
||||
userId: user.id,
|
||||
permission: "read_write",
|
||||
});
|
||||
this.props.toasts.showToast(
|
||||
showToast(
|
||||
t("{{ userName }} was added to the collection", {
|
||||
userName: user.name,
|
||||
}),
|
||||
{ type: "success" }
|
||||
);
|
||||
} catch (err) {
|
||||
this.props.toasts.showToast(t("Could not add user"), { type: "error" });
|
||||
showToast(t("Could not add user"), { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { users, collection, auth, t } = this.props;
|
||||
const { user, team } = auth;
|
||||
if (!user || !team) return null;
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
return (
|
||||
<Flex column>
|
||||
{can.inviteUser && (
|
||||
<HelpText>
|
||||
{t("Need to add someone who’s not yet on the team yet?")}{" "}
|
||||
<ButtonLink onClick={this.handleInviteModalOpen}>
|
||||
<ButtonLink onClick={() => setInviteModalOpen(true)}>
|
||||
{t("Invite people to {{ teamName }}", { teamName: team.name })}
|
||||
</ButtonLink>
|
||||
.
|
||||
</HelpText>
|
||||
)}
|
||||
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={`${t("Search by name")}…`}
|
||||
value={this.query}
|
||||
onChange={this.handleFilter}
|
||||
label={t("Search people")}
|
||||
autoFocus
|
||||
labelHidden
|
||||
flex
|
||||
/>
|
||||
<PaginatedList
|
||||
empty={
|
||||
this.query ? (
|
||||
<Empty>{t("No people matching your search")}</Empty>
|
||||
) : (
|
||||
<Empty>{t("No people left to add")}</Empty>
|
||||
)
|
||||
}
|
||||
items={users.notInCollection(collection.id, this.query)}
|
||||
fetch={this.query ? undefined : users.fetchPage}
|
||||
renderItem={(item) => (
|
||||
<MemberListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
onAdd={() => this.handleAddUser(item)}
|
||||
canEdit
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Modal
|
||||
title={t("Invite people")}
|
||||
onRequestClose={this.handleInviteModalClose}
|
||||
isOpen={this.inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={this.handleInviteModalClose} />
|
||||
</Modal>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={`${t("Search by name")}…`}
|
||||
value={query}
|
||||
onChange={handleFilter}
|
||||
label={t("Search people")}
|
||||
autoFocus
|
||||
labelHidden
|
||||
flex
|
||||
/>
|
||||
<PaginatedList
|
||||
empty={
|
||||
query ? (
|
||||
<Empty>{t("No people matching your search")}</Empty>
|
||||
) : (
|
||||
<Empty>{t("No people left to add")}</Empty>
|
||||
)
|
||||
}
|
||||
items={users.notInCollection(collection.id, query)}
|
||||
fetch={query ? undefined : users.fetchPage}
|
||||
renderItem={(item) => (
|
||||
<MemberListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
onAdd={() => handleAddUser(item)}
|
||||
canEdit
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Modal
|
||||
title={t("Invite people")}
|
||||
onRequestClose={() => setInviteModalOpen(false)}
|
||||
isOpen={inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={() => setInviteModalOpen(false)} />
|
||||
</Modal>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default withTranslation()<AddPeopleToCollection>(
|
||||
inject("auth", "users", "memberships", "toasts")(AddPeopleToCollection)
|
||||
);
|
||||
export default observer(AddPeopleToCollection);
|
||||
|
||||
@@ -5,7 +5,7 @@ import styled from "styled-components";
|
||||
import CollectionGroupMembership from "models/CollectionGroupMembership";
|
||||
import Group from "models/Group";
|
||||
import GroupListItem from "components/GroupListItem";
|
||||
import InputSelect from "components/InputSelect";
|
||||
import InputSelect, { type Props as SelectProps } from "components/InputSelect";
|
||||
import CollectionGroupMemberMenu from "menus/CollectionGroupMemberMenu";
|
||||
|
||||
type Props = {|
|
||||
@@ -47,8 +47,10 @@ const CollectionGroupMemberListItem = ({
|
||||
? collectionGroupMembership.permission
|
||||
: undefined
|
||||
}
|
||||
onChange={(ev) => onUpdate(ev.target.value)}
|
||||
onChange={onUpdate}
|
||||
ariaLabel={t("Permissions")}
|
||||
labelHidden
|
||||
nude
|
||||
/>
|
||||
<Spacer />
|
||||
<CollectionGroupMemberMenu
|
||||
@@ -65,7 +67,7 @@ const Spacer = styled.div`
|
||||
width: 8px;
|
||||
`;
|
||||
|
||||
const Select = styled(InputSelect)`
|
||||
const Select = (styled(InputSelect)`
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
border-color: transparent;
|
||||
@@ -73,6 +75,6 @@ const Select = styled(InputSelect)`
|
||||
select {
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
`: React.ComponentType<SelectProps>);
|
||||
|
||||
export default CollectionGroupMemberListItem;
|
||||
|
||||
@@ -8,7 +8,7 @@ import Avatar from "components/Avatar";
|
||||
import Badge from "components/Badge";
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
import InputSelect from "components/InputSelect";
|
||||
import InputSelect, { type Props as SelectProps } from "components/InputSelect";
|
||||
import ListItem from "components/List/Item";
|
||||
import Time from "components/Time";
|
||||
import MemberMenu from "menus/MemberMenu";
|
||||
@@ -64,9 +64,11 @@ const MemberListItem = ({
|
||||
label={t("Permissions")}
|
||||
options={PERMISSIONS}
|
||||
value={membership ? membership.permission : undefined}
|
||||
onChange={(ev) => onUpdate(ev.target.value)}
|
||||
onChange={onUpdate}
|
||||
disabled={!canEdit}
|
||||
ariaLabel={t("Permissions")}
|
||||
labelHidden
|
||||
nude
|
||||
/>
|
||||
)}
|
||||
{canEdit && (
|
||||
@@ -90,7 +92,7 @@ const Spacer = styled.div`
|
||||
width: 8px;
|
||||
`;
|
||||
|
||||
const Select = styled(InputSelect)`
|
||||
const Select = (styled(InputSelect)`
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
border-color: transparent;
|
||||
@@ -98,6 +100,6 @@ const Select = styled(InputSelect)`
|
||||
select {
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
`: React.ComponentType<SelectProps>);
|
||||
|
||||
export default MemberListItem;
|
||||
|
||||
@@ -138,9 +138,9 @@ function CollectionPermissions({ collection }: Props) {
|
||||
);
|
||||
|
||||
const handleChangePermission = React.useCallback(
|
||||
async (ev) => {
|
||||
async (permission: string) => {
|
||||
try {
|
||||
await collection.save({ permission: ev.target.value });
|
||||
await collection.save({ permission });
|
||||
showToast(t("Default access permissions were updated"), {
|
||||
type: "success",
|
||||
});
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// @flow
|
||||
import { inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import DataLoader from "./components/DataLoader";
|
||||
|
||||
class KeyedDocument extends React.Component<*> {
|
||||
componentWillUnmount() {
|
||||
this.props.ui.clearActiveDocument();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { documentSlug, revisionId } = this.props.match.params;
|
||||
|
||||
// the urlId portion of the url does not include the slugified title
|
||||
// we only want to force a re-mount of the document component when the
|
||||
// document changes, not when the title does so only this portion is used
|
||||
// for the key.
|
||||
const urlParts = documentSlug ? documentSlug.split("-") : [];
|
||||
const urlId = urlParts.length ? urlParts[urlParts.length - 1] : undefined;
|
||||
|
||||
return <DataLoader key={[urlId, revisionId].join("/")} {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default inject("ui")(KeyedDocument);
|
||||
@@ -0,0 +1,64 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { type Match } from "react-router-dom";
|
||||
import { useTheme } from "styled-components";
|
||||
import Error404 from "scenes/Error404";
|
||||
import ErrorOffline from "scenes/ErrorOffline";
|
||||
import useStores from "../../hooks/useStores";
|
||||
import Document from "./components/Document";
|
||||
import Loading from "./components/Loading";
|
||||
import { type LocationWithState } from "types";
|
||||
import { OfflineError } from "utils/errors";
|
||||
|
||||
const EMPTY_OBJECT = {};
|
||||
|
||||
type Props = {|
|
||||
match: Match,
|
||||
location: LocationWithState,
|
||||
|};
|
||||
|
||||
export default function SharedDocumentScene(props: Props) {
|
||||
const theme = useTheme();
|
||||
const [response, setResponse] = React.useState();
|
||||
const [error, setError] = React.useState<?Error>();
|
||||
const { documents } = useStores();
|
||||
const { shareId, documentSlug } = props.match.params;
|
||||
|
||||
// ensure the wider page color always matches the theme
|
||||
React.useEffect(() => {
|
||||
window.document.body.style.background = theme.background;
|
||||
}, [theme]);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const response = await documents.fetch(documentSlug, {
|
||||
shareId,
|
||||
});
|
||||
setResponse(response);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, [documents, documentSlug, shareId]);
|
||||
|
||||
if (error) {
|
||||
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return <Loading location={props.location} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Document
|
||||
abilities={EMPTY_OBJECT}
|
||||
document={response.document}
|
||||
sharedTree={response.sharedTree}
|
||||
location={props.location}
|
||||
shareId={shareId}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -18,16 +18,15 @@ import Document from "models/Document";
|
||||
import Revision from "models/Revision";
|
||||
import Error404 from "scenes/Error404";
|
||||
import ErrorOffline from "scenes/ErrorOffline";
|
||||
import DocumentComponent from "./Document";
|
||||
import HideSidebar from "./HideSidebar";
|
||||
import Loading from "./Loading";
|
||||
import SocketPresence from "./SocketPresence";
|
||||
import { type LocationWithState, type NavigationNode } from "types";
|
||||
import { NotFoundError, OfflineError } from "utils/errors";
|
||||
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
|
||||
import { isInternalUrl } from "utils/urls";
|
||||
type Props = {|
|
||||
match: Match,
|
||||
auth: AuthStore,
|
||||
location: LocationWithState,
|
||||
shares: SharesStore,
|
||||
documents: DocumentsStore,
|
||||
@@ -36,6 +35,7 @@ type Props = {|
|
||||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
history: RouterHistory,
|
||||
children: (any) => React.Node,
|
||||
|};
|
||||
|
||||
const sharedTreeCache = {};
|
||||
@@ -223,7 +223,7 @@ class DataLoader extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { location, policies, ui } = this.props;
|
||||
const { location, policies, auth, ui } = this.props;
|
||||
|
||||
if (this.error) {
|
||||
return this.error instanceof OfflineError ? (
|
||||
@@ -233,10 +233,11 @@ class DataLoader extends React.Component<Props> {
|
||||
);
|
||||
}
|
||||
|
||||
const team = auth.team;
|
||||
const document = this.document;
|
||||
const revision = this.revision;
|
||||
|
||||
if (!document) {
|
||||
if (!document || !team) {
|
||||
return (
|
||||
<>
|
||||
<Loading location={location} />
|
||||
@@ -247,20 +248,28 @@ class DataLoader extends React.Component<Props> {
|
||||
|
||||
const abilities = policies.abilities(document.id);
|
||||
|
||||
// We do not want to remount the document when changing from view->edit
|
||||
// on the multiplayer flag as the doc is guaranteed to be upto date.
|
||||
const key = team.collaborativeEditing
|
||||
? ""
|
||||
: this.isEditing
|
||||
? "editing"
|
||||
: "read-only";
|
||||
|
||||
return (
|
||||
<SocketPresence documentId={document.id} isEditing={this.isEditing}>
|
||||
<React.Fragment key={key}>
|
||||
{this.isEditing && <HideSidebar ui={ui} />}
|
||||
<DocumentComponent
|
||||
document={document}
|
||||
revision={revision}
|
||||
abilities={abilities}
|
||||
location={location}
|
||||
readOnly={!this.isEditing || !abilities.update || document.isArchived}
|
||||
onSearchLink={this.onSearchLink}
|
||||
onCreateLink={this.onCreateLink}
|
||||
sharedTree={this.sharedTree}
|
||||
/>
|
||||
</SocketPresence>
|
||||
{this.props.children({
|
||||
document,
|
||||
revision,
|
||||
abilities,
|
||||
isEditing: this.isEditing,
|
||||
readOnly: !this.isEditing || !abilities.update || document.isArchived,
|
||||
onSearchLink: this.onSearchLink,
|
||||
onCreateLink: this.onCreateLink,
|
||||
sharedTree: this.sharedTree,
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// @flow
|
||||
import { debounce } from "lodash";
|
||||
import { observable } from "mobx";
|
||||
import { action, observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { InputIcon } from "outline-icons";
|
||||
import { AllSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { type TFunction, Trans, withTranslation } from "react-i18next";
|
||||
import keydown from "react-keydown";
|
||||
@@ -18,6 +19,7 @@ import Document from "models/Document";
|
||||
import Revision from "models/Revision";
|
||||
import DocumentMove from "scenes/DocumentMove";
|
||||
import Branding from "components/Branding";
|
||||
import ConnectionStatus from "components/ConnectionStatus";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import Flex from "components/Flex";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
@@ -88,13 +90,21 @@ class DocumentScene extends React.Component<Props> {
|
||||
this.updateIsDirty();
|
||||
}
|
||||
|
||||
if (this.props.readOnly) {
|
||||
if (this.props.readOnly || auth.team?.collaborativeEditing) {
|
||||
this.lastRevision = document.revision;
|
||||
}
|
||||
|
||||
if (this.props.readOnly) {
|
||||
if (document.title !== this.title) {
|
||||
this.title = document.title;
|
||||
}
|
||||
} else if (prevProps.document.revision !== this.lastRevision) {
|
||||
}
|
||||
|
||||
if (
|
||||
!this.props.readOnly &&
|
||||
!auth.team?.collaborativeEditing &&
|
||||
prevProps.document.revision !== this.lastRevision
|
||||
) {
|
||||
if (auth.user && document.updatedBy.id !== auth.user.id) {
|
||||
this.props.toasts.showToast(
|
||||
t(`Document updated by {{userName}}`, {
|
||||
@@ -113,15 +123,31 @@ class DocumentScene extends React.Component<Props> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.injectTemplate) {
|
||||
document.injectTemplate = false;
|
||||
this.title = document.title;
|
||||
this.isDirty = true;
|
||||
this.updateIsDirty();
|
||||
}
|
||||
}
|
||||
|
||||
onSelectTemplate = (template: Document) => {
|
||||
this.title = template.title;
|
||||
this.isDirty = true;
|
||||
|
||||
const editorRef = this.editor.current;
|
||||
if (!editorRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { view, parser } = editorRef;
|
||||
view.dispatch(
|
||||
view.state.tr
|
||||
.setSelection(new AllSelection(view.state.doc))
|
||||
.replaceSelectionWith(parser.parse(template.text))
|
||||
);
|
||||
|
||||
this.props.document.templateId = template.id;
|
||||
this.props.document.title = template.title;
|
||||
this.props.document.text = template.text;
|
||||
|
||||
this.updateIsDirty();
|
||||
};
|
||||
|
||||
@keydown("m")
|
||||
goToMove(ev) {
|
||||
if (!this.props.readOnly) return;
|
||||
@@ -197,7 +223,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
autosave?: boolean,
|
||||
} = {}
|
||||
) => {
|
||||
const { document } = this.props;
|
||||
const { document, auth } = this.props;
|
||||
|
||||
// prevent saves when we are already saving
|
||||
if (document.isSaving) return;
|
||||
@@ -219,18 +245,29 @@ class DocumentScene extends React.Component<Props> {
|
||||
|
||||
document.title = title;
|
||||
document.text = text;
|
||||
document.tasks = getTasks(document.text);
|
||||
|
||||
let isNew = !document.id;
|
||||
this.isSaving = true;
|
||||
this.isPublishing = !!options.publish;
|
||||
|
||||
document.tasks = getTasks(document.text);
|
||||
|
||||
try {
|
||||
const savedDocument = await document.save({
|
||||
...options,
|
||||
lastRevision: this.lastRevision,
|
||||
});
|
||||
let savedDocument = document;
|
||||
if (auth.team?.collaborativeEditing) {
|
||||
// update does not send "text" field to the API, this is a workaround
|
||||
// while the multiplayer editor is toggleable. Once it's finalized
|
||||
// this can be cleaned up to single code path
|
||||
savedDocument = await document.update({
|
||||
...options,
|
||||
lastRevision: this.lastRevision,
|
||||
});
|
||||
} else {
|
||||
savedDocument = await document.save({
|
||||
...options,
|
||||
lastRevision: this.lastRevision,
|
||||
});
|
||||
}
|
||||
|
||||
this.isDirty = false;
|
||||
this.lastRevision = savedDocument.revision;
|
||||
|
||||
@@ -275,8 +312,21 @@ class DocumentScene extends React.Component<Props> {
|
||||
};
|
||||
|
||||
onChange = (getEditorText) => {
|
||||
const { document, auth } = this.props;
|
||||
|
||||
this.getEditorText = getEditorText;
|
||||
|
||||
// If the multiplayer editor is enabled then we still want to keep the local
|
||||
// text value in sync as it is used as a cache.
|
||||
if (auth.team?.collaborativeEditing) {
|
||||
action(() => {
|
||||
document.text = this.getEditorText();
|
||||
document.tasks = getTasks(document.text);
|
||||
})();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// document change while read only is presumed to be a checkbox edit,
|
||||
// in that case we don't delay in saving for a better user experience.
|
||||
if (this.props.readOnly) {
|
||||
@@ -314,7 +364,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
const isShare = !!shareId;
|
||||
|
||||
const value = revision ? revision.text : document.text;
|
||||
const injectTemplate = document.injectTemplate;
|
||||
const disableEmbeds =
|
||||
(team && team.documentEmbeds === false) || document.embedsDisabled;
|
||||
|
||||
@@ -323,6 +372,12 @@ class DocumentScene extends React.Component<Props> {
|
||||
: [];
|
||||
const showContents = ui.tocVisible && readOnly;
|
||||
|
||||
const collaborativeEditing =
|
||||
team?.collaborativeEditing &&
|
||||
!document.isArchived &&
|
||||
!document.isDeleted &&
|
||||
!revision;
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Background
|
||||
@@ -332,7 +387,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
auto
|
||||
>
|
||||
<Route
|
||||
path={`${match.url}/move`}
|
||||
path={`${document.url}/move`}
|
||||
component={() => (
|
||||
<Modal
|
||||
title={`Move ${document.noun}`}
|
||||
@@ -356,7 +411,11 @@ class DocumentScene extends React.Component<Props> {
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Prompt
|
||||
when={this.isDirty && !this.isUploading}
|
||||
when={
|
||||
this.isDirty &&
|
||||
!this.isUploading &&
|
||||
!team?.collaborativeEditing
|
||||
}
|
||||
message={t(
|
||||
`You have unsaved changes.\nAre you sure you want to discard them?`
|
||||
)}
|
||||
@@ -383,6 +442,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
savingIsDisabled={document.isSaving || this.isEmpty}
|
||||
sharedTree={this.props.sharedTree}
|
||||
goBack={this.goBack}
|
||||
onSelectTemplate={this.onSelectTemplate}
|
||||
onSave={this.onSave}
|
||||
headings={headings}
|
||||
/>
|
||||
@@ -443,11 +503,12 @@ class DocumentScene extends React.Component<Props> {
|
||||
{showContents && <Contents headings={headings} />}
|
||||
<Editor
|
||||
id={document.id}
|
||||
key={disableEmbeds ? "disabled" : "enabled"}
|
||||
innerRef={this.editor}
|
||||
multiplayer={collaborativeEditing}
|
||||
shareId={shareId}
|
||||
isDraft={document.isDraft}
|
||||
template={document.isTemplate}
|
||||
key={[injectTemplate, disableEmbeds].join("-")}
|
||||
title={revision ? revision.title : this.title}
|
||||
document={document}
|
||||
value={readOnly ? value : undefined}
|
||||
@@ -492,7 +553,12 @@ class DocumentScene extends React.Component<Props> {
|
||||
{isShare && !isCustomDomain() && (
|
||||
<Branding href="//www.getoutline.com?ref=sharelink" />
|
||||
)}
|
||||
{!isShare && <KeyboardShortcutsButton />}
|
||||
{!isShare && (
|
||||
<>
|
||||
<KeyboardShortcutsButton />
|
||||
<ConnectionStatus />
|
||||
</>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import Editor, { type Props as EditorProps } from "components/Editor";
|
||||
import Flex from "components/Flex";
|
||||
import HoverPreview from "components/HoverPreview";
|
||||
import Star, { AnimatedStar } from "components/Star";
|
||||
import MultiplayerEditor from "./MultiplayerEditor";
|
||||
import { isModKey } from "utils/keyboard";
|
||||
import { documentHistoryUrl } from "utils/routeHelpers";
|
||||
|
||||
@@ -27,6 +28,7 @@ type Props = {|
|
||||
document: Document,
|
||||
isDraft: boolean,
|
||||
shareId: ?string,
|
||||
multiplayer?: boolean,
|
||||
onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
|
||||
innerRef: { current: any },
|
||||
children: React.Node,
|
||||
@@ -107,10 +109,12 @@ class DocumentEditor extends React.Component<Props> {
|
||||
innerRef,
|
||||
children,
|
||||
policies,
|
||||
multiplayer,
|
||||
t,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
|
||||
const can = policies.abilities(document.id);
|
||||
const { emoji } = parseTitle(title);
|
||||
const startsWithEmojiAndSpace = !!(emoji && title.startsWith(`${emoji} `));
|
||||
@@ -162,7 +166,7 @@ class DocumentEditor extends React.Component<Props> {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Editor
|
||||
<EditorComponent
|
||||
ref={innerRef}
|
||||
autoFocus={!!title && !this.props.defaultValue}
|
||||
placeholder={t("…the rest is up to you")}
|
||||
|
||||
@@ -20,6 +20,7 @@ import Header from "components/Header";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import PublicBreadcrumb from "./PublicBreadcrumb";
|
||||
import ShareButton from "./ShareButton";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useMobile from "hooks/useMobile";
|
||||
import useStores from "hooks/useStores";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
@@ -41,6 +42,7 @@ type Props = {|
|
||||
isPublishing: boolean,
|
||||
publishingIsDisabled: boolean,
|
||||
savingIsDisabled: boolean,
|
||||
onSelectTemplate: (template: Document) => void,
|
||||
onDiscard: () => void,
|
||||
onSave: ({
|
||||
done?: boolean,
|
||||
@@ -61,11 +63,13 @@ function DocumentHeader({
|
||||
savingIsDisabled,
|
||||
publishingIsDisabled,
|
||||
sharedTree,
|
||||
onSelectTemplate,
|
||||
onSave,
|
||||
headings,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { auth, ui, policies } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const { ui, policies } = useStores();
|
||||
const isMobile = useMobile();
|
||||
|
||||
const handleSave = React.useCallback(() => {
|
||||
@@ -79,7 +83,7 @@ function DocumentHeader({
|
||||
const isNew = document.isNewDocument;
|
||||
const isTemplate = document.isTemplate;
|
||||
const can = policies.abilities(document.id);
|
||||
const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
|
||||
const canToggleEmbeds = team?.documentEmbeds;
|
||||
const canEdit = can.update && !isEditing;
|
||||
|
||||
const toc = (
|
||||
@@ -160,14 +164,16 @@ function DocumentHeader({
|
||||
<TableOfContentsMenu headings={headings} />
|
||||
</TocWrapper>
|
||||
)}
|
||||
{!isPublishing && isSaving && <Status>{t("Saving")}…</Status>}
|
||||
<Collaborators
|
||||
document={document}
|
||||
currentUserId={auth.user ? auth.user.id : undefined}
|
||||
/>
|
||||
{!isPublishing && isSaving && !team.collaborativeEditing && (
|
||||
<Status>{t("Saving")}…</Status>
|
||||
)}
|
||||
<Collaborators document={document} />
|
||||
{isEditing && !isTemplate && isNew && (
|
||||
<Action>
|
||||
<TemplatesMenu document={document} />
|
||||
<TemplatesMenu
|
||||
document={document}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
{!isEditing && (!isMobile || !isTemplate) && (
|
||||
|
||||
@@ -27,12 +27,7 @@ function KeyboardShortcutsButton() {
|
||||
>
|
||||
<KeyboardShortcuts />
|
||||
</Guide>
|
||||
<Tooltip
|
||||
tooltip={t("Keyboard shortcuts")}
|
||||
shortcut="?"
|
||||
placement="left"
|
||||
delay={500}
|
||||
>
|
||||
<Tooltip tooltip={t("Keyboard shortcuts")} shortcut="?" delay={500}>
|
||||
<Button onClick={handleOpenKeyboardShortcuts}>
|
||||
<KeyboardIcon />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// @flow
|
||||
import { HocuspocusProvider, WebSocketStatus } from "@hocuspocus/provider";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
import Editor, { type Props as EditorProps } from "components/Editor";
|
||||
import env from "env";
|
||||
import useCurrentToken from "hooks/useCurrentToken";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import useIdle from "hooks/useIdle";
|
||||
import usePageVisibility from "hooks/usePageVisibility";
|
||||
import useStores from "hooks/useStores";
|
||||
import useToasts from "hooks/useToasts";
|
||||
import MultiplayerExtension from "multiplayer/MultiplayerExtension";
|
||||
import { homeUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {|
|
||||
...EditorProps,
|
||||
id: string,
|
||||
|};
|
||||
|
||||
function MultiplayerEditor({ ...props }: Props, ref: any) {
|
||||
const documentId = props.id;
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
const { presence, ui } = useStores();
|
||||
const token = useCurrentToken();
|
||||
const [remoteProvider, setRemoteProvider] = React.useState();
|
||||
const [isLocalSynced, setLocalSynced] = React.useState(false);
|
||||
const [isRemoteSynced, setRemoteSynced] = React.useState(false);
|
||||
const [ydoc] = React.useState(() => new Y.Doc());
|
||||
const { showToast } = useToasts();
|
||||
const isIdle = useIdle();
|
||||
const isVisible = usePageVisibility();
|
||||
|
||||
// Provider initialization must be within useLayoutEffect rather than useState
|
||||
// or useMemo as both of these are ran twice in React StrictMode resulting in
|
||||
// an orphaned websocket connection.
|
||||
// see: https://github.com/facebook/react/issues/20090#issuecomment-715926549
|
||||
React.useLayoutEffect(() => {
|
||||
const debug = env.ENVIRONMENT === "development";
|
||||
const name = `document.${documentId}`;
|
||||
|
||||
const localProvider = new IndexeddbPersistence(name, ydoc);
|
||||
const provider = new HocuspocusProvider({
|
||||
url: `${env.COLLABORATION_URL}/collaboration`,
|
||||
debug,
|
||||
name,
|
||||
document: ydoc,
|
||||
token,
|
||||
maxReconnectTimeout: 10000,
|
||||
});
|
||||
|
||||
provider.on("authenticationFailed", () => {
|
||||
showToast(
|
||||
t(
|
||||
"Sorry, it looks like you don’t have permission to access the document"
|
||||
)
|
||||
);
|
||||
|
||||
history.replace(homeUrl());
|
||||
});
|
||||
|
||||
provider.on("awarenessChange", ({ states }) => {
|
||||
states.forEach(({ user }) => {
|
||||
if (user) {
|
||||
// could know if the user is editing here using `state.cursor` but it
|
||||
// feels distracting in the UI, once multiplayer is on for everyone we
|
||||
// can stop diffentiating
|
||||
presence.touch(documentId, user.id, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
localProvider.on("synced", () =>
|
||||
// only set local storage to "synced" if it's loaded a non-empty doc
|
||||
setLocalSynced(!!ydoc.get("default")._start)
|
||||
);
|
||||
provider.on("synced", () => setRemoteSynced(true));
|
||||
|
||||
if (debug) {
|
||||
provider.on("status", (ev) => console.log("status", ev.status));
|
||||
provider.on("message", (ev) => console.log("incoming", ev.message));
|
||||
provider.on("outgoingMessage", (ev) =>
|
||||
console.log("outgoing", ev.message)
|
||||
);
|
||||
localProvider.on("synced", (ev) => console.log("local synced"));
|
||||
}
|
||||
|
||||
provider.on("status", (ev) => ui.setMultiplayerStatus(ev.status));
|
||||
|
||||
setRemoteProvider(provider);
|
||||
|
||||
return () => {
|
||||
provider?.destroy();
|
||||
localProvider?.destroy();
|
||||
|
||||
setRemoteProvider(null);
|
||||
|
||||
ui.setMultiplayerStatus(undefined);
|
||||
};
|
||||
}, [history, showToast, t, documentId, ui, presence, token, ydoc]);
|
||||
|
||||
const user = React.useMemo(() => {
|
||||
return {
|
||||
id: currentUser.id,
|
||||
name: currentUser.name,
|
||||
color: currentUser.color,
|
||||
};
|
||||
}, [currentUser.id, currentUser.color, currentUser.name]);
|
||||
|
||||
const extensions = React.useMemo(() => {
|
||||
if (!remoteProvider) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
new MultiplayerExtension({
|
||||
user,
|
||||
provider: remoteProvider,
|
||||
document: ydoc,
|
||||
}),
|
||||
];
|
||||
}, [remoteProvider, user, ydoc]);
|
||||
|
||||
// Disconnect the realtime connection while idle. `isIdle` also checks for
|
||||
// page visibility and will immediately disconnect when a tab is hidden.
|
||||
React.useEffect(() => {
|
||||
if (!remoteProvider) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isIdle &&
|
||||
!isVisible &&
|
||||
remoteProvider.status === WebSocketStatus.Connected
|
||||
) {
|
||||
remoteProvider.disconnect();
|
||||
}
|
||||
if (
|
||||
(!isIdle || isVisible) &&
|
||||
remoteProvider.status === WebSocketStatus.Disconnected
|
||||
) {
|
||||
remoteProvider.connect();
|
||||
}
|
||||
}, [remoteProvider, isIdle, isVisible]);
|
||||
|
||||
if (!extensions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// while the collaborative document is loading, we render a version of the
|
||||
// document from the last text cache in read-only mode if we have it.
|
||||
const showCache = !isLocalSynced && !isRemoteSynced;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showCache && (
|
||||
<Editor defaultValue={props.defaultValue} readOnly ref={ref} />
|
||||
)}
|
||||
<Editor
|
||||
{...props}
|
||||
value={undefined}
|
||||
defaultValue={undefined}
|
||||
extensions={extensions}
|
||||
ref={showCache ? undefined : ref}
|
||||
style={showCache ? { display: "none" } : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.forwardRef<any, typeof MultiplayerEditor>(
|
||||
MultiplayerEditor
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { withRouter, type Location } from "react-router-dom";
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
@@ -42,12 +43,12 @@ class References extends React.Component<Props> {
|
||||
<Tabs>
|
||||
{showNestedDocuments && (
|
||||
<Tab to="#children" isActive={() => !isBacklinksTab}>
|
||||
Nested documents
|
||||
<Trans>Nested documents</Trans>
|
||||
</Tab>
|
||||
)}
|
||||
{showBacklinks && (
|
||||
<Tab to="#backlinks" isActive={() => isBacklinksTab}>
|
||||
Referenced by
|
||||
<Trans>Referenced by</Trans>
|
||||
</Tab>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
@@ -1,3 +1,61 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { type Match } from "react-router-dom";
|
||||
import DataLoader from "./components/DataLoader";
|
||||
export default DataLoader;
|
||||
import Document from "./components/Document";
|
||||
import SocketPresence from "./components/SocketPresence";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import useStores from "hooks/useStores";
|
||||
import { type LocationWithState } from "types";
|
||||
|
||||
type Props = {|
|
||||
location: LocationWithState,
|
||||
match: Match,
|
||||
|};
|
||||
|
||||
export default function DocumentScene(props: Props) {
|
||||
const { ui } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => ui.clearActiveDocument();
|
||||
}, [ui]);
|
||||
|
||||
const { documentSlug, revisionId } = props.match.params;
|
||||
|
||||
// the urlId portion of the url does not include the slugified title
|
||||
// we only want to force a re-mount of the document component when the
|
||||
// document changes, not when the title does so only this portion is used
|
||||
// for the key.
|
||||
const urlParts = documentSlug ? documentSlug.split("-") : [];
|
||||
const urlId = urlParts.length ? urlParts[urlParts.length - 1] : undefined;
|
||||
const key = [urlId, revisionId].join("/");
|
||||
const isMultiplayer = team.collaborativeEditing;
|
||||
|
||||
return (
|
||||
<DataLoader key={key} match={props.match}>
|
||||
{({ document, isEditing, ...rest }) => {
|
||||
const isActive =
|
||||
!document.isArchived && !document.isDeleted && !revisionId;
|
||||
|
||||
// TODO: Remove once multiplayer is 100% rollout, SocketPresence will
|
||||
// no longer be required
|
||||
if (isActive && !isMultiplayer) {
|
||||
return (
|
||||
<SocketPresence
|
||||
documentId={document.id}
|
||||
userId={user.id}
|
||||
isEditing={isEditing}
|
||||
>
|
||||
<Document document={document} match={props.match} {...rest} />
|
||||
</SocketPresence>
|
||||
);
|
||||
}
|
||||
|
||||
return <Document document={document} match={props.match} {...rest} />;
|
||||
}}
|
||||
</DataLoader>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
|
||||
</HelpText>
|
||||
)}
|
||||
<Button type="submit" danger>
|
||||
{isDeleting ? `${t("Deleting")}…` : t("I’m sure – Delete")}
|
||||
{isDeleting ? `${t("Deleting")}…` : t("I’m sure – Delete")}
|
||||
</Button>
|
||||
|
||||
{canArchive && (
|
||||
|
||||
@@ -51,7 +51,7 @@ function DocumentPermanentDelete({ document, onSubmit }: Props) {
|
||||
/>
|
||||
</HelpText>
|
||||
<Button type="submit" danger>
|
||||
{isDeleting ? `${t("Deleting")}…` : t("I’m sure – Delete")}
|
||||
{isDeleting ? `${t("Deleting")}…` : t("I’m sure – Delete")}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import Collection from "models/Collection";
|
||||
import Document from "models/Document";
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import useStores from "hooks/useStores";
|
||||
import useToasts from "hooks/useToasts";
|
||||
import { type NavigationNode } from "types";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
item: {|
|
||||
active: ?boolean,
|
||||
children: Array<NavigationNode>,
|
||||
collectionId: string,
|
||||
depth: number,
|
||||
id: string,
|
||||
title: string,
|
||||
url: string,
|
||||
|},
|
||||
collection: Collection,
|
||||
onCancel: () => void,
|
||||
onSubmit: () => void,
|
||||
|};
|
||||
|
||||
function DocumentReparent({
|
||||
document,
|
||||
collection,
|
||||
item,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
const [isSaving, setIsSaving] = useState();
|
||||
const { showToast } = useToasts();
|
||||
const { documents, collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const prevCollection = collections.get(item.collectionId);
|
||||
|
||||
const accessMapping = {
|
||||
read_write: t("view and edit access"),
|
||||
read: t("view only access"),
|
||||
null: t("no access"),
|
||||
};
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await documents.move(item.id, collection.id);
|
||||
showToast(t("Document moved"), {
|
||||
type: "info",
|
||||
});
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[documents, item.id, collection.id, showToast, t, onSubmit]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="Heads up – moving the document <em>{{ title }}</em> to the <em>{{ newCollectionName }}</em> collection will grant all team members <em>{{ newPermission }}</em>, they currently have {{ prevPermission }}."
|
||||
values={{
|
||||
title: item.title,
|
||||
prevCollectionName: prevCollection?.name,
|
||||
newCollectionName: collection.name,
|
||||
prevPermission:
|
||||
accessMapping[prevCollection?.permission || "null"],
|
||||
newPermission: accessMapping[collection.permission || "null"],
|
||||
}}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
</HelpText>
|
||||
<Button type="submit">
|
||||
{isSaving ? `${t("Moving")}…` : t("Move document")}
|
||||
</Button>{" "}
|
||||
<Button type="button" onClick={onCancel} neutral>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(DocumentReparent);
|
||||
@@ -47,7 +47,7 @@ function GroupDelete({ group, onSubmit }: Props) {
|
||||
/>
|
||||
</HelpText>
|
||||
<Button type="submit" danger>
|
||||
{isDeleting ? `${t("Deleting")}…` : t("I’m sure – Delete")}
|
||||
{isDeleting ? `${t("Deleting")}…` : t("I’m sure – Delete")}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
|
||||
+37
-2
@@ -2,11 +2,13 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Group from "models/Group";
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import Input from "components/Input";
|
||||
import Switch from "components/Switch";
|
||||
import useToasts from "hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
@@ -18,6 +20,7 @@ function GroupEdit({ group, onSubmit }: Props) {
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState(group.name);
|
||||
const [isPrivate, setIsPrivate] = React.useState(group.isPrivate);
|
||||
const [isSaving, setIsSaving] = React.useState();
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
@@ -26,7 +29,7 @@ function GroupEdit({ group, onSubmit }: Props) {
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await group.save({ name: name });
|
||||
await group.save({ name, isPrivate });
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
showToast(err.message, { type: "error" });
|
||||
@@ -34,7 +37,7 @@ function GroupEdit({ group, onSubmit }: Props) {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[group, onSubmit, showToast, name]
|
||||
[group, isPrivate, name, onSubmit, showToast]
|
||||
);
|
||||
|
||||
const handleNameChange = React.useCallback((ev: SyntheticInputEvent<*>) => {
|
||||
@@ -60,6 +63,21 @@ function GroupEdit({ group, onSubmit }: Props) {
|
||||
flex
|
||||
/>
|
||||
</Flex>
|
||||
<SwitchWrapper>
|
||||
<Switch
|
||||
id="isPrivate"
|
||||
label={t("Access to group")}
|
||||
onChange={() => setIsPrivate((prev) => !prev)}
|
||||
checked={!isPrivate}
|
||||
/>
|
||||
<SwitchLabel>
|
||||
<SwitchText>
|
||||
{isPrivate
|
||||
? t("Only members present in the group know about the group")
|
||||
: t("Everyone in the team can view the group")}
|
||||
</SwitchText>
|
||||
</SwitchLabel>
|
||||
</SwitchWrapper>
|
||||
|
||||
<Button type="submit" disabled={isSaving || !name}>
|
||||
{isSaving ? `${t("Saving")}…` : t("Save")}
|
||||
@@ -68,4 +86,21 @@ function GroupEdit({ group, onSubmit }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const SwitchWrapper = styled.div`
|
||||
margin: 20px 0;
|
||||
`;
|
||||
|
||||
const SwitchLabel = styled(Flex)`
|
||||
flex-align: center;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const SwitchText = styled(HelpText)`
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
`;
|
||||
|
||||
export default observer(GroupEdit);
|
||||
|
||||
+38
-1
@@ -2,6 +2,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Group from "models/Group";
|
||||
import GroupMembers from "scenes/GroupMembers";
|
||||
import Button from "components/Button";
|
||||
@@ -9,6 +10,7 @@ import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import Input from "components/Input";
|
||||
import Modal from "components/Modal";
|
||||
import Switch from "components/Switch";
|
||||
import useStores from "hooks/useStores";
|
||||
import useToasts from "hooks/useToasts";
|
||||
|
||||
@@ -22,6 +24,7 @@ function GroupNew({ onSubmit }: Props) {
|
||||
const { showToast } = useToasts();
|
||||
const [name, setName] = React.useState();
|
||||
const [isSaving, setIsSaving] = React.useState();
|
||||
const [isPrivate, setIsPrivate] = React.useState(true);
|
||||
const [group, setGroup] = React.useState();
|
||||
|
||||
const handleSubmit = async (ev: SyntheticEvent<>) => {
|
||||
@@ -29,7 +32,8 @@ function GroupNew({ onSubmit }: Props) {
|
||||
setIsSaving(true);
|
||||
const group = new Group(
|
||||
{
|
||||
name: name,
|
||||
name,
|
||||
isPrivate,
|
||||
},
|
||||
groups
|
||||
);
|
||||
@@ -72,6 +76,22 @@ function GroupNew({ onSubmit }: Props) {
|
||||
<Trans>You’ll be able to add people to the group next.</Trans>
|
||||
</HelpText>
|
||||
|
||||
<SwitchWrapper>
|
||||
<Switch
|
||||
id="isPrivate"
|
||||
label={t("Access to group")}
|
||||
onChange={() => setIsPrivate((prev) => !prev)}
|
||||
checked={!isPrivate}
|
||||
/>
|
||||
<SwitchLabel>
|
||||
<SwitchText>
|
||||
{isPrivate
|
||||
? t("Only members present in the group know about the group")
|
||||
: t("Everyone in the team can view the group")}
|
||||
</SwitchText>
|
||||
</SwitchLabel>
|
||||
</SwitchWrapper>
|
||||
|
||||
<Button type="submit" disabled={isSaving || !name}>
|
||||
{isSaving ? `${t("Creating")}…` : t("Continue")}
|
||||
</Button>
|
||||
@@ -87,4 +107,21 @@ function GroupNew({ onSubmit }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const SwitchWrapper = styled.div`
|
||||
margin: 20px 0;
|
||||
`;
|
||||
|
||||
const SwitchLabel = styled(Flex)`
|
||||
flex-align: center;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const SwitchText = styled(HelpText)`
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
`;
|
||||
|
||||
export default observer(GroupNew);
|
||||
|
||||
@@ -112,10 +112,10 @@ function Invite({ onSubmit }: Props) {
|
||||
});
|
||||
}, [showToast, t]);
|
||||
|
||||
const handleRoleChange = React.useCallback((ev, index) => {
|
||||
const handleRoleChange = React.useCallback((role: Role, index: number) => {
|
||||
setInvites((prevInvites) => {
|
||||
const newInvites = [...prevInvites];
|
||||
newInvites[index]["role"] = ev.target.value;
|
||||
newInvites[index]["role"] = role;
|
||||
return newInvites;
|
||||
});
|
||||
}, []);
|
||||
@@ -194,7 +194,7 @@ function Invite({ onSubmit }: Props) {
|
||||
required={!!invite.email}
|
||||
/>
|
||||
<InputSelectRole
|
||||
onChange={(ev) => handleRoleChange(ev, index)}
|
||||
onChange={(role: any) => handleRoleChange((role: Role), index)}
|
||||
value={invite.role}
|
||||
labelHidden={index !== 0}
|
||||
short
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
|
||||
import { debounce, isEqual } from "lodash";
|
||||
import { isEqual } from "lodash";
|
||||
import { observable, action } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
@@ -31,7 +31,7 @@ import LoadingIndicator from "components/LoadingIndicator";
|
||||
import PageTitle from "components/PageTitle";
|
||||
import CollectionFilter from "./components/CollectionFilter";
|
||||
import DateFilter from "./components/DateFilter";
|
||||
import SearchField from "./components/SearchField";
|
||||
import SearchInput from "./components/SearchInput";
|
||||
import StatusFilter from "./components/StatusFilter";
|
||||
import UserFilter from "./components/UserFilter";
|
||||
import NewDocumentMenu from "menus/NewDocumentMenu";
|
||||
@@ -88,8 +88,9 @@ class Search extends React.Component<Props> {
|
||||
this.props.history.goBack();
|
||||
}
|
||||
|
||||
handleKeyDown = (ev: SyntheticKeyboardEvent<>) => {
|
||||
handleKeyDown = (ev: SyntheticKeyboardEvent<HTMLInputElement>) => {
|
||||
if (ev.key === "Enter") {
|
||||
this.updateLocation(ev.currentTarget.value);
|
||||
this.fetchResults();
|
||||
return;
|
||||
}
|
||||
@@ -117,7 +118,7 @@ class Search extends React.Component<Props> {
|
||||
// To prevent "no results" showing before debounce kicks in
|
||||
this.isLoading = true;
|
||||
|
||||
this.fetchResultsDebounced();
|
||||
this.fetchResults();
|
||||
};
|
||||
|
||||
handleTermChange = () => {
|
||||
@@ -127,9 +128,9 @@ class Search extends React.Component<Props> {
|
||||
this.allowLoadMore = true;
|
||||
|
||||
// To prevent "no results" showing before debounce kicks in
|
||||
this.isLoading = !!this.query;
|
||||
this.isLoading = true;
|
||||
|
||||
this.fetchResultsDebounced();
|
||||
this.fetchResults();
|
||||
};
|
||||
|
||||
handleFilterChange = (search: {
|
||||
@@ -241,15 +242,11 @@ class Search extends React.Component<Props> {
|
||||
}
|
||||
} else {
|
||||
this.pinToTop = false;
|
||||
this.isLoading = false;
|
||||
this.lastQuery = this.query;
|
||||
}
|
||||
};
|
||||
|
||||
fetchResultsDebounced = debounce(this.fetchResults, 500, {
|
||||
leading: false,
|
||||
trailing: true,
|
||||
});
|
||||
|
||||
updateLocation = (query: string) => {
|
||||
this.props.history.replace({
|
||||
pathname: searchUrl(query),
|
||||
@@ -283,10 +280,9 @@ class Search extends React.Component<Props> {
|
||||
</div>
|
||||
)}
|
||||
<ResultsWrapper pinToTop={this.pinToTop} column auto>
|
||||
<SearchField
|
||||
<SearchInput
|
||||
placeholder={`${t("Search")}…`}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onChange={this.updateLocation}
|
||||
defaultValue={this.query}
|
||||
/>
|
||||
{showShortcutTip && (
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
// @flow
|
||||
import { SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
import { type Theme } from "types";
|
||||
|
||||
type Props = {
|
||||
onChange: (string) => void,
|
||||
defaultValue?: string,
|
||||
placeholder?: string,
|
||||
theme: Theme,
|
||||
};
|
||||
|
||||
class SearchField extends React.Component<Props> {
|
||||
input: ?HTMLInputElement;
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props && this.input) {
|
||||
// ensure that focus is placed at end of input
|
||||
const len = (this.props.defaultValue || "").length;
|
||||
this.input.setSelectionRange(len, len);
|
||||
}
|
||||
}
|
||||
|
||||
handleChange = (ev: SyntheticEvent<HTMLInputElement>) => {
|
||||
this.props.onChange(ev.currentTarget.value ? ev.currentTarget.value : "");
|
||||
};
|
||||
|
||||
focusInput = (ev: SyntheticEvent<>) => {
|
||||
if (this.input) this.input.focus();
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Field align="center">
|
||||
<StyledIcon
|
||||
type="Search"
|
||||
size={46}
|
||||
color={this.props.theme.textTertiary}
|
||||
onClick={this.focusInput}
|
||||
/>
|
||||
<StyledInput
|
||||
{...this.props}
|
||||
ref={(ref) => (this.input = ref)}
|
||||
onChange={this.handleChange}
|
||||
spellCheck="false"
|
||||
placeholder={this.props.placeholder}
|
||||
type="search"
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Field = styled(Flex)`
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const StyledInput = styled.input`
|
||||
width: 100%;
|
||||
padding: 10px 10px 10px 60px;
|
||||
font-size: 36px;
|
||||
font-weight: 400;
|
||||
outline: none;
|
||||
border: 0;
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
border-radius: 4px;
|
||||
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
::-webkit-input-placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
:-moz-placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
::-moz-placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
:-ms-input-placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledIcon = styled(SearchIcon)`
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
`;
|
||||
|
||||
export default withTheme(SearchField);
|
||||
@@ -0,0 +1,86 @@
|
||||
// @flow
|
||||
import { SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
type Props = {
|
||||
defaultValue?: string,
|
||||
placeholder?: string,
|
||||
};
|
||||
|
||||
function SearchInput({ defaultValue, ...rest }: Props) {
|
||||
const theme = useTheme();
|
||||
const inputRef = React.useRef();
|
||||
|
||||
React.useEffect(() => {
|
||||
// ensure that focus is placed at end of input
|
||||
const len = (defaultValue || "").length;
|
||||
inputRef.current?.setSelectionRange(len, len);
|
||||
}, [defaultValue]);
|
||||
|
||||
const focusInput = React.useCallback((ev: SyntheticEvent<>) => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Wrapper align="center">
|
||||
<StyledIcon
|
||||
type="Search"
|
||||
size={46}
|
||||
color={theme.textTertiary}
|
||||
onClick={focusInput}
|
||||
/>
|
||||
<StyledInput
|
||||
{...rest}
|
||||
defaultValue={defaultValue}
|
||||
ref={inputRef}
|
||||
spellCheck="false"
|
||||
type="search"
|
||||
autoFocus
|
||||
/>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const StyledInput = styled.input`
|
||||
width: 100%;
|
||||
padding: 10px 10px 10px 60px;
|
||||
font-size: 36px;
|
||||
font-weight: 400;
|
||||
outline: none;
|
||||
border: 0;
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
border-radius: 4px;
|
||||
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
::-webkit-input-placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
:-moz-placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
::-moz-placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
:-ms-input-placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledIcon = styled(SearchIcon)`
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
`;
|
||||
|
||||
export default SearchInput;
|
||||
@@ -0,0 +1,70 @@
|
||||
// @flow
|
||||
import { debounce } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { BeakerIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import Checkbox from "components/Checkbox";
|
||||
import Heading from "components/Heading";
|
||||
import HelpText from "components/HelpText";
|
||||
import Scene from "components/Scene";
|
||||
import env from "env";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
import useToasts from "hooks/useToasts";
|
||||
|
||||
function Features() {
|
||||
const { auth } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToasts();
|
||||
const [data, setData] = useState({
|
||||
collaborativeEditing: team.collaborativeEditing,
|
||||
});
|
||||
|
||||
const showSuccessMessage = React.useCallback(
|
||||
debounce(() => {
|
||||
showToast(t("Settings saved"), { type: "success" });
|
||||
}, 250),
|
||||
[t, showToast]
|
||||
);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
async (ev: SyntheticInputEvent<*>) => {
|
||||
const newData = { ...data, [ev.target.name]: ev.target.checked };
|
||||
setData(newData);
|
||||
|
||||
await auth.updateTeam(newData);
|
||||
|
||||
showSuccessMessage();
|
||||
},
|
||||
[auth, data, showSuccessMessage]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene title={t("Features")} icon={<BeakerIcon color="currentColor" />}>
|
||||
<Heading>
|
||||
<Trans>Features</Trans>
|
||||
</Heading>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
Manage optional and beta features. Changing these settings will affect
|
||||
all team members.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
|
||||
{env.COLLABORATION_URL && (
|
||||
<Checkbox
|
||||
label={t("Collaborative editing")}
|
||||
name="collaborativeEditing"
|
||||
checked={data.collaborativeEditing}
|
||||
onChange={handleChange}
|
||||
note="When enabled multiple people can edit documents at the same time (Beta)"
|
||||
/>
|
||||
)}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Features);
|
||||
@@ -7,6 +7,7 @@ import { useTranslation, Trans } from "react-i18next";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import { parseOutlineExport } from "shared/utils/zip";
|
||||
import FileOperation from "models/FileOperation";
|
||||
import Button from "components/Button";
|
||||
import Heading from "components/Heading";
|
||||
import HelpText from "components/HelpText";
|
||||
@@ -102,6 +103,18 @@ function ImportExport() {
|
||||
[t, collections, showToast]
|
||||
);
|
||||
|
||||
const handleDelete = React.useCallback(
|
||||
async (fileOperation: FileOperation) => {
|
||||
try {
|
||||
await fileOperations.delete(fileOperation);
|
||||
showToast(t("Export deleted"));
|
||||
} catch (err) {
|
||||
showToast(err.message, { type: "error" });
|
||||
}
|
||||
},
|
||||
[fileOperations, showToast, t]
|
||||
);
|
||||
|
||||
const hasCollections = importDetails
|
||||
? !!importDetails.filter((detail) => detail.type === "collection").length
|
||||
: false;
|
||||
@@ -216,6 +229,7 @@ function ImportExport() {
|
||||
<FileOperationListItem
|
||||
key={item.id + item.state}
|
||||
fileOperation={item}
|
||||
handleDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
+107
-135
@@ -1,13 +1,10 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ProfileIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, withTranslation, type TFunction } from "react-i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { languageOptions } from "shared/i18n";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import ToastsStore from "stores/ToastsStore";
|
||||
import UserDelete from "scenes/UserDelete";
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
@@ -17,162 +14,137 @@ import Input, { LabelText } from "components/Input";
|
||||
import InputSelect from "components/InputSelect";
|
||||
import Scene from "components/Scene";
|
||||
import ImageUpload from "./components/ImageUpload";
|
||||
import useStores from "hooks/useStores";
|
||||
import useToasts from "hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
toasts: ToastsStore,
|
||||
t: TFunction,
|
||||
};
|
||||
const Profile = () => {
|
||||
const { auth } = useStores();
|
||||
const form = React.useRef<?HTMLFormElement>();
|
||||
const [name, setName] = React.useState<string>(auth.user?.name || "");
|
||||
const [avatarUrl, setAvatarUrl] = React.useState<?string>();
|
||||
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
|
||||
const [language, setLanguage] = React.useState(auth.user?.language);
|
||||
|
||||
@observer
|
||||
class Profile extends React.Component<Props> {
|
||||
timeout: TimeoutID;
|
||||
form: ?HTMLFormElement;
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@observable name: string;
|
||||
@observable avatarUrl: ?string;
|
||||
@observable showDeleteModal: boolean = false;
|
||||
@observable language: string;
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.auth.user) {
|
||||
this.name = this.props.auth.user.name;
|
||||
this.language = this.props.auth.user.language;
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
|
||||
handleSubmit = async (ev: SyntheticEvent<>) => {
|
||||
const { t } = this.props;
|
||||
const handleSubmit = async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
|
||||
await this.props.auth.updateUser({
|
||||
name: this.name,
|
||||
avatarUrl: this.avatarUrl,
|
||||
language: this.language,
|
||||
await auth.updateUser({
|
||||
name,
|
||||
avatarUrl,
|
||||
language,
|
||||
});
|
||||
|
||||
this.props.toasts.showToast(t("Profile saved"), { type: "success" });
|
||||
showToast(t("Profile saved"), { type: "success" });
|
||||
};
|
||||
|
||||
handleNameChange = (ev: SyntheticInputEvent<*>) => {
|
||||
this.name = ev.target.value;
|
||||
const handleNameChange = (ev: SyntheticInputEvent<*>) => {
|
||||
setName(ev.target.value);
|
||||
};
|
||||
|
||||
handleAvatarUpload = async (avatarUrl: string) => {
|
||||
const { t } = this.props;
|
||||
this.avatarUrl = avatarUrl;
|
||||
const handleAvatarUpload = async (avatarUrl: string) => {
|
||||
setAvatarUrl(avatarUrl);
|
||||
|
||||
await this.props.auth.updateUser({
|
||||
avatarUrl: this.avatarUrl,
|
||||
await auth.updateUser({
|
||||
avatarUrl,
|
||||
});
|
||||
this.props.toasts.showToast(t("Profile picture updated"), {
|
||||
showToast(t("Profile picture updated"), {
|
||||
type: "success",
|
||||
});
|
||||
};
|
||||
|
||||
handleAvatarError = (error: ?string) => {
|
||||
const { t } = this.props;
|
||||
this.props.toasts.showToast(
|
||||
error || t("Unable to upload new profile picture"),
|
||||
{ type: "error" }
|
||||
);
|
||||
const handleAvatarError = (error: ?string) => {
|
||||
showToast(error || t("Unable to upload new profile picture"), {
|
||||
type: "error",
|
||||
});
|
||||
};
|
||||
|
||||
handleLanguageChange = (ev: SyntheticInputEvent<*>) => {
|
||||
this.language = ev.target.value;
|
||||
const handleLanguageChange = (value: string) => {
|
||||
setLanguage(value);
|
||||
};
|
||||
|
||||
toggleDeleteAccount = () => {
|
||||
this.showDeleteModal = !this.showDeleteModal;
|
||||
const toggleDeleteAccount = () => {
|
||||
setShowDeleteModal((prev) => !prev);
|
||||
};
|
||||
|
||||
get isValid() {
|
||||
return this.form && this.form.checkValidity();
|
||||
}
|
||||
const isValid = form.current && form.current.checkValidity();
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
const { user, isSaving } = this.props.auth;
|
||||
if (!user) return null;
|
||||
const avatarUrl = this.avatarUrl || user.avatarUrl;
|
||||
const { user, isSaving } = auth;
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Scene title={t("Profile")} icon={<ProfileIcon color="currentColor" />}>
|
||||
<Heading>{t("Profile")}</Heading>
|
||||
<ProfilePicture column>
|
||||
<LabelText>{t("Photo")}</LabelText>
|
||||
<AvatarContainer>
|
||||
<ImageUpload
|
||||
onSuccess={this.handleAvatarUpload}
|
||||
onError={this.handleAvatarError}
|
||||
return (
|
||||
<Scene title={t("Profile")} icon={<ProfileIcon color="currentColor" />}>
|
||||
<Heading>{t("Profile")}</Heading>
|
||||
<ProfilePicture column>
|
||||
<LabelText>{t("Photo")}</LabelText>
|
||||
<AvatarContainer>
|
||||
<ImageUpload
|
||||
onSuccess={handleAvatarUpload}
|
||||
onError={handleAvatarError}
|
||||
>
|
||||
<Avatar src={avatarUrl || user?.avatarUrl} />
|
||||
<Flex auto align="center" justify="center">
|
||||
{t("Upload")}
|
||||
</Flex>
|
||||
</ImageUpload>
|
||||
</AvatarContainer>
|
||||
</ProfilePicture>
|
||||
<form onSubmit={handleSubmit} ref={form}>
|
||||
<Input
|
||||
label={t("Full name")}
|
||||
autoComplete="name"
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
required
|
||||
short
|
||||
/>
|
||||
<br />
|
||||
<InputSelect
|
||||
label={t("Language")}
|
||||
options={languageOptions}
|
||||
value={language}
|
||||
onChange={handleLanguageChange}
|
||||
ariaLabel={t("Language")}
|
||||
short
|
||||
/>
|
||||
<HelpText small>
|
||||
<Trans>
|
||||
Please note that translations are currently in early access.
|
||||
<br />
|
||||
Community contributions are accepted though our{" "}
|
||||
<a
|
||||
href="https://translate.getoutline.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Avatar src={avatarUrl} />
|
||||
<Flex auto align="center" justify="center">
|
||||
{t("Upload")}
|
||||
</Flex>
|
||||
</ImageUpload>
|
||||
</AvatarContainer>
|
||||
</ProfilePicture>
|
||||
<form onSubmit={this.handleSubmit} ref={(ref) => (this.form = ref)}>
|
||||
<Input
|
||||
label={t("Full name")}
|
||||
autoComplete="name"
|
||||
value={this.name}
|
||||
onChange={this.handleNameChange}
|
||||
required
|
||||
short
|
||||
/>
|
||||
<br />
|
||||
<InputSelect
|
||||
label={t("Language")}
|
||||
options={languageOptions}
|
||||
value={this.language}
|
||||
onChange={this.handleLanguageChange}
|
||||
short
|
||||
/>
|
||||
<HelpText small>
|
||||
<Trans>
|
||||
Please note that translations are currently in early access.
|
||||
<br />
|
||||
Community contributions are accepted though our{" "}
|
||||
<a
|
||||
href="https://translate.getoutline.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
translation portal
|
||||
</a>
|
||||
</Trans>
|
||||
.
|
||||
</HelpText>
|
||||
<Button type="submit" disabled={isSaving || !this.isValid}>
|
||||
{isSaving ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
translation portal
|
||||
</a>
|
||||
</Trans>
|
||||
.
|
||||
</HelpText>
|
||||
<Button type="submit" disabled={isSaving || !isValid}>
|
||||
{isSaving ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<DangerZone>
|
||||
<h2>{t("Delete Account")}</h2>
|
||||
<HelpText small>
|
||||
<Trans>
|
||||
You may delete your account at any time, note that this is
|
||||
unrecoverable
|
||||
</Trans>
|
||||
</HelpText>
|
||||
<Button onClick={this.toggleDeleteAccount} neutral>
|
||||
{t("Delete account")}…
|
||||
</Button>
|
||||
</DangerZone>
|
||||
{this.showDeleteModal && (
|
||||
<UserDelete onRequestClose={this.toggleDeleteAccount} />
|
||||
)}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
}
|
||||
<DangerZone>
|
||||
<h2>{t("Delete Account")}</h2>
|
||||
<HelpText small>
|
||||
<Trans>
|
||||
You may delete your account at any time, note that this is
|
||||
unrecoverable
|
||||
</Trans>
|
||||
</HelpText>
|
||||
<Button onClick={toggleDeleteAccount} neutral>
|
||||
{t("Delete account")}…
|
||||
</Button>
|
||||
</DangerZone>
|
||||
{showDeleteModal && <UserDelete onRequestClose={toggleDeleteAccount} />}
|
||||
</Scene>
|
||||
);
|
||||
};
|
||||
|
||||
const DangerZone = styled.div`
|
||||
margin-top: 60px;
|
||||
@@ -215,4 +187,4 @@ const Avatar = styled.img`
|
||||
${avatarStyles};
|
||||
`;
|
||||
|
||||
export default withTranslation()<Profile>(inject("auth", "toasts")(Profile));
|
||||
export default observer(Profile);
|
||||
|
||||
@@ -2,16 +2,19 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FileOperation from "models/FileOperation";
|
||||
import Button from "components/Button";
|
||||
import { Action } from "components/Actions";
|
||||
import ListItem from "components/List/Item";
|
||||
import Time from "components/Time";
|
||||
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import FileOperationMenu from "menus/FileOperationMenu";
|
||||
type Props = {|
|
||||
fileOperation: FileOperation,
|
||||
handleDelete: (FileOperation) => Promise<void>,
|
||||
|};
|
||||
|
||||
const FileOperationListItem = ({ fileOperation }: Props) => {
|
||||
const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
|
||||
const stateMapping = {
|
||||
creating: t("Processing"),
|
||||
@@ -34,7 +37,7 @@ const FileOperationListItem = ({ fileOperation }: Props) => {
|
||||
)}
|
||||
{t(`{{userName}} requested`, {
|
||||
userName:
|
||||
fileOperation.id === fileOperation.user.id
|
||||
user.id === fileOperation.user.id
|
||||
? t("You")
|
||||
: fileOperation.user.name,
|
||||
})}
|
||||
@@ -45,13 +48,15 @@ const FileOperationListItem = ({ fileOperation }: Props) => {
|
||||
}
|
||||
actions={
|
||||
fileOperation.state === "complete" ? (
|
||||
<Button
|
||||
as="a"
|
||||
href={`/api/fileOperations.redirect?id=${fileOperation.id}`}
|
||||
neutral
|
||||
>
|
||||
{t("Download")}
|
||||
</Button>
|
||||
<Action>
|
||||
<FileOperationMenu
|
||||
id={fileOperation.id}
|
||||
onDelete={async (ev) => {
|
||||
ev.preventDefault();
|
||||
await handleDelete(fileOperation);
|
||||
}}
|
||||
/>
|
||||
</Action>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -603,7 +603,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
async update(params: {
|
||||
id: string,
|
||||
title: string,
|
||||
text: string,
|
||||
text?: string,
|
||||
lastRevision: number,
|
||||
}) {
|
||||
const document = await super.update(params);
|
||||
|
||||
@@ -6,7 +6,7 @@ import BaseStore from "./BaseStore";
|
||||
import RootStore from "./RootStore";
|
||||
|
||||
export default class FileOperationsStore extends BaseStore<FileOperation> {
|
||||
actions = ["list", "info"];
|
||||
actions = ["list", "info", "delete"];
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, FileOperation);
|
||||
|
||||
@@ -6,6 +6,8 @@ import Document from "models/Document";
|
||||
|
||||
const UI_STORE = "UI_STORE";
|
||||
|
||||
type Status = "connecting" | "connected" | "disconnected" | void;
|
||||
|
||||
class UiStore {
|
||||
// has the user seen the prompt to change the UI language and actioned it
|
||||
@observable languagePromptDismissed: boolean;
|
||||
@@ -24,6 +26,7 @@ class UiStore {
|
||||
@observable sidebarWidth: number;
|
||||
@observable sidebarCollapsed: boolean = false;
|
||||
@observable sidebarIsResizing: boolean = false;
|
||||
@observable multiplayerStatus: Status;
|
||||
|
||||
constructor() {
|
||||
// Rehydrate
|
||||
@@ -93,6 +96,11 @@ class UiStore {
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
setMultiplayerStatus = (status: Status): void => {
|
||||
this.multiplayerStatus = status;
|
||||
};
|
||||
|
||||
@action
|
||||
setSidebarResizing = (sidebarIsResizing: boolean): void => {
|
||||
this.sidebarIsResizing = sidebarIsResizing;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// @flow
|
||||
|
||||
// A function to delete all IndexedDB databases
|
||||
export async function deleteAllDatabases() {
|
||||
const databases = await window.indexedDB.databases();
|
||||
for (const database of databases) {
|
||||
if (database.name) {
|
||||
await window.indexedDB.deleteDatabase(database.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
zhCN,
|
||||
zhTW,
|
||||
ru,
|
||||
pl,
|
||||
} from "date-fns/locale";
|
||||
|
||||
const locales = {
|
||||
@@ -29,6 +30,7 @@ const locales = {
|
||||
zh_CN: zhCN,
|
||||
zh_TW: zhTW,
|
||||
ru_RU: ru,
|
||||
pl_PL: pl,
|
||||
};
|
||||
|
||||
export function dateLocale(userLocale: ?string) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @flow
|
||||
import { parseDomain } from "../../shared/utils/domains";
|
||||
import env from "env";
|
||||
|
||||
export function isInternalUrl(href: string) {
|
||||
if (href[0] === "/") return true;
|
||||
@@ -21,14 +20,6 @@ export function isInternalUrl(href: string) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function cdnPath(path: string): string {
|
||||
return `${env.CDN_URL}${path}`;
|
||||
}
|
||||
|
||||
export function imagePath(path: string): string {
|
||||
return cdnPath(`/images/${path}`);
|
||||
}
|
||||
|
||||
export function decodeURIComponentSafe(text: string) {
|
||||
return text
|
||||
? decodeURIComponent(text.replace(/%(?![0-9][0-9a-fA-F]+)/g, "%25"))
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Backend Services
|
||||
|
||||
Outline's backend is split into several distinct [services](../server/services)
|
||||
that combined form the application. When running the official Docker container
|
||||
it will run all of the production services by default.
|
||||
|
||||
You can choose which services to run through either a comma separated CLI flag,
|
||||
`--services`, or the `SERVICES` environment variable. For example:
|
||||
|
||||
```bash
|
||||
yarn start --services=web,worker
|
||||
```
|
||||
|
||||
## Admin
|
||||
|
||||
Currently this service is only used in development to view and debug the queues.
|
||||
It is hosted at `/admin`.
|
||||
|
||||
## Web
|
||||
|
||||
The web server hosts the Application and API, as such this is the main service
|
||||
and must be run by at least one process.
|
||||
|
||||
## Websockets
|
||||
|
||||
The websocket server is used to communicate with the frontend, it can be ran on
|
||||
the same box as the web server or separately.
|
||||
|
||||
## Worker
|
||||
|
||||
At least one worker process is required to process the [queues](../server/queues).
|
||||
|
||||
## Collaboration
|
||||
|
||||
The service is in alpha and as such is not started by default. It must run
|
||||
separately to the `websockets` service, and will not start in the same process.
|
||||
The `COLLABORATION_URL` must be set to the publicly accessible URL when running
|
||||
the service. For example, if the app is hosted at `https://docs.example.com` you
|
||||
may use something like: `COLLABORATION_URL=wss://docs-collaboration.example.com`.
|
||||
|
||||
Start the service with:
|
||||
|
||||
```bash
|
||||
yarn start --services=collaboration
|
||||
```
|
||||
Vendored
+2
@@ -9,6 +9,8 @@ declare var process: {
|
||||
env: {
|
||||
[string]: string,
|
||||
},
|
||||
stdout: Stream,
|
||||
stderr: Stream,
|
||||
};
|
||||
|
||||
declare var EDITOR_VERSION: string;
|
||||
|
||||
Vendored
+377
@@ -0,0 +1,377 @@
|
||||
// flow-typed signature: 97da878aea98698d6c06f8a696bb62af
|
||||
// flow-typed version: <<STUB>>/lib0_v0.2.34/flow_v0.104.0
|
||||
|
||||
/**
|
||||
* This is an autogenerated libdef stub for:
|
||||
*
|
||||
* 'lib0'
|
||||
*
|
||||
* Fill this stub out by replacing all the `any` types.
|
||||
*
|
||||
* Once filled out, we encourage you to share your work with the
|
||||
* community by sending a pull request to:
|
||||
* https://github.com/flowtype/flow-typed
|
||||
*/
|
||||
|
||||
// @flow
|
||||
declare module "lib0" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* We include stubs for each file inside this npm package in case you need to
|
||||
* require those files directly. Feel free to delete any files that aren't
|
||||
* needed.
|
||||
*/
|
||||
declare module "lib0/array" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/bin/gendocs" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/binary" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/broadcastchannel" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/buffer" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/component" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/conditions" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/decoding" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/diff" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/dist/test" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/dom" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/encoding" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/environment" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/error" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/eventloop" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/function" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/indexeddb" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/isomorphic" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/iterator" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/json" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/logging" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/map" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/math" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/metric" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/mutex" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/number" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/object" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/observable" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/pair" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/prng" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/prng/Mt19937" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/prng/Xoroshiro128plus" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/prng/Xorshift32" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/promise" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/queue" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/random" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/set" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/sort" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/statistics" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/storage" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/string" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/symbol" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/test" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/testing" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/time" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/tree" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/url" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/websocket" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
// Filename aliases
|
||||
declare module "lib0/array.js" {
|
||||
declare module.exports: $Exports<"lib0/array">;
|
||||
}
|
||||
declare module "lib0/bin/gendocs.js" {
|
||||
declare module.exports: $Exports<"lib0/bin/gendocs">;
|
||||
}
|
||||
declare module "lib0/binary.js" {
|
||||
declare module.exports: $Exports<"lib0/binary">;
|
||||
}
|
||||
declare module "lib0/broadcastchannel.js" {
|
||||
declare module.exports: $Exports<"lib0/broadcastchannel">;
|
||||
}
|
||||
declare module "lib0/buffer.js" {
|
||||
declare module.exports: $Exports<"lib0/buffer">;
|
||||
}
|
||||
declare module "lib0/component.js" {
|
||||
declare module.exports: $Exports<"lib0/component">;
|
||||
}
|
||||
declare module "lib0/conditions.js" {
|
||||
declare module.exports: $Exports<"lib0/conditions">;
|
||||
}
|
||||
declare module "lib0/decoding.js" {
|
||||
declare module.exports: $Exports<"lib0/decoding">;
|
||||
}
|
||||
declare module "lib0/dist/decoding.cjs" {
|
||||
declare module.exports: $Exports<"lib0/decoding">;
|
||||
}
|
||||
declare module "lib0/diff.js" {
|
||||
declare module.exports: $Exports<"lib0/diff">;
|
||||
}
|
||||
declare module "lib0/dist/test.js" {
|
||||
declare module.exports: $Exports<"lib0/dist/test">;
|
||||
}
|
||||
declare module "lib0/dom.js" {
|
||||
declare module.exports: $Exports<"lib0/dom">;
|
||||
}
|
||||
declare module "lib0/encoding.js" {
|
||||
declare module.exports: $Exports<"lib0/encoding">;
|
||||
}
|
||||
declare module "lib0/dist/encoding.cjs" {
|
||||
declare module.exports: $Exports<"lib0/encoding">;
|
||||
}
|
||||
declare module "lib0/environment.js" {
|
||||
declare module.exports: $Exports<"lib0/environment">;
|
||||
}
|
||||
declare module "lib0/error.js" {
|
||||
declare module.exports: $Exports<"lib0/error">;
|
||||
}
|
||||
declare module "lib0/eventloop.js" {
|
||||
declare module.exports: $Exports<"lib0/eventloop">;
|
||||
}
|
||||
declare module "lib0/function.js" {
|
||||
declare module.exports: $Exports<"lib0/function">;
|
||||
}
|
||||
declare module "lib0/index" {
|
||||
declare module.exports: $Exports<"lib0">;
|
||||
}
|
||||
declare module "lib0/index.js" {
|
||||
declare module.exports: $Exports<"lib0">;
|
||||
}
|
||||
declare module "lib0/indexeddb.js" {
|
||||
declare module.exports: $Exports<"lib0/indexeddb">;
|
||||
}
|
||||
declare module "lib0/isomorphic.js" {
|
||||
declare module.exports: $Exports<"lib0/isomorphic">;
|
||||
}
|
||||
declare module "lib0/iterator.js" {
|
||||
declare module.exports: $Exports<"lib0/iterator">;
|
||||
}
|
||||
declare module "lib0/json.js" {
|
||||
declare module.exports: $Exports<"lib0/json">;
|
||||
}
|
||||
declare module "lib0/logging.js" {
|
||||
declare module.exports: $Exports<"lib0/logging">;
|
||||
}
|
||||
declare module "lib0/map.js" {
|
||||
declare module.exports: $Exports<"lib0/map">;
|
||||
}
|
||||
declare module "lib0/math.js" {
|
||||
declare module.exports: $Exports<"lib0/math">;
|
||||
}
|
||||
declare module "lib0/metric.js" {
|
||||
declare module.exports: $Exports<"lib0/metric">;
|
||||
}
|
||||
declare module "lib0/mutex.js" {
|
||||
declare module.exports: $Exports<"lib0/mutex">;
|
||||
}
|
||||
declare module "lib0/dist/mutex.cjs" {
|
||||
declare module.exports: $Exports<"lib0/mutex">;
|
||||
}
|
||||
declare module "lib0/number.js" {
|
||||
declare module.exports: $Exports<"lib0/number">;
|
||||
}
|
||||
declare module "lib0/object.js" {
|
||||
declare module.exports: $Exports<"lib0/object">;
|
||||
}
|
||||
declare module "lib0/observable.js" {
|
||||
declare module.exports: $Exports<"lib0/observable">;
|
||||
}
|
||||
declare module "lib0/pair.js" {
|
||||
declare module.exports: $Exports<"lib0/pair">;
|
||||
}
|
||||
declare module "lib0/prng.js" {
|
||||
declare module.exports: $Exports<"lib0/prng">;
|
||||
}
|
||||
declare module "lib0/prng/Mt19937.js" {
|
||||
declare module.exports: $Exports<"lib0/prng/Mt19937">;
|
||||
}
|
||||
declare module "lib0/prng/Xoroshiro128plus.js" {
|
||||
declare module.exports: $Exports<"lib0/prng/Xoroshiro128plus">;
|
||||
}
|
||||
declare module "lib0/prng/Xorshift32.js" {
|
||||
declare module.exports: $Exports<"lib0/prng/Xorshift32">;
|
||||
}
|
||||
declare module "lib0/promise.js" {
|
||||
declare module.exports: $Exports<"lib0/promise">;
|
||||
}
|
||||
declare module "lib0/queue.js" {
|
||||
declare module.exports: $Exports<"lib0/queue">;
|
||||
}
|
||||
declare module "lib0/random.js" {
|
||||
declare module.exports: $Exports<"lib0/random">;
|
||||
}
|
||||
declare module "lib0/set.js" {
|
||||
declare module.exports: $Exports<"lib0/set">;
|
||||
}
|
||||
declare module "lib0/sort.js" {
|
||||
declare module.exports: $Exports<"lib0/sort">;
|
||||
}
|
||||
declare module "lib0/statistics.js" {
|
||||
declare module.exports: $Exports<"lib0/statistics">;
|
||||
}
|
||||
declare module "lib0/storage.js" {
|
||||
declare module.exports: $Exports<"lib0/storage">;
|
||||
}
|
||||
declare module "lib0/string.js" {
|
||||
declare module.exports: $Exports<"lib0/string">;
|
||||
}
|
||||
declare module "lib0/symbol.js" {
|
||||
declare module.exports: $Exports<"lib0/symbol">;
|
||||
}
|
||||
declare module "lib0/test.js" {
|
||||
declare module.exports: $Exports<"lib0/test">;
|
||||
}
|
||||
declare module "lib0/testing.js" {
|
||||
declare module.exports: $Exports<"lib0/testing">;
|
||||
}
|
||||
declare module "lib0/time.js" {
|
||||
declare module.exports: $Exports<"lib0/time">;
|
||||
}
|
||||
declare module "lib0/tree.js" {
|
||||
declare module.exports: $Exports<"lib0/tree">;
|
||||
}
|
||||
declare module "lib0/url.js" {
|
||||
declare module.exports: $Exports<"lib0/url">;
|
||||
}
|
||||
declare module "lib0/websocket.js" {
|
||||
declare module.exports: $Exports<"lib0/websocket">;
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
// flow-typed signature: 97da878aea98698d6c06f8a696bb62af
|
||||
// flow-typed version: <<STUB>>/lib0_v0.2.34/flow_v0.104.0
|
||||
|
||||
/**
|
||||
* This is an autogenerated libdef stub for:
|
||||
*
|
||||
* 'lib0'
|
||||
*
|
||||
* Fill this stub out by replacing all the `any` types.
|
||||
*
|
||||
* Once filled out, we encourage you to share your work with the
|
||||
* community by sending a pull request to:
|
||||
* https://github.com/flowtype/flow-typed
|
||||
*/
|
||||
|
||||
// @flow
|
||||
declare module "lib0" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* We include stubs for each file inside this npm package in case you need to
|
||||
* require those files directly. Feel free to delete any files that aren't
|
||||
* needed.
|
||||
*/
|
||||
declare module "lib0/array" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/bin/gendocs" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/binary" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/broadcastchannel" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/buffer" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/component" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/conditions" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/decoding" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/diff" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/dist/test" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/dom" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/encoding" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/environment" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/error" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/eventloop" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/function" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/indexeddb" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/isomorphic" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/iterator" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/json" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/logging" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/map" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/math" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/metric" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/mutex" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/number" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/object" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/observable" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/pair" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/prng" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/prng/Mt19937" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/prng/Xoroshiro128plus" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/prng/Xorshift32" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/promise" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/queue" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/random" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/set" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/sort" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/statistics" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/storage" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/string" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/symbol" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/test" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/testing" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/time" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/tree" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/url" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/websocket" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
// Filename aliases
|
||||
declare module "lib0/array.js" {
|
||||
declare module.exports: $Exports<"lib0/array">;
|
||||
}
|
||||
declare module "lib0/bin/gendocs.js" {
|
||||
declare module.exports: $Exports<"lib0/bin/gendocs">;
|
||||
}
|
||||
declare module "lib0/binary.js" {
|
||||
declare module.exports: $Exports<"lib0/binary">;
|
||||
}
|
||||
declare module "lib0/broadcastchannel.js" {
|
||||
declare module.exports: $Exports<"lib0/broadcastchannel">;
|
||||
}
|
||||
declare module "lib0/buffer.js" {
|
||||
declare module.exports: $Exports<"lib0/buffer">;
|
||||
}
|
||||
declare module "lib0/component.js" {
|
||||
declare module.exports: $Exports<"lib0/component">;
|
||||
}
|
||||
declare module "lib0/conditions.js" {
|
||||
declare module.exports: $Exports<"lib0/conditions">;
|
||||
}
|
||||
declare module "lib0/decoding.js" {
|
||||
declare module.exports: $Exports<"lib0/decoding">;
|
||||
}
|
||||
declare module "lib0/dist/decoding.cjs" {
|
||||
declare module.exports: $Exports<"lib0/decoding">;
|
||||
}
|
||||
declare module "lib0/diff.js" {
|
||||
declare module.exports: $Exports<"lib0/diff">;
|
||||
}
|
||||
declare module "lib0/dist/test.js" {
|
||||
declare module.exports: $Exports<"lib0/dist/test">;
|
||||
}
|
||||
declare module "lib0/dom.js" {
|
||||
declare module.exports: $Exports<"lib0/dom">;
|
||||
}
|
||||
declare module "lib0/encoding.js" {
|
||||
declare module.exports: $Exports<"lib0/encoding">;
|
||||
}
|
||||
declare module "lib0/dist/encoding.cjs" {
|
||||
declare module.exports: $Exports<"lib0/encoding">;
|
||||
}
|
||||
declare module "lib0/environment.js" {
|
||||
declare module.exports: $Exports<"lib0/environment">;
|
||||
}
|
||||
declare module "lib0/error.js" {
|
||||
declare module.exports: $Exports<"lib0/error">;
|
||||
}
|
||||
declare module "lib0/eventloop.js" {
|
||||
declare module.exports: $Exports<"lib0/eventloop">;
|
||||
}
|
||||
declare module "lib0/function.js" {
|
||||
declare module.exports: $Exports<"lib0/function">;
|
||||
}
|
||||
declare module "lib0/index" {
|
||||
declare module.exports: $Exports<"lib0">;
|
||||
}
|
||||
declare module "lib0/index.js" {
|
||||
declare module.exports: $Exports<"lib0">;
|
||||
}
|
||||
declare module "lib0/indexeddb.js" {
|
||||
declare module.exports: $Exports<"lib0/indexeddb">;
|
||||
}
|
||||
declare module "lib0/isomorphic.js" {
|
||||
declare module.exports: $Exports<"lib0/isomorphic">;
|
||||
}
|
||||
declare module "lib0/iterator.js" {
|
||||
declare module.exports: $Exports<"lib0/iterator">;
|
||||
}
|
||||
declare module "lib0/json.js" {
|
||||
declare module.exports: $Exports<"lib0/json">;
|
||||
}
|
||||
declare module "lib0/logging.js" {
|
||||
declare module.exports: $Exports<"lib0/logging">;
|
||||
}
|
||||
declare module "lib0/map.js" {
|
||||
declare module.exports: $Exports<"lib0/map">;
|
||||
}
|
||||
declare module "lib0/math.js" {
|
||||
declare module.exports: $Exports<"lib0/math">;
|
||||
}
|
||||
declare module "lib0/metric.js" {
|
||||
declare module.exports: $Exports<"lib0/metric">;
|
||||
}
|
||||
declare module "lib0/mutex.js" {
|
||||
declare module.exports: $Exports<"lib0/mutex">;
|
||||
}
|
||||
declare module "lib0/dist/mutex.cjs" {
|
||||
declare module.exports: $Exports<"lib0/mutex">;
|
||||
}
|
||||
declare module "lib0/number.js" {
|
||||
declare module.exports: $Exports<"lib0/number">;
|
||||
}
|
||||
declare module "lib0/object.js" {
|
||||
declare module.exports: $Exports<"lib0/object">;
|
||||
}
|
||||
declare module "lib0/observable.js" {
|
||||
declare module.exports: $Exports<"lib0/observable">;
|
||||
}
|
||||
declare module "lib0/pair.js" {
|
||||
declare module.exports: $Exports<"lib0/pair">;
|
||||
}
|
||||
declare module "lib0/prng.js" {
|
||||
declare module.exports: $Exports<"lib0/prng">;
|
||||
}
|
||||
declare module "lib0/prng/Mt19937.js" {
|
||||
declare module.exports: $Exports<"lib0/prng/Mt19937">;
|
||||
}
|
||||
declare module "lib0/prng/Xoroshiro128plus.js" {
|
||||
declare module.exports: $Exports<"lib0/prng/Xoroshiro128plus">;
|
||||
}
|
||||
declare module "lib0/prng/Xorshift32.js" {
|
||||
declare module.exports: $Exports<"lib0/prng/Xorshift32">;
|
||||
}
|
||||
declare module "lib0/promise.js" {
|
||||
declare module.exports: $Exports<"lib0/promise">;
|
||||
}
|
||||
declare module "lib0/queue.js" {
|
||||
declare module.exports: $Exports<"lib0/queue">;
|
||||
}
|
||||
declare module "lib0/random.js" {
|
||||
declare module.exports: $Exports<"lib0/random">;
|
||||
}
|
||||
declare module "lib0/set.js" {
|
||||
declare module.exports: $Exports<"lib0/set">;
|
||||
}
|
||||
declare module "lib0/sort.js" {
|
||||
declare module.exports: $Exports<"lib0/sort">;
|
||||
}
|
||||
declare module "lib0/statistics.js" {
|
||||
declare module.exports: $Exports<"lib0/statistics">;
|
||||
}
|
||||
declare module "lib0/storage.js" {
|
||||
declare module.exports: $Exports<"lib0/storage">;
|
||||
}
|
||||
declare module "lib0/string.js" {
|
||||
declare module.exports: $Exports<"lib0/string">;
|
||||
}
|
||||
declare module "lib0/symbol.js" {
|
||||
declare module.exports: $Exports<"lib0/symbol">;
|
||||
}
|
||||
declare module "lib0/test.js" {
|
||||
declare module.exports: $Exports<"lib0/test">;
|
||||
}
|
||||
declare module "lib0/testing.js" {
|
||||
declare module.exports: $Exports<"lib0/testing">;
|
||||
}
|
||||
declare module "lib0/time.js" {
|
||||
declare module.exports: $Exports<"lib0/time">;
|
||||
}
|
||||
declare module "lib0/tree.js" {
|
||||
declare module.exports: $Exports<"lib0/tree">;
|
||||
}
|
||||
declare module "lib0/url.js" {
|
||||
declare module.exports: $Exports<"lib0/url">;
|
||||
}
|
||||
declare module "lib0/websocket.js" {
|
||||
declare module.exports: $Exports<"lib0/websocket">;
|
||||
}
|
||||
Vendored
+39
@@ -0,0 +1,39 @@
|
||||
// flow-typed signature: 71e55e30d387153cf804d226f95c0ad8
|
||||
// flow-typed version: <<STUB>>/y-indexeddb_v^9.0.5/flow_v0.104.0
|
||||
|
||||
/**
|
||||
* This is an autogenerated libdef stub for:
|
||||
*
|
||||
* 'y-indexeddb'
|
||||
*
|
||||
* Fill this stub out by replacing all the `any` types.
|
||||
*
|
||||
* Once filled out, we encourage you to share your work with the
|
||||
* community by sending a pull request to:
|
||||
* https://github.com/flowtype/flow-typed
|
||||
*/
|
||||
|
||||
declare module 'y-indexeddb' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* We include stubs for each file inside this npm package in case you need to
|
||||
* require those files directly. Feel free to delete any files that aren't
|
||||
* needed.
|
||||
*/
|
||||
declare module 'y-indexeddb/dist/test' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'y-indexeddb/src/y-indexeddb' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
// Filename aliases
|
||||
declare module 'y-indexeddb/dist/test.js' {
|
||||
declare module.exports: $Exports<'y-indexeddb/dist/test'>;
|
||||
}
|
||||
declare module 'y-indexeddb/src/y-indexeddb.js' {
|
||||
declare module.exports: $Exports<'y-indexeddb/src/y-indexeddb'>;
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
// flow-typed signature: 2db53ec5dbb577a4e27bc465cd4670f3
|
||||
// flow-typed version: <<STUB>>/y-prosemirror_v^0.3.7/flow_v0.104.0
|
||||
|
||||
/**
|
||||
* This is an autogenerated libdef stub for:
|
||||
*
|
||||
* 'y-prosemirror'
|
||||
*
|
||||
* Fill this stub out by replacing all the `any` types.
|
||||
*
|
||||
* Once filled out, we encourage you to share your work with the
|
||||
* community by sending a pull request to:
|
||||
* https://github.com/flowtype/flow-typed
|
||||
*/
|
||||
|
||||
declare module 'y-prosemirror' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* We include stubs for each file inside this npm package in case you need to
|
||||
* require those files directly. Feel free to delete any files that aren't
|
||||
* needed.
|
||||
*/
|
||||
declare module 'y-prosemirror/dist/test' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'y-prosemirror/src/lib' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'y-prosemirror/src/plugins/cursor-plugin' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'y-prosemirror/src/plugins/sync-plugin' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'y-prosemirror/src/plugins/undo-plugin' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'y-prosemirror/src/y-prosemirror' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
// Filename aliases
|
||||
declare module 'y-prosemirror/dist/test.js' {
|
||||
declare module.exports: $Exports<'y-prosemirror/dist/test'>;
|
||||
}
|
||||
declare module 'y-prosemirror/src/lib.js' {
|
||||
declare module.exports: $Exports<'y-prosemirror/src/lib'>;
|
||||
}
|
||||
declare module 'y-prosemirror/src/plugins/cursor-plugin.js' {
|
||||
declare module.exports: $Exports<'y-prosemirror/src/plugins/cursor-plugin'>;
|
||||
}
|
||||
declare module 'y-prosemirror/src/plugins/sync-plugin.js' {
|
||||
declare module.exports: $Exports<'y-prosemirror/src/plugins/sync-plugin'>;
|
||||
}
|
||||
declare module 'y-prosemirror/src/plugins/undo-plugin.js' {
|
||||
declare module.exports: $Exports<'y-prosemirror/src/plugins/undo-plugin'>;
|
||||
}
|
||||
declare module 'y-prosemirror/src/y-prosemirror.js' {
|
||||
declare module.exports: $Exports<'y-prosemirror/src/y-prosemirror'>;
|
||||
}
|
||||
Vendored
+67
@@ -0,0 +1,67 @@
|
||||
// flow-typed signature: 3ef5e4dd42591ff15af5f507abd6aa97
|
||||
// flow-typed version: <<STUB>>/y-protocols_v^1.0.1/flow_v0.104.0
|
||||
|
||||
/**
|
||||
* This is an autogenerated libdef stub for:
|
||||
*
|
||||
* 'y-protocols'
|
||||
*
|
||||
* Fill this stub out by replacing all the `any` types.
|
||||
*
|
||||
* Once filled out, we encourage you to share your work with the
|
||||
* community by sending a pull request to:
|
||||
* https://github.com/flowtype/flow-typed
|
||||
*/
|
||||
|
||||
// @flow
|
||||
declare module "y-protocols" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* We include stubs for each file inside this npm package in case you need to
|
||||
* require those files directly. Feel free to delete any files that aren't
|
||||
* needed.
|
||||
*/
|
||||
declare module "y-protocols/auth" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "y-protocols/awareness" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "y-protocols/awareness.test" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "y-protocols/dist/test" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "y-protocols/sync" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
// Filename aliases
|
||||
declare module "y-protocols/auth.js" {
|
||||
declare module.exports: $Exports<"y-protocols/auth">;
|
||||
}
|
||||
declare module "y-protocols/awareness.js" {
|
||||
declare module.exports: $Exports<"y-protocols/awareness">;
|
||||
}
|
||||
declare module "y-protocols/dist/awareness.cjs" {
|
||||
declare module.exports: $Exports<"y-protocols/awareness">;
|
||||
}
|
||||
declare module "y-protocols/awareness.test.js" {
|
||||
declare module.exports: $Exports<"y-protocols/awareness.test">;
|
||||
}
|
||||
declare module "y-protocols/dist/test.js" {
|
||||
declare module.exports: $Exports<"y-protocols/dist/test">;
|
||||
}
|
||||
declare module "y-protocols/sync.js" {
|
||||
declare module.exports: $Exports<"y-protocols/sync">;
|
||||
}
|
||||
declare module "y-protocols/dist/sync.cjs" {
|
||||
declare module.exports: $Exports<"y-protocols/sync">;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user