mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5e30d90ca | |||
| 481605d017 | |||
| 985ba9be29 | |||
| 8412efcd0c | |||
| fb0b38fb71 | |||
| 8ff2f41068 | |||
| 334dce7984 | |||
| 61b303831f | |||
| a9d60d288e | |||
| 3f267d7745 | |||
| e845652cb8 | |||
| 7066a45323 | |||
| 654fdf1c7e | |||
| 2a5fd0b332 | |||
| 9ba63c6054 | |||
| 785e208c6c | |||
| 9d84652dff | |||
| ef6ce72cf5 | |||
| 7777cccf3b | |||
| 620e4942d8 | |||
| 91ee3e62f2 | |||
| eeb7650941 | |||
| ee57f1ccf5 | |||
| 32f0589190 | |||
| ce2b246e60 | |||
| ae13347d55 | |||
| 13205171d7 | |||
| a912ea24f6 | |||
| 6fa760688b | |||
| 2825df29de | |||
| 604e97a6ce | |||
| dc1bc44c8f | |||
| 2a55e78722 | |||
| eaf8dc5a3c | |||
| f89d5adc37 | |||
| 978a123122 | |||
| 96e65f495e | |||
| 4106f15450 | |||
| b3cd78c833 | |||
| 7b87fea4f4 | |||
| 7e9bcb0c37 | |||
| f6370ccf6d | |||
| 11e1108f4a | |||
| c9fdf48c33 | |||
| 6a206de6cd | |||
| c69b393776 | |||
| 6e9c456147 | |||
| 70626ffff0 | |||
| 993aad004e | |||
| 6fa9e700c8 | |||
| 836b2e310a | |||
| 24ecaa8ce4 | |||
| 40491fafe9 | |||
| 111212b038 | |||
| 774c3534d8 | |||
| 9759227d73 | |||
| f608872c11 | |||
| eff9544ef9 | |||
| 22fb464b87 | |||
| 3bace8c9e4 | |||
| 27fca28450 | |||
| afcce7a0ef | |||
| f33495dddc | |||
| 51b75fa09d | |||
| 522df125aa | |||
| 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 |
+8
-1
@@ -10,15 +10,20 @@ DATABASE_URL=postgres://user:pass@localhost:5532/outline
|
||||
DATABASE_URL_TEST=postgres://user:pass@localhost:5532/outline-test
|
||||
REDIS_URL=redis://localhost:6479
|
||||
|
||||
# Must point to the publicly accessible URL for the installation
|
||||
URL=http://localhost:3000
|
||||
PORT=3000
|
||||
|
||||
# Optional. If using a Cloudfront distribution or similar the origin server
|
||||
# should be set to the same as URL.
|
||||
CDN_URL=
|
||||
|
||||
# enforce (auto redirect to) https in production, (optional) default is true.
|
||||
# set to false if your SSL is terminated at a loadbalancer, for example
|
||||
FORCE_HTTPS=true
|
||||
|
||||
ENABLE_UPDATES=true
|
||||
DEBUG=cache,presenters,events
|
||||
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
|
||||
@@ -66,4 +71,6 @@ SMTP_REPLY_EMAIL=
|
||||
# Custom logo that displays on the authentication screen, scaled to height: 60px
|
||||
# TEAM_LOGO=https://example.com/images/logo.png
|
||||
|
||||
# See translate.getoutline.com for a list of available language codes and their
|
||||
# percentage translated.
|
||||
DEFAULT_LANGUAGE=en_US
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
[options]
|
||||
emoji=true
|
||||
sharedmemory.heap_size=3221225472
|
||||
|
||||
module.system.node.resolve_dirname=node_modules
|
||||
module.system.node.resolve_dirname=app
|
||||
@@ -32,6 +33,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
|
||||
|
||||
Vendored
+2
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"javascript.validate.enable": false,
|
||||
"javascript.format.enable": false,
|
||||
"typescript.validate.enable": false,
|
||||
"typescript.format.enable": false,
|
||||
"editor.formatOnSave": true,
|
||||
"typescript.format.enable": false
|
||||
}
|
||||
@@ -75,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 run 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;
|
||||
}
|
||||
|
||||
@@ -20,8 +20,10 @@ const Authenticated = ({ children }: Props) => {
|
||||
// Watching for language changes here as this is the earliest point we have
|
||||
// the user available and means we can start loading translations faster
|
||||
React.useEffect(() => {
|
||||
if (i18n.language !== language) {
|
||||
i18n.changeLanguage(language);
|
||||
if (language && i18n.language !== language) {
|
||||
// Languages are stored in en_US format in the database, however the
|
||||
// frontend translation framework (i18next) expects en-US
|
||||
i18n.changeLanguage(language.replace("_", "-"));
|
||||
}
|
||||
}, [i18n, language]);
|
||||
|
||||
|
||||
@@ -3,13 +3,17 @@ import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import User from "models/User";
|
||||
import placeholder from "./placeholder.png";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
src: string,
|
||||
size: number,
|
||||
icon?: React.Node,
|
||||
};
|
||||
user?: User,
|
||||
onClick?: () => void,
|
||||
className?: string,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class Avatar extends React.Component<Props> {
|
||||
|
||||
@@ -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"),
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+42
-18
@@ -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,14 +102,14 @@ 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;"};
|
||||
`;
|
||||
|
||||
export type Props = {
|
||||
type?: string,
|
||||
export type Props = {|
|
||||
type?: "button" | "submit",
|
||||
value?: string,
|
||||
icon?: React.Node,
|
||||
iconColor?: string,
|
||||
@@ -107,9 +117,22 @@ export type Props = {
|
||||
children?: React.Node,
|
||||
innerRef?: React.ElementRef<any>,
|
||||
disclosure?: boolean,
|
||||
neutral?: boolean,
|
||||
danger?: boolean,
|
||||
primary?: boolean,
|
||||
disabled?: boolean,
|
||||
fullwidth?: boolean,
|
||||
autoFocus?: boolean,
|
||||
style?: Object,
|
||||
as?: React.ComponentType<any>,
|
||||
to?: string,
|
||||
onClick?: (event: SyntheticEvent<>) => mixed,
|
||||
borderOnHover?: boolean,
|
||||
};
|
||||
|
||||
"data-on"?: string,
|
||||
"data-event-category"?: string,
|
||||
"data-event-action"?: string,
|
||||
|};
|
||||
|
||||
function Button({
|
||||
type = "text",
|
||||
@@ -118,13 +141,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,23 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
onClick: (ev: SyntheticEvent<>) => void,
|
||||
children: React.Node,
|
||||
};
|
||||
|
||||
export default function ButtonLink(props: Props) {
|
||||
return <Button {...props} />;
|
||||
}
|
||||
|
||||
const Button = styled.button`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: ${(props) => props.theme.link};
|
||||
line-height: inherit;
|
||||
background: none;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
`;
|
||||
@@ -1,18 +1,21 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import HelpText from "components/HelpText";
|
||||
import VisuallyHidden from "components/VisuallyHidden";
|
||||
|
||||
export type Props = {
|
||||
export type Props = {|
|
||||
checked?: boolean,
|
||||
label?: string,
|
||||
labelHidden?: boolean,
|
||||
className?: string,
|
||||
name?: string,
|
||||
disabled?: boolean,
|
||||
onChange: (event: SyntheticInputEvent<HTMLInputElement>) => mixed,
|
||||
note?: string,
|
||||
short?: boolean,
|
||||
small?: boolean,
|
||||
};
|
||||
|};
|
||||
|
||||
const LabelText = styled.span`
|
||||
font-weight: 500;
|
||||
|
||||
@@ -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;
|
||||
+33
-19
@@ -1,50 +1,62 @@
|
||||
// @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 = {
|
||||
type Props = {|
|
||||
onClick?: (SyntheticEvent<>) => void | Promise<void>,
|
||||
children?: React.Node,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
};
|
||||
to?: string,
|
||||
href?: string,
|
||||
target?: "_blank",
|
||||
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 +70,7 @@ const MenuItem = styled.a`
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
opacity: ${(props) => (props.disabled ? ".5" : 1)};
|
||||
}
|
||||
|
||||
@@ -66,7 +79,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 +98,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;
|
||||
}
|
||||
`;
|
||||
@@ -156,7 +156,7 @@ const Wrapper = styled(Flex)`
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
min-width: ${(props) => props.theme.sidebarWidth};
|
||||
min-width: ${(props) => props.theme.sidebarWidth}px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: none;
|
||||
@@ -165,7 +165,7 @@ const Wrapper = styled(Flex)`
|
||||
const Sidebar = styled(Flex)`
|
||||
display: none;
|
||||
background: ${(props) => props.theme.background};
|
||||
min-width: ${(props) => props.theme.sidebarWidth};
|
||||
min-width: ${(props) => props.theme.sidebarWidth}px;
|
||||
border-left: 1px solid ${(props) => props.theme.divider};
|
||||
z-index: 1;
|
||||
|
||||
|
||||
@@ -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,12 +2,17 @@
|
||||
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 = {
|
||||
type Props = {|
|
||||
documents: Document[],
|
||||
limit?: number,
|
||||
};
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
showDraft?: boolean,
|
||||
showTemplate?: boolean,
|
||||
|};
|
||||
|
||||
export default function DocumentList({ limit, documents, ...rest }: Props) {
|
||||
const items = limit ? documents.splice(0, limit) : documents;
|
||||
@@ -18,7 +23,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,243 @@
|
||||
// @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,
|
||||
showNestedDocuments?: boolean,
|
||||
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,
|
||||
showNestedDocuments,
|
||||
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}
|
||||
showNestedDocuments={showNestedDocuments}
|
||||
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`
|
||||
@@ -22,19 +23,21 @@ const Modified = styled.span`
|
||||
font-weight: ${(props) => (props.highlight ? "600" : "400")};
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showLastViewed?: boolean,
|
||||
showNestedDocuments?: boolean,
|
||||
document: Document,
|
||||
children: React.Node,
|
||||
to?: string,
|
||||
};
|
||||
|};
|
||||
|
||||
function DocumentMeta({
|
||||
showPublished,
|
||||
showCollection,
|
||||
showLastViewed,
|
||||
showNestedDocuments,
|
||||
document,
|
||||
children,
|
||||
to,
|
||||
@@ -122,6 +125,10 @@ function DocumentMeta({
|
||||
);
|
||||
};
|
||||
|
||||
const nestedDocumentsCount = collection
|
||||
? collection.getDocumentChildren(document.id).length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Container align="center" {...rest}>
|
||||
{updatedByMe ? t("You") : updatedBy.name}
|
||||
@@ -134,6 +141,12 @@ function DocumentMeta({
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
{showNestedDocuments && nestedDocumentsCount > 0 && (
|
||||
<span>
|
||||
· {nestedDocumentsCount}{" "}
|
||||
{t("nested document", { count: nestedDocumentsCount })}
|
||||
</span>
|
||||
)}
|
||||
{timeSinceNow()}
|
||||
{children}
|
||||
</Container>
|
||||
|
||||
@@ -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;
|
||||
@@ -60,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;
|
||||
@@ -87,7 +89,11 @@ class DropToImport extends React.Component<Props> {
|
||||
isDragAccept,
|
||||
isDragReject,
|
||||
}) => (
|
||||
<DropzoneContainer {...getRootProps()} {...{ isDragActive }}>
|
||||
<DropzoneContainer
|
||||
{...getRootProps()}
|
||||
{...{ isDragActive }}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{this.isImporting && <LoadingIndicator />}
|
||||
{this.props.children}
|
||||
|
||||
@@ -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";
|
||||
+30
-13
@@ -8,21 +8,39 @@ import UiStore from "stores/UiStore";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import embeds from "../embeds";
|
||||
import isInternalUrl from "utils/isInternalUrl";
|
||||
import { isModKey } from "utils/keyboard";
|
||||
import { uploadFile } from "utils/uploadFile";
|
||||
import { isInternalUrl } from "utils/urls";
|
||||
|
||||
const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));
|
||||
|
||||
const EMPTY_ARRAY = [];
|
||||
|
||||
type Props = {
|
||||
export type Props = {|
|
||||
id?: string,
|
||||
value?: string,
|
||||
defaultValue?: string,
|
||||
readOnly?: boolean,
|
||||
grow?: boolean,
|
||||
disableEmbeds?: boolean,
|
||||
ui?: UiStore,
|
||||
};
|
||||
autoFocus?: boolean,
|
||||
template?: boolean,
|
||||
placeholder?: string,
|
||||
scrollTo?: string,
|
||||
readOnlyWriteCheckboxes?: boolean,
|
||||
onBlur?: (event: SyntheticEvent<>) => any,
|
||||
onFocus?: (event: SyntheticEvent<>) => any,
|
||||
onPublish?: (event: SyntheticEvent<>) => any,
|
||||
onSave?: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
|
||||
onCancel?: () => any,
|
||||
onChange?: (getValue: () => string) => any,
|
||||
onSearchLink?: (title: string) => any,
|
||||
onHoverLink?: (event: MouseEvent) => any,
|
||||
onCreateLink?: (title: string) => Promise<string>,
|
||||
onImageUploadStart?: () => any,
|
||||
onImageUploadStop?: () => any,
|
||||
|};
|
||||
|
||||
type PropsWithRef = Props & {
|
||||
forwardedRef: React.Ref<any>,
|
||||
@@ -49,7 +67,7 @@ function Editor(props: PropsWithRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInternalUrl(href) && !event.metaKey && !event.shiftKey) {
|
||||
if (isInternalUrl(href) && !isModKey(event) && !event.shiftKey) {
|
||||
// relative
|
||||
let navigateTo = href;
|
||||
|
||||
@@ -171,17 +189,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 {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @flow
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
@@ -36,8 +37,8 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.Sentry) {
|
||||
window.Sentry.captureException(error);
|
||||
if (env.SENTRY_DSN) {
|
||||
Sentry.captureException(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +57,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
render() {
|
||||
if (this.error) {
|
||||
const error = this.error;
|
||||
const isReported = !!window.Sentry && env.DEPLOYMENT === "hosted";
|
||||
const isReported = !!env.SENTRY_DSN && env.DEPLOYMENT === "hosted";
|
||||
const isChunkError = this.error.message.match(/chunk/);
|
||||
|
||||
if (isChunkError) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { fadeAndSlideIn } from "shared/styles/animations";
|
||||
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import HoverPreviewDocument from "components/HoverPreviewDocument";
|
||||
import isInternalUrl from "utils/isInternalUrl";
|
||||
import { isInternalUrl } from "utils/urls";
|
||||
|
||||
const DELAY_OPEN = 300;
|
||||
const DELAY_CLOSE = 300;
|
||||
|
||||
@@ -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 aria-label={t("Show menu")} {...props}>
|
||||
<Component 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;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { cdnPath } from "utils/urls";
|
||||
|
||||
type Props = {|
|
||||
alt: string,
|
||||
src: string,
|
||||
title?: string,
|
||||
width?: number,
|
||||
height?: number,
|
||||
|};
|
||||
|
||||
export default function Image({ src, alt, ...rest }: Props) {
|
||||
return <img src={cdnPath(src)} alt={alt} {...rest} />;
|
||||
}
|
||||
+13
-4
@@ -2,9 +2,9 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
import VisuallyHidden from "components/VisuallyHidden";
|
||||
|
||||
const RealTextarea = styled.textarea`
|
||||
border: 0;
|
||||
@@ -75,8 +75,8 @@ export const LabelText = styled.div`
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
export type Props = {
|
||||
type?: string,
|
||||
export type Props = {|
|
||||
type?: "text" | "email" | "checkbox" | "search",
|
||||
value?: string,
|
||||
label?: string,
|
||||
className?: string,
|
||||
@@ -85,9 +85,18 @@ export type Props = {
|
||||
short?: boolean,
|
||||
margin?: string | number,
|
||||
icon?: React.Node,
|
||||
name?: string,
|
||||
minLength?: number,
|
||||
maxLength?: number,
|
||||
autoFocus?: boolean,
|
||||
autoComplete?: boolean | string,
|
||||
readOnly?: boolean,
|
||||
required?: boolean,
|
||||
placeholder?: string,
|
||||
onChange?: (ev: SyntheticInputEvent<HTMLInputElement>) => mixed,
|
||||
onFocus?: (ev: SyntheticEvent<>) => void,
|
||||
onBlur?: (ev: SyntheticEvent<>) => void,
|
||||
};
|
||||
|};
|
||||
|
||||
@observer
|
||||
class Input extends React.Component<Props> {
|
||||
|
||||
@@ -8,13 +8,13 @@ import Editor from "components/Editor";
|
||||
import HelpText from "components/HelpText";
|
||||
import { LabelText, Outline } from "components/Input";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
label: string,
|
||||
minHeight?: number,
|
||||
maxHeight?: number,
|
||||
readOnly?: boolean,
|
||||
ui: UiStore,
|
||||
};
|
||||
|};
|
||||
|
||||
@observer
|
||||
class InputRich extends React.Component<Props> {
|
||||
|
||||
@@ -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 = {
|
||||
@@ -16,6 +17,8 @@ type Props = {
|
||||
theme: Theme,
|
||||
source: string,
|
||||
placeholder?: string,
|
||||
label?: string,
|
||||
labelHidden?: boolean,
|
||||
collectionId?: string,
|
||||
t: TFunction,
|
||||
};
|
||||
@@ -25,7 +28,7 @@ class InputSearch extends React.Component<Props> {
|
||||
input: ?Input;
|
||||
@observable focused: boolean = false;
|
||||
|
||||
@keydown("meta+f")
|
||||
@keydown(`${meta}+f`)
|
||||
focus(ev: SyntheticEvent<>) {
|
||||
ev.preventDefault();
|
||||
|
||||
@@ -67,6 +70,8 @@ class InputSearch extends React.Component<Props> {
|
||||
color={this.focused ? theme.inputBorderFocused : theme.inputBorder}
|
||||
/>
|
||||
}
|
||||
label={this.props.label}
|
||||
labelHidden={this.props.labelHidden}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
margin={0}
|
||||
|
||||
@@ -2,17 +2,19 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import VisuallyHidden from "components/VisuallyHidden";
|
||||
import { Outline, LabelText } from "./Input";
|
||||
|
||||
const Select = styled.select`
|
||||
border: 0;
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
padding: 4px 0;
|
||||
margin: 0 12px;
|
||||
outline: none;
|
||||
background: none;
|
||||
color: ${(props) => props.theme.text};
|
||||
height: 30px;
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
@@ -34,6 +36,8 @@ export type Props = {
|
||||
className?: string,
|
||||
labelHidden?: boolean,
|
||||
options: Option[],
|
||||
onBlur?: () => void,
|
||||
onFocus?: () => void,
|
||||
};
|
||||
|
||||
@observer
|
||||
|
||||
@@ -4,10 +4,10 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
label: React.Node | string,
|
||||
children: React.Node,
|
||||
};
|
||||
|};
|
||||
|
||||
const Labeled = ({ label, children, ...props }: Props) => (
|
||||
<Flex column {...props}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { languages, languageOptions } from "shared/i18n";
|
||||
import ButtonLink from "components/ButtonLink";
|
||||
import Flex from "components/Flex";
|
||||
import NoticeTip from "components/NoticeTip";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
@@ -68,7 +69,7 @@ export default function LanguagePrompt() {
|
||||
like to change?
|
||||
</Trans>
|
||||
<br />
|
||||
<a
|
||||
<Link
|
||||
onClick={() => {
|
||||
auth.updateUser({
|
||||
language,
|
||||
@@ -77,14 +78,24 @@ export default function LanguagePrompt() {
|
||||
}}
|
||||
>
|
||||
{t("Change Language")}
|
||||
</a>{" "}
|
||||
· <a onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</a>
|
||||
</Link>{" "}
|
||||
·{" "}
|
||||
<Link onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</Link>
|
||||
</span>
|
||||
</Flex>
|
||||
</NoticeTip>
|
||||
);
|
||||
}
|
||||
|
||||
const Link = styled(ButtonLink)`
|
||||
color: ${(props) => props.theme.almostBlack};
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const LanguageIcon = styled(Icon)`
|
||||
margin-right: 12px;
|
||||
`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { MenuIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
@@ -14,14 +15,17 @@ import UiStore from "stores/UiStore";
|
||||
import ErrorSuspended from "scenes/ErrorSuspended";
|
||||
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
|
||||
import Analytics from "components/Analytics";
|
||||
import Button from "components/Button";
|
||||
import DocumentHistory from "components/DocumentHistory";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
import { LoadingIndicatorBar } from "components/LoadingIndicator";
|
||||
import Modal from "components/Modal";
|
||||
import Sidebar from "components/Sidebar";
|
||||
import SettingsSidebar from "components/Sidebar/Settings";
|
||||
import SkipNavContent from "components/SkipNavContent";
|
||||
import SkipNavLink from "components/SkipNavLink";
|
||||
import { type Theme } from "types";
|
||||
import { meta } from "utils/keyboard";
|
||||
import {
|
||||
homeUrl,
|
||||
searchUrl,
|
||||
@@ -65,7 +69,7 @@ class Layout extends React.Component<Props> {
|
||||
window.document.body.style.background = props.theme.background;
|
||||
}
|
||||
|
||||
@keydown("meta+.")
|
||||
@keydown(`${meta}+.`)
|
||||
handleToggleSidebar() {
|
||||
this.props.ui.toggleCollapsedSidebar();
|
||||
}
|
||||
@@ -80,7 +84,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();
|
||||
@@ -98,6 +102,7 @@ class Layout extends React.Component<Props> {
|
||||
const { auth, t, ui } = this.props;
|
||||
const { user, team } = auth;
|
||||
const showSidebar = auth.authenticated && user && team;
|
||||
const sidebarCollapsed = ui.editMode || ui.sidebarCollapsed;
|
||||
|
||||
if (auth.isSuspended) return <ErrorSuspended />;
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
@@ -111,11 +116,19 @@ class Layout extends React.Component<Props> {
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
</Helmet>
|
||||
<SkipNavLink />
|
||||
<Analytics />
|
||||
|
||||
{this.props.ui.progressBarVisible && <LoadingIndicatorBar />}
|
||||
{this.props.notifications}
|
||||
|
||||
<MobileMenuButton
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
icon={<MenuIcon />}
|
||||
iconColor="currentColor"
|
||||
neutral
|
||||
/>
|
||||
|
||||
<Container auto>
|
||||
{showSidebar && (
|
||||
<Switch>
|
||||
@@ -124,10 +137,17 @@ class Layout extends React.Component<Props> {
|
||||
</Switch>
|
||||
)}
|
||||
|
||||
<SkipNavContent />
|
||||
<Content
|
||||
auto
|
||||
justify="center"
|
||||
sidebarCollapsed={ui.editMode || ui.sidebarCollapsed}
|
||||
$isResizing={ui.sidebarIsResizing}
|
||||
$sidebarCollapsed={sidebarCollapsed}
|
||||
style={
|
||||
sidebarCollapsed
|
||||
? undefined
|
||||
: { marginLeft: `${ui.sidebarWidth}px` }
|
||||
}
|
||||
>
|
||||
{this.props.children}
|
||||
</Content>
|
||||
@@ -159,19 +179,38 @@ const Container = styled(Flex)`
|
||||
min-height: 100%;
|
||||
`;
|
||||
|
||||
const MobileMenuButton = styled(Button)`
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: ${(props) => props.theme.depths.sidebar - 1};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: none;
|
||||
`};
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Content = styled(Flex)`
|
||||
margin: 0;
|
||||
transition: margin-left 100ms ease-out;
|
||||
transition: ${(props) =>
|
||||
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
|
||||
|
||||
@media print {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
margin-left: 0 !important;
|
||||
`}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
margin-left: ${(props) =>
|
||||
props.sidebarCollapsed
|
||||
? props.theme.sidebarCollapsedWidth
|
||||
: props.theme.sidebarWidth};
|
||||
${(props) =>
|
||||
props.$sidebarCollapsed &&
|
||||
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
|
||||
`};
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// @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`),
|
||||
it: require(`date-fns/locale/it`),
|
||||
ko: require(`date-fns/locale/ko`),
|
||||
pt: require(`date-fns/locale/pt`),
|
||||
zh: require(`date-fns/locale/zh_cn`),
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -5,10 +5,10 @@ import { randomInteger } from "shared/random";
|
||||
import { pulsate } from "shared/styles/animations";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
header?: boolean,
|
||||
height?: number,
|
||||
};
|
||||
|};
|
||||
|
||||
class Mask extends React.Component<Props> {
|
||||
width: number;
|
||||
@@ -23,7 +23,7 @@ class Mask extends React.Component<Props> {
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Redacted width={this.width} {...this.props} />;
|
||||
return <Redacted width={this.width} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,12 @@ import Scrollable from "components/Scrollable";
|
||||
|
||||
ReactModal.setAppElement("#root");
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
children?: React.Node,
|
||||
isOpen: boolean,
|
||||
title?: string,
|
||||
onRequestClose: () => void,
|
||||
};
|
||||
|};
|
||||
|
||||
const GlobalStyles = createGlobalStyle`
|
||||
.ReactModal__Overlay {
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import useStores from "hooks/useStores";
|
||||
import { cdnPath } from "utils/urls";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
title: string,
|
||||
favicon?: string,
|
||||
auth: AuthStore,
|
||||
};
|
||||
|};
|
||||
|
||||
const PageTitle = observer(({ auth, title, favicon }: Props) => {
|
||||
const PageTitle = ({ title, favicon }: Props) => {
|
||||
const { auth } = useStores();
|
||||
const { team } = auth;
|
||||
|
||||
return (
|
||||
@@ -21,12 +22,12 @@ const PageTitle = observer(({ auth, title, favicon }: Props) => {
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
type="image/png"
|
||||
href={favicon || "/favicon-32.png"}
|
||||
href={favicon || cdnPath("/favicon-32.png")}
|
||||
sizes="32x32"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</Helmet>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default inject("auth")(PageTitle);
|
||||
export default observer(PageTitle);
|
||||
|
||||
@@ -2,16 +2,22 @@
|
||||
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 = {
|
||||
type Props = {|
|
||||
documents: Document[],
|
||||
fetch: (options: ?Object) => Promise<void>,
|
||||
options?: Object,
|
||||
heading?: React.Node,
|
||||
empty?: React.Node,
|
||||
};
|
||||
showNestedDocuments?: boolean,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
showDraft?: boolean,
|
||||
showTemplate?: boolean,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class PaginatedDocumentList extends React.Component<Props> {
|
||||
@@ -26,7 +32,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} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// @flow
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { Route } from "react-router-dom";
|
||||
import env from "env";
|
||||
|
||||
let Component = Route;
|
||||
|
||||
if (env.SENTRY_DSN) {
|
||||
Component = Sentry.withSentryRouting(Route);
|
||||
}
|
||||
|
||||
export default Component;
|
||||
@@ -1,28 +1,52 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import useWindowSize from "hooks/useWindowSize";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
shadow?: boolean,
|
||||
};
|
||||
topShadow?: boolean,
|
||||
bottomShadow?: boolean,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class Scrollable extends React.Component<Props> {
|
||||
@observable shadow: boolean = false;
|
||||
function Scrollable({ shadow, topShadow, bottomShadow, ...rest }: Props) {
|
||||
const ref = React.useRef<?HTMLDivElement>();
|
||||
const [topShadowVisible, setTopShadow] = React.useState(false);
|
||||
const [bottomShadowVisible, setBottomShadow] = React.useState(false);
|
||||
const { height } = useWindowSize();
|
||||
|
||||
handleScroll = (ev: SyntheticMouseEvent<HTMLDivElement>) => {
|
||||
this.shadow = !!(this.props.shadow && ev.currentTarget.scrollTop > 0);
|
||||
};
|
||||
const updateShadows = React.useCallback(() => {
|
||||
const c = ref.current;
|
||||
if (!c) return;
|
||||
|
||||
render() {
|
||||
const { shadow, ...rest } = this.props;
|
||||
const scrollTop = c.scrollTop;
|
||||
const tsv = !!((shadow || topShadow) && scrollTop > 0);
|
||||
if (tsv !== topShadowVisible) {
|
||||
setTopShadow(tsv);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper onScroll={this.handleScroll} shadow={this.shadow} {...rest} />
|
||||
);
|
||||
}
|
||||
const wrapperHeight = c.scrollHeight - c.clientHeight;
|
||||
const bsv = !!((shadow || bottomShadow) && wrapperHeight - scrollTop !== 0);
|
||||
|
||||
if (bsv !== bottomShadowVisible) {
|
||||
setBottomShadow(bsv);
|
||||
}
|
||||
}, [shadow, topShadow, bottomShadow, topShadowVisible, bottomShadowVisible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
updateShadows();
|
||||
}, [height, updateShadows]);
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
ref={ref}
|
||||
onScroll={updateShadows}
|
||||
$topShadowVisible={topShadowVisible}
|
||||
$bottomShadowVisible={bottomShadowVisible}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
@@ -31,9 +55,20 @@ const Wrapper = styled.div`
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
box-shadow: ${(props) =>
|
||||
props.shadow ? "0 1px inset rgba(0,0,0,.1)" : "none"};
|
||||
transition: all 250ms ease-in-out;
|
||||
box-shadow: ${(props) => {
|
||||
if (props.$topShadowVisible && props.$bottomShadowVisible) {
|
||||
return "0 1px inset rgba(0,0,0,.1), 0 -1px inset rgba(0,0,0,.1)";
|
||||
}
|
||||
if (props.$topShadowVisible) {
|
||||
return "0 1px inset rgba(0,0,0,.1)";
|
||||
}
|
||||
if (props.$bottomShadowVisible) {
|
||||
return "0 -1px inset rgba(0,0,0,.1)";
|
||||
}
|
||||
|
||||
return "none";
|
||||
}};
|
||||
transition: all 100ms ease-in-out;
|
||||
`;
|
||||
|
||||
export default Scrollable;
|
||||
export default observer(Scrollable);
|
||||
|
||||
+160
-160
@@ -1,6 +1,5 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
HomeIcon,
|
||||
@@ -10,14 +9,11 @@ import {
|
||||
ShapesIcon,
|
||||
TrashIcon,
|
||||
PlusIcon,
|
||||
SettingsIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import CollectionNew from "scenes/CollectionNew";
|
||||
import Invite from "scenes/Invite";
|
||||
import Flex from "components/Flex";
|
||||
@@ -29,175 +25,179 @@ import Collections from "./components/Collections";
|
||||
import HeaderBlock from "./components/HeaderBlock";
|
||||
import Section from "./components/Section";
|
||||
import SidebarLink from "./components/SidebarLink";
|
||||
import useStores from "hooks/useStores";
|
||||
import AccountMenu from "menus/AccountMenu";
|
||||
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
documents: DocumentsStore,
|
||||
policies: PoliciesStore,
|
||||
t: TFunction,
|
||||
};
|
||||
function MainSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { policies, auth, documents } = useStores();
|
||||
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
|
||||
const [
|
||||
createCollectionModalOpen,
|
||||
setCreateCollectionModalOpen,
|
||||
] = React.useState(false);
|
||||
|
||||
@observer
|
||||
class MainSidebar extends React.Component<Props> {
|
||||
@observable inviteModalOpen = false;
|
||||
@observable createCollectionModalOpen = false;
|
||||
React.useEffect(() => {
|
||||
documents.fetchDrafts();
|
||||
documents.fetchTemplates();
|
||||
}, [documents]);
|
||||
|
||||
componentDidMount() {
|
||||
this.props.documents.fetchDrafts();
|
||||
this.props.documents.fetchTemplates();
|
||||
}
|
||||
const handleCreateCollectionModalOpen = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
setCreateCollectionModalOpen(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
handleCreateCollectionModalOpen = (ev: SyntheticEvent<>) => {
|
||||
const handleCreateCollectionModalClose = React.useCallback(() => {
|
||||
setCreateCollectionModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleInviteModalOpen = React.useCallback((ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.createCollectionModalOpen = true;
|
||||
};
|
||||
setInviteModalOpen(true);
|
||||
}, []);
|
||||
|
||||
handleCreateCollectionModalClose = (ev: SyntheticEvent<>) => {
|
||||
this.createCollectionModalOpen = false;
|
||||
};
|
||||
const handleInviteModalClose = React.useCallback(() => {
|
||||
setInviteModalOpen(false);
|
||||
}, []);
|
||||
|
||||
handleInviteModalOpen = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.inviteModalOpen = true;
|
||||
};
|
||||
const { user, team } = auth;
|
||||
if (!user || !team) return null;
|
||||
|
||||
handleInviteModalClose = () => {
|
||||
this.inviteModalOpen = false;
|
||||
};
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
render() {
|
||||
const { auth, documents, policies, t } = this.props;
|
||||
const { user, team } = auth;
|
||||
if (!user || !team) return null;
|
||||
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<AccountMenu
|
||||
label={
|
||||
<HeaderBlock
|
||||
subheading={user.name}
|
||||
teamName={team.name}
|
||||
logoUrl={team.avatarUrl}
|
||||
showDisclosure
|
||||
return (
|
||||
<Sidebar>
|
||||
<AccountMenu>
|
||||
{(props) => (
|
||||
<HeaderBlock
|
||||
{...props}
|
||||
subheading={user.name}
|
||||
teamName={team.name}
|
||||
logoUrl={team.avatarUrl}
|
||||
showDisclosure
|
||||
/>
|
||||
)}
|
||||
</AccountMenu>
|
||||
<Flex auto column>
|
||||
<Scrollable shadow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to="/home"
|
||||
icon={<HomeIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Flex auto column>
|
||||
<Scrollable shadow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to={{
|
||||
pathname: "/search",
|
||||
state: { fromMenu: true },
|
||||
}}
|
||||
icon={<SearchIcon color="currentColor" />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/starred"
|
||||
icon={<StarredIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Starred")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/templates"
|
||||
icon={<ShapesIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Templates")}
|
||||
active={documents.active ? documents.active.template : undefined}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/drafts"
|
||||
icon={<EditIcon color="currentColor" />}
|
||||
label={
|
||||
<Drafts align="center">
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 && (
|
||||
<Bubble count={documents.totalDrafts} />
|
||||
)}
|
||||
</Drafts>
|
||||
}
|
||||
active={
|
||||
documents.active
|
||||
? !documents.active.publishedAt &&
|
||||
!documents.active.isDeleted &&
|
||||
!documents.active.isTemplate
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<Collections onCreateCollection={handleCreateCollectionModalOpen} />
|
||||
</Section>
|
||||
</Scrollable>
|
||||
<Secondary>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to="/archive"
|
||||
icon={<ArchiveIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
active={
|
||||
documents.active
|
||||
? documents.active.isArchived && !documents.active.isDeleted
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/trash"
|
||||
icon={<TrashIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Trash")}
|
||||
active={documents.active ? documents.active.isDeleted : undefined}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings"
|
||||
icon={<SettingsIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Settings")}
|
||||
/>
|
||||
{can.invite && (
|
||||
<SidebarLink
|
||||
to="/home"
|
||||
icon={<HomeIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
to="/settings/people"
|
||||
onClick={handleInviteModalOpen}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label={`${t("Invite people")}…`}
|
||||
/>
|
||||
<SidebarLink
|
||||
to={{
|
||||
pathname: "/search",
|
||||
state: { fromMenu: true },
|
||||
}}
|
||||
icon={<SearchIcon color="currentColor" />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/starred"
|
||||
icon={<StarredIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Starred")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/templates"
|
||||
icon={<ShapesIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Templates")}
|
||||
active={
|
||||
documents.active ? documents.active.template : undefined
|
||||
}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/drafts"
|
||||
icon={<EditIcon color="currentColor" />}
|
||||
label={
|
||||
<Drafts align="center">
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 && (
|
||||
<Bubble count={documents.totalDrafts} />
|
||||
)}
|
||||
</Drafts>
|
||||
}
|
||||
active={
|
||||
documents.active
|
||||
? !documents.active.publishedAt &&
|
||||
!documents.active.isDeleted &&
|
||||
!documents.active.isTemplate
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<Collections
|
||||
onCreateCollection={this.handleCreateCollectionModalOpen}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to="/archive"
|
||||
icon={<ArchiveIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
active={
|
||||
documents.active
|
||||
? documents.active.isArchived && !documents.active.isDeleted
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/trash"
|
||||
icon={<TrashIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Trash")}
|
||||
active={
|
||||
documents.active ? documents.active.isDeleted : undefined
|
||||
}
|
||||
/>
|
||||
{can.invite && (
|
||||
<SidebarLink
|
||||
to="/settings/people"
|
||||
onClick={this.handleInviteModalOpen}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label={t("Invite people…")}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Scrollable>
|
||||
</Flex>
|
||||
<Modal
|
||||
title={t("Invite people")}
|
||||
onRequestClose={this.handleInviteModalClose}
|
||||
isOpen={this.inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={this.handleInviteModalClose} />
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Create a collection")}
|
||||
onRequestClose={this.handleCreateCollectionModalClose}
|
||||
isOpen={this.createCollectionModalOpen}
|
||||
>
|
||||
<CollectionNew onSubmit={this.handleCreateCollectionModalClose} />
|
||||
</Modal>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</Section>
|
||||
</Secondary>
|
||||
</Flex>
|
||||
<Modal
|
||||
title={t("Invite people")}
|
||||
onRequestClose={handleInviteModalClose}
|
||||
isOpen={inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={handleInviteModalClose} />
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Create a collection")}
|
||||
onRequestClose={handleCreateCollectionModalClose}
|
||||
isOpen={createCollectionModalOpen}
|
||||
>
|
||||
<CollectionNew onSubmit={handleCreateCollectionModalClose} />
|
||||
</Modal>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const Secondary = styled.div`
|
||||
overflow-x: hidden;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Drafts = styled(Flex)`
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export default withTranslation()<MainSidebar>(
|
||||
inject("documents", "policies", "auth")(MainSidebar)
|
||||
);
|
||||
export default observer(MainSidebar);
|
||||
|
||||
+112
-124
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
DocumentIcon,
|
||||
EmailIcon,
|
||||
@@ -13,11 +13,9 @@ import {
|
||||
ExpandedIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import type { RouterHistory } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import Flex from "components/Flex";
|
||||
import Scrollable from "components/Scrollable";
|
||||
|
||||
@@ -30,131 +28,123 @@ import Version from "./components/Version";
|
||||
import SlackIcon from "./icons/Slack";
|
||||
import ZapierIcon from "./icons/Zapier";
|
||||
import env from "env";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
const isHosted = env.DEPLOYMENT === "hosted";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
policies: PoliciesStore,
|
||||
auth: AuthStore,
|
||||
t: TFunction,
|
||||
};
|
||||
function SettingsSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const team = useCurrentTeam();
|
||||
const { policies } = useStores();
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
@observer
|
||||
class SettingsSidebar extends React.Component<Props> {
|
||||
returnToDashboard = () => {
|
||||
this.props.history.push("/home");
|
||||
};
|
||||
const returnToDashboard = React.useCallback(() => {
|
||||
history.push("/home");
|
||||
}, [history]);
|
||||
|
||||
render() {
|
||||
const { policies, t, auth } = this.props;
|
||||
const { team } = auth;
|
||||
if (!team) return null;
|
||||
return (
|
||||
<Sidebar>
|
||||
<HeaderBlock
|
||||
subheading={
|
||||
<ReturnToApp align="center">
|
||||
<BackIcon color="currentColor" /> {t("Return to App")}
|
||||
</ReturnToApp>
|
||||
}
|
||||
teamName={team.name}
|
||||
logoUrl={team.avatarUrl}
|
||||
onClick={returnToDashboard}
|
||||
/>
|
||||
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<HeaderBlock
|
||||
subheading={
|
||||
<ReturnToApp align="center">
|
||||
<BackIcon color="currentColor" /> {t("Return to App")}
|
||||
</ReturnToApp>
|
||||
}
|
||||
teamName={team.name}
|
||||
logoUrl={team.avatarUrl}
|
||||
onClick={this.returnToDashboard}
|
||||
/>
|
||||
|
||||
<Flex auto column>
|
||||
<Scrollable shadow>
|
||||
<Section>
|
||||
<Header>Account</Header>
|
||||
<SidebarLink
|
||||
to="/settings"
|
||||
icon={<ProfileIcon color="currentColor" />}
|
||||
label={t("Profile")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/notifications"
|
||||
icon={<EmailIcon color="currentColor" />}
|
||||
label={t("Notifications")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/tokens"
|
||||
icon={<CodeIcon color="currentColor" />}
|
||||
label={t("API Tokens")}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<Header>Team</Header>
|
||||
{can.update && (
|
||||
<SidebarLink
|
||||
to="/settings/details"
|
||||
icon={<TeamIcon color="currentColor" />}
|
||||
label={t("Details")}
|
||||
/>
|
||||
)}
|
||||
{can.update && (
|
||||
<SidebarLink
|
||||
to="/settings/security"
|
||||
icon={<PadlockIcon color="currentColor" />}
|
||||
label={t("Security")}
|
||||
/>
|
||||
)}
|
||||
<SidebarLink
|
||||
to="/settings/people"
|
||||
icon={<UserIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("People")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/groups"
|
||||
icon={<GroupIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Groups")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/shares"
|
||||
icon={<LinkIcon color="currentColor" />}
|
||||
label={t("Share Links")}
|
||||
/>
|
||||
{can.export && (
|
||||
<SidebarLink
|
||||
to="/settings/export"
|
||||
icon={<DocumentIcon color="currentColor" />}
|
||||
label={t("Export Data")}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
<Flex auto column>
|
||||
<Scrollable topShadow>
|
||||
<Section>
|
||||
<Header>{t("Account")}</Header>
|
||||
<SidebarLink
|
||||
to="/settings"
|
||||
icon={<ProfileIcon color="currentColor" />}
|
||||
label={t("Profile")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/notifications"
|
||||
icon={<EmailIcon color="currentColor" />}
|
||||
label={t("Notifications")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/tokens"
|
||||
icon={<CodeIcon color="currentColor" />}
|
||||
label={t("API Tokens")}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<Header>{t("Team")}</Header>
|
||||
{can.update && (
|
||||
<Section>
|
||||
<Header>{t("Integrations")}</Header>
|
||||
<SidebarLink
|
||||
to="/settings/details"
|
||||
icon={<TeamIcon color="currentColor" />}
|
||||
label={t("Details")}
|
||||
/>
|
||||
)}
|
||||
{can.update && (
|
||||
<SidebarLink
|
||||
to="/settings/security"
|
||||
icon={<PadlockIcon color="currentColor" />}
|
||||
label={t("Security")}
|
||||
/>
|
||||
)}
|
||||
<SidebarLink
|
||||
to="/settings/people"
|
||||
icon={<UserIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("People")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/groups"
|
||||
icon={<GroupIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Groups")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/shares"
|
||||
icon={<LinkIcon color="currentColor" />}
|
||||
label={t("Share Links")}
|
||||
/>
|
||||
{can.export && (
|
||||
<SidebarLink
|
||||
to="/settings/export"
|
||||
icon={<DocumentIcon color="currentColor" />}
|
||||
label={t("Export Data")}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
{can.update && (
|
||||
<Section>
|
||||
<Header>{t("Integrations")}</Header>
|
||||
<SidebarLink
|
||||
to="/settings/integrations/slack"
|
||||
icon={<SlackIcon color="currentColor" />}
|
||||
label="Slack"
|
||||
/>
|
||||
{isHosted && (
|
||||
<SidebarLink
|
||||
to="/settings/integrations/slack"
|
||||
icon={<SlackIcon color="currentColor" />}
|
||||
label="Slack"
|
||||
to="/settings/integrations/zapier"
|
||||
icon={<ZapierIcon color="currentColor" />}
|
||||
label="Zapier"
|
||||
/>
|
||||
{isHosted && (
|
||||
<SidebarLink
|
||||
to="/settings/integrations/zapier"
|
||||
icon={<ZapierIcon color="currentColor" />}
|
||||
label="Zapier"
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{can.update && !isHosted && (
|
||||
<Section>
|
||||
<Header>{t("Installation")}</Header>
|
||||
<Version />
|
||||
</Section>
|
||||
)}
|
||||
</Scrollable>
|
||||
</Flex>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{can.update && !isHosted && (
|
||||
<Section>
|
||||
<Header>{t("Installation")}</Header>
|
||||
<Version />
|
||||
</Section>
|
||||
)}
|
||||
</Scrollable>
|
||||
</Flex>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const BackIcon = styled(ExpandedIcon)`
|
||||
@@ -166,6 +156,4 @@ const ReturnToApp = styled(Flex)`
|
||||
height: 16px;
|
||||
`;
|
||||
|
||||
export default withTranslation()<SettingsSidebar>(
|
||||
inject("auth", "policies")(SettingsSidebar)
|
||||
);
|
||||
export default observer(SettingsSidebar);
|
||||
|
||||
@@ -1,55 +1,171 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon, MenuIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Portal } from "react-portal";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import type { Location } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import CollapseToggle, { Button } from "./components/CollapseToggle";
|
||||
import CollapseToggle, {
|
||||
Button as CollapseButton,
|
||||
} from "./components/CollapseToggle";
|
||||
import ResizeBorder from "./components/ResizeBorder";
|
||||
import ResizeHandle from "./components/ResizeHandle";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
let firstRender = true;
|
||||
let BOUNCE_ANIMATION_MS = 250;
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
location: Location,
|
||||
};
|
||||
|
||||
const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [isAnimating, setAnimating] = React.useState(false);
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
const isSmallerThanMinimum = width < minWidth;
|
||||
|
||||
const handleDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
// suppresses text selection
|
||||
event.preventDefault();
|
||||
|
||||
// this is simple because the sidebar is always against the left edge
|
||||
const width = Math.min(event.pageX - offset, maxWidth);
|
||||
setWidth(width);
|
||||
},
|
||||
[offset, maxWidth, setWidth]
|
||||
);
|
||||
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
setResizing(false);
|
||||
|
||||
if (isSmallerThanMinimum) {
|
||||
setWidth(minWidth);
|
||||
setAnimating(true);
|
||||
} else {
|
||||
setWidth(width);
|
||||
}
|
||||
}, [isSmallerThanMinimum, minWidth, width, setWidth]);
|
||||
|
||||
const handleStartDrag = React.useCallback(
|
||||
(event) => {
|
||||
setOffset(event.pageX - width);
|
||||
setResizing(true);
|
||||
setAnimating(false);
|
||||
},
|
||||
[width]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isAnimating) {
|
||||
setTimeout(() => setAnimating(false), BOUNCE_ANIMATION_MS);
|
||||
}
|
||||
}, [isAnimating]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isResizing) {
|
||||
document.addEventListener("mousemove", handleDrag);
|
||||
document.addEventListener("mouseup", handleStopDrag);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleDrag);
|
||||
document.removeEventListener("mouseup", handleStopDrag);
|
||||
};
|
||||
}, [isResizing, handleDrag, handleStopDrag]);
|
||||
|
||||
return { isAnimating, isSmallerThanMinimum, isResizing, handleStartDrag };
|
||||
};
|
||||
|
||||
function Sidebar({ location, children }: Props) {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
const previousLocation = usePrevious(location);
|
||||
|
||||
const width = ui.sidebarWidth;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
const collapsed = ui.editMode || ui.sidebarCollapsed;
|
||||
|
||||
const {
|
||||
isAnimating,
|
||||
isSmallerThanMinimum,
|
||||
isResizing,
|
||||
handleStartDrag,
|
||||
} = useResize({
|
||||
width,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
setWidth: ui.setSidebarWidth,
|
||||
});
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
ui.setSidebarWidth(theme.sidebarWidth);
|
||||
}, [ui, theme.sidebarWidth]);
|
||||
|
||||
React.useEffect(() => {
|
||||
ui.setSidebarResizing(isResizing);
|
||||
}, [ui, isResizing]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (location !== previousLocation) {
|
||||
ui.hideMobileSidebar();
|
||||
}
|
||||
}, [ui, location, previousLocation]);
|
||||
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
width: `${width}px`,
|
||||
left:
|
||||
collapsed && !ui.mobileSidebarVisible
|
||||
? `${-width + theme.sidebarCollapsedWidth}px`
|
||||
: 0,
|
||||
}),
|
||||
[width, collapsed, theme.sidebarCollapsedWidth, ui.mobileSidebarVisible]
|
||||
);
|
||||
|
||||
const content = (
|
||||
<Container
|
||||
mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
collapsed={ui.editMode || ui.sidebarCollapsed}
|
||||
style={style}
|
||||
$sidebarWidth={ui.sidebarWidth}
|
||||
$isAnimating={isAnimating}
|
||||
$isSmallerThanMinimum={isSmallerThanMinimum}
|
||||
$mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
$collapsed={collapsed}
|
||||
column
|
||||
>
|
||||
<CollapseToggle
|
||||
collapsed={ui.sidebarCollapsed}
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
/>
|
||||
<Toggle
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
>
|
||||
{ui.mobileSidebarVisible ? (
|
||||
<CloseIcon size={32} />
|
||||
) : (
|
||||
<MenuIcon size={32} />
|
||||
)}
|
||||
</Toggle>
|
||||
{!isResizing && (
|
||||
<CollapseToggle
|
||||
collapsed={ui.sidebarCollapsed}
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
/>
|
||||
)}
|
||||
{ui.mobileSidebarVisible && (
|
||||
<Portal>
|
||||
<Fade>
|
||||
<Background onClick={ui.toggleMobileSidebar} />
|
||||
</Fade>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
{children}
|
||||
{!ui.sidebarCollapsed && (
|
||||
<ResizeBorder
|
||||
onMouseDown={handleStartDrag}
|
||||
onDoubleClick={handleReset}
|
||||
$isResizing={isResizing}
|
||||
>
|
||||
<ResizeHandle aria-label={t("Resize sidebar")} />
|
||||
</ResizeBorder>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -62,82 +178,67 @@ function Sidebar({ location, children }: Props) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const Background = styled.a`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
cursor: default;
|
||||
z-index: ${(props) => props.theme.depths.sidebar - 1};
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
transition: box-shadow, 100ms, ease-in-out, left 100ms ease-out,
|
||||
${(props) => props.theme.backgroundTransition};
|
||||
margin-left: ${(props) => (props.mobileSidebarVisible ? 0 : "-100%")};
|
||||
transition: box-shadow, 100ms, ease-in-out, margin-left 100ms ease-out,
|
||||
left 100ms ease-out,
|
||||
${(props) => props.theme.backgroundTransition}
|
||||
${(props) =>
|
||||
props.$isAnimating ? `,width ${BOUNCE_ANIMATION_MS}ms ease-out` : ""};
|
||||
margin-left: ${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")};
|
||||
z-index: ${(props) => props.theme.depths.sidebar};
|
||||
max-width: 70%;
|
||||
min-width: 280px;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: "";
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
position: absolute;
|
||||
top: -50vh;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 50vh;
|
||||
}
|
||||
|
||||
&:after {
|
||||
top: auto;
|
||||
bottom: -50vh;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
left: ${(props) =>
|
||||
props.collapsed
|
||||
? `calc(-${props.theme.sidebarWidth} + ${props.theme.sidebarCollapsedWidth})`
|
||||
: 0};
|
||||
width: ${(props) => props.theme.sidebarWidth};
|
||||
margin: 0;
|
||||
z-index: 3;
|
||||
min-width: 0;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
left: 0;
|
||||
left: 0 !important;
|
||||
box-shadow: ${(props) =>
|
||||
props.collapsed ? "rgba(0, 0, 0, 0.2) 1px 0 4px" : "none"};
|
||||
props.$collapsed
|
||||
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
|
||||
: props.$isSmallerThanMinimum
|
||||
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
|
||||
: "none"};
|
||||
|
||||
& ${Button} {
|
||||
& ${CollapseButton} {
|
||||
opacity: .75;
|
||||
}
|
||||
|
||||
& ${Button}:hover {
|
||||
& ${CollapseButton}:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:hover):not(:focus-within) > div {
|
||||
opacity: ${(props) => (props.collapsed ? "0" : "1")};
|
||||
opacity: ${(props) => (props.$collapsed ? "0" : "1")};
|
||||
transition: opacity 100ms ease-in-out;
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
const Toggle = styled.a`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: ${(props) => (props.mobileSidebarVisible ? "auto" : 0)};
|
||||
right: ${(props) => (props.mobileSidebarVisible ? 0 : "auto")};
|
||||
z-index: 1;
|
||||
margin: 12px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: none;
|
||||
`};
|
||||
`;
|
||||
|
||||
export default withRouter(observer(Sidebar));
|
||||
|
||||
@@ -8,7 +8,7 @@ import { meta } from "utils/keyboard";
|
||||
|
||||
type Props = {|
|
||||
collapsed: boolean,
|
||||
onClick?: () => void,
|
||||
onClick?: (event: SyntheticEvent<>) => void,
|
||||
|};
|
||||
|
||||
function CollapseToggle({ collapsed, ...rest }: Props) {
|
||||
@@ -21,7 +21,7 @@ function CollapseToggle({ collapsed, ...rest }: Props) {
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button {...rest} aria-hidden>
|
||||
<Button {...rest} tabIndex="-1" aria-hidden>
|
||||
{collapsed ? (
|
||||
<NextIcon color="currentColor" />
|
||||
) : (
|
||||
@@ -43,7 +43,7 @@ export const Button = styled.button`
|
||||
z-index: 1;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
background: ${(props) => props.theme.sidebarItemBackground};
|
||||
background: transparent;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
|
||||
@@ -2,16 +2,19 @@
|
||||
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,
|
||||
@@ -39,11 +42,14 @@ function CollectionLink({
|
||||
|
||||
const { documents, policies } = useStores();
|
||||
const expanded = collection.id === ui.activeCollectionId;
|
||||
const manualSort = collection.sort.field === "index";
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
// Droppable
|
||||
// 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);
|
||||
},
|
||||
@@ -51,16 +57,28 @@ function CollectionLink({
|
||||
return policies.abilities(collection.id).update;
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: !!monitor.isOver(),
|
||||
isOver: !!monitor.isOver({ shallow: true }),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
|
||||
// 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}>
|
||||
<div ref={drop} style={{ position: "relative" }}>
|
||||
<DropToImport key={collection.id} collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
<SidebarLinkWithPadding
|
||||
key={collection.id}
|
||||
to={collection.url}
|
||||
icon={
|
||||
@@ -68,7 +86,7 @@ function CollectionLink({
|
||||
}
|
||||
iconColor={collection.color}
|
||||
expanded={expanded}
|
||||
menuOpen={menuOpen}
|
||||
showActions={menuOpen || expanded}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
label={
|
||||
<EditableTitle
|
||||
@@ -79,19 +97,30 @@ function CollectionLink({
|
||||
}
|
||||
exact={false}
|
||||
menu={
|
||||
<CollectionMenu
|
||||
position="right"
|
||||
collection={collection}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(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) => (
|
||||
collection.documents.map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
@@ -100,10 +129,19 @@ function CollectionLink({
|
||||
prefetchDocument={prefetchDocument}
|
||||
canUpdate={canUpdate}
|
||||
depth={1.5}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SidebarLinkWithPadding = styled(SidebarLink)`
|
||||
padding-right: 60px;
|
||||
`;
|
||||
|
||||
const CollectionSortMenuWithMargin = styled(CollectionSortMenu)`
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
export default observer(CollectionLink);
|
||||
|
||||
@@ -9,6 +9,7 @@ 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";
|
||||
@@ -20,19 +21,21 @@ type Props = {|
|
||||
canUpdate: boolean,
|
||||
collection?: Collection,
|
||||
activeDocument: ?Document,
|
||||
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, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
@@ -76,6 +79,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();
|
||||
@@ -108,6 +119,7 @@ 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({
|
||||
@@ -120,77 +132,131 @@ function DocumentLink({
|
||||
},
|
||||
});
|
||||
|
||||
// Droppable
|
||||
const [{ isOver, canDrop }, drop] = useDrop({
|
||||
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) => ({
|
||||
isOver: !!monitor.isOver(),
|
||||
canDrop: monitor.canDrop(),
|
||||
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 (
|
||||
<>
|
||||
<Draggable
|
||||
key={node.id}
|
||||
ref={drag}
|
||||
$isDragging={isDragging}
|
||||
$isMoving={isMoving}
|
||||
>
|
||||
<div ref={drop}>
|
||||
<DropToImport documentId={node.id} activeClassName="activeDropZone">
|
||||
<SidebarLink
|
||||
innerRef={isActiveDocument ? activeDocumentRef : undefined}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
to={{
|
||||
pathname: node.url,
|
||||
state: { title: node.title },
|
||||
}}
|
||||
label={
|
||||
<>
|
||||
{hasChildDocuments && (
|
||||
<Disclosure
|
||||
expanded={expanded && !isDragging}
|
||||
onClick={handleDisclosureClick}
|
||||
<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
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
<EditableTitle
|
||||
title={node.title || t("Untitled")}
|
||||
onSubmit={handleTitleChange}
|
||||
canUpdate={canUpdate}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
menuOpen={menuOpen}
|
||||
menu={
|
||||
document && !isMoving ? (
|
||||
<Fade>
|
||||
<DocumentMenu
|
||||
position="right"
|
||||
document={document}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</DropToImport>
|
||||
</div>
|
||||
</Draggable>
|
||||
|
||||
</>
|
||||
}
|
||||
isActiveDrop={isOverReparent && canDropToReparent}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
showActions={menuOpen}
|
||||
menu={
|
||||
document && !isMoving ? (
|
||||
<Fade>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</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}
|
||||
@@ -199,6 +265,8 @@ function DocumentLink({
|
||||
prefetchDocument={prefetchDocument}
|
||||
depth={depth + 1}
|
||||
canUpdate={canUpdate}
|
||||
index={index}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,33 +5,35 @@ import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
import TeamLogo from "components/TeamLogo";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
teamName: string,
|
||||
subheading: React.Node,
|
||||
showDisclosure?: boolean,
|
||||
onClick: (event: SyntheticEvent<>) => void,
|
||||
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) => (
|
||||
<Wrapper>
|
||||
<Header justify="flex-start" align="center" ref={ref} {...rest}>
|
||||
<TeamLogo
|
||||
alt={`${teamName} logo`}
|
||||
src={logoUrl}
|
||||
width={38}
|
||||
height={38}
|
||||
/>
|
||||
<Flex align="flex-start" column>
|
||||
<TeamName showDisclosure>
|
||||
{teamName}{" "}
|
||||
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
|
||||
</TeamName>
|
||||
<Subheading>{subheading}</Subheading>
|
||||
</Flex>
|
||||
</Header>
|
||||
</Wrapper>
|
||||
)
|
||||
);
|
||||
|
||||
const StyledExpandedIcon = styled(ExpandedIcon)`
|
||||
position: absolute;
|
||||
@@ -44,6 +46,7 @@ const Subheading = styled.div`
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
`;
|
||||
|
||||
@@ -53,16 +56,20 @@ const TeamName = styled.div`
|
||||
padding-right: 24px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Header = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding: 20px 24px;
|
||||
position: relative;
|
||||
background: none;
|
||||
line-height: inherit;
|
||||
border: 0;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// @flow
|
||||
// ref: https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/NavLink.js
|
||||
|
||||
// This file is pulled almost 100% from react-router with the addition of one
|
||||
// thing, automatic scroll to the active link. It's worth the copy paste because
|
||||
// it avoids recalculating the link match again.
|
||||
import { createLocation } from "history";
|
||||
import * as React from "react";
|
||||
import {
|
||||
__RouterContext as RouterContext,
|
||||
matchPath,
|
||||
type Location,
|
||||
} from "react-router";
|
||||
import { Link } from "react-router-dom";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
|
||||
const resolveToLocation = (to, currentLocation) =>
|
||||
typeof to === "function" ? to(currentLocation) : to;
|
||||
|
||||
const normalizeToLocation = (to, currentLocation) => {
|
||||
return typeof to === "string"
|
||||
? createLocation(to, null, null, currentLocation)
|
||||
: to;
|
||||
};
|
||||
|
||||
const joinClassnames = (...classnames) => {
|
||||
return classnames.filter((i) => i).join(" ");
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
activeClassName?: String,
|
||||
activeStyle?: Object,
|
||||
className?: string,
|
||||
exact?: boolean,
|
||||
isActive?: any,
|
||||
location?: Location,
|
||||
strict?: boolean,
|
||||
style?: Object,
|
||||
to: string,
|
||||
|};
|
||||
|
||||
/**
|
||||
* A <Link> wrapper that knows if it's "active" or not.
|
||||
*/
|
||||
const NavLink = ({
|
||||
"aria-current": ariaCurrent = "page",
|
||||
activeClassName = "active",
|
||||
activeStyle,
|
||||
className: classNameProp,
|
||||
exact,
|
||||
isActive: isActiveProp,
|
||||
location: locationProp,
|
||||
strict,
|
||||
style: styleProp,
|
||||
to,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const linkRef = React.useRef();
|
||||
const context = React.useContext(RouterContext);
|
||||
const currentLocation = locationProp || context.location;
|
||||
const toLocation = normalizeToLocation(
|
||||
resolveToLocation(to, currentLocation),
|
||||
currentLocation
|
||||
);
|
||||
const { pathname: path } = toLocation;
|
||||
// Regex taken from: https://github.com/pillarjs/path-to-regexp/blob/master/index.js#L202
|
||||
const escapedPath = path && path.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1");
|
||||
|
||||
const match = escapedPath
|
||||
? matchPath(currentLocation.pathname, {
|
||||
path: escapedPath,
|
||||
exact,
|
||||
strict,
|
||||
})
|
||||
: null;
|
||||
const isActive = !!(isActiveProp
|
||||
? isActiveProp(match, currentLocation)
|
||||
: match);
|
||||
|
||||
const className = isActive
|
||||
? joinClassnames(classNameProp, activeClassName)
|
||||
: classNameProp;
|
||||
const style = isActive ? { ...styleProp, ...activeStyle } : styleProp;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isActive && linkRef.current) {
|
||||
scrollIntoView(linkRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "instant",
|
||||
});
|
||||
}
|
||||
}, [linkRef, isActive]);
|
||||
|
||||
const props = {
|
||||
"aria-current": (isActive && ariaCurrent) || null,
|
||||
className,
|
||||
style,
|
||||
to: toLocation,
|
||||
...rest,
|
||||
};
|
||||
|
||||
return <Link ref={linkRef} {...props} />;
|
||||
};
|
||||
|
||||
export default NavLink;
|
||||
@@ -0,0 +1,28 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
import ResizeHandle from "./ResizeHandle";
|
||||
|
||||
const ResizeBorder = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: -6px;
|
||||
width: 12px;
|
||||
cursor: ew-resize;
|
||||
|
||||
${(props) =>
|
||||
props.$isResizing &&
|
||||
`
|
||||
${ResizeHandle} {
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
|
||||
&:hover {
|
||||
${ResizeHandle} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default ResizeBorder;
|
||||
@@ -0,0 +1,39 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
const ResizeHandle = styled.button`
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
transform: translateY(-50%);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
height: 40px;
|
||||
right: -10px;
|
||||
width: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -24px;
|
||||
bottom: -24px;
|
||||
left: -12px;
|
||||
right: -12px;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: ${(props) => props.theme.sidebarText};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
pointer-events: all;
|
||||
cursor: ew-resize;
|
||||
`}
|
||||
`;
|
||||
|
||||
export default ResizeHandle;
|
||||
@@ -5,7 +5,9 @@ import Flex from "components/Flex";
|
||||
const Section = styled(Flex)`
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
margin: 24px 8px;
|
||||
margin: 20px 8px;
|
||||
min-width: ${(props) => props.theme.sidebarMinWidth}px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default Section;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { withRouter, NavLink } from "react-router-dom";
|
||||
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import EventBoundary from "components/EventBoundary";
|
||||
import NavLink from "./NavLink";
|
||||
import { type Theme } from "types";
|
||||
|
||||
type Props = {
|
||||
@@ -10,14 +13,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,
|
||||
@@ -33,13 +39,14 @@ function SidebarLink({
|
||||
active,
|
||||
isActiveDrop,
|
||||
menu,
|
||||
menuOpen,
|
||||
showActions,
|
||||
theme,
|
||||
exact,
|
||||
href,
|
||||
innerRef,
|
||||
depth,
|
||||
...rest
|
||||
history,
|
||||
match,
|
||||
className,
|
||||
}: Props) {
|
||||
const style = React.useMemo(() => {
|
||||
return {
|
||||
@@ -48,16 +55,20 @@ function SidebarLink({
|
||||
}, [depth]);
|
||||
|
||||
const activeStyle = {
|
||||
color: theme.text,
|
||||
fontWeight: 600,
|
||||
color: theme.text,
|
||||
background: theme.sidebarItemBackground,
|
||||
...style,
|
||||
};
|
||||
|
||||
const activeDropStyle = {
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledNavLink
|
||||
<Link
|
||||
$isActiveDrop={isActiveDrop}
|
||||
activeStyle={isActiveDrop ? undefined : activeStyle}
|
||||
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
|
||||
style={active ? activeStyle : style}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
@@ -65,12 +76,12 @@ function SidebarLink({
|
||||
to={to}
|
||||
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>}
|
||||
</StyledNavLink>
|
||||
{menu && <Actions showActions={showActions}>{menu}</Actions>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,42 +90,49 @@ const IconWrapper = styled.span`
|
||||
margin-left: -4px;
|
||||
margin-right: 4px;
|
||||
height: 24px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledNavLink = styled(NavLink)`
|
||||
const Link = styled(NavLink)`
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 4px 16px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
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 {
|
||||
@@ -127,11 +145,20 @@ const StyledNavLink = styled(NavLink)`
|
||||
background: ${(props) => props.theme.black05};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
> ${Action} {
|
||||
display: inline;
|
||||
&:hover,
|
||||
&:active {
|
||||
> ${Actions} {
|
||||
display: inline-flex;
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px 16px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Label = styled.div`
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
export const id = "skip-nav";
|
||||
|
||||
export default function SkipNavContent() {
|
||||
return <div id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { id } from "components/SkipNavContent";
|
||||
|
||||
export default function SkipNavLink() {
|
||||
return <Anchor href={`#${id}`}>Skip navigation</Anchor>;
|
||||
}
|
||||
|
||||
const Anchor = styled.a`
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
&:focus {
|
||||
padding: 1rem;
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
background: ${(props) => props.theme.background};
|
||||
color: ${(props) => props.theme.text};
|
||||
outline-color: ${(props) => props.theme.primary};
|
||||
z-index: ${(props) => props.theme.depths.popover};
|
||||
width: auto;
|
||||
height: auto;
|
||||
clip: auto;
|
||||
}
|
||||
`;
|
||||
@@ -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,66 @@
|
||||
// @flow
|
||||
import { StarredIcon, UnstarredIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { t } = useTranslation();
|
||||
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}
|
||||
aria-label={document.isStarred ? t("Unstar") : t("Star")}
|
||||
{...rest}
|
||||
>
|
||||
{document.isStarred ? (
|
||||
<AnimatedStar size={size} color="currentColor" />
|
||||
) : (
|
||||
<AnimatedStar size={size} color="currentColor" as={UnstarredIcon} />
|
||||
)}
|
||||
</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;
|
||||
@@ -3,12 +3,15 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { LabelText } from "components/Input";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
width?: number,
|
||||
height?: number,
|
||||
label?: string,
|
||||
checked?: boolean,
|
||||
disabled?: boolean,
|
||||
onChange: (event: SyntheticInputEvent<HTMLInputElement>) => mixed,
|
||||
id?: string,
|
||||
};
|
||||
|};
|
||||
|
||||
function Switch({ width = 38, height = 20, label, ...props }: Props) {
|
||||
const component = (
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
const TeamLogo = styled.img`
|
||||
width: ${(props) => props.size || "auto"};
|
||||
height: ${(props) => props.size || "38px"};
|
||||
width: ${(props) =>
|
||||
props.width ? `${props.width}px` : props.size || "auto"};
|
||||
height: ${(props) =>
|
||||
props.height ? `${props.height}px` : props.size || "38px"};
|
||||
border-radius: 4px;
|
||||
background: ${(props) => props.theme.background};
|
||||
border: 1px solid ${(props) => props.theme.divider};
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default TeamLogo;
|
||||
|
||||
+9
-44
@@ -1,25 +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";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
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,
|
||||
@@ -30,29 +13,11 @@ type Props = {
|
||||
};
|
||||
|
||||
function Time(props: Props) {
|
||||
const { auth } = useStores();
|
||||
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();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { shorten, addSuffix } = props;
|
||||
let content = distanceInWordsToNow(props.dateTime, {
|
||||
addSuffix,
|
||||
locale: auth.user ? auth.user.language : undefined,
|
||||
addSuffix: props.addSuffix,
|
||||
});
|
||||
|
||||
if (shorten) {
|
||||
if (props.shorten) {
|
||||
content = content
|
||||
.replace("about", "")
|
||||
.replace("less than a minute ago", "just now")
|
||||
@@ -60,13 +25,13 @@ function Time(props: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
tooltip={format(props.dateTime, "MMMM Do, YYYY h:mm a")}
|
||||
delay={props.tooltipDelay}
|
||||
placement="bottom"
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<time dateTime={props.dateTime}>{props.children || content}</time>
|
||||
}
|
||||
>
|
||||
<time dateTime={props.dateTime}>{props.children || content}</time>
|
||||
</Tooltip>
|
||||
<LocaleTime {...props} />
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,14 +3,14 @@ import Tippy from "@tippy.js/react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
tooltip: React.Node,
|
||||
shortcut?: React.Node,
|
||||
placement?: "top" | "bottom" | "left" | "right",
|
||||
children: React.Node,
|
||||
delay?: number,
|
||||
className?: string,
|
||||
};
|
||||
|};
|
||||
|
||||
class Tooltip extends React.Component<Props> {
|
||||
render() {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
|
||||
const VisuallyHidden = styled("span")`
|
||||
position: absolute !important;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
`;
|
||||
|
||||
export default VisuallyHidden;
|
||||
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import Image from "components/Image";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
const URL_REGEX = new RegExp("^https?://docs.google.com/document/(.*)$");
|
||||
@@ -20,7 +21,7 @@ export default class GoogleDocs extends React.Component<Props> {
|
||||
{...this.props}
|
||||
src={this.props.attrs.href.replace("/edit", "/preview")}
|
||||
icon={
|
||||
<img
|
||||
<Image
|
||||
src="/images/google-docs.png"
|
||||
alt="Google Docs Icon"
|
||||
width={16}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import Image from "components/Image";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
const URL_REGEX = new RegExp(
|
||||
"^https://docs.google.com/drawings/d/(.*)/(edit|preview)(.*)$"
|
||||
);
|
||||
|
||||
type Props = {|
|
||||
attrs: {|
|
||||
href: string,
|
||||
matches: string[],
|
||||
|},
|
||||
|};
|
||||
|
||||
export default class GoogleDrawings extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={this.props.attrs.href.replace("/edit", "/preview")}
|
||||
icon={
|
||||
<Image
|
||||
src="/images/google-drawings.png"
|
||||
alt="Google Drawings"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
}
|
||||
canonicalUrl={this.props.attrs.href.replace("/preview", "/edit")}
|
||||
title="Google Drawings"
|
||||
border
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import GoogleDrawings from "./GoogleDrawings";
|
||||
|
||||
describe("GoogleDrawings", () => {
|
||||
const match = GoogleDrawings.ENABLED[0];
|
||||
test("to be enabled on share link", () => {
|
||||
expect(
|
||||
"https://docs.google.com/drawings/d/1zDLtJ4HSCnjGCGSoCgqGe3F8p6o7R8Vjk8MDR6dKf-U/edit".match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
"https://docs.google.com/drawings/d/1zDLtJ4HSCnjGCGSoCgqGe3F8p6o7R8Vjk8MDR6dKf-U/edit?usp=sharing".match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("to not be enabled elsewhere", () => {
|
||||
expect(
|
||||
"https://docs.google.com/drawings/d/e/2PACX-1vRtzIzEWN6svSrIYZq-kq2XZEN6WaOFXHbPKRLXNOFRlxLIdJg0Vo6RfretGqs9SzD-fUazLeS594Kw/pub?w=960&h=720".match(
|
||||
match
|
||||
)
|
||||
).toBe(null);
|
||||
expect("https://docs.google.com/drawings".match(match)).toBe(null);
|
||||
expect("https://docs.google.com".match(match)).toBe(null);
|
||||
expect("https://www.google.com".match(match)).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import Image from "components/Image";
|
||||
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={
|
||||
<Image
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import Image from "components/Image";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
const URL_REGEX = new RegExp("^https?://docs.google.com/spreadsheets/d/(.*)$");
|
||||
@@ -20,7 +21,7 @@ export default class GoogleSlides extends React.Component<Props> {
|
||||
{...this.props}
|
||||
src={this.props.attrs.href.replace("/edit", "/preview")}
|
||||
icon={
|
||||
<img
|
||||
<Image
|
||||
src="/images/google-sheets.png"
|
||||
alt="Google Sheets Icon"
|
||||
width={16}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import Image from "components/Image";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
const URL_REGEX = new RegExp("^https?://docs.google.com/presentation/d/(.*)$");
|
||||
@@ -22,7 +23,7 @@ export default class GoogleSlides extends React.Component<Props> {
|
||||
.replace("/edit", "/preview")
|
||||
.replace("/pub", "/embed")}
|
||||
icon={
|
||||
<img
|
||||
<Image
|
||||
src="/images/google-slides.png"
|
||||
alt="Google Slides Icon"
|
||||
width={16}
|
||||
|
||||
+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);
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
import * as React from "react";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
const URL_REGEX = /(http|https)?:\/\/(www\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^\/]*)\/videos\/|)(\d+)(?:|\/\?)/;
|
||||
const URL_REGEX = /(http|https)?:\/\/(www\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|)(\d+)(?:|\/\?)/;
|
||||
|
||||
type Props = {|
|
||||
attrs: {|
|
||||
|
||||
@@ -6,6 +6,17 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
// This wrapper allows us to pass non-standard HTML attributes through to the DOM element
|
||||
// https://www.styled-components.com/docs/basics#passed-props
|
||||
const Iframe = (props) => <iframe title="Embed" {...props} />;
|
||||
|
||||
const StyledIframe = styled(Iframe)`
|
||||
border: 1px solid;
|
||||
border-color: ${(props) => props.theme.embedBorder};
|
||||
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
|
||||
display: block;
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
src?: string,
|
||||
border?: boolean,
|
||||
@@ -129,17 +140,6 @@ const Bar = styled(Flex)`
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
// This wrapper allows us to pass non-standard HTML attributes through to the DOM element
|
||||
// https://www.styled-components.com/docs/basics#passed-props
|
||||
const Iframe = (props) => <iframe {...props} />;
|
||||
|
||||
const StyledIframe = styled(Iframe)`
|
||||
border: 1px solid;
|
||||
border-color: ${(props) => props.theme.embedBorder};
|
||||
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
|
||||
display: block;
|
||||
`;
|
||||
|
||||
export default React.forwardRef<Props, typeof Frame>((props, ref) => (
|
||||
<Frame {...props} forwardedRef={ref} />
|
||||
));
|
||||
|
||||
+18
-1
@@ -1,6 +1,7 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Image from "components/Image";
|
||||
import Abstract from "./Abstract";
|
||||
import Airtable from "./Airtable";
|
||||
import ClickUp from "./ClickUp";
|
||||
@@ -9,6 +10,8 @@ import Figma from "./Figma";
|
||||
import Framer from "./Framer";
|
||||
import Gist from "./Gist";
|
||||
import GoogleDocs from "./GoogleDocs";
|
||||
import GoogleDrawings from "./GoogleDrawings";
|
||||
import GoogleDrive from "./GoogleDrive";
|
||||
import GoogleSheets from "./GoogleSheets";
|
||||
import GoogleSlides from "./GoogleSlides";
|
||||
import InVision from "./InVision";
|
||||
@@ -37,7 +40,7 @@ function matcher(Component) {
|
||||
};
|
||||
}
|
||||
|
||||
const Img = styled.img`
|
||||
const Img = styled(Image)`
|
||||
margin: 4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -93,6 +96,20 @@ export default [
|
||||
component: Gist,
|
||||
matcher: matcher(Gist),
|
||||
},
|
||||
{
|
||||
title: "Google Drawings",
|
||||
keywords: "drawings",
|
||||
icon: () => <Img src="/images/google-drawings.png" />,
|
||||
component: GoogleDrawings,
|
||||
matcher: matcher(GoogleDrawings),
|
||||
},
|
||||
{
|
||||
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,9 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import useStores from "./useStores";
|
||||
|
||||
export default function useCurrentTeam() {
|
||||
const { auth } = useStores();
|
||||
invariant(auth.team, "team required");
|
||||
return auth.team;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// @flow
|
||||
import useStores from "./useStores";
|
||||
|
||||
export default function useUserLocale() {
|
||||
const { auth } = useStores();
|
||||
|
||||
if (!auth.user || !auth.user.language) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return auth.user.language.split("_")[0];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// @flow
|
||||
import { debounce } from "lodash";
|
||||
import * as React from "react";
|
||||
|
||||
export default function useWindowSize() {
|
||||
const [windowSize, setWindowSize] = React.useState({
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
// Handler to call on window resize
|
||||
const handleResize = debounce(() => {
|
||||
// Set window width/height to state
|
||||
setWindowSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
}, 100);
|
||||
|
||||
// Add event listener
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
// Call handler right away so state gets updated with initial window size
|
||||
handleResize();
|
||||
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
return windowSize;
|
||||
}
|
||||
+15
-9
@@ -1,12 +1,12 @@
|
||||
// @flow
|
||||
import "mobx-react-lite/batchingForReactDom";
|
||||
import "focus-visible";
|
||||
import { createBrowserHistory } from "history";
|
||||
import { Provider } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { render } from "react-dom";
|
||||
import { BrowserRouter as Router } from "react-router-dom";
|
||||
import { Router } from "react-router-dom";
|
||||
import { initI18n } from "shared/i18n";
|
||||
import stores from "stores";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
@@ -15,18 +15,24 @@ import Theme from "components/Theme";
|
||||
import Toasts from "components/Toasts";
|
||||
import Routes from "./routes";
|
||||
import env from "env";
|
||||
import { initSentry } from "utils/sentry";
|
||||
|
||||
initI18n();
|
||||
|
||||
const element = document.getElementById("root");
|
||||
const history = createBrowserHistory();
|
||||
|
||||
if (env.SENTRY_DSN) {
|
||||
initSentry(history);
|
||||
}
|
||||
|
||||
if (element) {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Provider {...stores}>
|
||||
<Theme>
|
||||
<Provider {...stores}>
|
||||
<Theme>
|
||||
<ErrorBoundary>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<Router>
|
||||
<Router history={history}>
|
||||
<>
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
@@ -35,9 +41,9 @@ if (element) {
|
||||
</>
|
||||
</Router>
|
||||
</DndProvider>
|
||||
</Theme>
|
||||
</Provider>
|
||||
</ErrorBoundary>,
|
||||
</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 aria-label={t("Show path to document")} {...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 aria-label={t("Show menu")} {...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);
|
||||
+194
-194
@@ -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 { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
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 aria-label={t("Show menu")} {...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 aria-label={t("Show sort menu")} {...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);
|
||||
+304
-327
@@ -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,325 @@ 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.preventDefault();
|
||||
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.preventDefault();
|
||||
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
|
||||
return (
|
||||
<>
|
||||
{label ? (
|
||||
<MenuButton {...menu}>{label}</MenuButton>
|
||||
) : (
|
||||
<OverflowMenuButton
|
||||
className={className}
|
||||
position={position}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
label={label}
|
||||
>
|
||||
<DropdownMenuItems
|
||||
items={[
|
||||
{
|
||||
title: t("Restore"),
|
||||
visible: !!can.unarchive,
|
||||
onClick: this.handleRestore,
|
||||
aria-label={t("Show menu")}
|
||||
{...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 aria-label={t("Show menu")} {...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 aria-label={t("Show menu")} {...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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user