Compare commits

..

1 Commits

Author SHA1 Message Date
Tom Moor b440e07c03 wip 2020-12-21 19:23:52 -08:00
228 changed files with 4072 additions and 7084 deletions
+1 -8
View File
@@ -10,20 +10,15 @@ 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,emails,mailer,utils,multiplayer,server,services
DEBUG=cache,presenters,events
# Third party signin credentials (at least one is required)
SLACK_KEY=get_a_key_from_slack
@@ -71,6 +66,4 @@ 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
-2
View File
@@ -18,7 +18,6 @@
[options]
emoji=true
sharedmemory.heap_size=3221225472
module.system.node.resolve_dirname=node_modules
module.system.node.resolve_dirname=app
@@ -33,7 +32,6 @@ 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
+1 -2
View File
@@ -1,7 +1,6 @@
{
"javascript.validate.enable": false,
"javascript.format.enable": false,
"typescript.validate.enable": false,
"typescript.format.enable": false,
"editor.formatOnSave": true,
"typescript.format.enable": false
}
-14
View File
@@ -75,20 +75,6 @@ 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
+5
View File
@@ -11,6 +11,11 @@ export const Action = styled(Flex)`
font-size: 15px;
flex-shrink: 0;
a {
color: ${(props) => props.theme.text};
height: 24px;
}
&:empty {
display: none;
}
+2 -4
View File
@@ -20,10 +20,8 @@ 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 (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("_", "-"));
if (i18n.language !== language) {
i18n.changeLanguage(language);
}
}, [i18n, language]);
+2 -6
View File
@@ -3,17 +3,13 @@ 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> {
+33 -25
View File
@@ -4,6 +4,7 @@ import {
ArchiveIcon,
EditIcon,
GoToIcon,
MoreIcon,
PadlockIcon,
ShapesIcon,
TrashIcon,
@@ -13,15 +14,18 @@ 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,
};
@@ -31,11 +35,11 @@ function Icon({ document }) {
if (document.isDeleted) {
return (
<>
<CategoryName to="/trash">
<CollectionName to="/trash">
<TrashIcon color="currentColor" />
&nbsp;
<span>{t("Trash")}</span>
</CategoryName>
</CollectionName>
<Slash />
</>
);
@@ -43,11 +47,11 @@ function Icon({ document }) {
if (document.isArchived) {
return (
<>
<CategoryName to="/archive">
<CollectionName to="/archive">
<ArchiveIcon color="currentColor" />
&nbsp;
<span>{t("Archive")}</span>
</CategoryName>
</CollectionName>
<Slash />
</>
);
@@ -55,11 +59,11 @@ function Icon({ document }) {
if (document.isDraft) {
return (
<>
<CategoryName to="/drafts">
<CollectionName to="/drafts">
<EditIcon color="currentColor" />
&nbsp;
<span>{t("Drafts")}</span>
</CategoryName>
</CollectionName>
<Slash />
</>
);
@@ -67,11 +71,11 @@ function Icon({ document }) {
if (document.isTemplate) {
return (
<>
<CategoryName to="/templates">
<CollectionName to="/templates">
<ShapesIcon color="currentColor" />
&nbsp;
<span>{t("Templates")}</span>
</CategoryName>
</CollectionName>
<Slash />
</>
);
@@ -85,6 +89,8 @@ 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"),
@@ -129,7 +135,7 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
</CollectionName>
{isNestedDocument && (
<>
<Slash /> <BreadcrumbMenu path={menuPath} />
<Slash /> <BreadcrumbMenu label={<Overflow />} path={menuPath} />
</>
)}
{lastPath && (
@@ -144,11 +150,6 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
);
};
export const Slash = styled(GoToIcon)`
flex-shrink: 0;
fill: ${(props) => props.theme.divider};
`;
const Wrapper = styled(Flex)`
display: none;
@@ -169,6 +170,22 @@ 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;
@@ -184,21 +201,12 @@ const Crumb = styled(Link)`
const CollectionName = styled(Link)`
display: flex;
flex-shrink: 1;
flex-shrink: 0;
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);
+22
View File
@@ -0,0 +1,22 @@
// @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>
);
}
+18 -42
View File
@@ -22,13 +22,9 @@ const RealButton = styled.button`
cursor: pointer;
user-select: none;
${(props) =>
!props.borderOnHover &&
`
svg {
fill: ${props.iconColor || props.theme.buttonText};
}
`}
svg {
fill: ${(props) => props.iconColor || props.theme.buttonText};
}
&::-moz-focus-inner {
padding: 0;
@@ -46,30 +42,24 @@ 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, ${props.theme.buttonNeutralBorder} 0 0 0 1px inset`
props.borderOnHover ? "none" : "rgba(0, 0, 0, 0.07) 0px 1px 2px"
};
border: 1px solid ${
props.borderOnHover ? "transparent" : props.theme.buttonNeutralBorder
};
${
props.borderOnHover
? ""
: `svg {
svg {
fill: ${props.iconColor || props.theme.buttonNeutralText};
}`
}
&:hover {
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
props.theme.buttonNeutralBorder
} 0 0 0 1px inset;
border: 1px solid ${props.theme.buttonNeutralBorder};
}
&:disabled {
@@ -81,9 +71,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)};
}
`};
`;
@@ -102,14 +92,14 @@ export const Inner = styled.span`
line-height: ${(props) => (props.hasIcon ? 24 : 32)}px;
justify-content: center;
align-items: center;
min-height: 32px;
min-height: 30px;
${(props) => props.hasIcon && props.hasText && "padding-left: 4px;"};
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
`;
export type Props = {|
type?: "button" | "submit",
export type Props = {
type?: string,
value?: string,
icon?: React.Node,
iconColor?: string,
@@ -117,22 +107,9 @@ 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",
@@ -141,14 +118,13 @@ function Button({
value,
disclosure,
innerRef,
neutral,
...rest
}: Props) {
const hasText = children !== undefined || value !== undefined;
const hasIcon = icon !== undefined;
return (
<RealButton type={type} ref={innerRef} $neutral={neutral} {...rest}>
<RealButton type={type} ref={innerRef} {...rest}>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
{hasIcon && icon}
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
-23
View File
@@ -1,23 +0,0 @@
// @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;
`;
+3 -6
View File
@@ -1,21 +1,18 @@
// @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;
-13
View File
@@ -1,13 +0,0 @@
// @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;
@@ -1,21 +0,0 @@
// @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>
);
}
-16
View File
@@ -1,16 +0,0 @@
// @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;
`;
-77
View File
@@ -1,77 +0,0 @@
// @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}px;
min-width: ${(props) => props.theme.sidebarWidth};
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}px;
min-width: ${(props) => props.theme.sidebarWidth};
border-left: 1px solid ${(props) => props.theme.divider};
z-index: 1;
@@ -1,5 +1,6 @@
// @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";
@@ -44,7 +45,9 @@ class RevisionListItem extends React.Component<Props> {
<StyledRevisionMenu
document={document}
revision={revision}
iconColor={selected ? theme.white : theme.textTertiary}
label={
<MoreIcon color={selected ? theme.white : theme.textTertiary} />
}
/>
)}
</StyledNavLink>
+4 -9
View File
@@ -2,17 +2,12 @@
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import * as React from "react";
import Document from "models/Document";
import DocumentListItem from "components/DocumentListItem";
import DocumentPreview from "components/DocumentPreview";
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;
@@ -23,7 +18,7 @@ export default function DocumentList({ limit, documents, ...rest }: Props) {
defaultActiveChildIndex={0}
>
{items.map((document) => (
<DocumentListItem key={document.id} document={document} {...rest} />
<DocumentPreview key={document.id} document={document} {...rest} />
))}
</ArrowKeyNavigation>
);
-243
View File
@@ -1,243 +0,0 @@
// @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>
&nbsp;
</>
)}
<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);
+2 -15
View File
@@ -15,7 +15,6 @@ const Container = styled(Flex)`
font-size: 13px;
white-space: nowrap;
overflow: hidden;
min-width: 0;
`;
const Modified = styled.span`
@@ -23,21 +22,19 @@ 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,
@@ -125,10 +122,6 @@ function DocumentMeta({
);
};
const nestedDocumentsCount = collection
? collection.getDocumentChildren(document.id).length
: 0;
return (
<Container align="center" {...rest}>
{updatedByMe ? t("You") : updatedBy.name}&nbsp;
@@ -141,12 +134,6 @@ function DocumentMeta({
</strong>
</span>
)}
{showNestedDocuments && nestedDocumentsCount > 0 && (
<span>
&nbsp;&middot; {nestedDocumentsCount}{" "}
{t("nested document", { count: nestedDocumentsCount })}
</span>
)}
&nbsp;{timeSinceNow()}
{children}
</Container>
-2
View File
@@ -35,8 +35,6 @@ 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;
@@ -0,0 +1,247 @@
// @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>
)}
&nbsp;
<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);
+3
View File
@@ -0,0 +1,3 @@
// @flow
import DocumentPreview from "./DocumentPreview";
export default DocumentPreview;
+2 -8
View File
@@ -60,9 +60,7 @@ class DropToImport extends React.Component<Props> {
}
}
} catch (err) {
this.props.ui.showToast(`Could not import file. ${err.message}`, {
type: "error",
});
this.props.ui.showToast(`Could not import file. ${err.message}`);
} finally {
this.isImporting = false;
importingLock = false;
@@ -89,11 +87,7 @@ class DropToImport extends React.Component<Props> {
isDragAccept,
isDragReject,
}) => (
<DropzoneContainer
{...getRootProps()}
{...{ isDragActive }}
tabIndex="-1"
>
<DropzoneContainer {...getRootProps()} {...{ isDragActive }}>
<input {...getInputProps()} />
{this.isImporting && <LoadingIndicator />}
{this.props.children}
+289
View File
@@ -0,0 +1,289 @@
// @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,62 +1,50 @@
// @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 MenuItem = ({
const DropdownMenuItem = ({
onClick,
children,
selected,
disabled,
as,
...rest
}: Props) => {
return (
<BaseMenuItem
<MenuItem
onClick={disabled ? undefined : onClick}
disabled={disabled}
role="menuitem"
tabIndex="-1"
{...rest}
>
{(props) => (
<MenuAnchor as={onClick ? "button" : as} {...props}>
{selected !== undefined && (
<>
{selected ? <CheckmarkIcon /> : <Spacer />}
&nbsp;
</>
)}
{children}
</MenuAnchor>
{selected !== undefined && (
<>
<CheckmarkIcon
color={selected === false ? "transparent" : undefined}
/>
&nbsp;
</>
)}
</BaseMenuItem>
{children}
</MenuItem>
);
};
const Spacer = styled.div`
width: 24px;
height: 24px;
`;
export const MenuAnchor = styled.a`
const MenuItem = 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;
@@ -70,7 +58,6 @@ export const MenuAnchor = styled.a`
}
svg {
flex-shrink: 0;
opacity: ${(props) => (props.disabled ? ".5" : 1)};
}
@@ -79,8 +66,7 @@ export const MenuAnchor = styled.a`
? "pointer-events: none;"
: `
&:hover,
&.focus-visible {
&:hover {
color: ${props.theme.white};
background: ${props.theme.primary};
box-shadow: none;
@@ -98,4 +84,4 @@ export const MenuAnchor = styled.a`
`};
`;
export default MenuItem;
export default DropdownMenuItem;
@@ -1,38 +1,26 @@
// @flow
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
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 ".";
import DropdownMenu from "./DropdownMenu";
import DropdownMenuItem from "./DropdownMenuItem";
type TMenuItem =
type MenuItem =
| {|
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,
|}
| {|
@@ -41,7 +29,7 @@ type TMenuItem =
disabled?: boolean,
style?: Object,
hover?: boolean,
items: TMenuItem[],
items: MenuItem[],
|}
| {|
type: "separator",
@@ -54,35 +42,10 @@ type TMenuItem =
|};
type Props = {|
items: TMenuItem[],
items: MenuItem[],
|};
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 {
export default function DropdownMenuItems({ items }: Props): React.Node {
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unneccessary separators
@@ -103,67 +66,63 @@ function Template({ items, ...menu }: Props): React.Node {
return filtered.map((item, index) => {
if (item.to) {
return (
<MenuItem
<DropdownMenuItem
as={Link}
to={item.to}
key={index}
disabled={item.disabled}
selected={item.selected}
{...menu}
>
{item.title}
</MenuItem>
</DropdownMenuItem>
);
}
if (item.href) {
return (
<MenuItem
<DropdownMenuItem
href={item.href}
key={index}
disabled={item.disabled}
selected={item.selected}
target="_blank"
{...menu}
>
{item.title}
</MenuItem>
</DropdownMenuItem>
);
}
if (item.onClick) {
return (
<MenuItem
as="button"
<DropdownMenuItem
onClick={item.onClick}
disabled={item.disabled}
selected={item.selected}
key={index}
{...menu}
>
{item.title}
</MenuItem>
</DropdownMenuItem>
);
}
if (item.items) {
return (
<BaseMenuItem
<DropdownMenu
style={item.style}
label={
<DropdownMenuItem disabled={item.disabled}>
{item.title}
</DropdownMenuItem>
}
hover={item.hover}
key={index}
as={Submenu}
templateItems={item.items}
title={item.title}
{...menu}
/>
>
<DropdownMenuItems items={item.items} />
</DropdownMenu>
);
}
if (item.type === "separator") {
return <Separator key={index} />;
return <hr key={index} />;
}
return null;
});
}
export default React.memo<Props>(Template);
+3
View File
@@ -0,0 +1,3 @@
// @flow
export { default as DropdownMenu, Header } from "./DropdownMenu";
export { default as DropdownMenuItem } from "./DropdownMenuItem";
+13 -30
View File
@@ -8,39 +8,21 @@ import UiStore from "stores/UiStore";
import ErrorBoundary from "components/ErrorBoundary";
import Tooltip from "components/Tooltip";
import embeds from "../embeds";
import { isModKey } from "utils/keyboard";
import isInternalUrl from "utils/isInternalUrl";
import { uploadFile } from "utils/uploadFile";
import { isInternalUrl } from "utils/urls";
const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));
const EMPTY_ARRAY = [];
export type Props = {|
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>,
@@ -67,7 +49,7 @@ function Editor(props: PropsWithRef) {
return;
}
if (isInternalUrl(href) && !isModKey(event) && !event.shiftKey) {
if (isInternalUrl(href) && !event.metaKey && !event.shiftKey) {
// relative
let navigateTo = href;
@@ -189,16 +171,17 @@ const StyledEditor = styled(RichMarkdownEditor)`
font-weight: 500;
}
.heading-anchor {
box-sizing: border-box;
}
.heading-name {
pointer-events: none;
display: block;
position: relative;
top: -60px;
visibility: hidden;
}
/* 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:first-child {
+3 -4
View File
@@ -1,5 +1,4 @@
// @flow
import * as Sentry from "@sentry/react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
@@ -37,8 +36,8 @@ class ErrorBoundary extends React.Component<Props> {
return;
}
if (env.SENTRY_DSN) {
Sentry.captureException(error);
if (window.Sentry) {
window.Sentry.captureException(error);
}
}
@@ -57,7 +56,7 @@ class ErrorBoundary extends React.Component<Props> {
render() {
if (this.error) {
const error = this.error;
const isReported = !!env.SENTRY_DSN && env.DEPLOYMENT === "hosted";
const isReported = !!window.Sentry && env.DEPLOYMENT === "hosted";
const isChunkError = this.error.message.match(/chunk/);
if (isChunkError) {
+2 -7
View File
@@ -4,18 +4,13 @@ import * as React from "react";
type Props = {
children: React.Node,
className?: string,
};
export default function EventBoundary({ children, className }: Props) {
export default function EventBoundary({ children }: Props) {
const handleClick = React.useCallback((event: SyntheticEvent<>) => {
event.preventDefault();
event.stopPropagation();
}, []);
return (
<span onClick={handleClick} className={className}>
{children}
</span>
);
return <span onClick={handleClick}>{children}</span>;
}
-3
View File
@@ -7,11 +7,8 @@ const Heading = styled.h1`
${(props) => (props.centered ? "text-align: center;" : "")}
svg {
margin-top: 4px;
margin-left: -6px;
margin-right: 2px;
align-self: flex-start;
flex-shrink: 0;
}
`;
+1 -1
View File
@@ -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/urls";
import isInternalUrl from "utils/isInternalUrl";
const DELAY_OPEN = 300;
const DELAY_CLOSE = 300;
+93 -64
View File
@@ -1,4 +1,6 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import {
CollectionIcon,
CoinsIcon,
@@ -20,17 +22,14 @@ import {
VehicleIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
import { withTranslation, type TFunction } from "react-i18next";
import styled from "styled-components";
import ContextMenu from "components/ContextMenu";
import { DropdownMenu } from "components/DropdownMenu";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import { LabelText } from "components/Input";
import NudeButton from "components/NudeButton";
const style = { width: 30, height: 30 };
const TwitterPicker = React.lazy(() =>
import("react-color/lib/components/twitter/Twitter")
);
@@ -123,77 +122,107 @@ const colors = [
"#2F362F",
];
type Props = {|
type Props = {
onOpen?: () => void,
onChange: (color: string, icon: string) => void,
icon: string,
color: string,
|};
t: TFunction,
};
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>
);
function preventEventBubble(event) {
event.stopPropagation();
}
const Label = styled.label`
display: block;
`;
@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 Icons = styled.div`
padding: 15px 9px 9px 15px;
width: 276px;
`;
const Button = styled(NudeButton)`
const LabelButton = styled(NudeButton)`
border: 1px solid ${(props) => props.theme.inputBorder};
width: 32px;
height: 32px;
@@ -220,4 +249,4 @@ const Wrapper = styled("div")`
position: relative;
`;
export default IconPicker;
export default withTranslation()<IconPicker>(IconPicker);
-15
View File
@@ -1,15 +0,0 @@
// @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} />;
}
+4 -13
View File
@@ -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?: "text" | "email" | "checkbox" | "search",
export type Props = {
type?: string,
value?: string,
label?: string,
className?: string,
@@ -85,18 +85,9 @@ 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> {
+2 -2
View File
@@ -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> {
+1 -6
View File
@@ -9,7 +9,6 @@ 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 = {
@@ -17,8 +16,6 @@ type Props = {
theme: Theme,
source: string,
placeholder?: string,
label?: string,
labelHidden?: boolean,
collectionId?: string,
t: TFunction,
};
@@ -28,7 +25,7 @@ class InputSearch extends React.Component<Props> {
input: ?Input;
@observable focused: boolean = false;
@keydown(`${meta}+f`)
@keydown("meta+f")
focus(ev: SyntheticEvent<>) {
ev.preventDefault();
@@ -70,8 +67,6 @@ 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 -6
View File
@@ -2,19 +2,17 @@
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: 4px 0;
margin: 0 12px;
padding: 8px 12px;
outline: none;
background: none;
color: ${(props) => props.theme.text};
height: 30px;
&:disabled,
&::placeholder {
@@ -36,8 +34,6 @@ export type Props = {
className?: string,
labelHidden?: boolean,
options: Option[],
onBlur?: () => void,
onFocus?: () => void,
};
@observer
+2 -2
View File
@@ -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}>
+3 -14
View File
@@ -4,7 +4,6 @@ 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";
@@ -69,7 +68,7 @@ export default function LanguagePrompt() {
like to change?
</Trans>
<br />
<Link
<a
onClick={() => {
auth.updateUser({
language,
@@ -78,24 +77,14 @@ export default function LanguagePrompt() {
}}
>
{t("Change Language")}
</Link>{" "}
&middot;{" "}
<Link onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</Link>
</a>{" "}
&middot; <a onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</a>
</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;
`;
+9 -48
View File
@@ -1,7 +1,6 @@
// @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";
@@ -15,17 +14,14 @@ 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,
@@ -69,7 +65,7 @@ class Layout extends React.Component<Props> {
window.document.body.style.background = props.theme.background;
}
@keydown(`${meta}+.`)
@keydown("meta+.")
handleToggleSidebar() {
this.props.ui.toggleCollapsedSidebar();
}
@@ -84,7 +80,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();
@@ -102,7 +98,6 @@ 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 />;
@@ -116,19 +111,11 @@ 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>
@@ -137,17 +124,10 @@ class Layout extends React.Component<Props> {
</Switch>
)}
<SkipNavContent />
<Content
auto
justify="center"
$isResizing={ui.sidebarIsResizing}
$sidebarCollapsed={sidebarCollapsed}
style={
sidebarCollapsed
? undefined
: { marginLeft: `${ui.sidebarWidth}px` }
}
sidebarCollapsed={ui.editMode || ui.sidebarCollapsed}
>
{this.props.children}
</Content>
@@ -179,38 +159,19 @@ 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: ${(props) =>
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
transition: margin-left 100ms ease-out;
@media print {
margin: 0;
}
${breakpoint("mobile", "tablet")`
margin-left: 0 !important;
`}
${breakpoint("tablet")`
${(props) =>
props.$sidebarCollapsed &&
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
margin-left: ${(props) =>
props.sidebarCollapsed
? props.theme.sidebarCollapsedWidth
: props.theme.sidebarWidth};
`};
`;
-89
View File
@@ -1,89 +0,0 @@
// @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;
+3 -3
View File
@@ -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} />;
return <Redacted width={this.width} {...this.props} />;
}
}
+2 -2
View File
@@ -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 {
+5 -7
View File
@@ -3,17 +3,15 @@ import * as React from "react";
import styled from "styled-components";
const Button = styled.button`
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
width: 24px;
height: 24px;
background: none;
border-radius: 4px;
line-height: 0;
border: 0;
padding: 0;
cursor: pointer;
user-select: none;
`;
export default React.forwardRef<any, typeof Button>(
({ size = 24, ...props }, ref) => <Button size={size} {...props} ref={ref} />
);
export default React.forwardRef<any, typeof Button>((props, ref) => (
<Button {...props} ref={ref} />
));
+9 -10
View File
@@ -1,17 +1,16 @@
// @flow
import { observer } from "mobx-react";
import { observer, inject } from "mobx-react";
import * as React from "react";
import { Helmet } from "react-helmet";
import useStores from "hooks/useStores";
import { cdnPath } from "utils/urls";
import AuthStore from "stores/AuthStore";
type Props = {|
type Props = {
title: string,
favicon?: string,
|};
auth: AuthStore,
};
const PageTitle = ({ title, favicon }: Props) => {
const { auth } = useStores();
const PageTitle = observer(({ auth, title, favicon }: Props) => {
const { team } = auth;
return (
@@ -22,12 +21,12 @@ const PageTitle = ({ title, favicon }: Props) => {
<link
rel="shortcut icon"
type="image/png"
href={favicon || cdnPath("/favicon-32.png")}
href={favicon || "/favicon-32.png"}
sizes="32x32"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</Helmet>
);
};
});
export default observer(PageTitle);
export default inject("auth")(PageTitle);
+4 -10
View File
@@ -2,22 +2,16 @@
import { observer } from "mobx-react";
import * as React from "react";
import Document from "models/Document";
import DocumentListItem from "components/DocumentListItem";
import DocumentPreview from "components/DocumentPreview";
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> {
@@ -32,7 +26,7 @@ class PaginatedDocumentList extends React.Component<Props> {
fetch={fetch}
options={options}
renderItem={(item) => (
<DocumentListItem key={item.id} document={item} {...rest} />
<DocumentPreview key={item.id} document={item} {...rest} />
)}
/>
);
-12
View File
@@ -1,12 +0,0 @@
// @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;
+19 -54
View File
@@ -1,52 +1,28 @@
// @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,
|};
};
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();
@observer
class Scrollable extends React.Component<Props> {
@observable shadow: boolean = false;
const updateShadows = React.useCallback(() => {
const c = ref.current;
if (!c) return;
handleScroll = (ev: SyntheticMouseEvent<HTMLDivElement>) => {
this.shadow = !!(this.props.shadow && ev.currentTarget.scrollTop > 0);
};
const scrollTop = c.scrollTop;
const tsv = !!((shadow || topShadow) && scrollTop > 0);
if (tsv !== topShadowVisible) {
setTopShadow(tsv);
}
render() {
const { shadow, ...rest } = this.props;
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}
/>
);
return (
<Wrapper onScroll={this.handleScroll} shadow={this.shadow} {...rest} />
);
}
}
const Wrapper = styled.div`
@@ -55,20 +31,9 @@ const Wrapper = styled.div`
overflow-x: hidden;
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
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;
box-shadow: ${(props) =>
props.shadow ? "0 1px inset rgba(0,0,0,.1)" : "none"};
transition: all 250ms ease-in-out;
`;
export default observer(Scrollable);
export default Scrollable;
+160 -160
View File
@@ -1,5 +1,6 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import {
ArchiveIcon,
HomeIcon,
@@ -9,11 +10,14 @@ import {
ShapesIcon,
TrashIcon,
PlusIcon,
SettingsIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { withTranslation, type TFunction } 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";
@@ -25,179 +29,175 @@ 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";
function MainSidebar() {
const { t } = useTranslation();
const { policies, auth, documents } = useStores();
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
const [
createCollectionModalOpen,
setCreateCollectionModalOpen,
] = React.useState(false);
type Props = {
auth: AuthStore,
documents: DocumentsStore,
policies: PoliciesStore,
t: TFunction,
};
React.useEffect(() => {
documents.fetchDrafts();
documents.fetchTemplates();
}, [documents]);
@observer
class MainSidebar extends React.Component<Props> {
@observable inviteModalOpen = false;
@observable createCollectionModalOpen = false;
const handleCreateCollectionModalOpen = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
setCreateCollectionModalOpen(true);
},
[]
);
componentDidMount() {
this.props.documents.fetchDrafts();
this.props.documents.fetchTemplates();
}
const handleCreateCollectionModalClose = React.useCallback(() => {
setCreateCollectionModalOpen(false);
}, []);
const handleInviteModalOpen = React.useCallback((ev: SyntheticEvent<>) => {
handleCreateCollectionModalOpen = (ev: SyntheticEvent<>) => {
ev.preventDefault();
setInviteModalOpen(true);
}, []);
this.createCollectionModalOpen = true;
};
const handleInviteModalClose = React.useCallback(() => {
setInviteModalOpen(false);
}, []);
handleCreateCollectionModalClose = (ev: SyntheticEvent<>) => {
this.createCollectionModalOpen = false;
};
const { user, team } = auth;
if (!user || !team) return null;
handleInviteModalOpen = (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.inviteModalOpen = true;
};
const can = policies.abilities(team.id);
handleInviteModalClose = () => {
this.inviteModalOpen = false;
};
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")}
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
/>
<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 && (
}
/>
<Flex auto column>
<Scrollable shadow>
<Section>
<SidebarLink
to="/settings/people"
onClick={handleInviteModalOpen}
icon={<PlusIcon color="currentColor" />}
label={`${t("Invite people")}`}
to="/home"
icon={<HomeIcon color="currentColor" />}
exact={false}
label={t("Home")}
/>
)}
</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>
);
<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>
);
}
}
const Secondary = styled.div`
overflow-x: hidden;
flex-shrink: 0;
`;
const Drafts = styled(Flex)`
height: 24px;
`;
export default observer(MainSidebar);
export default withTranslation()<MainSidebar>(
inject("documents", "policies", "auth")(MainSidebar)
);
+120 -108
View File
@@ -1,5 +1,5 @@
// @flow
import { observer } from "mobx-react";
import { observer, inject } from "mobx-react";
import {
DocumentIcon,
EmailIcon,
@@ -13,9 +13,11 @@ import {
ExpandedIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { withTranslation, type TFunction } from "react-i18next";
import type { RouterHistory } 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";
@@ -28,123 +30,131 @@ 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";
function SettingsSidebar() {
const { t } = useTranslation();
const history = useHistory();
const team = useCurrentTeam();
const { policies } = useStores();
const can = policies.abilities(team.id);
type Props = {
history: RouterHistory,
policies: PoliciesStore,
auth: AuthStore,
t: TFunction,
};
const returnToDashboard = React.useCallback(() => {
history.push("/home");
}, [history]);
@observer
class SettingsSidebar extends React.Component<Props> {
returnToDashboard = () => {
this.props.history.push("/home");
};
return (
<Sidebar>
<HeaderBlock
subheading={
<ReturnToApp align="center">
<BackIcon color="currentColor" /> {t("Return to App")}
</ReturnToApp>
}
teamName={team.name}
logoUrl={team.avatarUrl}
onClick={returnToDashboard}
/>
render() {
const { policies, t, auth } = this.props;
const { team } = auth;
if (!team) return null;
<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 && (
<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 && (
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>{t("Integrations")}</Header>
<Header>Account</Header>
<SidebarLink
to="/settings/integrations/slack"
icon={<SlackIcon color="currentColor" />}
label="Slack"
to="/settings"
icon={<ProfileIcon color="currentColor" />}
label={t("Profile")}
/>
{isHosted && (
<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/integrations/zapier"
icon={<ZapierIcon color="currentColor" />}
label="Zapier"
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 && !isHosted && (
<Section>
<Header>{t("Installation")}</Header>
<Version />
</Section>
)}
</Scrollable>
</Flex>
</Sidebar>
);
{can.update && (
<Section>
<Header>{t("Integrations")}</Header>
<SidebarLink
to="/settings/integrations/slack"
icon={<SlackIcon color="currentColor" />}
label="Slack"
/>
{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>
);
}
}
const BackIcon = styled(ExpandedIcon)`
@@ -156,4 +166,6 @@ const ReturnToApp = styled(Flex)`
height: 16px;
`;
export default observer(SettingsSidebar);
export default withTranslation()<SettingsSidebar>(
inject("auth", "policies")(SettingsSidebar)
);
+63 -164
View File
@@ -1,171 +1,55 @@
// @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, { useTheme } from "styled-components";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Fade from "components/Fade";
import Flex from "components/Flex";
import CollapseToggle, {
Button as CollapseButton,
} from "./components/CollapseToggle";
import ResizeBorder from "./components/ResizeBorder";
import ResizeHandle from "./components/ResizeHandle";
import CollapseToggle, { Button } from "./components/CollapseToggle";
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
style={style}
$sidebarWidth={ui.sidebarWidth}
$isAnimating={isAnimating}
$isSmallerThanMinimum={isSmallerThanMinimum}
$mobileSidebarVisible={ui.mobileSidebarVisible}
$collapsed={collapsed}
mobileSidebarVisible={ui.mobileSidebarVisible}
collapsed={ui.editMode || ui.sidebarCollapsed}
column
>
{!isResizing && (
<CollapseToggle
collapsed={ui.sidebarCollapsed}
onClick={ui.toggleCollapsedSidebar}
/>
)}
{ui.mobileSidebarVisible && (
<Portal>
<Fade>
<Background onClick={ui.toggleMobileSidebar} />
</Fade>
</Portal>
)}
<CollapseToggle
collapsed={ui.sidebarCollapsed}
onClick={ui.toggleCollapsedSidebar}
/>
<Toggle
onClick={ui.toggleMobileSidebar}
mobileSidebarVisible={ui.mobileSidebarVisible}
>
{ui.mobileSidebarVisible ? (
<CloseIcon size={32} />
) : (
<MenuIcon size={32} />
)}
</Toggle>
{children}
{!ui.sidebarCollapsed && (
<ResizeBorder
onMouseDown={handleStartDrag}
onDoubleClick={handleReset}
$isResizing={isResizing}
>
<ResizeHandle aria-label={t("Resize sidebar")} />
</ResizeBorder>
)}
</Container>
);
@@ -178,67 +62,82 @@ 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, 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%")};
transition: box-shadow, 100ms, ease-in-out, left 100ms ease-out,
${(props) => props.theme.backgroundTransition};
margin-left: ${(props) => (props.mobileSidebarVisible ? 0 : "-100%")};
z-index: ${(props) => props.theme.depths.sidebar};
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 !important;
left: 0;
box-shadow: ${(props) =>
props.$collapsed
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
: props.$isSmallerThanMinimum
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
: "none"};
props.collapsed ? "rgba(0, 0, 0, 0.2) 1px 0 4px" : "none"};
& ${CollapseButton} {
& ${Button} {
opacity: .75;
}
& ${CollapseButton}:hover {
& ${Button}: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?: (event: SyntheticEvent<>) => void,
onClick?: () => void,
|};
function CollapseToggle({ collapsed, ...rest }: Props) {
@@ -21,7 +21,7 @@ function CollapseToggle({ collapsed, ...rest }: Props) {
delay={500}
placement="bottom"
>
<Button {...rest} tabIndex="-1" aria-hidden>
<Button {...rest} 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: transparent;
background: ${(props) => props.theme.sidebarItemBackground};
transition: opacity 100ms ease-in-out;
border-radius: 4px;
opacity: 0;
@@ -2,19 +2,16 @@
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,
@@ -42,14 +39,11 @@ function CollectionLink({
const { documents, policies } = useStores();
const expanded = collection.id === ui.activeCollectionId;
const manualSort = collection.sort.field === "index";
const can = policies.abilities(collection.id);
// Drop to re-parent
// Droppable
const [{ isOver, canDrop }, drop] = useDrop({
accept: "document",
drop: (item, monitor) => {
if (monitor.didDrop()) return;
if (!collection) return;
documents.move(item.id, collection.id);
},
@@ -57,28 +51,16 @@ function CollectionLink({
return policies.abilities(collection.id).update;
},
collect: (monitor) => ({
isOver: !!monitor.isOver({ shallow: true }),
isOver: !!monitor.isOver(),
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} style={{ position: "relative" }}>
<div ref={drop}>
<DropToImport key={collection.id} collectionId={collection.id}>
<SidebarLinkWithPadding
<SidebarLink
key={collection.id}
to={collection.url}
icon={
@@ -86,7 +68,7 @@ function CollectionLink({
}
iconColor={collection.color}
expanded={expanded}
showActions={menuOpen || expanded}
menuOpen={menuOpen}
isActiveDrop={isOver && canDrop}
label={
<EditableTitle
@@ -97,30 +79,19 @@ function CollectionLink({
}
exact={false}
menu={
<>
{can.update && (
<CollectionSortMenuWithMargin
collection={collection}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
)}
<CollectionMenu
collection={collection}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</>
<CollectionMenu
position="right"
collection={collection}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
}
/>
></SidebarLink>
</DropToImport>
{expanded && manualSort && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</div>
{expanded &&
collection.documents.map((node, index) => (
collection.documents.map((node) => (
<DocumentLink
key={node.id}
node={node}
@@ -129,19 +100,10 @@ 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);
+58 -126
View File
@@ -9,7 +9,6 @@ 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";
@@ -21,21 +20,19 @@ 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,
index,
parentId,
canUpdate,
}: Props) {
const { documents, policies } = useStores();
const { t } = useTranslation();
@@ -79,14 +76,6 @@ 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();
@@ -119,7 +108,6 @@ 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({
@@ -132,131 +120,77 @@ function DocumentLink({
},
});
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({
// Droppable
const [{ isOver, canDrop }, drop] = useDrop({
accept: "document",
drop: async (item, monitor) => {
if (monitor.didDrop()) return;
if (!collection) return;
documents.move(item.id, collection.id, node.id);
},
canDrop: (item, monitor) =>
pathToNode && !pathToNode.includes(monitor.getItem().id),
hover: (item, monitor) => {
// Enables expansion of document children when hovering over the document
// for more than half a second.
if (
hasChildDocuments &&
monitor.canDrop() &&
monitor.isOver({ shallow: true })
) {
if (!hoverExpanding.current) {
hoverExpanding.current = setTimeout(() => {
hoverExpanding.current = null;
if (monitor.isOver({ shallow: true })) {
setExpanded(true);
}
}, 500);
}
}
},
collect: (monitor) => ({
isOverReparent: !!monitor.isOver({ shallow: true }),
canDropToReparent: monitor.canDrop(),
}),
});
// Drop to reorder
const [{ isOverReorder }, dropToReorder] = useDrop({
accept: "document",
drop: async (item, monitor) => {
if (!collection) return;
if (item.id === node.id) return;
if (expanded) {
documents.move(item.id, collection.id, node.id, 0);
return;
}
documents.move(item.id, collection.id, parentId, index + 1);
},
collect: (monitor) => ({
isOverReorder: !!monitor.isOver(),
isOver: !!monitor.isOver(),
canDrop: monitor.canDrop(),
}),
});
return (
<>
<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}
<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}
/>
</>
}
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>
)}
<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>
{expanded && !isDragging && (
<>
{node.children.map((childNode, index) => (
{node.children.map((childNode) => (
<ObservedDocumentLink
key={childNode.id}
collection={collection}
@@ -265,8 +199,6 @@ function DocumentLink({
prefetchDocument={prefetchDocument}
depth={depth + 1}
canUpdate={canUpdate}
index={index}
parentId={node.id}
/>
))}
</>
@@ -1,42 +0,0 @@
// @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,9 +52,7 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
setOriginalValue(value);
} catch (error) {
setValue(originalValue);
ui.showToast(error.message, {
type: "error",
});
ui.showToast(error.message);
throw error;
}
}
@@ -5,35 +5,33 @@ 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,
|};
};
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>
)
);
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 StyledExpandedIcon = styled(ExpandedIcon)`
position: absolute;
@@ -46,7 +44,6 @@ const Subheading = styled.div`
font-size: 11px;
text-transform: uppercase;
font-weight: 500;
white-space: nowrap;
color: ${(props) => props.theme.sidebarText};
`;
@@ -56,20 +53,16 @@ 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;
@@ -1,105 +0,0 @@
// @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;
@@ -1,28 +0,0 @@
// @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;
@@ -1,39 +0,0 @@
// @flow
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
const ResizeHandle = styled.button`
opacity: 0;
transition: opacity 100ms ease-in-out;
transform: translateY(-50%);
position: absolute;
top: 50%;
height: 40px;
right: -10px;
width: 8px;
padding: 0;
border: 0;
background: ${(props) => props.theme.sidebarBackground};
border-radius: 8px;
pointer-events: none;
&:after {
content: "";
position: absolute;
top: -24px;
bottom: -24px;
left: -12px;
right: -12px;
}
&:active {
background: ${(props) => props.theme.sidebarText};
}
${breakpoint("tablet")`
pointer-events: all;
cursor: ew-resize;
`}
`;
export default ResizeHandle;
+1 -3
View File
@@ -5,9 +5,7 @@ import Flex from "components/Flex";
const Section = styled(Flex)`
position: relative;
flex-direction: column;
margin: 20px 8px;
min-width: ${(props) => props.theme.sidebarMinWidth}px;
flex-shrink: 0;
margin: 24px 8px;
`;
export default Section;
@@ -1,10 +1,7 @@
// @flow
import * as React from "react";
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
import { withRouter, NavLink } 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 = {
@@ -13,17 +10,14 @@ 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,
showActions?: boolean,
menuOpen?: boolean,
iconColor?: string,
active?: boolean,
isActiveDrop?: boolean,
history: RouterHistory,
match: Match,
theme: Theme,
exact?: boolean,
depth?: number,
@@ -39,14 +33,13 @@ function SidebarLink({
active,
isActiveDrop,
menu,
showActions,
menuOpen,
theme,
exact,
href,
innerRef,
depth,
history,
match,
className,
...rest
}: Props) {
const style = React.useMemo(() => {
return {
@@ -55,20 +48,16 @@ function SidebarLink({
}, [depth]);
const activeStyle = {
fontWeight: 600,
color: theme.text,
fontWeight: 600,
background: theme.sidebarItemBackground,
...style,
};
const activeDropStyle = {
fontWeight: 600,
};
return (
<Link
<StyledNavLink
$isActiveDrop={isActiveDrop}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
activeStyle={isActiveDrop ? undefined : activeStyle}
style={active ? activeStyle : style}
onClick={onClick}
onMouseEnter={onMouseEnter}
@@ -76,12 +65,12 @@ function SidebarLink({
to={to}
as={to ? undefined : href ? "a" : "div"}
href={href}
className={className}
ref={innerRef}
>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
{menu && <Actions showActions={showActions}>{menu}</Actions>}
</Link>
{menu && <Action menuOpen={menuOpen}>{menu}</Action>}
</StyledNavLink>
);
}
@@ -90,49 +79,42 @@ const IconWrapper = styled.span`
margin-left: -4px;
margin-right: 4px;
height: 24px;
overflow: hidden;
flex-shrink: 0;
`;
const Actions = styled(EventBoundary)`
display: ${(props) => (props.showActions ? "inline-flex" : "none")};
const Action = styled.span`
display: ${(props) => (props.menuOpen ? "inline" : "none")};
position: absolute;
top: 4px;
right: 4px;
color: ${(props) => props.theme.textTertiary};
transition: opacity 50ms;
svg {
color: ${(props) => props.theme.textSecondary};
fill: currentColor;
opacity: 0.5;
opacity: 0.75;
}
&:hover {
svg {
opacity: 0.75;
opacity: 1;
}
}
`;
const Link = styled(NavLink)`
const StyledNavLink = styled(NavLink)`
display: flex;
position: relative;
overflow: hidden;
text-overflow: ellipsis;
padding: 6px 16px;
padding: 4px 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 {
@@ -145,20 +127,11 @@ const Link = styled(NavLink)`
background: ${(props) => props.theme.black05};
}
&:hover,
&:active {
> ${Actions} {
display: inline-flex;
svg {
opacity: 0.75;
}
&:hover {
> ${Action} {
display: inline;
}
}
${breakpoint("tablet")`
padding: 4px 16px;
`}
`;
const Label = styled.div`
-8
View File
@@ -1,8 +0,0 @@
// @flow
import * as React from "react";
export const id = "skip-nav";
export default function SkipNavContent() {
return <div id={id} />;
}
-34
View File
@@ -1,34 +0,0 @@
// @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;
}
`;
+1 -3
View File
@@ -110,9 +110,7 @@ class SocketProvider extends React.Component<Props> {
this.socket.on("unauthorized", (err) => {
this.socket.authenticated = false;
ui.showToast(err.message, {
type: "error",
});
ui.showToast(err.message);
throw err;
});
-66
View File
@@ -1,66 +0,0 @@
// @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;
+2 -5
View File
@@ -3,15 +3,12 @@ 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 -5
View File
@@ -2,15 +2,12 @@
import styled from "styled-components";
const TeamLogo = styled.img`
width: ${(props) =>
props.width ? `${props.width}px` : props.size || "auto"};
height: ${(props) =>
props.height ? `${props.height}px` : props.size || "38px"};
width: ${(props) => props.size || "auto"};
height: ${(props) => 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;
+44 -9
View File
@@ -1,8 +1,25 @@
// @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";
const LocaleTime = React.lazy(() => import("components/LocaleTime"));
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,
@@ -13,11 +30,29 @@ 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: props.addSuffix,
addSuffix,
locale: auth.user ? auth.user.language : undefined,
});
if (props.shorten) {
if (shorten) {
content = content
.replace("about", "")
.replace("less than a minute ago", "just now")
@@ -25,13 +60,13 @@ function Time(props: Props) {
}
return (
<React.Suspense
fallback={
<time dateTime={props.dateTime}>{props.children || content}</time>
}
<Tooltip
tooltip={format(props.dateTime, "MMMM Do, YYYY h:mm a")}
delay={props.tooltipDelay}
placement="bottom"
>
<LocaleTime {...props} />
</React.Suspense>
<time dateTime={props.dateTime}>{props.children || content}</time>
</Tooltip>
);
}
+22 -16
View File
@@ -1,24 +1,30 @@
// @flow
import { observer } from "mobx-react";
import { observer, inject } 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";
function Toasts() {
const { ui } = useStores();
type Props = {
ui: UiStore,
};
@observer
class Toasts extends React.Component<Props> {
render() {
const { ui } = this.props;
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`
@@ -31,4 +37,4 @@ const List = styled.ol`
z-index: ${(props) => props.theme.depths.toasts};
`;
export default observer(Toasts);
export default inject("ui")(Toasts);
+42 -55
View File
@@ -1,61 +1,58 @@
// @flow
import { CheckboxIcon, InfoIcon, WarningIcon } from "outline-icons";
import { darken } from "polished";
import * as React from "react";
import styled, { css } from "styled-components";
import { fadeAndScaleIn, pulse } from "shared/styles/animations";
import styled from "styled-components";
import { fadeAndScaleIn } from "shared/styles/animations";
import type { Toast as TToast } from "types";
type Props = {
onRequestClose: () => void,
closeAfterMs?: number,
closeAfterMs: number,
toast: TToast,
};
function Toast({ closeAfterMs = 3000, onRequestClose, toast }: Props) {
const timeout = React.useRef();
const [pulse, setPulse] = React.useState(false);
const { action, type = "info", reoccurring } = toast;
class Toast extends React.Component<Props> {
timeout: TimeoutID;
React.useEffect(() => {
timeout.current = setTimeout(onRequestClose, toast.timeout || closeAfterMs);
static defaultProps = {
closeAfterMs: 3000,
};
return () => clearTimeout(timeout.current);
}, [onRequestClose, toast, closeAfterMs]);
componentDidMount() {
this.timeout = setTimeout(
this.props.onRequestClose,
this.props.toast.timeout || this.props.closeAfterMs
);
}
React.useEffect(() => {
if (reoccurring) {
setPulse(reoccurring);
componentWillUnmount() {
clearTimeout(this.timeout);
}
// must match animation time in css below vvv
setTimeout(() => setPulse(false), 250);
}
}, [reoccurring]);
render() {
const { toast, onRequestClose } = this.props;
const { action } = toast;
const message =
typeof toast.message === "string"
? toast.message
: toast.message.toString();
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>
);
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 Action = styled.span`
@@ -74,20 +71,11 @@ const Action = styled.span`
}
`;
const ListItem = styled.li`
${(props) =>
props.$pulse &&
css`
animation: ${pulse} 250ms;
`}
`;
const Container = styled.div`
display: inline-flex;
display: inline-block;
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;
@@ -101,8 +89,7 @@ const Container = styled.div`
const Message = styled.div`
display: inline-block;
font-weight: 500;
padding: 10px 4px;
padding: 10px 12px;
`;
export default Toast;
+2 -2
View File
@@ -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() {
+13
View File
@@ -0,0 +1,13 @@
// @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 -2
View File
@@ -1,6 +1,5 @@
// @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/(.*)$");
@@ -21,7 +20,7 @@ export default class GoogleDocs extends React.Component<Props> {
{...this.props}
src={this.props.attrs.href.replace("/edit", "/preview")}
icon={
<Image
<img
src="/images/google-docs.png"
alt="Google Docs Icon"
width={16}
-39
View File
@@ -1,39 +0,0 @@
// @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
/>
);
}
}
-29
View File
@@ -1,29 +0,0 @@
/* 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);
});
});
-38
View File
@@ -1,38 +0,0 @@
// @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
/>
);
}
}
-39
View File
@@ -1,39 +0,0 @@
/* 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 -2
View File
@@ -1,6 +1,5 @@
// @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/(.*)$");
@@ -21,7 +20,7 @@ export default class GoogleSlides extends React.Component<Props> {
{...this.props}
src={this.props.attrs.href.replace("/edit", "/preview")}
icon={
<Image
<img
src="/images/google-sheets.png"
alt="Google Sheets Icon"
width={16}
+1 -2
View File
@@ -1,6 +1,5 @@
// @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/(.*)$");
@@ -23,7 +22,7 @@ export default class GoogleSlides extends React.Component<Props> {
.replace("/edit", "/preview")
.replace("/pub", "/embed")}
icon={
<Image
<img
src="/images/google-slides.png"
alt="Google Slides Icon"
width={16}
+4 -6
View File
@@ -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,15 +16,13 @@ export default class RealtimeBoard extends React.Component<Props> {
render() {
const { matches } = this.props.attrs;
const domain = matches[1];
const boardId = matches[2];
const titleName = domain === "realtimeboard" ? "RealtimeBoard" : "Miro";
const boardId = matches[1];
return (
<Frame
{...this.props}
src={`https://${domain}.com/app/embed/${boardId}`}
title={`${titleName} (${boardId})`}
src={`https://realtimeboard.com/app/embed/${boardId}`}
title={`RealtimeBoard (${boardId})`}
/>
);
}
-6
View File
@@ -13,12 +13,6 @@ 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
View File
@@ -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: {|
+11 -11
View File
@@ -6,17 +6,6 @@ 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,
@@ -140,6 +129,17 @@ 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} />
));
+1 -18
View File
@@ -1,7 +1,6 @@
// @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";
@@ -10,8 +9,6 @@ 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";
@@ -40,7 +37,7 @@ function matcher(Component) {
};
}
const Img = styled(Image)`
const Img = styled.img`
margin: 4px;
width: 18px;
height: 18px;
@@ -96,20 +93,6 @@ 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" />,
-9
View File
@@ -1,9 +0,0 @@
// @flow
import invariant from "invariant";
import useStores from "./useStores";
export default function useCurrentTeam() {
const { auth } = useStores();
invariant(auth.team, "team required");
return auth.team;
}
-12
View File
@@ -1,12 +0,0 @@
// @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];
}
-31
View File
@@ -1,31 +0,0 @@
// @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;
}
+9 -15
View File
@@ -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 { Router } from "react-router-dom";
import { BrowserRouter as Router } from "react-router-dom";
import { initI18n } from "shared/i18n";
import stores from "stores";
import ErrorBoundary from "components/ErrorBoundary";
@@ -15,24 +15,18 @@ 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(
<Provider {...stores}>
<Theme>
<ErrorBoundary>
<ErrorBoundary>
<Provider {...stores}>
<Theme>
<DndProvider backend={HTML5Backend}>
<Router history={history}>
<Router>
<>
<ScrollToTop>
<Routes />
@@ -41,9 +35,9 @@ if (element) {
</>
</Router>
</DndProvider>
</ErrorBoundary>
</Theme>
</Provider>,
</Theme>
</Provider>
</ErrorBoundary>,
element
);
}
+112 -106
View File
@@ -1,128 +1,134 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { SunIcon, MoonIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { withTranslation, type TFunction } 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";
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";
} from "../../shared/utils/routeHelpers";
type Props = {|
children: (props: any) => React.Node,
|};
type Props = {
label: React.Node,
ui: UiStore,
auth: AuthStore,
t: TFunction,
};
const AppearanceMenu = React.forwardRef((props, ref) => {
const { ui } = useStores();
const { t } = useTranslation();
const menu = useMenuState();
@observer
class AccountMenu extends React.Component<Props> {
@observable keyboardShortcutsOpen: boolean = false;
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"}
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")}
>
{t("System")}
</MenuItem>
<MenuItem
{...menu}
onClick={() => ui.setTheme("light")}
selected={ui.theme === "light"}
<KeyboardShortcuts />
</Modal>
<DropdownMenu
style={{ marginRight: 10, marginTop: -10 }}
label={this.props.label}
>
{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>
</>
);
<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>
</>
);
}
}
const ChangeTheme = styled(Flex)`
width: 100%;
`;
export default observer(AccountMenu);
export default withTranslation()<AccountMenu>(
inject("ui", "auth")(AccountMenu)
);
-34
View File
@@ -1,34 +0,0 @@
// @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>
</>
);
}
-44
View File
@@ -1,44 +0,0 @@
// @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
View File
@@ -1,221 +1,221 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { useMenuState, MenuButton } from "reakit/Menu";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import { 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 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 ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
import VisuallyHidden from "components/VisuallyHidden";
import getDataTransferFiles from "utils/getDataTransferFiles";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {|
type Props = {
position?: "left" | "right" | "center",
ui: UiStore,
policies: PoliciesStore,
documents: DocumentsStore,
collection: Collection,
placement?: string,
modal?: boolean,
label?: (any) => React.Node,
history: RouterHistory,
onOpen?: () => void,
onClose?: () => void,
|};
t: TFunction,
};
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();
@observer
class CollectionMenu extends React.Component<Props> {
file: ?HTMLInputElement;
@observable showCollectionMembers = false;
@observable showCollectionEdit = false;
@observable showCollectionDelete = false;
@observable showCollectionExport = false;
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);
onNewDocument = (ev: SyntheticEvent<>) => {
ev.preventDefault();
const { collection } = this.props;
this.props.history.push(newDocumentUrl(collection.id));
};
const handleOpen = React.useCallback(() => {
setRenderModals(true);
if (onOpen) {
onOpen();
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);
}
}, [onOpen]);
};
const handleNewDocument = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
history.push(newDocumentUrl(collection.id));
},
[history, collection.id]
);
handleEditCollectionOpen = (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.showCollectionEdit = true;
};
const handleImportDocument = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
handleEditCollectionClose = () => {
this.showCollectionEdit = false;
};
// simulate a click on the file upload input element
if (file.current) {
file.current.click();
}
},
[file]
);
handleDeleteCollectionOpen = (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.showCollectionDelete = true;
};
const handleFilePicked = React.useCallback(
async (ev: SyntheticEvent<>) => {
const files = getDataTransferFiles(ev);
handleDeleteCollectionClose = () => {
this.showCollectionDelete = false;
};
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]
);
handleExportCollectionOpen = (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.showCollectionExport = true;
};
const can = policies.abilities(collection.id);
handleExportCollectionClose = () => {
this.showCollectionExport = false;
};
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>
</>
)}
</>
);
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>
</>
);
}
}
export default observer(CollectionMenu);
export default withTranslation()<CollectionMenu>(
inject("ui", "documents", "policies")(withRouter(CollectionMenu))
);
-72
View File
@@ -1,72 +0,0 @@
// @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);
+327 -304
View File
@@ -1,21 +1,21 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { useMenuState, MenuButton } from "reakit/Menu";
import styled from "styled-components";
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 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 ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
import Flex from "components/Flex";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
import {
documentHistoryUrl,
documentMoveUrl,
@@ -24,325 +24,348 @@ import {
newDocumentUrl,
} from "utils/routeHelpers";
type Props = {|
type Props = {
ui: UiStore,
auth: AuthStore,
position?: "left" | "right" | "center",
document: Document,
collections: CollectionStore,
policies: PoliciesStore,
className: string,
isRevision?: boolean,
showPrint?: boolean,
modal?: boolean,
showToggleEmbeds?: boolean,
showPin?: boolean,
label?: (any) => React.Node,
label?: React.Node,
onOpen?: () => void,
onClose?: () => void,
|};
t: TFunction,
};
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);
@observer
class DocumentMenu extends React.Component<Props> {
@observable redirectTo: ?string;
@observable showDeleteModal = false;
@observable showTemplateModal = false;
@observable showShareModal = false;
const handleOpen = React.useCallback(() => {
setRenderModals(true);
if (onOpen) {
onOpen();
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);
}
}, [onOpen]);
};
const handleDuplicate = React.useCallback(
async (ev: SyntheticEvent<>) => {
const duped = await document.duplicate();
handleMove = (ev: SyntheticEvent<>) => {
this.redirectTo = documentMoveUrl(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]
);
handleEdit = (ev: SyntheticEvent<>) => {
this.redirectTo = editDocumentUrl(this.props.document);
};
const handleArchive = React.useCallback(
async (ev: SyntheticEvent<>) => {
await document.archive();
ui.showToast(t("Document archived"), { type: "success" });
},
[ui, t, document]
);
handleDuplicate = async (ev: SyntheticEvent<>) => {
const duped = await this.props.document.duplicate();
const handleRestore = React.useCallback(
async (ev: SyntheticEvent<>, options?: { collectionId: string }) => {
await document.restore(options);
ui.showToast(t("Document restored"), { 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 handleUnpublish = React.useCallback(
async (ev: SyntheticEvent<>) => {
await document.unpublish();
ui.showToast(t("Document unpublished"), { type: "success" });
},
[ui, t, document]
);
handleOpenTemplateModal = () => {
this.showTemplateModal = true;
};
const handlePrint = React.useCallback((ev: SyntheticEvent<>) => {
window.print();
}, []);
handleCloseTemplateModal = () => {
this.showTemplateModal = false;
};
const handleStar = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
document.star();
},
[document]
);
handleCloseDeleteModal = () => {
this.showDeleteModal = false;
};
const handleUnstar = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
document.unstar();
},
[document]
);
handleArchive = async (ev: SyntheticEvent<>) => {
await this.props.document.archive();
const { t } = this.props;
this.props.ui.showToast(t("Document archived"));
};
const handleShareLink = React.useCallback(
async (ev: SyntheticEvent<>) => {
await document.share();
setShowShareModal(true);
},
[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 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);
handleUnpublish = async (ev: SyntheticEvent<>) => {
await this.props.document.unpublish();
const { t } = this.props;
this.props.ui.showToast(t("Document unpublished"));
};
return (
<>
{label ? (
<MenuButton {...menu}>{label}</MenuButton>
) : (
<OverflowMenuButton
handlePin = (ev: SyntheticEvent<>) => {
this.props.document.pin();
};
handleUnpin = (ev: SyntheticEvent<>) => {
this.props.document.unpin();
};
handleStar = (ev: SyntheticEvent<>) => {
ev.stopPropagation();
this.props.document.star();
};
handleUnstar = (ev: SyntheticEvent<>) => {
ev.stopPropagation();
this.props.document.unstar();
};
handleExport = (ev: SyntheticEvent<>) => {
this.props.document.download();
};
handleShareLink = async (ev: SyntheticEvent<>) => {
const { document } = this.props;
await document.share();
this.showShareModal = true;
};
handleCloseShareModal = () => {
this.showShareModal = false;
};
render() {
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
const {
policies,
document,
position,
className,
showToggleEmbeds,
showPrint,
showPin,
auth,
collections,
label,
onOpen,
onClose,
t,
} = this.props;
const can = policies.abilities(document.id);
const canShareDocuments = !!(can.share && auth.team && auth.team.sharing);
const canViewHistory = can.read && !can.restore;
const collection = collections.get(document.collectionId);
return (
<>
<DropdownMenu
className={className}
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,
position={position}
onOpen={onOpen}
onClose={onClose}
label={label}
>
<DropdownMenuItems
items={[
{
title: t("Restore"),
visible: !!can.unarchive,
onClick: this.handleRestore,
},
hover: true,
items: [
{
type: "heading",
title: t("Choose a collection"),
{
title: t("Restore"),
visible: !!(collection && can.restore),
onClick: this.handleRestore,
},
{
title: `${t("Restore")}`,
visible: !collection && !!can.restore,
style: {
left: -170,
position: "relative",
top: -40,
},
...collections.orderedData.map((collection) => {
const can = policies.abilities(collection.id);
hover: true,
items: [
{
type: "heading",
title: t("Choose a collection"),
},
...collections.orderedData.map((collection) => {
const can = policies.abilities(collection.id);
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>
</>
)}
</>
);
return {
title: (
<>
<CollectionIcon collection={collection} />
&nbsp;{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>
</>
);
}
}
const CollectionName = styled.div`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`;
export default observer(DocumentMenu);
export default withTranslation()<DocumentMenu>(
inject("ui", "auth", "collections", "policies")(DocumentMenu)
);
-36
View File
@@ -1,36 +0,0 @@
// @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);
+95 -61
View File
@@ -1,74 +1,108 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
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 Group from "models/Group";
import GroupDelete from "scenes/GroupDelete";
import GroupEdit from "scenes/GroupEdit";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
type Props = {|
type Props = {
ui: UiStore,
policies: PoliciesStore,
group: Group,
history: RouterHistory,
onMembers: () => void,
|};
onOpen?: () => void,
onClose?: () => void,
t: TFunction,
};
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);
@observer
class GroupMenu extends React.Component<Props> {
@observable editModalOpen: boolean = false;
@observable deleteModalOpen: boolean = false;
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>
</>
);
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>
</>
);
}
}
export default observer(GroupMenu);
export default withTranslation()<GroupMenu>(
inject("policies")(withRouter(GroupMenu))
);

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