mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
118 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cb82f9349 | |||
| 7b7c1c0bc2 | |||
| 1fd2ec31fd | |||
| 1af00a0b3d | |||
| ab40545a01 | |||
| c8d305aeca | |||
| 68a65be135 | |||
| b4d307b3b4 | |||
| 03cb6d66e7 | |||
| 7b8cbc50d5 | |||
| f501da9c0f | |||
| 74a762a7c7 | |||
| 93ac9892d5 | |||
| e8b7782f5e | |||
| 47369dd968 | |||
| d258082c5f | |||
| c0bbae50c4 | |||
| ac082e4a5f | |||
| 7504d43452 | |||
| 5dba68dfd3 | |||
| 4b85603f30 | |||
| 34598b317d | |||
| cbfa25fa2f | |||
| 67a2246e1a | |||
| de7bf8c133 | |||
| 4fda50f4ad | |||
| f4c5cc054e | |||
| f799758a6f | |||
| 9df02d6fd4 | |||
| bb81aa0065 | |||
| 68bbd9fa34 | |||
| 308d4bd797 | |||
| 5329474c85 | |||
| d90af48741 | |||
| 611e9b97b3 | |||
| eda5adca2c | |||
| f0b361158e | |||
| f8ab793053 | |||
| 2cc45187e6 | |||
| ba61091c4c | |||
| 8dba32b5e0 | |||
| 40bd9aed0a | |||
| d4bb04e921 | |||
| 8a3a279c0e | |||
| 37f2cc8d55 | |||
| 89903b4bbe | |||
| b6ab816bb3 | |||
| ac1120914a | |||
| ea57cef89c | |||
| 7d44e1aeeb | |||
| 25d5ad8a7e | |||
| e34ba1457e | |||
| e966eb8c9a | |||
| 4684b3a3f3 | |||
| 47ce8afcc5 | |||
| decbe4f643 | |||
| 117d278d16 | |||
| 40ca73e684 | |||
| 051ecab0fc | |||
| 3469b82beb | |||
| f2c3481670 | |||
| bc141dc40c | |||
| 99814f6e2f | |||
| 3737f0b42c | |||
| 9ef27cb436 | |||
| c9fa3f93f2 | |||
| 24ed96c9a5 | |||
| 956cf401bd | |||
| 7cb837f478 | |||
| a3209e9d23 | |||
| 29582a1bb1 | |||
| 4084c91769 | |||
| 6772f28226 | |||
| c6b110d339 | |||
| 0a43b50c66 | |||
| 4a82cb0658 | |||
| 2f7fca6106 | |||
| 8f83cfef25 | |||
| e2e66954b5 | |||
| 3dbe54ac1e | |||
| 50577f6f2f | |||
| 16d504703d | |||
| 173febcaa1 | |||
| f92f4cde7a | |||
| 23bec75bd0 | |||
| 4dd667f68b | |||
| 4b3cb77cc7 | |||
| 0e83d54f93 | |||
| d867d9fea5 | |||
| 28c8b8acfe | |||
| 51efffe2ce | |||
| 4e9ee7249f | |||
| 574fcc4bb3 | |||
| 5c3000d5cf | |||
| c0216cbb8d | |||
| cf12301077 | |||
| 1eb7da8742 | |||
| b3c548382f | |||
| 7ebac53b43 | |||
| 64428a6894 | |||
| d536af5269 | |||
| 1726a88a60 | |||
| 3fe807a10a | |||
| 72189e041b | |||
| bc156f4cc8 | |||
| 26693c60df | |||
| 9e1f31e14c | |||
| 63d926e196 | |||
| 3f9f1f0bed | |||
| b2bdc7f1d4 | |||
| 2e798c698d | |||
| aa59f5fe09 | |||
| ac2060b166 | |||
| 424c29536d | |||
| 6c1ecde4e7 | |||
| aa6fc45097 | |||
| 9478944718 | |||
| 9e1c5d1db3 |
+6
-1
@@ -18,12 +18,17 @@ PORT=3000
|
||||
FORCE_HTTPS=true
|
||||
|
||||
ENABLE_UPDATES=true
|
||||
DEBUG=cache,presenters,events
|
||||
DEBUG=cache,presenters,events,emails,mailer,utils,multiplayer,server,services
|
||||
|
||||
# Third party signin credentials (at least one is required)
|
||||
SLACK_KEY=get_a_key_from_slack
|
||||
SLACK_SECRET=get_the_secret_of_above_key
|
||||
|
||||
# To configure Google auth, you'll need to create an OAuth Client ID at
|
||||
# => https://console.cloud.google.com/apis/credentials
|
||||
#
|
||||
# When configuring the Client ID, add an Authorized redirect URI:
|
||||
# https://<your Outline URL>/auth/google.callback
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ module.file_ext=.json
|
||||
esproposal.decorators=ignore
|
||||
esproposal.class_static_fields=enable
|
||||
esproposal.class_instance_fields=enable
|
||||
esproposal.optional_chaining=enable
|
||||
|
||||
suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe
|
||||
suppress_comment=\\(.\\|\n\\)*\\$FlowIssue
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.50.0
|
||||
Licensed Work: Outline 0.51.0
|
||||
The Licensed Work is (c) 2020 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2023-11-14
|
||||
Change Date: 2023-12-13
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield&circle-token=c0c4c2f39990e277385d5c1ae96169c409eb887a"></a>
|
||||
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat"></a>
|
||||
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg"></a>
|
||||
<a href="https://translate.getoutline.com/project/outline"><img src="https://badges.crowdin.net/outline/localized.svg"></a>
|
||||
</p>
|
||||
|
||||
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com).
|
||||
@@ -74,6 +75,20 @@ In development you can quickly get an environment running using Docker by follow
|
||||
1. Ensure that the bot token scope contains at least `users:read`
|
||||
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
|
||||
|
||||
### Upgrade
|
||||
|
||||
#### Docker
|
||||
|
||||
If you're running Outline with Docker you'll need to run migrations within the docker container after updating the image. The command will be something like:
|
||||
```
|
||||
docker run --rm outlinewiki/outline:latest yarn sequelize:migrate
|
||||
```
|
||||
#### Yarn
|
||||
|
||||
If you're running Outline by cloning this repository, run the following command to upgrade:
|
||||
```
|
||||
yarn upgrade
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -11,11 +11,6 @@ export const Action = styled(Flex)`
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
|
||||
a {
|
||||
color: ${(props) => props.theme.text};
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
ArchiveIcon,
|
||||
EditIcon,
|
||||
GoToIcon,
|
||||
MoreIcon,
|
||||
PadlockIcon,
|
||||
ShapesIcon,
|
||||
TrashIcon,
|
||||
@@ -14,18 +13,15 @@ import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import Document from "models/Document";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import Flex from "components/Flex";
|
||||
import BreadcrumbMenu from "./BreadcrumbMenu";
|
||||
import useStores from "hooks/useStores";
|
||||
import BreadcrumbMenu from "menus/BreadcrumbMenu";
|
||||
import { collectionUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document,
|
||||
collections: CollectionsStore,
|
||||
onlyText: boolean,
|
||||
};
|
||||
|
||||
@@ -35,11 +31,11 @@ function Icon({ document }) {
|
||||
if (document.isDeleted) {
|
||||
return (
|
||||
<>
|
||||
<CollectionName to="/trash">
|
||||
<CategoryName to="/trash">
|
||||
<TrashIcon color="currentColor" />
|
||||
|
||||
<span>{t("Trash")}</span>
|
||||
</CollectionName>
|
||||
</CategoryName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
@@ -47,11 +43,11 @@ function Icon({ document }) {
|
||||
if (document.isArchived) {
|
||||
return (
|
||||
<>
|
||||
<CollectionName to="/archive">
|
||||
<CategoryName to="/archive">
|
||||
<ArchiveIcon color="currentColor" />
|
||||
|
||||
<span>{t("Archive")}</span>
|
||||
</CollectionName>
|
||||
</CategoryName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
@@ -59,11 +55,11 @@ function Icon({ document }) {
|
||||
if (document.isDraft) {
|
||||
return (
|
||||
<>
|
||||
<CollectionName to="/drafts">
|
||||
<CategoryName to="/drafts">
|
||||
<EditIcon color="currentColor" />
|
||||
|
||||
<span>{t("Drafts")}</span>
|
||||
</CollectionName>
|
||||
</CategoryName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
@@ -71,11 +67,11 @@ function Icon({ document }) {
|
||||
if (document.isTemplate) {
|
||||
return (
|
||||
<>
|
||||
<CollectionName to="/templates">
|
||||
<CategoryName to="/templates">
|
||||
<ShapesIcon color="currentColor" />
|
||||
|
||||
<span>{t("Templates")}</span>
|
||||
</CollectionName>
|
||||
</CategoryName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
@@ -89,8 +85,6 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
|
||||
|
||||
let collection = collections.get(document.collectionId);
|
||||
if (!collection) {
|
||||
if (!document.deletedAt) return <div />;
|
||||
|
||||
collection = {
|
||||
id: document.collectionId,
|
||||
name: t("Deleted Collection"),
|
||||
@@ -99,7 +93,7 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
|
||||
}
|
||||
|
||||
const path = collection.pathToDocument
|
||||
? collection.pathToDocument(document).slice(0, -1)
|
||||
? collection.pathToDocument(document.id).slice(0, -1)
|
||||
: [];
|
||||
|
||||
if (onlyText === true) {
|
||||
@@ -135,7 +129,7 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
|
||||
</CollectionName>
|
||||
{isNestedDocument && (
|
||||
<>
|
||||
<Slash /> <BreadcrumbMenu label={<Overflow />} path={menuPath} />
|
||||
<Slash /> <BreadcrumbMenu path={menuPath} />
|
||||
</>
|
||||
)}
|
||||
{lastPath && (
|
||||
@@ -150,6 +144,11 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const Slash = styled(GoToIcon)`
|
||||
flex-shrink: 0;
|
||||
fill: ${(props) => props.theme.divider};
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
display: none;
|
||||
|
||||
@@ -170,22 +169,6 @@ const SmallSlash = styled(GoToIcon)`
|
||||
opacity: 0.25;
|
||||
`;
|
||||
|
||||
export const Slash = styled(GoToIcon)`
|
||||
flex-shrink: 0;
|
||||
fill: ${(props) => props.theme.divider};
|
||||
`;
|
||||
|
||||
const Overflow = styled(MoreIcon)`
|
||||
flex-shrink: 0;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
fill: ${(props) => props.theme.divider};
|
||||
|
||||
&:active,
|
||||
&:hover {
|
||||
fill: ${(props) => props.theme.text};
|
||||
}
|
||||
`;
|
||||
|
||||
const Crumb = styled(Link)`
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 15px;
|
||||
@@ -201,12 +184,21 @@ const Crumb = styled(Link)`
|
||||
|
||||
const CollectionName = styled(Link)`
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-shrink: 1;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const CategoryName = styled(CollectionName)`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default observer(Breadcrumb);
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { DropdownMenu } from "components/DropdownMenu";
|
||||
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
|
||||
|
||||
type Props = {
|
||||
label: React.Node,
|
||||
path: Array<any>,
|
||||
};
|
||||
|
||||
export default function BreadcrumbMenu({ label, path }: Props) {
|
||||
return (
|
||||
<DropdownMenu label={label} position="center">
|
||||
<DropdownMenuItems
|
||||
items={path.map((item) => ({
|
||||
title: item.title,
|
||||
to: item.url,
|
||||
}))}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
+27
-15
@@ -22,9 +22,13 @@ const RealButton = styled.button`
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
svg {
|
||||
fill: ${(props) => props.iconColor || props.theme.buttonText};
|
||||
}
|
||||
${(props) =>
|
||||
!props.borderOnHover &&
|
||||
`
|
||||
svg {
|
||||
fill: ${props.iconColor || props.theme.buttonText};
|
||||
}
|
||||
`}
|
||||
|
||||
&::-moz-focus-inner {
|
||||
padding: 0;
|
||||
@@ -42,24 +46,30 @@ const RealButton = styled.button`
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.neutral &&
|
||||
props.$neutral &&
|
||||
`
|
||||
background: ${props.theme.buttonNeutralBackground};
|
||||
color: ${props.theme.buttonNeutralText};
|
||||
box-shadow: ${
|
||||
props.borderOnHover ? "none" : "rgba(0, 0, 0, 0.07) 0px 1px 2px"
|
||||
};
|
||||
border: 1px solid ${
|
||||
props.borderOnHover ? "transparent" : props.theme.buttonNeutralBorder
|
||||
props.borderOnHover
|
||||
? "none"
|
||||
: `rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.buttonNeutralBorder} 0 0 0 1px inset`
|
||||
};
|
||||
|
||||
svg {
|
||||
${
|
||||
props.borderOnHover
|
||||
? ""
|
||||
: `svg {
|
||||
fill: ${props.iconColor || props.theme.buttonNeutralText};
|
||||
}`
|
||||
}
|
||||
|
||||
|
||||
&:hover {
|
||||
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
|
||||
border: 1px solid ${props.theme.buttonNeutralBorder};
|
||||
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
|
||||
props.theme.buttonNeutralBorder
|
||||
} 0 0 0 1px inset;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -71,9 +81,9 @@ const RealButton = styled.button`
|
||||
background: ${props.theme.danger};
|
||||
color: ${props.theme.white};
|
||||
|
||||
&:hover {
|
||||
background: ${darken(0.05, props.theme.danger)};
|
||||
}
|
||||
&:hover {
|
||||
background: ${darken(0.05, props.theme.danger)};
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -92,7 +102,7 @@ export const Inner = styled.span`
|
||||
line-height: ${(props) => (props.hasIcon ? 24 : 32)}px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
min-height: 32px;
|
||||
|
||||
${(props) => props.hasIcon && props.hasText && "padding-left: 4px;"};
|
||||
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
|
||||
@@ -107,6 +117,7 @@ export type Props = {
|
||||
children?: React.Node,
|
||||
innerRef?: React.ElementRef<any>,
|
||||
disclosure?: boolean,
|
||||
neutral?: boolean,
|
||||
fullwidth?: boolean,
|
||||
borderOnHover?: boolean,
|
||||
};
|
||||
@@ -118,13 +129,14 @@ function Button({
|
||||
value,
|
||||
disclosure,
|
||||
innerRef,
|
||||
neutral,
|
||||
...rest
|
||||
}: Props) {
|
||||
const hasText = children !== undefined || value !== undefined;
|
||||
const hasIcon = icon !== undefined;
|
||||
|
||||
return (
|
||||
<RealButton type={type} ref={innerRef} {...rest}>
|
||||
<RealButton type={type} ref={innerRef} $neutral={neutral} {...rest}>
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
{hasIcon && icon}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
|
||||
const Header = styled.h3`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
letter-spacing: 0.04em;
|
||||
margin: 1em 12px 0.5em;
|
||||
`;
|
||||
|
||||
export default Header;
|
||||
+28
-17
@@ -1,6 +1,7 @@
|
||||
// @flow
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
@@ -8,43 +9,51 @@ type Props = {
|
||||
children?: React.Node,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
as?: string | React.ComponentType<*>,
|
||||
};
|
||||
|
||||
const DropdownMenuItem = ({
|
||||
const MenuItem = ({
|
||||
onClick,
|
||||
children,
|
||||
selected,
|
||||
disabled,
|
||||
as,
|
||||
...rest
|
||||
}: Props) => {
|
||||
return (
|
||||
<MenuItem
|
||||
<BaseMenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
role="menuitem"
|
||||
tabIndex="-1"
|
||||
{...rest}
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<>
|
||||
<CheckmarkIcon
|
||||
color={selected === false ? "transparent" : undefined}
|
||||
/>
|
||||
|
||||
</>
|
||||
{(props) => (
|
||||
<MenuAnchor as={onClick ? "button" : as} {...props}>
|
||||
{selected !== undefined && (
|
||||
<>
|
||||
{selected ? <CheckmarkIcon /> : <Spacer />}
|
||||
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</MenuAnchor>
|
||||
)}
|
||||
{children}
|
||||
</MenuItem>
|
||||
</BaseMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuItem = styled.a`
|
||||
const Spacer = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export const MenuAnchor = styled.a`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 6px 12px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
|
||||
background: none;
|
||||
color: ${(props) =>
|
||||
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
|
||||
justify-content: left;
|
||||
@@ -58,6 +67,7 @@ const MenuItem = styled.a`
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
opacity: ${(props) => (props.disabled ? ".5" : 1)};
|
||||
}
|
||||
|
||||
@@ -66,7 +76,8 @@ const MenuItem = styled.a`
|
||||
? "pointer-events: none;"
|
||||
: `
|
||||
|
||||
&:hover {
|
||||
&:hover,
|
||||
&.focus-visible {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.theme.primary};
|
||||
box-shadow: none;
|
||||
@@ -84,4 +95,4 @@ const MenuItem = styled.a`
|
||||
`};
|
||||
`;
|
||||
|
||||
export default DropdownMenuItem;
|
||||
export default MenuItem;
|
||||
@@ -0,0 +1,21 @@
|
||||
// @flow
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import NudeButton from "components/NudeButton";
|
||||
|
||||
export default function OverflowMenuButton({
|
||||
iconColor,
|
||||
className,
|
||||
...rest
|
||||
}: any) {
|
||||
return (
|
||||
<MenuButton {...rest}>
|
||||
{(props) => (
|
||||
<NudeButton className={className} {...props}>
|
||||
<MoreIcon color={iconColor} />
|
||||
</NudeButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { MenuSeparator } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
|
||||
export default function Separator(rest: {}) {
|
||||
return (
|
||||
<MenuSeparator {...rest}>
|
||||
{(props) => <HorizontalRule {...props} />}
|
||||
</MenuSeparator>
|
||||
);
|
||||
}
|
||||
|
||||
const HorizontalRule = styled.hr`
|
||||
margin: 0.5em 12px;
|
||||
`;
|
||||
+65
-24
@@ -1,26 +1,38 @@
|
||||
// @flow
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import DropdownMenu from "./DropdownMenu";
|
||||
import DropdownMenuItem from "./DropdownMenuItem";
|
||||
import {
|
||||
useMenuState,
|
||||
MenuButton,
|
||||
MenuItem as BaseMenuItem,
|
||||
} from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import MenuItem, { MenuAnchor } from "./MenuItem";
|
||||
import Separator from "./Separator";
|
||||
import ContextMenu from ".";
|
||||
|
||||
type MenuItem =
|
||||
type TMenuItem =
|
||||
| {|
|
||||
title: React.Node,
|
||||
to: string,
|
||||
visible?: boolean,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
|}
|
||||
| {|
|
||||
title: React.Node,
|
||||
onClick: (event: SyntheticEvent<>) => void | Promise<void>,
|
||||
visible?: boolean,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
|}
|
||||
| {|
|
||||
title: React.Node,
|
||||
href: string,
|
||||
visible?: boolean,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
|}
|
||||
| {|
|
||||
@@ -29,7 +41,7 @@ type MenuItem =
|
||||
disabled?: boolean,
|
||||
style?: Object,
|
||||
hover?: boolean,
|
||||
items: MenuItem[],
|
||||
items: TMenuItem[],
|
||||
|}
|
||||
| {|
|
||||
type: "separator",
|
||||
@@ -42,10 +54,35 @@ type MenuItem =
|
||||
|};
|
||||
|
||||
type Props = {|
|
||||
items: MenuItem[],
|
||||
items: TMenuItem[],
|
||||
|};
|
||||
|
||||
export default function DropdownMenuItems({ items }: Props): React.Node {
|
||||
const Disclosure = styled(ExpandedIcon)`
|
||||
transform: rotate(270deg);
|
||||
justify-self: flex-end;
|
||||
`;
|
||||
|
||||
const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({ modal: true });
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton ref={ref} {...menu} {...rest}>
|
||||
{(props) => (
|
||||
<MenuAnchor {...props}>
|
||||
{title} <Disclosure color="currentColor" />
|
||||
</MenuAnchor>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Submenu")}>
|
||||
<Template {...menu} items={templateItems} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function Template({ items, ...menu }: Props): React.Node {
|
||||
let filtered = items.filter((item) => item.visible !== false);
|
||||
|
||||
// this block literally just trims unneccessary separators
|
||||
@@ -66,63 +103,67 @@ export default function DropdownMenuItems({ items }: Props): React.Node {
|
||||
return filtered.map((item, index) => {
|
||||
if (item.to) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
<MenuItem
|
||||
as={Link}
|
||||
to={item.to}
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</DropdownMenuItem>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.href) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
<MenuItem
|
||||
href={item.href}
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
target="_blank"
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</DropdownMenuItem>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.onClick) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
<MenuItem
|
||||
as="button"
|
||||
onClick={item.onClick}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
key={index}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</DropdownMenuItem>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.items) {
|
||||
return (
|
||||
<DropdownMenu
|
||||
style={item.style}
|
||||
label={
|
||||
<DropdownMenuItem disabled={item.disabled}>
|
||||
{item.title}
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
hover={item.hover}
|
||||
<BaseMenuItem
|
||||
key={index}
|
||||
>
|
||||
<DropdownMenuItems items={item.items} />
|
||||
</DropdownMenu>
|
||||
as={Submenu}
|
||||
templateItems={item.items}
|
||||
title={item.title}
|
||||
{...menu}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "separator") {
|
||||
return <hr key={index} />;
|
||||
return <Separator key={index} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export default React.memo<Props>(Template);
|
||||
@@ -0,0 +1,77 @@
|
||||
// @flow
|
||||
import { rgba } from "polished";
|
||||
import * as React from "react";
|
||||
import { Menu } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
|
||||
type Props = {|
|
||||
"aria-label": string,
|
||||
visible?: boolean,
|
||||
animating?: boolean,
|
||||
children: React.Node,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
|};
|
||||
|
||||
export default function ContextMenu({
|
||||
children,
|
||||
onOpen,
|
||||
onClose,
|
||||
...rest
|
||||
}: Props) {
|
||||
const previousVisible = usePrevious(rest.visible);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (rest.visible && !previousVisible) {
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
}
|
||||
if (!rest.visible && previousVisible) {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}, [onOpen, onClose, previousVisible, rest.visible]);
|
||||
|
||||
return (
|
||||
<Menu {...rest}>
|
||||
{(props) => (
|
||||
<Position {...props}>
|
||||
<Background>
|
||||
{rest.visible || rest.animating ? children : null}
|
||||
</Background>
|
||||
</Position>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
const Position = styled.div`
|
||||
position: absolute;
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
`;
|
||||
|
||||
const Background = styled.div`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: ${(props) => (props.left !== undefined ? "25%" : "75%")} 0;
|
||||
background: ${(props) => rgba(props.theme.menuBackground, 0.95)};
|
||||
border: ${(props) =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
border-radius: 2px;
|
||||
padding: 0.5em 0;
|
||||
min-width: 180px;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: 75vh;
|
||||
max-width: 276px;
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
pointer-events: all;
|
||||
font-weight: normal;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
@@ -1,6 +1,5 @@
|
||||
// @flow
|
||||
import format from "date-fns/format";
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
@@ -45,9 +44,7 @@ class RevisionListItem extends React.Component<Props> {
|
||||
<StyledRevisionMenu
|
||||
document={document}
|
||||
revision={revision}
|
||||
label={
|
||||
<MoreIcon color={selected ? theme.white : theme.textTertiary} />
|
||||
}
|
||||
iconColor={selected ? theme.white : theme.textTertiary}
|
||||
/>
|
||||
)}
|
||||
</StyledNavLink>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
|
||||
import * as React from "react";
|
||||
import Document from "models/Document";
|
||||
import DocumentPreview from "components/DocumentPreview";
|
||||
import DocumentListItem from "components/DocumentListItem";
|
||||
|
||||
type Props = {
|
||||
documents: Document[],
|
||||
@@ -18,7 +18,7 @@ export default function DocumentList({ limit, documents, ...rest }: Props) {
|
||||
defaultActiveChildIndex={0}
|
||||
>
|
||||
{items.map((document) => (
|
||||
<DocumentPreview key={document.id} document={document} {...rest} />
|
||||
<DocumentListItem key={document.id} document={document} {...rest} />
|
||||
))}
|
||||
</ArrowKeyNavigation>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Document from "models/Document";
|
||||
import Badge from "components/Badge";
|
||||
import Button from "components/Button";
|
||||
import DocumentMeta from "components/DocumentMeta";
|
||||
import EventBoundary from "components/EventBoundary";
|
||||
import Flex from "components/Flex";
|
||||
import Highlight from "components/Highlight";
|
||||
import StarButton, { AnimatedStar } from "components/Star";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
highlight?: ?string,
|
||||
context?: ?string,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
showDraft?: boolean,
|
||||
showTemplate?: boolean,
|
||||
|};
|
||||
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
|
||||
function replaceResultMarks(tag: string) {
|
||||
// don't use SEARCH_RESULT_REGEX here as it causes
|
||||
// an infinite loop to trigger a regex inside it's own callback
|
||||
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
|
||||
}
|
||||
|
||||
function DocumentListItem(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const {
|
||||
document,
|
||||
showCollection,
|
||||
showPublished,
|
||||
showPin,
|
||||
showDraft = true,
|
||||
showTemplate,
|
||||
highlight,
|
||||
context,
|
||||
} = props;
|
||||
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar =
|
||||
!document.isDraft && !document.isArchived && !document.isTemplate;
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: document.url,
|
||||
state: { title: document.titleWithDefault },
|
||||
}}
|
||||
>
|
||||
<Content>
|
||||
<Heading>
|
||||
<Title text={document.titleWithDefault} highlight={highlight} />
|
||||
{document.isNew && document.createdBy.id !== currentUser.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip
|
||||
tooltip={t("Only visible to you")}
|
||||
delay={500}
|
||||
placement="top"
|
||||
>
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showLastViewed
|
||||
/>
|
||||
</Content>
|
||||
<Actions>
|
||||
{document.isTemplate && !document.isArchived && !document.isDeleted && (
|
||||
<>
|
||||
<Button
|
||||
as={Link}
|
||||
to={newDocumentUrl(document.collectionId, {
|
||||
templateId: document.id,
|
||||
})}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
|
||||
</>
|
||||
)}
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
showPin={showPin}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
modal={false}
|
||||
/>
|
||||
</Actions>
|
||||
</DocumentLink>
|
||||
);
|
||||
}
|
||||
|
||||
const Content = styled.div`
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const Actions = styled(EventBoundary)`
|
||||
display: none;
|
||||
align-items: center;
|
||||
margin: 8px;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
`;
|
||||
|
||||
const DocumentLink = styled(Link)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 10px -8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
max-height: 50vh;
|
||||
min-width: 100%;
|
||||
max-width: calc(100vw - 40px);
|
||||
|
||||
${Actions} {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
${AnimatedStar} {
|
||||
opacity: ${(props) => (props.$isStarred ? "1 !important" : 0)};
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
|
||||
${Actions} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${AnimatedStar} {
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$menuOpen &&
|
||||
css`
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
|
||||
${Actions} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${AnimatedStar} {
|
||||
opacity: 0.5;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Heading = styled.h3`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.25em;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
`;
|
||||
|
||||
const StarPositioner = styled(Flex)`
|
||||
margin-left: 4px;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Title = styled(Highlight)`
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const ResultContext = styled(Highlight)`
|
||||
display: block;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-size: 14px;
|
||||
margin-top: -0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
`;
|
||||
|
||||
export default observer(DocumentListItem);
|
||||
@@ -15,6 +15,7 @@ const Container = styled(Flex)`
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const Modified = styled.span`
|
||||
|
||||
@@ -35,6 +35,8 @@ function DocumentMetaWithViews({ to, isDraft, document }: Props) {
|
||||
const Meta = styled(DocumentMeta)`
|
||||
margin: -12px 0 2em 0;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { StarredIcon, PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { Link, Redirect } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import Badge from "components/Badge";
|
||||
import Button from "components/Button";
|
||||
import DocumentMeta from "components/DocumentMeta";
|
||||
import EventBoundary from "components/EventBoundary";
|
||||
import Flex from "components/Flex";
|
||||
import Highlight from "components/Highlight";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document,
|
||||
highlight?: ?string,
|
||||
context?: ?string,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
showDraft?: boolean,
|
||||
showTemplate?: boolean,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
|
||||
@observer
|
||||
class DocumentPreview extends React.Component<Props> {
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
handleStar = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.document.star();
|
||||
};
|
||||
|
||||
handleUnstar = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.document.unstar();
|
||||
};
|
||||
|
||||
replaceResultMarks = (tag: string) => {
|
||||
// don't use SEARCH_RESULT_REGEX here as it causes
|
||||
// an infinite loop to trigger a regex inside it's own callback
|
||||
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
|
||||
};
|
||||
|
||||
handleNewFromTemplate = (event: SyntheticEvent<>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const { document } = this.props;
|
||||
|
||||
this.redirectTo = newDocumentUrl(document.collectionId, {
|
||||
templateId: document.id,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
document,
|
||||
showCollection,
|
||||
showPublished,
|
||||
showPin,
|
||||
showDraft = true,
|
||||
showTemplate,
|
||||
highlight,
|
||||
context,
|
||||
t,
|
||||
} = this.props;
|
||||
|
||||
if (this.redirectTo) {
|
||||
return <Redirect to={this.redirectTo} push />;
|
||||
}
|
||||
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
to={{
|
||||
pathname: document.url,
|
||||
state: { title: document.titleWithDefault },
|
||||
}}
|
||||
>
|
||||
<Heading>
|
||||
<Title text={document.titleWithDefault} highlight={highlight} />
|
||||
{document.isNew && <Badge yellow>{t("New")}</Badge>}
|
||||
{!document.isDraft &&
|
||||
!document.isArchived &&
|
||||
!document.isTemplate && (
|
||||
<Actions>
|
||||
{document.isStarred ? (
|
||||
<StyledStar onClick={this.handleUnstar} solid />
|
||||
) : (
|
||||
<StyledStar onClick={this.handleStar} />
|
||||
)}
|
||||
</Actions>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip
|
||||
tooltip={t("Only visible to you")}
|
||||
delay={500}
|
||||
placement="top"
|
||||
>
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
<SecondaryActions>
|
||||
{document.isTemplate &&
|
||||
!document.isArchived &&
|
||||
!document.isDeleted && (
|
||||
<Button
|
||||
onClick={this.handleNewFromTemplate}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<EventBoundary>
|
||||
<DocumentMenu document={document} showPin={showPin} />
|
||||
</EventBoundary>
|
||||
</SecondaryActions>
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={this.replaceResultMarks}
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showLastViewed
|
||||
/>
|
||||
</DocumentLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const StyledStar = withTheme(styled(({ solid, theme, ...props }) => (
|
||||
<StarredIcon color={theme.text} {...props} />
|
||||
))`
|
||||
flex-shrink: 0;
|
||||
opacity: ${(props) => (props.solid ? "1 !important" : 0)};
|
||||
transition: all 100ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
`);
|
||||
|
||||
const SecondaryActions = styled(Flex)`
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
`;
|
||||
|
||||
const DocumentLink = styled(Link)`
|
||||
display: block;
|
||||
margin: 10px -8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
max-height: 50vh;
|
||||
min-width: 100%;
|
||||
max-width: calc(100vw - 40px);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
${SecondaryActions} {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
|
||||
${SecondaryActions} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${StyledStar} {
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Heading = styled.h3`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.25em;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
`;
|
||||
|
||||
const Actions = styled(Flex)`
|
||||
margin-left: 4px;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Title = styled(Highlight)`
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const ResultContext = styled(Highlight)`
|
||||
display: block;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-size: 14px;
|
||||
margin-top: -0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
`;
|
||||
|
||||
export default withTranslation()<DocumentPreview>(DocumentPreview);
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import DocumentPreview from "./DocumentPreview";
|
||||
export default DocumentPreview;
|
||||
@@ -5,7 +5,7 @@ import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
|
||||
import { createGlobalStyle } from "styled-components";
|
||||
import styled, { css } from "styled-components";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
@@ -17,8 +17,6 @@ type Props = {
|
||||
children: React.Node,
|
||||
collectionId: string,
|
||||
documentId?: string,
|
||||
activeClassName?: string,
|
||||
rejectClassName?: string,
|
||||
ui: UiStore,
|
||||
documents: DocumentsStore,
|
||||
disabled: boolean,
|
||||
@@ -28,18 +26,6 @@ type Props = {
|
||||
staticContext: Object,
|
||||
};
|
||||
|
||||
export const GlobalStyles = createGlobalStyle`
|
||||
.activeDropZone {
|
||||
border-radius: 4px;
|
||||
background: ${(props) => props.theme.slateDark};
|
||||
svg { fill: ${(props) => props.theme.white}; }
|
||||
}
|
||||
|
||||
.activeDropZone a {
|
||||
color: ${(props) => props.theme.white} !important;
|
||||
}
|
||||
`;
|
||||
|
||||
@observer
|
||||
class DropToImport extends React.Component<Props> {
|
||||
@observable isImporting: boolean = false;
|
||||
@@ -74,7 +60,9 @@ class DropToImport extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(`Could not import file. ${err.message}`);
|
||||
this.props.ui.showToast(`Could not import file. ${err.message}`, {
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
this.isImporting = false;
|
||||
importingLock = false;
|
||||
@@ -82,18 +70,7 @@ class DropToImport extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
documentId,
|
||||
collectionId,
|
||||
documents,
|
||||
disabled,
|
||||
location,
|
||||
match,
|
||||
history,
|
||||
staticContext,
|
||||
ui,
|
||||
...rest
|
||||
} = this.props;
|
||||
const { documents } = this.props;
|
||||
|
||||
if (this.props.disabled) return this.props.children;
|
||||
|
||||
@@ -102,16 +79,45 @@ class DropToImport extends React.Component<Props> {
|
||||
accept={documents.importFileTypes.join(", ")}
|
||||
onDropAccepted={this.onDropAccepted}
|
||||
style={EMPTY_OBJECT}
|
||||
disableClick
|
||||
disablePreview
|
||||
noClick
|
||||
multiple
|
||||
{...rest}
|
||||
>
|
||||
{this.isImporting && <LoadingIndicator />}
|
||||
{this.props.children}
|
||||
{({
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
isDragActive,
|
||||
isDragAccept,
|
||||
isDragReject,
|
||||
}) => (
|
||||
<DropzoneContainer
|
||||
{...getRootProps()}
|
||||
{...{ isDragActive }}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{this.isImporting && <LoadingIndicator />}
|
||||
{this.props.children}
|
||||
</DropzoneContainer>
|
||||
)}
|
||||
</Dropzone>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const DropzoneContainer = styled("div")`
|
||||
border-radius: 4px;
|
||||
|
||||
${({ isDragActive, theme }) =>
|
||||
isDragActive &&
|
||||
css`
|
||||
background: ${theme.slateDark};
|
||||
a {
|
||||
color: ${theme.white} !important;
|
||||
}
|
||||
svg {
|
||||
fill: ${theme.white};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export default inject("documents", "ui")(withRouter(DropToImport));
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import { rgba } from "polished";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { PortalWithState } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||
import Flex from "components/Flex";
|
||||
import NudeButton from "components/NudeButton";
|
||||
|
||||
let previousClosePortal;
|
||||
let counter = 0;
|
||||
|
||||
type Children =
|
||||
| React.Node
|
||||
| ((options: { closePortal: () => void }) => React.Node);
|
||||
|
||||
type Props = {|
|
||||
label?: React.Node,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
children?: Children,
|
||||
className?: string,
|
||||
hover?: boolean,
|
||||
style?: Object,
|
||||
position?: "left" | "right" | "center",
|
||||
t: TFunction,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class DropdownMenu extends React.Component<Props> {
|
||||
id: string = `menu${counter++}`;
|
||||
closeTimeout: TimeoutID;
|
||||
|
||||
@observable top: ?number;
|
||||
@observable bottom: ?number;
|
||||
@observable right: ?number;
|
||||
@observable left: ?number;
|
||||
@observable position: "left" | "right" | "center";
|
||||
@observable fixed: ?boolean;
|
||||
@observable bodyRect: ClientRect;
|
||||
@observable labelRect: ClientRect;
|
||||
@observable dropdownRef: { current: null | HTMLElement } = React.createRef();
|
||||
@observable menuRef: { current: null | HTMLElement } = React.createRef();
|
||||
|
||||
handleOpen = (
|
||||
openPortal: (SyntheticEvent<>) => void,
|
||||
closePortal: () => void
|
||||
) => {
|
||||
return (ev: SyntheticMouseEvent<HTMLElement>) => {
|
||||
ev.preventDefault();
|
||||
const currentTarget = ev.currentTarget;
|
||||
invariant(document.body, "why you not here");
|
||||
|
||||
if (currentTarget instanceof HTMLDivElement) {
|
||||
this.bodyRect = document.body.getBoundingClientRect();
|
||||
this.labelRect = currentTarget.getBoundingClientRect();
|
||||
this.top = this.labelRect.bottom - this.bodyRect.top;
|
||||
this.bottom = undefined;
|
||||
this.position = this.props.position || "left";
|
||||
|
||||
if (currentTarget.parentElement) {
|
||||
const triggerParentStyle = getComputedStyle(
|
||||
currentTarget.parentElement
|
||||
);
|
||||
|
||||
if (triggerParentStyle.position === "static") {
|
||||
this.fixed = true;
|
||||
this.top = this.labelRect.bottom;
|
||||
}
|
||||
}
|
||||
|
||||
this.initPosition();
|
||||
|
||||
// attempt to keep only one flyout menu open at once
|
||||
if (previousClosePortal && !this.props.hover) {
|
||||
previousClosePortal();
|
||||
}
|
||||
previousClosePortal = closePortal;
|
||||
openPortal(ev);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
initPosition() {
|
||||
if (this.position === "left") {
|
||||
this.right =
|
||||
this.bodyRect.width - this.labelRect.left - this.labelRect.width;
|
||||
} else if (this.position === "center") {
|
||||
this.left = this.labelRect.left + this.labelRect.width / 2;
|
||||
} else {
|
||||
this.left = this.labelRect.left;
|
||||
}
|
||||
}
|
||||
|
||||
onOpen = () => {
|
||||
if (typeof this.props.onOpen === "function") {
|
||||
this.props.onOpen();
|
||||
}
|
||||
this.fitOnTheScreen();
|
||||
};
|
||||
|
||||
fitOnTheScreen() {
|
||||
if (!this.dropdownRef || !this.dropdownRef.current) return;
|
||||
const el = this.dropdownRef.current;
|
||||
|
||||
const sticksOutPastBottomEdge =
|
||||
el.clientHeight + this.top > window.innerHeight;
|
||||
if (sticksOutPastBottomEdge) {
|
||||
this.top = undefined;
|
||||
this.bottom = this.fixed ? 0 : -1 * window.pageYOffset;
|
||||
} else {
|
||||
this.bottom = undefined;
|
||||
}
|
||||
|
||||
if (this.position === "left" || this.position === "right") {
|
||||
const totalWidth =
|
||||
Math.sign(this.position === "left" ? -1 : 1) * el.offsetLeft +
|
||||
el.scrollWidth;
|
||||
const isVisible = totalWidth < window.innerWidth;
|
||||
|
||||
if (!isVisible) {
|
||||
if (this.position === "right") {
|
||||
this.position = "left";
|
||||
this.left = undefined;
|
||||
} else if (this.position === "left") {
|
||||
this.position = "right";
|
||||
this.right = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.initPosition();
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
closeAfterTimeout = (closePortal: () => void) => () => {
|
||||
if (this.closeTimeout) {
|
||||
clearTimeout(this.closeTimeout);
|
||||
}
|
||||
this.closeTimeout = setTimeout(closePortal, 500);
|
||||
};
|
||||
|
||||
clearCloseTimeout = () => {
|
||||
if (this.closeTimeout) {
|
||||
clearTimeout(this.closeTimeout);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { className, hover, label, children, t } = this.props;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<PortalWithState
|
||||
onOpen={this.onOpen}
|
||||
onClose={this.props.onClose}
|
||||
closeOnOutsideClick
|
||||
closeOnEsc
|
||||
>
|
||||
{({ closePortal, openPortal, isOpen, portal }) => (
|
||||
<>
|
||||
<Label
|
||||
onMouseMove={hover ? this.clearCloseTimeout : undefined}
|
||||
onMouseOut={
|
||||
hover ? this.closeAfterTimeout(closePortal) : undefined
|
||||
}
|
||||
onMouseEnter={
|
||||
hover ? this.handleOpen(openPortal, closePortal) : undefined
|
||||
}
|
||||
onClick={
|
||||
hover ? undefined : this.handleOpen(openPortal, closePortal)
|
||||
}
|
||||
>
|
||||
{label || (
|
||||
<NudeButton
|
||||
id={`${this.id}button`}
|
||||
aria-label={t("More options")}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isOpen ? "true" : "false"}
|
||||
aria-controls={this.id}
|
||||
>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
)}
|
||||
</Label>
|
||||
{portal(
|
||||
<Position
|
||||
ref={this.dropdownRef}
|
||||
position={this.position}
|
||||
fixed={this.fixed}
|
||||
top={this.top}
|
||||
bottom={this.bottom}
|
||||
left={this.left}
|
||||
right={this.right}
|
||||
>
|
||||
<Menu
|
||||
ref={this.menuRef}
|
||||
onMouseMove={hover ? this.clearCloseTimeout : undefined}
|
||||
onMouseOut={
|
||||
hover ? this.closeAfterTimeout(closePortal) : undefined
|
||||
}
|
||||
onClick={
|
||||
typeof children === "function"
|
||||
? undefined
|
||||
: (ev) => {
|
||||
ev.stopPropagation();
|
||||
closePortal();
|
||||
}
|
||||
}
|
||||
style={this.props.style}
|
||||
id={this.id}
|
||||
aria-labelledby={`${this.id}button`}
|
||||
role="menu"
|
||||
>
|
||||
{typeof children === "function"
|
||||
? children({ closePortal })
|
||||
: children}
|
||||
</Menu>
|
||||
</Position>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PortalWithState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Label = styled(Flex).attrs({
|
||||
justify: "center",
|
||||
align: "center",
|
||||
})`
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const Position = styled.div`
|
||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
||||
display: flex;
|
||||
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
|
||||
${({ right }) => (right !== undefined ? `right: ${right}px` : "")};
|
||||
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
|
||||
${({ bottom }) => (bottom !== undefined ? `bottom: ${bottom}px` : "")};
|
||||
max-height: 75%;
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
transform: ${(props) =>
|
||||
props.position === "center" ? "translateX(-50%)" : "initial"};
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
const Menu = styled.div`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: ${(props) => (props.left !== undefined ? "25%" : "75%")} 0;
|
||||
backdrop-filter: blur(10px);
|
||||
background: ${(props) => rgba(props.theme.menuBackground, 0.8)};
|
||||
border: ${(props) =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
border-radius: 2px;
|
||||
padding: 0.5em 0;
|
||||
min-width: 180px;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
pointer-events: all;
|
||||
|
||||
hr {
|
||||
margin: 0.5em 12px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Header = styled.h3`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
letter-spacing: 0.04em;
|
||||
margin: 1em 12px 0.5em;
|
||||
`;
|
||||
|
||||
export default withTranslation()<DropdownMenu>(DropdownMenu);
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
export { default as DropdownMenu, Header } from "./DropdownMenu";
|
||||
export { default as DropdownMenuItem } from "./DropdownMenuItem";
|
||||
+15
-15
@@ -9,6 +9,7 @@ import ErrorBoundary from "components/ErrorBoundary";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import embeds from "../embeds";
|
||||
import isInternalUrl from "utils/isInternalUrl";
|
||||
import { isMetaKey } from "utils/keyboard";
|
||||
import { uploadFile } from "utils/uploadFile";
|
||||
|
||||
const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));
|
||||
@@ -49,7 +50,7 @@ function Editor(props: PropsWithRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInternalUrl(href) && !event.metaKey && !event.shiftKey) {
|
||||
if (isInternalUrl(href) && !isMetaKey(event) && !event.shiftKey) {
|
||||
// relative
|
||||
let navigateTo = href;
|
||||
|
||||
@@ -102,7 +103,7 @@ function Editor(props: PropsWithRef) {
|
||||
deleteTable: t("Delete table"),
|
||||
em: t("Italic"),
|
||||
embedInvalidLink: t("Sorry, that link won’t work for this embed type"),
|
||||
findOrCreateDoc: t("Find or create a doc…"),
|
||||
findOrCreateDoc: `${t("Find or create a doc")}…`,
|
||||
h1: t("Big heading"),
|
||||
h2: t("Medium heading"),
|
||||
h3: t("Small heading"),
|
||||
@@ -115,18 +116,18 @@ function Editor(props: PropsWithRef) {
|
||||
link: t("Link"),
|
||||
linkCopied: t("Link copied to clipboard"),
|
||||
mark: t("Highlight"),
|
||||
newLineEmpty: t("Type '/' to insert…"),
|
||||
newLineWithSlash: t("Keep typing to filter…"),
|
||||
newLineEmpty: `${t("Type '/' to insert")}…`,
|
||||
newLineWithSlash: `${t("Keep typing to filter")}…`,
|
||||
noResults: t("No results"),
|
||||
openLink: t("Open link"),
|
||||
orderedList: t("Ordered list"),
|
||||
pasteLink: t("Paste a link…"),
|
||||
pasteLink: `${t("Paste a link")}…`,
|
||||
pasteLinkWithTitle: (service: string) =>
|
||||
t("Paste a {{service}} link…", { service }),
|
||||
placeholder: t("Placeholder"),
|
||||
quote: t("Quote"),
|
||||
removeLink: t("Remove link"),
|
||||
searchOrPasteLink: t("Search or paste a link…"),
|
||||
searchOrPasteLink: `${t("Search or paste a link")}…`,
|
||||
strikethrough: t("Strikethrough"),
|
||||
strong: t("Bold"),
|
||||
subheading: t("Subheading"),
|
||||
@@ -171,17 +172,16 @@ const StyledEditor = styled(RichMarkdownEditor)`
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.heading-name {
|
||||
pointer-events: none;
|
||||
.heading-anchor {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* pseudo element allows us to add spacing for fixed header */
|
||||
/* ref: https://stackoverflow.com/a/28824157 */
|
||||
.heading-name::before {
|
||||
content: "";
|
||||
display: ${(props) => (props.readOnly ? "block" : "none")};
|
||||
height: 72px;
|
||||
margin: -72px 0 0;
|
||||
.heading-name {
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
position: relative;
|
||||
top: -60px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.heading-name:first-child {
|
||||
|
||||
@@ -4,13 +4,18 @@ import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
className?: string,
|
||||
};
|
||||
|
||||
export default function EventBoundary({ children }: Props) {
|
||||
export default function EventBoundary({ children, className }: Props) {
|
||||
const handleClick = React.useCallback((event: SyntheticEvent<>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return <span onClick={handleClick}>{children}</span>;
|
||||
return (
|
||||
<span onClick={handleClick} className={className}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@ const Heading = styled.h1`
|
||||
${(props) => (props.centered ? "text-align: center;" : "")}
|
||||
|
||||
svg {
|
||||
margin-top: 4px;
|
||||
margin-left: -6px;
|
||||
margin-right: 2px;
|
||||
align-self: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
CollectionIcon,
|
||||
CoinsIcon,
|
||||
@@ -22,14 +20,17 @@ import {
|
||||
VehicleIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { DropdownMenu } from "components/DropdownMenu";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import { LabelText } from "components/Input";
|
||||
import NudeButton from "components/NudeButton";
|
||||
|
||||
const style = { width: 30, height: 30 };
|
||||
|
||||
const TwitterPicker = React.lazy(() =>
|
||||
import("react-color/lib/components/twitter/Twitter")
|
||||
);
|
||||
@@ -122,107 +123,77 @@ const colors = [
|
||||
"#2F362F",
|
||||
];
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
onOpen?: () => void,
|
||||
onChange: (color: string, icon: string) => void,
|
||||
icon: string,
|
||||
color: string,
|
||||
t: TFunction,
|
||||
};
|
||||
|};
|
||||
|
||||
function preventEventBubble(event) {
|
||||
event.stopPropagation();
|
||||
function IconPicker({ onOpen, icon, color, onChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
placement: "bottom-end",
|
||||
});
|
||||
const Component = icons[icon || "collection"].component;
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Label>
|
||||
<LabelText>{t("Icon")}</LabelText>
|
||||
</Label>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<Button {...props}>
|
||||
<Component role="button" color={color} size={30} />
|
||||
</Button>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} onOpen={onOpen} aria-label={t("Choose icon")}>
|
||||
<Icons>
|
||||
{Object.keys(icons).map((name) => {
|
||||
const Component = icons[name].component;
|
||||
return (
|
||||
<MenuItem
|
||||
key={name}
|
||||
onClick={() => onChange(color, name)}
|
||||
{...menu}
|
||||
>
|
||||
{(props) => (
|
||||
<IconButton style={style} {...props}>
|
||||
<Component color={color} size={30} />
|
||||
</IconButton>
|
||||
)}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Icons>
|
||||
<Flex>
|
||||
<React.Suspense fallback={<Loading>{t("Loading")}…</Loading>}>
|
||||
<ColorPicker
|
||||
color={color}
|
||||
onChange={(color) => onChange(color.hex, icon)}
|
||||
colors={colors}
|
||||
triangle="hide"
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Flex>
|
||||
</ContextMenu>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@observer
|
||||
class IconPicker extends React.Component<Props> {
|
||||
@observable isOpen: boolean = false;
|
||||
node: ?HTMLElement;
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener("click", this.handleClickOutside);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("click", this.handleClickOutside);
|
||||
}
|
||||
|
||||
handleClose = () => {
|
||||
this.isOpen = false;
|
||||
};
|
||||
|
||||
handleOpen = () => {
|
||||
this.isOpen = true;
|
||||
|
||||
if (this.props.onOpen) {
|
||||
this.props.onOpen();
|
||||
}
|
||||
};
|
||||
|
||||
handleClickOutside = (ev: SyntheticMouseEvent<>) => {
|
||||
// $FlowFixMe
|
||||
if (ev.target && this.node && this.node.contains(ev.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleClose();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
const Component = icons[this.props.icon || "collection"].component;
|
||||
|
||||
return (
|
||||
<Wrapper ref={(ref) => (this.node = ref)}>
|
||||
<label>
|
||||
<LabelText>{t("Icon")}</LabelText>
|
||||
</label>
|
||||
<DropdownMenu
|
||||
onOpen={this.handleOpen}
|
||||
label={
|
||||
<LabelButton>
|
||||
<Component role="button" color={this.props.color} size={30} />
|
||||
</LabelButton>
|
||||
}
|
||||
>
|
||||
<Icons onClick={preventEventBubble}>
|
||||
{Object.keys(icons).map((name) => {
|
||||
const Component = icons[name].component;
|
||||
return (
|
||||
<IconButton
|
||||
key={name}
|
||||
onClick={() => this.props.onChange(this.props.color, name)}
|
||||
style={{ width: 30, height: 30 }}
|
||||
>
|
||||
<Component color={this.props.color} size={30} />
|
||||
</IconButton>
|
||||
);
|
||||
})}
|
||||
</Icons>
|
||||
<Flex onClick={preventEventBubble}>
|
||||
<React.Suspense fallback={<Loading>{t("Loading…")}</Loading>}>
|
||||
<ColorPicker
|
||||
color={this.props.color}
|
||||
onChange={(color) =>
|
||||
this.props.onChange(color.hex, this.props.icon)
|
||||
}
|
||||
colors={colors}
|
||||
triangle="hide"
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Flex>
|
||||
</DropdownMenu>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
const Label = styled.label`
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const Icons = styled.div`
|
||||
padding: 15px 9px 9px 15px;
|
||||
width: 276px;
|
||||
`;
|
||||
|
||||
const LabelButton = styled(NudeButton)`
|
||||
const Button = styled(NudeButton)`
|
||||
border: 1px solid ${(props) => props.theme.inputBorder};
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -249,4 +220,4 @@ const Wrapper = styled("div")`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export default withTranslation()<IconPicker>(IconPicker);
|
||||
export default IconPicker;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import Input from "./Input";
|
||||
import { type Theme } from "types";
|
||||
import { meta } from "utils/keyboard";
|
||||
import { searchUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
@@ -25,7 +26,7 @@ class InputSearch extends React.Component<Props> {
|
||||
input: ?Input;
|
||||
@observable focused: boolean = false;
|
||||
|
||||
@keydown("meta+f")
|
||||
@keydown(`${meta}+f`)
|
||||
focus(ev: SyntheticEvent<>) {
|
||||
ev.preventDefault();
|
||||
|
||||
@@ -54,7 +55,7 @@ class InputSearch extends React.Component<Props> {
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
const { theme, placeholder = t("Search…") } = this.props;
|
||||
const { theme, placeholder = `${t("Search")}…` } = this.props;
|
||||
|
||||
return (
|
||||
<InputMaxWidth
|
||||
|
||||
@@ -9,7 +9,8 @@ import { Outline, LabelText } from "./Input";
|
||||
const Select = styled.select`
|
||||
border: 0;
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
padding: 8px 0;
|
||||
margin: 0 12px;
|
||||
outline: none;
|
||||
background: none;
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
@@ -15,7 +15,6 @@ import ErrorSuspended from "scenes/ErrorSuspended";
|
||||
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
|
||||
import Analytics from "components/Analytics";
|
||||
import DocumentHistory from "components/DocumentHistory";
|
||||
import { GlobalStyles } from "components/DropToImport";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
import { LoadingIndicatorBar } from "components/LoadingIndicator";
|
||||
@@ -23,6 +22,7 @@ import Modal from "components/Modal";
|
||||
import Sidebar from "components/Sidebar";
|
||||
import SettingsSidebar from "components/Sidebar/Settings";
|
||||
import { type Theme } from "types";
|
||||
import { meta } from "utils/keyboard";
|
||||
import {
|
||||
homeUrl,
|
||||
searchUrl,
|
||||
@@ -66,6 +66,11 @@ class Layout extends React.Component<Props> {
|
||||
window.document.body.style.background = props.theme.background;
|
||||
}
|
||||
|
||||
@keydown(`${meta}+.`)
|
||||
handleToggleSidebar() {
|
||||
this.props.ui.toggleCollapsedSidebar();
|
||||
}
|
||||
|
||||
@keydown("shift+/")
|
||||
handleOpenKeyboardShortcuts() {
|
||||
if (this.props.ui.editMode) return;
|
||||
@@ -76,7 +81,7 @@ class Layout extends React.Component<Props> {
|
||||
this.keyboardShortcutsOpen = false;
|
||||
};
|
||||
|
||||
@keydown(["t", "/", "meta+k"])
|
||||
@keydown(["t", "/", `${meta}+k`])
|
||||
goToSearch(ev: SyntheticEvent<>) {
|
||||
if (this.props.ui.editMode) return;
|
||||
ev.preventDefault();
|
||||
@@ -120,7 +125,11 @@ class Layout extends React.Component<Props> {
|
||||
</Switch>
|
||||
)}
|
||||
|
||||
<Content auto justify="center" editMode={ui.editMode}>
|
||||
<Content
|
||||
auto
|
||||
justify="center"
|
||||
sidebarCollapsed={ui.editMode || ui.sidebarCollapsed}
|
||||
>
|
||||
{this.props.children}
|
||||
</Content>
|
||||
|
||||
@@ -138,7 +147,6 @@ class Layout extends React.Component<Props> {
|
||||
>
|
||||
<KeyboardShortcuts />
|
||||
</Modal>
|
||||
<GlobalStyles />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -161,7 +169,10 @@ const Content = styled(Flex)`
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
margin-left: ${(props) => (props.editMode ? 0 : props.theme.sidebarWidth)};
|
||||
margin-left: ${(props) =>
|
||||
props.sidebarCollapsed
|
||||
? props.theme.sidebarCollapsedWidth
|
||||
: props.theme.sidebarWidth};
|
||||
`};
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import format from "date-fns/format";
|
||||
import * as React from "react";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import useUserLocale from "hooks/useUserLocale";
|
||||
|
||||
const locales = {
|
||||
en: require(`date-fns/locale/en`),
|
||||
de: require(`date-fns/locale/de`),
|
||||
es: require(`date-fns/locale/es`),
|
||||
fr: require(`date-fns/locale/fr`),
|
||||
ko: require(`date-fns/locale/ko`),
|
||||
pt: require(`date-fns/locale/pt`),
|
||||
};
|
||||
|
||||
let callbacks = [];
|
||||
|
||||
// This is a shared timer that fires every minute, used for
|
||||
// updating all Time components across the page all at once.
|
||||
setInterval(() => {
|
||||
callbacks.forEach((cb) => cb());
|
||||
}, 1000 * 60);
|
||||
|
||||
function eachMinute(fn) {
|
||||
callbacks.push(fn);
|
||||
|
||||
return () => {
|
||||
callbacks = callbacks.filter((cb) => cb !== fn);
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
dateTime: string,
|
||||
children?: React.Node,
|
||||
tooltipDelay?: number,
|
||||
addSuffix?: boolean,
|
||||
shorten?: boolean,
|
||||
};
|
||||
|
||||
function LocaleTime({
|
||||
addSuffix,
|
||||
children,
|
||||
dateTime,
|
||||
shorten,
|
||||
tooltipDelay,
|
||||
}: Props) {
|
||||
const userLocale = useUserLocale();
|
||||
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line no-unused-vars
|
||||
const callback = React.useRef();
|
||||
|
||||
React.useEffect(() => {
|
||||
callback.current = eachMinute(() => {
|
||||
setMinutesMounted((state) => ++state);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (callback.current) {
|
||||
callback.current();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
let content = distanceInWordsToNow(dateTime, {
|
||||
addSuffix,
|
||||
locale: userLocale ? locales[userLocale] : undefined,
|
||||
});
|
||||
|
||||
if (shorten) {
|
||||
content = content
|
||||
.replace("about", "")
|
||||
.replace("less than a minute ago", "just now")
|
||||
.replace("minute", "min");
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
tooltip={format(dateTime, "MMMM Do, YYYY h:mm a")}
|
||||
delay={tooltipDelay}
|
||||
placement="bottom"
|
||||
>
|
||||
<time dateTime={dateTime}>{children || content}</time>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default LocaleTime;
|
||||
@@ -3,15 +3,17 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Button = styled.button`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
line-height: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
export default React.forwardRef<any, typeof Button>((props, ref) => (
|
||||
<Button {...props} ref={ref} />
|
||||
));
|
||||
export default React.forwardRef<any, typeof Button>(
|
||||
({ size = 24, ...props }, ref) => <Button size={size} {...props} ref={ref} />
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import Document from "models/Document";
|
||||
import DocumentPreview from "components/DocumentPreview";
|
||||
import DocumentListItem from "components/DocumentListItem";
|
||||
import PaginatedList from "components/PaginatedList";
|
||||
|
||||
type Props = {
|
||||
@@ -26,7 +26,7 @@ class PaginatedDocumentList extends React.Component<Props> {
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item) => (
|
||||
<DocumentPreview key={item.id} document={item} {...rest} />
|
||||
<DocumentListItem key={item.id} document={item} {...rest} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -75,16 +75,17 @@ class MainSidebar extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<AccountMenu
|
||||
label={
|
||||
<AccountMenu>
|
||||
{(props) => (
|
||||
<HeaderBlock
|
||||
{...props}
|
||||
subheading={user.name}
|
||||
teamName={team.name}
|
||||
logoUrl={team.avatarUrl}
|
||||
showDisclosure
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</AccountMenu>
|
||||
<Flex auto column>
|
||||
<Scrollable shadow>
|
||||
<Section>
|
||||
|
||||
@@ -8,6 +8,7 @@ import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import CollapseToggle, { Button } from "./components/CollapseToggle";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
@@ -30,10 +31,14 @@ function Sidebar({ location, children }: Props) {
|
||||
|
||||
const content = (
|
||||
<Container
|
||||
editMode={ui.editMode}
|
||||
mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
collapsed={ui.editMode || ui.sidebarCollapsed}
|
||||
column
|
||||
>
|
||||
<CollapseToggle
|
||||
collapsed={ui.sidebarCollapsed}
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
/>
|
||||
<Toggle
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
@@ -63,7 +68,7 @@ const Container = styled(Flex)`
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
transition: left 100ms ease-out,
|
||||
transition: box-shadow, 100ms, ease-in-out, left 100ms ease-out,
|
||||
${(props) => props.theme.backgroundTransition};
|
||||
margin-left: ${(props) => (props.mobileSidebarVisible ? 0 : "-100%")};
|
||||
z-index: ${(props) => props.theme.depths.sidebar};
|
||||
@@ -90,10 +95,33 @@ const Container = styled(Flex)`
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
left: ${(props) => (props.editMode ? `-${props.theme.sidebarWidth}` : 0)};
|
||||
left: ${(props) =>
|
||||
props.collapsed
|
||||
? `calc(-${props.theme.sidebarWidth} + ${props.theme.sidebarCollapsedWidth})`
|
||||
: 0};
|
||||
width: ${(props) => props.theme.sidebarWidth};
|
||||
margin: 0;
|
||||
z-index: 3;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
left: 0;
|
||||
box-shadow: ${(props) =>
|
||||
props.collapsed ? "rgba(0, 0, 0, 0.2) 1px 0 4px" : "none"};
|
||||
|
||||
& ${Button} {
|
||||
opacity: .75;
|
||||
}
|
||||
|
||||
& ${Button}:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:hover):not(:focus-within) > div {
|
||||
opacity: ${(props) => (props.collapsed ? "0" : "1")};
|
||||
transition: opacity 100ms ease-in-out;
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// @flow
|
||||
import { NextIcon, BackIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import { meta } from "utils/keyboard";
|
||||
|
||||
type Props = {|
|
||||
collapsed: boolean,
|
||||
onClick?: () => void,
|
||||
|};
|
||||
|
||||
function CollapseToggle({ collapsed, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
tooltip={collapsed ? t("Expand") : t("Collapse")}
|
||||
shortcut={`${meta}+.`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button {...rest} aria-hidden>
|
||||
{collapsed ? (
|
||||
<NextIcon color="currentColor" />
|
||||
) : (
|
||||
<BackIcon color="currentColor" />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export const Button = styled.button`
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
right: 8px;
|
||||
border: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
z-index: 1;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
background: ${(props) => props.theme.sidebarItemBackground};
|
||||
transition: opacity 100ms ease-in-out;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.white};
|
||||
background: ${(props) => props.theme.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
export default CollapseToggle;
|
||||
@@ -1,16 +1,20 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import styled from "styled-components";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Collection from "models/Collection";
|
||||
import Document from "models/Document";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import DropToImport from "components/DropToImport";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import useStores from "hooks/useStores";
|
||||
import CollectionMenu from "menus/CollectionMenu";
|
||||
import CollectionSortMenu from "menus/CollectionSortMenu";
|
||||
|
||||
type Props = {|
|
||||
collection: Collection,
|
||||
@@ -20,33 +24,61 @@ type Props = {|
|
||||
prefetchDocument: (id: string) => Promise<void>,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class CollectionLink extends React.Component<Props> {
|
||||
@observable menuOpen = false;
|
||||
function CollectionLink({
|
||||
collection,
|
||||
activeDocument,
|
||||
prefetchDocument,
|
||||
canUpdate,
|
||||
ui,
|
||||
}: Props) {
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
|
||||
handleTitleChange = async (name: string) => {
|
||||
await this.props.collection.save({ name });
|
||||
};
|
||||
const handleTitleChange = React.useCallback(
|
||||
async (name: string) => {
|
||||
await collection.save({ name });
|
||||
},
|
||||
[collection]
|
||||
);
|
||||
|
||||
render() {
|
||||
const {
|
||||
collection,
|
||||
activeDocument,
|
||||
prefetchDocument,
|
||||
canUpdate,
|
||||
ui,
|
||||
} = this.props;
|
||||
const { documents, policies } = useStores();
|
||||
const expanded = collection.id === ui.activeCollectionId;
|
||||
const manualSort = collection.sort.field === "index";
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
const expanded = collection.id === ui.activeCollectionId;
|
||||
// Drop to re-parent
|
||||
const [{ isOver, canDrop }, drop] = useDrop({
|
||||
accept: "document",
|
||||
drop: (item, monitor) => {
|
||||
if (monitor.didDrop()) return;
|
||||
if (!collection) return;
|
||||
documents.move(item.id, collection.id);
|
||||
},
|
||||
canDrop: (item, monitor) => {
|
||||
return policies.abilities(collection.id).update;
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: !!monitor.isOver({ shallow: true }),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropToImport
|
||||
key={collection.id}
|
||||
collectionId={collection.id}
|
||||
activeClassName="activeDropZone"
|
||||
>
|
||||
<SidebarLink
|
||||
// Drop to reorder
|
||||
const [{ isOverReorder }, dropToReorder] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item, monitor) => {
|
||||
if (!collection) return;
|
||||
documents.move(item.id, collection.id, undefined, 0);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: !!monitor.isOver(),
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={drop} style={{ position: "relative" }}>
|
||||
<DropToImport key={collection.id} collectionId={collection.id}>
|
||||
<SidebarLinkWithPadding
|
||||
key={collection.id}
|
||||
to={collection.url}
|
||||
icon={
|
||||
@@ -54,41 +86,62 @@ class CollectionLink extends React.Component<Props> {
|
||||
}
|
||||
iconColor={collection.color}
|
||||
expanded={expanded}
|
||||
menuOpen={this.menuOpen}
|
||||
showActions={menuOpen || expanded}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={collection.name}
|
||||
onSubmit={this.handleTitleChange}
|
||||
onSubmit={handleTitleChange}
|
||||
canUpdate={canUpdate}
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
menu={
|
||||
<CollectionMenu
|
||||
position="right"
|
||||
collection={collection}
|
||||
onOpen={() => (this.menuOpen = true)}
|
||||
onClose={() => (this.menuOpen = false)}
|
||||
/>
|
||||
<>
|
||||
{can.update && (
|
||||
<CollectionSortMenuWithMargin
|
||||
collection={collection}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
></SidebarLink>
|
||||
/>
|
||||
</DropToImport>
|
||||
{expanded && manualSort && (
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded &&
|
||||
collection.documents.map((node) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={collection}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
canUpdate={canUpdate}
|
||||
depth={1.5}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
{expanded &&
|
||||
collection.documents.map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={collection}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
canUpdate={canUpdate}
|
||||
depth={1.5}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CollectionLink;
|
||||
const SidebarLinkWithPadding = styled(SidebarLink)`
|
||||
padding-right: 60px;
|
||||
`;
|
||||
|
||||
const CollectionSortMenuWithMargin = styled(CollectionSortMenu)`
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
export default observer(CollectionLink);
|
||||
|
||||
@@ -72,7 +72,7 @@ class Collections extends React.Component<Props> {
|
||||
to="/collections"
|
||||
onClick={this.props.onCreateCollection}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label={t("New collection…")}
|
||||
label={`${t("New collection")}…`}
|
||||
exact
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Collection from "models/Collection";
|
||||
import Document from "models/Document";
|
||||
import DropToImport from "components/DropToImport";
|
||||
import Fade from "components/Fade";
|
||||
import DropCursor from "./DropCursor";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import useStores from "hooks/useStores";
|
||||
@@ -22,18 +24,22 @@ type Props = {|
|
||||
activeDocumentRef?: (?HTMLElement) => void,
|
||||
prefetchDocument: (documentId: string) => Promise<void>,
|
||||
depth: number,
|
||||
index: number,
|
||||
parentId?: string,
|
||||
|};
|
||||
|
||||
function DocumentLink({
|
||||
node,
|
||||
canUpdate,
|
||||
collection,
|
||||
activeDocument,
|
||||
activeDocumentRef,
|
||||
prefetchDocument,
|
||||
depth,
|
||||
canUpdate,
|
||||
index,
|
||||
parentId,
|
||||
}: Props) {
|
||||
const { documents } = useStores();
|
||||
const { documents, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isActiveDocument = activeDocument && activeDocument.id === node.id;
|
||||
@@ -48,13 +54,19 @@ function DocumentLink({
|
||||
}
|
||||
}, [fetchChildDocuments, node, hasChildDocuments, isActiveDocument]);
|
||||
|
||||
const pathToNode = React.useMemo(
|
||||
() =>
|
||||
collection && collection.pathToDocument(node.id).map((entry) => entry.id),
|
||||
[collection, node]
|
||||
);
|
||||
|
||||
const showChildren = React.useMemo(() => {
|
||||
return !!(
|
||||
hasChildDocuments &&
|
||||
activeDocument &&
|
||||
collection &&
|
||||
(collection
|
||||
.pathToDocument(activeDocument)
|
||||
.pathToDocument(activeDocument.id)
|
||||
.map((entry) => entry.id)
|
||||
.includes(node.id) ||
|
||||
isActiveDocument)
|
||||
@@ -69,6 +81,14 @@ function DocumentLink({
|
||||
}
|
||||
}, [showChildren]);
|
||||
|
||||
// when the last child document is removed,
|
||||
// also close the local folder state to closed
|
||||
React.useEffect(() => {
|
||||
if (expanded && !hasChildDocuments) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [expanded, hasChildDocuments]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
@@ -100,53 +120,146 @@ function DocumentLink({
|
||||
);
|
||||
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const isMoving = documents.movingDocumentId === node.id;
|
||||
const manualSort = collection?.sort.field === "index";
|
||||
|
||||
// Draggable
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
item: { type: "document", ...node, depth, active: isActiveDocument },
|
||||
collect: (monitor) => ({
|
||||
isDragging: !!monitor.isDragging(),
|
||||
}),
|
||||
canDrag: (monitor) => {
|
||||
return policies.abilities(node.id).move;
|
||||
},
|
||||
});
|
||||
|
||||
const hoverExpanding = React.useRef(null);
|
||||
|
||||
// We set a timeout when the user first starts hovering over the document link,
|
||||
// to trigger expansion of children. Clear this timeout when they stop hovering.
|
||||
const resetHoverExpanding = React.useCallback(() => {
|
||||
if (hoverExpanding.current) {
|
||||
clearTimeout(hoverExpanding.current);
|
||||
hoverExpanding.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Drop to re-parent
|
||||
const [{ isOverReparent, canDropToReparent }, dropToReparent] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item, monitor) => {
|
||||
if (monitor.didDrop()) return;
|
||||
if (!collection) return;
|
||||
documents.move(item.id, collection.id, node.id);
|
||||
},
|
||||
|
||||
canDrop: (item, monitor) =>
|
||||
pathToNode && !pathToNode.includes(monitor.getItem().id),
|
||||
|
||||
hover: (item, monitor) => {
|
||||
// Enables expansion of document children when hovering over the document
|
||||
// for more than half a second.
|
||||
if (
|
||||
hasChildDocuments &&
|
||||
monitor.canDrop() &&
|
||||
monitor.isOver({ shallow: true })
|
||||
) {
|
||||
if (!hoverExpanding.current) {
|
||||
hoverExpanding.current = setTimeout(() => {
|
||||
hoverExpanding.current = null;
|
||||
if (monitor.isOver({ shallow: true })) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
collect: (monitor) => ({
|
||||
isOverReparent: !!monitor.isOver({ shallow: true }),
|
||||
canDropToReparent: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Drop to reorder
|
||||
const [{ isOverReorder }, dropToReorder] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item, monitor) => {
|
||||
if (!collection) return;
|
||||
if (item.id === node.id) return;
|
||||
|
||||
if (expanded) {
|
||||
documents.move(item.id, collection.id, node.id, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
documents.move(item.id, collection.id, parentId, index + 1);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: !!monitor.isOver(),
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<React.Fragment key={node.id}>
|
||||
<DropToImport documentId={node.id} activeClassName="activeDropZone">
|
||||
<SidebarLink
|
||||
innerRef={isActiveDocument ? activeDocumentRef : undefined}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
to={{
|
||||
pathname: node.url,
|
||||
state: { title: node.title },
|
||||
}}
|
||||
label={
|
||||
<>
|
||||
{hasChildDocuments && (
|
||||
<Disclosure
|
||||
expanded={expanded}
|
||||
onClick={handleDisclosureClick}
|
||||
/>
|
||||
)}
|
||||
<EditableTitle
|
||||
title={node.title || t("Untitled")}
|
||||
onSubmit={handleTitleChange}
|
||||
canUpdate={canUpdate}
|
||||
<>
|
||||
<div style={{ position: "relative" }} onDragLeave={resetHoverExpanding}>
|
||||
<Draggable
|
||||
key={node.id}
|
||||
ref={drag}
|
||||
$isDragging={isDragging}
|
||||
$isMoving={isMoving}
|
||||
>
|
||||
<div ref={dropToReparent}>
|
||||
<DropToImport documentId={node.id} activeClassName="activeDropZone">
|
||||
<SidebarLink
|
||||
innerRef={isActiveDocument ? activeDocumentRef : undefined}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
to={{
|
||||
pathname: node.url,
|
||||
state: { title: node.title },
|
||||
}}
|
||||
label={
|
||||
<>
|
||||
{hasChildDocuments && (
|
||||
<Disclosure
|
||||
expanded={expanded && !isDragging}
|
||||
onClick={handleDisclosureClick}
|
||||
/>
|
||||
)}
|
||||
<EditableTitle
|
||||
title={node.title || t("Untitled")}
|
||||
onSubmit={handleTitleChange}
|
||||
canUpdate={canUpdate}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
isActiveDrop={isOverReparent && canDropToReparent}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
showActions={menuOpen}
|
||||
menu={
|
||||
document && !isMoving ? (
|
||||
<Fade>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
menuOpen={menuOpen}
|
||||
menu={
|
||||
document ? (
|
||||
<Fade>
|
||||
<DocumentMenu
|
||||
position="right"
|
||||
document={document}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined
|
||||
}
|
||||
></SidebarLink>
|
||||
</DropToImport>
|
||||
|
||||
{expanded && (
|
||||
</DropToImport>
|
||||
</div>
|
||||
</Draggable>
|
||||
{manualSort && (
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
)}
|
||||
</div>
|
||||
{expanded && !isDragging && (
|
||||
<>
|
||||
{node.children.map((childNode) => (
|
||||
{node.children.map((childNode, index) => (
|
||||
<ObservedDocumentLink
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
@@ -155,14 +268,21 @@ function DocumentLink({
|
||||
prefetchDocument={prefetchDocument}
|
||||
depth={depth + 1}
|
||||
canUpdate={canUpdate}
|
||||
index={index}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Draggable = styled("div")`
|
||||
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)};
|
||||
pointer-events: ${(props) => (props.$isMoving ? "none" : "all")};
|
||||
`;
|
||||
|
||||
const Disclosure = styled(CollapsedIcon)`
|
||||
position: absolute;
|
||||
left: -24px;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import { type Theme } from "types";
|
||||
|
||||
function DropCursor({
|
||||
isActiveDrop,
|
||||
innerRef,
|
||||
theme,
|
||||
}: {
|
||||
isActiveDrop: boolean,
|
||||
innerRef: React.Ref<any>,
|
||||
theme: Theme,
|
||||
}) {
|
||||
return <Cursor isOver={isActiveDrop} ref={innerRef} />;
|
||||
}
|
||||
|
||||
// transparent hover zone with a thin visible band vertically centered
|
||||
const Cursor = styled("div")`
|
||||
opacity: ${(props) => (props.isOver ? 1 : 0)};
|
||||
transition: opacity 150ms;
|
||||
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
bottom: -7px;
|
||||
background: transparent;
|
||||
|
||||
::after {
|
||||
background: ${(props) => props.theme.slateDark};
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
content: "";
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
export default withTheme(DropCursor);
|
||||
@@ -52,7 +52,9 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
|
||||
setOriginalValue(value);
|
||||
} catch (error) {
|
||||
setValue(originalValue);
|
||||
ui.showToast(error.message);
|
||||
ui.showToast(error.message, {
|
||||
type: "error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,26 +12,22 @@ type Props = {
|
||||
logoUrl: string,
|
||||
};
|
||||
|
||||
function HeaderBlock({
|
||||
showDisclosure,
|
||||
teamName,
|
||||
subheading,
|
||||
logoUrl,
|
||||
...rest
|
||||
}: Props) {
|
||||
return (
|
||||
<Header justify="flex-start" align="center" {...rest}>
|
||||
<TeamLogo alt={`${teamName} logo`} src={logoUrl} size="38px" />
|
||||
<Flex align="flex-start" column>
|
||||
<TeamName showDisclosure>
|
||||
{teamName}{" "}
|
||||
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
|
||||
</TeamName>
|
||||
<Subheading>{subheading}</Subheading>
|
||||
</Flex>
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
const HeaderBlock = React.forwardRef<Props, any>(
|
||||
({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => {
|
||||
return (
|
||||
<Header justify="flex-start" align="center" ref={ref} {...rest}>
|
||||
<TeamLogo alt={`${teamName} logo`} src={logoUrl} size="38px" />
|
||||
<Flex align="flex-start" column>
|
||||
<TeamName showDisclosure>
|
||||
{teamName}{" "}
|
||||
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
|
||||
</TeamName>
|
||||
<Subheading>{subheading}</Subheading>
|
||||
</Flex>
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const StyledExpandedIcon = styled(ExpandedIcon)`
|
||||
position: absolute;
|
||||
@@ -61,7 +57,7 @@ const Header = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding: 16px 24px;
|
||||
padding: 20px 24px;
|
||||
position: relative;
|
||||
background: none;
|
||||
line-height: inherit;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { withRouter, NavLink } from "react-router-dom";
|
||||
import {
|
||||
withRouter,
|
||||
NavLink,
|
||||
type RouterHistory,
|
||||
type Match,
|
||||
} from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import EventBoundary from "components/EventBoundary";
|
||||
import { type Theme } from "types";
|
||||
|
||||
type Props = {
|
||||
@@ -10,13 +16,17 @@ type Props = {
|
||||
innerRef?: (?HTMLElement) => void,
|
||||
onClick?: (SyntheticEvent<>) => void,
|
||||
onMouseEnter?: (SyntheticEvent<>) => void,
|
||||
className?: string,
|
||||
children?: React.Node,
|
||||
icon?: React.Node,
|
||||
label?: React.Node,
|
||||
menu?: React.Node,
|
||||
menuOpen?: boolean,
|
||||
showActions?: boolean,
|
||||
iconColor?: string,
|
||||
active?: boolean,
|
||||
isActiveDrop?: boolean,
|
||||
history: RouterHistory,
|
||||
match: Match,
|
||||
theme: Theme,
|
||||
exact?: boolean,
|
||||
depth?: number,
|
||||
@@ -30,14 +40,17 @@ function SidebarLink({
|
||||
to,
|
||||
label,
|
||||
active,
|
||||
isActiveDrop,
|
||||
menu,
|
||||
menuOpen,
|
||||
showActions,
|
||||
theme,
|
||||
exact,
|
||||
href,
|
||||
innerRef,
|
||||
depth,
|
||||
...rest
|
||||
history,
|
||||
match,
|
||||
className,
|
||||
}: Props) {
|
||||
const style = React.useMemo(() => {
|
||||
return {
|
||||
@@ -46,15 +59,20 @@ function SidebarLink({
|
||||
}, [depth]);
|
||||
|
||||
const activeStyle = {
|
||||
fontWeight: 600,
|
||||
color: theme.text,
|
||||
background: theme.sidebarItemBackground,
|
||||
fontWeight: 600,
|
||||
...style,
|
||||
};
|
||||
|
||||
const activeFontWeightOnly = {
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledNavLink
|
||||
activeStyle={activeStyle}
|
||||
$isActiveDrop={isActiveDrop}
|
||||
activeStyle={isActiveDrop ? activeFontWeightOnly : activeStyle}
|
||||
style={active ? activeStyle : style}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
@@ -63,10 +81,11 @@ function SidebarLink({
|
||||
as={to ? undefined : href ? "a" : "div"}
|
||||
href={href}
|
||||
ref={innerRef}
|
||||
className={className}
|
||||
>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label>{label}</Label>
|
||||
{menu && <Action menuOpen={menuOpen}>{menu}</Action>}
|
||||
{menu && <Actions showActions={showActions}>{menu}</Actions>}
|
||||
</StyledNavLink>
|
||||
);
|
||||
}
|
||||
@@ -76,22 +95,26 @@ const IconWrapper = styled.span`
|
||||
margin-left: -4px;
|
||||
margin-right: 4px;
|
||||
height: 24px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Action = styled.span`
|
||||
display: ${(props) => (props.menuOpen ? "inline" : "none")};
|
||||
const Actions = styled(EventBoundary)`
|
||||
display: ${(props) => (props.showActions ? "inline-flex" : "none")};
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
transition: opacity 50ms;
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
fill: currentColor;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
svg {
|
||||
opacity: 1;
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -99,16 +122,26 @@ const Action = styled.span`
|
||||
const StyledNavLink = styled(NavLink)`
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 4px 16px;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
transition: background 50ms, color 50ms;
|
||||
background: ${(props) =>
|
||||
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
|
||||
color: ${(props) =>
|
||||
props.$isActiveDrop ? props.theme.white : props.theme.sidebarText};
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
svg {
|
||||
${(props) => (props.$isActiveDrop ? `fill: ${props.theme.white};` : "")}
|
||||
transition: fill 50ms
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
color: ${(props) =>
|
||||
props.$isActiveDrop ? props.theme.white : props.theme.text};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
@@ -116,9 +149,14 @@ const StyledNavLink = styled(NavLink)`
|
||||
background: ${(props) => props.theme.black05};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
> ${Action} {
|
||||
display: inline;
|
||||
&:hover,
|
||||
&:active {
|
||||
> ${Actions} {
|
||||
display: inline-flex;
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -110,7 +110,9 @@ class SocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on("unauthorized", (err) => {
|
||||
this.socket.authenticated = false;
|
||||
ui.showToast(err.message);
|
||||
ui.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// @flow
|
||||
import { StarredIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import NudeButton from "./NudeButton";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
size?: number,
|
||||
|};
|
||||
|
||||
function Star({ size, document, ...rest }: Props) {
|
||||
const handleClick = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (document.isStarred) {
|
||||
document.unstar();
|
||||
} else {
|
||||
document.star();
|
||||
}
|
||||
},
|
||||
[document]
|
||||
);
|
||||
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={handleClick} size={size} {...rest}>
|
||||
<AnimatedStar
|
||||
solid={document.isStarred}
|
||||
size={size}
|
||||
color="currentColor"
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const Button = styled(NudeButton)`
|
||||
color: ${(props) => props.theme.text};
|
||||
`;
|
||||
|
||||
export const AnimatedStar = styled(StarredIcon)`
|
||||
flex-shrink: 0;
|
||||
transition: all 100ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
`;
|
||||
|
||||
export default Star;
|
||||
+19
-52
@@ -1,24 +1,8 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import format from "date-fns/format";
|
||||
import * as React from "react";
|
||||
import Tooltip from "components/Tooltip";
|
||||
|
||||
let callbacks = [];
|
||||
|
||||
// This is a shared timer that fires every minute, used for
|
||||
// updating all Time components across the page all at once.
|
||||
setInterval(() => {
|
||||
callbacks.forEach((cb) => cb());
|
||||
}, 1000 * 60);
|
||||
|
||||
function eachMinute(fn) {
|
||||
callbacks.push(fn);
|
||||
|
||||
return () => {
|
||||
callbacks = callbacks.filter((cb) => cb !== fn);
|
||||
};
|
||||
}
|
||||
const LocaleTime = React.lazy(() => import("components/LocaleTime"));
|
||||
|
||||
type Props = {
|
||||
dateTime: string,
|
||||
@@ -28,44 +12,27 @@ type Props = {
|
||||
shorten?: boolean,
|
||||
};
|
||||
|
||||
class Time extends React.Component<Props> {
|
||||
removeEachMinuteCallback: () => void;
|
||||
function Time(props: Props) {
|
||||
let content = distanceInWordsToNow(props.dateTime, {
|
||||
addSuffix: props.addSuffix,
|
||||
});
|
||||
|
||||
componentDidMount() {
|
||||
this.removeEachMinuteCallback = eachMinute(() => {
|
||||
this.forceUpdate();
|
||||
});
|
||||
if (props.shorten) {
|
||||
content = content
|
||||
.replace("about", "")
|
||||
.replace("less than a minute ago", "just now")
|
||||
.replace("minute", "min");
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.removeEachMinuteCallback();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { shorten, addSuffix } = this.props;
|
||||
let content = distanceInWordsToNow(this.props.dateTime, {
|
||||
addSuffix,
|
||||
});
|
||||
|
||||
if (shorten) {
|
||||
content = content
|
||||
.replace("about", "")
|
||||
.replace("less than a minute ago", "just now")
|
||||
.replace("minute", "min");
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
tooltip={format(this.props.dateTime, "MMMM Do, YYYY h:mm a")}
|
||||
delay={this.props.tooltipDelay}
|
||||
placement="bottom"
|
||||
>
|
||||
<time dateTime={this.props.dateTime}>
|
||||
{this.props.children || content}
|
||||
</time>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<time dateTime={props.dateTime}>{props.children || content}</time>
|
||||
}
|
||||
>
|
||||
<LocaleTime {...props} />
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export default Time;
|
||||
|
||||
@@ -1,30 +1,24 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import UiStore from "../../stores/UiStore";
|
||||
import Toast from "./components/Toast";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
};
|
||||
@observer
|
||||
class Toasts extends React.Component<Props> {
|
||||
render() {
|
||||
const { ui } = this.props;
|
||||
function Toasts() {
|
||||
const { ui } = useStores();
|
||||
|
||||
return (
|
||||
<List>
|
||||
{ui.orderedToasts.map((toast) => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
toast={toast}
|
||||
onRequestClose={() => ui.removeToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<List>
|
||||
{ui.orderedToasts.map((toast) => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
toast={toast}
|
||||
onRequestClose={() => ui.removeToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
const List = styled.ol`
|
||||
@@ -37,4 +31,4 @@ const List = styled.ol`
|
||||
z-index: ${(props) => props.theme.depths.toasts};
|
||||
`;
|
||||
|
||||
export default inject("ui")(Toasts);
|
||||
export default observer(Toasts);
|
||||
|
||||
@@ -1,58 +1,61 @@
|
||||
// @flow
|
||||
import { CheckboxIcon, InfoIcon, WarningIcon } from "outline-icons";
|
||||
import { darken } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||
import styled, { css } from "styled-components";
|
||||
import { fadeAndScaleIn, pulse } from "shared/styles/animations";
|
||||
import type { Toast as TToast } from "types";
|
||||
|
||||
type Props = {
|
||||
onRequestClose: () => void,
|
||||
closeAfterMs: number,
|
||||
closeAfterMs?: number,
|
||||
toast: TToast,
|
||||
};
|
||||
|
||||
class Toast extends React.Component<Props> {
|
||||
timeout: TimeoutID;
|
||||
function Toast({ closeAfterMs = 3000, onRequestClose, toast }: Props) {
|
||||
const timeout = React.useRef();
|
||||
const [pulse, setPulse] = React.useState(false);
|
||||
const { action, type = "info", reoccurring } = toast;
|
||||
|
||||
static defaultProps = {
|
||||
closeAfterMs: 3000,
|
||||
};
|
||||
React.useEffect(() => {
|
||||
timeout.current = setTimeout(onRequestClose, toast.timeout || closeAfterMs);
|
||||
|
||||
componentDidMount() {
|
||||
this.timeout = setTimeout(
|
||||
this.props.onRequestClose,
|
||||
this.props.toast.timeout || this.props.closeAfterMs
|
||||
);
|
||||
}
|
||||
return () => clearTimeout(timeout.current);
|
||||
}, [onRequestClose, toast, closeAfterMs]);
|
||||
|
||||
componentWillUnmount() {
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
React.useEffect(() => {
|
||||
if (reoccurring) {
|
||||
setPulse(reoccurring);
|
||||
|
||||
render() {
|
||||
const { toast, onRequestClose } = this.props;
|
||||
const { action } = toast;
|
||||
const message =
|
||||
typeof toast.message === "string"
|
||||
? toast.message
|
||||
: toast.message.toString();
|
||||
// must match animation time in css below vvv
|
||||
setTimeout(() => setPulse(false), 250);
|
||||
}
|
||||
}, [reoccurring]);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Container
|
||||
onClick={action ? undefined : onRequestClose}
|
||||
type={toast.type || "success"}
|
||||
>
|
||||
<Message>{message}</Message>
|
||||
{action && (
|
||||
<Action type={toast.type || "success"} onClick={action.onClick}>
|
||||
{action.text}
|
||||
</Action>
|
||||
)}
|
||||
</Container>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const message =
|
||||
typeof toast.message === "string"
|
||||
? toast.message
|
||||
: toast.message.toString();
|
||||
|
||||
return (
|
||||
<ListItem $pulse={pulse}>
|
||||
<Container
|
||||
onClick={action ? undefined : onRequestClose}
|
||||
type={toast.type || "success"}
|
||||
>
|
||||
{type === "info" && <InfoIcon color="currentColor" />}
|
||||
{type === "success" && <CheckboxIcon checked color="currentColor" />}
|
||||
{type === "warning" ||
|
||||
(type === "error" && <WarningIcon color="currentColor" />)}
|
||||
<Message>{message}</Message>
|
||||
{action && (
|
||||
<Action type={toast.type || "success"} onClick={action.onClick}>
|
||||
{action.text}
|
||||
</Action>
|
||||
)}
|
||||
</Container>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
const Action = styled.span`
|
||||
@@ -71,11 +74,20 @@ const Action = styled.span`
|
||||
}
|
||||
`;
|
||||
|
||||
const ListItem = styled.li`
|
||||
${(props) =>
|
||||
props.$pulse &&
|
||||
css`
|
||||
animation: ${pulse} 250ms;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Container = styled.div`
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
animation: ${fadeAndScaleIn} 100ms ease;
|
||||
margin: 8px 0;
|
||||
padding: 0 12px;
|
||||
color: ${(props) => props.theme.toastText};
|
||||
background: ${(props) => props.theme.toastBackground};
|
||||
font-size: 15px;
|
||||
@@ -89,7 +101,8 @@ const Container = styled.div`
|
||||
|
||||
const Message = styled.div`
|
||||
display: inline-block;
|
||||
padding: 10px 12px;
|
||||
font-weight: 500;
|
||||
padding: 10px 4px;
|
||||
`;
|
||||
|
||||
export default Toast;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
const URL_REGEX = new RegExp(
|
||||
"^https?://drive.google.com/file/d/(.*)/(preview|view).?usp=sharing$"
|
||||
);
|
||||
|
||||
type Props = {|
|
||||
attrs: {|
|
||||
href: string,
|
||||
matches: string[],
|
||||
|},
|
||||
|};
|
||||
|
||||
export default class GoogleDrive extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Frame
|
||||
src={this.props.attrs.href.replace("/view", "/preview")}
|
||||
icon={
|
||||
<img
|
||||
src="/images/google-drive.png"
|
||||
alt="Google Drive Icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
}
|
||||
title="Google Drive Embed"
|
||||
canonicalUrl={this.props.attrs.href}
|
||||
border
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import GoogleDrive from "./GoogleDrive";
|
||||
|
||||
describe("GoogleDrive", () => {
|
||||
const match = GoogleDrive.ENABLED[0];
|
||||
test("to be enabled on share link", () => {
|
||||
expect(
|
||||
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/view?usp=sharing".match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/preview?usp=sharing".match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("to not be enabled elsewhere", () => {
|
||||
expect(
|
||||
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/view".match(
|
||||
match
|
||||
)
|
||||
).toBe(null);
|
||||
expect(
|
||||
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/preview".match(
|
||||
match
|
||||
)
|
||||
).toBe(null);
|
||||
expect(
|
||||
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/view?usp=restricted".match(
|
||||
match
|
||||
)
|
||||
).toBe(null);
|
||||
expect("https://drive.google.com/file".match(match)).toBe(null);
|
||||
expect("https://drive.google.com".match(match)).toBe(null);
|
||||
expect("https://www.google.com".match(match)).toBe(null);
|
||||
});
|
||||
});
|
||||
+6
-4
@@ -2,7 +2,7 @@
|
||||
import * as React from "react";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
const URL_REGEX = /^https:\/\/(?:realtimeboard|miro).com\/app\/board\/(.*)$/;
|
||||
const URL_REGEX = /^https:\/\/(realtimeboard|miro).com\/app\/board\/(.*)$/;
|
||||
|
||||
type Props = {|
|
||||
attrs: {|
|
||||
@@ -16,13 +16,15 @@ export default class RealtimeBoard extends React.Component<Props> {
|
||||
|
||||
render() {
|
||||
const { matches } = this.props.attrs;
|
||||
const boardId = matches[1];
|
||||
const domain = matches[1];
|
||||
const boardId = matches[2];
|
||||
const titleName = domain === "realtimeboard" ? "RealtimeBoard" : "Miro";
|
||||
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={`https://realtimeboard.com/app/embed/${boardId}`}
|
||||
title={`RealtimeBoard (${boardId})`}
|
||||
src={`https://${domain}.com/app/embed/${boardId}`}
|
||||
title={`${titleName} (${boardId})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ describe("Miro", () => {
|
||||
expect("https://miro.com/app/board/o9J_k0fwiss=".match(match)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("to extract the domain as part of the match for later use", () => {
|
||||
expect(
|
||||
"https://realtimeboard.com/app/board/o9J_k0fwiss=".match(match)[1]
|
||||
).toBe("realtimeboard");
|
||||
});
|
||||
|
||||
test("to not be enabled elsewhere", () => {
|
||||
expect("https://miro.com".match(match)).toBe(null);
|
||||
expect("https://realtimeboard.com".match(match)).toBe(null);
|
||||
|
||||
@@ -9,6 +9,7 @@ import Figma from "./Figma";
|
||||
import Framer from "./Framer";
|
||||
import Gist from "./Gist";
|
||||
import GoogleDocs from "./GoogleDocs";
|
||||
import GoogleDrive from "./GoogleDrive";
|
||||
import GoogleSheets from "./GoogleSheets";
|
||||
import GoogleSlides from "./GoogleSlides";
|
||||
import InVision from "./InVision";
|
||||
@@ -93,6 +94,13 @@ export default [
|
||||
component: Gist,
|
||||
matcher: matcher(Gist),
|
||||
},
|
||||
{
|
||||
title: "Google Drive",
|
||||
keywords: "drive",
|
||||
icon: () => <Img src="/images/google-drive.png" />,
|
||||
component: GoogleDrive,
|
||||
matcher: matcher(GoogleDrive),
|
||||
},
|
||||
{
|
||||
title: "Google Docs",
|
||||
icon: () => <Img src="/images/google-docs.png" />,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// @flow
|
||||
import useStores from "./useStores";
|
||||
|
||||
export default function useUserLocale() {
|
||||
const { auth } = useStores();
|
||||
|
||||
if (!auth.user) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return auth.user.language.split("_")[0];
|
||||
}
|
||||
+18
-16
@@ -1,11 +1,11 @@
|
||||
// @flow
|
||||
import "mobx-react-lite/batchingForReactDom";
|
||||
import "focus-visible";
|
||||
import { Provider } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { render } from "react-dom";
|
||||
import { BrowserRouter as Router } from "react-router-dom";
|
||||
|
||||
import { initI18n } from "shared/i18n";
|
||||
import stores from "stores";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
@@ -21,20 +21,22 @@ const element = document.getElementById("root");
|
||||
|
||||
if (element) {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Provider {...stores}>
|
||||
<Theme>
|
||||
<Router>
|
||||
<>
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
</>
|
||||
</Router>
|
||||
</Theme>
|
||||
</Provider>
|
||||
</ErrorBoundary>,
|
||||
<Provider {...stores}>
|
||||
<Theme>
|
||||
<ErrorBoundary>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<Router>
|
||||
<>
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
</>
|
||||
</Router>
|
||||
</DndProvider>
|
||||
</ErrorBoundary>
|
||||
</Theme>
|
||||
</Provider>,
|
||||
element
|
||||
);
|
||||
}
|
||||
|
||||
+106
-112
@@ -1,134 +1,128 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import { SunIcon, MoonIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
|
||||
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
|
||||
import Flex from "components/Flex";
|
||||
import Modal from "components/Modal";
|
||||
import {
|
||||
developers,
|
||||
changelog,
|
||||
githubIssuesUrl,
|
||||
mailToUrl,
|
||||
settings,
|
||||
} from "../../shared/utils/routeHelpers";
|
||||
} from "shared/utils/routeHelpers";
|
||||
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import MenuItem, { MenuAnchor } from "components/ContextMenu/MenuItem";
|
||||
import Separator from "components/ContextMenu/Separator";
|
||||
import Flex from "components/Flex";
|
||||
import Modal from "components/Modal";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
label: React.Node,
|
||||
ui: UiStore,
|
||||
auth: AuthStore,
|
||||
t: TFunction,
|
||||
};
|
||||
type Props = {|
|
||||
children: (props: any) => React.Node,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class AccountMenu extends React.Component<Props> {
|
||||
@observable keyboardShortcutsOpen: boolean = false;
|
||||
const AppearanceMenu = React.forwardRef((props, ref) => {
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState();
|
||||
|
||||
handleLogout = () => {
|
||||
this.props.auth.logout();
|
||||
};
|
||||
|
||||
handleOpenKeyboardShortcuts = () => {
|
||||
this.keyboardShortcutsOpen = true;
|
||||
};
|
||||
|
||||
handleCloseKeyboardShortcuts = () => {
|
||||
this.keyboardShortcutsOpen = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { ui, t } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={this.keyboardShortcutsOpen}
|
||||
onRequestClose={this.handleCloseKeyboardShortcuts}
|
||||
title={t("Keyboard shortcuts")}
|
||||
return (
|
||||
<>
|
||||
<MenuButton ref={ref} {...menu} {...props}>
|
||||
{(props) => (
|
||||
<MenuAnchor {...props}>
|
||||
<ChangeTheme justify="space-between">
|
||||
{t("Appearance")}
|
||||
{ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />}
|
||||
</ChangeTheme>
|
||||
</MenuAnchor>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Appearance")}>
|
||||
<MenuItem
|
||||
{...menu}
|
||||
onClick={() => ui.setTheme("system")}
|
||||
selected={ui.theme === "system"}
|
||||
>
|
||||
<KeyboardShortcuts />
|
||||
</Modal>
|
||||
<DropdownMenu
|
||||
style={{ marginRight: 10, marginTop: -10 }}
|
||||
label={this.props.label}
|
||||
{t("System")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
{...menu}
|
||||
onClick={() => ui.setTheme("light")}
|
||||
selected={ui.theme === "light"}
|
||||
>
|
||||
<DropdownMenuItem as={Link} to={settings()}>
|
||||
{t("Settings")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.handleOpenKeyboardShortcuts}>
|
||||
{t("Keyboard shortcuts")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem href={developers()} target="_blank">
|
||||
{t("API documentation")}
|
||||
</DropdownMenuItem>
|
||||
<hr />
|
||||
<DropdownMenuItem href={changelog()} target="_blank">
|
||||
{t("Changelog")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem href={mailToUrl()} target="_blank">
|
||||
{t("Send us feedback")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem href={githubIssuesUrl()} target="_blank">
|
||||
{t("Report a bug")}
|
||||
</DropdownMenuItem>
|
||||
<hr />
|
||||
<DropdownMenu
|
||||
position="right"
|
||||
style={{
|
||||
left: 170,
|
||||
position: "relative",
|
||||
top: -40,
|
||||
}}
|
||||
label={
|
||||
<DropdownMenuItem>
|
||||
<ChangeTheme justify="space-between">
|
||||
{t("Appearance")}
|
||||
{ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />}
|
||||
</ChangeTheme>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
hover
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => ui.setTheme("system")}
|
||||
selected={ui.theme === "system"}
|
||||
>
|
||||
{t("System")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => ui.setTheme("light")}
|
||||
selected={ui.theme === "light"}
|
||||
>
|
||||
{t("Light")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => ui.setTheme("dark")}
|
||||
selected={ui.theme === "dark"}
|
||||
>
|
||||
{t("Dark")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
<hr />
|
||||
<DropdownMenuItem onClick={this.handleLogout}>
|
||||
{t("Log out")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
{t("Light")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
{...menu}
|
||||
onClick={() => ui.setTheme("dark")}
|
||||
selected={ui.theme === "dark"}
|
||||
>
|
||||
{t("Dark")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function AccountMenu(props: Props) {
|
||||
const menu = useMenuState({
|
||||
placement: "bottom-start",
|
||||
modal: true,
|
||||
});
|
||||
const { auth } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [keyboardShortcutsOpen, setKeyboardShortcutsOpen] = React.useState(
|
||||
false
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={keyboardShortcutsOpen}
|
||||
onRequestClose={() => setKeyboardShortcutsOpen(false)}
|
||||
title={t("Keyboard shortcuts")}
|
||||
>
|
||||
<KeyboardShortcuts />
|
||||
</Modal>
|
||||
<MenuButton {...menu}>{props.children}</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Account")}>
|
||||
<MenuItem {...menu} as={Link} to={settings()}>
|
||||
{t("Settings")}
|
||||
</MenuItem>
|
||||
<MenuItem {...menu} onClick={() => setKeyboardShortcutsOpen(true)}>
|
||||
{t("Keyboard shortcuts")}
|
||||
</MenuItem>
|
||||
<MenuItem {...menu} href={developers()} target="_blank">
|
||||
{t("API documentation")}
|
||||
</MenuItem>
|
||||
<Separator {...menu} />
|
||||
<MenuItem {...menu} href={changelog()} target="_blank">
|
||||
{t("Changelog")}
|
||||
</MenuItem>
|
||||
<MenuItem {...menu} href={mailToUrl()} target="_blank">
|
||||
{t("Send us feedback")}
|
||||
</MenuItem>
|
||||
<MenuItem {...menu} href={githubIssuesUrl()} target="_blank">
|
||||
{t("Report a bug")}
|
||||
</MenuItem>
|
||||
<Separator {...menu} />
|
||||
<MenuItem {...menu} as={AppearanceMenu} />
|
||||
<Separator {...menu} />
|
||||
<MenuItem {...menu} onClick={auth.logout}>
|
||||
{t("Log out")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ChangeTheme = styled(Flex)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export default withTranslation()<AccountMenu>(
|
||||
inject("ui", "auth")(AccountMenu)
|
||||
);
|
||||
export default observer(AccountMenu);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// @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 = {
|
||||
path: Array<any>,
|
||||
};
|
||||
|
||||
export default function BreadcrumbMenu({ path }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
placement: "bottom",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Path to document")}>
|
||||
<Template
|
||||
{...menu}
|
||||
items={path.map((item) => ({
|
||||
title: item.title,
|
||||
to: item.url,
|
||||
}))}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
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 = {|
|
||||
onMembers: () => void,
|
||||
onRemove: () => void,
|
||||
|};
|
||||
|
||||
function CollectionGroupMemberMenu({ onMembers, onRemove }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({ modal: true });
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Group member options")}>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
title: t("Members"),
|
||||
onClick: onMembers,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: t("Remove"),
|
||||
onClick: onRemove,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(CollectionGroupMemberMenu);
|
||||
+193
-193
@@ -1,221 +1,221 @@
|
||||
// @flow
|
||||
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 { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||
import Collection from "models/Collection";
|
||||
import CollectionDelete from "scenes/CollectionDelete";
|
||||
import CollectionEdit from "scenes/CollectionEdit";
|
||||
import CollectionExport from "scenes/CollectionExport";
|
||||
import CollectionMembers from "scenes/CollectionMembers";
|
||||
import { DropdownMenu } from "components/DropdownMenu";
|
||||
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import Modal from "components/Modal";
|
||||
import VisuallyHidden from "components/VisuallyHidden";
|
||||
import useStores from "hooks/useStores";
|
||||
import getDataTransferFiles from "utils/getDataTransferFiles";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
position?: "left" | "right" | "center",
|
||||
ui: UiStore,
|
||||
policies: PoliciesStore,
|
||||
documents: DocumentsStore,
|
||||
type Props = {|
|
||||
collection: Collection,
|
||||
history: RouterHistory,
|
||||
placement?: string,
|
||||
modal?: boolean,
|
||||
label?: (any) => React.Node,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
t: TFunction,
|
||||
};
|
||||
|};
|
||||
|
||||
@observer
|
||||
class CollectionMenu extends React.Component<Props> {
|
||||
file: ?HTMLInputElement;
|
||||
@observable showCollectionMembers = false;
|
||||
@observable showCollectionEdit = false;
|
||||
@observable showCollectionDelete = false;
|
||||
@observable showCollectionExport = false;
|
||||
function CollectionMenu({
|
||||
collection,
|
||||
label,
|
||||
modal = true,
|
||||
placement,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const menu = useMenuState({ modal, placement });
|
||||
const [renderModals, setRenderModals] = React.useState(false);
|
||||
const { ui, documents, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
|
||||
onNewDocument = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { collection } = this.props;
|
||||
this.props.history.push(newDocumentUrl(collection.id));
|
||||
};
|
||||
const file = React.useRef<?HTMLInputElement>();
|
||||
const [showCollectionMembers, setShowCollectionMembers] = React.useState(
|
||||
false
|
||||
);
|
||||
const [showCollectionEdit, setShowCollectionEdit] = React.useState(false);
|
||||
const [showCollectionDelete, setShowCollectionDelete] = React.useState(false);
|
||||
const [showCollectionExport, setShowCollectionExport] = React.useState(false);
|
||||
|
||||
onImportDocument = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
// simulate a click on the file upload input element
|
||||
if (this.file) this.file.click();
|
||||
};
|
||||
|
||||
onFilePicked = async (ev: SyntheticEvent<>) => {
|
||||
const files = getDataTransferFiles(ev);
|
||||
|
||||
try {
|
||||
const file = files[0];
|
||||
const document = await this.props.documents.import(
|
||||
file,
|
||||
null,
|
||||
this.props.collection.id,
|
||||
{ publish: true }
|
||||
);
|
||||
this.props.history.push(document.url);
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message);
|
||||
const handleOpen = React.useCallback(() => {
|
||||
setRenderModals(true);
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
};
|
||||
}, [onOpen]);
|
||||
|
||||
handleEditCollectionOpen = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.showCollectionEdit = true;
|
||||
};
|
||||
const handleNewDocument = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
history.push(newDocumentUrl(collection.id));
|
||||
},
|
||||
[history, collection.id]
|
||||
);
|
||||
|
||||
handleEditCollectionClose = () => {
|
||||
this.showCollectionEdit = false;
|
||||
};
|
||||
const handleImportDocument = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
handleDeleteCollectionOpen = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.showCollectionDelete = true;
|
||||
};
|
||||
// simulate a click on the file upload input element
|
||||
if (file.current) {
|
||||
file.current.click();
|
||||
}
|
||||
},
|
||||
[file]
|
||||
);
|
||||
|
||||
handleDeleteCollectionClose = () => {
|
||||
this.showCollectionDelete = false;
|
||||
};
|
||||
const handleFilePicked = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
const files = getDataTransferFiles(ev);
|
||||
|
||||
handleExportCollectionOpen = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.showCollectionExport = true;
|
||||
};
|
||||
try {
|
||||
const file = files[0];
|
||||
const document = await documents.import(
|
||||
file,
|
||||
null,
|
||||
this.props.collection.id,
|
||||
{ publish: true }
|
||||
);
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
ui.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
[history, ui, documents]
|
||||
);
|
||||
|
||||
handleExportCollectionClose = () => {
|
||||
this.showCollectionExport = false;
|
||||
};
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
handleMembersModalOpen = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.showCollectionMembers = true;
|
||||
};
|
||||
|
||||
handleMembersModalClose = () => {
|
||||
this.showCollectionMembers = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
policies,
|
||||
documents,
|
||||
collection,
|
||||
position,
|
||||
onOpen,
|
||||
onClose,
|
||||
t,
|
||||
} = this.props;
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VisuallyHidden>
|
||||
<input
|
||||
type="file"
|
||||
ref={(ref) => (this.file = ref)}
|
||||
onChange={this.onFilePicked}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
accept={documents.importFileTypes.join(", ")}
|
||||
/>
|
||||
</VisuallyHidden>
|
||||
|
||||
<Modal
|
||||
title={t("Collection permissions")}
|
||||
onRequestClose={this.handleMembersModalClose}
|
||||
isOpen={this.showCollectionMembers}
|
||||
>
|
||||
<CollectionMembers
|
||||
collection={collection}
|
||||
onSubmit={this.handleMembersModalClose}
|
||||
handleEditCollectionOpen={this.handleEditCollectionOpen}
|
||||
onEdit={this.handleEditCollectionOpen}
|
||||
/>
|
||||
</Modal>
|
||||
<DropdownMenu onOpen={onOpen} onClose={onClose} position={position}>
|
||||
<DropdownMenuItems
|
||||
items={[
|
||||
{
|
||||
title: t("New document"),
|
||||
visible: !!(collection && can.update),
|
||||
onClick: this.onNewDocument,
|
||||
},
|
||||
{
|
||||
title: t("Import document"),
|
||||
visible: !!(collection && can.update),
|
||||
onClick: this.onImportDocument,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: t("Edit…"),
|
||||
visible: !!(collection && can.update),
|
||||
onClick: this.handleEditCollectionOpen,
|
||||
},
|
||||
{
|
||||
title: t("Permissions…"),
|
||||
visible: !!(collection && can.update),
|
||||
onClick: this.handleMembersModalOpen,
|
||||
},
|
||||
{
|
||||
title: t("Export…"),
|
||||
visible: !!(collection && can.export),
|
||||
onClick: this.handleExportCollectionOpen,
|
||||
},
|
||||
{
|
||||
title: t("Delete…"),
|
||||
visible: !!(collection && can.delete),
|
||||
onClick: this.handleDeleteCollectionOpen,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
<Modal
|
||||
title={t("Edit collection")}
|
||||
isOpen={this.showCollectionEdit}
|
||||
onRequestClose={this.handleEditCollectionClose}
|
||||
>
|
||||
<CollectionEdit
|
||||
onSubmit={this.handleEditCollectionClose}
|
||||
collection={collection}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Delete collection")}
|
||||
isOpen={this.showCollectionDelete}
|
||||
onRequestClose={this.handleDeleteCollectionClose}
|
||||
>
|
||||
<CollectionDelete
|
||||
onSubmit={this.handleDeleteCollectionClose}
|
||||
collection={collection}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Export collection")}
|
||||
isOpen={this.showCollectionExport}
|
||||
onRequestClose={this.handleExportCollectionClose}
|
||||
>
|
||||
<CollectionExport
|
||||
onSubmit={this.handleExportCollectionClose}
|
||||
collection={collection}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<VisuallyHidden>
|
||||
<input
|
||||
type="file"
|
||||
ref={file}
|
||||
onChange={handleFilePicked}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
accept={documents.importFileTypes.join(", ")}
|
||||
tabIndex="-1"
|
||||
/>
|
||||
</VisuallyHidden>
|
||||
{label ? (
|
||||
<MenuButton {...menu}>{label}</MenuButton>
|
||||
) : (
|
||||
<OverflowMenuButton {...menu} />
|
||||
)}
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
onOpen={handleOpen}
|
||||
onClose={onClose}
|
||||
aria-label={t("Collection")}
|
||||
>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
title: t("New document"),
|
||||
visible: can.update,
|
||||
onClick: handleNewDocument,
|
||||
},
|
||||
{
|
||||
title: t("Import document"),
|
||||
visible: can.update,
|
||||
onClick: handleImportDocument,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: `${t("Edit")}…`,
|
||||
visible: can.update,
|
||||
onClick: () => setShowCollectionEdit(true),
|
||||
},
|
||||
{
|
||||
title: `${t("Permissions")}…`,
|
||||
visible: can.update,
|
||||
onClick: () => setShowCollectionMembers(true),
|
||||
},
|
||||
{
|
||||
title: `${t("Export")}…`,
|
||||
visible: !!(collection && can.export),
|
||||
onClick: () => setShowCollectionExport(true),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: `${t("Delete")}…`,
|
||||
visible: !!(collection && can.delete),
|
||||
onClick: () => setShowCollectionDelete(true),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
{renderModals && (
|
||||
<>
|
||||
<Modal
|
||||
title={t("Collection permissions")}
|
||||
onRequestClose={() => setShowCollectionMembers(false)}
|
||||
isOpen={showCollectionMembers}
|
||||
>
|
||||
<CollectionMembers
|
||||
collection={collection}
|
||||
onSubmit={() => setShowCollectionMembers(false)}
|
||||
onEdit={() => setShowCollectionEdit(true)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Edit collection")}
|
||||
isOpen={showCollectionEdit}
|
||||
onRequestClose={() => setShowCollectionEdit(false)}
|
||||
>
|
||||
<CollectionEdit
|
||||
onSubmit={() => setShowCollectionEdit(false)}
|
||||
collection={collection}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Delete collection")}
|
||||
isOpen={showCollectionDelete}
|
||||
onRequestClose={() => setShowCollectionDelete(false)}
|
||||
>
|
||||
<CollectionDelete
|
||||
onSubmit={() => setShowCollectionDelete(false)}
|
||||
collection={collection}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Export collection")}
|
||||
isOpen={showCollectionExport}
|
||||
onRequestClose={() => setShowCollectionExport(false)}
|
||||
>
|
||||
<CollectionExport
|
||||
onSubmit={() => setShowCollectionExport(false)}
|
||||
collection={collection}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withTranslation()<CollectionMenu>(
|
||||
inject("ui", "documents", "policies")(withRouter(CollectionMenu))
|
||||
);
|
||||
export default observer(CollectionMenu);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { AlphabeticalSortIcon, ManualSortIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||
import Collection from "models/Collection";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import NudeButton from "components/NudeButton";
|
||||
|
||||
type Props = {|
|
||||
collection: Collection,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
|};
|
||||
|
||||
function CollectionSortMenu({ collection, onOpen, onClose, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({ modal: true });
|
||||
|
||||
const handleChangeSort = React.useCallback(
|
||||
(field: string) => {
|
||||
menu.hide();
|
||||
return collection.save({
|
||||
sort: {
|
||||
field,
|
||||
direction: "asc",
|
||||
},
|
||||
});
|
||||
},
|
||||
[collection, menu]
|
||||
);
|
||||
|
||||
const alphabeticalSort = collection.sort.field === "title";
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<NudeButton {...props}>
|
||||
{alphabeticalSort ? <AlphabeticalSortIcon /> : <ManualSortIcon />}
|
||||
</NudeButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
aria-label={t("Sort in sidebar")}
|
||||
>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
title: t("Alphabetical sort"),
|
||||
onClick: () => handleChangeSort("title"),
|
||||
selected: alphabeticalSort,
|
||||
},
|
||||
{
|
||||
title: t("Manual sort"),
|
||||
onClick: () => handleChangeSort("index"),
|
||||
selected: !alphabeticalSort,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(CollectionSortMenu);
|
||||
+299
-328
@@ -1,21 +1,21 @@
|
||||
// @flow
|
||||
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 { Redirect } from "react-router-dom";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import CollectionStore from "stores/CollectionsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import DocumentDelete from "scenes/DocumentDelete";
|
||||
import DocumentShare from "scenes/DocumentShare";
|
||||
import DocumentTemplatize from "scenes/DocumentTemplatize";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import { DropdownMenu } from "components/DropdownMenu";
|
||||
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import Flex from "components/Flex";
|
||||
import Modal from "components/Modal";
|
||||
import useStores from "hooks/useStores";
|
||||
import {
|
||||
documentHistoryUrl,
|
||||
documentMoveUrl,
|
||||
@@ -24,348 +24,319 @@ import {
|
||||
newDocumentUrl,
|
||||
} from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
auth: AuthStore,
|
||||
position?: "left" | "right" | "center",
|
||||
type Props = {|
|
||||
document: Document,
|
||||
collections: CollectionStore,
|
||||
policies: PoliciesStore,
|
||||
className: string,
|
||||
isRevision?: boolean,
|
||||
showPrint?: boolean,
|
||||
modal?: boolean,
|
||||
showToggleEmbeds?: boolean,
|
||||
showPin?: boolean,
|
||||
label?: React.Node,
|
||||
label?: (any) => React.Node,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
t: TFunction,
|
||||
};
|
||||
|};
|
||||
|
||||
@observer
|
||||
class DocumentMenu extends React.Component<Props> {
|
||||
@observable redirectTo: ?string;
|
||||
@observable showDeleteModal = false;
|
||||
@observable showTemplateModal = false;
|
||||
@observable showShareModal = false;
|
||||
function DocumentMenu({
|
||||
document,
|
||||
isRevision,
|
||||
className,
|
||||
modal = true,
|
||||
showToggleEmbeds,
|
||||
showPrint,
|
||||
showPin,
|
||||
label,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const { policies, collections, auth, ui } = useStores();
|
||||
const menu = useMenuState({ modal });
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const [renderModals, setRenderModals] = React.useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
|
||||
const [showTemplateModal, setShowTemplateModal] = React.useState(false);
|
||||
const [showShareModal, setShowShareModal] = React.useState(false);
|
||||
|
||||
componentDidUpdate() {
|
||||
this.redirectTo = undefined;
|
||||
}
|
||||
|
||||
handleNewChild = (ev: SyntheticEvent<>) => {
|
||||
const { document } = this.props;
|
||||
this.redirectTo = newDocumentUrl(document.collectionId, {
|
||||
parentDocumentId: document.id,
|
||||
});
|
||||
};
|
||||
|
||||
handleDelete = (ev: SyntheticEvent<>) => {
|
||||
this.showDeleteModal = true;
|
||||
};
|
||||
|
||||
handleDocumentHistory = () => {
|
||||
if (this.props.isRevision) {
|
||||
this.redirectTo = documentUrl(this.props.document);
|
||||
} else {
|
||||
this.redirectTo = documentHistoryUrl(this.props.document);
|
||||
const handleOpen = React.useCallback(() => {
|
||||
setRenderModals(true);
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
};
|
||||
}, [onOpen]);
|
||||
|
||||
handleMove = (ev: SyntheticEvent<>) => {
|
||||
this.redirectTo = documentMoveUrl(this.props.document);
|
||||
};
|
||||
const handleDuplicate = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
const duped = await document.duplicate();
|
||||
|
||||
handleEdit = (ev: SyntheticEvent<>) => {
|
||||
this.redirectTo = editDocumentUrl(this.props.document);
|
||||
};
|
||||
// when duplicating, go straight to the duplicated document content
|
||||
history.push(duped.url);
|
||||
ui.showToast(t("Document duplicated"), { type: "success" });
|
||||
},
|
||||
[ui, t, history, document]
|
||||
);
|
||||
|
||||
handleDuplicate = async (ev: SyntheticEvent<>) => {
|
||||
const duped = await this.props.document.duplicate();
|
||||
const handleArchive = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
await document.archive();
|
||||
ui.showToast(t("Document archived"), { type: "success" });
|
||||
},
|
||||
[ui, t, document]
|
||||
);
|
||||
|
||||
// when duplicating, go straight to the duplicated document content
|
||||
this.redirectTo = duped.url;
|
||||
const { t } = this.props;
|
||||
this.props.ui.showToast(t("Document duplicated"));
|
||||
};
|
||||
const handleRestore = React.useCallback(
|
||||
async (ev: SyntheticEvent<>, options?: { collectionId: string }) => {
|
||||
await document.restore(options);
|
||||
ui.showToast(t("Document restored"), { type: "success" });
|
||||
},
|
||||
[ui, t, document]
|
||||
);
|
||||
|
||||
handleOpenTemplateModal = () => {
|
||||
this.showTemplateModal = true;
|
||||
};
|
||||
const handleUnpublish = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
await document.unpublish();
|
||||
ui.showToast(t("Document unpublished"), { type: "success" });
|
||||
},
|
||||
[ui, t, document]
|
||||
);
|
||||
|
||||
handleCloseTemplateModal = () => {
|
||||
this.showTemplateModal = false;
|
||||
};
|
||||
const handlePrint = React.useCallback((ev: SyntheticEvent<>) => {
|
||||
window.print();
|
||||
}, []);
|
||||
|
||||
handleCloseDeleteModal = () => {
|
||||
this.showDeleteModal = false;
|
||||
};
|
||||
const handleStar = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.stopPropagation();
|
||||
document.star();
|
||||
},
|
||||
[document]
|
||||
);
|
||||
|
||||
handleArchive = async (ev: SyntheticEvent<>) => {
|
||||
await this.props.document.archive();
|
||||
const { t } = this.props;
|
||||
this.props.ui.showToast(t("Document archived"));
|
||||
};
|
||||
const handleUnstar = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.stopPropagation();
|
||||
document.unstar();
|
||||
},
|
||||
[document]
|
||||
);
|
||||
|
||||
handleRestore = async (
|
||||
ev: SyntheticEvent<>,
|
||||
options?: { collectionId: string }
|
||||
) => {
|
||||
await this.props.document.restore(options);
|
||||
const { t } = this.props;
|
||||
this.props.ui.showToast(t("Document restored"));
|
||||
};
|
||||
const handleShareLink = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
await document.share();
|
||||
setShowShareModal(true);
|
||||
},
|
||||
[document]
|
||||
);
|
||||
|
||||
handleUnpublish = async (ev: SyntheticEvent<>) => {
|
||||
await this.props.document.unpublish();
|
||||
const { t } = this.props;
|
||||
this.props.ui.showToast(t("Document unpublished"));
|
||||
};
|
||||
const can = policies.abilities(document.id);
|
||||
const canShareDocuments = !!(can.share && auth.team && auth.team.sharing);
|
||||
const canViewHistory = can.read && !can.restore;
|
||||
const collection = collections.get(document.collectionId);
|
||||
|
||||
handlePin = (ev: SyntheticEvent<>) => {
|
||||
this.props.document.pin();
|
||||
};
|
||||
|
||||
handleUnpin = (ev: SyntheticEvent<>) => {
|
||||
this.props.document.unpin();
|
||||
};
|
||||
|
||||
handleStar = (ev: SyntheticEvent<>) => {
|
||||
ev.stopPropagation();
|
||||
this.props.document.star();
|
||||
};
|
||||
|
||||
handleUnstar = (ev: SyntheticEvent<>) => {
|
||||
ev.stopPropagation();
|
||||
this.props.document.unstar();
|
||||
};
|
||||
|
||||
handleExport = (ev: SyntheticEvent<>) => {
|
||||
this.props.document.download();
|
||||
};
|
||||
|
||||
handleShareLink = async (ev: SyntheticEvent<>) => {
|
||||
const { document } = this.props;
|
||||
await document.share();
|
||||
this.showShareModal = true;
|
||||
};
|
||||
|
||||
handleCloseShareModal = () => {
|
||||
this.showShareModal = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
const {
|
||||
policies,
|
||||
document,
|
||||
position,
|
||||
className,
|
||||
showToggleEmbeds,
|
||||
showPrint,
|
||||
showPin,
|
||||
auth,
|
||||
collections,
|
||||
label,
|
||||
onOpen,
|
||||
onClose,
|
||||
t,
|
||||
} = this.props;
|
||||
|
||||
const can = policies.abilities(document.id);
|
||||
const canShareDocuments = !!(can.share && auth.team && auth.team.sharing);
|
||||
const canViewHistory = can.read && !can.restore;
|
||||
const collection = collections.get(document.collectionId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu
|
||||
className={className}
|
||||
position={position}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
label={label}
|
||||
>
|
||||
<DropdownMenuItems
|
||||
items={[
|
||||
{
|
||||
title: t("Restore"),
|
||||
visible: !!can.unarchive,
|
||||
onClick: this.handleRestore,
|
||||
return (
|
||||
<>
|
||||
{label ? (
|
||||
<MenuButton {...menu}>{label}</MenuButton>
|
||||
) : (
|
||||
<OverflowMenuButton className={className} {...menu} />
|
||||
)}
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
aria-label={t("Document options")}
|
||||
onOpen={handleOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
title: t("Restore"),
|
||||
visible: !!can.unarchive,
|
||||
onClick: handleRestore,
|
||||
},
|
||||
{
|
||||
title: t("Restore"),
|
||||
visible: !!(collection && can.restore),
|
||||
onClick: handleRestore,
|
||||
},
|
||||
{
|
||||
title: t("Restore"),
|
||||
visible: !collection && !!can.restore,
|
||||
style: {
|
||||
left: -170,
|
||||
position: "relative",
|
||||
top: -40,
|
||||
},
|
||||
{
|
||||
title: t("Restore"),
|
||||
visible: !!(collection && can.restore),
|
||||
onClick: this.handleRestore,
|
||||
},
|
||||
{
|
||||
title: t("Restore…"),
|
||||
visible: !collection && !!can.restore,
|
||||
style: {
|
||||
left: -170,
|
||||
position: "relative",
|
||||
top: -40,
|
||||
hover: true,
|
||||
items: [
|
||||
{
|
||||
type: "heading",
|
||||
title: t("Choose a collection"),
|
||||
},
|
||||
hover: true,
|
||||
items: [
|
||||
{
|
||||
type: "heading",
|
||||
title: t("Choose a collection"),
|
||||
},
|
||||
...collections.orderedData.map((collection) => {
|
||||
const can = policies.abilities(collection.id);
|
||||
...collections.orderedData.map((collection) => {
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
return {
|
||||
title: (
|
||||
<>
|
||||
<CollectionIcon collection={collection} />
|
||||
{collection.name}
|
||||
</>
|
||||
),
|
||||
onClick: (ev) =>
|
||||
this.handleRestore(ev, { collectionId: collection.id }),
|
||||
disabled: !can.update,
|
||||
};
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("Unpin"),
|
||||
onClick: this.handleUnpin,
|
||||
visible: !!(showPin && document.pinned && can.unpin),
|
||||
},
|
||||
{
|
||||
title: t("Pin to collection"),
|
||||
onClick: this.handlePin,
|
||||
visible: !!(showPin && !document.pinned && can.pin),
|
||||
},
|
||||
{
|
||||
title: t("Unstar"),
|
||||
onClick: this.handleUnstar,
|
||||
visible: document.isStarred && !!can.unstar,
|
||||
},
|
||||
{
|
||||
title: t("Star"),
|
||||
onClick: this.handleStar,
|
||||
visible: !document.isStarred && !!can.star,
|
||||
},
|
||||
{
|
||||
title: t("Share link…"),
|
||||
onClick: this.handleShareLink,
|
||||
visible: canShareDocuments,
|
||||
},
|
||||
{
|
||||
title: t("Enable embeds"),
|
||||
onClick: document.enableEmbeds,
|
||||
visible: !!showToggleEmbeds && document.embedsDisabled,
|
||||
},
|
||||
{
|
||||
title: t("Disable embeds"),
|
||||
onClick: document.disableEmbeds,
|
||||
visible: !!showToggleEmbeds && !document.embedsDisabled,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: t("New nested document"),
|
||||
onClick: this.handleNewChild,
|
||||
visible: !!can.createChildDocument,
|
||||
},
|
||||
{
|
||||
title: t("Create template…"),
|
||||
onClick: this.handleOpenTemplateModal,
|
||||
visible: !!can.update && !document.isTemplate,
|
||||
},
|
||||
{
|
||||
title: t("Edit"),
|
||||
onClick: this.handleEdit,
|
||||
visible: !!can.update,
|
||||
},
|
||||
{
|
||||
title: t("Duplicate"),
|
||||
onClick: this.handleDuplicate,
|
||||
visible: !!can.update,
|
||||
},
|
||||
{
|
||||
title: t("Unpublish"),
|
||||
onClick: this.handleUnpublish,
|
||||
visible: !!can.unpublish,
|
||||
},
|
||||
{
|
||||
title: t("Archive"),
|
||||
onClick: this.handleArchive,
|
||||
visible: !!can.archive,
|
||||
},
|
||||
{
|
||||
title: t("Delete…"),
|
||||
onClick: this.handleDelete,
|
||||
visible: !!can.delete,
|
||||
},
|
||||
{
|
||||
title: t("Move…"),
|
||||
onClick: this.handleMove,
|
||||
visible: !!can.move,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: t("History"),
|
||||
onClick: this.handleDocumentHistory,
|
||||
visible: canViewHistory,
|
||||
},
|
||||
{
|
||||
title: t("Download"),
|
||||
onClick: this.handleExport,
|
||||
visible: !!can.download,
|
||||
},
|
||||
{
|
||||
title: t("Print"),
|
||||
onClick: window.print,
|
||||
visible: !!showPrint,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
<Modal
|
||||
title={t("Delete {{ documentName }}", {
|
||||
documentName: this.props.document.noun,
|
||||
})}
|
||||
onRequestClose={this.handleCloseDeleteModal}
|
||||
isOpen={this.showDeleteModal}
|
||||
>
|
||||
<DocumentDelete
|
||||
document={this.props.document}
|
||||
onSubmit={this.handleCloseDeleteModal}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Create template")}
|
||||
onRequestClose={this.handleCloseTemplateModal}
|
||||
isOpen={this.showTemplateModal}
|
||||
>
|
||||
<DocumentTemplatize
|
||||
document={this.props.document}
|
||||
onSubmit={this.handleCloseTemplateModal}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Share document")}
|
||||
onRequestClose={this.handleCloseShareModal}
|
||||
isOpen={this.showShareModal}
|
||||
>
|
||||
<DocumentShare
|
||||
document={this.props.document}
|
||||
onSubmit={this.handleCloseShareModal}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return {
|
||||
title: (
|
||||
<Flex align="center">
|
||||
<CollectionIcon collection={collection} />
|
||||
<CollectionName>{collection.name}</CollectionName>
|
||||
</Flex>
|
||||
),
|
||||
onClick: (ev) =>
|
||||
handleRestore(ev, { collectionId: collection.id }),
|
||||
disabled: !can.update,
|
||||
};
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("Unpin"),
|
||||
onClick: document.unpin,
|
||||
visible: !!(showPin && document.pinned && can.unpin),
|
||||
},
|
||||
{
|
||||
title: t("Pin to collection"),
|
||||
onClick: document.pin,
|
||||
visible: !!(showPin && !document.pinned && can.pin),
|
||||
},
|
||||
{
|
||||
title: t("Unstar"),
|
||||
onClick: handleUnstar,
|
||||
visible: document.isStarred && !!can.unstar,
|
||||
},
|
||||
{
|
||||
title: t("Star"),
|
||||
onClick: handleStar,
|
||||
visible: !document.isStarred && !!can.star,
|
||||
},
|
||||
{
|
||||
title: `${t("Share link")}…`,
|
||||
onClick: handleShareLink,
|
||||
visible: canShareDocuments,
|
||||
},
|
||||
{
|
||||
title: t("Enable embeds"),
|
||||
onClick: document.enableEmbeds,
|
||||
visible: !!showToggleEmbeds && document.embedsDisabled,
|
||||
},
|
||||
{
|
||||
title: t("Disable embeds"),
|
||||
onClick: document.disableEmbeds,
|
||||
visible: !!showToggleEmbeds && !document.embedsDisabled,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: t("New nested document"),
|
||||
to: newDocumentUrl(document.collectionId, {
|
||||
parentDocumentId: document.id,
|
||||
}),
|
||||
visible: !!can.createChildDocument,
|
||||
},
|
||||
{
|
||||
title: `${t("Create template")}…`,
|
||||
onClick: () => setShowTemplateModal(true),
|
||||
visible: !!can.update && !document.isTemplate,
|
||||
},
|
||||
{
|
||||
title: t("Edit"),
|
||||
to: editDocumentUrl(document),
|
||||
visible: !!can.update,
|
||||
},
|
||||
{
|
||||
title: t("Duplicate"),
|
||||
onClick: handleDuplicate,
|
||||
visible: !!can.update,
|
||||
},
|
||||
{
|
||||
title: t("Unpublish"),
|
||||
onClick: handleUnpublish,
|
||||
visible: !!can.unpublish,
|
||||
},
|
||||
{
|
||||
title: t("Archive"),
|
||||
onClick: handleArchive,
|
||||
visible: !!can.archive,
|
||||
},
|
||||
{
|
||||
title: `${t("Delete")}…`,
|
||||
onClick: () => setShowDeleteModal(true),
|
||||
visible: !!can.delete,
|
||||
},
|
||||
{
|
||||
title: `${t("Move")}…`,
|
||||
to: documentMoveUrl(document),
|
||||
visible: !!can.move,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: t("History"),
|
||||
to: isRevision
|
||||
? documentUrl(document)
|
||||
: documentHistoryUrl(document),
|
||||
visible: canViewHistory,
|
||||
},
|
||||
{
|
||||
title: t("Download"),
|
||||
onClick: document.download,
|
||||
visible: !!can.download,
|
||||
},
|
||||
{
|
||||
title: t("Print"),
|
||||
onClick: handlePrint,
|
||||
visible: !!showPrint,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
{renderModals && (
|
||||
<>
|
||||
<Modal
|
||||
title={t("Delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowDeleteModal(false)}
|
||||
isOpen={showDeleteModal}
|
||||
>
|
||||
<DocumentDelete
|
||||
document={document}
|
||||
onSubmit={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Create template")}
|
||||
onRequestClose={() => setShowTemplateModal(false)}
|
||||
isOpen={showTemplateModal}
|
||||
>
|
||||
<DocumentTemplatize
|
||||
document={document}
|
||||
onSubmit={() => setShowTemplateModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Share document")}
|
||||
onRequestClose={() => setShowShareModal(false)}
|
||||
isOpen={showShareModal}
|
||||
>
|
||||
<DocumentShare
|
||||
document={document}
|
||||
onSubmit={() => setShowShareModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withTranslation()<DocumentMenu>(
|
||||
inject("ui", "auth", "collections", "policies")(DocumentMenu)
|
||||
);
|
||||
const CollectionName = styled.div`
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
export default observer(DocumentMenu);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
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 = {|
|
||||
onRemove: () => void,
|
||||
|};
|
||||
|
||||
function GroupMemberMenu({ onRemove }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({ modal: true });
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Group member options")}>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
title: t("Remove"),
|
||||
onClick: onRemove,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(GroupMemberMenu);
|
||||
+61
-95
@@ -1,108 +1,74 @@
|
||||
// @flow
|
||||
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 { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import Group from "models/Group";
|
||||
import GroupDelete from "scenes/GroupDelete";
|
||||
import GroupEdit from "scenes/GroupEdit";
|
||||
import { DropdownMenu } from "components/DropdownMenu";
|
||||
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import Modal from "components/Modal";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
policies: PoliciesStore,
|
||||
type Props = {|
|
||||
group: Group,
|
||||
history: RouterHistory,
|
||||
onMembers: () => void,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
t: TFunction,
|
||||
};
|
||||
|};
|
||||
|
||||
@observer
|
||||
class GroupMenu extends React.Component<Props> {
|
||||
@observable editModalOpen: boolean = false;
|
||||
@observable deleteModalOpen: boolean = false;
|
||||
function GroupMenu({ group, onMembers }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { policies } = useStores();
|
||||
const menu = useMenuState({ modal: true });
|
||||
const [editModalOpen, setEditModalOpen] = React.useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = React.useState(false);
|
||||
const can = policies.abilities(group.id);
|
||||
|
||||
onEdit = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.editModalOpen = true;
|
||||
};
|
||||
|
||||
onDelete = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.deleteModalOpen = true;
|
||||
};
|
||||
|
||||
handleEditModalClose = () => {
|
||||
this.editModalOpen = false;
|
||||
};
|
||||
|
||||
handleDeleteModalClose = () => {
|
||||
this.deleteModalOpen = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { policies, group, onOpen, onClose, t } = this.props;
|
||||
const can = policies.abilities(group.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title={t("Edit group")}
|
||||
onRequestClose={this.handleEditModalClose}
|
||||
isOpen={this.editModalOpen}
|
||||
>
|
||||
<GroupEdit
|
||||
group={this.props.group}
|
||||
onSubmit={this.handleEditModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t("Delete group")}
|
||||
onRequestClose={this.handleDeleteModalClose}
|
||||
isOpen={this.deleteModalOpen}
|
||||
>
|
||||
<GroupDelete
|
||||
group={this.props.group}
|
||||
onSubmit={this.handleDeleteModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
<DropdownMenu onOpen={onOpen} onClose={onClose}>
|
||||
<DropdownMenuItems
|
||||
items={[
|
||||
{
|
||||
title: t("Members…"),
|
||||
onClick: this.props.onMembers,
|
||||
visible: !!(group && can.read),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: t("Edit…"),
|
||||
onClick: this.onEdit,
|
||||
visible: !!(group && can.update),
|
||||
},
|
||||
{
|
||||
title: t("Delete…"),
|
||||
onClick: this.onDelete,
|
||||
visible: !!(group && can.delete),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title={t("Edit group")}
|
||||
onRequestClose={() => setEditModalOpen(false)}
|
||||
isOpen={editModalOpen}
|
||||
>
|
||||
<GroupEdit group={group} onSubmit={() => setEditModalOpen(false)} />
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Delete group")}
|
||||
onRequestClose={() => setDeleteModalOpen(false)}
|
||||
isOpen={deleteModalOpen}
|
||||
>
|
||||
<GroupDelete group={group} onSubmit={() => setDeleteModalOpen(false)} />
|
||||
</Modal>
|
||||
<OverflowMenuButton {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Group options")}>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
title: `${t("Members")}…`,
|
||||
onClick: onMembers,
|
||||
visible: !!(group && can.read),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: `${t("Edit")}…`,
|
||||
onClick: () => setEditModalOpen(true),
|
||||
visible: !!(group && can.update),
|
||||
},
|
||||
{
|
||||
title: `${t("Delete")}…`,
|
||||
onClick: () => setDeleteModalOpen(true),
|
||||
visible: !!(group && can.delete),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withTranslation()<GroupMenu>(
|
||||
inject("policies")(withRouter(GroupMenu))
|
||||
);
|
||||
export default observer(GroupMenu);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
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 = {|
|
||||
onRemove: () => void,
|
||||
|};
|
||||
|
||||
function MemberMenu({ onRemove }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({ modal: true });
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Member options")}>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
title: t("Remove"),
|
||||
onClick: onRemove,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(MemberMenu);
|
||||
@@ -1,75 +1,54 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { Redirect } from "react-router-dom";
|
||||
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||
import Document from "models/Document";
|
||||
import { DropdownMenu } from "components/DropdownMenu";
|
||||
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import useStores from "hooks/useStores";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
label?: React.Node,
|
||||
label?: (any) => React.Node,
|
||||
document: Document,
|
||||
collections: CollectionsStore,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
class NewChildDocumentMenu extends React.Component<Props> {
|
||||
@observable redirectTo: ?string;
|
||||
function NewChildDocumentMenu({ document, label }: Props) {
|
||||
const menu = useMenuState({ modal: true });
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const collection = collections.get(document.collectionId);
|
||||
const collectionName = collection ? collection.name : t("collection");
|
||||
|
||||
componentDidUpdate() {
|
||||
this.redirectTo = undefined;
|
||||
}
|
||||
|
||||
handleNewDocument = () => {
|
||||
const { document } = this.props;
|
||||
this.redirectTo = newDocumentUrl(document.collectionId);
|
||||
};
|
||||
|
||||
handleNewChild = () => {
|
||||
const { document } = this.props;
|
||||
this.redirectTo = newDocumentUrl(document.collectionId, {
|
||||
parentDocumentId: document.id,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
const { label, document, collections, t } = this.props;
|
||||
const collection = collections.get(document.collectionId);
|
||||
|
||||
return (
|
||||
<DropdownMenu label={label}>
|
||||
<DropdownMenuItems
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>{label}</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("New child document")}>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
title: (
|
||||
<span>
|
||||
{t("New document in")}{" "}
|
||||
<strong>
|
||||
{collection ? collection.name : t("collection")}
|
||||
</strong>
|
||||
<Trans>
|
||||
New document in <strong>{{ collectionName }}</strong>
|
||||
</Trans>
|
||||
</span>
|
||||
),
|
||||
onClick: this.handleNewDocument,
|
||||
to: newDocumentUrl(document.collectionId),
|
||||
},
|
||||
{
|
||||
title: t("New nested document"),
|
||||
onClick: this.handleNewChild,
|
||||
to: newDocumentUrl(document.collectionId, {
|
||||
parentDocumentId: document.id,
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withTranslation()<NewChildDocumentMenu>(
|
||||
inject("collections")(NewChildDocumentMenu)
|
||||
);
|
||||
export default observer(NewChildDocumentMenu);
|
||||
|
||||
@@ -1,92 +1,72 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { Redirect } from "react-router-dom";
|
||||
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import Button from "components/Button";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import { DropdownMenu, Header } from "components/DropdownMenu";
|
||||
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import Header from "components/ContextMenu/Header";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import Flex from "components/Flex";
|
||||
import useStores from "hooks/useStores";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
label?: React.Node,
|
||||
documents: DocumentsStore,
|
||||
collections: CollectionsStore,
|
||||
policies: PoliciesStore,
|
||||
t: TFunction,
|
||||
};
|
||||
function NewDocumentMenu() {
|
||||
const menu = useMenuState();
|
||||
const { t } = useTranslation();
|
||||
const { collections, policies } = useStores();
|
||||
const singleCollection = collections.orderedData.length === 1;
|
||||
|
||||
@observer
|
||||
class NewDocumentMenu extends React.Component<Props> {
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
componentDidUpdate() {
|
||||
this.redirectTo = undefined;
|
||||
if (singleCollection) {
|
||||
return (
|
||||
<Button
|
||||
as={Link}
|
||||
to={newDocumentUrl(collections.orderedData[0].id)}
|
||||
icon={<PlusIcon />}
|
||||
small
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
handleNewDocument = (
|
||||
collectionId: string,
|
||||
options?: {
|
||||
parentDocumentId?: string,
|
||||
template?: boolean,
|
||||
templateId?: string,
|
||||
}
|
||||
) => {
|
||||
this.redirectTo = newDocumentUrl(collectionId, options);
|
||||
};
|
||||
|
||||
onOpen = () => {
|
||||
const { collections } = this.props;
|
||||
|
||||
if (collections.orderedData.length === 1) {
|
||||
this.handleNewDocument(collections.orderedData[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
const { collections, documents, policies, label, t, ...rest } = this.props;
|
||||
const singleCollection = collections.orderedData.length === 1;
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
label={
|
||||
label || (
|
||||
<Button icon={<PlusIcon />} small>
|
||||
{t("New doc")}
|
||||
{singleCollection ? "" : "…"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
onOpen={this.onOpen}
|
||||
{...rest}
|
||||
>
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<Button icon={<PlusIcon />} {...props} small>
|
||||
{`${t("New doc")}…`}
|
||||
</Button>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("New document")}>
|
||||
<Header>{t("Choose a collection")}</Header>
|
||||
<DropdownMenuItems
|
||||
<Template
|
||||
{...menu}
|
||||
items={collections.orderedData.map((collection) => ({
|
||||
onClick: () => this.handleNewDocument(collection.id),
|
||||
to: newDocumentUrl(collection.id),
|
||||
disabled: !policies.abilities(collection.id).update,
|
||||
title: (
|
||||
<>
|
||||
<Flex align="center">
|
||||
<CollectionIcon collection={collection} />
|
||||
{collection.name}
|
||||
</>
|
||||
<CollectionName>{collection.name}</CollectionName>
|
||||
</Flex>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withTranslation()<NewDocumentMenu>(
|
||||
inject("collections", "documents", "policies")(NewDocumentMenu)
|
||||
);
|
||||
const CollectionName = styled.div`
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
export default observer(NewDocumentMenu);
|
||||
|
||||
@@ -1,74 +1,59 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { Redirect } from "react-router-dom";
|
||||
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import Button from "components/Button";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import { DropdownMenu, Header } from "components/DropdownMenu";
|
||||
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import Header from "components/ContextMenu/Header";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import Flex from "components/Flex";
|
||||
import useStores from "hooks/useStores";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
label?: React.Node,
|
||||
collections: CollectionsStore,
|
||||
policies: PoliciesStore,
|
||||
t: TFunction,
|
||||
};
|
||||
function NewTemplateMenu() {
|
||||
const menu = useMenuState();
|
||||
const { t } = useTranslation();
|
||||
const { collections, policies } = useStores();
|
||||
|
||||
@observer
|
||||
class NewTemplateMenu extends React.Component<Props> {
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
componentDidUpdate() {
|
||||
this.redirectTo = undefined;
|
||||
}
|
||||
|
||||
handleNewDocument = (collectionId: string) => {
|
||||
this.redirectTo = newDocumentUrl(collectionId, {
|
||||
template: true,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
const { collections, policies, label, t, ...rest } = this.props;
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
label={
|
||||
label || (
|
||||
<Button icon={<PlusIcon />} small>
|
||||
{t("New template…")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
{...rest}
|
||||
>
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<Button icon={<PlusIcon />} {...props} small>
|
||||
{t("New template")}…
|
||||
</Button>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu aria-label={t("New template")} {...menu}>
|
||||
<Header>{t("Choose a collection")}</Header>
|
||||
<DropdownMenuItems
|
||||
<Template
|
||||
{...menu}
|
||||
items={collections.orderedData.map((collection) => ({
|
||||
onClick: () => this.handleNewDocument(collection.id),
|
||||
to: newDocumentUrl(collection.id, {
|
||||
template: true,
|
||||
}),
|
||||
disabled: !policies.abilities(collection.id).update,
|
||||
title: (
|
||||
<>
|
||||
<Flex align="center">
|
||||
<CollectionIcon collection={collection} />
|
||||
{collection.name}
|
||||
</>
|
||||
<CollectionName>{collection.name}</CollectionName>
|
||||
</Flex>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withTranslation()<NewTemplateMenu>(
|
||||
inject("collections", "policies")(NewTemplateMenu)
|
||||
);
|
||||
const CollectionName = styled.div`
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
export default observer(NewTemplateMenu);
|
||||
|
||||
+50
-49
@@ -1,68 +1,69 @@
|
||||
// @flow
|
||||
import { inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
|
||||
import UiStore from "stores/UiStore";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import Document from "models/Document";
|
||||
import Revision from "models/Revision";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import MenuItem from "components/ContextMenu/MenuItem";
|
||||
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||
import Separator from "components/ContextMenu/Separator";
|
||||
import CopyToClipboard from "components/CopyToClipboard";
|
||||
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
|
||||
import useStores from "hooks/useStores";
|
||||
import { documentHistoryUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
onOpen?: () => void,
|
||||
onClose: () => void,
|
||||
history: RouterHistory,
|
||||
type Props = {|
|
||||
document: Document,
|
||||
revision: Revision,
|
||||
iconColor?: string,
|
||||
className?: string,
|
||||
label: React.Node,
|
||||
ui: UiStore,
|
||||
t: TFunction,
|
||||
};
|
||||
|};
|
||||
|
||||
class RevisionMenu extends React.Component<Props> {
|
||||
handleRestore = async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
await this.props.document.restore({ revisionId: this.props.revision.id });
|
||||
const { t } = this.props;
|
||||
this.props.ui.showToast(t("Document restored"));
|
||||
this.props.history.push(this.props.document.url);
|
||||
};
|
||||
function RevisionMenu({ document, revision, className, iconColor }: Props) {
|
||||
const { ui } = useStores();
|
||||
const menu = useMenuState({ modal: true });
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
|
||||
handleCopy = () => {
|
||||
const { t } = this.props;
|
||||
this.props.ui.showToast(t("Link copied"));
|
||||
};
|
||||
const handleRestore = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
await document.restore({ revisionId: revision.id });
|
||||
ui.showToast(t("Document restored"), { type: "success" });
|
||||
history.push(document.url);
|
||||
},
|
||||
[history, ui, t, document, revision]
|
||||
);
|
||||
|
||||
render() {
|
||||
const { className, label, onOpen, onClose, t } = this.props;
|
||||
const url = `${window.location.origin}${documentHistoryUrl(
|
||||
this.props.document,
|
||||
this.props.revision.id
|
||||
)}`;
|
||||
const handleCopy = React.useCallback(() => {
|
||||
ui.showToast(t("Link copied"), { type: "info" });
|
||||
}, [ui, t]);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
const url = `${window.location.origin}${documentHistoryUrl(
|
||||
document,
|
||||
revision.id
|
||||
)}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton
|
||||
className={className}
|
||||
label={label}
|
||||
>
|
||||
<DropdownMenuItem onClick={this.handleRestore}>
|
||||
iconColor={iconColor}
|
||||
{...menu}
|
||||
/>
|
||||
<ContextMenu {...menu} aria-label={t("Revision options")}>
|
||||
<MenuItem {...menu} onClick={handleRestore}>
|
||||
{t("Restore version")}
|
||||
</DropdownMenuItem>
|
||||
<hr />
|
||||
<CopyToClipboard text={url} onCopy={this.handleCopy}>
|
||||
<DropdownMenuItem>{t("Copy link")}</DropdownMenuItem>
|
||||
</MenuItem>
|
||||
<Separator />
|
||||
<CopyToClipboard text={url} onCopy={handleCopy}>
|
||||
<MenuItem {...menu}>{t("Copy link")}</MenuItem>
|
||||
</CopyToClipboard>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withTranslation()<RevisionMenu>(
|
||||
withRouter(inject("ui")(RevisionMenu))
|
||||
);
|
||||
export default observer(RevisionMenu);
|
||||
|
||||
+49
-55
@@ -1,75 +1,69 @@
|
||||
// @flow
|
||||
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 { Redirect } from "react-router-dom";
|
||||
|
||||
import SharesStore from "stores/SharesStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import Share from "models/Share";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import MenuItem from "components/ContextMenu/MenuItem";
|
||||
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||
import CopyToClipboard from "components/CopyToClipboard";
|
||||
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
onOpen?: () => void,
|
||||
onClose: () => void,
|
||||
shares: SharesStore,
|
||||
ui: UiStore,
|
||||
share: Share,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
class ShareMenu extends React.Component<Props> {
|
||||
@observable redirectTo: ?string;
|
||||
function ShareMenu({ share }: Props) {
|
||||
const menu = useMenuState({ modal: true });
|
||||
const { ui, shares } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
|
||||
componentDidUpdate() {
|
||||
this.redirectTo = undefined;
|
||||
}
|
||||
const handleGoToDocument = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
history.push(share.documentUrl);
|
||||
},
|
||||
[history, share]
|
||||
);
|
||||
|
||||
handleGoToDocument = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.redirectTo = this.props.share.documentUrl;
|
||||
};
|
||||
const handleRevoke = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
|
||||
handleRevoke = async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
try {
|
||||
await shares.revoke(share);
|
||||
ui.showToast(t("Share link revoked"), { type: "info" });
|
||||
} catch (err) {
|
||||
ui.showToast(err.message, { type: "error" });
|
||||
}
|
||||
},
|
||||
[t, shares, share, ui]
|
||||
);
|
||||
|
||||
try {
|
||||
await this.props.shares.revoke(this.props.share);
|
||||
const { t } = this.props;
|
||||
this.props.ui.showToast(t("Share link revoked"));
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message);
|
||||
}
|
||||
};
|
||||
const handleCopy = React.useCallback(() => {
|
||||
ui.showToast(t("Share link copied"), { type: "info" });
|
||||
}, [t, ui]);
|
||||
|
||||
handleCopy = () => {
|
||||
const { t } = this.props;
|
||||
this.props.ui.showToast(t("Share link copied"));
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
const { share, onOpen, onClose, t } = this.props;
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpen={onOpen} onClose={onClose}>
|
||||
<CopyToClipboard text={share.url} onCopy={this.handleCopy}>
|
||||
<DropdownMenuItem>{t("Copy link")}</DropdownMenuItem>
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Share options")}>
|
||||
<CopyToClipboard text={share.url} onCopy={handleCopy}>
|
||||
<MenuItem {...menu}>{t("Copy link")}</MenuItem>
|
||||
</CopyToClipboard>
|
||||
<DropdownMenuItem onClick={this.handleGoToDocument}>
|
||||
<MenuItem {...menu} onClick={handleGoToDocument}>
|
||||
{t("Go to document")}
|
||||
</DropdownMenuItem>
|
||||
</MenuItem>
|
||||
<hr />
|
||||
<DropdownMenuItem onClick={this.handleRevoke}>
|
||||
<MenuItem {...menu} onClick={handleRevoke}>
|
||||
{t("Revoke link")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withTranslation()<ShareMenu>(inject("shares", "ui")(ShareMenu));
|
||||
export default observer(ShareMenu);
|
||||
|
||||
+30
-32
@@ -1,42 +1,42 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import Document from "models/Document";
|
||||
import Button from "components/Button";
|
||||
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import MenuItem from "components/ContextMenu/MenuItem";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
document: Document,
|
||||
documents: DocumentsStore,
|
||||
t: TFunction,
|
||||
};
|
||||
|};
|
||||
|
||||
@observer
|
||||
class TemplatesMenu extends React.Component<Props> {
|
||||
render() {
|
||||
const { documents, document, t, ...rest } = this.props;
|
||||
const templates = documents.templatesInCollection(document.collectionId);
|
||||
function TemplatesMenu({ document }: Props) {
|
||||
const menu = useMenuState({ modal: true });
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const templates = documents.templatesInCollection(document.collectionId);
|
||||
|
||||
if (!templates.length) {
|
||||
return null;
|
||||
}
|
||||
if (!templates.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
position="left"
|
||||
label={
|
||||
<Button disclosure neutral>
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<Button {...props} disclosure neutral>
|
||||
{t("Templates")}
|
||||
</Button>
|
||||
}
|
||||
{...rest}
|
||||
>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Templates")}>
|
||||
{templates.map((template) => (
|
||||
<DropdownMenuItem
|
||||
<MenuItem
|
||||
key={template.id}
|
||||
onClick={() => document.updateFromTemplate(template)}
|
||||
>
|
||||
@@ -48,17 +48,15 @@ class TemplatesMenu extends React.Component<Props> {
|
||||
{t("By {{ author }}", { author: template.createdBy.name })}
|
||||
</Author>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</MenuItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Author = styled.div`
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
export default withTranslation()<TemplatesMenu>(
|
||||
inject("documents")(TemplatesMenu)
|
||||
);
|
||||
export default observer(TemplatesMenu);
|
||||
|
||||
+92
-80
@@ -1,123 +1,135 @@
|
||||
// @flow
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import UsersStore from "stores/UsersStore";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import User from "models/User";
|
||||
import { DropdownMenu } from "components/DropdownMenu";
|
||||
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
user: User,
|
||||
users: UsersStore,
|
||||
t: TFunction,
|
||||
};
|
||||
|};
|
||||
|
||||
@observer
|
||||
class UserMenu extends React.Component<Props> {
|
||||
handlePromote = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { user, users, t } = this.props;
|
||||
if (
|
||||
!window.confirm(
|
||||
t(
|
||||
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.",
|
||||
{ userName: user.name }
|
||||
function UserMenu({ user }: Props) {
|
||||
const { users } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({ modal: true });
|
||||
|
||||
const handlePromote = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
if (
|
||||
!window.confirm(
|
||||
t(
|
||||
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.",
|
||||
{ userName: user.name }
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
users.promote(user);
|
||||
};
|
||||
) {
|
||||
return;
|
||||
}
|
||||
users.promote(user);
|
||||
},
|
||||
[users, user, t]
|
||||
);
|
||||
|
||||
handleDemote = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { user, users, t } = this.props;
|
||||
if (
|
||||
!window.confirm(
|
||||
t("Are you sure you want to make {{ userName }} a member?", {
|
||||
userName: user.name,
|
||||
})
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
users.demote(user);
|
||||
};
|
||||
|
||||
handleSuspend = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { user, users, t } = this.props;
|
||||
if (
|
||||
!window.confirm(
|
||||
t(
|
||||
"Are you sure you want to suspend this account? Suspended users will be prevented from logging in."
|
||||
const handleDemote = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
if (
|
||||
!window.confirm(
|
||||
t("Are you sure you want to make {{ userName }} a member?", {
|
||||
userName: user.name,
|
||||
})
|
||||
)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
users.suspend(user);
|
||||
};
|
||||
) {
|
||||
return;
|
||||
}
|
||||
users.demote(user);
|
||||
},
|
||||
[users, user, t]
|
||||
);
|
||||
|
||||
handleRevoke = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { user, users } = this.props;
|
||||
users.delete(user, { confirmation: true });
|
||||
};
|
||||
const handleSuspend = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
if (
|
||||
!window.confirm(
|
||||
t(
|
||||
"Are you sure you want to suspend this account? Suspended users will be prevented from logging in."
|
||||
)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
users.suspend(user);
|
||||
},
|
||||
[users, user, t]
|
||||
);
|
||||
|
||||
handleActivate = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { user, users } = this.props;
|
||||
users.activate(user);
|
||||
};
|
||||
const handleRevoke = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
users.delete(user, { confirmation: true });
|
||||
},
|
||||
[users, user]
|
||||
);
|
||||
|
||||
render() {
|
||||
const { user, t } = this.props;
|
||||
const handleActivate = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
users.activate(user);
|
||||
},
|
||||
[users, user]
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuItems
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("User options")}>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
title: t("Make {{ userName }} a member…", {
|
||||
userName: user.name,
|
||||
}),
|
||||
onClick: this.handleDemote,
|
||||
onClick: handleDemote,
|
||||
visible: user.isAdmin,
|
||||
},
|
||||
{
|
||||
title: t("Make {{ userName }} an admin…", {
|
||||
userName: user.name,
|
||||
}),
|
||||
onClick: this.handlePromote,
|
||||
onClick: handlePromote,
|
||||
visible: !user.isAdmin && !user.isSuspended,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: t("Revoke invite…"),
|
||||
onClick: this.handleRevoke,
|
||||
title: `${t("Revoke invite")}…`,
|
||||
onClick: handleRevoke,
|
||||
visible: user.isInvited,
|
||||
},
|
||||
{
|
||||
title: t("Activate account"),
|
||||
onClick: this.handleActivate,
|
||||
onClick: handleActivate,
|
||||
visible: !user.isInvited && user.isSuspended,
|
||||
},
|
||||
{
|
||||
title: t("Suspend account…"),
|
||||
onClick: this.handleSuspend,
|
||||
title: `${t("Suspend account")}…`,
|
||||
onClick: handleSuspend,
|
||||
visible: !user.isInvited && !user.isSuspended,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withTranslation()<UserMenu>(inject("users")(UserMenu));
|
||||
export default observer(UserMenu);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import { pick } from "lodash";
|
||||
import { pick, trim } from "lodash";
|
||||
import { action, computed, observable } from "mobx";
|
||||
import BaseModel from "models/BaseModel";
|
||||
import Document from "models/Document";
|
||||
@@ -20,6 +20,7 @@ export default class Collection extends BaseModel {
|
||||
createdAt: ?string;
|
||||
updatedAt: ?string;
|
||||
deletedAt: ?string;
|
||||
sort: { field: string, direction: "asc" | "desc" };
|
||||
url: string;
|
||||
|
||||
@computed
|
||||
@@ -45,6 +46,11 @@ export default class Collection extends BaseModel {
|
||||
return results;
|
||||
}
|
||||
|
||||
@computed
|
||||
get hasDescription(): boolean {
|
||||
return !!trim(this.description, "\\").trim();
|
||||
}
|
||||
|
||||
@action
|
||||
updateDocument(document: Document) {
|
||||
const travelDocuments = (documentList, path) =>
|
||||
@@ -79,12 +85,12 @@ export default class Collection extends BaseModel {
|
||||
return result;
|
||||
}
|
||||
|
||||
pathToDocument(document: Document) {
|
||||
pathToDocument(documentId: string) {
|
||||
let path;
|
||||
const traveler = (nodes, previousPath) => {
|
||||
nodes.forEach((childNode) => {
|
||||
const newPath = [...previousPath, childNode];
|
||||
if (childNode.id === document.id) {
|
||||
if (childNode.id === documentId) {
|
||||
path = newPath;
|
||||
return;
|
||||
}
|
||||
@@ -108,6 +114,7 @@ export default class Collection extends BaseModel {
|
||||
"description",
|
||||
"icon",
|
||||
"private",
|
||||
"sort",
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ export default class Document extends BaseModel {
|
||||
@action
|
||||
view = () => {
|
||||
// we don't record views for documents in the trash
|
||||
if (this.isDeleted) {
|
||||
if (this.isDeleted || !this.publishedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ export default class Document extends BaseModel {
|
||||
};
|
||||
|
||||
move = (collectionId: string, parentDocumentId: ?string) => {
|
||||
return this.store.move(this, collectionId, parentDocumentId);
|
||||
return this.store.move(this.id, collectionId, parentDocumentId);
|
||||
};
|
||||
|
||||
duplicate = () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
|
||||
import { NewDocumentIcon, PlusIcon, PinIcon } from "outline-icons";
|
||||
import { NewDocumentIcon, PlusIcon, PinIcon, MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, Trans, type TFunction } from "react-i18next";
|
||||
import { Redirect, Link, Switch, Route, type Match } from "react-router-dom";
|
||||
@@ -144,7 +144,7 @@ class CollectionScene extends React.Component<Props> {
|
||||
<Action>
|
||||
<InputSearch
|
||||
source="collection"
|
||||
placeholder={t("Search in collection…")}
|
||||
placeholder={`${t("Search in collection")}…`}
|
||||
collectionId={match.params.id}
|
||||
/>
|
||||
</Action>
|
||||
@@ -164,7 +164,20 @@ class CollectionScene extends React.Component<Props> {
|
||||
</>
|
||||
)}
|
||||
<Action>
|
||||
<CollectionMenu collection={this.collection} />
|
||||
<CollectionMenu
|
||||
collection={this.collection}
|
||||
placement="bottom-end"
|
||||
modal={false}
|
||||
label={(props) => (
|
||||
<Button
|
||||
icon={<MoreIcon />}
|
||||
{...props}
|
||||
borderOnHover
|
||||
neutral
|
||||
small
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Action>
|
||||
</Actions>
|
||||
);
|
||||
@@ -179,9 +192,10 @@ class CollectionScene extends React.Component<Props> {
|
||||
const pinnedDocuments = this.collection
|
||||
? documents.pinnedInCollection(this.collection.id)
|
||||
: [];
|
||||
const hasPinnedDocuments = !!pinnedDocuments.length;
|
||||
const collection = this.collection;
|
||||
const collectionName = collection ? collection.name : "";
|
||||
const hasPinnedDocuments = !!pinnedDocuments.length;
|
||||
const hasDescription = collection ? collection.hasDescription : false;
|
||||
|
||||
return (
|
||||
<CenteredContent>
|
||||
@@ -207,7 +221,7 @@ class CollectionScene extends React.Component<Props> {
|
||||
|
||||
{collection.private && (
|
||||
<Button onClick={this.onPermissions} neutral>
|
||||
{t("Manage members…")}
|
||||
{t("Manage members")}…
|
||||
</Button>
|
||||
)}
|
||||
</Wrapper>
|
||||
@@ -240,7 +254,7 @@ class CollectionScene extends React.Component<Props> {
|
||||
{collection.name}
|
||||
</Heading>
|
||||
|
||||
{collection.description && (
|
||||
{hasDescription && (
|
||||
<React.Suspense fallback={<p>Loading…</p>}>
|
||||
<Editor
|
||||
id={collection.id}
|
||||
|
||||
@@ -32,7 +32,7 @@ class CollectionDelete extends React.Component<Props> {
|
||||
this.props.history.push(homeUrl());
|
||||
this.props.onSubmit();
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message);
|
||||
this.props.ui.showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
this.isDeleting = false;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { withTranslation, Trans, type TFunction } from "react-i18next";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Collection from "models/Collection";
|
||||
import Button from "components/Button";
|
||||
@@ -11,6 +11,7 @@ import HelpText from "components/HelpText";
|
||||
import IconPicker from "components/IconPicker";
|
||||
import Input from "components/Input";
|
||||
import InputRich from "components/InputRich";
|
||||
import InputSelect from "components/InputSelect";
|
||||
import Switch from "components/Switch";
|
||||
|
||||
type Props = {
|
||||
@@ -27,6 +28,8 @@ class CollectionEdit extends React.Component<Props> {
|
||||
@observable icon: string = this.props.collection.icon;
|
||||
@observable color: string = this.props.collection.color || "#4E5C6E";
|
||||
@observable private: boolean = this.props.collection.private;
|
||||
@observable sort: { field: string, direction: "asc" | "desc" } = this.props
|
||||
.collection.sort;
|
||||
@observable isSaving: boolean;
|
||||
|
||||
handleSubmit = async (ev: SyntheticEvent<*>) => {
|
||||
@@ -41,16 +44,27 @@ class CollectionEdit extends React.Component<Props> {
|
||||
icon: this.icon,
|
||||
color: this.color,
|
||||
private: this.private,
|
||||
sort: this.sort,
|
||||
});
|
||||
this.props.onSubmit();
|
||||
this.props.ui.showToast(t("The collection was updated"));
|
||||
this.props.ui.showToast(t("The collection was updated"), {
|
||||
type: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message);
|
||||
this.props.ui.showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
handleSortChange = (ev: SyntheticInputEvent<HTMLSelectElement>) => {
|
||||
const [field, direction] = ev.target.value.split(".");
|
||||
|
||||
if (direction === "asc" || direction === "desc") {
|
||||
this.sort = { field, direction };
|
||||
}
|
||||
};
|
||||
|
||||
handleDescriptionChange = (getValue: () => string) => {
|
||||
this.description = getValue();
|
||||
};
|
||||
@@ -75,9 +89,10 @@ class CollectionEdit extends React.Component<Props> {
|
||||
<Flex column>
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<HelpText>
|
||||
{t(
|
||||
"You can edit the name and other details at any time, however doing so often might confuse your team mates."
|
||||
)}
|
||||
<Trans>
|
||||
You can edit the name and other details at any time, however doing
|
||||
so often might confuse your team mates.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
<Flex>
|
||||
<Input
|
||||
@@ -105,6 +120,15 @@ class CollectionEdit extends React.Component<Props> {
|
||||
minHeight={68}
|
||||
maxHeight={200}
|
||||
/>
|
||||
<InputSelect
|
||||
label={t("Sort in sidebar")}
|
||||
options={[
|
||||
{ label: t("Alphabetical"), value: "title.asc" },
|
||||
{ label: t("Manual sort"), value: "index.asc" },
|
||||
]}
|
||||
value={`${this.sort.field}.${this.sort.direction}`}
|
||||
onChange={this.handleSortChange}
|
||||
/>
|
||||
<Switch
|
||||
id="private"
|
||||
label={t("Private collection")}
|
||||
@@ -112,15 +136,15 @@ class CollectionEdit extends React.Component<Props> {
|
||||
checked={this.private}
|
||||
/>
|
||||
<HelpText>
|
||||
{t(
|
||||
"A private collection will only be visible to invited team members."
|
||||
)}
|
||||
<Trans>
|
||||
A private collection will only be visible to invited team members.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={this.isSaving || !this.props.collection.name}
|
||||
>
|
||||
{this.isSaving ? t("Saving…") : t("Save")}
|
||||
{this.isSaving ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
|
||||
@@ -67,10 +67,11 @@ class AddGroupsToCollection extends React.Component<Props> {
|
||||
this.props.ui.showToast(
|
||||
t("{{ groupName }} was added to the collection", {
|
||||
groupName: group.name,
|
||||
})
|
||||
}),
|
||||
{ type: "success" }
|
||||
);
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(t("Could not add user"));
|
||||
this.props.ui.showToast(t("Could not add user"), { type: "error" });
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
@@ -92,7 +93,7 @@ class AddGroupsToCollection extends React.Component<Props> {
|
||||
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("Search by group name…")}
|
||||
placeholder={`${t("Search by group name")}…`}
|
||||
value={this.query}
|
||||
onChange={this.handleFilter}
|
||||
label={t("Search groups")}
|
||||
|
||||
@@ -62,10 +62,13 @@ class AddPeopleToCollection extends React.Component<Props> {
|
||||
permission: "read_write",
|
||||
});
|
||||
this.props.ui.showToast(
|
||||
t("{{ userName }} was added to the collection", { userName: user.name })
|
||||
t("{{ userName }} was added to the collection", {
|
||||
userName: user.name,
|
||||
}),
|
||||
{ type: "success" }
|
||||
);
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(t("Could not add user"));
|
||||
this.props.ui.showToast(t("Could not add user"), { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -86,7 +89,7 @@ class AddPeopleToCollection extends React.Component<Props> {
|
||||
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("Search by name…")}
|
||||
placeholder={`${t("Search by name")}…`}
|
||||
value={this.query}
|
||||
onChange={this.handleFilter}
|
||||
label={t("Search people")}
|
||||
|
||||
@@ -61,9 +61,11 @@ class CollectionMembers extends React.Component<Props> {
|
||||
collectionId: this.props.collection.id,
|
||||
userId: user.id,
|
||||
});
|
||||
this.props.ui.showToast(`${user.name} was removed from the collection`);
|
||||
this.props.ui.showToast(`${user.name} was removed from the collection`, {
|
||||
type: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
this.props.ui.showToast("Could not remove user");
|
||||
this.props.ui.showToast("Could not remove user", { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -74,9 +76,11 @@ class CollectionMembers extends React.Component<Props> {
|
||||
userId: user.id,
|
||||
permission,
|
||||
});
|
||||
this.props.ui.showToast(`${user.name} permissions were updated`);
|
||||
this.props.ui.showToast(`${user.name} permissions were updated`, {
|
||||
type: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
this.props.ui.showToast("Could not update user");
|
||||
this.props.ui.showToast("Could not update user", { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -86,9 +90,11 @@ class CollectionMembers extends React.Component<Props> {
|
||||
collectionId: this.props.collection.id,
|
||||
groupId: group.id,
|
||||
});
|
||||
this.props.ui.showToast(`${group.name} was removed from the collection`);
|
||||
this.props.ui.showToast(`${group.name} was removed from the collection`, {
|
||||
type: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
this.props.ui.showToast("Could not remove group");
|
||||
this.props.ui.showToast("Could not remove group", { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,9 +105,11 @@ class CollectionMembers extends React.Component<Props> {
|
||||
groupId: group.id,
|
||||
permission,
|
||||
});
|
||||
this.props.ui.showToast(`${group.name} permissions were updated`);
|
||||
this.props.ui.showToast(`${group.name} permissions were updated`, {
|
||||
type: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
this.props.ui.showToast("Could not update user");
|
||||
this.props.ui.showToast("Could not update user", { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import CollectionGroupMembership from "models/CollectionGroupMembership";
|
||||
import Group from "models/Group";
|
||||
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
|
||||
import GroupListItem from "components/GroupListItem";
|
||||
import InputSelect from "components/InputSelect";
|
||||
import CollectionGroupMemberMenu from "menus/CollectionGroupMemberMenu";
|
||||
|
||||
type Props = {
|
||||
group: Group,
|
||||
@@ -50,15 +50,10 @@ const MemberListItem = ({
|
||||
labelHidden
|
||||
/>
|
||||
<ButtonWrap>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuItem onClick={openMembersModal}>
|
||||
{t("Members…")}
|
||||
</DropdownMenuItem>
|
||||
<hr />
|
||||
<DropdownMenuItem onClick={onRemove}>
|
||||
{t("Remove")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
<CollectionGroupMemberMenu
|
||||
onMembers={openMembersModal}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</ButtonWrap>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Membership from "models/Membership";
|
||||
import User from "models/User";
|
||||
import Avatar from "components/Avatar";
|
||||
import Badge from "components/Badge";
|
||||
import Button from "components/Button";
|
||||
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
|
||||
import Flex from "components/Flex";
|
||||
import InputSelect from "components/InputSelect";
|
||||
import ListItem from "components/List/Item";
|
||||
import Time from "components/Time";
|
||||
import MemberMenu from "menus/MemberMenu";
|
||||
|
||||
type Props = {
|
||||
user: User,
|
||||
@@ -46,11 +46,9 @@ const MemberListItem = ({
|
||||
subtitle={
|
||||
<>
|
||||
{user.lastActiveAt ? (
|
||||
<>
|
||||
{t("Active {{ lastActiveAt }} ago", {
|
||||
lastActiveAt: <Time dateTime={user.lastActiveAt} />,
|
||||
})}
|
||||
</>
|
||||
<Trans>
|
||||
Active <Time dateTime={user.lastActiveAt} /> ago
|
||||
</Trans>
|
||||
) : (
|
||||
t("Never signed in")
|
||||
)}
|
||||
@@ -71,13 +69,7 @@ const MemberListItem = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{canEdit && onRemove && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuItem onClick={onRemove}>
|
||||
{t("Remove")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{canEdit && onRemove && <MemberMenu onRemove={onRemove} />}
|
||||
{canEdit && onAdd && (
|
||||
<Button onClick={onAdd} neutral>
|
||||
{t("Add")}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @flow
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import User from "models/User";
|
||||
import Avatar from "components/Avatar";
|
||||
import Badge from "components/Badge";
|
||||
@@ -25,11 +25,9 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
|
||||
subtitle={
|
||||
<>
|
||||
{user.lastActiveAt ? (
|
||||
<>
|
||||
{t("Active {{ lastActiveAt }} ago", {
|
||||
lastActiveAt: <Time dateTime={user.lastActiveAt} />,
|
||||
})}
|
||||
</>
|
||||
<Trans>
|
||||
Active <Time dateTime={user.lastActiveAt} /> ago
|
||||
</Trans>
|
||||
) : (
|
||||
t("Never signed in")
|
||||
)}
|
||||
|
||||
@@ -53,7 +53,7 @@ class CollectionNew extends React.Component<Props> {
|
||||
this.props.onSubmit();
|
||||
this.props.history.push(collection.url);
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message);
|
||||
this.props.ui.showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
@@ -148,7 +148,7 @@ class CollectionNew extends React.Component<Props> {
|
||||
</HelpText>
|
||||
|
||||
<Button type="submit" disabled={this.isSaving || !this.name}>
|
||||
{this.isSaving ? t("Creating…") : t("Create")}
|
||||
{this.isSaving ? `${t("Creating")}…` : t("Create")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @flow
|
||||
import useWindowScrollPosition from "@rehooks/window-scroll-position";
|
||||
import { darken } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
@@ -72,7 +71,7 @@ const Wrapper = styled("div")`
|
||||
position: sticky;
|
||||
top: 80px;
|
||||
|
||||
box-shadow: 1px 0 0 ${(props) => darken(0.05, props.theme.sidebarBackground)};
|
||||
box-shadow: 1px 0 0 ${(props) => props.theme.divider};
|
||||
margin-top: 40px;
|
||||
margin-right: 2em;
|
||||
min-height: 40px;
|
||||
@@ -108,7 +107,7 @@ const ListItem = styled("li")`
|
||||
padding-right: 2em;
|
||||
line-height: 1.3;
|
||||
border-right: 3px solid
|
||||
${(props) => (props.active ? props.theme.textSecondary : "transparent")};
|
||||
${(props) => (props.active ? props.theme.divider : "transparent")};
|
||||
`;
|
||||
|
||||
const Link = styled("a")`
|
||||
|
||||
@@ -240,13 +240,11 @@ class DataLoader extends React.Component<Props> {
|
||||
}
|
||||
|
||||
const abilities = policies.abilities(document.id);
|
||||
const key = this.isEditing ? "editing" : "read-only";
|
||||
|
||||
return (
|
||||
<SocketPresence documentId={document.id} isEditing={this.isEditing}>
|
||||
{this.isEditing && <HideSidebar ui={ui} />}
|
||||
<DocumentComponent
|
||||
key={key}
|
||||
document={document}
|
||||
revision={revision}
|
||||
abilities={abilities}
|
||||
|
||||
@@ -33,6 +33,7 @@ import References from "./References";
|
||||
import { type LocationWithState, type Theme } from "types";
|
||||
import { isCustomDomain } from "utils/domains";
|
||||
import { emojiToUrl } from "utils/emoji";
|
||||
import { meta } from "utils/keyboard";
|
||||
import {
|
||||
collectionUrl,
|
||||
documentMoveUrl,
|
||||
@@ -80,13 +81,13 @@ class DocumentScene extends React.Component<Props> {
|
||||
@observable title: string = this.props.document.title;
|
||||
getEditorText: () => string = () => this.props.document.text;
|
||||
|
||||
componentDidMount() {
|
||||
this.updateIsDirty();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { auth, document } = this.props;
|
||||
|
||||
if (prevProps.readOnly && !this.props.readOnly) {
|
||||
this.updateIsDirty();
|
||||
}
|
||||
|
||||
if (this.props.readOnly) {
|
||||
this.lastRevision = document.revision;
|
||||
|
||||
@@ -99,6 +100,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
`Document updated by ${document.updatedBy.name}`,
|
||||
{
|
||||
timeout: 30 * 1000,
|
||||
type: "warning",
|
||||
action: {
|
||||
text: "Reload",
|
||||
onClick: () => {
|
||||
@@ -163,7 +165,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
@keydown("meta+shift+p")
|
||||
@keydown(`${meta}+shift+p`)
|
||||
onPublish(ev) {
|
||||
ev.preventDefault();
|
||||
const { document } = this.props;
|
||||
@@ -171,7 +173,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
this.onSave({ publish: true, done: true });
|
||||
}
|
||||
|
||||
@keydown("meta+ctrl+h")
|
||||
@keydown(`${meta}+ctrl+h`)
|
||||
onToggleTableOfContents(ev) {
|
||||
if (!this.props.readOnly) return;
|
||||
|
||||
@@ -238,7 +240,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
this.props.ui.setActiveDocument(savedDocument);
|
||||
}
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message);
|
||||
this.props.ui.showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
this.isPublishing = false;
|
||||
@@ -438,7 +440,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
ui={this.props.ui}
|
||||
/>
|
||||
</Flex>
|
||||
{readOnly && !isShare && !revision && (
|
||||
{!isShare && !revision && (
|
||||
<>
|
||||
<MarkAsViewed document={document} />
|
||||
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
|
||||
|
||||
@@ -86,7 +86,7 @@ class DocumentMove extends React.Component<Props> {
|
||||
}
|
||||
|
||||
handleSuccess = () => {
|
||||
this.props.ui.showToast("Document moved");
|
||||
this.props.ui.showToast("Document moved", { type: "info" });
|
||||
this.props.onRequestClose();
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import DocumentMetaWithViews from "components/DocumentMetaWithViews";
|
||||
import Editor from "components/Editor";
|
||||
import Flex from "components/Flex";
|
||||
import HoverPreview from "components/HoverPreview";
|
||||
import Star, { AnimatedStar } from "components/Star";
|
||||
import { isMetaKey } from "utils/keyboard";
|
||||
import { documentHistoryUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
@@ -53,7 +55,7 @@ class DocumentEditor extends React.Component<Props> {
|
||||
handleTitleKeyDown = (event: SyntheticKeyboardEvent<>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (event.metaKey) {
|
||||
if (isMetaKey(event)) {
|
||||
this.props.onSave({ done: true });
|
||||
return;
|
||||
}
|
||||
@@ -67,12 +69,12 @@ class DocumentEditor extends React.Component<Props> {
|
||||
this.focusAtStart();
|
||||
return;
|
||||
}
|
||||
if (event.key === "p" && event.metaKey && event.shiftKey) {
|
||||
if (event.key === "p" && isMetaKey(event) && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
this.props.onSave({ publish: true, done: true });
|
||||
return;
|
||||
}
|
||||
if (event.key === "s" && event.metaKey) {
|
||||
if (event.key === "s" && isMetaKey(event)) {
|
||||
event.preventDefault();
|
||||
this.props.onSave({});
|
||||
return;
|
||||
@@ -97,23 +99,35 @@ class DocumentEditor extends React.Component<Props> {
|
||||
readOnly,
|
||||
innerRef,
|
||||
} = this.props;
|
||||
|
||||
const { emoji } = parseTitle(title);
|
||||
const startsWithEmojiAndSpace = !!(emoji && title.startsWith(`${emoji} `));
|
||||
const normalizedTitle =
|
||||
!title && readOnly ? document.titleWithDefault : title;
|
||||
|
||||
return (
|
||||
<Flex auto column>
|
||||
<Title
|
||||
type="text"
|
||||
onChange={onChangeTitle}
|
||||
onKeyDown={this.handleTitleKeyDown}
|
||||
placeholder={document.placeholder}
|
||||
value={!title && readOnly ? document.titleWithDefault : title}
|
||||
style={startsWithEmojiAndSpace ? { marginLeft: "-1.2em" } : undefined}
|
||||
readOnly={readOnly}
|
||||
disabled={readOnly}
|
||||
autoFocus={!title}
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
/>
|
||||
{readOnly ? (
|
||||
<Title
|
||||
as="div"
|
||||
$startsWithEmojiAndSpace={startsWithEmojiAndSpace}
|
||||
$isStarred={document.isStarred}
|
||||
>
|
||||
<span>{normalizedTitle}</span>{" "}
|
||||
{!isShare && <StarButton document={document} size={32} />}
|
||||
</Title>
|
||||
) : (
|
||||
<Title
|
||||
type="text"
|
||||
onChange={onChangeTitle}
|
||||
onKeyDown={this.handleTitleKeyDown}
|
||||
placeholder={document.placeholder}
|
||||
value={normalizedTitle}
|
||||
$startsWithEmojiAndSpace={startsWithEmojiAndSpace}
|
||||
autoFocus={!title}
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
/>
|
||||
)}
|
||||
<DocumentMetaWithViews
|
||||
isDraft={isDraft}
|
||||
document={document}
|
||||
@@ -141,11 +155,17 @@ class DocumentEditor extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
const StarButton = styled(Star)`
|
||||
position: relative;
|
||||
top: 4px;
|
||||
`;
|
||||
|
||||
const Title = styled(Textarea)`
|
||||
z-index: 1;
|
||||
line-height: 1.25;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
margin-left: ${(props) => (props.$startsWithEmojiAndSpace ? "-1.2em" : 0)};
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
color: ${(props) => props.theme.text};
|
||||
@@ -161,6 +181,20 @@ const Title = styled(Textarea)`
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
-webkit-text-fill-color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
|
||||
${AnimatedStar} {
|
||||
opacity: ${(props) => (props.$isStarred ? "1 !important" : 0)};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
${AnimatedStar} {
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default DocumentEditor;
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { transparentize, darken } from "polished";
|
||||
import * as React from "react";
|
||||
import { withTranslation, Trans, type TFunction } from "react-i18next";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
@@ -34,7 +34,7 @@ import Tooltip from "components/Tooltip";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
import NewChildDocumentMenu from "menus/NewChildDocumentMenu";
|
||||
import TemplatesMenu from "menus/TemplatesMenu";
|
||||
import { meta } from "utils/keyboard";
|
||||
import { metaDisplay } from "utils/keyboard";
|
||||
import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
@@ -63,7 +63,6 @@ type Props = {
|
||||
class Header extends React.Component<Props> {
|
||||
@observable isScrolled = false;
|
||||
@observable showShareModal = false;
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener("scroll", this.handleScroll);
|
||||
@@ -79,18 +78,6 @@ class Header extends React.Component<Props> {
|
||||
|
||||
handleScroll = throttle(this.updateIsScrolled, 50);
|
||||
|
||||
handleEdit = () => {
|
||||
this.redirectTo = editDocumentUrl(this.props.document);
|
||||
};
|
||||
|
||||
handleNewFromTemplate = () => {
|
||||
const { document } = this.props;
|
||||
|
||||
this.redirectTo = newDocumentUrl(document.collectionId, {
|
||||
templateId: document.id,
|
||||
});
|
||||
};
|
||||
|
||||
handleSave = () => {
|
||||
this.props.onSave({ done: true });
|
||||
};
|
||||
@@ -118,8 +105,6 @@ class Header extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
const {
|
||||
shares,
|
||||
document,
|
||||
@@ -172,7 +157,7 @@ class Header extends React.Component<Props> {
|
||||
tooltip={
|
||||
ui.tocVisible ? t("Hide contents") : t("Show contents")
|
||||
}
|
||||
shortcut={`ctrl+${meta}+h`}
|
||||
shortcut={`ctrl+${metaDisplay}+h`}
|
||||
delay={250}
|
||||
placement="bottom"
|
||||
>
|
||||
@@ -203,7 +188,7 @@ class Header extends React.Component<Props> {
|
||||
<Wrapper align="center" justify="flex-end">
|
||||
{isSaving && !isPublishing && (
|
||||
<Action>
|
||||
<Status>{t("Saving…")}</Status>
|
||||
<Status>{t("Saving")}…</Status>
|
||||
</Action>
|
||||
)}
|
||||
|
||||
@@ -250,7 +235,7 @@ class Header extends React.Component<Props> {
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("Save")}
|
||||
shortcut={`${meta}+enter`}
|
||||
shortcut={`${metaDisplay}+enter`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
@@ -276,8 +261,9 @@ class Header extends React.Component<Props> {
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
as={Link}
|
||||
icon={<EditIcon />}
|
||||
onClick={this.handleEdit}
|
||||
to={editDocumentUrl(this.props.document)}
|
||||
neutral
|
||||
small
|
||||
>
|
||||
@@ -290,18 +276,18 @@ class Header extends React.Component<Props> {
|
||||
<Action>
|
||||
<NewChildDocumentMenu
|
||||
document={document}
|
||||
label={
|
||||
label={(props) => (
|
||||
<Tooltip
|
||||
tooltip={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button icon={<PlusIcon />} neutral>
|
||||
<Button icon={<PlusIcon />} {...props} neutral>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
@@ -309,7 +295,10 @@ class Header extends React.Component<Props> {
|
||||
<Action>
|
||||
<Button
|
||||
icon={<PlusIcon />}
|
||||
onClick={this.handleNewFromTemplate}
|
||||
as={Link}
|
||||
to={newDocumentUrl(document.collectionId, {
|
||||
templateId: document.id,
|
||||
})}
|
||||
primary
|
||||
small
|
||||
>
|
||||
@@ -321,7 +310,7 @@ class Header extends React.Component<Props> {
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("Publish")}
|
||||
shortcut={`${meta}+shift+p`}
|
||||
shortcut={`${metaDisplay}+shift+p`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
@@ -331,7 +320,7 @@ class Header extends React.Component<Props> {
|
||||
disabled={publishingIsDisabled}
|
||||
small
|
||||
>
|
||||
{isPublishing ? t("Publishing…") : t("Publish")}
|
||||
{isPublishing ? `${t("Publishing")}…` : t("Publish")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
@@ -343,15 +332,16 @@ class Header extends React.Component<Props> {
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
isRevision={isRevision}
|
||||
label={
|
||||
label={(props) => (
|
||||
<Button
|
||||
icon={<MoreIcon />}
|
||||
iconColor="currentColor"
|
||||
{...props}
|
||||
borderOnHover
|
||||
neutral
|
||||
small
|
||||
/>
|
||||
}
|
||||
)}
|
||||
showToggleEmbeds={canToggleEmbeds}
|
||||
showPrint
|
||||
/>
|
||||
@@ -422,6 +412,7 @@ const Title = styled.div`
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
width: 0;
|
||||
|
||||
|
||||
@@ -16,8 +16,9 @@ class MarkAsViewed extends React.Component<Props> {
|
||||
const { document } = this.props;
|
||||
|
||||
this.viewTimeout = setTimeout(async () => {
|
||||
if (document.publishedAt) {
|
||||
const view = await document.view();
|
||||
const view = await document.view();
|
||||
|
||||
if (view) {
|
||||
document.updateLastViewed(view);
|
||||
}
|
||||
}, MARK_AS_VIEWED_AFTER);
|
||||
|
||||
@@ -1,84 +1,118 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
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 { collectionUrl, documentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
document: Document,
|
||||
documents: DocumentsStore,
|
||||
ui: UiStore,
|
||||
onSubmit: () => void,
|
||||
};
|
||||
|
||||
@observer
|
||||
class DocumentDelete extends React.Component<Props> {
|
||||
@observable isDeleting: boolean;
|
||||
function DocumentDelete({ document, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { ui, documents } = useStores();
|
||||
const history = useHistory();
|
||||
const [isDeleting, setDeleting] = React.useState(false);
|
||||
const [isArchiving, setArchiving] = React.useState(false);
|
||||
const { showToast } = ui;
|
||||
const canArchive = !document.isDraft && !document.isArchived;
|
||||
|
||||
handleSubmit = async (ev: SyntheticEvent<>) => {
|
||||
const { documents, document } = this.props;
|
||||
ev.preventDefault();
|
||||
this.isDeleting = true;
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
setDeleting(true);
|
||||
|
||||
try {
|
||||
await document.delete();
|
||||
try {
|
||||
await document.delete();
|
||||
|
||||
// only redirect if we're currently viewing the document that's deleted
|
||||
if (this.props.ui.activeDocumentId === document.id) {
|
||||
// If the document has a parent and it's available in the store then
|
||||
// redirect to it
|
||||
if (document.parentDocumentId) {
|
||||
const parent = documents.get(document.parentDocumentId);
|
||||
if (parent) {
|
||||
this.props.history.push(documentUrl(parent));
|
||||
return;
|
||||
// only redirect if we're currently viewing the document that's deleted
|
||||
if (ui.activeDocumentId === document.id) {
|
||||
// If the document has a parent and it's available in the store then
|
||||
// redirect to it
|
||||
if (document.parentDocumentId) {
|
||||
const parent = documents.get(document.parentDocumentId);
|
||||
if (parent) {
|
||||
history.push(documentUrl(parent));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise, redirect to the collection home
|
||||
history.push(collectionUrl(document.collectionId));
|
||||
}
|
||||
|
||||
// otherwise, redirect to the collection home
|
||||
this.props.history.push(collectionUrl(document.collectionId));
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
this.props.onSubmit();
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message);
|
||||
} finally {
|
||||
this.isDeleting = false;
|
||||
}
|
||||
};
|
||||
},
|
||||
[showToast, onSubmit, ui, document, documents, history]
|
||||
);
|
||||
|
||||
render() {
|
||||
const { document } = this.props;
|
||||
const handleArchive = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
setArchiving(true);
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<HelpText>
|
||||
Are you sure about that? Deleting the{" "}
|
||||
<strong>{document.titleWithDefault}</strong> {document.noun} will
|
||||
delete all of its history
|
||||
{document.isTemplate ? "" : ", and any nested documents"}.
|
||||
</HelpText>
|
||||
{!document.isDraft && !document.isArchived && (
|
||||
<HelpText>
|
||||
If you’d like the option of referencing or restoring this{" "}
|
||||
{document.noun} in the future, consider archiving it instead.
|
||||
</HelpText>
|
||||
try {
|
||||
await document.archive();
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
setArchiving(false);
|
||||
}
|
||||
},
|
||||
[showToast, onSubmit, document]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<HelpText>
|
||||
{document.isTemplate ? (
|
||||
<Trans>
|
||||
Are you sure you want to delete the{" "}
|
||||
<strong>{{ documentTitle: document.titleWithDefault }}</strong>{" "}
|
||||
template?
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Are you sure about that? Deleting the{" "}
|
||||
<strong>{{ documentTitle: document.titleWithDefault }}</strong>{" "}
|
||||
document will delete all of its history and any nested documents.
|
||||
</Trans>
|
||||
)}
|
||||
<Button type="submit" danger>
|
||||
{this.isDeleting ? "Deleting…" : "I’m sure – Delete"}
|
||||
</HelpText>
|
||||
{canArchive && (
|
||||
<HelpText>
|
||||
<Trans>
|
||||
If you’d like the option of referencing or restoring the{" "}
|
||||
{{ noun: document.noun }} in the future, consider archiving it
|
||||
instead.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
)}
|
||||
<Button type="submit" danger>
|
||||
{isDeleting ? `${t("Deleting")}…` : t("I’m sure – Delete")}
|
||||
</Button>
|
||||
|
||||
{canArchive && (
|
||||
<Button type="button" onClick={handleArchive} neutral>
|
||||
{isArchiving ? `${t("Archiving")}…` : t("Archive")}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default inject("documents", "ui")(withRouter(DocumentDelete));
|
||||
export default observer(DocumentDelete);
|
||||
|
||||
@@ -37,7 +37,9 @@ class DocumentNew extends React.Component<Props> {
|
||||
});
|
||||
this.props.history.replace(editDocumentUrl(document));
|
||||
} catch (err) {
|
||||
this.props.ui.showToast("Couldn’t create the document, try again?");
|
||||
this.props.ui.showToast("Couldn’t create the document, try again?", {
|
||||
type: "error",
|
||||
});
|
||||
this.props.history.goBack();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ class DocumentShare extends React.Component<Props> {
|
||||
try {
|
||||
await share.save({ published: event.target.checked });
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message);
|
||||
this.props.ui.showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
|
||||
@@ -28,10 +28,12 @@ class DocumentTemplatize extends React.Component<Props> {
|
||||
try {
|
||||
const template = await this.props.document.templatize();
|
||||
this.props.history.push(documentUrl(template));
|
||||
this.props.ui.showToast("Template created, go ahead and customize it");
|
||||
this.props.ui.showToast("Template created, go ahead and customize it", {
|
||||
type: "info",
|
||||
});
|
||||
this.props.onSubmit();
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message);
|
||||
this.props.ui.showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class GroupDelete extends React.Component<Props> {
|
||||
this.props.history.push(groupSettings());
|
||||
this.props.onSubmit();
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message);
|
||||
this.props.ui.showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
this.isDeleting = false;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class GroupEdit extends React.Component<Props> {
|
||||
await this.props.group.save({ name: this.name });
|
||||
this.props.onSubmit();
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message);
|
||||
this.props.ui.showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
|
||||
@@ -62,10 +62,11 @@ class AddPeopleToGroup extends React.Component<Props> {
|
||||
userId: user.id,
|
||||
});
|
||||
this.props.ui.showToast(
|
||||
t(`{{userName}} was added to the group`, { userName: user.name })
|
||||
t(`{{userName}} was added to the group`, { userName: user.name }),
|
||||
{ type: "success" }
|
||||
);
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(t("Could not add user"));
|
||||
this.props.ui.showToast(t("Could not add user"), { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -88,7 +89,7 @@ class AddPeopleToGroup extends React.Component<Props> {
|
||||
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("Search by name…")}
|
||||
placeholder={`${t("Search by name")}…`}
|
||||
value={this.query}
|
||||
onChange={this.handleFilter}
|
||||
label={t("Search people")}
|
||||
@@ -111,7 +112,6 @@ class AddPeopleToGroup extends React.Component<Props> {
|
||||
key={item.id}
|
||||
user={item}
|
||||
onAdd={() => this.handleAddUser(item)}
|
||||
canEdit
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -52,10 +52,11 @@ class GroupMembers extends React.Component<Props> {
|
||||
userId: user.id,
|
||||
});
|
||||
this.props.ui.showToast(
|
||||
t(`{{userName}} was removed from the group`, { userName: user.name })
|
||||
t(`{{userName}} was removed from the group`, { userName: user.name }),
|
||||
{ type: "success" }
|
||||
);
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(t("Could not remove user"));
|
||||
this.props.ui.showToast(t("Could not remove user"), { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,7 +83,7 @@ class GroupMembers extends React.Component<Props> {
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("Add people…")}
|
||||
{t("Add people")}…
|
||||
</Button>
|
||||
</span>
|
||||
</>
|
||||
@@ -102,7 +103,6 @@ class GroupMembers extends React.Component<Props> {
|
||||
<GroupMemberListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
membership={groupMemberships.get(`${item.id}-${group.id}`)}
|
||||
onRemove={
|
||||
can.update ? () => this.handleRemoveUser(item) : undefined
|
||||
}
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import GroupMembership from "models/GroupMembership";
|
||||
import User from "models/User";
|
||||
import Avatar from "components/Avatar";
|
||||
import Badge from "components/Badge";
|
||||
import Button from "components/Button";
|
||||
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
|
||||
import Flex from "components/Flex";
|
||||
import ListItem from "components/List/Item";
|
||||
import Time from "components/Time";
|
||||
import GroupMemberMenu from "menus/GroupMemberMenu";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
user: User,
|
||||
groupMembership?: ?GroupMembership,
|
||||
onAdd?: () => Promise<void>,
|
||||
onRemove?: () => Promise<void>,
|
||||
};
|
||||
|};
|
||||
|
||||
const GroupMemberListItem = ({
|
||||
user,
|
||||
groupMembership,
|
||||
onRemove,
|
||||
onAdd,
|
||||
}: Props) => {
|
||||
const GroupMemberListItem = ({ user, onRemove, onAdd }: Props) => {
|
||||
return (
|
||||
<ListItem
|
||||
title={user.name}
|
||||
@@ -42,11 +35,7 @@ const GroupMemberListItem = ({
|
||||
image={<Avatar src={user.avatarUrl} size={40} />}
|
||||
actions={
|
||||
<Flex align="center">
|
||||
{onRemove && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuItem onClick={onRemove}>Remove</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{onRemove && <GroupMemberMenu onRemove={onRemove} />}
|
||||
{onAdd && (
|
||||
<Button onClick={onAdd} neutral>
|
||||
Add
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user