Merge main
This commit is contained in:
@@ -11,11 +11,6 @@ export const Action = styled(Flex)`
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
|
||||
a {
|
||||
color: ${(props) => props.theme.text};
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -20,8 +20,10 @@ const Authenticated = ({ children }: Props) => {
|
||||
// Watching for language changes here as this is the earliest point we have
|
||||
// the user available and means we can start loading translations faster
|
||||
React.useEffect(() => {
|
||||
if (i18n.language !== language) {
|
||||
i18n.changeLanguage(language);
|
||||
if (language && i18n.language !== language) {
|
||||
// Languages are stored in en_US format in the database, however the
|
||||
// frontend translation framework (i18next) expects en-US
|
||||
i18n.changeLanguage(language.replace("_", "-"));
|
||||
}
|
||||
}, [i18n, language]);
|
||||
|
||||
|
||||
@@ -3,13 +3,17 @@ import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import User from "models/User";
|
||||
import placeholder from "./placeholder.png";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
src: string,
|
||||
size: number,
|
||||
icon?: React.Node,
|
||||
};
|
||||
user?: User,
|
||||
onClick?: () => void,
|
||||
className?: string,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class Avatar extends React.Component<Props> {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
ArchiveIcon,
|
||||
EditIcon,
|
||||
GoToIcon,
|
||||
MoreIcon,
|
||||
PadlockIcon,
|
||||
ShapesIcon,
|
||||
TrashIcon,
|
||||
@@ -14,20 +13,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 = {
|
||||
type Props = {|
|
||||
document: Document,
|
||||
collections: CollectionsStore,
|
||||
children?: React.Node,
|
||||
onlyText: boolean,
|
||||
};
|
||||
|};
|
||||
|
||||
function Icon({ document }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -35,11 +32,11 @@ function Icon({ document }) {
|
||||
if (document.isDeleted) {
|
||||
return (
|
||||
<>
|
||||
<CollectionName to="/trash">
|
||||
<CategoryName to="/trash">
|
||||
<TrashIcon color="currentColor" />
|
||||
|
||||
<span>{t("Trash")}</span>
|
||||
</CollectionName>
|
||||
</CategoryName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
@@ -47,11 +44,11 @@ function Icon({ document }) {
|
||||
if (document.isArchived) {
|
||||
return (
|
||||
<>
|
||||
<CollectionName to="/archive">
|
||||
<CategoryName to="/archive">
|
||||
<ArchiveIcon color="currentColor" />
|
||||
|
||||
<span>{t("Archive")}</span>
|
||||
</CollectionName>
|
||||
</CategoryName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
@@ -59,11 +56,11 @@ function Icon({ document }) {
|
||||
if (document.isDraft) {
|
||||
return (
|
||||
<>
|
||||
<CollectionName to="/drafts">
|
||||
<CategoryName to="/drafts">
|
||||
<EditIcon color="currentColor" />
|
||||
|
||||
<span>{t("Drafts")}</span>
|
||||
</CollectionName>
|
||||
</CategoryName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
@@ -71,11 +68,11 @@ function Icon({ document }) {
|
||||
if (document.isTemplate) {
|
||||
return (
|
||||
<>
|
||||
<CollectionName to="/templates">
|
||||
<CategoryName to="/templates">
|
||||
<ShapesIcon color="currentColor" />
|
||||
|
||||
<span>{t("Templates")}</span>
|
||||
</CollectionName>
|
||||
</CategoryName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
@@ -83,14 +80,16 @@ function Icon({ document }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Breadcrumb = ({ document, onlyText }: Props) => {
|
||||
const Breadcrumb = ({ document, children, onlyText }: Props) => {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return <Wrapper />;
|
||||
}
|
||||
|
||||
let collection = collections.get(document.collectionId);
|
||||
if (!collection) {
|
||||
if (!document.deletedAt) return <div />;
|
||||
|
||||
collection = {
|
||||
id: document.collectionId,
|
||||
name: t("Deleted Collection"),
|
||||
@@ -135,7 +134,7 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
|
||||
</CollectionName>
|
||||
{isNestedDocument && (
|
||||
<>
|
||||
<Slash /> <BreadcrumbMenu label={<Overflow />} path={menuPath} />
|
||||
<Slash /> <BreadcrumbMenu path={menuPath} />
|
||||
</>
|
||||
)}
|
||||
{lastPath && (
|
||||
@@ -146,10 +145,16 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
|
||||
</Crumb>
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const Slash = styled(GoToIcon)`
|
||||
flex-shrink: 0;
|
||||
fill: ${(props) => props.theme.divider};
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
display: none;
|
||||
|
||||
@@ -170,22 +175,6 @@ const SmallSlash = styled(GoToIcon)`
|
||||
opacity: 0.25;
|
||||
`;
|
||||
|
||||
export const Slash = styled(GoToIcon)`
|
||||
flex-shrink: 0;
|
||||
fill: ${(props) => props.theme.divider};
|
||||
`;
|
||||
|
||||
const Overflow = styled(MoreIcon)`
|
||||
flex-shrink: 0;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
fill: ${(props) => props.theme.divider};
|
||||
|
||||
&:active,
|
||||
&:hover {
|
||||
fill: ${(props) => props.theme.text};
|
||||
}
|
||||
`;
|
||||
|
||||
const Crumb = styled(Link)`
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 15px;
|
||||
@@ -201,12 +190,21 @@ const Crumb = styled(Link)`
|
||||
|
||||
const CollectionName = styled(Link)`
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-shrink: 1;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const CategoryName = styled(CollectionName)`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default observer(Breadcrumb);
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { DropdownMenu } from "components/DropdownMenu";
|
||||
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
|
||||
|
||||
type Props = {
|
||||
label: React.Node,
|
||||
path: Array<any>,
|
||||
};
|
||||
|
||||
export default function BreadcrumbMenu({ label, path }: Props) {
|
||||
return (
|
||||
<DropdownMenu label={label} position="center">
|
||||
<DropdownMenuItems
|
||||
items={path.map((item) => ({
|
||||
title: item.title,
|
||||
to: item.url,
|
||||
}))}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -22,9 +22,13 @@ const RealButton = styled.button`
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
svg {
|
||||
fill: ${(props) => props.iconColor || props.theme.buttonText};
|
||||
}
|
||||
${(props) =>
|
||||
!props.borderOnHover &&
|
||||
`
|
||||
svg {
|
||||
fill: ${props.iconColor || props.theme.buttonText};
|
||||
}
|
||||
`}
|
||||
|
||||
&::-moz-focus-inner {
|
||||
padding: 0;
|
||||
@@ -42,24 +46,30 @@ const RealButton = styled.button`
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.neutral &&
|
||||
props.$neutral &&
|
||||
`
|
||||
background: ${props.theme.buttonNeutralBackground};
|
||||
color: ${props.theme.buttonNeutralText};
|
||||
box-shadow: ${
|
||||
props.borderOnHover ? "none" : "rgba(0, 0, 0, 0.07) 0px 1px 2px"
|
||||
};
|
||||
border: 1px solid ${
|
||||
props.borderOnHover ? "transparent" : props.theme.buttonNeutralBorder
|
||||
props.borderOnHover
|
||||
? "none"
|
||||
: `rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.buttonNeutralBorder} 0 0 0 1px inset`
|
||||
};
|
||||
|
||||
svg {
|
||||
${
|
||||
props.borderOnHover
|
||||
? ""
|
||||
: `svg {
|
||||
fill: ${props.iconColor || props.theme.buttonNeutralText};
|
||||
}`
|
||||
}
|
||||
|
||||
|
||||
&:hover {
|
||||
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
|
||||
border: 1px solid ${props.theme.buttonNeutralBorder};
|
||||
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
|
||||
props.theme.buttonNeutralBorder
|
||||
} 0 0 0 1px inset;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -71,9 +81,9 @@ const RealButton = styled.button`
|
||||
background: ${props.theme.danger};
|
||||
color: ${props.theme.white};
|
||||
|
||||
&:hover {
|
||||
background: ${darken(0.05, props.theme.danger)};
|
||||
}
|
||||
&:hover {
|
||||
background: ${darken(0.05, props.theme.danger)};
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -92,14 +102,14 @@ export const Inner = styled.span`
|
||||
line-height: ${(props) => (props.hasIcon ? 24 : 32)}px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
min-height: 32px;
|
||||
|
||||
${(props) => props.hasIcon && props.hasText && "padding-left: 4px;"};
|
||||
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
|
||||
`;
|
||||
|
||||
export type Props = {
|
||||
type?: string,
|
||||
export type Props = {|
|
||||
type?: "button" | "submit",
|
||||
value?: string,
|
||||
icon?: React.Node,
|
||||
iconColor?: string,
|
||||
@@ -107,9 +117,22 @@ export type Props = {
|
||||
children?: React.Node,
|
||||
innerRef?: React.ElementRef<any>,
|
||||
disclosure?: boolean,
|
||||
neutral?: boolean,
|
||||
danger?: boolean,
|
||||
primary?: boolean,
|
||||
disabled?: boolean,
|
||||
fullwidth?: boolean,
|
||||
autoFocus?: boolean,
|
||||
style?: Object,
|
||||
as?: React.ComponentType<any>,
|
||||
to?: string,
|
||||
onClick?: (event: SyntheticEvent<>) => mixed,
|
||||
borderOnHover?: boolean,
|
||||
};
|
||||
|
||||
"data-on"?: string,
|
||||
"data-event-category"?: string,
|
||||
"data-event-action"?: string,
|
||||
|};
|
||||
|
||||
function Button({
|
||||
type = "text",
|
||||
@@ -118,13 +141,14 @@ function Button({
|
||||
value,
|
||||
disclosure,
|
||||
innerRef,
|
||||
neutral,
|
||||
...rest
|
||||
}: Props) {
|
||||
const hasText = children !== undefined || value !== undefined;
|
||||
const hasIcon = icon !== undefined;
|
||||
|
||||
return (
|
||||
<RealButton type={type} ref={innerRef} {...rest}>
|
||||
<RealButton type={type} ref={innerRef} $neutral={neutral} {...rest}>
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
{hasIcon && icon}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
|
||||
23
app/components/ButtonLink.js
Normal file
23
app/components/ButtonLink.js
Normal file
@@ -0,0 +1,23 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
onClick: (ev: SyntheticEvent<>) => void,
|
||||
children: React.Node,
|
||||
};
|
||||
|
||||
export default function ButtonLink(props: Props) {
|
||||
return <Button {...props} />;
|
||||
}
|
||||
|
||||
const Button = styled.button`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: ${(props) => props.theme.link};
|
||||
line-height: inherit;
|
||||
background: none;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
`;
|
||||
@@ -1,18 +1,21 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import HelpText from "components/HelpText";
|
||||
import VisuallyHidden from "components/VisuallyHidden";
|
||||
|
||||
export type Props = {
|
||||
export type Props = {|
|
||||
checked?: boolean,
|
||||
label?: string,
|
||||
labelHidden?: boolean,
|
||||
className?: string,
|
||||
name?: string,
|
||||
disabled?: boolean,
|
||||
onChange: (event: SyntheticInputEvent<HTMLInputElement>) => mixed,
|
||||
note?: string,
|
||||
short?: boolean,
|
||||
small?: boolean,
|
||||
};
|
||||
|};
|
||||
|
||||
const LabelText = styled.span`
|
||||
font-weight: 500;
|
||||
|
||||
13
app/components/ContextMenu/Header.js
Normal file
13
app/components/ContextMenu/Header.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
|
||||
const Header = styled.h3`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
letter-spacing: 0.04em;
|
||||
margin: 1em 12px 0.5em;
|
||||
`;
|
||||
|
||||
export default Header;
|
||||
@@ -1,50 +1,62 @@
|
||||
// @flow
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
onClick?: (SyntheticEvent<>) => void | Promise<void>,
|
||||
children?: React.Node,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
};
|
||||
to?: string,
|
||||
href?: string,
|
||||
target?: "_blank",
|
||||
as?: string | React.ComponentType<*>,
|
||||
|};
|
||||
|
||||
const DropdownMenuItem = ({
|
||||
const MenuItem = ({
|
||||
onClick,
|
||||
children,
|
||||
selected,
|
||||
disabled,
|
||||
as,
|
||||
...rest
|
||||
}: Props) => {
|
||||
return (
|
||||
<MenuItem
|
||||
<BaseMenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
role="menuitem"
|
||||
tabIndex="-1"
|
||||
{...rest}
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<>
|
||||
<CheckmarkIcon
|
||||
color={selected === false ? "transparent" : undefined}
|
||||
/>
|
||||
|
||||
</>
|
||||
{(props) => (
|
||||
<MenuAnchor as={onClick ? "button" : as} {...props}>
|
||||
{selected !== undefined && (
|
||||
<>
|
||||
{selected ? <CheckmarkIcon /> : <Spacer />}
|
||||
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</MenuAnchor>
|
||||
)}
|
||||
{children}
|
||||
</MenuItem>
|
||||
</BaseMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuItem = styled.a`
|
||||
const Spacer = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export const MenuAnchor = styled.a`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 6px 12px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
|
||||
background: none;
|
||||
color: ${(props) =>
|
||||
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
|
||||
justify-content: left;
|
||||
@@ -58,6 +70,7 @@ const MenuItem = styled.a`
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
opacity: ${(props) => (props.disabled ? ".5" : 1)};
|
||||
}
|
||||
|
||||
@@ -66,7 +79,8 @@ const MenuItem = styled.a`
|
||||
? "pointer-events: none;"
|
||||
: `
|
||||
|
||||
&:hover {
|
||||
&:hover,
|
||||
&.focus-visible {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.theme.primary};
|
||||
box-shadow: none;
|
||||
@@ -84,4 +98,4 @@ const MenuItem = styled.a`
|
||||
`};
|
||||
`;
|
||||
|
||||
export default DropdownMenuItem;
|
||||
export default MenuItem;
|
||||
21
app/components/ContextMenu/OverflowMenuButton.js
Normal file
21
app/components/ContextMenu/OverflowMenuButton.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// @flow
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import NudeButton from "components/NudeButton";
|
||||
|
||||
export default function OverflowMenuButton({
|
||||
iconColor,
|
||||
className,
|
||||
...rest
|
||||
}: any) {
|
||||
return (
|
||||
<MenuButton {...rest}>
|
||||
{(props) => (
|
||||
<NudeButton className={className} {...props}>
|
||||
<MoreIcon color={iconColor} />
|
||||
</NudeButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
);
|
||||
}
|
||||
16
app/components/ContextMenu/Separator.js
Normal file
16
app/components/ContextMenu/Separator.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { MenuSeparator } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
|
||||
export default function Separator(rest: {}) {
|
||||
return (
|
||||
<MenuSeparator {...rest}>
|
||||
{(props) => <HorizontalRule {...props} />}
|
||||
</MenuSeparator>
|
||||
);
|
||||
}
|
||||
|
||||
const HorizontalRule = styled.hr`
|
||||
margin: 0.5em 12px;
|
||||
`;
|
||||
@@ -1,26 +1,38 @@
|
||||
// @flow
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import DropdownMenu from "./DropdownMenu";
|
||||
import DropdownMenuItem from "./DropdownMenuItem";
|
||||
import {
|
||||
useMenuState,
|
||||
MenuButton,
|
||||
MenuItem as BaseMenuItem,
|
||||
} from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import MenuItem, { MenuAnchor } from "./MenuItem";
|
||||
import Separator from "./Separator";
|
||||
import ContextMenu from ".";
|
||||
|
||||
type MenuItem =
|
||||
type TMenuItem =
|
||||
| {|
|
||||
title: React.Node,
|
||||
to: string,
|
||||
visible?: boolean,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
|}
|
||||
| {|
|
||||
title: React.Node,
|
||||
onClick: (event: SyntheticEvent<>) => void | Promise<void>,
|
||||
visible?: boolean,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
|}
|
||||
| {|
|
||||
title: React.Node,
|
||||
href: string,
|
||||
visible?: boolean,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
|}
|
||||
| {|
|
||||
@@ -29,7 +41,7 @@ type MenuItem =
|
||||
disabled?: boolean,
|
||||
style?: Object,
|
||||
hover?: boolean,
|
||||
items: MenuItem[],
|
||||
items: TMenuItem[],
|
||||
|}
|
||||
| {|
|
||||
type: "separator",
|
||||
@@ -42,10 +54,35 @@ type MenuItem =
|
||||
|};
|
||||
|
||||
type Props = {|
|
||||
items: MenuItem[],
|
||||
items: TMenuItem[],
|
||||
|};
|
||||
|
||||
export default function DropdownMenuItems({ items }: Props): React.Node {
|
||||
const Disclosure = styled(ExpandedIcon)`
|
||||
transform: rotate(270deg);
|
||||
justify-self: flex-end;
|
||||
`;
|
||||
|
||||
const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({ modal: true });
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton ref={ref} {...menu} {...rest}>
|
||||
{(props) => (
|
||||
<MenuAnchor {...props}>
|
||||
{title} <Disclosure color="currentColor" />
|
||||
</MenuAnchor>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Submenu")}>
|
||||
<Template {...menu} items={templateItems} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function Template({ items, ...menu }: Props): React.Node {
|
||||
let filtered = items.filter((item) => item.visible !== false);
|
||||
|
||||
// this block literally just trims unneccessary separators
|
||||
@@ -66,63 +103,67 @@ export default function DropdownMenuItems({ items }: Props): React.Node {
|
||||
return filtered.map((item, index) => {
|
||||
if (item.to) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
<MenuItem
|
||||
as={Link}
|
||||
to={item.to}
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</DropdownMenuItem>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.href) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
<MenuItem
|
||||
href={item.href}
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
target="_blank"
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</DropdownMenuItem>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.onClick) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
<MenuItem
|
||||
as="button"
|
||||
onClick={item.onClick}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
key={index}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</DropdownMenuItem>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.items) {
|
||||
return (
|
||||
<DropdownMenu
|
||||
style={item.style}
|
||||
label={
|
||||
<DropdownMenuItem disabled={item.disabled}>
|
||||
{item.title}
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
hover={item.hover}
|
||||
<BaseMenuItem
|
||||
key={index}
|
||||
>
|
||||
<DropdownMenuItems items={item.items} />
|
||||
</DropdownMenu>
|
||||
as={Submenu}
|
||||
templateItems={item.items}
|
||||
title={item.title}
|
||||
{...menu}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "separator") {
|
||||
return <hr key={index} />;
|
||||
return <Separator key={index} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export default React.memo<Props>(Template);
|
||||
77
app/components/ContextMenu/index.js
Normal file
77
app/components/ContextMenu/index.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// @flow
|
||||
import { rgba } from "polished";
|
||||
import * as React from "react";
|
||||
import { Menu } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
|
||||
type Props = {|
|
||||
"aria-label": string,
|
||||
visible?: boolean,
|
||||
animating?: boolean,
|
||||
children: React.Node,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
|};
|
||||
|
||||
export default function ContextMenu({
|
||||
children,
|
||||
onOpen,
|
||||
onClose,
|
||||
...rest
|
||||
}: Props) {
|
||||
const previousVisible = usePrevious(rest.visible);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (rest.visible && !previousVisible) {
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
}
|
||||
if (!rest.visible && previousVisible) {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}, [onOpen, onClose, previousVisible, rest.visible]);
|
||||
|
||||
return (
|
||||
<Menu {...rest}>
|
||||
{(props) => (
|
||||
<Position {...props}>
|
||||
<Background>
|
||||
{rest.visible || rest.animating ? children : null}
|
||||
</Background>
|
||||
</Position>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
const Position = styled.div`
|
||||
position: absolute;
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
`;
|
||||
|
||||
const Background = styled.div`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: ${(props) => (props.left !== undefined ? "25%" : "75%")} 0;
|
||||
background: ${(props) => rgba(props.theme.menuBackground, 0.95)};
|
||||
border: ${(props) =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
border-radius: 2px;
|
||||
padding: 0.5em 0;
|
||||
min-width: 180px;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: 75vh;
|
||||
max-width: 276px;
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
pointer-events: all;
|
||||
font-weight: normal;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
@@ -156,7 +156,7 @@ const Wrapper = styled(Flex)`
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
min-width: ${(props) => props.theme.sidebarWidth};
|
||||
min-width: ${(props) => props.theme.sidebarWidth}px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: none;
|
||||
@@ -165,7 +165,7 @@ const Wrapper = styled(Flex)`
|
||||
const Sidebar = styled(Flex)`
|
||||
display: none;
|
||||
background: ${(props) => props.theme.background};
|
||||
min-width: ${(props) => props.theme.sidebarWidth};
|
||||
min-width: ${(props) => props.theme.sidebarWidth}px;
|
||||
border-left: 1px solid ${(props) => props.theme.divider};
|
||||
z-index: 1;
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @flow
|
||||
import format from "date-fns/format";
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
@@ -45,9 +44,7 @@ class RevisionListItem extends React.Component<Props> {
|
||||
<StyledRevisionMenu
|
||||
document={document}
|
||||
revision={revision}
|
||||
label={
|
||||
<MoreIcon color={selected ? theme.white : theme.textTertiary} />
|
||||
}
|
||||
iconColor={selected ? theme.white : theme.textTertiary}
|
||||
/>
|
||||
)}
|
||||
</StyledNavLink>
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
|
||||
import * as React from "react";
|
||||
import Document from "models/Document";
|
||||
import DocumentPreview from "components/DocumentPreview";
|
||||
import DocumentListItem from "components/DocumentListItem";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
documents: Document[],
|
||||
limit?: number,
|
||||
};
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
showDraft?: boolean,
|
||||
showTemplate?: boolean,
|
||||
|};
|
||||
|
||||
export default function DocumentList({ limit, documents, ...rest }: Props) {
|
||||
const items = limit ? documents.splice(0, limit) : documents;
|
||||
@@ -18,7 +23,7 @@ export default function DocumentList({ limit, documents, ...rest }: Props) {
|
||||
defaultActiveChildIndex={0}
|
||||
>
|
||||
{items.map((document) => (
|
||||
<DocumentPreview key={document.id} document={document} {...rest} />
|
||||
<DocumentListItem key={document.id} document={document} {...rest} />
|
||||
))}
|
||||
</ArrowKeyNavigation>
|
||||
);
|
||||
|
||||
243
app/components/DocumentListItem.js
Normal file
243
app/components/DocumentListItem.js
Normal file
@@ -0,0 +1,243 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Document from "models/Document";
|
||||
import Badge from "components/Badge";
|
||||
import Button from "components/Button";
|
||||
import DocumentMeta from "components/DocumentMeta";
|
||||
import EventBoundary from "components/EventBoundary";
|
||||
import Flex from "components/Flex";
|
||||
import Highlight from "components/Highlight";
|
||||
import StarButton, { AnimatedStar } from "components/Star";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
highlight?: ?string,
|
||||
context?: ?string,
|
||||
showNestedDocuments?: boolean,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
showDraft?: boolean,
|
||||
showTemplate?: boolean,
|
||||
|};
|
||||
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
|
||||
function replaceResultMarks(tag: string) {
|
||||
// don't use SEARCH_RESULT_REGEX here as it causes
|
||||
// an infinite loop to trigger a regex inside it's own callback
|
||||
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
|
||||
}
|
||||
|
||||
function DocumentListItem(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const {
|
||||
document,
|
||||
showNestedDocuments,
|
||||
showCollection,
|
||||
showPublished,
|
||||
showPin,
|
||||
showDraft = true,
|
||||
showTemplate,
|
||||
highlight,
|
||||
context,
|
||||
} = props;
|
||||
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar =
|
||||
!document.isDraft && !document.isArchived && !document.isTemplate;
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: document.url,
|
||||
state: { title: document.titleWithDefault },
|
||||
}}
|
||||
>
|
||||
<Content>
|
||||
<Heading>
|
||||
<Title text={document.titleWithDefault} highlight={highlight} />
|
||||
{document.isNew && document.createdBy.id !== currentUser.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip
|
||||
tooltip={t("Only visible to you")}
|
||||
delay={500}
|
||||
placement="top"
|
||||
>
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showNestedDocuments={showNestedDocuments}
|
||||
showLastViewed
|
||||
/>
|
||||
</Content>
|
||||
<Actions>
|
||||
{document.isTemplate && !document.isArchived && !document.isDeleted && (
|
||||
<>
|
||||
<Button
|
||||
as={Link}
|
||||
to={newDocumentUrl(document.collectionId, {
|
||||
templateId: document.id,
|
||||
})}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
|
||||
</>
|
||||
)}
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
showPin={showPin}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
modal={false}
|
||||
/>
|
||||
</Actions>
|
||||
</DocumentLink>
|
||||
);
|
||||
}
|
||||
|
||||
const Content = styled.div`
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const Actions = styled(EventBoundary)`
|
||||
display: none;
|
||||
align-items: center;
|
||||
margin: 8px;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
`;
|
||||
|
||||
const DocumentLink = styled(Link)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 10px -8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
max-height: 50vh;
|
||||
min-width: 100%;
|
||||
max-width: calc(100vw - 40px);
|
||||
|
||||
${Actions} {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
${AnimatedStar} {
|
||||
opacity: ${(props) => (props.$isStarred ? "1 !important" : 0)};
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
|
||||
${Actions} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${AnimatedStar} {
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$menuOpen &&
|
||||
css`
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
|
||||
${Actions} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${AnimatedStar} {
|
||||
opacity: 0.5;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Heading = styled.h3`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.25em;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
`;
|
||||
|
||||
const StarPositioner = styled(Flex)`
|
||||
margin-left: 4px;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Title = styled(Highlight)`
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const ResultContext = styled(Highlight)`
|
||||
display: block;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-size: 14px;
|
||||
margin-top: -0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
`;
|
||||
|
||||
export default observer(DocumentListItem);
|
||||
@@ -15,6 +15,7 @@ const Container = styled(Flex)`
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const Modified = styled.span`
|
||||
@@ -22,19 +23,21 @@ const Modified = styled.span`
|
||||
font-weight: ${(props) => (props.highlight ? "600" : "400")};
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showLastViewed?: boolean,
|
||||
showNestedDocuments?: boolean,
|
||||
document: Document,
|
||||
children: React.Node,
|
||||
to?: string,
|
||||
};
|
||||
|};
|
||||
|
||||
function DocumentMeta({
|
||||
showPublished,
|
||||
showCollection,
|
||||
showLastViewed,
|
||||
showNestedDocuments,
|
||||
document,
|
||||
children,
|
||||
to,
|
||||
@@ -122,6 +125,10 @@ function DocumentMeta({
|
||||
);
|
||||
};
|
||||
|
||||
const nestedDocumentsCount = collection
|
||||
? collection.getDocumentChildren(document.id).length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Container align="center" {...rest}>
|
||||
{updatedByMe ? t("You") : updatedBy.name}
|
||||
@@ -134,6 +141,12 @@ function DocumentMeta({
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
{showNestedDocuments && nestedDocumentsCount > 0 && (
|
||||
<span>
|
||||
· {nestedDocumentsCount}{" "}
|
||||
{t("nested document", { count: nestedDocumentsCount })}
|
||||
</span>
|
||||
)}
|
||||
{timeSinceNow()}
|
||||
{children}
|
||||
</Container>
|
||||
|
||||
@@ -35,6 +35,8 @@ function DocumentMetaWithViews({ to, isDraft, document }: Props) {
|
||||
const Meta = styled(DocumentMeta)`
|
||||
margin: -12px 0 2em 0;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { StarredIcon, PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { Link, Redirect } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import Badge from "components/Badge";
|
||||
import Button from "components/Button";
|
||||
import DocumentMeta from "components/DocumentMeta";
|
||||
import EventBoundary from "components/EventBoundary";
|
||||
import Flex from "components/Flex";
|
||||
import Highlight from "components/Highlight";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document,
|
||||
highlight?: ?string,
|
||||
context?: ?string,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
showDraft?: boolean,
|
||||
showTemplate?: boolean,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
|
||||
@observer
|
||||
class DocumentPreview extends React.Component<Props> {
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
handleStar = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.document.star();
|
||||
};
|
||||
|
||||
handleUnstar = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.document.unstar();
|
||||
};
|
||||
|
||||
replaceResultMarks = (tag: string) => {
|
||||
// don't use SEARCH_RESULT_REGEX here as it causes
|
||||
// an infinite loop to trigger a regex inside it's own callback
|
||||
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
|
||||
};
|
||||
|
||||
handleNewFromTemplate = (event: SyntheticEvent<>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const { document } = this.props;
|
||||
|
||||
this.redirectTo = newDocumentUrl(document.collectionId, {
|
||||
templateId: document.id,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
document,
|
||||
showCollection,
|
||||
showPublished,
|
||||
showPin,
|
||||
showDraft = true,
|
||||
showTemplate,
|
||||
highlight,
|
||||
context,
|
||||
t,
|
||||
} = this.props;
|
||||
|
||||
if (this.redirectTo) {
|
||||
return <Redirect to={this.redirectTo} push />;
|
||||
}
|
||||
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
to={{
|
||||
pathname: document.url,
|
||||
state: { title: document.titleWithDefault },
|
||||
}}
|
||||
>
|
||||
<Heading>
|
||||
<Title text={document.titleWithDefault} highlight={highlight} />
|
||||
{document.isNew && <Badge yellow>{t("New")}</Badge>}
|
||||
{!document.isDraft &&
|
||||
!document.isArchived &&
|
||||
!document.isTemplate && (
|
||||
<Actions>
|
||||
{document.isStarred ? (
|
||||
<StyledStar onClick={this.handleUnstar} solid />
|
||||
) : (
|
||||
<StyledStar onClick={this.handleStar} />
|
||||
)}
|
||||
</Actions>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip
|
||||
tooltip={t("Only visible to you")}
|
||||
delay={500}
|
||||
placement="top"
|
||||
>
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
<SecondaryActions>
|
||||
{document.isTemplate &&
|
||||
!document.isArchived &&
|
||||
!document.isDeleted && (
|
||||
<Button
|
||||
onClick={this.handleNewFromTemplate}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<EventBoundary>
|
||||
<DocumentMenu document={document} showPin={showPin} />
|
||||
</EventBoundary>
|
||||
</SecondaryActions>
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={this.replaceResultMarks}
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showLastViewed
|
||||
/>
|
||||
</DocumentLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const StyledStar = withTheme(styled(({ solid, theme, ...props }) => (
|
||||
<StarredIcon color={theme.text} {...props} />
|
||||
))`
|
||||
flex-shrink: 0;
|
||||
opacity: ${(props) => (props.solid ? "1 !important" : 0)};
|
||||
transition: all 100ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
`);
|
||||
|
||||
const SecondaryActions = styled(Flex)`
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
`;
|
||||
|
||||
const DocumentLink = styled(Link)`
|
||||
display: block;
|
||||
margin: 10px -8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
max-height: 50vh;
|
||||
min-width: 100%;
|
||||
max-width: calc(100vw - 40px);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
${SecondaryActions} {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
|
||||
${SecondaryActions} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${StyledStar} {
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Heading = styled.h3`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.25em;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
`;
|
||||
|
||||
const Actions = styled(Flex)`
|
||||
margin-left: 4px;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Title = styled(Highlight)`
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const ResultContext = styled(Highlight)`
|
||||
display: block;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-size: 14px;
|
||||
margin-top: -0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
`;
|
||||
|
||||
export default withTranslation()<DocumentPreview>(DocumentPreview);
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import DocumentPreview from "./DocumentPreview";
|
||||
export default DocumentPreview;
|
||||
@@ -60,7 +60,9 @@ class DropToImport extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(`Could not import file. ${err.message}`);
|
||||
this.props.ui.showToast(`Could not import file. ${err.message}`, {
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
this.isImporting = false;
|
||||
importingLock = false;
|
||||
@@ -87,7 +89,11 @@ class DropToImport extends React.Component<Props> {
|
||||
isDragAccept,
|
||||
isDragReject,
|
||||
}) => (
|
||||
<DropzoneContainer {...getRootProps()} {...{ isDragActive }}>
|
||||
<DropzoneContainer
|
||||
{...getRootProps()}
|
||||
{...{ isDragActive }}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{this.isImporting && <LoadingIndicator />}
|
||||
{this.props.children}
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import { rgba } from "polished";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { PortalWithState } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||
import Flex from "components/Flex";
|
||||
import NudeButton from "components/NudeButton";
|
||||
|
||||
let previousClosePortal;
|
||||
let counter = 0;
|
||||
|
||||
type Children =
|
||||
| React.Node
|
||||
| ((options: { closePortal: () => void }) => React.Node);
|
||||
|
||||
type Props = {|
|
||||
label?: React.Node,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
children?: Children,
|
||||
className?: string,
|
||||
hover?: boolean,
|
||||
style?: Object,
|
||||
position?: "left" | "right" | "center",
|
||||
t: TFunction,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class DropdownMenu extends React.Component<Props> {
|
||||
id: string = `menu${counter++}`;
|
||||
closeTimeout: TimeoutID;
|
||||
|
||||
@observable top: ?number;
|
||||
@observable bottom: ?number;
|
||||
@observable right: ?number;
|
||||
@observable left: ?number;
|
||||
@observable position: "left" | "right" | "center";
|
||||
@observable fixed: ?boolean;
|
||||
@observable bodyRect: ClientRect;
|
||||
@observable labelRect: ClientRect;
|
||||
@observable dropdownRef: { current: null | HTMLElement } = React.createRef();
|
||||
@observable menuRef: { current: null | HTMLElement } = React.createRef();
|
||||
|
||||
handleOpen = (
|
||||
openPortal: (SyntheticEvent<>) => void,
|
||||
closePortal: () => void
|
||||
) => {
|
||||
return (ev: SyntheticMouseEvent<HTMLElement>) => {
|
||||
ev.preventDefault();
|
||||
const currentTarget = ev.currentTarget;
|
||||
invariant(document.body, "why you not here");
|
||||
|
||||
if (currentTarget instanceof HTMLDivElement) {
|
||||
this.bodyRect = document.body.getBoundingClientRect();
|
||||
this.labelRect = currentTarget.getBoundingClientRect();
|
||||
this.top = this.labelRect.bottom - this.bodyRect.top;
|
||||
this.bottom = undefined;
|
||||
this.position = this.props.position || "left";
|
||||
|
||||
if (currentTarget.parentElement) {
|
||||
const triggerParentStyle = getComputedStyle(
|
||||
currentTarget.parentElement
|
||||
);
|
||||
|
||||
if (triggerParentStyle.position === "static") {
|
||||
this.fixed = true;
|
||||
this.top = this.labelRect.bottom;
|
||||
}
|
||||
}
|
||||
|
||||
this.initPosition();
|
||||
|
||||
// attempt to keep only one flyout menu open at once
|
||||
if (previousClosePortal && !this.props.hover) {
|
||||
previousClosePortal();
|
||||
}
|
||||
previousClosePortal = closePortal;
|
||||
openPortal(ev);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
initPosition() {
|
||||
if (this.position === "left") {
|
||||
this.right =
|
||||
this.bodyRect.width - this.labelRect.left - this.labelRect.width;
|
||||
} else if (this.position === "center") {
|
||||
this.left = this.labelRect.left + this.labelRect.width / 2;
|
||||
} else {
|
||||
this.left = this.labelRect.left;
|
||||
}
|
||||
}
|
||||
|
||||
onOpen = () => {
|
||||
if (typeof this.props.onOpen === "function") {
|
||||
this.props.onOpen();
|
||||
}
|
||||
this.fitOnTheScreen();
|
||||
};
|
||||
|
||||
fitOnTheScreen() {
|
||||
if (!this.dropdownRef || !this.dropdownRef.current) return;
|
||||
const el = this.dropdownRef.current;
|
||||
|
||||
const sticksOutPastBottomEdge =
|
||||
el.clientHeight + this.top > window.innerHeight;
|
||||
if (sticksOutPastBottomEdge) {
|
||||
this.top = undefined;
|
||||
this.bottom = this.fixed ? 0 : -1 * window.pageYOffset;
|
||||
} else {
|
||||
this.bottom = undefined;
|
||||
}
|
||||
|
||||
if (this.position === "left" || this.position === "right") {
|
||||
const totalWidth =
|
||||
Math.sign(this.position === "left" ? -1 : 1) * el.offsetLeft +
|
||||
el.scrollWidth;
|
||||
const isVisible = totalWidth < window.innerWidth;
|
||||
|
||||
if (!isVisible) {
|
||||
if (this.position === "right") {
|
||||
this.position = "left";
|
||||
this.left = undefined;
|
||||
} else if (this.position === "left") {
|
||||
this.position = "right";
|
||||
this.right = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.initPosition();
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
closeAfterTimeout = (closePortal: () => void) => () => {
|
||||
if (this.closeTimeout) {
|
||||
clearTimeout(this.closeTimeout);
|
||||
}
|
||||
this.closeTimeout = setTimeout(closePortal, 500);
|
||||
};
|
||||
|
||||
clearCloseTimeout = () => {
|
||||
if (this.closeTimeout) {
|
||||
clearTimeout(this.closeTimeout);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { className, hover, label, children, t } = this.props;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<PortalWithState
|
||||
onOpen={this.onOpen}
|
||||
onClose={this.props.onClose}
|
||||
closeOnOutsideClick
|
||||
closeOnEsc
|
||||
>
|
||||
{({ closePortal, openPortal, isOpen, portal }) => (
|
||||
<>
|
||||
<Label
|
||||
onMouseMove={hover ? this.clearCloseTimeout : undefined}
|
||||
onMouseOut={
|
||||
hover ? this.closeAfterTimeout(closePortal) : undefined
|
||||
}
|
||||
onMouseEnter={
|
||||
hover ? this.handleOpen(openPortal, closePortal) : undefined
|
||||
}
|
||||
onClick={
|
||||
hover ? undefined : this.handleOpen(openPortal, closePortal)
|
||||
}
|
||||
>
|
||||
{label || (
|
||||
<NudeButton
|
||||
id={`${this.id}button`}
|
||||
aria-label={t("More options")}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isOpen ? "true" : "false"}
|
||||
aria-controls={this.id}
|
||||
>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
)}
|
||||
</Label>
|
||||
{portal(
|
||||
<Position
|
||||
ref={this.dropdownRef}
|
||||
position={this.position}
|
||||
fixed={this.fixed}
|
||||
top={this.top}
|
||||
bottom={this.bottom}
|
||||
left={this.left}
|
||||
right={this.right}
|
||||
>
|
||||
<Menu
|
||||
ref={this.menuRef}
|
||||
onMouseMove={hover ? this.clearCloseTimeout : undefined}
|
||||
onMouseOut={
|
||||
hover ? this.closeAfterTimeout(closePortal) : undefined
|
||||
}
|
||||
onClick={
|
||||
typeof children === "function"
|
||||
? undefined
|
||||
: (ev) => {
|
||||
ev.stopPropagation();
|
||||
closePortal();
|
||||
}
|
||||
}
|
||||
style={this.props.style}
|
||||
id={this.id}
|
||||
aria-labelledby={`${this.id}button`}
|
||||
role="menu"
|
||||
>
|
||||
{typeof children === "function"
|
||||
? children({ closePortal })
|
||||
: children}
|
||||
</Menu>
|
||||
</Position>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PortalWithState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Label = styled(Flex).attrs({
|
||||
justify: "center",
|
||||
align: "center",
|
||||
})`
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const Position = styled.div`
|
||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
||||
display: flex;
|
||||
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
|
||||
${({ right }) => (right !== undefined ? `right: ${right}px` : "")};
|
||||
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
|
||||
${({ bottom }) => (bottom !== undefined ? `bottom: ${bottom}px` : "")};
|
||||
max-height: 75%;
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
transform: ${(props) =>
|
||||
props.position === "center" ? "translateX(-50%)" : "initial"};
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
const Menu = styled.div`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: ${(props) => (props.left !== undefined ? "25%" : "75%")} 0;
|
||||
backdrop-filter: blur(10px);
|
||||
background: ${(props) => rgba(props.theme.menuBackground, 0.8)};
|
||||
border: ${(props) =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
border-radius: 2px;
|
||||
padding: 0.5em 0;
|
||||
min-width: 180px;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
pointer-events: all;
|
||||
|
||||
hr {
|
||||
margin: 0.5em 12px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Header = styled.h3`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
letter-spacing: 0.04em;
|
||||
margin: 1em 12px 0.5em;
|
||||
`;
|
||||
|
||||
export default withTranslation()<DropdownMenu>(DropdownMenu);
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
export { default as DropdownMenu, Header } from "./DropdownMenu";
|
||||
export { default as DropdownMenuItem } from "./DropdownMenuItem";
|
||||
@@ -8,21 +8,39 @@ import UiStore from "stores/UiStore";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import embeds from "../embeds";
|
||||
import isInternalUrl from "utils/isInternalUrl";
|
||||
import { isModKey } from "utils/keyboard";
|
||||
import { uploadFile } from "utils/uploadFile";
|
||||
import { isInternalUrl } from "utils/urls";
|
||||
|
||||
const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));
|
||||
|
||||
const EMPTY_ARRAY = [];
|
||||
|
||||
type Props = {
|
||||
export type Props = {|
|
||||
id?: string,
|
||||
value?: string,
|
||||
defaultValue?: string,
|
||||
readOnly?: boolean,
|
||||
grow?: boolean,
|
||||
disableEmbeds?: boolean,
|
||||
ui?: UiStore,
|
||||
};
|
||||
autoFocus?: boolean,
|
||||
template?: boolean,
|
||||
placeholder?: string,
|
||||
scrollTo?: string,
|
||||
readOnlyWriteCheckboxes?: boolean,
|
||||
onBlur?: (event: SyntheticEvent<>) => any,
|
||||
onFocus?: (event: SyntheticEvent<>) => any,
|
||||
onPublish?: (event: SyntheticEvent<>) => any,
|
||||
onSave?: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
|
||||
onCancel?: () => any,
|
||||
onChange?: (getValue: () => string) => any,
|
||||
onSearchLink?: (title: string) => any,
|
||||
onHoverLink?: (event: MouseEvent) => any,
|
||||
onCreateLink?: (title: string) => Promise<string>,
|
||||
onImageUploadStart?: () => any,
|
||||
onImageUploadStop?: () => any,
|
||||
|};
|
||||
|
||||
type PropsWithRef = Props & {
|
||||
forwardedRef: React.Ref<any>,
|
||||
@@ -49,7 +67,7 @@ function Editor(props: PropsWithRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInternalUrl(href) && !event.metaKey && !event.shiftKey) {
|
||||
if (isInternalUrl(href) && !isModKey(event) && !event.shiftKey) {
|
||||
// relative
|
||||
let navigateTo = href;
|
||||
|
||||
@@ -171,17 +189,16 @@ const StyledEditor = styled(RichMarkdownEditor)`
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.heading-name {
|
||||
pointer-events: none;
|
||||
.heading-anchor {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* pseudo element allows us to add spacing for fixed header */
|
||||
/* ref: https://stackoverflow.com/a/28824157 */
|
||||
.heading-name::before {
|
||||
content: "";
|
||||
display: ${(props) => (props.readOnly ? "block" : "none")};
|
||||
height: 72px;
|
||||
margin: -72px 0 0;
|
||||
.heading-name {
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
position: relative;
|
||||
top: -60px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.heading-name:first-child {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @flow
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
@@ -36,8 +37,8 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.Sentry) {
|
||||
window.Sentry.captureException(error);
|
||||
if (env.SENTRY_DSN) {
|
||||
Sentry.captureException(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +57,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
render() {
|
||||
if (this.error) {
|
||||
const error = this.error;
|
||||
const isReported = !!window.Sentry && env.DEPLOYMENT === "hosted";
|
||||
const isReported = !!env.SENTRY_DSN && env.DEPLOYMENT === "hosted";
|
||||
const isChunkError = this.error.message.match(/chunk/);
|
||||
|
||||
if (isChunkError) {
|
||||
|
||||
@@ -4,13 +4,18 @@ import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
className?: string,
|
||||
};
|
||||
|
||||
export default function EventBoundary({ children }: Props) {
|
||||
export default function EventBoundary({ children, className }: Props) {
|
||||
const handleClick = React.useCallback((event: SyntheticEvent<>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return <span onClick={handleClick}>{children}</span>;
|
||||
return (
|
||||
<span onClick={handleClick} className={className}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@ const Heading = styled.h1`
|
||||
${(props) => (props.centered ? "text-align: center;" : "")}
|
||||
|
||||
svg {
|
||||
margin-top: 4px;
|
||||
margin-left: -6px;
|
||||
margin-right: 2px;
|
||||
align-self: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { fadeAndSlideIn } from "shared/styles/animations";
|
||||
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import HoverPreviewDocument from "components/HoverPreviewDocument";
|
||||
import isInternalUrl from "utils/isInternalUrl";
|
||||
import { isInternalUrl } from "utils/urls";
|
||||
|
||||
const DELAY_OPEN = 300;
|
||||
const DELAY_CLOSE = 300;
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
CollectionIcon,
|
||||
CoinsIcon,
|
||||
@@ -22,14 +20,17 @@ import {
|
||||
VehicleIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { DropdownMenu } from "components/DropdownMenu";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import { LabelText } from "components/Input";
|
||||
import NudeButton from "components/NudeButton";
|
||||
|
||||
const style = { width: 30, height: 30 };
|
||||
|
||||
const TwitterPicker = React.lazy(() =>
|
||||
import("react-color/lib/components/twitter/Twitter")
|
||||
);
|
||||
@@ -122,107 +123,77 @@ const colors = [
|
||||
"#2F362F",
|
||||
];
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
onOpen?: () => void,
|
||||
onChange: (color: string, icon: string) => void,
|
||||
icon: string,
|
||||
color: string,
|
||||
t: TFunction,
|
||||
};
|
||||
|};
|
||||
|
||||
function preventEventBubble(event) {
|
||||
event.stopPropagation();
|
||||
function IconPicker({ onOpen, icon, color, onChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
placement: "bottom-end",
|
||||
});
|
||||
const Component = icons[icon || "collection"].component;
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Label>
|
||||
<LabelText>{t("Icon")}</LabelText>
|
||||
</Label>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<Button aria-label={t("Show menu")} {...props}>
|
||||
<Component color={color} size={30} />
|
||||
</Button>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} onOpen={onOpen} aria-label={t("Choose icon")}>
|
||||
<Icons>
|
||||
{Object.keys(icons).map((name) => {
|
||||
const Component = icons[name].component;
|
||||
return (
|
||||
<MenuItem
|
||||
key={name}
|
||||
onClick={() => onChange(color, name)}
|
||||
{...menu}
|
||||
>
|
||||
{(props) => (
|
||||
<IconButton style={style} {...props}>
|
||||
<Component color={color} size={30} />
|
||||
</IconButton>
|
||||
)}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Icons>
|
||||
<Flex>
|
||||
<React.Suspense fallback={<Loading>{t("Loading")}…</Loading>}>
|
||||
<ColorPicker
|
||||
color={color}
|
||||
onChange={(color) => onChange(color.hex, icon)}
|
||||
colors={colors}
|
||||
triangle="hide"
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Flex>
|
||||
</ContextMenu>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@observer
|
||||
class IconPicker extends React.Component<Props> {
|
||||
@observable isOpen: boolean = false;
|
||||
node: ?HTMLElement;
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener("click", this.handleClickOutside);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("click", this.handleClickOutside);
|
||||
}
|
||||
|
||||
handleClose = () => {
|
||||
this.isOpen = false;
|
||||
};
|
||||
|
||||
handleOpen = () => {
|
||||
this.isOpen = true;
|
||||
|
||||
if (this.props.onOpen) {
|
||||
this.props.onOpen();
|
||||
}
|
||||
};
|
||||
|
||||
handleClickOutside = (ev: SyntheticMouseEvent<>) => {
|
||||
// $FlowFixMe
|
||||
if (ev.target && this.node && this.node.contains(ev.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleClose();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
const Component = icons[this.props.icon || "collection"].component;
|
||||
|
||||
return (
|
||||
<Wrapper ref={(ref) => (this.node = ref)}>
|
||||
<label>
|
||||
<LabelText>{t("Icon")}</LabelText>
|
||||
</label>
|
||||
<DropdownMenu
|
||||
onOpen={this.handleOpen}
|
||||
label={
|
||||
<LabelButton>
|
||||
<Component role="button" color={this.props.color} size={30} />
|
||||
</LabelButton>
|
||||
}
|
||||
>
|
||||
<Icons onClick={preventEventBubble}>
|
||||
{Object.keys(icons).map((name) => {
|
||||
const Component = icons[name].component;
|
||||
return (
|
||||
<IconButton
|
||||
key={name}
|
||||
onClick={() => this.props.onChange(this.props.color, name)}
|
||||
style={{ width: 30, height: 30 }}
|
||||
>
|
||||
<Component color={this.props.color} size={30} />
|
||||
</IconButton>
|
||||
);
|
||||
})}
|
||||
</Icons>
|
||||
<Flex onClick={preventEventBubble}>
|
||||
<React.Suspense fallback={<Loading>{t("Loading")}…</Loading>}>
|
||||
<ColorPicker
|
||||
color={this.props.color}
|
||||
onChange={(color) =>
|
||||
this.props.onChange(color.hex, this.props.icon)
|
||||
}
|
||||
colors={colors}
|
||||
triangle="hide"
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Flex>
|
||||
</DropdownMenu>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
const Label = styled.label`
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const Icons = styled.div`
|
||||
padding: 15px 9px 9px 15px;
|
||||
width: 276px;
|
||||
`;
|
||||
|
||||
const LabelButton = styled(NudeButton)`
|
||||
const Button = styled(NudeButton)`
|
||||
border: 1px solid ${(props) => props.theme.inputBorder};
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -249,4 +220,4 @@ const Wrapper = styled("div")`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export default withTranslation()<IconPicker>(IconPicker);
|
||||
export default IconPicker;
|
||||
|
||||
15
app/components/Image.js
Normal file
15
app/components/Image.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { cdnPath } from "utils/urls";
|
||||
|
||||
type Props = {|
|
||||
alt: string,
|
||||
src: string,
|
||||
title?: string,
|
||||
width?: number,
|
||||
height?: number,
|
||||
|};
|
||||
|
||||
export default function Image({ src, alt, ...rest }: Props) {
|
||||
return <img src={cdnPath(src)} alt={alt} {...rest} />;
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
import VisuallyHidden from "components/VisuallyHidden";
|
||||
|
||||
const RealTextarea = styled.textarea`
|
||||
border: 0;
|
||||
@@ -75,8 +75,8 @@ export const LabelText = styled.div`
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
export type Props = {
|
||||
type?: string,
|
||||
export type Props = {|
|
||||
type?: "text" | "email" | "checkbox" | "search",
|
||||
value?: string,
|
||||
label?: string,
|
||||
className?: string,
|
||||
@@ -85,9 +85,18 @@ export type Props = {
|
||||
short?: boolean,
|
||||
margin?: string | number,
|
||||
icon?: React.Node,
|
||||
name?: string,
|
||||
minLength?: number,
|
||||
maxLength?: number,
|
||||
autoFocus?: boolean,
|
||||
autoComplete?: boolean | string,
|
||||
readOnly?: boolean,
|
||||
required?: boolean,
|
||||
placeholder?: string,
|
||||
onChange?: (ev: SyntheticInputEvent<HTMLInputElement>) => mixed,
|
||||
onFocus?: (ev: SyntheticEvent<>) => void,
|
||||
onBlur?: (ev: SyntheticEvent<>) => void,
|
||||
};
|
||||
|};
|
||||
|
||||
@observer
|
||||
class Input extends React.Component<Props> {
|
||||
|
||||
@@ -8,13 +8,13 @@ import Editor from "components/Editor";
|
||||
import HelpText from "components/HelpText";
|
||||
import { LabelText, Outline } from "components/Input";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
label: string,
|
||||
minHeight?: number,
|
||||
maxHeight?: number,
|
||||
readOnly?: boolean,
|
||||
ui: UiStore,
|
||||
};
|
||||
|};
|
||||
|
||||
@observer
|
||||
class InputRich extends React.Component<Props> {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import Input from "./Input";
|
||||
import { type Theme } from "types";
|
||||
import { meta } from "utils/keyboard";
|
||||
import { searchUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
@@ -16,6 +17,8 @@ type Props = {
|
||||
theme: Theme,
|
||||
source: string,
|
||||
placeholder?: string,
|
||||
label?: string,
|
||||
labelHidden?: boolean,
|
||||
collectionId?: string,
|
||||
t: TFunction,
|
||||
};
|
||||
@@ -25,7 +28,7 @@ class InputSearch extends React.Component<Props> {
|
||||
input: ?Input;
|
||||
@observable focused: boolean = false;
|
||||
|
||||
@keydown("meta+f")
|
||||
@keydown(`${meta}+f`)
|
||||
focus(ev: SyntheticEvent<>) {
|
||||
ev.preventDefault();
|
||||
|
||||
@@ -67,6 +70,8 @@ class InputSearch extends React.Component<Props> {
|
||||
color={this.focused ? theme.inputBorderFocused : theme.inputBorder}
|
||||
/>
|
||||
}
|
||||
label={this.props.label}
|
||||
labelHidden={this.props.labelHidden}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
margin={0}
|
||||
|
||||
@@ -2,17 +2,19 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import VisuallyHidden from "components/VisuallyHidden";
|
||||
import { Outline, LabelText } from "./Input";
|
||||
|
||||
const Select = styled.select`
|
||||
border: 0;
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
padding: 4px 0;
|
||||
margin: 0 12px;
|
||||
outline: none;
|
||||
background: none;
|
||||
color: ${(props) => props.theme.text};
|
||||
height: 30px;
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
@@ -34,6 +36,8 @@ export type Props = {
|
||||
className?: string,
|
||||
labelHidden?: boolean,
|
||||
options: Option[],
|
||||
onBlur?: () => void,
|
||||
onFocus?: () => void,
|
||||
};
|
||||
|
||||
@observer
|
||||
|
||||
@@ -4,10 +4,10 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
label: React.Node | string,
|
||||
children: React.Node,
|
||||
};
|
||||
|};
|
||||
|
||||
const Labeled = ({ label, children, ...props }: Props) => (
|
||||
<Flex column {...props}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { languages, languageOptions } from "shared/i18n";
|
||||
import ButtonLink from "components/ButtonLink";
|
||||
import Flex from "components/Flex";
|
||||
import NoticeTip from "components/NoticeTip";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
@@ -68,7 +69,7 @@ export default function LanguagePrompt() {
|
||||
like to change?
|
||||
</Trans>
|
||||
<br />
|
||||
<a
|
||||
<Link
|
||||
onClick={() => {
|
||||
auth.updateUser({
|
||||
language,
|
||||
@@ -77,14 +78,24 @@ export default function LanguagePrompt() {
|
||||
}}
|
||||
>
|
||||
{t("Change Language")}
|
||||
</a>{" "}
|
||||
· <a onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</a>
|
||||
</Link>{" "}
|
||||
·{" "}
|
||||
<Link onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</Link>
|
||||
</span>
|
||||
</Flex>
|
||||
</NoticeTip>
|
||||
);
|
||||
}
|
||||
|
||||
const Link = styled(ButtonLink)`
|
||||
color: ${(props) => props.theme.almostBlack};
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const LanguageIcon = styled(Icon)`
|
||||
margin-right: 12px;
|
||||
`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { MenuIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
@@ -14,14 +15,17 @@ import UiStore from "stores/UiStore";
|
||||
import ErrorSuspended from "scenes/ErrorSuspended";
|
||||
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
|
||||
import Analytics from "components/Analytics";
|
||||
import Button from "components/Button";
|
||||
import DocumentHistory from "components/DocumentHistory";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
import { LoadingIndicatorBar } from "components/LoadingIndicator";
|
||||
import Modal from "components/Modal";
|
||||
import Sidebar from "components/Sidebar";
|
||||
import SettingsSidebar from "components/Sidebar/Settings";
|
||||
import SkipNavContent from "components/SkipNavContent";
|
||||
import SkipNavLink from "components/SkipNavLink";
|
||||
import { type Theme } from "types";
|
||||
import { meta } from "utils/keyboard";
|
||||
import {
|
||||
homeUrl,
|
||||
searchUrl,
|
||||
@@ -65,7 +69,7 @@ class Layout extends React.Component<Props> {
|
||||
window.document.body.style.background = props.theme.background;
|
||||
}
|
||||
|
||||
@keydown("meta+.")
|
||||
@keydown(`${meta}+.`)
|
||||
handleToggleSidebar() {
|
||||
this.props.ui.toggleCollapsedSidebar();
|
||||
}
|
||||
@@ -80,7 +84,7 @@ class Layout extends React.Component<Props> {
|
||||
this.keyboardShortcutsOpen = false;
|
||||
};
|
||||
|
||||
@keydown(["t", "/", "meta+k"])
|
||||
@keydown(["t", "/", `${meta}+k`])
|
||||
goToSearch(ev: SyntheticEvent<>) {
|
||||
if (this.props.ui.editMode) return;
|
||||
ev.preventDefault();
|
||||
@@ -98,6 +102,7 @@ class Layout extends React.Component<Props> {
|
||||
const { auth, t, ui } = this.props;
|
||||
const { user, team } = auth;
|
||||
const showSidebar = auth.authenticated && user && team;
|
||||
const sidebarCollapsed = ui.editMode || ui.sidebarCollapsed;
|
||||
|
||||
if (auth.isSuspended) return <ErrorSuspended />;
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
@@ -111,11 +116,19 @@ class Layout extends React.Component<Props> {
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
</Helmet>
|
||||
<SkipNavLink />
|
||||
<Analytics />
|
||||
|
||||
{this.props.ui.progressBarVisible && <LoadingIndicatorBar />}
|
||||
{this.props.notifications}
|
||||
|
||||
<MobileMenuButton
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
icon={<MenuIcon />}
|
||||
iconColor="currentColor"
|
||||
neutral
|
||||
/>
|
||||
|
||||
<Container auto>
|
||||
{showSidebar && (
|
||||
<Switch>
|
||||
@@ -124,10 +137,17 @@ class Layout extends React.Component<Props> {
|
||||
</Switch>
|
||||
)}
|
||||
|
||||
<SkipNavContent />
|
||||
<Content
|
||||
auto
|
||||
justify="center"
|
||||
sidebarCollapsed={ui.editMode || ui.sidebarCollapsed}
|
||||
$isResizing={ui.sidebarIsResizing}
|
||||
$sidebarCollapsed={sidebarCollapsed}
|
||||
style={
|
||||
sidebarCollapsed
|
||||
? undefined
|
||||
: { marginLeft: `${ui.sidebarWidth}px` }
|
||||
}
|
||||
>
|
||||
{this.props.children}
|
||||
</Content>
|
||||
@@ -159,19 +179,38 @@ const Container = styled(Flex)`
|
||||
min-height: 100%;
|
||||
`;
|
||||
|
||||
const MobileMenuButton = styled(Button)`
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: ${(props) => props.theme.depths.sidebar - 1};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: none;
|
||||
`};
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Content = styled(Flex)`
|
||||
margin: 0;
|
||||
transition: margin-left 100ms ease-out;
|
||||
transition: ${(props) =>
|
||||
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
|
||||
|
||||
@media print {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
margin-left: 0 !important;
|
||||
`}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
margin-left: ${(props) =>
|
||||
props.sidebarCollapsed
|
||||
? props.theme.sidebarCollapsedWidth
|
||||
: props.theme.sidebarWidth};
|
||||
${(props) =>
|
||||
props.$sidebarCollapsed &&
|
||||
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
|
||||
`};
|
||||
`;
|
||||
|
||||
|
||||
89
app/components/LocaleTime.js
Normal file
89
app/components/LocaleTime.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import format from "date-fns/format";
|
||||
import * as React from "react";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import useUserLocale from "hooks/useUserLocale";
|
||||
|
||||
const locales = {
|
||||
en: require(`date-fns/locale/en`),
|
||||
de: require(`date-fns/locale/de`),
|
||||
es: require(`date-fns/locale/es`),
|
||||
fr: require(`date-fns/locale/fr`),
|
||||
it: require(`date-fns/locale/it`),
|
||||
ko: require(`date-fns/locale/ko`),
|
||||
pt: require(`date-fns/locale/pt`),
|
||||
zh: require(`date-fns/locale/zh_cn`),
|
||||
};
|
||||
|
||||
let callbacks = [];
|
||||
|
||||
// This is a shared timer that fires every minute, used for
|
||||
// updating all Time components across the page all at once.
|
||||
setInterval(() => {
|
||||
callbacks.forEach((cb) => cb());
|
||||
}, 1000 * 60);
|
||||
|
||||
function eachMinute(fn) {
|
||||
callbacks.push(fn);
|
||||
|
||||
return () => {
|
||||
callbacks = callbacks.filter((cb) => cb !== fn);
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
dateTime: string,
|
||||
children?: React.Node,
|
||||
tooltipDelay?: number,
|
||||
addSuffix?: boolean,
|
||||
shorten?: boolean,
|
||||
};
|
||||
|
||||
function LocaleTime({
|
||||
addSuffix,
|
||||
children,
|
||||
dateTime,
|
||||
shorten,
|
||||
tooltipDelay,
|
||||
}: Props) {
|
||||
const userLocale = useUserLocale();
|
||||
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line no-unused-vars
|
||||
const callback = React.useRef();
|
||||
|
||||
React.useEffect(() => {
|
||||
callback.current = eachMinute(() => {
|
||||
setMinutesMounted((state) => ++state);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (callback.current) {
|
||||
callback.current();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
let content = distanceInWordsToNow(dateTime, {
|
||||
addSuffix,
|
||||
locale: userLocale ? locales[userLocale] : undefined,
|
||||
});
|
||||
|
||||
if (shorten) {
|
||||
content = content
|
||||
.replace("about", "")
|
||||
.replace("less than a minute ago", "just now")
|
||||
.replace("minute", "min");
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
tooltip={format(dateTime, "MMMM Do, YYYY h:mm a")}
|
||||
delay={tooltipDelay}
|
||||
placement="bottom"
|
||||
>
|
||||
<time dateTime={dateTime}>{children || content}</time>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default LocaleTime;
|
||||
@@ -5,10 +5,10 @@ import { randomInteger } from "shared/random";
|
||||
import { pulsate } from "shared/styles/animations";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
header?: boolean,
|
||||
height?: number,
|
||||
};
|
||||
|};
|
||||
|
||||
class Mask extends React.Component<Props> {
|
||||
width: number;
|
||||
@@ -23,7 +23,7 @@ class Mask extends React.Component<Props> {
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Redacted width={this.width} {...this.props} />;
|
||||
return <Redacted width={this.width} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,12 @@ import Scrollable from "components/Scrollable";
|
||||
|
||||
ReactModal.setAppElement("#root");
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
children?: React.Node,
|
||||
isOpen: boolean,
|
||||
title?: string,
|
||||
onRequestClose: () => void,
|
||||
};
|
||||
|};
|
||||
|
||||
const GlobalStyles = createGlobalStyle`
|
||||
.ReactModal__Overlay {
|
||||
|
||||
@@ -3,15 +3,17 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Button = styled.button`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
line-height: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
export default React.forwardRef<any, typeof Button>((props, ref) => (
|
||||
<Button {...props} ref={ref} />
|
||||
));
|
||||
export default React.forwardRef<any, typeof Button>(
|
||||
({ size = 24, ...props }, ref) => <Button size={size} {...props} ref={ref} />
|
||||
);
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import useStores from "hooks/useStores";
|
||||
import { cdnPath } from "utils/urls";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
title: string,
|
||||
favicon?: string,
|
||||
auth: AuthStore,
|
||||
};
|
||||
|};
|
||||
|
||||
const PageTitle = observer(({ auth, title, favicon }: Props) => {
|
||||
const PageTitle = ({ title, favicon }: Props) => {
|
||||
const { auth } = useStores();
|
||||
const { team } = auth;
|
||||
|
||||
return (
|
||||
@@ -21,12 +22,12 @@ const PageTitle = observer(({ auth, title, favicon }: Props) => {
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
type="image/png"
|
||||
href={favicon || "/favicon-32.png"}
|
||||
href={favicon || cdnPath("/favicon-32.png")}
|
||||
sizes="32x32"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</Helmet>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default inject("auth")(PageTitle);
|
||||
export default observer(PageTitle);
|
||||
|
||||
@@ -2,16 +2,22 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import Document from "models/Document";
|
||||
import DocumentPreview from "components/DocumentPreview";
|
||||
import DocumentListItem from "components/DocumentListItem";
|
||||
import PaginatedList from "components/PaginatedList";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
documents: Document[],
|
||||
fetch: (options: ?Object) => Promise<void>,
|
||||
options?: Object,
|
||||
heading?: React.Node,
|
||||
empty?: React.Node,
|
||||
};
|
||||
showNestedDocuments?: boolean,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
showDraft?: boolean,
|
||||
showTemplate?: boolean,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class PaginatedDocumentList extends React.Component<Props> {
|
||||
@@ -26,7 +32,7 @@ class PaginatedDocumentList extends React.Component<Props> {
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item) => (
|
||||
<DocumentPreview key={item.id} document={item} {...rest} />
|
||||
<DocumentListItem key={item.id} document={item} {...rest} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// @flow
|
||||
import BoundlessPopover from "boundless-popover";
|
||||
import * as React from "react";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
|
||||
const fadeIn = keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledPopover = styled(BoundlessPopover)`
|
||||
animation: ${fadeIn} 150ms ease-in-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
line-height: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: ${(props) => props.theme.depths.popover};
|
||||
|
||||
svg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
position: absolute;
|
||||
|
||||
polygon:first-child {
|
||||
fill: rgba(0, 0, 0, 0.075);
|
||||
}
|
||||
polygon {
|
||||
fill: #fff;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Dialog = styled.div`
|
||||
outline: none;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 8px 16px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
line-height: 1.5;
|
||||
padding: 16px;
|
||||
margin-top: 14px;
|
||||
min-width: 200px;
|
||||
min-height: 150px;
|
||||
`;
|
||||
|
||||
export const Preset = BoundlessPopover.preset;
|
||||
|
||||
export default function Popover(props: Object) {
|
||||
return (
|
||||
<StyledPopover
|
||||
dialogComponent={Dialog}
|
||||
closeOnOutsideScroll
|
||||
closeOnOutsideFocus
|
||||
closeOnEscKey
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
12
app/components/ProfiledRoute.js
Normal file
12
app/components/ProfiledRoute.js
Normal file
@@ -0,0 +1,12 @@
|
||||
// @flow
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { Route } from "react-router-dom";
|
||||
import env from "env";
|
||||
|
||||
let Component = Route;
|
||||
|
||||
if (env.SENTRY_DSN) {
|
||||
Component = Sentry.withSentryRouting(Route);
|
||||
}
|
||||
|
||||
export default Component;
|
||||
@@ -1,28 +1,52 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import useWindowSize from "hooks/useWindowSize";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
shadow?: boolean,
|
||||
};
|
||||
topShadow?: boolean,
|
||||
bottomShadow?: boolean,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class Scrollable extends React.Component<Props> {
|
||||
@observable shadow: boolean = false;
|
||||
function Scrollable({ shadow, topShadow, bottomShadow, ...rest }: Props) {
|
||||
const ref = React.useRef<?HTMLDivElement>();
|
||||
const [topShadowVisible, setTopShadow] = React.useState(false);
|
||||
const [bottomShadowVisible, setBottomShadow] = React.useState(false);
|
||||
const { height } = useWindowSize();
|
||||
|
||||
handleScroll = (ev: SyntheticMouseEvent<HTMLDivElement>) => {
|
||||
this.shadow = !!(this.props.shadow && ev.currentTarget.scrollTop > 0);
|
||||
};
|
||||
const updateShadows = React.useCallback(() => {
|
||||
const c = ref.current;
|
||||
if (!c) return;
|
||||
|
||||
render() {
|
||||
const { shadow, ...rest } = this.props;
|
||||
const scrollTop = c.scrollTop;
|
||||
const tsv = !!((shadow || topShadow) && scrollTop > 0);
|
||||
if (tsv !== topShadowVisible) {
|
||||
setTopShadow(tsv);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper onScroll={this.handleScroll} shadow={this.shadow} {...rest} />
|
||||
);
|
||||
}
|
||||
const wrapperHeight = c.scrollHeight - c.clientHeight;
|
||||
const bsv = !!((shadow || bottomShadow) && wrapperHeight - scrollTop !== 0);
|
||||
|
||||
if (bsv !== bottomShadowVisible) {
|
||||
setBottomShadow(bsv);
|
||||
}
|
||||
}, [shadow, topShadow, bottomShadow, topShadowVisible, bottomShadowVisible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
updateShadows();
|
||||
}, [height, updateShadows]);
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
ref={ref}
|
||||
onScroll={updateShadows}
|
||||
$topShadowVisible={topShadowVisible}
|
||||
$bottomShadowVisible={bottomShadowVisible}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
@@ -31,9 +55,20 @@ const Wrapper = styled.div`
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
box-shadow: ${(props) =>
|
||||
props.shadow ? "0 1px inset rgba(0,0,0,.1)" : "none"};
|
||||
transition: all 250ms ease-in-out;
|
||||
box-shadow: ${(props) => {
|
||||
if (props.$topShadowVisible && props.$bottomShadowVisible) {
|
||||
return "0 1px inset rgba(0,0,0,.1), 0 -1px inset rgba(0,0,0,.1)";
|
||||
}
|
||||
if (props.$topShadowVisible) {
|
||||
return "0 1px inset rgba(0,0,0,.1)";
|
||||
}
|
||||
if (props.$bottomShadowVisible) {
|
||||
return "0 -1px inset rgba(0,0,0,.1)";
|
||||
}
|
||||
|
||||
return "none";
|
||||
}};
|
||||
transition: all 100ms ease-in-out;
|
||||
`;
|
||||
|
||||
export default Scrollable;
|
||||
export default observer(Scrollable);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
HomeIcon,
|
||||
@@ -10,14 +9,11 @@ import {
|
||||
ShapesIcon,
|
||||
TrashIcon,
|
||||
PlusIcon,
|
||||
SettingsIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import CollectionNew from "scenes/CollectionNew";
|
||||
import Invite from "scenes/Invite";
|
||||
import Flex from "components/Flex";
|
||||
@@ -29,175 +25,179 @@ import Collections from "./components/Collections";
|
||||
import HeaderBlock from "./components/HeaderBlock";
|
||||
import Section from "./components/Section";
|
||||
import SidebarLink from "./components/SidebarLink";
|
||||
import useStores from "hooks/useStores";
|
||||
import AccountMenu from "menus/AccountMenu";
|
||||
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
documents: DocumentsStore,
|
||||
policies: PoliciesStore,
|
||||
t: TFunction,
|
||||
};
|
||||
function MainSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { policies, auth, documents } = useStores();
|
||||
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
|
||||
const [
|
||||
createCollectionModalOpen,
|
||||
setCreateCollectionModalOpen,
|
||||
] = React.useState(false);
|
||||
|
||||
@observer
|
||||
class MainSidebar extends React.Component<Props> {
|
||||
@observable inviteModalOpen = false;
|
||||
@observable createCollectionModalOpen = false;
|
||||
React.useEffect(() => {
|
||||
documents.fetchDrafts();
|
||||
documents.fetchTemplates();
|
||||
}, [documents]);
|
||||
|
||||
componentDidMount() {
|
||||
this.props.documents.fetchDrafts();
|
||||
this.props.documents.fetchTemplates();
|
||||
}
|
||||
const handleCreateCollectionModalOpen = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
setCreateCollectionModalOpen(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
handleCreateCollectionModalOpen = (ev: SyntheticEvent<>) => {
|
||||
const handleCreateCollectionModalClose = React.useCallback(() => {
|
||||
setCreateCollectionModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleInviteModalOpen = React.useCallback((ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.createCollectionModalOpen = true;
|
||||
};
|
||||
setInviteModalOpen(true);
|
||||
}, []);
|
||||
|
||||
handleCreateCollectionModalClose = (ev: SyntheticEvent<>) => {
|
||||
this.createCollectionModalOpen = false;
|
||||
};
|
||||
const handleInviteModalClose = React.useCallback(() => {
|
||||
setInviteModalOpen(false);
|
||||
}, []);
|
||||
|
||||
handleInviteModalOpen = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.inviteModalOpen = true;
|
||||
};
|
||||
const { user, team } = auth;
|
||||
if (!user || !team) return null;
|
||||
|
||||
handleInviteModalClose = () => {
|
||||
this.inviteModalOpen = false;
|
||||
};
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
render() {
|
||||
const { auth, documents, policies, t } = this.props;
|
||||
const { user, team } = auth;
|
||||
if (!user || !team) return null;
|
||||
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<AccountMenu
|
||||
label={
|
||||
<HeaderBlock
|
||||
subheading={user.name}
|
||||
teamName={team.name}
|
||||
logoUrl={team.avatarUrl}
|
||||
showDisclosure
|
||||
return (
|
||||
<Sidebar>
|
||||
<AccountMenu>
|
||||
{(props) => (
|
||||
<HeaderBlock
|
||||
{...props}
|
||||
subheading={user.name}
|
||||
teamName={team.name}
|
||||
logoUrl={team.avatarUrl}
|
||||
showDisclosure
|
||||
/>
|
||||
)}
|
||||
</AccountMenu>
|
||||
<Flex auto column>
|
||||
<Scrollable shadow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to="/home"
|
||||
icon={<HomeIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Flex auto column>
|
||||
<Scrollable shadow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to={{
|
||||
pathname: "/search",
|
||||
state: { fromMenu: true },
|
||||
}}
|
||||
icon={<SearchIcon color="currentColor" />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/starred"
|
||||
icon={<StarredIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Starred")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/templates"
|
||||
icon={<ShapesIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Templates")}
|
||||
active={documents.active ? documents.active.template : undefined}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/drafts"
|
||||
icon={<EditIcon color="currentColor" />}
|
||||
label={
|
||||
<Drafts align="center">
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 && (
|
||||
<Bubble count={documents.totalDrafts} />
|
||||
)}
|
||||
</Drafts>
|
||||
}
|
||||
active={
|
||||
documents.active
|
||||
? !documents.active.publishedAt &&
|
||||
!documents.active.isDeleted &&
|
||||
!documents.active.isTemplate
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<Collections onCreateCollection={handleCreateCollectionModalOpen} />
|
||||
</Section>
|
||||
</Scrollable>
|
||||
<Secondary>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to="/archive"
|
||||
icon={<ArchiveIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
active={
|
||||
documents.active
|
||||
? documents.active.isArchived && !documents.active.isDeleted
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/trash"
|
||||
icon={<TrashIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Trash")}
|
||||
active={documents.active ? documents.active.isDeleted : undefined}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings"
|
||||
icon={<SettingsIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Settings")}
|
||||
/>
|
||||
{can.invite && (
|
||||
<SidebarLink
|
||||
to="/home"
|
||||
icon={<HomeIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
to="/settings/people"
|
||||
onClick={handleInviteModalOpen}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label={`${t("Invite people")}…`}
|
||||
/>
|
||||
<SidebarLink
|
||||
to={{
|
||||
pathname: "/search",
|
||||
state: { fromMenu: true },
|
||||
}}
|
||||
icon={<SearchIcon color="currentColor" />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/starred"
|
||||
icon={<StarredIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Starred")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/templates"
|
||||
icon={<ShapesIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Templates")}
|
||||
active={
|
||||
documents.active ? documents.active.template : undefined
|
||||
}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/drafts"
|
||||
icon={<EditIcon color="currentColor" />}
|
||||
label={
|
||||
<Drafts align="center">
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 && (
|
||||
<Bubble count={documents.totalDrafts} />
|
||||
)}
|
||||
</Drafts>
|
||||
}
|
||||
active={
|
||||
documents.active
|
||||
? !documents.active.publishedAt &&
|
||||
!documents.active.isDeleted &&
|
||||
!documents.active.isTemplate
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<Collections
|
||||
onCreateCollection={this.handleCreateCollectionModalOpen}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to="/archive"
|
||||
icon={<ArchiveIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
active={
|
||||
documents.active
|
||||
? documents.active.isArchived && !documents.active.isDeleted
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/trash"
|
||||
icon={<TrashIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Trash")}
|
||||
active={
|
||||
documents.active ? documents.active.isDeleted : undefined
|
||||
}
|
||||
/>
|
||||
{can.invite && (
|
||||
<SidebarLink
|
||||
to="/settings/people"
|
||||
onClick={this.handleInviteModalOpen}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label={t("Invite people…")}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Scrollable>
|
||||
</Flex>
|
||||
<Modal
|
||||
title={t("Invite people")}
|
||||
onRequestClose={this.handleInviteModalClose}
|
||||
isOpen={this.inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={this.handleInviteModalClose} />
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Create a collection")}
|
||||
onRequestClose={this.handleCreateCollectionModalClose}
|
||||
isOpen={this.createCollectionModalOpen}
|
||||
>
|
||||
<CollectionNew onSubmit={this.handleCreateCollectionModalClose} />
|
||||
</Modal>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</Section>
|
||||
</Secondary>
|
||||
</Flex>
|
||||
<Modal
|
||||
title={t("Invite people")}
|
||||
onRequestClose={handleInviteModalClose}
|
||||
isOpen={inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={handleInviteModalClose} />
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Create a collection")}
|
||||
onRequestClose={handleCreateCollectionModalClose}
|
||||
isOpen={createCollectionModalOpen}
|
||||
>
|
||||
<CollectionNew onSubmit={handleCreateCollectionModalClose} />
|
||||
</Modal>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const Secondary = styled.div`
|
||||
overflow-x: hidden;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Drafts = styled(Flex)`
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export default withTranslation()<MainSidebar>(
|
||||
inject("documents", "policies", "auth")(MainSidebar)
|
||||
);
|
||||
export default observer(MainSidebar);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
DocumentIcon,
|
||||
EmailIcon,
|
||||
@@ -13,11 +13,9 @@ import {
|
||||
ExpandedIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import type { RouterHistory } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import Flex from "components/Flex";
|
||||
import Scrollable from "components/Scrollable";
|
||||
|
||||
@@ -30,131 +28,123 @@ import Version from "./components/Version";
|
||||
import SlackIcon from "./icons/Slack";
|
||||
import ZapierIcon from "./icons/Zapier";
|
||||
import env from "env";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
const isHosted = env.DEPLOYMENT === "hosted";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
policies: PoliciesStore,
|
||||
auth: AuthStore,
|
||||
t: TFunction,
|
||||
};
|
||||
function SettingsSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const team = useCurrentTeam();
|
||||
const { policies } = useStores();
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
@observer
|
||||
class SettingsSidebar extends React.Component<Props> {
|
||||
returnToDashboard = () => {
|
||||
this.props.history.push("/home");
|
||||
};
|
||||
const returnToDashboard = React.useCallback(() => {
|
||||
history.push("/home");
|
||||
}, [history]);
|
||||
|
||||
render() {
|
||||
const { policies, t, auth } = this.props;
|
||||
const { team } = auth;
|
||||
if (!team) return null;
|
||||
return (
|
||||
<Sidebar>
|
||||
<HeaderBlock
|
||||
subheading={
|
||||
<ReturnToApp align="center">
|
||||
<BackIcon color="currentColor" /> {t("Return to App")}
|
||||
</ReturnToApp>
|
||||
}
|
||||
teamName={team.name}
|
||||
logoUrl={team.avatarUrl}
|
||||
onClick={returnToDashboard}
|
||||
/>
|
||||
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<HeaderBlock
|
||||
subheading={
|
||||
<ReturnToApp align="center">
|
||||
<BackIcon color="currentColor" /> {t("Return to App")}
|
||||
</ReturnToApp>
|
||||
}
|
||||
teamName={team.name}
|
||||
logoUrl={team.avatarUrl}
|
||||
onClick={this.returnToDashboard}
|
||||
/>
|
||||
|
||||
<Flex auto column>
|
||||
<Scrollable shadow>
|
||||
<Section>
|
||||
<Header>Account</Header>
|
||||
<SidebarLink
|
||||
to="/settings"
|
||||
icon={<ProfileIcon color="currentColor" />}
|
||||
label={t("Profile")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/notifications"
|
||||
icon={<EmailIcon color="currentColor" />}
|
||||
label={t("Notifications")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/tokens"
|
||||
icon={<CodeIcon color="currentColor" />}
|
||||
label={t("API Tokens")}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<Header>Team</Header>
|
||||
{can.update && (
|
||||
<SidebarLink
|
||||
to="/settings/details"
|
||||
icon={<TeamIcon color="currentColor" />}
|
||||
label={t("Details")}
|
||||
/>
|
||||
)}
|
||||
{can.update && (
|
||||
<SidebarLink
|
||||
to="/settings/security"
|
||||
icon={<PadlockIcon color="currentColor" />}
|
||||
label={t("Security")}
|
||||
/>
|
||||
)}
|
||||
<SidebarLink
|
||||
to="/settings/people"
|
||||
icon={<UserIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("People")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/groups"
|
||||
icon={<GroupIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Groups")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/shares"
|
||||
icon={<LinkIcon color="currentColor" />}
|
||||
label={t("Share Links")}
|
||||
/>
|
||||
{can.export && (
|
||||
<SidebarLink
|
||||
to="/settings/import-export"
|
||||
icon={<DocumentIcon color="currentColor" />}
|
||||
label={t("Import / Export")}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
<Flex auto column>
|
||||
<Scrollable topShadow>
|
||||
<Section>
|
||||
<Header>{t("Account")}</Header>
|
||||
<SidebarLink
|
||||
to="/settings"
|
||||
icon={<ProfileIcon color="currentColor" />}
|
||||
label={t("Profile")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/notifications"
|
||||
icon={<EmailIcon color="currentColor" />}
|
||||
label={t("Notifications")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/tokens"
|
||||
icon={<CodeIcon color="currentColor" />}
|
||||
label={t("API Tokens")}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<Header>{t("Team")}</Header>
|
||||
{can.update && (
|
||||
<Section>
|
||||
<Header>{t("Integrations")}</Header>
|
||||
<SidebarLink
|
||||
to="/settings/details"
|
||||
icon={<TeamIcon color="currentColor" />}
|
||||
label={t("Details")}
|
||||
/>
|
||||
)}
|
||||
{can.update && (
|
||||
<SidebarLink
|
||||
to="/settings/security"
|
||||
icon={<PadlockIcon color="currentColor" />}
|
||||
label={t("Security")}
|
||||
/>
|
||||
)}
|
||||
<SidebarLink
|
||||
to="/settings/people"
|
||||
icon={<UserIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("People")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/groups"
|
||||
icon={<GroupIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Groups")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/shares"
|
||||
icon={<LinkIcon color="currentColor" />}
|
||||
label={t("Share Links")}
|
||||
/>
|
||||
{can.export && (
|
||||
<SidebarLink
|
||||
to="/settings/import-export"
|
||||
icon={<DocumentIcon color="currentColor" />}
|
||||
label={t("Import / Export")}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
{can.update && (
|
||||
<Section>
|
||||
<Header>{t("Integrations")}</Header>
|
||||
<SidebarLink
|
||||
to="/settings/integrations/slack"
|
||||
icon={<SlackIcon color="currentColor" />}
|
||||
label="Slack"
|
||||
/>
|
||||
{isHosted && (
|
||||
<SidebarLink
|
||||
to="/settings/integrations/slack"
|
||||
icon={<SlackIcon color="currentColor" />}
|
||||
label="Slack"
|
||||
to="/settings/integrations/zapier"
|
||||
icon={<ZapierIcon color="currentColor" />}
|
||||
label="Zapier"
|
||||
/>
|
||||
{isHosted && (
|
||||
<SidebarLink
|
||||
to="/settings/integrations/zapier"
|
||||
icon={<ZapierIcon color="currentColor" />}
|
||||
label="Zapier"
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{can.update && !isHosted && (
|
||||
<Section>
|
||||
<Header>{t("Installation")}</Header>
|
||||
<Version />
|
||||
</Section>
|
||||
)}
|
||||
</Scrollable>
|
||||
</Flex>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{can.update && !isHosted && (
|
||||
<Section>
|
||||
<Header>{t("Installation")}</Header>
|
||||
<Version />
|
||||
</Section>
|
||||
)}
|
||||
</Scrollable>
|
||||
</Flex>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const BackIcon = styled(ExpandedIcon)`
|
||||
@@ -166,6 +156,4 @@ const ReturnToApp = styled(Flex)`
|
||||
height: 16px;
|
||||
`;
|
||||
|
||||
export default withTranslation()<SettingsSidebar>(
|
||||
inject("auth", "policies")(SettingsSidebar)
|
||||
);
|
||||
export default observer(SettingsSidebar);
|
||||
|
||||
@@ -1,55 +1,171 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon, MenuIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Portal } from "react-portal";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import type { Location } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import CollapseToggle, { Button } from "./components/CollapseToggle";
|
||||
import CollapseToggle, {
|
||||
Button as CollapseButton,
|
||||
} from "./components/CollapseToggle";
|
||||
import ResizeBorder from "./components/ResizeBorder";
|
||||
import ResizeHandle from "./components/ResizeHandle";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
let firstRender = true;
|
||||
let BOUNCE_ANIMATION_MS = 250;
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
location: Location,
|
||||
};
|
||||
|
||||
const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [isAnimating, setAnimating] = React.useState(false);
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
const isSmallerThanMinimum = width < minWidth;
|
||||
|
||||
const handleDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
// suppresses text selection
|
||||
event.preventDefault();
|
||||
|
||||
// this is simple because the sidebar is always against the left edge
|
||||
const width = Math.min(event.pageX - offset, maxWidth);
|
||||
setWidth(width);
|
||||
},
|
||||
[offset, maxWidth, setWidth]
|
||||
);
|
||||
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
setResizing(false);
|
||||
|
||||
if (isSmallerThanMinimum) {
|
||||
setWidth(minWidth);
|
||||
setAnimating(true);
|
||||
} else {
|
||||
setWidth(width);
|
||||
}
|
||||
}, [isSmallerThanMinimum, minWidth, width, setWidth]);
|
||||
|
||||
const handleStartDrag = React.useCallback(
|
||||
(event) => {
|
||||
setOffset(event.pageX - width);
|
||||
setResizing(true);
|
||||
setAnimating(false);
|
||||
},
|
||||
[width]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isAnimating) {
|
||||
setTimeout(() => setAnimating(false), BOUNCE_ANIMATION_MS);
|
||||
}
|
||||
}, [isAnimating]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isResizing) {
|
||||
document.addEventListener("mousemove", handleDrag);
|
||||
document.addEventListener("mouseup", handleStopDrag);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleDrag);
|
||||
document.removeEventListener("mouseup", handleStopDrag);
|
||||
};
|
||||
}, [isResizing, handleDrag, handleStopDrag]);
|
||||
|
||||
return { isAnimating, isSmallerThanMinimum, isResizing, handleStartDrag };
|
||||
};
|
||||
|
||||
function Sidebar({ location, children }: Props) {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
const previousLocation = usePrevious(location);
|
||||
|
||||
const width = ui.sidebarWidth;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
const collapsed = ui.editMode || ui.sidebarCollapsed;
|
||||
|
||||
const {
|
||||
isAnimating,
|
||||
isSmallerThanMinimum,
|
||||
isResizing,
|
||||
handleStartDrag,
|
||||
} = useResize({
|
||||
width,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
setWidth: ui.setSidebarWidth,
|
||||
});
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
ui.setSidebarWidth(theme.sidebarWidth);
|
||||
}, [ui, theme.sidebarWidth]);
|
||||
|
||||
React.useEffect(() => {
|
||||
ui.setSidebarResizing(isResizing);
|
||||
}, [ui, isResizing]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (location !== previousLocation) {
|
||||
ui.hideMobileSidebar();
|
||||
}
|
||||
}, [ui, location, previousLocation]);
|
||||
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
width: `${width}px`,
|
||||
left:
|
||||
collapsed && !ui.mobileSidebarVisible
|
||||
? `${-width + theme.sidebarCollapsedWidth}px`
|
||||
: 0,
|
||||
}),
|
||||
[width, collapsed, theme.sidebarCollapsedWidth, ui.mobileSidebarVisible]
|
||||
);
|
||||
|
||||
const content = (
|
||||
<Container
|
||||
mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
collapsed={ui.editMode || ui.sidebarCollapsed}
|
||||
style={style}
|
||||
$sidebarWidth={ui.sidebarWidth}
|
||||
$isAnimating={isAnimating}
|
||||
$isSmallerThanMinimum={isSmallerThanMinimum}
|
||||
$mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
$collapsed={collapsed}
|
||||
column
|
||||
>
|
||||
<CollapseToggle
|
||||
collapsed={ui.sidebarCollapsed}
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
/>
|
||||
<Toggle
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
>
|
||||
{ui.mobileSidebarVisible ? (
|
||||
<CloseIcon size={32} />
|
||||
) : (
|
||||
<MenuIcon size={32} />
|
||||
)}
|
||||
</Toggle>
|
||||
{!isResizing && (
|
||||
<CollapseToggle
|
||||
collapsed={ui.sidebarCollapsed}
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
/>
|
||||
)}
|
||||
{ui.mobileSidebarVisible && (
|
||||
<Portal>
|
||||
<Fade>
|
||||
<Background onClick={ui.toggleMobileSidebar} />
|
||||
</Fade>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
{children}
|
||||
{!ui.sidebarCollapsed && (
|
||||
<ResizeBorder
|
||||
onMouseDown={handleStartDrag}
|
||||
onDoubleClick={handleReset}
|
||||
$isResizing={isResizing}
|
||||
>
|
||||
<ResizeHandle aria-label={t("Resize sidebar")} />
|
||||
</ResizeBorder>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -62,82 +178,67 @@ function Sidebar({ location, children }: Props) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const Background = styled.a`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
cursor: default;
|
||||
z-index: ${(props) => props.theme.depths.sidebar - 1};
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
transition: box-shadow, 100ms, ease-in-out, left 100ms ease-out,
|
||||
${(props) => props.theme.backgroundTransition};
|
||||
margin-left: ${(props) => (props.mobileSidebarVisible ? 0 : "-100%")};
|
||||
transition: box-shadow, 100ms, ease-in-out, margin-left 100ms ease-out,
|
||||
left 100ms ease-out,
|
||||
${(props) => props.theme.backgroundTransition}
|
||||
${(props) =>
|
||||
props.$isAnimating ? `,width ${BOUNCE_ANIMATION_MS}ms ease-out` : ""};
|
||||
margin-left: ${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")};
|
||||
z-index: ${(props) => props.theme.depths.sidebar};
|
||||
max-width: 70%;
|
||||
min-width: 280px;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: "";
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
position: absolute;
|
||||
top: -50vh;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 50vh;
|
||||
}
|
||||
|
||||
&:after {
|
||||
top: auto;
|
||||
bottom: -50vh;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
left: ${(props) =>
|
||||
props.collapsed
|
||||
? `calc(-${props.theme.sidebarWidth} + ${props.theme.sidebarCollapsedWidth})`
|
||||
: 0};
|
||||
width: ${(props) => props.theme.sidebarWidth};
|
||||
margin: 0;
|
||||
z-index: 3;
|
||||
min-width: 0;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
left: 0;
|
||||
left: 0 !important;
|
||||
box-shadow: ${(props) =>
|
||||
props.collapsed ? "rgba(0, 0, 0, 0.2) 1px 0 4px" : "none"};
|
||||
props.$collapsed
|
||||
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
|
||||
: props.$isSmallerThanMinimum
|
||||
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
|
||||
: "none"};
|
||||
|
||||
& ${Button} {
|
||||
& ${CollapseButton} {
|
||||
opacity: .75;
|
||||
}
|
||||
|
||||
& ${Button}:hover {
|
||||
& ${CollapseButton}:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:hover):not(:focus-within) > div {
|
||||
opacity: ${(props) => (props.collapsed ? "0" : "1")};
|
||||
opacity: ${(props) => (props.$collapsed ? "0" : "1")};
|
||||
transition: opacity 100ms ease-in-out;
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
const Toggle = styled.a`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: ${(props) => (props.mobileSidebarVisible ? "auto" : 0)};
|
||||
right: ${(props) => (props.mobileSidebarVisible ? 0 : "auto")};
|
||||
z-index: 1;
|
||||
margin: 12px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: none;
|
||||
`};
|
||||
`;
|
||||
|
||||
export default withRouter(observer(Sidebar));
|
||||
|
||||
@@ -8,7 +8,7 @@ import { meta } from "utils/keyboard";
|
||||
|
||||
type Props = {|
|
||||
collapsed: boolean,
|
||||
onClick?: () => void,
|
||||
onClick?: (event: SyntheticEvent<>) => void,
|
||||
|};
|
||||
|
||||
function CollapseToggle({ collapsed, ...rest }: Props) {
|
||||
@@ -21,7 +21,7 @@ function CollapseToggle({ collapsed, ...rest }: Props) {
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button {...rest} aria-hidden>
|
||||
<Button {...rest} tabIndex="-1" aria-hidden>
|
||||
{collapsed ? (
|
||||
<NextIcon color="currentColor" />
|
||||
) : (
|
||||
@@ -43,7 +43,7 @@ export const Button = styled.button`
|
||||
z-index: 1;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
background: ${(props) => props.theme.sidebarItemBackground};
|
||||
background: transparent;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
|
||||
@@ -2,16 +2,19 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import styled from "styled-components";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Collection from "models/Collection";
|
||||
import Document from "models/Document";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import DropToImport from "components/DropToImport";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import useStores from "hooks/useStores";
|
||||
import CollectionMenu from "menus/CollectionMenu";
|
||||
import CollectionSortMenu from "menus/CollectionSortMenu";
|
||||
|
||||
type Props = {|
|
||||
collection: Collection,
|
||||
@@ -39,11 +42,14 @@ function CollectionLink({
|
||||
|
||||
const { documents, policies } = useStores();
|
||||
const expanded = collection.id === ui.activeCollectionId;
|
||||
const manualSort = collection.sort.field === "index";
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
// Droppable
|
||||
// Drop to re-parent
|
||||
const [{ isOver, canDrop }, drop] = useDrop({
|
||||
accept: "document",
|
||||
drop: (item, monitor) => {
|
||||
if (monitor.didDrop()) return;
|
||||
if (!collection) return;
|
||||
documents.move(item.id, collection.id);
|
||||
},
|
||||
@@ -51,16 +57,28 @@ function CollectionLink({
|
||||
return policies.abilities(collection.id).update;
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: !!monitor.isOver(),
|
||||
isOver: !!monitor.isOver({ shallow: true }),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Drop to reorder
|
||||
const [{ isOverReorder }, dropToReorder] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item, monitor) => {
|
||||
if (!collection) return;
|
||||
documents.move(item.id, collection.id, undefined, 0);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: !!monitor.isOver(),
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={drop}>
|
||||
<div ref={drop} style={{ position: "relative" }}>
|
||||
<DropToImport key={collection.id} collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
<SidebarLinkWithPadding
|
||||
key={collection.id}
|
||||
to={collection.url}
|
||||
icon={
|
||||
@@ -68,7 +86,7 @@ function CollectionLink({
|
||||
}
|
||||
iconColor={collection.color}
|
||||
expanded={expanded}
|
||||
menuOpen={menuOpen}
|
||||
showActions={menuOpen || expanded}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
label={
|
||||
<EditableTitle
|
||||
@@ -79,19 +97,30 @@ function CollectionLink({
|
||||
}
|
||||
exact={false}
|
||||
menu={
|
||||
<CollectionMenu
|
||||
position="right"
|
||||
collection={collection}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
<>
|
||||
{can.update && (
|
||||
<CollectionSortMenuWithMargin
|
||||
collection={collection}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
></SidebarLink>
|
||||
/>
|
||||
</DropToImport>
|
||||
{expanded && manualSort && (
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded &&
|
||||
collection.documents.map((node) => (
|
||||
collection.documents.map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
@@ -100,10 +129,19 @@ function CollectionLink({
|
||||
prefetchDocument={prefetchDocument}
|
||||
canUpdate={canUpdate}
|
||||
depth={1.5}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SidebarLinkWithPadding = styled(SidebarLink)`
|
||||
padding-right: 60px;
|
||||
`;
|
||||
|
||||
const CollectionSortMenuWithMargin = styled(CollectionSortMenu)`
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
export default observer(CollectionLink);
|
||||
|
||||
@@ -9,6 +9,7 @@ import Collection from "models/Collection";
|
||||
import Document from "models/Document";
|
||||
import DropToImport from "components/DropToImport";
|
||||
import Fade from "components/Fade";
|
||||
import DropCursor from "./DropCursor";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import useStores from "hooks/useStores";
|
||||
@@ -20,19 +21,21 @@ type Props = {|
|
||||
canUpdate: boolean,
|
||||
collection?: Collection,
|
||||
activeDocument: ?Document,
|
||||
activeDocumentRef?: (?HTMLElement) => void,
|
||||
prefetchDocument: (documentId: string) => Promise<void>,
|
||||
depth: number,
|
||||
index: number,
|
||||
parentId?: string,
|
||||
|};
|
||||
|
||||
function DocumentLink({
|
||||
node,
|
||||
canUpdate,
|
||||
collection,
|
||||
activeDocument,
|
||||
activeDocumentRef,
|
||||
prefetchDocument,
|
||||
depth,
|
||||
canUpdate,
|
||||
index,
|
||||
parentId,
|
||||
}: Props) {
|
||||
const { documents, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
@@ -76,6 +79,14 @@ function DocumentLink({
|
||||
}
|
||||
}, [showChildren]);
|
||||
|
||||
// when the last child document is removed,
|
||||
// also close the local folder state to closed
|
||||
React.useEffect(() => {
|
||||
if (expanded && !hasChildDocuments) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [expanded, hasChildDocuments]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
@@ -108,6 +119,7 @@ function DocumentLink({
|
||||
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const isMoving = documents.movingDocumentId === node.id;
|
||||
const manualSort = collection?.sort.field === "index";
|
||||
|
||||
// Draggable
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
@@ -120,77 +132,131 @@ function DocumentLink({
|
||||
},
|
||||
});
|
||||
|
||||
// Droppable
|
||||
const [{ isOver, canDrop }, drop] = useDrop({
|
||||
const hoverExpanding = React.useRef(null);
|
||||
|
||||
// We set a timeout when the user first starts hovering over the document link,
|
||||
// to trigger expansion of children. Clear this timeout when they stop hovering.
|
||||
const resetHoverExpanding = React.useCallback(() => {
|
||||
if (hoverExpanding.current) {
|
||||
clearTimeout(hoverExpanding.current);
|
||||
hoverExpanding.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Drop to re-parent
|
||||
const [{ isOverReparent, canDropToReparent }, dropToReparent] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item, monitor) => {
|
||||
if (monitor.didDrop()) return;
|
||||
if (!collection) return;
|
||||
documents.move(item.id, collection.id, node.id);
|
||||
},
|
||||
|
||||
canDrop: (item, monitor) =>
|
||||
pathToNode && !pathToNode.includes(monitor.getItem().id),
|
||||
|
||||
hover: (item, monitor) => {
|
||||
// Enables expansion of document children when hovering over the document
|
||||
// for more than half a second.
|
||||
if (
|
||||
hasChildDocuments &&
|
||||
monitor.canDrop() &&
|
||||
monitor.isOver({ shallow: true })
|
||||
) {
|
||||
if (!hoverExpanding.current) {
|
||||
hoverExpanding.current = setTimeout(() => {
|
||||
hoverExpanding.current = null;
|
||||
if (monitor.isOver({ shallow: true })) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
collect: (monitor) => ({
|
||||
isOver: !!monitor.isOver(),
|
||||
canDrop: monitor.canDrop(),
|
||||
isOverReparent: !!monitor.isOver({ shallow: true }),
|
||||
canDropToReparent: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Drop to reorder
|
||||
const [{ isOverReorder }, dropToReorder] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item, monitor) => {
|
||||
if (!collection) return;
|
||||
if (item.id === node.id) return;
|
||||
|
||||
if (expanded) {
|
||||
documents.move(item.id, collection.id, node.id, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
documents.move(item.id, collection.id, parentId, index + 1);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: !!monitor.isOver(),
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Draggable
|
||||
key={node.id}
|
||||
ref={drag}
|
||||
$isDragging={isDragging}
|
||||
$isMoving={isMoving}
|
||||
>
|
||||
<div ref={drop}>
|
||||
<DropToImport documentId={node.id} activeClassName="activeDropZone">
|
||||
<SidebarLink
|
||||
innerRef={isActiveDocument ? activeDocumentRef : undefined}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
to={{
|
||||
pathname: node.url,
|
||||
state: { title: node.title },
|
||||
}}
|
||||
label={
|
||||
<>
|
||||
{hasChildDocuments && (
|
||||
<Disclosure
|
||||
expanded={expanded && !isDragging}
|
||||
onClick={handleDisclosureClick}
|
||||
<div style={{ position: "relative" }} onDragLeave={resetHoverExpanding}>
|
||||
<Draggable
|
||||
key={node.id}
|
||||
ref={drag}
|
||||
$isDragging={isDragging}
|
||||
$isMoving={isMoving}
|
||||
>
|
||||
<div ref={dropToReparent}>
|
||||
<DropToImport documentId={node.id} activeClassName="activeDropZone">
|
||||
<SidebarLink
|
||||
onMouseEnter={handleMouseEnter}
|
||||
to={{
|
||||
pathname: node.url,
|
||||
state: { title: node.title },
|
||||
}}
|
||||
label={
|
||||
<>
|
||||
{hasChildDocuments && (
|
||||
<Disclosure
|
||||
expanded={expanded && !isDragging}
|
||||
onClick={handleDisclosureClick}
|
||||
/>
|
||||
)}
|
||||
<EditableTitle
|
||||
title={node.title || t("Untitled")}
|
||||
onSubmit={handleTitleChange}
|
||||
canUpdate={canUpdate}
|
||||
/>
|
||||
)}
|
||||
<EditableTitle
|
||||
title={node.title || t("Untitled")}
|
||||
onSubmit={handleTitleChange}
|
||||
canUpdate={canUpdate}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
menuOpen={menuOpen}
|
||||
menu={
|
||||
document && !isMoving ? (
|
||||
<Fade>
|
||||
<DocumentMenu
|
||||
position="right"
|
||||
document={document}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</DropToImport>
|
||||
</div>
|
||||
</Draggable>
|
||||
|
||||
</>
|
||||
}
|
||||
isActiveDrop={isOverReparent && canDropToReparent}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
showActions={menuOpen}
|
||||
menu={
|
||||
document && !isMoving ? (
|
||||
<Fade>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</DropToImport>
|
||||
</div>
|
||||
</Draggable>
|
||||
{manualSort && (
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
)}
|
||||
</div>
|
||||
{expanded && !isDragging && (
|
||||
<>
|
||||
{node.children.map((childNode) => (
|
||||
{node.children.map((childNode, index) => (
|
||||
<ObservedDocumentLink
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
@@ -199,6 +265,8 @@ function DocumentLink({
|
||||
prefetchDocument={prefetchDocument}
|
||||
depth={depth + 1}
|
||||
canUpdate={canUpdate}
|
||||
index={index}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
42
app/components/Sidebar/components/DropCursor.js
Normal file
42
app/components/Sidebar/components/DropCursor.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import { type Theme } from "types";
|
||||
|
||||
function DropCursor({
|
||||
isActiveDrop,
|
||||
innerRef,
|
||||
theme,
|
||||
}: {
|
||||
isActiveDrop: boolean,
|
||||
innerRef: React.Ref<any>,
|
||||
theme: Theme,
|
||||
}) {
|
||||
return <Cursor isOver={isActiveDrop} ref={innerRef} />;
|
||||
}
|
||||
|
||||
// transparent hover zone with a thin visible band vertically centered
|
||||
const Cursor = styled("div")`
|
||||
opacity: ${(props) => (props.isOver ? 1 : 0)};
|
||||
transition: opacity 150ms;
|
||||
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
bottom: -7px;
|
||||
background: transparent;
|
||||
|
||||
::after {
|
||||
background: ${(props) => props.theme.slateDark};
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
content: "";
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
export default withTheme(DropCursor);
|
||||
@@ -52,7 +52,9 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
|
||||
setOriginalValue(value);
|
||||
} catch (error) {
|
||||
setValue(originalValue);
|
||||
ui.showToast(error.message);
|
||||
ui.showToast(error.message, {
|
||||
type: "error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,33 +5,35 @@ import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
import TeamLogo from "components/TeamLogo";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
teamName: string,
|
||||
subheading: React.Node,
|
||||
showDisclosure?: boolean,
|
||||
onClick: (event: SyntheticEvent<>) => void,
|
||||
logoUrl: string,
|
||||
};
|
||||
|};
|
||||
|
||||
function HeaderBlock({
|
||||
showDisclosure,
|
||||
teamName,
|
||||
subheading,
|
||||
logoUrl,
|
||||
...rest
|
||||
}: Props) {
|
||||
return (
|
||||
<Header justify="flex-start" align="center" {...rest}>
|
||||
<TeamLogo alt={`${teamName} logo`} src={logoUrl} size="38px" />
|
||||
<Flex align="flex-start" column>
|
||||
<TeamName showDisclosure>
|
||||
{teamName}{" "}
|
||||
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
|
||||
</TeamName>
|
||||
<Subheading>{subheading}</Subheading>
|
||||
</Flex>
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
const HeaderBlock = React.forwardRef<Props, any>(
|
||||
({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => (
|
||||
<Wrapper>
|
||||
<Header justify="flex-start" align="center" ref={ref} {...rest}>
|
||||
<TeamLogo
|
||||
alt={`${teamName} logo`}
|
||||
src={logoUrl}
|
||||
width={38}
|
||||
height={38}
|
||||
/>
|
||||
<Flex align="flex-start" column>
|
||||
<TeamName showDisclosure>
|
||||
{teamName}{" "}
|
||||
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
|
||||
</TeamName>
|
||||
<Subheading>{subheading}</Subheading>
|
||||
</Flex>
|
||||
</Header>
|
||||
</Wrapper>
|
||||
)
|
||||
);
|
||||
|
||||
const StyledExpandedIcon = styled(ExpandedIcon)`
|
||||
position: absolute;
|
||||
@@ -44,6 +46,7 @@ const Subheading = styled.div`
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
`;
|
||||
|
||||
@@ -53,16 +56,20 @@ const TeamName = styled.div`
|
||||
padding-right: 24px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Header = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding: 20px 24px;
|
||||
position: relative;
|
||||
background: none;
|
||||
line-height: inherit;
|
||||
border: 0;
|
||||
|
||||
105
app/components/Sidebar/components/NavLink.js
Normal file
105
app/components/Sidebar/components/NavLink.js
Normal file
@@ -0,0 +1,105 @@
|
||||
// @flow
|
||||
// ref: https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/NavLink.js
|
||||
|
||||
// This file is pulled almost 100% from react-router with the addition of one
|
||||
// thing, automatic scroll to the active link. It's worth the copy paste because
|
||||
// it avoids recalculating the link match again.
|
||||
import { createLocation } from "history";
|
||||
import * as React from "react";
|
||||
import {
|
||||
__RouterContext as RouterContext,
|
||||
matchPath,
|
||||
type Location,
|
||||
} from "react-router";
|
||||
import { Link } from "react-router-dom";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
|
||||
const resolveToLocation = (to, currentLocation) =>
|
||||
typeof to === "function" ? to(currentLocation) : to;
|
||||
|
||||
const normalizeToLocation = (to, currentLocation) => {
|
||||
return typeof to === "string"
|
||||
? createLocation(to, null, null, currentLocation)
|
||||
: to;
|
||||
};
|
||||
|
||||
const joinClassnames = (...classnames) => {
|
||||
return classnames.filter((i) => i).join(" ");
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
activeClassName?: String,
|
||||
activeStyle?: Object,
|
||||
className?: string,
|
||||
exact?: boolean,
|
||||
isActive?: any,
|
||||
location?: Location,
|
||||
strict?: boolean,
|
||||
style?: Object,
|
||||
to: string,
|
||||
|};
|
||||
|
||||
/**
|
||||
* A <Link> wrapper that knows if it's "active" or not.
|
||||
*/
|
||||
const NavLink = ({
|
||||
"aria-current": ariaCurrent = "page",
|
||||
activeClassName = "active",
|
||||
activeStyle,
|
||||
className: classNameProp,
|
||||
exact,
|
||||
isActive: isActiveProp,
|
||||
location: locationProp,
|
||||
strict,
|
||||
style: styleProp,
|
||||
to,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const linkRef = React.useRef();
|
||||
const context = React.useContext(RouterContext);
|
||||
const currentLocation = locationProp || context.location;
|
||||
const toLocation = normalizeToLocation(
|
||||
resolveToLocation(to, currentLocation),
|
||||
currentLocation
|
||||
);
|
||||
const { pathname: path } = toLocation;
|
||||
// Regex taken from: https://github.com/pillarjs/path-to-regexp/blob/master/index.js#L202
|
||||
const escapedPath = path && path.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1");
|
||||
|
||||
const match = escapedPath
|
||||
? matchPath(currentLocation.pathname, {
|
||||
path: escapedPath,
|
||||
exact,
|
||||
strict,
|
||||
})
|
||||
: null;
|
||||
const isActive = !!(isActiveProp
|
||||
? isActiveProp(match, currentLocation)
|
||||
: match);
|
||||
|
||||
const className = isActive
|
||||
? joinClassnames(classNameProp, activeClassName)
|
||||
: classNameProp;
|
||||
const style = isActive ? { ...styleProp, ...activeStyle } : styleProp;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isActive && linkRef.current) {
|
||||
scrollIntoView(linkRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "instant",
|
||||
});
|
||||
}
|
||||
}, [linkRef, isActive]);
|
||||
|
||||
const props = {
|
||||
"aria-current": (isActive && ariaCurrent) || null,
|
||||
className,
|
||||
style,
|
||||
to: toLocation,
|
||||
...rest,
|
||||
};
|
||||
|
||||
return <Link ref={linkRef} {...props} />;
|
||||
};
|
||||
|
||||
export default NavLink;
|
||||
28
app/components/Sidebar/components/ResizeBorder.js
Normal file
28
app/components/Sidebar/components/ResizeBorder.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
import ResizeHandle from "./ResizeHandle";
|
||||
|
||||
const ResizeBorder = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: -6px;
|
||||
width: 12px;
|
||||
cursor: ew-resize;
|
||||
|
||||
${(props) =>
|
||||
props.$isResizing &&
|
||||
`
|
||||
${ResizeHandle} {
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
|
||||
&:hover {
|
||||
${ResizeHandle} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default ResizeBorder;
|
||||
39
app/components/Sidebar/components/ResizeHandle.js
Normal file
39
app/components/Sidebar/components/ResizeHandle.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
const ResizeHandle = styled.button`
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
transform: translateY(-50%);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
height: 40px;
|
||||
right: -10px;
|
||||
width: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -24px;
|
||||
bottom: -24px;
|
||||
left: -12px;
|
||||
right: -12px;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: ${(props) => props.theme.sidebarText};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
pointer-events: all;
|
||||
cursor: ew-resize;
|
||||
`}
|
||||
`;
|
||||
|
||||
export default ResizeHandle;
|
||||
@@ -5,7 +5,9 @@ import Flex from "components/Flex";
|
||||
const Section = styled(Flex)`
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
margin: 24px 8px;
|
||||
margin: 20px 8px;
|
||||
min-width: ${(props) => props.theme.sidebarMinWidth}px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default Section;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { withRouter, NavLink } from "react-router-dom";
|
||||
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import EventBoundary from "components/EventBoundary";
|
||||
import NavLink from "./NavLink";
|
||||
import { type Theme } from "types";
|
||||
|
||||
type Props = {
|
||||
@@ -10,14 +13,17 @@ type Props = {
|
||||
innerRef?: (?HTMLElement) => void,
|
||||
onClick?: (SyntheticEvent<>) => void,
|
||||
onMouseEnter?: (SyntheticEvent<>) => void,
|
||||
className?: string,
|
||||
children?: React.Node,
|
||||
icon?: React.Node,
|
||||
label?: React.Node,
|
||||
menu?: React.Node,
|
||||
menuOpen?: boolean,
|
||||
showActions?: boolean,
|
||||
iconColor?: string,
|
||||
active?: boolean,
|
||||
isActiveDrop?: boolean,
|
||||
history: RouterHistory,
|
||||
match: Match,
|
||||
theme: Theme,
|
||||
exact?: boolean,
|
||||
depth?: number,
|
||||
@@ -33,13 +39,14 @@ function SidebarLink({
|
||||
active,
|
||||
isActiveDrop,
|
||||
menu,
|
||||
menuOpen,
|
||||
showActions,
|
||||
theme,
|
||||
exact,
|
||||
href,
|
||||
innerRef,
|
||||
depth,
|
||||
...rest
|
||||
history,
|
||||
match,
|
||||
className,
|
||||
}: Props) {
|
||||
const style = React.useMemo(() => {
|
||||
return {
|
||||
@@ -48,16 +55,20 @@ function SidebarLink({
|
||||
}, [depth]);
|
||||
|
||||
const activeStyle = {
|
||||
color: theme.text,
|
||||
fontWeight: 600,
|
||||
color: theme.text,
|
||||
background: theme.sidebarItemBackground,
|
||||
...style,
|
||||
};
|
||||
|
||||
const activeDropStyle = {
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledNavLink
|
||||
<Link
|
||||
$isActiveDrop={isActiveDrop}
|
||||
activeStyle={isActiveDrop ? undefined : activeStyle}
|
||||
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
|
||||
style={active ? activeStyle : style}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
@@ -65,12 +76,12 @@ function SidebarLink({
|
||||
to={to}
|
||||
as={to ? undefined : href ? "a" : "div"}
|
||||
href={href}
|
||||
ref={innerRef}
|
||||
className={className}
|
||||
>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label>{label}</Label>
|
||||
{menu && <Action menuOpen={menuOpen}>{menu}</Action>}
|
||||
</StyledNavLink>
|
||||
{menu && <Actions showActions={showActions}>{menu}</Actions>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,42 +90,49 @@ const IconWrapper = styled.span`
|
||||
margin-left: -4px;
|
||||
margin-right: 4px;
|
||||
height: 24px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Action = styled.span`
|
||||
display: ${(props) => (props.menuOpen ? "inline" : "none")};
|
||||
const Actions = styled(EventBoundary)`
|
||||
display: ${(props) => (props.showActions ? "inline-flex" : "none")};
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
transition: opacity 50ms;
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
fill: currentColor;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
svg {
|
||||
opacity: 1;
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledNavLink = styled(NavLink)`
|
||||
const Link = styled(NavLink)`
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 4px 16px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
transition: background 50ms, color 50ms;
|
||||
background: ${(props) =>
|
||||
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
|
||||
color: ${(props) =>
|
||||
props.$isActiveDrop ? props.theme.white : props.theme.sidebarText};
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
svg {
|
||||
${(props) => (props.$isActiveDrop ? `fill: ${props.theme.white};` : "")}
|
||||
transition: fill 50ms;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@@ -127,11 +145,20 @@ const StyledNavLink = styled(NavLink)`
|
||||
background: ${(props) => props.theme.black05};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
> ${Action} {
|
||||
display: inline;
|
||||
&:hover,
|
||||
&:active {
|
||||
> ${Actions} {
|
||||
display: inline-flex;
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px 16px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Label = styled.div`
|
||||
|
||||
8
app/components/SkipNavContent.js
Normal file
8
app/components/SkipNavContent.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
export const id = "skip-nav";
|
||||
|
||||
export default function SkipNavContent() {
|
||||
return <div id={id} />;
|
||||
}
|
||||
34
app/components/SkipNavLink.js
Normal file
34
app/components/SkipNavLink.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { id } from "components/SkipNavContent";
|
||||
|
||||
export default function SkipNavLink() {
|
||||
return <Anchor href={`#${id}`}>Skip navigation</Anchor>;
|
||||
}
|
||||
|
||||
const Anchor = styled.a`
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
&:focus {
|
||||
padding: 1rem;
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
background: ${(props) => props.theme.background};
|
||||
color: ${(props) => props.theme.text};
|
||||
outline-color: ${(props) => props.theme.primary};
|
||||
z-index: ${(props) => props.theme.depths.popover};
|
||||
width: auto;
|
||||
height: auto;
|
||||
clip: auto;
|
||||
}
|
||||
`;
|
||||
@@ -110,7 +110,9 @@ class SocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on("unauthorized", (err) => {
|
||||
this.socket.authenticated = false;
|
||||
ui.showToast(err.message);
|
||||
ui.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
|
||||
|
||||
66
app/components/Star.js
Normal file
66
app/components/Star.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// @flow
|
||||
import { StarredIcon, UnstarredIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import NudeButton from "./NudeButton";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
size?: number,
|
||||
|};
|
||||
|
||||
function Star({ size, document, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const handleClick = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (document.isStarred) {
|
||||
document.unstar();
|
||||
} else {
|
||||
document.star();
|
||||
}
|
||||
},
|
||||
[document]
|
||||
);
|
||||
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
size={size}
|
||||
aria-label={document.isStarred ? t("Unstar") : t("Star")}
|
||||
{...rest}
|
||||
>
|
||||
{document.isStarred ? (
|
||||
<AnimatedStar size={size} color="currentColor" />
|
||||
) : (
|
||||
<AnimatedStar size={size} color="currentColor" as={UnstarredIcon} />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const Button = styled(NudeButton)`
|
||||
color: ${(props) => props.theme.text};
|
||||
`;
|
||||
|
||||
export const AnimatedStar = styled(StarredIcon)`
|
||||
flex-shrink: 0;
|
||||
transition: all 100ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
`;
|
||||
|
||||
export default Star;
|
||||
@@ -3,12 +3,15 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { LabelText } from "components/Input";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
width?: number,
|
||||
height?: number,
|
||||
label?: string,
|
||||
checked?: boolean,
|
||||
disabled?: boolean,
|
||||
onChange: (event: SyntheticInputEvent<HTMLInputElement>) => mixed,
|
||||
id?: string,
|
||||
};
|
||||
|};
|
||||
|
||||
function Switch({ width = 38, height = 20, label, ...props }: Props) {
|
||||
const component = (
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
const TeamLogo = styled.img`
|
||||
width: ${(props) => props.size || "auto"};
|
||||
height: ${(props) => props.size || "38px"};
|
||||
width: ${(props) =>
|
||||
props.width ? `${props.width}px` : props.size || "auto"};
|
||||
height: ${(props) =>
|
||||
props.height ? `${props.height}px` : props.size || "38px"};
|
||||
border-radius: 4px;
|
||||
background: ${(props) => props.theme.background};
|
||||
border: 1px solid ${(props) => props.theme.divider};
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default TeamLogo;
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import format from "date-fns/format";
|
||||
import * as React from "react";
|
||||
import Tooltip from "components/Tooltip";
|
||||
|
||||
let callbacks = [];
|
||||
|
||||
// This is a shared timer that fires every minute, used for
|
||||
// updating all Time components across the page all at once.
|
||||
setInterval(() => {
|
||||
callbacks.forEach((cb) => cb());
|
||||
}, 1000 * 60);
|
||||
|
||||
function eachMinute(fn) {
|
||||
callbacks.push(fn);
|
||||
|
||||
return () => {
|
||||
callbacks = callbacks.filter((cb) => cb !== fn);
|
||||
};
|
||||
}
|
||||
const LocaleTime = React.lazy(() => import("components/LocaleTime"));
|
||||
|
||||
type Props = {
|
||||
dateTime: string,
|
||||
@@ -28,44 +12,27 @@ type Props = {
|
||||
shorten?: boolean,
|
||||
};
|
||||
|
||||
class Time extends React.Component<Props> {
|
||||
removeEachMinuteCallback: () => void;
|
||||
function Time(props: Props) {
|
||||
let content = distanceInWordsToNow(props.dateTime, {
|
||||
addSuffix: props.addSuffix,
|
||||
});
|
||||
|
||||
componentDidMount() {
|
||||
this.removeEachMinuteCallback = eachMinute(() => {
|
||||
this.forceUpdate();
|
||||
});
|
||||
if (props.shorten) {
|
||||
content = content
|
||||
.replace("about", "")
|
||||
.replace("less than a minute ago", "just now")
|
||||
.replace("minute", "min");
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.removeEachMinuteCallback();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { shorten, addSuffix } = this.props;
|
||||
let content = distanceInWordsToNow(this.props.dateTime, {
|
||||
addSuffix,
|
||||
});
|
||||
|
||||
if (shorten) {
|
||||
content = content
|
||||
.replace("about", "")
|
||||
.replace("less than a minute ago", "just now")
|
||||
.replace("minute", "min");
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
tooltip={format(this.props.dateTime, "MMMM Do, YYYY h:mm a")}
|
||||
delay={this.props.tooltipDelay}
|
||||
placement="bottom"
|
||||
>
|
||||
<time dateTime={this.props.dateTime}>
|
||||
{this.props.children || content}
|
||||
</time>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<time dateTime={props.dateTime}>{props.children || content}</time>
|
||||
}
|
||||
>
|
||||
<LocaleTime {...props} />
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export default Time;
|
||||
|
||||
@@ -1,30 +1,24 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import UiStore from "../../stores/UiStore";
|
||||
import Toast from "./components/Toast";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
};
|
||||
@observer
|
||||
class Toasts extends React.Component<Props> {
|
||||
render() {
|
||||
const { ui } = this.props;
|
||||
function Toasts() {
|
||||
const { ui } = useStores();
|
||||
|
||||
return (
|
||||
<List>
|
||||
{ui.orderedToasts.map((toast) => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
toast={toast}
|
||||
onRequestClose={() => ui.removeToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<List>
|
||||
{ui.orderedToasts.map((toast) => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
toast={toast}
|
||||
onRequestClose={() => ui.removeToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
const List = styled.ol`
|
||||
@@ -37,4 +31,4 @@ const List = styled.ol`
|
||||
z-index: ${(props) => props.theme.depths.toasts};
|
||||
`;
|
||||
|
||||
export default inject("ui")(Toasts);
|
||||
export default observer(Toasts);
|
||||
|
||||
@@ -1,58 +1,61 @@
|
||||
// @flow
|
||||
import { CheckboxIcon, InfoIcon, WarningIcon } from "outline-icons";
|
||||
import { darken } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||
import styled, { css } from "styled-components";
|
||||
import { fadeAndScaleIn, pulse } from "shared/styles/animations";
|
||||
import type { Toast as TToast } from "types";
|
||||
|
||||
type Props = {
|
||||
onRequestClose: () => void,
|
||||
closeAfterMs: number,
|
||||
closeAfterMs?: number,
|
||||
toast: TToast,
|
||||
};
|
||||
|
||||
class Toast extends React.Component<Props> {
|
||||
timeout: TimeoutID;
|
||||
function Toast({ closeAfterMs = 3000, onRequestClose, toast }: Props) {
|
||||
const timeout = React.useRef();
|
||||
const [pulse, setPulse] = React.useState(false);
|
||||
const { action, type = "info", reoccurring } = toast;
|
||||
|
||||
static defaultProps = {
|
||||
closeAfterMs: 3000,
|
||||
};
|
||||
React.useEffect(() => {
|
||||
timeout.current = setTimeout(onRequestClose, toast.timeout || closeAfterMs);
|
||||
|
||||
componentDidMount() {
|
||||
this.timeout = setTimeout(
|
||||
this.props.onRequestClose,
|
||||
this.props.toast.timeout || this.props.closeAfterMs
|
||||
);
|
||||
}
|
||||
return () => clearTimeout(timeout.current);
|
||||
}, [onRequestClose, toast, closeAfterMs]);
|
||||
|
||||
componentWillUnmount() {
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
React.useEffect(() => {
|
||||
if (reoccurring) {
|
||||
setPulse(reoccurring);
|
||||
|
||||
render() {
|
||||
const { toast, onRequestClose } = this.props;
|
||||
const { action } = toast;
|
||||
const message =
|
||||
typeof toast.message === "string"
|
||||
? toast.message
|
||||
: toast.message.toString();
|
||||
// must match animation time in css below vvv
|
||||
setTimeout(() => setPulse(false), 250);
|
||||
}
|
||||
}, [reoccurring]);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Container
|
||||
onClick={action ? undefined : onRequestClose}
|
||||
type={toast.type || "success"}
|
||||
>
|
||||
<Message>{message}</Message>
|
||||
{action && (
|
||||
<Action type={toast.type || "success"} onClick={action.onClick}>
|
||||
{action.text}
|
||||
</Action>
|
||||
)}
|
||||
</Container>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const message =
|
||||
typeof toast.message === "string"
|
||||
? toast.message
|
||||
: toast.message.toString();
|
||||
|
||||
return (
|
||||
<ListItem $pulse={pulse}>
|
||||
<Container
|
||||
onClick={action ? undefined : onRequestClose}
|
||||
type={toast.type || "success"}
|
||||
>
|
||||
{type === "info" && <InfoIcon color="currentColor" />}
|
||||
{type === "success" && <CheckboxIcon checked color="currentColor" />}
|
||||
{type === "warning" ||
|
||||
(type === "error" && <WarningIcon color="currentColor" />)}
|
||||
<Message>{message}</Message>
|
||||
{action && (
|
||||
<Action type={toast.type || "success"} onClick={action.onClick}>
|
||||
{action.text}
|
||||
</Action>
|
||||
)}
|
||||
</Container>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
const Action = styled.span`
|
||||
@@ -71,11 +74,20 @@ const Action = styled.span`
|
||||
}
|
||||
`;
|
||||
|
||||
const ListItem = styled.li`
|
||||
${(props) =>
|
||||
props.$pulse &&
|
||||
css`
|
||||
animation: ${pulse} 250ms;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Container = styled.div`
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
animation: ${fadeAndScaleIn} 100ms ease;
|
||||
margin: 8px 0;
|
||||
padding: 0 12px;
|
||||
color: ${(props) => props.theme.toastText};
|
||||
background: ${(props) => props.theme.toastBackground};
|
||||
font-size: 15px;
|
||||
@@ -89,7 +101,8 @@ const Container = styled.div`
|
||||
|
||||
const Message = styled.div`
|
||||
display: inline-block;
|
||||
padding: 10px 12px;
|
||||
font-weight: 500;
|
||||
padding: 10px 4px;
|
||||
`;
|
||||
|
||||
export default Toast;
|
||||
|
||||
@@ -3,14 +3,14 @@ import Tippy from "@tippy.js/react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
tooltip: React.Node,
|
||||
shortcut?: React.Node,
|
||||
placement?: "top" | "bottom" | "left" | "right",
|
||||
children: React.Node,
|
||||
delay?: number,
|
||||
className?: string,
|
||||
};
|
||||
|};
|
||||
|
||||
class Tooltip extends React.Component<Props> {
|
||||
render() {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
|
||||
const VisuallyHidden = styled("span")`
|
||||
position: absolute !important;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
`;
|
||||
|
||||
export default VisuallyHidden;
|
||||
Reference in New Issue
Block a user