mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4c594423f | |||
| 2bf237d54b | |||
| 3565e68725 | |||
| 61039e9d0d | |||
| 6d09122d56 | |||
| 5fb6097153 | |||
| ec17874568 | |||
| 40c3e9e85f | |||
| 9f739f3788 | |||
| f6837b4742 | |||
| 1560e3c9f7 | |||
| ca74908dc5 | |||
| de7ec1119b | |||
| 2093b4297f | |||
| 3df82c500b |
@@ -15,7 +15,6 @@ type Props = {|
|
||||
target?: "_blank",
|
||||
as?: string | React.ComponentType<*>,
|
||||
hide?: () => void,
|
||||
level?: number,
|
||||
|};
|
||||
|
||||
const MenuItem = ({
|
||||
@@ -89,7 +88,6 @@ export const MenuAnchor = styled.a`
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 12px;
|
||||
padding-left: ${(props) => 12 + props.level * 10}px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
background: none;
|
||||
|
||||
@@ -9,11 +9,49 @@ import {
|
||||
MenuItem as BaseMenuItem,
|
||||
} from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import Header from "./Header";
|
||||
import MenuItem, { MenuAnchor } from "./MenuItem";
|
||||
import Separator from "./Separator";
|
||||
import ContextMenu from ".";
|
||||
import { type MenuItem as TMenuItem } from "types";
|
||||
|
||||
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,
|
||||
|}
|
||||
| {|
|
||||
title: React.Node,
|
||||
visible?: boolean,
|
||||
disabled?: boolean,
|
||||
style?: Object,
|
||||
hover?: boolean,
|
||||
items: TMenuItem[],
|
||||
|}
|
||||
| {|
|
||||
type: "separator",
|
||||
visible?: boolean,
|
||||
|}
|
||||
| {|
|
||||
type: "heading",
|
||||
visible?: boolean,
|
||||
title: React.Node,
|
||||
|};
|
||||
|
||||
type Props = {|
|
||||
items: TMenuItem[],
|
||||
@@ -90,8 +128,7 @@ function Template({ items, ...menu }: Props): React.Node {
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
level={item.level}
|
||||
target={item.href.startsWith("#") ? undefined : "_blank"}
|
||||
target="_blank"
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
@@ -130,11 +167,6 @@ function Template({ items, ...menu }: Props): React.Node {
|
||||
return <Separator key={index} />;
|
||||
}
|
||||
|
||||
if (item.type === "heading") {
|
||||
return <Header>{item.title}</Header>;
|
||||
}
|
||||
|
||||
console.warn("Unrecognized menu item", item);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,16 +6,14 @@ import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import {
|
||||
fadeIn,
|
||||
fadeAndSlideUp,
|
||||
fadeAndSlideDown,
|
||||
mobileContextMenu,
|
||||
fadeAndScaleIn,
|
||||
fadeAndSlideIn,
|
||||
} from "shared/styles/animations";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
|
||||
type Props = {|
|
||||
"aria-label": string,
|
||||
visible?: boolean,
|
||||
placement?: string,
|
||||
animating?: boolean,
|
||||
children: React.Node,
|
||||
onOpen?: () => void,
|
||||
@@ -46,25 +44,13 @@ export default function ContextMenu({
|
||||
return (
|
||||
<>
|
||||
<Menu hideOnClickOutside preventBodyScroll {...rest}>
|
||||
{(props) => {
|
||||
// kind of hacky, but this is an effective way of telling which way
|
||||
// the menu will _actually_ be placed when taking into account screen
|
||||
// positioning.
|
||||
const topAnchor = props.style.top === "0";
|
||||
const rightAnchor = props.placement === "bottom-end";
|
||||
|
||||
return (
|
||||
<Position {...props}>
|
||||
<Background
|
||||
dir="auto"
|
||||
topAnchor={topAnchor}
|
||||
rightAnchor={rightAnchor}
|
||||
>
|
||||
{rest.visible || rest.animating ? children : null}
|
||||
</Background>
|
||||
</Position>
|
||||
);
|
||||
}}
|
||||
{(props) => (
|
||||
<Position {...props}>
|
||||
<Background dir="auto">
|
||||
{rest.visible || rest.animating ? children : null}
|
||||
</Background>
|
||||
</Position>
|
||||
)}
|
||||
</Menu>
|
||||
{(rest.visible || rest.animating) && (
|
||||
<Portal>
|
||||
@@ -105,7 +91,7 @@ const Position = styled.div`
|
||||
`;
|
||||
|
||||
const Background = styled.div`
|
||||
animation: ${mobileContextMenu} 200ms ease;
|
||||
animation: ${fadeAndSlideIn} 200ms ease;
|
||||
transform-origin: 50% 100%;
|
||||
max-width: 100%;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
@@ -123,10 +109,9 @@ const Background = styled.div`
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
animation: ${(props) =>
|
||||
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: ${(props) =>
|
||||
props.rightAnchor === "bottom-end" ? "75%" : "25%"} 0;
|
||||
props.left !== undefined ? "25%" : "75%"} 0;
|
||||
max-width: 276px;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
|
||||
@@ -15,7 +15,7 @@ import RevisionsStore from "stores/RevisionsStore";
|
||||
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
import PlaceholderList from "components/List/Placeholder";
|
||||
import { ListPlaceholder } from "components/LoadingPlaceholder";
|
||||
import Revision from "./components/Revision";
|
||||
import { documentHistoryUrl, documentUrl } from "utils/routeHelpers";
|
||||
|
||||
@@ -120,7 +120,7 @@ class DocumentHistory extends React.Component<Props> {
|
||||
</Header>
|
||||
{showLoading ? (
|
||||
<Loading>
|
||||
<PlaceholderList count={5} />
|
||||
<ListPlaceholder count={5} />
|
||||
</Loading>
|
||||
) : (
|
||||
<ArrowKeyNavigation
|
||||
|
||||
@@ -15,7 +15,6 @@ import Flex from "components/Flex";
|
||||
import Highlight from "components/Highlight";
|
||||
import StarButton, { AnimatedStar } from "components/Star";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import useBoolean from "hooks/useBoolean";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import useStores from "hooks/useStores";
|
||||
@@ -47,7 +46,7 @@ function DocumentListItem(props: Props, ref) {
|
||||
const { policies } = useStores();
|
||||
const currentUser = useCurrentUser();
|
||||
const currentTeam = useCurrentTeam();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const {
|
||||
document,
|
||||
showNestedDocuments,
|
||||
@@ -144,8 +143,8 @@ function DocumentListItem(props: Props, ref) {
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
showPin={showPin}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
modal={false}
|
||||
/>
|
||||
</Actions>
|
||||
|
||||
@@ -3,7 +3,6 @@ import * as Sentry from "@sentry/react";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction, Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Button from "components/Button";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
@@ -12,11 +11,10 @@ import PageTitle from "components/PageTitle";
|
||||
import { githubIssuesUrl } from "../../shared/utils/routeHelpers";
|
||||
import env from "env";
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
reloadOnChunkMissing?: boolean,
|
||||
t: TFunction,
|
||||
|};
|
||||
};
|
||||
|
||||
@observer
|
||||
class ErrorBoundary extends React.Component<Props> {
|
||||
@@ -57,8 +55,6 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
|
||||
if (this.error) {
|
||||
const error = this.error;
|
||||
const isReported = !!env.SENTRY_DSN && env.DEPLOYMENT === "hosted";
|
||||
@@ -67,21 +63,15 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
if (isChunkError) {
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title={t("Module failed to load")} />
|
||||
<h1>
|
||||
<Trans>Loading Failed</Trans>
|
||||
</h1>
|
||||
<PageTitle title="Module failed to load" />
|
||||
<h1>Loading Failed</h1>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
Sorry, part of the application failed to load. This may be
|
||||
because it was updated since you opened the tab or because of a
|
||||
failed network request. Please try reloading.
|
||||
</Trans>
|
||||
Sorry, part of the application failed to load. This may be because
|
||||
it was updated since you opened the tab or because of a failed
|
||||
network request. Please try reloading.
|
||||
</HelpText>
|
||||
<p>
|
||||
<Button onClick={this.handleReload}>
|
||||
<Trans>Reload</Trans>
|
||||
</Button>
|
||||
<Button onClick={this.handleReload}>Reload</Button>
|
||||
</p>
|
||||
</CenteredContent>
|
||||
);
|
||||
@@ -89,32 +79,23 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title={t("Something Unexpected Happened")} />
|
||||
<h1>
|
||||
<Trans>Something Unexpected Happened</Trans>
|
||||
</h1>
|
||||
<PageTitle title="Something Unexpected Happened" />
|
||||
<h1>Something Unexpected Happened</h1>
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
|
||||
values={{
|
||||
notified: isReported
|
||||
? ` – ${t("our engineers have been notified")}`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
Sorry, an unrecoverable error occurred
|
||||
{isReported && " – our engineers have been notified"}. Please try
|
||||
reloading the page, it may have been a temporary glitch.
|
||||
</HelpText>
|
||||
{this.showDetails && <Pre>{error.toString()}</Pre>}
|
||||
<p>
|
||||
<Button onClick={this.handleReload}>
|
||||
<Trans>Reload</Trans>
|
||||
</Button>{" "}
|
||||
<Button onClick={this.handleReload}>Reload</Button>{" "}
|
||||
{this.showDetails ? (
|
||||
<Button onClick={this.handleReportBug} neutral>
|
||||
<Trans>Report a Bug</Trans>…
|
||||
Report a Bug…
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={this.handleShowDetails} neutral>
|
||||
<Trans>Show Detail</Trans>…
|
||||
Show Details…
|
||||
</Button>
|
||||
)}
|
||||
</p>
|
||||
@@ -133,4 +114,4 @@ const Pre = styled.pre`
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
|
||||
export default withTranslation()<ErrorBoundary>(ErrorBoundary);
|
||||
export default ErrorBoundary;
|
||||
|
||||
@@ -45,7 +45,7 @@ const Container = styled.div`
|
||||
align-items: ${({ align }) => align};
|
||||
justify-content: ${({ justify }) => justify};
|
||||
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
|
||||
gap: ${({ gap }) => (gap ? `${gap}px` : "initial")};
|
||||
gap: ${({ gap }) => `${gap}px` || "initial"};
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
@@ -72,10 +72,6 @@ const Actions = styled(Flex)`
|
||||
flex-basis: 0;
|
||||
min-width: auto;
|
||||
padding-left: 8px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
position: unset;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
@@ -88,12 +84,12 @@ const Wrapper = styled(Flex)`
|
||||
transform: translate3d(0, 0, 0);
|
||||
backdrop-filter: blur(20px);
|
||||
min-height: 56px;
|
||||
justify-content: flex-start;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
justify-content: flex-start;
|
||||
${breakpoint("tablet")`
|
||||
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
|
||||
justify-content: "center";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import { fadeAndSlideDown } from "shared/styles/animations";
|
||||
import { fadeAndSlideIn } from "shared/styles/animations";
|
||||
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import HoverPreviewDocument from "components/HoverPreviewDocument";
|
||||
@@ -136,7 +136,7 @@ function HoverPreview({ node, ...rest }: Props) {
|
||||
}
|
||||
|
||||
const Animate = styled.div`
|
||||
animation: ${fadeAndSlideDown} 150ms ease;
|
||||
animation: ${fadeAndSlideIn} 150ms ease;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
|
||||
@@ -29,10 +29,6 @@ const RealInput = styled.input`
|
||||
background: none;
|
||||
color: ${(props) => props.theme.text};
|
||||
height: 30px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
|
||||
+38
-38
@@ -1,58 +1,58 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Editor from "components/Editor";
|
||||
import HelpText from "components/HelpText";
|
||||
import { LabelText, Outline } from "components/Input";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
label: string,
|
||||
minHeight?: number,
|
||||
maxHeight?: number,
|
||||
readOnly?: boolean,
|
||||
ui: UiStore,
|
||||
|};
|
||||
|
||||
function InputRich({ label, minHeight, maxHeight, ...rest }: Props) {
|
||||
const [focused, setFocused] = React.useState<boolean>(false);
|
||||
const { ui } = useStores();
|
||||
@observer
|
||||
class InputRich extends React.Component<Props> {
|
||||
@observable editorComponent: React.ComponentType<any>;
|
||||
@observable focused: boolean = false;
|
||||
|
||||
const handleBlur = React.useCallback(() => {
|
||||
setFocused(false);
|
||||
}, []);
|
||||
handleBlur = () => {
|
||||
this.focused = false;
|
||||
};
|
||||
|
||||
const handleFocus = React.useCallback(() => {
|
||||
setFocused(true);
|
||||
}, []);
|
||||
handleFocus = () => {
|
||||
this.focused = true;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<LabelText>{label}</LabelText>
|
||||
<StyledOutline
|
||||
maxHeight={maxHeight}
|
||||
minHeight={minHeight}
|
||||
focused={focused}
|
||||
>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<HelpText>
|
||||
<Trans>Loading editor</Trans>…
|
||||
</HelpText>
|
||||
}
|
||||
render() {
|
||||
const { label, minHeight, maxHeight, ui, ...rest } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LabelText>{label}</LabelText>
|
||||
<StyledOutline
|
||||
maxHeight={maxHeight}
|
||||
minHeight={minHeight}
|
||||
focused={this.focused}
|
||||
>
|
||||
<Editor
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
ui={ui}
|
||||
grow
|
||||
{...rest}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</StyledOutline>
|
||||
</>
|
||||
);
|
||||
<React.Suspense fallback={<HelpText>Loading editor…</HelpText>}>
|
||||
<Editor
|
||||
onBlur={this.handleBlur}
|
||||
onFocus={this.handleFocus}
|
||||
ui={ui}
|
||||
grow
|
||||
{...rest}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</StyledOutline>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const StyledOutline = styled(Outline)`
|
||||
@@ -67,4 +67,4 @@ const StyledOutline = styled(Outline)`
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(withTheme(InputRich));
|
||||
export default inject("ui")(withTheme(InputRich));
|
||||
|
||||
@@ -4,19 +4,18 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import PlaceholderText from "components/PlaceholderText";
|
||||
import Mask from "components/Mask";
|
||||
|
||||
type Props = {
|
||||
count?: number,
|
||||
};
|
||||
|
||||
const ListPlaceHolder = ({ count }: Props) => {
|
||||
const Placeholder = ({ count }: Props) => {
|
||||
return (
|
||||
<Fade>
|
||||
{times(count || 2, (index) => (
|
||||
<Item key={index} column auto>
|
||||
<PlaceholderText header delay={0.2 * index} />
|
||||
<PlaceholderText delay={0.2 * index} />
|
||||
<Mask />
|
||||
</Item>
|
||||
))}
|
||||
</Fade>
|
||||
@@ -24,7 +23,7 @@ const ListPlaceHolder = ({ count }: Props) => {
|
||||
};
|
||||
|
||||
const Item = styled(Flex)`
|
||||
padding: 10px 0;
|
||||
padding: 15px 0 16px;
|
||||
`;
|
||||
|
||||
export default ListPlaceHolder;
|
||||
export default Placeholder;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// @flow
|
||||
import { times } from "lodash";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import Mask from "components/Mask";
|
||||
|
||||
type Props = {
|
||||
count?: number,
|
||||
};
|
||||
|
||||
const ListPlaceHolder = ({ count }: Props) => {
|
||||
return (
|
||||
<Fade>
|
||||
{times(count || 2, (index) => (
|
||||
<Item key={index} column auto>
|
||||
<Mask header />
|
||||
<Mask />
|
||||
</Item>
|
||||
))}
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
const Item = styled(Flex)`
|
||||
padding: 10px 0;
|
||||
`;
|
||||
|
||||
export default ListPlaceHolder;
|
||||
+6
-7
@@ -4,19 +4,18 @@ import styled from "styled-components";
|
||||
import DelayedMount from "components/DelayedMount";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import PlaceholderText from "components/PlaceholderText";
|
||||
import Mask from "components/Mask";
|
||||
|
||||
export default function PlaceholderDocument(props: Object) {
|
||||
export default function LoadingPlaceholder(props: Object) {
|
||||
return (
|
||||
<DelayedMount>
|
||||
<Wrapper>
|
||||
<Flex column auto {...props}>
|
||||
<PlaceholderText height={34} maxWidth={70} />
|
||||
<PlaceholderText delay={0.2} maxWidth={40} />
|
||||
<Mask height={34} />
|
||||
<br />
|
||||
<PlaceholderText delay={0.2} />
|
||||
<PlaceholderText delay={0.4} />
|
||||
<PlaceholderText delay={0.6} />
|
||||
<Mask />
|
||||
<Mask />
|
||||
<Mask />
|
||||
</Flex>
|
||||
</Wrapper>
|
||||
</DelayedMount>
|
||||
@@ -0,0 +1,6 @@
|
||||
// @flow
|
||||
import ListPlaceholder from "./ListPlaceholder";
|
||||
import LoadingPlaceholder from "./LoadingPlaceholder";
|
||||
|
||||
export default LoadingPlaceholder;
|
||||
export { ListPlaceholder };
|
||||
@@ -3,11 +3,9 @@ import { format, formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
enUS,
|
||||
de,
|
||||
faIR,
|
||||
fr,
|
||||
es,
|
||||
it,
|
||||
ja,
|
||||
ko,
|
||||
ptBR,
|
||||
pt,
|
||||
@@ -23,10 +21,8 @@ const locales = {
|
||||
en_US: enUS,
|
||||
de_DE: de,
|
||||
es_ES: es,
|
||||
fa_IR: faIR,
|
||||
fr_FR: fr,
|
||||
it_IT: it,
|
||||
ja_JP: ja,
|
||||
ko_KR: ko,
|
||||
pt_BR: ptBR,
|
||||
pt_PT: pt,
|
||||
|
||||
@@ -10,40 +10,36 @@ type Props = {|
|
||||
height?: number,
|
||||
minWidth?: number,
|
||||
maxWidth?: number,
|
||||
delay?: number,
|
||||
|};
|
||||
|
||||
class PlaceholderText extends React.Component<Props> {
|
||||
width = randomInteger(this.props.minWidth || 75, this.props.maxWidth || 100);
|
||||
class Mask extends React.Component<Props> {
|
||||
width: number;
|
||||
|
||||
shouldComponentUpdate() {
|
||||
return false;
|
||||
}
|
||||
|
||||
constructor(props: Props) {
|
||||
super();
|
||||
this.width = randomInteger(props.minWidth || 75, props.maxWidth || 100);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Mask
|
||||
width={this.width}
|
||||
height={this.props.height}
|
||||
delay={this.props.delay}
|
||||
/>
|
||||
);
|
||||
return <Redacted width={this.width} height={this.props.height} />;
|
||||
}
|
||||
}
|
||||
|
||||
const Mask = styled(Flex)`
|
||||
const Redacted = styled(Flex)`
|
||||
width: ${(props) => (props.header ? props.width / 2 : props.width)}%;
|
||||
height: ${(props) =>
|
||||
props.height ? props.height : props.header ? 24 : 18}px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 6px;
|
||||
background-color: ${(props) => props.theme.divider};
|
||||
animation: ${pulsate} 2s infinite;
|
||||
animation-delay: ${(props) => props.delay || 0}s;
|
||||
animation: ${pulsate} 1.3s infinite;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default PlaceholderText;
|
||||
export default Mask;
|
||||
@@ -1,4 +1,5 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import Document from "models/Document";
|
||||
import DocumentListItem from "components/DocumentListItem";
|
||||
@@ -18,26 +19,24 @@ type Props = {|
|
||||
showTemplate?: boolean,
|
||||
|};
|
||||
|
||||
const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
empty,
|
||||
heading,
|
||||
documents,
|
||||
fetch,
|
||||
options,
|
||||
...rest
|
||||
}: Props) {
|
||||
return (
|
||||
<PaginatedList
|
||||
items={documents}
|
||||
empty={empty}
|
||||
heading={heading}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item) => (
|
||||
<DocumentListItem key={item.id} document={item} {...rest} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@observer
|
||||
class PaginatedDocumentList extends React.Component<Props> {
|
||||
render() {
|
||||
const { empty, heading, documents, fetch, options, ...rest } = this.props;
|
||||
|
||||
return (
|
||||
<PaginatedList
|
||||
items={documents}
|
||||
empty={empty}
|
||||
heading={heading}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item) => (
|
||||
<DocumentListItem key={item.id} document={item} {...rest} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PaginatedDocumentList;
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as React from "react";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
|
||||
import DelayedMount from "components/DelayedMount";
|
||||
import PlaceholderList from "components/List/Placeholder";
|
||||
import { ListPlaceholder } from "components/LoadingPlaceholder";
|
||||
|
||||
type Props = {
|
||||
fetch?: (options: ?Object) => Promise<void>,
|
||||
@@ -128,7 +128,7 @@ class PaginatedList extends React.Component<Props> {
|
||||
)}
|
||||
{showLoading && (
|
||||
<DelayedMount>
|
||||
<PlaceholderList count={5} />
|
||||
<ListPlaceholder count={5} />
|
||||
</DelayedMount>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -114,6 +114,17 @@ function MainSidebar() {
|
||||
exact={false}
|
||||
label={t("Starred")}
|
||||
/>
|
||||
{can.createDocument && (
|
||||
<SidebarLink
|
||||
to="/templates"
|
||||
icon={<ShapesIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Templates")}
|
||||
active={
|
||||
documents.active ? documents.active.template : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{can.createDocument && (
|
||||
<SidebarLink
|
||||
to="/drafts"
|
||||
@@ -140,40 +151,26 @@ function MainSidebar() {
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
{can.createDocument && (
|
||||
<>
|
||||
<SidebarLink
|
||||
to="/templates"
|
||||
icon={<ShapesIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Templates")}
|
||||
active={
|
||||
documents.active ? documents.active.template : undefined
|
||||
}
|
||||
/>
|
||||
<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="/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" />}
|
||||
|
||||
@@ -12,7 +12,6 @@ import DropCursor from "./DropCursor";
|
||||
import DropToImport from "./DropToImport";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import useBoolean from "hooks/useBoolean";
|
||||
import useStores from "hooks/useStores";
|
||||
import CollectionMenu from "menus/CollectionMenu";
|
||||
import CollectionSortMenu from "menus/CollectionSortMenu";
|
||||
@@ -36,7 +35,7 @@ function CollectionLink({
|
||||
isDraggingAnyCollection,
|
||||
onChangeDragging,
|
||||
}: Props) {
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
|
||||
const handleTitleChange = React.useCallback(
|
||||
async (name: string) => {
|
||||
@@ -164,14 +163,14 @@ function CollectionLink({
|
||||
{can.update && (
|
||||
<CollectionSortMenuWithMargin
|
||||
collection={collection}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import useStores from "../../../hooks/useStores";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import CollectionsLoading from "./CollectionsLoading";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Header from "./Header";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
type Props = {
|
||||
@@ -105,7 +105,7 @@ function Collections({ onCreateCollection }: Props) {
|
||||
return (
|
||||
<Flex column>
|
||||
<Header>{t("Collections")}</Header>
|
||||
<PlaceholderCollections />
|
||||
<CollectionsLoading />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Mask from "components/Mask";
|
||||
|
||||
function CollectionsLoading() {
|
||||
return (
|
||||
<Wrapper>
|
||||
<Mask />
|
||||
<Mask />
|
||||
<Mask />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
margin: 4px 16px;
|
||||
width: 75%;
|
||||
`;
|
||||
|
||||
export default CollectionsLoading;
|
||||
@@ -12,7 +12,6 @@ import DropCursor from "./DropCursor";
|
||||
import DropToImport from "./DropToImport";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import useBoolean from "hooks/useBoolean";
|
||||
import useStores from "hooks/useStores";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
import { type NavigationNode } from "types";
|
||||
@@ -121,7 +120,7 @@ function DocumentLink(
|
||||
[documents, document]
|
||||
);
|
||||
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const isMoving = documents.movingDocumentId === node.id;
|
||||
const manualSort = collection?.sort.field === "index";
|
||||
|
||||
@@ -246,8 +245,8 @@ function DocumentLink(
|
||||
<Fade>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import PlaceholderText from "components/PlaceholderText";
|
||||
|
||||
function PlaceholderCollections() {
|
||||
return (
|
||||
<Wrapper>
|
||||
<PlaceholderText />
|
||||
<PlaceholderText delay={0.2} />
|
||||
<PlaceholderText delay={0.4} />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
margin: 4px 16px;
|
||||
width: 75%;
|
||||
`;
|
||||
|
||||
export default PlaceholderCollections;
|
||||
+8
-49
@@ -1,26 +1,14 @@
|
||||
// @flow
|
||||
import { m } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import { NavLink, Route } from "react-router-dom";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import { type Theme } from "types";
|
||||
|
||||
type Props = {
|
||||
theme: Theme,
|
||||
children: React.Node,
|
||||
};
|
||||
|
||||
const NavLinkWithChildrenFunc = ({ to, exact = false, children, ...rest }) => (
|
||||
<Route path={to} exact={exact}>
|
||||
{({ match }) => (
|
||||
<NavLink to={to} {...rest}>
|
||||
{children(match)}
|
||||
</NavLink>
|
||||
)}
|
||||
</Route>
|
||||
);
|
||||
|
||||
const TabLink = styled(NavLinkWithChildrenFunc)`
|
||||
const TabLink = styled(NavLink)`
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -32,48 +20,19 @@ const TabLink = styled(NavLinkWithChildrenFunc)`
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
border-bottom: 3px solid ${(props) => props.theme.divider};
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Active = styled(m.div)`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
background: ${(props) => props.theme.textSecondary};
|
||||
`;
|
||||
|
||||
const transition = {
|
||||
type: "spring",
|
||||
stiffness: 500,
|
||||
damping: 30,
|
||||
};
|
||||
|
||||
function Tab({ theme, children, ...rest }: Props) {
|
||||
function Tab({ theme, ...rest }: Props) {
|
||||
const activeStyle = {
|
||||
paddingBottom: "5px",
|
||||
borderBottom: `3px solid ${theme.textSecondary}`,
|
||||
color: theme.textSecondary,
|
||||
};
|
||||
|
||||
return (
|
||||
<TabLink {...rest} activeStyle={activeStyle}>
|
||||
{(match) => (
|
||||
<>
|
||||
{children}
|
||||
{match && (
|
||||
<Active
|
||||
layoutId="underline"
|
||||
initial={false}
|
||||
transition={transition}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabLink>
|
||||
);
|
||||
return <TabLink {...rest} activeStyle={activeStyle} />;
|
||||
}
|
||||
|
||||
export default withTheme(Tab);
|
||||
|
||||
@@ -8,7 +8,7 @@ import styled from "styled-components";
|
||||
import Button from "components/Button";
|
||||
import Empty from "components/Empty";
|
||||
import Flex from "components/Flex";
|
||||
import PlaceholderText from "components/PlaceholderText";
|
||||
import Mask from "components/Mask";
|
||||
|
||||
export type Props = {|
|
||||
data: any[],
|
||||
@@ -170,7 +170,7 @@ export const Placeholder = ({
|
||||
<Row key={row}>
|
||||
{new Array(columns).fill().map((_, col) => (
|
||||
<Cell key={col}>
|
||||
<PlaceholderText minWidth={25} maxWidth={75} />
|
||||
<Mask minWidth={25} maxWidth={75} />
|
||||
</Cell>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
+5
-57
@@ -1,40 +1,13 @@
|
||||
// @flow
|
||||
import { AnimateSharedLayout } from "framer-motion";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import useWindowSize from "hooks/useWindowSize";
|
||||
|
||||
const Nav = styled.nav`
|
||||
border-bottom: 1px solid ${(props) => props.theme.divider};
|
||||
margin: 12px 0;
|
||||
overflow-y: auto;
|
||||
white-space: nowrap;
|
||||
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 50px;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
background: ${(props) =>
|
||||
props.$shadowVisible
|
||||
? `linear-gradient(
|
||||
90deg,
|
||||
${transparentize(1, props.theme.background)} 0%,
|
||||
${props.theme.background} 100%
|
||||
)`
|
||||
: `transparent`};
|
||||
}
|
||||
transition: opacity 250ms ease-out;
|
||||
`;
|
||||
|
||||
// When sticky we need extra background coverage around the sides otherwise
|
||||
@@ -57,36 +30,11 @@ export const Separator = styled.span`
|
||||
margin-top: 6px;
|
||||
`;
|
||||
|
||||
const Tabs = ({ children }: {| children: React.Node |}) => {
|
||||
const ref = React.useRef<?HTMLDivElement>();
|
||||
const [shadowVisible, setShadow] = React.useState(false);
|
||||
const { width } = useWindowSize();
|
||||
|
||||
const updateShadows = React.useCallback(() => {
|
||||
const c = ref.current;
|
||||
if (!c) return;
|
||||
|
||||
const scrollLeft = c.scrollLeft;
|
||||
const wrapperWidth = c.scrollWidth - c.clientWidth;
|
||||
const fade = !!(wrapperWidth - scrollLeft !== 0);
|
||||
|
||||
if (fade !== shadowVisible) {
|
||||
setShadow(fade);
|
||||
}
|
||||
}, [shadowVisible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
updateShadows();
|
||||
}, [width, updateShadows]);
|
||||
|
||||
const Tabs = (props: {}) => {
|
||||
return (
|
||||
<AnimateSharedLayout>
|
||||
<Sticky>
|
||||
<Nav ref={ref} onScroll={updateShadows} $shadowVisible={shadowVisible}>
|
||||
{children}
|
||||
</Nav>
|
||||
</Sticky>
|
||||
</AnimateSharedLayout>
|
||||
<Sticky>
|
||||
<Nav {...props}></Nav>
|
||||
</Sticky>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import Image from "components/Image";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
const URL_REGEX = new RegExp(
|
||||
"^https?://datastudio.google.com/(embed|u/0)/reporting/(.*)/page/(.*)(/edit)?$"
|
||||
);
|
||||
|
||||
type Props = {|
|
||||
attrs: {|
|
||||
href: string,
|
||||
matches: string[],
|
||||
|},
|
||||
|};
|
||||
|
||||
export default class GoogleDataStudio extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={this.props.attrs.href.replace("u/0", "embed").replace("/edit", "")}
|
||||
icon={
|
||||
<Image
|
||||
src="/images/google-datastudio.png"
|
||||
alt="Google Data Studio Icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
}
|
||||
canonicalUrl={this.props.attrs.href}
|
||||
title="Google Data Studio"
|
||||
border
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import GoogleDataStudio from "./GoogleDataStudio";
|
||||
|
||||
describe("GoogleDataStudio", () => {
|
||||
const match = GoogleDataStudio.ENABLED[0];
|
||||
test("to be enabled on share link", () => {
|
||||
expect(
|
||||
"https://datastudio.google.com/embed/reporting/aab01789-f3a2-4ff3-9cba-c4c94c4a92e8/page/7zFD".match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("to not be enabled elsewhere", () => {
|
||||
expect("https://datastudio.google.com/u/0/".match(match)).toBe(null);
|
||||
expect("https://datastudio.google.com".match(match)).toBe(null);
|
||||
expect("https://www.google.com".match(match)).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,6 @@ import Descript from "./Descript";
|
||||
import Figma from "./Figma";
|
||||
import Framer from "./Framer";
|
||||
import Gist from "./Gist";
|
||||
import GoogleDataStudio from "./GoogleDataStudio";
|
||||
import GoogleDocs from "./GoogleDocs";
|
||||
import GoogleDrawings from "./GoogleDrawings";
|
||||
import GoogleDrive from "./GoogleDrive";
|
||||
@@ -149,13 +148,6 @@ export default [
|
||||
component: GoogleSlides,
|
||||
matcher: matcher(GoogleSlides),
|
||||
},
|
||||
{
|
||||
title: "Google Data Studio",
|
||||
keywords: "business intelligence",
|
||||
icon: () => <Img src="/images/google-datastudio.png" />,
|
||||
component: GoogleDataStudio,
|
||||
matcher: matcher(GoogleDataStudio),
|
||||
},
|
||||
{
|
||||
title: "InVision",
|
||||
keywords: "design prototype",
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
type InitialState = boolean | (() => boolean);
|
||||
|
||||
/**
|
||||
* React hook to manage booleans
|
||||
*
|
||||
* @param initialState the initial boolean state value
|
||||
*/
|
||||
export default function useBoolean(initialState: InitialState = false) {
|
||||
const [value, setValue] = React.useState(initialState);
|
||||
|
||||
const setTrue = React.useCallback(() => {
|
||||
setValue(true);
|
||||
}, []);
|
||||
|
||||
const setFalse = React.useCallback(() => {
|
||||
setValue(false);
|
||||
}, []);
|
||||
|
||||
return [value, setTrue, setFalse];
|
||||
}
|
||||
+9
-16
@@ -1,6 +1,5 @@
|
||||
// @flow
|
||||
import "focus-visible";
|
||||
import { LazyMotion } from "framer-motion";
|
||||
import { createBrowserHistory } from "history";
|
||||
import { Provider } from "mobx-react";
|
||||
import * as React from "react";
|
||||
@@ -50,10 +49,6 @@ if ("serviceWorker" in window.navigator) {
|
||||
});
|
||||
}
|
||||
|
||||
// Make sure to return the specific export containing the feature bundle.
|
||||
const loadFeatures = () =>
|
||||
import("./utils/motion.js").then((res) => res.default);
|
||||
|
||||
if (element) {
|
||||
const App = () => (
|
||||
<React.StrictMode>
|
||||
@@ -61,17 +56,15 @@ if (element) {
|
||||
<Analytics>
|
||||
<Theme>
|
||||
<ErrorBoundary>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
<Router history={history}>
|
||||
<>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
</>
|
||||
</Router>
|
||||
</LazyMotion>
|
||||
<Router history={history}>
|
||||
<>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
</>
|
||||
</Router>
|
||||
</ErrorBoundary>
|
||||
</Theme>
|
||||
</Analytics>
|
||||
|
||||
@@ -19,7 +19,6 @@ import MenuItem, { MenuAnchor } from "components/ContextMenu/MenuItem";
|
||||
import Separator from "components/ContextMenu/Separator";
|
||||
import Flex from "components/Flex";
|
||||
import Guide from "components/Guide";
|
||||
import useBoolean from "hooks/useBoolean";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
@@ -79,11 +78,9 @@ function AccountMenu(props: Props) {
|
||||
const { auth, ui } = useStores();
|
||||
const previousTheme = usePrevious(ui.theme);
|
||||
const { t } = useTranslation();
|
||||
const [
|
||||
keyboardShortcutsOpen,
|
||||
handleKeyboardShortcutsOpen,
|
||||
handleKeyboardShortcutsClose,
|
||||
] = useBoolean();
|
||||
const [keyboardShortcutsOpen, setKeyboardShortcutsOpen] = React.useState(
|
||||
false
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (ui.theme !== previousTheme) {
|
||||
@@ -95,7 +92,7 @@ function AccountMenu(props: Props) {
|
||||
<>
|
||||
<Guide
|
||||
isOpen={keyboardShortcutsOpen}
|
||||
onRequestClose={handleKeyboardShortcutsClose}
|
||||
onRequestClose={() => setKeyboardShortcutsOpen(false)}
|
||||
title={t("Keyboard shortcuts")}
|
||||
>
|
||||
<KeyboardShortcuts />
|
||||
@@ -105,7 +102,7 @@ function AccountMenu(props: Props) {
|
||||
<MenuItem {...menu} as={Link} to={settings()}>
|
||||
{t("Settings")}
|
||||
</MenuItem>
|
||||
<MenuItem {...menu} onClick={handleKeyboardShortcutsOpen}>
|
||||
<MenuItem {...menu} onClick={() => setKeyboardShortcutsOpen(true)}>
|
||||
{t("Keyboard shortcuts")}
|
||||
</MenuItem>
|
||||
<MenuItem {...menu} href={developers()} target="_blank">
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { TableOfContentsIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import Button from "components/Button";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import { type MenuItem } from "types";
|
||||
|
||||
type Props = {|
|
||||
headings: { title: string, level: number, id: string }[],
|
||||
|};
|
||||
|
||||
function TableOfContentsMenu({ headings }: Props) {
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
unstable_preventOverflow: true,
|
||||
unstable_fixed: true,
|
||||
unstable_flip: true,
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const minHeading = headings.reduce(
|
||||
(memo, heading) => (heading.level < memo ? heading.level : memo),
|
||||
Infinity
|
||||
);
|
||||
|
||||
const items: MenuItem[] = React.useMemo(() => {
|
||||
let i = [
|
||||
{
|
||||
type: "heading",
|
||||
visible: true,
|
||||
title: t("Contents"),
|
||||
},
|
||||
...headings.map((heading) => ({
|
||||
href: `#${heading.id}`,
|
||||
title: t(heading.title),
|
||||
level: heading.level - minHeading,
|
||||
})),
|
||||
];
|
||||
|
||||
if (i.length === 1) {
|
||||
i.push({
|
||||
href: "#",
|
||||
title: t("Headings you add to the document will appear here"),
|
||||
disabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
return i;
|
||||
}, [t, headings, minHeading]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
icon={<TableOfContentsIcon />}
|
||||
iconColor="currentColor"
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Table of contents")}>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(TableOfContentsMenu);
|
||||
@@ -149,6 +149,12 @@ export default class Document extends BaseModel {
|
||||
get isFromTemplate(): boolean {
|
||||
return !!this.templateId;
|
||||
}
|
||||
|
||||
@computed
|
||||
get placeholder(): ?string {
|
||||
return this.isTemplate ? "Start your template…" : "Start with a title…";
|
||||
}
|
||||
|
||||
@action
|
||||
share = async () => {
|
||||
return this.store.rootStore.shares.create({ documentId: this.id });
|
||||
|
||||
@@ -14,7 +14,7 @@ import Trash from "scenes/Trash";
|
||||
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import Layout from "components/Layout";
|
||||
import PlaceholderDocument from "components/PlaceholderDocument";
|
||||
import LoadingPlaceholder from "components/LoadingPlaceholder";
|
||||
import Route from "components/ProfiledRoute";
|
||||
import SocketProvider from "components/SocketProvider";
|
||||
import { matchDocumentSlug as slug } from "utils/routeHelpers";
|
||||
@@ -43,7 +43,7 @@ export default function AuthenticatedRoutes() {
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<CenteredContent>
|
||||
<PlaceholderDocument />
|
||||
<LoadingPlaceholder />
|
||||
</CenteredContent>
|
||||
}
|
||||
>
|
||||
|
||||
+13
-10
@@ -27,11 +27,11 @@ import Flex from "components/Flex";
|
||||
import Heading from "components/Heading";
|
||||
import HelpText from "components/HelpText";
|
||||
import InputSearchPage from "components/InputSearchPage";
|
||||
import PlaceholderList from "components/List/Placeholder";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
import { ListPlaceholder } from "components/LoadingPlaceholder";
|
||||
import Mask from "components/Mask";
|
||||
import Modal from "components/Modal";
|
||||
import PaginatedDocumentList from "components/PaginatedDocumentList";
|
||||
import PlaceholderText from "components/PlaceholderText";
|
||||
import Scene from "components/Scene";
|
||||
import Subheading from "components/Subheading";
|
||||
import Tab from "components/Tab";
|
||||
@@ -39,7 +39,6 @@ import Tabs from "components/Tabs";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import Collection from "../models/Collection";
|
||||
import { updateCollectionUrl } from "../utils/routeHelpers";
|
||||
import useBoolean from "hooks/useBoolean";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useImportDocument from "hooks/useImportDocument";
|
||||
import useStores from "hooks/useStores";
|
||||
@@ -55,11 +54,7 @@ function CollectionScene() {
|
||||
const team = useCurrentTeam();
|
||||
const [isFetching, setFetching] = React.useState();
|
||||
const [error, setError] = React.useState();
|
||||
const [
|
||||
permissionsModalOpen,
|
||||
handlePermissionsModalOpen,
|
||||
handlePermissionsModalClose,
|
||||
] = useBoolean();
|
||||
const [permissionsModalOpen, setPermissionsModalOpen] = React.useState(false);
|
||||
|
||||
const id = params.id || "";
|
||||
const collection: ?Collection =
|
||||
@@ -107,6 +102,14 @@ function CollectionScene() {
|
||||
load();
|
||||
}, [collections, isFetching, collection, error, id, can]);
|
||||
|
||||
const handlePermissionsModalOpen = React.useCallback(() => {
|
||||
setPermissionsModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handlePermissionsModalClose = React.useCallback(() => {
|
||||
setPermissionsModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleRejection = React.useCallback(() => {
|
||||
ui.showToast(
|
||||
t("Document not supported – try Markdown, Plain text, HTML, or Word"),
|
||||
@@ -373,9 +376,9 @@ function CollectionScene() {
|
||||
) : (
|
||||
<CenteredContent>
|
||||
<Heading>
|
||||
<PlaceholderText height={35} />
|
||||
<Mask height={35} />
|
||||
</Heading>
|
||||
<PlaceholderList count={5} />
|
||||
<ListPlaceholder count={5} />
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,60 +1,62 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Collection from "models/Collection";
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import useStores from "hooks/useStores";
|
||||
import { homeUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
collection: Collection,
|
||||
collections: CollectionsStore,
|
||||
ui: UiStore,
|
||||
onSubmit: () => void,
|
||||
};
|
||||
|
||||
function CollectionDelete({ collection, onSubmit }: Props) {
|
||||
const [isDeleting, setIsDeleting] = React.useState();
|
||||
const { ui } = useStores();
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
@observer
|
||||
class CollectionDelete extends React.Component<Props> {
|
||||
@observable isDeleting: boolean;
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
setIsDeleting(true);
|
||||
handleSubmit = async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.isDeleting = true;
|
||||
|
||||
try {
|
||||
await collection.delete();
|
||||
history.push(homeUrl());
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
ui.showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
},
|
||||
[ui, onSubmit, collection, history]
|
||||
);
|
||||
try {
|
||||
await this.props.collection.delete();
|
||||
this.props.history.push(homeUrl());
|
||||
this.props.onSubmit();
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
this.isDeleting = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash."
|
||||
values={{ collectionName: collection.name }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
</HelpText>
|
||||
<Button type="submit" disabled={isDeleting} autoFocus danger>
|
||||
{isDeleting ? `${t("Deleting")}…` : t("I’m sure – Delete")}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
render() {
|
||||
const { collection } = this.props;
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<HelpText>
|
||||
Are you sure about that? Deleting the{" "}
|
||||
<strong>{collection.name}</strong> collection is permanent and
|
||||
cannot be restored, however documents within will be moved to the
|
||||
trash.
|
||||
</HelpText>
|
||||
<Button type="submit" disabled={this.isDeleting} autoFocus danger>
|
||||
{this.isDeleting ? "Deleting…" : "I’m sure – Delete"}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default observer(CollectionDelete);
|
||||
export default inject("collections", "ui")(withRouter(CollectionDelete));
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Collection from "models/Collection";
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
@@ -9,41 +11,43 @@ import HelpText from "components/HelpText";
|
||||
|
||||
type Props = {
|
||||
collection: Collection,
|
||||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
onSubmit: () => void,
|
||||
};
|
||||
|
||||
function CollectionExport({ collection, onSubmit }: Props) {
|
||||
const [isLoading, setIsLoading] = React.useState();
|
||||
const { t } = useTranslation();
|
||||
@observer
|
||||
class CollectionExport extends React.Component<Props> {
|
||||
@observable isLoading: boolean = false;
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
handleSubmit = async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
|
||||
setIsLoading(true);
|
||||
await collection.export();
|
||||
setIsLoading(false);
|
||||
onSubmit();
|
||||
},
|
||||
[collection, onSubmit]
|
||||
);
|
||||
this.isLoading = true;
|
||||
await this.props.collection.export();
|
||||
this.isLoading = false;
|
||||
this.props.onSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format."
|
||||
values={{ collectionName: collection.name }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
</HelpText>
|
||||
<Button type="submit" disabled={isLoading} primary>
|
||||
{isLoading ? `${t("Exporting")}…` : t("Export Collection")}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
render() {
|
||||
const { collection, auth } = this.props;
|
||||
if (!auth.user) return null;
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<HelpText>
|
||||
Exporting the collection <strong>{collection.name}</strong> may take
|
||||
a few seconds. Your documents will be downloaded as a zip of folders
|
||||
with files in Markdown format.
|
||||
</HelpText>
|
||||
<Button type="submit" disabled={this.isLoading} primary>
|
||||
{this.isLoading ? "Exporting…" : "Export Collection"}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default observer(CollectionExport);
|
||||
export default inject("ui", "auth")(CollectionExport);
|
||||
|
||||
@@ -17,7 +17,6 @@ import AddGroupsToCollection from "./AddGroupsToCollection";
|
||||
import AddPeopleToCollection from "./AddPeopleToCollection";
|
||||
import CollectionGroupMemberListItem from "./components/CollectionGroupMemberListItem";
|
||||
import MemberListItem from "./components/MemberListItem";
|
||||
import useBoolean from "hooks/useBoolean";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
@@ -35,16 +34,8 @@ function CollectionPermissions({ collection }: Props) {
|
||||
users,
|
||||
groups,
|
||||
} = useStores();
|
||||
const [
|
||||
addGroupModalOpen,
|
||||
handleAddGroupModalOpen,
|
||||
handleAddGroupModalClose,
|
||||
] = useBoolean();
|
||||
const [
|
||||
addMemberModalOpen,
|
||||
handleAddMemberModalOpen,
|
||||
handleAddMemberModalClose,
|
||||
] = useBoolean();
|
||||
const [addGroupModalOpen, setAddGroupModalOpen] = React.useState(false);
|
||||
const [addMemberModalOpen, setAddMemberModalOpen] = React.useState(false);
|
||||
|
||||
const handleRemoveUser = React.useCallback(
|
||||
async (user) => {
|
||||
@@ -192,7 +183,7 @@ function CollectionPermissions({ collection }: Props) {
|
||||
<Actions>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddGroupModalOpen}
|
||||
onClick={() => setAddGroupModalOpen(true)}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
@@ -200,7 +191,7 @@ function CollectionPermissions({ collection }: Props) {
|
||||
</Button>{" "}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddMemberModalOpen}
|
||||
onClick={() => setAddMemberModalOpen(true)}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
@@ -253,24 +244,24 @@ function CollectionPermissions({ collection }: Props) {
|
||||
title={t(`Add groups to {{ collectionName }}`, {
|
||||
collectionName: collection.name,
|
||||
})}
|
||||
onRequestClose={handleAddGroupModalClose}
|
||||
onRequestClose={() => setAddGroupModalOpen(false)}
|
||||
isOpen={addGroupModalOpen}
|
||||
>
|
||||
<AddGroupsToCollection
|
||||
collection={collection}
|
||||
onSubmit={handleAddGroupModalClose}
|
||||
onSubmit={() => setAddGroupModalOpen(false)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t(`Add people to {{ collectionName }}`, {
|
||||
collectionName: collection.name,
|
||||
})}
|
||||
onRequestClose={handleAddMemberModalClose}
|
||||
onRequestClose={() => setAddMemberModalOpen(false)}
|
||||
isOpen={addMemberModalOpen}
|
||||
>
|
||||
<AddPeopleToCollection
|
||||
collection={collection}
|
||||
onSubmit={handleAddMemberModalClose}
|
||||
onSubmit={() => setAddMemberModalOpen(false)}
|
||||
/>
|
||||
</Modal>
|
||||
</Flex>
|
||||
|
||||
@@ -70,13 +70,11 @@ const Wrapper = styled("div")`
|
||||
display: none;
|
||||
position: sticky;
|
||||
top: 80px;
|
||||
max-height: calc(100vh - 80px);
|
||||
|
||||
box-shadow: 1px 0 0 ${(props) => props.theme.divider};
|
||||
margin-top: 40px;
|
||||
margin-right: 2em;
|
||||
min-height: 40px;
|
||||
overflow-y: auto;
|
||||
|
||||
${breakpoint("desktopLarge")`
|
||||
margin-left: -16em;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { InputIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { type TFunction, Trans, withTranslation } from "react-i18next";
|
||||
import keydown from "react-keydown";
|
||||
import { Prompt, Route, withRouter } from "react-router-dom";
|
||||
import type { RouterHistory, Match } from "react-router-dom";
|
||||
@@ -19,10 +18,10 @@ import Branding from "components/Branding";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import Flex from "components/Flex";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
import LoadingPlaceholder from "components/LoadingPlaceholder";
|
||||
import Modal from "components/Modal";
|
||||
import Notice from "components/Notice";
|
||||
import PageTitle from "components/PageTitle";
|
||||
import PlaceholderDocument from "components/PlaceholderDocument";
|
||||
import Time from "components/Time";
|
||||
import Container from "./Container";
|
||||
import Contents from "./Contents";
|
||||
@@ -45,6 +44,15 @@ import {
|
||||
|
||||
const AUTOSAVE_DELAY = 3000;
|
||||
const IS_DIRTY_DELAY = 500;
|
||||
const DISCARD_CHANGES = `
|
||||
You have unsaved changes.
|
||||
Are you sure you want to discard them?
|
||||
`;
|
||||
const UPLOADING_WARNING = `
|
||||
Images are still uploading.
|
||||
Are you sure you want to discard them?
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
match: Match,
|
||||
history: RouterHistory,
|
||||
@@ -59,7 +67,6 @@ type Props = {
|
||||
theme: Theme,
|
||||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -75,7 +82,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
getEditorText: () => string = () => this.props.document.text;
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { auth, document, t } = this.props;
|
||||
const { auth, document } = this.props;
|
||||
|
||||
if (prevProps.readOnly && !this.props.readOnly) {
|
||||
this.updateIsDirty();
|
||||
@@ -90,7 +97,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
} else if (prevProps.document.revision !== this.lastRevision) {
|
||||
if (auth.user && document.updatedBy.id !== auth.user.id) {
|
||||
this.props.ui.showToast(
|
||||
t(`Document updated by ${document.updatedBy.name}`),
|
||||
`Document updated by ${document.updatedBy.name}`,
|
||||
{
|
||||
timeout: 30 * 1000,
|
||||
type: "warning",
|
||||
@@ -295,7 +302,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
auth,
|
||||
ui,
|
||||
match,
|
||||
t,
|
||||
} = this.props;
|
||||
const team = auth.team;
|
||||
const { shareId } = match.params;
|
||||
@@ -345,15 +351,11 @@ class DocumentScene extends React.Component<Props> {
|
||||
<>
|
||||
<Prompt
|
||||
when={this.isDirty && !this.isUploading}
|
||||
message={t(
|
||||
`You have unsaved changes.\nAre you sure you want to discard them?`
|
||||
)}
|
||||
message={DISCARD_CHANGES}
|
||||
/>
|
||||
<Prompt
|
||||
when={this.isUploading && !this.isDirty}
|
||||
message={t(
|
||||
`Images are still uploading.\nAre you sure you want to discard them?`
|
||||
)}
|
||||
message={UPLOADING_WARNING}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -372,7 +374,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
sharedTree={this.props.sharedTree}
|
||||
goBack={this.goBack}
|
||||
onSave={this.onSave}
|
||||
headings={headings}
|
||||
/>
|
||||
<MaxWidth
|
||||
archived={document.isArchived}
|
||||
@@ -382,51 +383,33 @@ class DocumentScene extends React.Component<Props> {
|
||||
>
|
||||
{document.isTemplate && !readOnly && (
|
||||
<Notice muted>
|
||||
<Trans>
|
||||
You’re editing a template. Highlight some text and use the{" "}
|
||||
<PlaceholderIcon color="currentColor" /> control to add
|
||||
placeholders that can be filled out when creating new
|
||||
documents from this template.
|
||||
</Trans>
|
||||
You’re editing a template. Highlight some text and use the{" "}
|
||||
<PlaceholderIcon color="currentColor" /> control to add
|
||||
placeholders that can be filled out when creating new
|
||||
documents from this template.
|
||||
</Notice>
|
||||
)}
|
||||
{document.archivedAt && !document.deletedAt && (
|
||||
<Notice muted>
|
||||
{t("Archived by {{userName}}", {
|
||||
userName: document.updatedBy.name,
|
||||
})}{" "}
|
||||
<Time dateTime={document.updatedAt} addSuffix />
|
||||
Archived by {document.updatedBy.name}{" "}
|
||||
<Time dateTime={document.archivedAt} /> ago
|
||||
</Notice>
|
||||
)}
|
||||
{document.deletedAt && (
|
||||
<Notice muted>
|
||||
<strong>
|
||||
{t("Deleted by {{userName}}", {
|
||||
userName: document.updatedBy.name,
|
||||
})}{" "}
|
||||
<Time dateTime={document.deletedAt || ""} addSuffix />
|
||||
</strong>
|
||||
Deleted by {document.updatedBy.name}{" "}
|
||||
<Time dateTime={document.deletedAt} /> ago
|
||||
{document.permanentlyDeletedAt && (
|
||||
<>
|
||||
<br />
|
||||
{document.template ? (
|
||||
<Trans>
|
||||
This template will be permanently deleted in{" "}
|
||||
<Time dateTime={document.permanentlyDeletedAt} />{" "}
|
||||
unless restored.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
This document will be permanently deleted in{" "}
|
||||
<Time dateTime={document.permanentlyDeletedAt} />{" "}
|
||||
unless restored.
|
||||
</Trans>
|
||||
)}
|
||||
This {document.noun} will be permanently deleted in{" "}
|
||||
<Time dateTime={document.permanentlyDeletedAt} /> unless
|
||||
restored.
|
||||
</>
|
||||
)}
|
||||
</Notice>
|
||||
)}
|
||||
<React.Suspense fallback={<PlaceholderDocument />}>
|
||||
<React.Suspense fallback={<LoadingPlaceholder />}>
|
||||
<Flex auto={!readOnly}>
|
||||
{showContents && <Contents headings={headings} />}
|
||||
<Editor
|
||||
@@ -524,7 +507,5 @@ const MaxWidth = styled(Flex)`
|
||||
`;
|
||||
|
||||
export default withRouter(
|
||||
withTranslation()<DocumentScene>(
|
||||
inject("ui", "auth", "policies", "revisions")(DocumentScene)
|
||||
)
|
||||
inject("ui", "auth", "policies", "revisions")(DocumentScene)
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import Textarea from "react-autosize-textarea";
|
||||
import { type TFunction, withTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { MAX_TITLE_LENGTH } from "shared/constants";
|
||||
@@ -29,7 +28,6 @@ type Props = {|
|
||||
onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
|
||||
innerRef: { current: any },
|
||||
children: React.Node,
|
||||
t: TFunction,
|
||||
|};
|
||||
|
||||
@observer
|
||||
@@ -104,7 +102,6 @@ class DocumentEditor extends React.Component<Props> {
|
||||
readOnly,
|
||||
innerRef,
|
||||
children,
|
||||
t,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
@@ -132,11 +129,7 @@ class DocumentEditor extends React.Component<Props> {
|
||||
ref={this.ref}
|
||||
onChange={onChangeTitle}
|
||||
onKeyDown={this.handleTitleKeyDown}
|
||||
placeholder={
|
||||
document.isTemplate
|
||||
? t("Start your template…")
|
||||
: t("Start with a title…")
|
||||
}
|
||||
placeholder={document.placeholder}
|
||||
value={normalizedTitle}
|
||||
$startsWithEmojiAndSpace={startsWithEmojiAndSpace}
|
||||
autoFocus={!title}
|
||||
@@ -159,7 +152,7 @@ class DocumentEditor extends React.Component<Props> {
|
||||
<Editor
|
||||
ref={innerRef}
|
||||
autoFocus={!!title && !this.props.defaultValue}
|
||||
placeholder={t("…the rest is up to you")}
|
||||
placeholder="…the rest is up to you"
|
||||
onHoverLink={this.handleLinkActive}
|
||||
scrollTo={window.location.hash}
|
||||
readOnly={readOnly}
|
||||
@@ -231,4 +224,4 @@ const Title = styled(Textarea)`
|
||||
}
|
||||
`;
|
||||
|
||||
export default withTranslation()<DocumentEditor>(DocumentEditor);
|
||||
export default DocumentEditor;
|
||||
|
||||
@@ -24,7 +24,6 @@ import useMobile from "hooks/useMobile";
|
||||
import useStores from "hooks/useStores";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
import NewChildDocumentMenu from "menus/NewChildDocumentMenu";
|
||||
import TableOfContentsMenu from "menus/TableOfContentsMenu";
|
||||
import TemplatesMenu from "menus/TemplatesMenu";
|
||||
import { type NavigationNode } from "types";
|
||||
import { metaDisplay } from "utils/keyboard";
|
||||
@@ -47,7 +46,6 @@ type Props = {|
|
||||
publish?: boolean,
|
||||
autosave?: boolean,
|
||||
}) => void,
|
||||
headings: { title: string, level: number, id: string }[],
|
||||
|};
|
||||
|
||||
function DocumentHeader({
|
||||
@@ -62,7 +60,6 @@ function DocumentHeader({
|
||||
publishingIsDisabled,
|
||||
sharedTree,
|
||||
onSave,
|
||||
headings,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { auth, ui, policies } = useStores();
|
||||
@@ -156,11 +153,6 @@ function DocumentHeader({
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{isMobile && (
|
||||
<TocWrapper>
|
||||
<TableOfContentsMenu headings={headings} />
|
||||
</TocWrapper>
|
||||
)}
|
||||
{!isPublishing && isSaving && <Status>{t("Saving")}…</Status>}
|
||||
<Collaborators
|
||||
document={document}
|
||||
@@ -282,9 +274,4 @@ const Status = styled(Action)`
|
||||
color: ${(props) => props.theme.slate};
|
||||
`;
|
||||
|
||||
const TocWrapper = styled(Action)`
|
||||
position: absolute;
|
||||
left: 42px;
|
||||
`;
|
||||
|
||||
export default observer(DocumentHeader);
|
||||
|
||||
@@ -8,15 +8,20 @@ import KeyboardShortcuts from "scenes/KeyboardShortcuts";
|
||||
import Guide from "components/Guide";
|
||||
import NudeButton from "components/NudeButton";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import useBoolean from "hooks/useBoolean";
|
||||
|
||||
function KeyboardShortcutsButton() {
|
||||
const { t } = useTranslation();
|
||||
const [
|
||||
keyboardShortcutsOpen,
|
||||
handleOpenKeyboardShortcuts,
|
||||
handleCloseKeyboardShortcuts,
|
||||
] = useBoolean();
|
||||
const [keyboardShortcutsOpen, setKeyboardShortcutsOpen] = React.useState(
|
||||
false
|
||||
);
|
||||
|
||||
const handleCloseKeyboardShortcuts = React.useCallback(() => {
|
||||
setKeyboardShortcutsOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleOpenKeyboardShortcuts = React.useCallback(() => {
|
||||
setKeyboardShortcutsOpen(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import LoadingPlaceholder from "components/LoadingPlaceholder";
|
||||
import PageTitle from "components/PageTitle";
|
||||
import PlaceholderDocument from "components/PlaceholderDocument";
|
||||
import Container from "./Container";
|
||||
import type { LocationWithState } from "types";
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function Loading({ location }: Props) {
|
||||
title={location.state ? location.state.title : t("Untitled")}
|
||||
/>
|
||||
<CenteredContent>
|
||||
<PlaceholderDocument />
|
||||
<LoadingPlaceholder />
|
||||
</CenteredContent>
|
||||
</Container>
|
||||
);
|
||||
|
||||
+86
-73
@@ -1,32 +1,37 @@
|
||||
// @flow
|
||||
import { Search } from "js-search";
|
||||
import { last } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { observable, computed } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { FixedSizeList as List } from "react-window";
|
||||
import styled from "styled-components";
|
||||
import { type DocumentPath } from "stores/CollectionsStore";
|
||||
import CollectionsStore, { type DocumentPath } from "stores/CollectionsStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Document from "models/Document";
|
||||
import Flex from "components/Flex";
|
||||
import { Outline } from "components/Input";
|
||||
import Labeled from "components/Labeled";
|
||||
import PathToDocument from "components/PathToDocument";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
documents: DocumentsStore,
|
||||
collections: CollectionsStore,
|
||||
ui: UiStore,
|
||||
onRequestClose: () => void,
|
||||
|};
|
||||
|
||||
function DocumentMove({ document, onRequestClose }: Props) {
|
||||
const [searchTerm, setSearchTerm] = useState();
|
||||
const { ui, collections, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
@observer
|
||||
class DocumentMove extends React.Component<Props> {
|
||||
@observable searchTerm: ?string;
|
||||
@observable isSaving: boolean;
|
||||
|
||||
const searchIndex = useMemo(() => {
|
||||
@computed
|
||||
get searchIndex() {
|
||||
const { collections, documents } = this.props;
|
||||
const paths = collections.pathsToDocuments;
|
||||
const index = new Search("id");
|
||||
index.addIndex("title");
|
||||
@@ -42,16 +47,19 @@ function DocumentMove({ document, onRequestClose }: Props) {
|
||||
index.addDocuments(indexeableDocuments);
|
||||
|
||||
return index;
|
||||
}, [documents, collections.pathsToDocuments]);
|
||||
}
|
||||
|
||||
const results: DocumentPath[] = useMemo(() => {
|
||||
@computed
|
||||
get results(): DocumentPath[] {
|
||||
const { document, collections } = this.props;
|
||||
const onlyShowCollections = document.isTemplate;
|
||||
|
||||
let results = [];
|
||||
if (collections.isLoaded) {
|
||||
if (searchTerm) {
|
||||
results = searchIndex.search(searchTerm);
|
||||
if (this.searchTerm) {
|
||||
results = this.searchIndex.search(this.searchTerm);
|
||||
} else {
|
||||
results = searchIndex._documents;
|
||||
results = this.searchIndex._documents;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,18 +82,19 @@ function DocumentMove({ document, onRequestClose }: Props) {
|
||||
}
|
||||
|
||||
return results;
|
||||
}, [document, collections, searchTerm, searchIndex]);
|
||||
}
|
||||
|
||||
const handleSuccess = () => {
|
||||
ui.showToast(t("Document moved"), { type: "info" });
|
||||
onRequestClose();
|
||||
handleSuccess = () => {
|
||||
this.props.ui.showToast("Document moved", { type: "info" });
|
||||
this.props.onRequestClose();
|
||||
};
|
||||
|
||||
const handleFilter = (ev: SyntheticInputEvent<*>) => {
|
||||
setSearchTerm(ev.target.value);
|
||||
handleFilter = (ev: SyntheticInputEvent<*>) => {
|
||||
this.searchTerm = ev.target.value;
|
||||
};
|
||||
|
||||
const renderPathToCurrentDocument = () => {
|
||||
renderPathToCurrentDocument() {
|
||||
const { collections, document } = this.props;
|
||||
const result = collections.getPathForDocument(document.id);
|
||||
|
||||
if (result) {
|
||||
@@ -96,71 +105,75 @@ function DocumentMove({ document, onRequestClose }: Props) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const row = ({ index, data, style }) => {
|
||||
row = ({ index, data, style }) => {
|
||||
const result = data[index];
|
||||
const { document, collections } = this.props;
|
||||
|
||||
return (
|
||||
<PathToDocument
|
||||
result={result}
|
||||
document={document}
|
||||
collection={collections.get(result.collectionId)}
|
||||
onSuccess={handleSuccess}
|
||||
onSuccess={this.handleSuccess}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const data = results;
|
||||
render() {
|
||||
const { document, collections } = this.props;
|
||||
const data = this.results;
|
||||
|
||||
if (!document || !collections.isLoaded) {
|
||||
return null;
|
||||
if (!document || !collections.isLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<Section>
|
||||
<Labeled label="Current location">
|
||||
{this.renderPathToCurrentDocument()}
|
||||
</Labeled>
|
||||
</Section>
|
||||
|
||||
<Section column>
|
||||
<Labeled label="Choose a new location" />
|
||||
<NewLocation>
|
||||
<InputWrapper>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search collections & documents…"
|
||||
onChange={this.handleFilter}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</InputWrapper>
|
||||
<Results>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<Flex role="listbox" column>
|
||||
<List
|
||||
key={data.length}
|
||||
width={width}
|
||||
height={height}
|
||||
itemData={data}
|
||||
itemCount={data.length}
|
||||
itemSize={40}
|
||||
itemKey={(index, data) => data[index].id}
|
||||
>
|
||||
{this.row}
|
||||
</List>
|
||||
</Flex>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</Results>
|
||||
</NewLocation>
|
||||
</Section>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<Section>
|
||||
<Labeled label={t("Current location")}>
|
||||
{renderPathToCurrentDocument()}
|
||||
</Labeled>
|
||||
</Section>
|
||||
|
||||
<Section column>
|
||||
<Labeled label={t("Choose a new location")} />
|
||||
<NewLocation>
|
||||
<InputWrapper>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={`${t("Search collections & documents")}…`}
|
||||
onChange={handleFilter}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</InputWrapper>
|
||||
<Results>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<Flex role="listbox" column>
|
||||
<List
|
||||
key={data.length}
|
||||
width={width}
|
||||
height={height}
|
||||
itemData={data}
|
||||
itemCount={data.length}
|
||||
itemSize={40}
|
||||
itemKey={(index, data) => data[index].id}
|
||||
>
|
||||
{row}
|
||||
</List>
|
||||
</Flex>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</Results>
|
||||
</NewLocation>
|
||||
</Section>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const InputWrapper = styled("div")`
|
||||
@@ -197,4 +210,4 @@ const Section = styled(Flex)`
|
||||
margin-bottom: 24px;
|
||||
`;
|
||||
|
||||
export default observer(DocumentMove);
|
||||
export default inject("documents", "collections", "ui")(DocumentMove);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import Flex from "components/Flex";
|
||||
import PlaceholderDocument from "components/PlaceholderDocument";
|
||||
import LoadingPlaceholder from "components/LoadingPlaceholder";
|
||||
import useStores from "hooks/useStores";
|
||||
import { editDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
@@ -48,7 +48,7 @@ function DocumentNew() {
|
||||
return (
|
||||
<Flex column auto>
|
||||
<CenteredContent>
|
||||
<PlaceholderDocument />
|
||||
<LoadingPlaceholder />
|
||||
</CenteredContent>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -1,64 +1,63 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Document from "models/Document";
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import useStores from "hooks/useStores";
|
||||
import { documentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
document: Document,
|
||||
history: RouterHistory,
|
||||
onSubmit: () => void,
|
||||
};
|
||||
|
||||
function DocumentTemplatize({ document, onSubmit }: Props) {
|
||||
const [isSaving, setIsSaving] = useState();
|
||||
const history = useHistory();
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
@observer
|
||||
class DocumentTemplatize extends React.Component<Props> {
|
||||
@observable isSaving: boolean;
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
handleSubmit = async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
const template = await document.templatize();
|
||||
history.push(documentUrl(template));
|
||||
ui.showToast(t("Template created, go ahead and customize it"), {
|
||||
type: "info",
|
||||
});
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
ui.showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[document, ui, history, onSubmit, t]
|
||||
);
|
||||
try {
|
||||
const template = await this.props.document.templatize();
|
||||
this.props.history.push(documentUrl(template));
|
||||
this.props.ui.showToast("Template created, go ahead and customize it", {
|
||||
type: "info",
|
||||
});
|
||||
this.props.onSubmit();
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents."
|
||||
values={{ titleWithDefault: document.titleWithDefault }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
</HelpText>
|
||||
<Button type="submit">
|
||||
{isSaving ? `${t("Creating")}…` : t("Create template")}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
render() {
|
||||
const { document } = this.props;
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<HelpText>
|
||||
Creating a template from{" "}
|
||||
<strong>{document.titleWithDefault}</strong> is a non-destructive
|
||||
action – we'll make a copy of the document and turn it into a
|
||||
template that can be used as a starting point for new documents.
|
||||
</HelpText>
|
||||
<Button type="submit">
|
||||
{this.isSaving ? "Creating…" : "Create template"}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default observer(DocumentTemplatize);
|
||||
export default inject("ui")(withRouter(DocumentTemplatize));
|
||||
|
||||
+37
-35
@@ -1,57 +1,59 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import { groupSettings } from "shared/utils/routeHelpers";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Group from "models/Group";
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
group: Group,
|
||||
ui: UiStore,
|
||||
onSubmit: () => void,
|
||||
|};
|
||||
};
|
||||
|
||||
function GroupDelete({ group, onSubmit }: Props) {
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const [isDeleting, setIsDeleting] = React.useState();
|
||||
@observer
|
||||
class GroupDelete extends React.Component<Props> {
|
||||
@observable isDeleting: boolean;
|
||||
|
||||
const handleSubmit = async (ev: SyntheticEvent<>) => {
|
||||
handleSubmit = async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
setIsDeleting(true);
|
||||
this.isDeleting = true;
|
||||
|
||||
try {
|
||||
await group.delete();
|
||||
history.push(groupSettings());
|
||||
onSubmit();
|
||||
await this.props.group.delete();
|
||||
this.props.history.push(groupSettings());
|
||||
this.props.onSubmit();
|
||||
} catch (err) {
|
||||
ui.showToast(err.message, { type: "error" });
|
||||
this.props.ui.showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
this.isDeleting = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with."
|
||||
values={{ groupName: group.name }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
</HelpText>
|
||||
<Button type="submit" danger>
|
||||
{isDeleting ? `${t("Deleting")}…` : t("I’m sure – Delete")}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
render() {
|
||||
const { group } = this.props;
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<HelpText>
|
||||
Are you sure about that? Deleting the <strong>{group.name}</strong>{" "}
|
||||
group will cause its members to lose access to collections and
|
||||
documents that it is associated with.
|
||||
</HelpText>
|
||||
<Button type="submit" danger>
|
||||
{this.isDeleting ? "Deleting…" : "I’m sure – Delete"}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default observer(GroupDelete);
|
||||
export default inject("ui")(withRouter(GroupDelete));
|
||||
|
||||
+48
-49
@@ -1,71 +1,70 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Group from "models/Group";
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import Input from "components/Input";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
ui: UiStore,
|
||||
group: Group,
|
||||
onSubmit: () => void,
|
||||
};
|
||||
|
||||
function GroupEdit({ group, onSubmit }: Props) {
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState(group.name);
|
||||
const [isSaving, setIsSaving] = React.useState();
|
||||
@observer
|
||||
class GroupEdit extends React.Component<Props> {
|
||||
@observable name: string = this.props.group.name;
|
||||
@observable isSaving: boolean;
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
handleSubmit = async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
await group.save({ name: name });
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
ui.showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[group, onSubmit, ui, name]
|
||||
);
|
||||
try {
|
||||
await this.props.group.save({ name: this.name });
|
||||
this.props.onSubmit();
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = React.useCallback((ev: SyntheticInputEvent<*>) => {
|
||||
setName(ev.target.value);
|
||||
}, []);
|
||||
handleNameChange = (ev: SyntheticInputEvent<*>) => {
|
||||
this.name = ev.target.value;
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
render() {
|
||||
return (
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<HelpText>
|
||||
You can edit the name of this group at any time, however doing so too
|
||||
often might confuse your team mates.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
<Flex>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
onChange={handleNameChange}
|
||||
value={name}
|
||||
required
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
</Flex>
|
||||
</HelpText>
|
||||
<Flex>
|
||||
<Input
|
||||
type="text"
|
||||
label="Name"
|
||||
onChange={this.handleNameChange}
|
||||
value={this.name}
|
||||
required
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Button type="submit" disabled={isSaving || !name}>
|
||||
{isSaving ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
<Button type="submit" disabled={this.isSaving || !this.name}>
|
||||
{this.isSaving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default observer(GroupEdit);
|
||||
export default inject("ui")(withRouter(GroupEdit));
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import GroupMembershipsStore from "stores/GroupMembershipsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import UsersStore from "stores/UsersStore";
|
||||
import Group from "models/Group";
|
||||
import User from "models/User";
|
||||
import Button from "components/Button";
|
||||
@@ -14,99 +20,112 @@ import PaginatedList from "components/PaginatedList";
|
||||
import Subheading from "components/Subheading";
|
||||
import AddPeopleToGroup from "./AddPeopleToGroup";
|
||||
import GroupMemberListItem from "./components/GroupMemberListItem";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
auth: AuthStore,
|
||||
group: Group,
|
||||
users: UsersStore,
|
||||
policies: PoliciesStore,
|
||||
groupMemberships: GroupMembershipsStore,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
function GroupMembers({ group }: Props) {
|
||||
const [addModalOpen, setAddModalOpen] = React.useState();
|
||||
const { users, groupMemberships, policies, ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = policies.abilities(group.id);
|
||||
@observer
|
||||
class GroupMembers extends React.Component<Props> {
|
||||
@observable addModalOpen: boolean = false;
|
||||
|
||||
const handleAddModal = (state) => {
|
||||
setAddModalOpen(state);
|
||||
handleAddModalOpen = () => {
|
||||
this.addModalOpen = true;
|
||||
};
|
||||
|
||||
const handleRemoveUser = async (user: User) => {
|
||||
handleAddModalClose = () => {
|
||||
this.addModalOpen = false;
|
||||
};
|
||||
|
||||
handleRemoveUser = async (user: User) => {
|
||||
const { t } = this.props;
|
||||
|
||||
try {
|
||||
await groupMemberships.delete({
|
||||
groupId: group.id,
|
||||
await this.props.groupMemberships.delete({
|
||||
groupId: this.props.group.id,
|
||||
userId: user.id,
|
||||
});
|
||||
ui.showToast(
|
||||
this.props.ui.showToast(
|
||||
t(`{{userName}} was removed from the group`, { userName: user.name }),
|
||||
{ type: "success" }
|
||||
);
|
||||
} catch (err) {
|
||||
ui.showToast(t("Could not remove user"), { type: "error" });
|
||||
this.props.ui.showToast(t("Could not remove user"), { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
{can.update ? (
|
||||
<>
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="Add and remove team members in the <em>{{groupName}}</em> group. Adding people to the group will give them access to any collections this group has been added to."
|
||||
values={{ groupName: group.name }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
</HelpText>
|
||||
<span>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleAddModal(true)}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("Add people")}…
|
||||
</Button>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="Listing team members in the <em>{{groupName}}</em> group."
|
||||
values={{ groupName: group.name }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
</HelpText>
|
||||
)}
|
||||
render() {
|
||||
const { group, users, groupMemberships, policies, t, auth } = this.props;
|
||||
const { user } = auth;
|
||||
if (!user) return null;
|
||||
|
||||
<Subheading>
|
||||
<Trans>Members</Trans>
|
||||
</Subheading>
|
||||
<PaginatedList
|
||||
items={users.inGroup(group.id)}
|
||||
fetch={groupMemberships.fetchPage}
|
||||
options={{ id: group.id }}
|
||||
empty={<Empty>{t("This group has no members.")}</Empty>}
|
||||
renderItem={(item) => (
|
||||
<GroupMemberListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
onRemove={can.update ? () => handleRemoveUser(item) : undefined}
|
||||
/>
|
||||
const can = policies.abilities(group.id);
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
{can.update ? (
|
||||
<>
|
||||
<HelpText>
|
||||
Add and remove team members in the <strong>{group.name}</strong>{" "}
|
||||
group. Adding people to the group will give them access to any
|
||||
collections this group has been added to.
|
||||
</HelpText>
|
||||
<span>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={this.handleAddModalOpen}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("Add people")}…
|
||||
</Button>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<HelpText>
|
||||
Listing team members in the <strong>{group.name}</strong> group.
|
||||
</HelpText>
|
||||
)}
|
||||
/>
|
||||
{can.update && (
|
||||
<Modal
|
||||
title={t(`Add people to {{groupName}}`, { groupName: group.name })}
|
||||
onRequestClose={() => handleAddModal(false)}
|
||||
isOpen={addModalOpen}
|
||||
>
|
||||
<AddPeopleToGroup
|
||||
group={group}
|
||||
onSubmit={() => handleAddModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
<Subheading>Members</Subheading>
|
||||
<PaginatedList
|
||||
items={users.inGroup(group.id)}
|
||||
fetch={groupMemberships.fetchPage}
|
||||
options={{ id: group.id }}
|
||||
empty={<Empty>{t("This group has no members.")}</Empty>}
|
||||
renderItem={(item) => (
|
||||
<GroupMemberListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
onRemove={
|
||||
can.update ? () => this.handleRemoveUser(item) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{can.update && (
|
||||
<Modal
|
||||
title={`Add people to ${group.name}`}
|
||||
onRequestClose={this.handleAddModalClose}
|
||||
isOpen={this.addModalOpen}
|
||||
>
|
||||
<AddPeopleToGroup
|
||||
group={group}
|
||||
onSubmit={this.handleAddModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default observer(GroupMembers);
|
||||
export default withTranslation()<GroupMembers>(
|
||||
inject("auth", "users", "policies", "groupMemberships", "ui")(GroupMembers)
|
||||
);
|
||||
|
||||
+55
-53
@@ -1,7 +1,10 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import GroupsStore from "stores/GroupsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Group from "models/Group";
|
||||
import GroupMembers from "scenes/GroupMembers";
|
||||
import Button from "components/Button";
|
||||
@@ -9,80 +12,79 @@ import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import Input from "components/Input";
|
||||
import Modal from "components/Modal";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
ui: UiStore,
|
||||
groups: GroupsStore,
|
||||
onSubmit: () => void,
|
||||
};
|
||||
|
||||
function GroupNew({ onSubmit }: Props) {
|
||||
const { ui, groups } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState();
|
||||
const [isSaving, setIsSaving] = React.useState();
|
||||
const [group, setGroup] = React.useState();
|
||||
@observer
|
||||
class GroupNew extends React.Component<Props> {
|
||||
@observable name: string = "";
|
||||
@observable isSaving: boolean;
|
||||
@observable group: Group;
|
||||
|
||||
const handleSubmit = async (ev: SyntheticEvent<>) => {
|
||||
handleSubmit = async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
this.isSaving = true;
|
||||
const group = new Group(
|
||||
{
|
||||
name: name,
|
||||
name: this.name,
|
||||
},
|
||||
groups
|
||||
this.props.groups
|
||||
);
|
||||
|
||||
try {
|
||||
setGroup(await group.save());
|
||||
this.group = await group.save();
|
||||
} catch (err) {
|
||||
ui.showToast(err.message, { type: "error" });
|
||||
this.props.ui.showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
this.isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (ev: SyntheticInputEvent<*>) => {
|
||||
setName(ev.target.value);
|
||||
handleNameChange = (ev: SyntheticInputEvent<*>) => {
|
||||
this.name = ev.target.value;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<HelpText>
|
||||
Groups are for organizing your team. They work best when centered
|
||||
around a function or a responsibility — Support or Engineering for
|
||||
example.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
<Flex>
|
||||
<Input
|
||||
type="text"
|
||||
label="Name"
|
||||
onChange={handleNameChange}
|
||||
value={name}
|
||||
required
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
</Flex>
|
||||
<HelpText>
|
||||
<Trans>You’ll be able to add people to the group next.</Trans>
|
||||
</HelpText>
|
||||
</HelpText>
|
||||
<Flex>
|
||||
<Input
|
||||
type="text"
|
||||
label="Name"
|
||||
onChange={this.handleNameChange}
|
||||
value={this.name}
|
||||
required
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
</Flex>
|
||||
<HelpText>You’ll be able to add people to the group next.</HelpText>
|
||||
|
||||
<Button type="submit" disabled={isSaving || !name}>
|
||||
{isSaving ? `${t("Creating")}…` : t("Continue")}
|
||||
</Button>
|
||||
</form>
|
||||
<Modal
|
||||
title={t("Group members")}
|
||||
onRequestClose={onSubmit}
|
||||
isOpen={!!group}
|
||||
>
|
||||
<GroupMembers group={group} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
<Button type="submit" disabled={this.isSaving || !this.name}>
|
||||
{this.isSaving ? "Creating…" : "Continue"}
|
||||
</Button>
|
||||
</form>
|
||||
<Modal
|
||||
title="Group members"
|
||||
onRequestClose={this.props.onSubmit}
|
||||
isOpen={!!this.group}
|
||||
>
|
||||
<GroupMembers group={this.group} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default observer(GroupNew);
|
||||
export default inject("groups", "ui")(withRouter(GroupNew));
|
||||
|
||||
+165
-173
@@ -1,10 +1,14 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { observable, action } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { LinkIcon, CloseIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, withRouter, type RouterHistory } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import UsersStore from "stores/UsersStore";
|
||||
import Button from "components/Button";
|
||||
import CopyToClipboard from "components/CopyToClipboard";
|
||||
import Flex from "components/Flex";
|
||||
@@ -12,210 +16,198 @@ import HelpText from "components/HelpText";
|
||||
import Input from "components/Input";
|
||||
import NudeButton from "components/NudeButton";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
const MAX_INVITES = 20;
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
users: UsersStore,
|
||||
history: RouterHistory,
|
||||
policies: PoliciesStore,
|
||||
ui: UiStore,
|
||||
onSubmit: () => void,
|
||||
|};
|
||||
};
|
||||
|
||||
type InviteRequest = {
|
||||
email: string,
|
||||
name: string,
|
||||
};
|
||||
|
||||
function Invite({ onSubmit }: Props) {
|
||||
const [isSaving, setIsSaving] = React.useState();
|
||||
const [linkCopied, setLinkCopied] = React.useState<boolean>(false);
|
||||
const [invites, setInvites] = React.useState<InviteRequest[]>([
|
||||
@observer
|
||||
class Invite extends React.Component<Props> {
|
||||
@observable isSaving: boolean;
|
||||
@observable linkCopied: boolean = false;
|
||||
@observable
|
||||
invites: InviteRequest[] = [
|
||||
{ email: "", name: "" },
|
||||
{ email: "", name: "" },
|
||||
{ email: "", name: "" },
|
||||
]);
|
||||
];
|
||||
|
||||
const { users, policies, ui } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
handleSubmit = async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.isSaving = true;
|
||||
|
||||
const predictedDomain = user.email.split("@")[1];
|
||||
const can = policies.abilities(team.id);
|
||||
try {
|
||||
await this.props.users.invite(this.invites);
|
||||
this.props.onSubmit();
|
||||
this.props.ui.showToast("We sent out your invites!", { type: "success" });
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
@action
|
||||
handleChange = (ev, index) => {
|
||||
this.invites[index][ev.target.name] = ev.target.value;
|
||||
};
|
||||
|
||||
try {
|
||||
await users.invite(invites);
|
||||
onSubmit();
|
||||
ui.showToast(t("We sent out your invites!"), { type: "success" });
|
||||
} catch (err) {
|
||||
ui.showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[onSubmit, ui, invites, t, users]
|
||||
);
|
||||
@action
|
||||
handleGuestChange = (ev, index) => {
|
||||
this.invites[index][ev.target.name] = ev.target.checked;
|
||||
};
|
||||
|
||||
const handleChange = React.useCallback((ev, index) => {
|
||||
setInvites((prevInvites) => {
|
||||
const newInvites = [...prevInvites];
|
||||
newInvites[index][ev.target.name] = ev.target.value;
|
||||
return newInvites;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAdd = React.useCallback(() => {
|
||||
if (invites.length >= MAX_INVITES) {
|
||||
ui.showToast(
|
||||
t("Sorry, you can only send {{MAX_INVITES}} invites at a time", {
|
||||
MAX_INVITES,
|
||||
}),
|
||||
@action
|
||||
handleAdd = () => {
|
||||
if (this.invites.length >= MAX_INVITES) {
|
||||
this.props.ui.showToast(
|
||||
`Sorry, you can only send ${MAX_INVITES} invites at a time`,
|
||||
{ type: "warning" }
|
||||
);
|
||||
}
|
||||
|
||||
setInvites((prevInvites) => {
|
||||
const newInvites = [...prevInvites];
|
||||
newInvites.push({ email: "", name: "" });
|
||||
return newInvites;
|
||||
});
|
||||
}, [ui, invites, t]);
|
||||
this.invites.push({ email: "", name: "" });
|
||||
};
|
||||
|
||||
const handleRemove = React.useCallback(
|
||||
(ev: SyntheticEvent<>, index: number) => {
|
||||
ev.preventDefault();
|
||||
@action
|
||||
handleRemove = (ev: SyntheticEvent<>, index: number) => {
|
||||
ev.preventDefault();
|
||||
this.invites.splice(index, 1);
|
||||
};
|
||||
|
||||
setInvites((prevInvites) => {
|
||||
const newInvites = [...prevInvites];
|
||||
newInvites.splice(index, 1);
|
||||
return newInvites;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleCopy = React.useCallback(() => {
|
||||
setLinkCopied(true);
|
||||
ui.showToast(t("Share link copied"), {
|
||||
handleCopy = () => {
|
||||
this.linkCopied = true;
|
||||
this.props.ui.showToast("Share link copied", {
|
||||
type: "success",
|
||||
});
|
||||
}, [ui, t]);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{team.guestSignin ? (
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="Invite team members or guests to join your knowledge base. Team members can sign in with {{signinMethods}} or use their email address."
|
||||
values={{ signinMethods: team.signinMethods }}
|
||||
/>
|
||||
</HelpText>
|
||||
) : (
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="Invite team members to join your knowledge base. They will need to sign in with {{signinMethods}}."
|
||||
values={{ signinMethods: team.signinMethods }}
|
||||
/>{" "}
|
||||
{can.update && (
|
||||
<Trans>
|
||||
As an admin you can also{" "}
|
||||
<Link to="/settings/security">enable email sign-in</Link>.
|
||||
</Trans>
|
||||
)}
|
||||
</HelpText>
|
||||
)}
|
||||
{team.subdomain && (
|
||||
<CopyBlock>
|
||||
<Flex align="flex-end">
|
||||
render() {
|
||||
const { team, user } = this.props.auth;
|
||||
if (!team || !user) return null;
|
||||
|
||||
const predictedDomain = user.email.split("@")[1];
|
||||
const can = this.props.policies.abilities(team.id);
|
||||
|
||||
return (
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
{team.guestSignin ? (
|
||||
<HelpText>
|
||||
Invite team members or guests to join your knowledge base. Team
|
||||
members can sign in with {team.signinMethods} or use their email
|
||||
address.
|
||||
</HelpText>
|
||||
) : (
|
||||
<HelpText>
|
||||
Invite team members to join your knowledge base. They will need to
|
||||
sign in with {team.signinMethods}.{" "}
|
||||
{can.update && (
|
||||
<>
|
||||
As an admin you can also{" "}
|
||||
<Link to="/settings/security">enable email sign-in</Link>.
|
||||
</>
|
||||
)}
|
||||
</HelpText>
|
||||
)}
|
||||
{team.subdomain && (
|
||||
<CopyBlock>
|
||||
<Flex align="flex-end">
|
||||
<Input
|
||||
type="text"
|
||||
value={team.url}
|
||||
label="Want a link to share directly with your team?"
|
||||
readOnly
|
||||
flex
|
||||
/>
|
||||
|
||||
<CopyToClipboard text={team.url} onCopy={this.handleCopy}>
|
||||
<Button
|
||||
type="button"
|
||||
icon={<LinkIcon />}
|
||||
style={{ marginBottom: "16px" }}
|
||||
neutral
|
||||
>
|
||||
{this.linkCopied ? "Link copied" : "Copy link"}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</Flex>
|
||||
<p>
|
||||
<hr />
|
||||
</p>
|
||||
</CopyBlock>
|
||||
)}
|
||||
{this.invites.map((invite, index) => (
|
||||
<Flex key={index}>
|
||||
<Input
|
||||
type="text"
|
||||
value={team.url}
|
||||
label={t("Want a link to share directly with your team?")}
|
||||
readOnly
|
||||
type="email"
|
||||
name="email"
|
||||
label="Email"
|
||||
labelHidden={index !== 0}
|
||||
onChange={(ev) => this.handleChange(ev, index)}
|
||||
placeholder={`example@${predictedDomain}`}
|
||||
value={invite.email}
|
||||
required={index === 0}
|
||||
autoFocus={index === 0}
|
||||
flex
|
||||
/>
|
||||
|
||||
<CopyToClipboard text={team.url} onCopy={handleCopy}>
|
||||
<Button
|
||||
type="button"
|
||||
icon={<LinkIcon />}
|
||||
style={{ marginBottom: "16px" }}
|
||||
neutral
|
||||
>
|
||||
{linkCopied ? t("Link copied") : t("Copy link")}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
<Input
|
||||
type="text"
|
||||
name="name"
|
||||
label="Full name"
|
||||
labelHidden={index !== 0}
|
||||
onChange={(ev) => this.handleChange(ev, index)}
|
||||
value={invite.name}
|
||||
required={!!invite.email}
|
||||
flex
|
||||
/>
|
||||
{index !== 0 && (
|
||||
<Remove>
|
||||
<Tooltip tooltip="Remove invite" placement="top">
|
||||
<NudeButton onClick={(ev) => this.handleRemove(ev, index)}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Remove>
|
||||
)}
|
||||
</Flex>
|
||||
<p>
|
||||
<hr />
|
||||
</p>
|
||||
</CopyBlock>
|
||||
)}
|
||||
{invites.map((invite, index) => (
|
||||
<Flex key={index}>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
label={t("Email")}
|
||||
labelHidden={index !== 0}
|
||||
onChange={(ev) => handleChange(ev, index)}
|
||||
placeholder={`example@${predictedDomain}`}
|
||||
value={invite.email}
|
||||
required={index === 0}
|
||||
autoFocus={index === 0}
|
||||
flex
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
name="name"
|
||||
label={t("Full name")}
|
||||
labelHidden={index !== 0}
|
||||
onChange={(ev) => handleChange(ev, index)}
|
||||
value={invite.name}
|
||||
required={!!invite.email}
|
||||
flex
|
||||
/>
|
||||
{index !== 0 && (
|
||||
<Remove>
|
||||
<Tooltip tooltip={t("Remove invite")} placement="top">
|
||||
<NudeButton onClick={(ev) => handleRemove(ev, index)}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Remove>
|
||||
))}
|
||||
|
||||
<Flex justify="space-between">
|
||||
{this.invites.length <= MAX_INVITES ? (
|
||||
<Button type="button" onClick={this.handleAdd} neutral>
|
||||
Add another…
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</Flex>
|
||||
))}
|
||||
|
||||
<Flex justify="space-between">
|
||||
{invites.length <= MAX_INVITES ? (
|
||||
<Button type="button" onClick={handleAdd} neutral>
|
||||
<Trans>Add another</Trans>…
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={this.isSaving}
|
||||
data-on="click"
|
||||
data-event-category="invite"
|
||||
data-event-action="sendInvites"
|
||||
>
|
||||
{this.isSaving ? "Inviting…" : "Send Invites"}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
data-on="click"
|
||||
data-event-category="invite"
|
||||
data-event-action="sendInvites"
|
||||
>
|
||||
{isSaving ? `${t("Inviting")}…` : t("Send Invites")}
|
||||
</Button>
|
||||
</Flex>
|
||||
<br />
|
||||
</form>
|
||||
);
|
||||
</Flex>
|
||||
<br />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const CopyBlock = styled("div")`
|
||||
@@ -229,4 +221,4 @@ const Remove = styled("div")`
|
||||
right: -32px;
|
||||
`;
|
||||
|
||||
export default observer(Invite);
|
||||
export default inject("auth", "users", "policies", "ui")(withRouter(Invite));
|
||||
|
||||
+124
-114
@@ -1,10 +1,11 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { TeamIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
import Heading from "components/Heading";
|
||||
@@ -13,128 +14,137 @@ import Input, { LabelText } from "components/Input";
|
||||
import Scene from "components/Scene";
|
||||
import ImageUpload from "./components/ImageUpload";
|
||||
import env from "env";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
function Details() {
|
||||
const { auth, ui } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const form = useRef<?HTMLFormElement>();
|
||||
const [name, setName] = useState(team.name);
|
||||
const [subdomain, setSubdomain] = useState(team.subdomain);
|
||||
const [avatarUrl, setAvatarUrl] = useState();
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
};
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (event: ?SyntheticEvent<>) => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
@observer
|
||||
class Details extends React.Component<Props> {
|
||||
timeout: TimeoutID;
|
||||
form: ?HTMLFormElement;
|
||||
|
||||
try {
|
||||
await auth.updateTeam({
|
||||
name,
|
||||
avatarUrl,
|
||||
subdomain,
|
||||
});
|
||||
ui.showToast(t("Settings saved"), { type: "success" });
|
||||
} catch (err) {
|
||||
ui.showToast(err.message, { type: "error" });
|
||||
}
|
||||
},
|
||||
[auth, ui, name, avatarUrl, subdomain, t]
|
||||
);
|
||||
@observable name: string;
|
||||
@observable subdomain: ?string;
|
||||
@observable avatarUrl: ?string;
|
||||
|
||||
const handleNameChange = React.useCallback((ev: SyntheticInputEvent<*>) => {
|
||||
setName(ev.target.value);
|
||||
}, []);
|
||||
componentDidMount() {
|
||||
const { team } = this.props.auth;
|
||||
if (team) {
|
||||
this.name = team.name;
|
||||
this.subdomain = team.subdomain;
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubdomainChange = React.useCallback(
|
||||
(ev: SyntheticInputEvent<*>) => {
|
||||
setSubdomain(ev.target.value.toLowerCase());
|
||||
},
|
||||
[]
|
||||
);
|
||||
componentWillUnmount() {
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
|
||||
const handleAvatarUpload = React.useCallback(
|
||||
(avatarUrl: string) => {
|
||||
setAvatarUrl(avatarUrl);
|
||||
handleSubmit();
|
||||
},
|
||||
[handleSubmit]
|
||||
);
|
||||
handleSubmit = async (event: ?SyntheticEvent<>) => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
const handleAvatarError = React.useCallback(
|
||||
(error: ?string) => {
|
||||
ui.showToast(error || t("Unable to upload new logo"));
|
||||
},
|
||||
[ui, t]
|
||||
);
|
||||
try {
|
||||
await this.props.auth.updateTeam({
|
||||
name: this.name,
|
||||
avatarUrl: this.avatarUrl,
|
||||
subdomain: this.subdomain,
|
||||
});
|
||||
this.props.ui.showToast("Settings saved", { type: "success" });
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message, { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const isValid = form.current && form.current.checkValidity();
|
||||
handleNameChange = (ev: SyntheticInputEvent<*>) => {
|
||||
this.name = ev.target.value;
|
||||
};
|
||||
|
||||
return (
|
||||
<Scene title={t("Details")} icon={<TeamIcon color="currentColor" />}>
|
||||
<Heading>{t("Details")}</Heading>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
handleSubdomainChange = (ev: SyntheticInputEvent<*>) => {
|
||||
this.subdomain = ev.target.value.toLowerCase();
|
||||
};
|
||||
|
||||
handleAvatarUpload = (avatarUrl: string) => {
|
||||
this.avatarUrl = avatarUrl;
|
||||
this.handleSubmit();
|
||||
};
|
||||
|
||||
handleAvatarError = (error: ?string) => {
|
||||
this.props.ui.showToast(error || "Unable to upload new logo");
|
||||
};
|
||||
|
||||
get isValid() {
|
||||
return this.form && this.form.checkValidity();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { team, isSaving } = this.props.auth;
|
||||
if (!team) return null;
|
||||
const avatarUrl = this.avatarUrl || team.avatarUrl;
|
||||
|
||||
return (
|
||||
<Scene title="Details" icon={<TeamIcon color="currentColor" />}>
|
||||
<Heading>Details</Heading>
|
||||
<HelpText>
|
||||
These details affect the way that your Outline appears to everyone on
|
||||
the team.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
</HelpText>
|
||||
|
||||
<ProfilePicture column>
|
||||
<LabelText>{t("Logo")}</LabelText>
|
||||
<AvatarContainer>
|
||||
<ImageUpload
|
||||
onSuccess={handleAvatarUpload}
|
||||
onError={handleAvatarError}
|
||||
submitText={t("Crop logo")}
|
||||
borderRadius={0}
|
||||
>
|
||||
<Avatar src={avatarUrl} />
|
||||
<Flex auto align="center" justify="center">
|
||||
<Trans>Upload</Trans>
|
||||
</Flex>
|
||||
</ImageUpload>
|
||||
</AvatarContainer>
|
||||
</ProfilePicture>
|
||||
<form onSubmit={handleSubmit} ref={form}>
|
||||
<Input
|
||||
label={t("Name")}
|
||||
name="name"
|
||||
autoComplete="organization"
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
required
|
||||
short
|
||||
/>
|
||||
{env.SUBDOMAINS_ENABLED && (
|
||||
<>
|
||||
<Input
|
||||
label={t("Subdomain")}
|
||||
name="subdomain"
|
||||
value={subdomain || ""}
|
||||
onChange={handleSubdomainChange}
|
||||
autoComplete="off"
|
||||
minLength={4}
|
||||
maxLength={32}
|
||||
short
|
||||
/>
|
||||
{subdomain && (
|
||||
<HelpText small>
|
||||
<Trans>Your knowledge base will be accessible at</Trans>{" "}
|
||||
<strong>{subdomain}.getoutline.com</strong>
|
||||
</HelpText>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button type="submit" disabled={auth.isSaving || !isValid}>
|
||||
{auth.isSaving ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
</Scene>
|
||||
);
|
||||
<ProfilePicture column>
|
||||
<LabelText>Logo</LabelText>
|
||||
<AvatarContainer>
|
||||
<ImageUpload
|
||||
onSuccess={this.handleAvatarUpload}
|
||||
onError={this.handleAvatarError}
|
||||
submitText="Crop logo"
|
||||
borderRadius={0}
|
||||
>
|
||||
<Avatar src={avatarUrl} />
|
||||
<Flex auto align="center" justify="center">
|
||||
Upload
|
||||
</Flex>
|
||||
</ImageUpload>
|
||||
</AvatarContainer>
|
||||
</ProfilePicture>
|
||||
<form onSubmit={this.handleSubmit} ref={(ref) => (this.form = ref)}>
|
||||
<Input
|
||||
label="Name"
|
||||
name="name"
|
||||
autoComplete="organization"
|
||||
value={this.name}
|
||||
onChange={this.handleNameChange}
|
||||
required
|
||||
short
|
||||
/>
|
||||
{env.SUBDOMAINS_ENABLED && (
|
||||
<>
|
||||
<Input
|
||||
label="Subdomain"
|
||||
name="subdomain"
|
||||
value={this.subdomain || ""}
|
||||
onChange={this.handleSubdomainChange}
|
||||
autoComplete="off"
|
||||
minLength={4}
|
||||
maxLength={32}
|
||||
short
|
||||
/>
|
||||
{this.subdomain && (
|
||||
<HelpText small>
|
||||
Your knowledge base will be accessible at{" "}
|
||||
<strong>{this.subdomain}.getoutline.com</strong>
|
||||
</HelpText>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button type="submit" disabled={isSaving || !this.isValid}>
|
||||
{isSaving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</form>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ProfilePicture = styled(Flex)`
|
||||
@@ -176,4 +186,4 @@ const Avatar = styled.img`
|
||||
${avatarStyles};
|
||||
`;
|
||||
|
||||
export default observer(Details);
|
||||
export default inject("auth", "ui")(Details);
|
||||
|
||||
@@ -14,7 +14,6 @@ import Modal from "components/Modal";
|
||||
import PaginatedList from "components/PaginatedList";
|
||||
import Scene from "components/Scene";
|
||||
import Subheading from "components/Subheading";
|
||||
import useBoolean from "hooks/useBoolean";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
import GroupMenu from "menus/GroupMenu";
|
||||
@@ -24,11 +23,15 @@ function Groups() {
|
||||
const { policies, groups } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const can = policies.abilities(team.id);
|
||||
const [
|
||||
newGroupModalOpen,
|
||||
handleNewGroupModalOpen,
|
||||
handleNewGroupModalClose,
|
||||
] = useBoolean();
|
||||
const [newGroupModalOpen, setNewGroupModalOpen] = React.useState(false);
|
||||
|
||||
const handleNewGroupModalOpen = React.useCallback(() => {
|
||||
setNewGroupModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleNewGroupModalClose = React.useCallback(() => {
|
||||
setNewGroupModalOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
|
||||
@@ -1,140 +1,137 @@
|
||||
// @flow
|
||||
import { debounce } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { EmailIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import NotificationSettingsStore from "stores/NotificationSettingsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Heading from "components/Heading";
|
||||
import HelpText from "components/HelpText";
|
||||
import Input from "components/Input";
|
||||
import Notice from "components/Notice";
|
||||
import Scene from "components/Scene";
|
||||
import Subheading from "components/Subheading";
|
||||
|
||||
import NotificationListItem from "./components/NotificationListItem";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
function Notifications() {
|
||||
const { notificationSettings, ui } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
auth: AuthStore,
|
||||
notificationSettings: NotificationSettingsStore,
|
||||
};
|
||||
|
||||
const options = [
|
||||
{
|
||||
event: "documents.publish",
|
||||
title: t("Document published"),
|
||||
description: t(
|
||||
"Receive a notification whenever a new document is published"
|
||||
),
|
||||
},
|
||||
{
|
||||
event: "documents.update",
|
||||
title: t("Document updated"),
|
||||
description: t(
|
||||
"Receive a notification when a document you created is edited"
|
||||
),
|
||||
},
|
||||
{
|
||||
event: "collections.create",
|
||||
title: t("Collection created"),
|
||||
description: t(
|
||||
"Receive a notification whenever a new collection is created"
|
||||
),
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
event: "emails.onboarding",
|
||||
title: t("Getting started"),
|
||||
description: t(
|
||||
"Tips on getting started with Outline`s features and functionality"
|
||||
),
|
||||
},
|
||||
{
|
||||
event: "emails.features",
|
||||
title: t("New features"),
|
||||
description: t("Receive an email when new features of note are added"),
|
||||
},
|
||||
];
|
||||
const options = [
|
||||
{
|
||||
event: "documents.publish",
|
||||
title: "Document published",
|
||||
description: "Receive a notification whenever a new document is published",
|
||||
},
|
||||
{
|
||||
event: "documents.update",
|
||||
title: "Document updated",
|
||||
description: "Receive a notification when a document you created is edited",
|
||||
},
|
||||
{
|
||||
event: "collections.create",
|
||||
title: "Collection created",
|
||||
description: "Receive a notification whenever a new collection is created",
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
event: "emails.onboarding",
|
||||
title: "Getting started",
|
||||
description:
|
||||
"Tips on getting started with Outline`s features and functionality",
|
||||
},
|
||||
{
|
||||
event: "emails.features",
|
||||
title: "New features",
|
||||
description: "Receive an email when new features of note are added",
|
||||
},
|
||||
];
|
||||
|
||||
React.useEffect(() => {
|
||||
notificationSettings.fetchPage();
|
||||
}, [notificationSettings]);
|
||||
@observer
|
||||
class Notifications extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
this.props.notificationSettings.fetchPage();
|
||||
}
|
||||
|
||||
const showSuccessMessage = debounce(() => {
|
||||
ui.showToast(t("Notifications saved"), { type: "success" });
|
||||
handleChange = async (ev: SyntheticInputEvent<>) => {
|
||||
const { notificationSettings } = this.props;
|
||||
const setting = notificationSettings.getByEvent(ev.target.name);
|
||||
|
||||
if (ev.target.checked) {
|
||||
await notificationSettings.save({
|
||||
event: ev.target.name,
|
||||
});
|
||||
} else if (setting) {
|
||||
await notificationSettings.delete(setting);
|
||||
}
|
||||
|
||||
this.showSuccessMessage();
|
||||
};
|
||||
|
||||
showSuccessMessage = debounce(() => {
|
||||
this.props.ui.showToast("Notifications saved", { type: "success" });
|
||||
}, 500);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
async (ev: SyntheticInputEvent<>) => {
|
||||
const setting = notificationSettings.getByEvent(ev.target.name);
|
||||
render() {
|
||||
const { notificationSettings, auth } = this.props;
|
||||
const showSuccessNotice = window.location.search === "?success";
|
||||
const { user, team } = auth;
|
||||
if (!team || !user) return null;
|
||||
|
||||
if (ev.target.checked) {
|
||||
await notificationSettings.save({
|
||||
event: ev.target.name,
|
||||
});
|
||||
} else if (setting) {
|
||||
await notificationSettings.delete(setting);
|
||||
}
|
||||
|
||||
showSuccessMessage();
|
||||
},
|
||||
[notificationSettings, showSuccessMessage]
|
||||
);
|
||||
|
||||
const showSuccessNotice = window.location.search === "?success";
|
||||
|
||||
return (
|
||||
<Scene title={t("Notifications")} icon={<EmailIcon color="currentColor" />}>
|
||||
{showSuccessNotice && (
|
||||
<Notice>
|
||||
<Trans>
|
||||
return (
|
||||
<Scene title="Notifications" icon={<EmailIcon color="currentColor" />}>
|
||||
{showSuccessNotice && (
|
||||
<Notice>
|
||||
Unsubscription successful. Your notification settings were updated
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
<Heading>{t("Notifications")}</Heading>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
</Notice>
|
||||
)}
|
||||
<Heading>Notifications</Heading>
|
||||
<HelpText>
|
||||
Manage when and where you receive email notifications from Outline.
|
||||
Your email address can be updated in your SSO provider.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
<Input
|
||||
type="email"
|
||||
value={user.email}
|
||||
label={t("Email address")}
|
||||
readOnly
|
||||
short
|
||||
/>
|
||||
</HelpText>
|
||||
|
||||
<Subheading>{t("Notifications")}</Subheading>
|
||||
<Input
|
||||
type="email"
|
||||
value={user.email}
|
||||
label="Email address"
|
||||
readOnly
|
||||
short
|
||||
/>
|
||||
|
||||
{options.map((option, index) => {
|
||||
if (option.separator) return <Separator key={`separator-${index}`} />;
|
||||
<Subheading>Notifications</Subheading>
|
||||
|
||||
const setting = notificationSettings.getByEvent(option.event);
|
||||
{options.map((option, index) => {
|
||||
if (option.separator) return <Separator key={`separator-${index}`} />;
|
||||
|
||||
return (
|
||||
<NotificationListItem
|
||||
key={option.event}
|
||||
onChange={handleChange}
|
||||
setting={setting}
|
||||
disabled={
|
||||
(setting && setting.isSaving) || notificationSettings.isFetching
|
||||
}
|
||||
{...option}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Scene>
|
||||
);
|
||||
const setting = notificationSettings.getByEvent(option.event);
|
||||
|
||||
return (
|
||||
<NotificationListItem
|
||||
key={option.event}
|
||||
onChange={this.handleChange}
|
||||
setting={setting}
|
||||
disabled={
|
||||
(setting && setting.isSaving) || notificationSettings.isFetching
|
||||
}
|
||||
{...option}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Separator = styled.hr`
|
||||
padding-bottom: 12px;
|
||||
`;
|
||||
|
||||
export default observer(Notifications);
|
||||
export default inject("notificationSettings", "auth", "ui")(Notifications);
|
||||
|
||||
@@ -18,7 +18,6 @@ import Modal from "components/Modal";
|
||||
import Scene from "components/Scene";
|
||||
import PeopleTable from "./components/PeopleTable";
|
||||
import UserStatusFilter from "./components/UserStatusFilter";
|
||||
import useBoolean from "hooks/useBoolean";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useQuery from "hooks/useQuery";
|
||||
import useStores from "hooks/useStores";
|
||||
@@ -27,11 +26,7 @@ function People(props) {
|
||||
const topRef = React.useRef();
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const [
|
||||
inviteModalOpen,
|
||||
handleInviteModalOpen,
|
||||
handleInviteModalClose,
|
||||
] = useBoolean();
|
||||
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
|
||||
const team = useCurrentTeam();
|
||||
const { users, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
@@ -101,6 +96,14 @@ function People(props) {
|
||||
userIds,
|
||||
]);
|
||||
|
||||
const handleInviteModalOpen = React.useCallback(() => {
|
||||
setInviteModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleInviteModalClose = React.useCallback(() => {
|
||||
setInviteModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleFilter = React.useCallback(
|
||||
(filter) => {
|
||||
if (filter) {
|
||||
|
||||
@@ -1,94 +1,97 @@
|
||||
// @flow
|
||||
import { debounce } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { PadlockIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Checkbox from "components/Checkbox";
|
||||
import Heading from "components/Heading";
|
||||
import HelpText from "components/HelpText";
|
||||
import Scene from "components/Scene";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
function Security() {
|
||||
const { auth, ui } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const [sharing, setSharing] = useState(team.documentEmbeds);
|
||||
const [documentEmbeds, setDocumentEmbeds] = useState(team.guestSignin);
|
||||
const [guestSignin, setGuestSignin] = useState(team.sharing);
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
};
|
||||
|
||||
const showSuccessMessage = debounce(() => {
|
||||
ui.showToast(t("Settings saved"), { type: "success" });
|
||||
@observer
|
||||
class Security extends React.Component<Props> {
|
||||
form: ?HTMLFormElement;
|
||||
|
||||
@observable sharing: boolean;
|
||||
@observable documentEmbeds: boolean;
|
||||
@observable guestSignin: boolean;
|
||||
|
||||
componentDidMount() {
|
||||
const { auth } = this.props;
|
||||
if (auth.team) {
|
||||
this.documentEmbeds = auth.team.documentEmbeds;
|
||||
this.guestSignin = auth.team.guestSignin;
|
||||
this.sharing = auth.team.sharing;
|
||||
}
|
||||
}
|
||||
|
||||
handleChange = async (ev: SyntheticInputEvent<*>) => {
|
||||
switch (ev.target.name) {
|
||||
case "sharing":
|
||||
this.sharing = ev.target.checked;
|
||||
break;
|
||||
case "documentEmbeds":
|
||||
this.documentEmbeds = ev.target.checked;
|
||||
break;
|
||||
case "guestSignin":
|
||||
this.guestSignin = ev.target.checked;
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
await this.props.auth.updateTeam({
|
||||
sharing: this.sharing,
|
||||
documentEmbeds: this.documentEmbeds,
|
||||
guestSignin: this.guestSignin,
|
||||
});
|
||||
this.showSuccessMessage();
|
||||
};
|
||||
|
||||
showSuccessMessage = debounce(() => {
|
||||
this.props.ui.showToast("Settings saved", { type: "success" });
|
||||
}, 500);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
async (ev: SyntheticInputEvent<*>) => {
|
||||
switch (ev.target.name) {
|
||||
case "sharing":
|
||||
setSharing(ev.target.checked);
|
||||
break;
|
||||
case "documentEmbeds":
|
||||
setDocumentEmbeds(ev.target.checked);
|
||||
break;
|
||||
case "guestSignin":
|
||||
setGuestSignin(ev.target.checked);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
await auth.updateTeam({
|
||||
sharing,
|
||||
documentEmbeds,
|
||||
guestSignin,
|
||||
});
|
||||
|
||||
showSuccessMessage();
|
||||
},
|
||||
[auth, sharing, documentEmbeds, guestSignin, showSuccessMessage]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene title={t("Security")} icon={<PadlockIcon color="currentColor" />}>
|
||||
<Heading>
|
||||
<Trans>Security</Trans>
|
||||
</Heading>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
render() {
|
||||
return (
|
||||
<Scene title="Security" icon={<PadlockIcon color="currentColor" />}>
|
||||
<Heading>Security</Heading>
|
||||
<HelpText>
|
||||
Settings that impact the access, security, and content of your
|
||||
knowledge base.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
</HelpText>
|
||||
|
||||
<Checkbox
|
||||
label={t("Allow email authentication")}
|
||||
name="guestSignin"
|
||||
checked={guestSignin}
|
||||
onChange={handleChange}
|
||||
note={t("When enabled, users can sign-in using their email address")}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t("Public document sharing")}
|
||||
name="sharing"
|
||||
checked={sharing}
|
||||
onChange={handleChange}
|
||||
note={t(
|
||||
"When enabled, documents can be shared publicly on the internet by any team member"
|
||||
)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t("Rich service embeds")}
|
||||
name="documentEmbeds"
|
||||
checked={documentEmbeds}
|
||||
onChange={handleChange}
|
||||
note={t(
|
||||
"Links to supported services are shown as rich embeds within your documents"
|
||||
)}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
<Checkbox
|
||||
label="Allow email authentication"
|
||||
name="guestSignin"
|
||||
checked={this.guestSignin}
|
||||
onChange={this.handleChange}
|
||||
note="When enabled, users can sign-in using their email address"
|
||||
/>
|
||||
<Checkbox
|
||||
label="Public document sharing"
|
||||
name="sharing"
|
||||
checked={this.sharing}
|
||||
onChange={this.handleChange}
|
||||
note="When enabled, documents can be shared publicly on the internet by any team member"
|
||||
/>
|
||||
<Checkbox
|
||||
label="Rich service embeds"
|
||||
name="documentEmbeds"
|
||||
checked={this.documentEmbeds}
|
||||
onChange={this.handleChange}
|
||||
note="Links to supported services are shown as rich embeds within your documents"
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default observer(Security);
|
||||
export default inject("auth", "ui")(Security);
|
||||
|
||||
@@ -13,7 +13,6 @@ import PaginatedList from "components/PaginatedList";
|
||||
import Scene from "components/Scene";
|
||||
import Subheading from "components/Subheading";
|
||||
import TokenListItem from "./components/TokenListItem";
|
||||
import useBoolean from "hooks/useBoolean";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
@@ -21,9 +20,17 @@ function Tokens() {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const { apiKeys, policies } = useStores();
|
||||
const [newModalOpen, handleNewModalOpen, handleNewModalClose] = useBoolean();
|
||||
const [newModalOpen, setNewModalOpen] = React.useState(false);
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
const handleNewModalOpen = React.useCallback(() => {
|
||||
setNewModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleNewModalClose = React.useCallback(() => {
|
||||
setNewModalOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={t("API Tokens")}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import Button from "components/Button";
|
||||
import Heading from "components/Heading";
|
||||
import HelpText from "components/HelpText";
|
||||
@@ -8,16 +7,13 @@ import Scene from "components/Scene";
|
||||
import ZapierIcon from "components/ZapierIcon";
|
||||
|
||||
function Zapier() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Scene title={t("Zapier")} icon={<ZapierIcon color="currentColor" />}>
|
||||
<Heading>{t("Zapier")}</Heading>
|
||||
<Scene title="Zapier" icon={<ZapierIcon color="currentColor" />}>
|
||||
<Heading>Zapier</Heading>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
Zapier is a platform that allows Outline to easily integrate with
|
||||
thousands of other business tools. Head over to Zapier to setup a
|
||||
"Zap" and start programmatically interacting with Outline.'
|
||||
</Trans>
|
||||
Zapier is a platform that allows Outline to easily integrate with
|
||||
thousands of other business tools. Head over to Zapier to setup a "Zap"
|
||||
and start programmatically interacting with Outline.
|
||||
</HelpText>
|
||||
<p>
|
||||
<Button
|
||||
@@ -25,7 +21,7 @@ function Zapier() {
|
||||
(window.location.href = "https://zapier.com/apps/outline")
|
||||
}
|
||||
>
|
||||
{t("Open Zapier")} →
|
||||
Open Zapier →
|
||||
</Button>
|
||||
</p>
|
||||
</Scene>
|
||||
|
||||
+45
-46
@@ -1,64 +1,63 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import Modal from "components/Modal";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
onRequestClose: () => void,
|
||||
|};
|
||||
};
|
||||
|
||||
function UserDelete({ onRequestClose }: Props) {
|
||||
const [isDeleting, setIsDeleting] = React.useState();
|
||||
const { auth, ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
@observer
|
||||
class UserDelete extends React.Component<Props> {
|
||||
@observable isDeleting: boolean;
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
setIsDeleting(true);
|
||||
handleSubmit = async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.isDeleting = true;
|
||||
|
||||
try {
|
||||
await auth.deleteUser();
|
||||
auth.logout();
|
||||
} catch (error) {
|
||||
ui.showToast(error.message, { type: "error" });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
},
|
||||
[auth, ui]
|
||||
);
|
||||
try {
|
||||
await this.props.auth.deleteUser();
|
||||
this.props.auth.logout();
|
||||
} catch (error) {
|
||||
this.props.ui.showToast(error.message, { type: "error" });
|
||||
} finally {
|
||||
this.isDeleting = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen title={t("Delete Account")} onRequestClose={onRequestClose}>
|
||||
<Flex column>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
render() {
|
||||
const { onRequestClose } = this.props;
|
||||
|
||||
return (
|
||||
<Modal isOpen title="Delete Account" onRequestClose={onRequestClose}>
|
||||
<Flex column>
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<HelpText>
|
||||
Are you sure? Deleting your account will destroy identifying data
|
||||
associated with your user and cannot be undone. You will be
|
||||
immediately logged out of Outline and all your API tokens will be
|
||||
revoked.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned."
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
</HelpText>
|
||||
<Button type="submit" danger>
|
||||
{isDeleting ? `${t("Deleting")}…` : t("Delete My Account")}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
</HelpText>
|
||||
<HelpText>
|
||||
<strong>Note:</strong> Signing back in will cause a new account to
|
||||
be automatically reprovisioned.
|
||||
</HelpText>
|
||||
<Button type="submit" danger>
|
||||
{this.isDeleting ? "Deleting…" : "Delete My Account"}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default observer(UserDelete);
|
||||
export default inject("auth", "ui")(UserDelete);
|
||||
|
||||
@@ -7,5 +7,3 @@ import Adapter from "enzyme-adapter-react-16";
|
||||
Enzyme.configure({ adapter: new Adapter() });
|
||||
|
||||
global.localStorage = localStorage;
|
||||
|
||||
require("jest-fetch-mock").enableMocks();
|
||||
|
||||
@@ -58,44 +58,3 @@ export type SearchResult = {
|
||||
context: string,
|
||||
document: Document,
|
||||
};
|
||||
|
||||
export type MenuItem =
|
||||
| {|
|
||||
title: React.Node,
|
||||
to: string,
|
||||
visible?: boolean,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
|}
|
||||
| {|
|
||||
title: React.Node,
|
||||
onClick: (event: SyntheticEvent<>) => void | Promise<void>,
|
||||
visible?: boolean,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
|}
|
||||
| {|
|
||||
title: React.Node,
|
||||
href: string,
|
||||
visible?: boolean,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
level?: number,
|
||||
|}
|
||||
| {|
|
||||
title: React.Node,
|
||||
visible?: boolean,
|
||||
disabled?: boolean,
|
||||
style?: Object,
|
||||
hover?: boolean,
|
||||
items: MenuItem[],
|
||||
|}
|
||||
| {|
|
||||
type: "separator",
|
||||
visible?: boolean,
|
||||
|}
|
||||
| {|
|
||||
type: "heading",
|
||||
visible?: boolean,
|
||||
title: React.Node,
|
||||
|};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// @flow
|
||||
import retry from "fetch-retry";
|
||||
import invariant from "invariant";
|
||||
import { map, trim } from "lodash";
|
||||
import { getCookie } from "tiny-cookie";
|
||||
@@ -25,8 +24,6 @@ const CF_AUTHORIZATION = getCookie("CF_Authorization");
|
||||
// if the cookie is set, we must pass it with all ApiClient requests
|
||||
const CREDENTIALS = CF_AUTHORIZATION ? "same-origin" : "omit";
|
||||
|
||||
const fetchWithRetry = retry(fetch);
|
||||
|
||||
class ApiClient {
|
||||
baseUrl: string;
|
||||
userAgent: string;
|
||||
@@ -95,7 +92,7 @@ class ApiClient {
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetchWithRetry(urlToFetch, {
|
||||
response = await fetch(urlToFetch, {
|
||||
method,
|
||||
body,
|
||||
headers,
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import { domMax } from "framer-motion";
|
||||
export default domMax;
|
||||
+8
-9
@@ -10,10 +10,10 @@
|
||||
"build:webpack": "webpack --config webpack.config.prod.js",
|
||||
"build": "yarn clean && yarn build:webpack && yarn build:i18n && yarn build:server",
|
||||
"start": "node ./build/server/index.js",
|
||||
"dev": "nodemon --exec \"yarn build:server && yarn build:i18n && node --inspect=0.0.0.0 build/server/index.js\" -e js --ignore build/ --ignore app/ --ignore flow-typed/",
|
||||
"dev": "nodemon --exec \"yarn build:server && yarn build:i18n && node --inspect=0.0.0.0 build/server/index.js\" -e js --ignore build/ --ignore app/",
|
||||
"lint": "eslint app server shared",
|
||||
"deploy": "git push heroku master",
|
||||
"prepare": "yarn yarn-deduplicate yarn.lock",
|
||||
"postinstall": "yarn yarn-deduplicate yarn.lock",
|
||||
"heroku-postbuild": "yarn build && yarn db:migrate",
|
||||
"sequelize:migrate": "sequelize db:migrate",
|
||||
"db:create-migration": "sequelize migration:create",
|
||||
@@ -73,13 +73,11 @@
|
||||
"emoji-regex": "^6.5.1",
|
||||
"es6-error": "^4.1.1",
|
||||
"exports-loader": "^0.6.4",
|
||||
"fetch-retry": "^4.1.1",
|
||||
"fetch-with-proxy": "^3.0.1",
|
||||
"file-loader": "^1.1.6",
|
||||
"flow-typed": "^3.3.1",
|
||||
"focus-visible": "^5.1.0",
|
||||
"fractional-index": "^1.0.0",
|
||||
"framer-motion": "^4.1.17",
|
||||
"fs-extra": "^4.0.2",
|
||||
"http-errors": "1.4.0",
|
||||
"i18next": "^19.8.3",
|
||||
@@ -88,6 +86,7 @@
|
||||
"imports-loader": "0.6.5",
|
||||
"invariant": "^2.2.2",
|
||||
"ioredis": "^4.24.3",
|
||||
"isomorphic-fetch": "2.2.1",
|
||||
"joplin-turndown-plugin-gfm": "^1.0.12",
|
||||
"js-search": "^1.4.2",
|
||||
"json-loader": "0.5.4",
|
||||
@@ -111,6 +110,7 @@
|
||||
"mobx": "^4.15.4",
|
||||
"mobx-react": "^6.3.1",
|
||||
"natural-sort": "^1.0.0",
|
||||
"node-htmldiff": "^0.9.3",
|
||||
"nodemailer": "^6.4.16",
|
||||
"outline-icons": "^1.27.0",
|
||||
"oy-vey": "^0.10.0",
|
||||
@@ -144,7 +144,7 @@
|
||||
"react-window": "^1.8.6",
|
||||
"reakit": "^1.3.8",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rich-markdown-editor": "^11.15.0-0",
|
||||
"rich-markdown-editor": "^11.13.0",
|
||||
"semver": "^7.3.2",
|
||||
"sequelize": "^6.3.4",
|
||||
"sequelize-cli": "^6.2.0",
|
||||
@@ -162,8 +162,8 @@
|
||||
"styled-normalize": "^8.0.4",
|
||||
"tiny-cookie": "^2.3.1",
|
||||
"tmp": "^0.2.1",
|
||||
"turndown": "^7.1.1",
|
||||
"utf8": "^3.0.0",
|
||||
"turndown": "^6.0.0",
|
||||
"utf8": "^2.1.0",
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "5.2.0"
|
||||
},
|
||||
@@ -190,7 +190,6 @@
|
||||
"html-webpack-plugin": "3.2.0",
|
||||
"i18next-parser": "^3.3.0",
|
||||
"jest-cli": "^26.0.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"koa-webpack-dev-middleware": "^1.4.5",
|
||||
"koa-webpack-hot-middleware": "^1.0.3",
|
||||
"nodemon": "^1.19.4",
|
||||
@@ -212,4 +211,4 @@
|
||||
"js-yaml": "^3.13.1"
|
||||
},
|
||||
"version": "0.57.0"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.6 KiB |
@@ -69,7 +69,7 @@ router.post("collections.create", auth(), async (ctx) => {
|
||||
if (index) {
|
||||
ctx.assertIndexCharacters(
|
||||
index,
|
||||
"Index characters must be between x20 to x7E ASCII"
|
||||
"Index characters must be between x21 to x7E ASCII"
|
||||
);
|
||||
} else {
|
||||
index = fractionalIndex(
|
||||
@@ -664,7 +664,7 @@ router.post("collections.move", auth(), async (ctx) => {
|
||||
ctx.assertPresent(index, "index is required");
|
||||
ctx.assertIndexCharacters(
|
||||
index,
|
||||
"Index characters must be between x20 to x7E ASCII"
|
||||
"Index characters must be between x21 to x7E ASCII"
|
||||
);
|
||||
ctx.assertUuid(id, "id must be a uuid");
|
||||
|
||||
|
||||
+5
-2
@@ -74,9 +74,12 @@ if (isProduction) {
|
||||
// display nothing to the console
|
||||
quiet: false,
|
||||
|
||||
// switch into lazy mode
|
||||
// that means no watching, but recompilation on every request
|
||||
lazy: false,
|
||||
|
||||
watchOptions: {
|
||||
poll: 1000,
|
||||
ignored: ["node_modules", "flow-typed", "server", "build", "__mocks__"],
|
||||
ignored: ["node_modules"],
|
||||
},
|
||||
|
||||
// public path to bind the middleware to
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
// @flow
|
||||
import { Document, User, Event, Revision } from "../models";
|
||||
import { sequelize } from "../sequelize";
|
||||
|
||||
export default async function revisionCreator({
|
||||
document,
|
||||
user,
|
||||
ip,
|
||||
}: {
|
||||
document: Document,
|
||||
user: User,
|
||||
ip?: string,
|
||||
}) {
|
||||
let transaction;
|
||||
|
||||
try {
|
||||
transaction = await sequelize.transaction();
|
||||
|
||||
const revision = await Revision.createFromDocument(document, {
|
||||
transaction,
|
||||
});
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
modelId: revision.id,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
createdAt: document.updatedAt,
|
||||
ip: ip || user.lastActiveIp,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await transaction.commit();
|
||||
|
||||
return revision;
|
||||
} catch (err) {
|
||||
if (transaction) {
|
||||
await transaction.rollback();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// @flow
|
||||
import { Event } from "../models";
|
||||
import { buildDocument, buildUser } from "../test/factories";
|
||||
import { flushdb } from "../test/support";
|
||||
import revisionCreator from "./revisionCreator";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
describe("revisionCreator", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should create revision model from document", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const revision = await revisionCreator({ document, user, ip });
|
||||
const event = await Event.findOne();
|
||||
|
||||
expect(revision.documentId).toEqual(document.id);
|
||||
expect(revision.userId).toEqual(user.id);
|
||||
expect(event.name).toEqual("revisions.create");
|
||||
expect(event.modelId).toEqual(revision.id);
|
||||
expect(event.createdAt).toEqual(document.updatedAt);
|
||||
});
|
||||
});
|
||||
@@ -41,11 +41,13 @@ export const CollectionNotificationEmail = ({
|
||||
<Body>
|
||||
<Heading>{collection.name}</Heading>
|
||||
<p>
|
||||
{actor.name} {eventName} the collection "{collection.name}".
|
||||
{actor.name} {eventName} the collection “{collection.name}”.
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={`${process.env.URL}${collection.url}`}>
|
||||
<Button
|
||||
href={`${process.env.URL}${collection.url}?ref=notification-email`}
|
||||
>
|
||||
Open Collection
|
||||
</Button>
|
||||
</p>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import theme from "../../shared/styles/theme";
|
||||
import { User, Document, Team, Collection } from "../models";
|
||||
import Body from "./components/Body";
|
||||
import Button from "./components/Button";
|
||||
import Diff from "./components/Diff";
|
||||
import EmailTemplate from "./components/EmailLayout";
|
||||
import EmptySpace from "./components/EmptySpace";
|
||||
import Footer from "./components/Footer";
|
||||
@@ -15,6 +17,7 @@ export type Props = {
|
||||
document: Document,
|
||||
collection: Collection,
|
||||
eventName: string,
|
||||
summary: string,
|
||||
unsubscribeUrl: string,
|
||||
};
|
||||
|
||||
@@ -38,26 +41,34 @@ export const DocumentNotificationEmail = ({
|
||||
document,
|
||||
collection,
|
||||
eventName = "published",
|
||||
summary,
|
||||
unsubscribeUrl,
|
||||
}: Props) => {
|
||||
const link = `${team.url}${document.url}?ref=notification-email`;
|
||||
|
||||
return (
|
||||
<EmailTemplate>
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>
|
||||
"{document.title}" {eventName}
|
||||
“{document.title}” {eventName}
|
||||
</Heading>
|
||||
<p>
|
||||
{actor.name} {eventName} the document "{document.title}", in the{" "}
|
||||
{collection.name} collection.
|
||||
</p>
|
||||
<hr />
|
||||
<EmptySpace height={10} />
|
||||
<p>{document.getSummary()}</p>
|
||||
<EmptySpace height={10} />
|
||||
{summary && (
|
||||
<>
|
||||
<EmptySpace height={20} />
|
||||
<Diff href={link}>
|
||||
<div dangerouslySetInnerHTML={{ __html: summary }} />
|
||||
</Diff>
|
||||
<EmptySpace height={20} />
|
||||
</>
|
||||
)}
|
||||
<p>
|
||||
<Button href={`${team.url}${document.url}`}>Open Document</Button>
|
||||
<Button href={link}>Open Document</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
@@ -65,3 +76,211 @@ export const DocumentNotificationEmail = ({
|
||||
</EmailTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
export const css = `
|
||||
font-family: ${theme.fontFamily};
|
||||
font-weight: ${theme.fontWeight};
|
||||
font-size: 1em;
|
||||
line-height: 1.7em;
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
img {
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
img.image-right-50 {
|
||||
float: right;
|
||||
width: 50%;
|
||||
margin-left: 2em;
|
||||
margin-bottom: 1em;
|
||||
clear: initial;
|
||||
}
|
||||
|
||||
img.image-left-50 {
|
||||
float: left;
|
||||
width: 50%;
|
||||
margin-right: 2em;
|
||||
margin-bottom: 1em;
|
||||
clear: initial;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 1em 0 0.5em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: ${theme.noticeInfoBackground};
|
||||
color: ${theme.noticeInfoText};
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.notice-tip {
|
||||
background: ${theme.noticeTipBackground};
|
||||
color: ${theme.noticeTipText};
|
||||
}
|
||||
|
||||
.notice-warning {
|
||||
background: ${theme.noticeWarningBackground};
|
||||
color: ${theme.noticeWarningText};
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: ${theme.link};
|
||||
}
|
||||
|
||||
ins {
|
||||
background-color: #128a2929;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
del {
|
||||
background-color: ${theme.slateLight};
|
||||
color: ${theme.slate};
|
||||
text-decoration: strikethrough;
|
||||
}
|
||||
|
||||
hr {
|
||||
position: relative;
|
||||
height: 1em;
|
||||
border: 0;
|
||||
}
|
||||
hr:before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
border-top: 1px solid ${theme.horizontalRule};
|
||||
top: 0.5em;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
hr.page-break {
|
||||
page-break-after: always;
|
||||
}
|
||||
hr.page-break:before {
|
||||
border-top: 1px dashed ${theme.horizontalRule};
|
||||
}
|
||||
|
||||
code {
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${theme.codeBorder};
|
||||
padding: 3px 4px;
|
||||
font-family: ${theme.fontFamilyMono};
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
mark {
|
||||
border-radius: 1px;
|
||||
color: ${theme.textHighlightForeground};
|
||||
background: ${theme.textHighlight};
|
||||
a {
|
||||
color: ${theme.textHighlightForeground};
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.checkbox-list-item {
|
||||
list-style: none;
|
||||
padding: 4px 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
font-size: 0;
|
||||
display: block;
|
||||
float: left;
|
||||
white-space: nowrap;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-top: 2px;
|
||||
margin-right: 8px;
|
||||
border: 1px solid ${theme.textSecondary};
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 0.75em 1em;
|
||||
line-height: 1.4em;
|
||||
position: relative;
|
||||
background: ${theme.codeBackground};
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${theme.codeBorder};
|
||||
-webkit-font-smoothing: initial;
|
||||
font-family: ${theme.fontFamilyMono};
|
||||
font-size: 13px;
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
-webkit-hyphens: none;
|
||||
-moz-hyphens: none;
|
||||
-ms-hyphens: none;
|
||||
hyphens: none;
|
||||
margin: 0;
|
||||
|
||||
code {
|
||||
font-size: 13px;
|
||||
background: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-radius: 4px;
|
||||
margin-top: 1em;
|
||||
box-sizing: border-box;
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
tr {
|
||||
position: relative;
|
||||
border-bottom: 1px solid ${theme.tableDivider};
|
||||
}
|
||||
td,
|
||||
th {
|
||||
position: relative;
|
||||
vertical-align: top;
|
||||
border: 1px solid ${theme.tableDivider};
|
||||
position: relative;
|
||||
padding: 4px 8px;
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -47,7 +47,7 @@ export const InviteEmail = ({
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={teamUrl}>Join now</Button>
|
||||
<Button href={`${teamUrl}?ref=invite-email`}>Join now</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
|
||||
@@ -43,7 +43,9 @@ export const WelcomeEmail = ({ teamUrl }: Props) => {
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={`${teamUrl}/home`}>View my dashboard</Button>
|
||||
<Button href={`${teamUrl}/home?ref=welcome-email`}>
|
||||
View my dashboard
|
||||
</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import theme from "../../../shared/styles/theme";
|
||||
|
||||
type Props = {|
|
||||
children: React.Node,
|
||||
href?: string,
|
||||
|};
|
||||
|
||||
export default ({ children, ...rest }: Props) => {
|
||||
const style = {
|
||||
borderRadius: "4px",
|
||||
background: theme.secondaryBackground,
|
||||
padding: ".5em 1em",
|
||||
color: theme.text,
|
||||
display: "block",
|
||||
textDecoration: "none",
|
||||
};
|
||||
|
||||
return (
|
||||
<a width="100%" style={style} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -3,9 +3,9 @@ import { Table, TBody, TR, TD } from "oy-vey";
|
||||
import * as React from "react";
|
||||
import theme from "../../../shared/styles/theme";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
children: React.Node,
|
||||
};
|
||||
|};
|
||||
|
||||
export default (props: Props) => (
|
||||
<Table width="550" padding="40">
|
||||
|
||||
@@ -100,6 +100,8 @@ export type RevisionEvent = {
|
||||
documentId: string,
|
||||
collectionId: string,
|
||||
teamId: string,
|
||||
actorId: string,
|
||||
modelId: string,
|
||||
};
|
||||
|
||||
export type CollectionImportEvent = {
|
||||
|
||||
+3
-1
@@ -13,6 +13,7 @@ import {
|
||||
type Props as DocumentNotificationEmailT,
|
||||
DocumentNotificationEmail,
|
||||
documentNotificationEmailText,
|
||||
css as documentNotificationEmailCSS,
|
||||
} from "./emails/DocumentNotificationEmail";
|
||||
import { ExportEmail, exportEmailText } from "./emails/ExportEmail";
|
||||
import {
|
||||
@@ -146,8 +147,9 @@ export class Mailer {
|
||||
this.sendMail({
|
||||
to: opts.to,
|
||||
title: `“${opts.document.title}” ${opts.eventName}`,
|
||||
previewText: `${opts.actor.name} ${opts.eventName} a new document`,
|
||||
previewText: `${opts.actor.name} ${opts.eventName} a document`,
|
||||
html: <DocumentNotificationEmail {...opts} />,
|
||||
headCSS: documentNotificationEmailCSS,
|
||||
text: documentNotificationEmailText(opts),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addIndex("events", ["documentId"]);
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeIndex("events", ["documentId"]);
|
||||
}
|
||||
};
|
||||
@@ -67,7 +67,6 @@ Event.ACTIVITY_EVENTS = [
|
||||
"documents.delete",
|
||||
"documents.permanent_delete",
|
||||
"documents.restore",
|
||||
"revisions.create",
|
||||
"users.create",
|
||||
];
|
||||
|
||||
@@ -97,7 +96,6 @@ Event.AUDIT_EVENTS = [
|
||||
"groups.create",
|
||||
"groups.update",
|
||||
"groups.delete",
|
||||
"revisions.create",
|
||||
"shares.create",
|
||||
"shares.update",
|
||||
"shares.revoke",
|
||||
|
||||
+12
-15
@@ -49,22 +49,19 @@ Revision.findLatest = function (documentId) {
|
||||
});
|
||||
};
|
||||
|
||||
Revision.createFromDocument = function (document, options) {
|
||||
return Revision.create(
|
||||
{
|
||||
title: document.title,
|
||||
text: document.text,
|
||||
userId: document.lastModifiedById,
|
||||
editorVersion: document.editorVersion,
|
||||
version: document.version,
|
||||
documentId: document.id,
|
||||
Revision.createFromDocument = function (document) {
|
||||
return Revision.create({
|
||||
title: document.title,
|
||||
text: document.text,
|
||||
userId: document.lastModifiedById,
|
||||
editorVersion: document.editorVersion,
|
||||
version: document.version,
|
||||
documentId: document.id,
|
||||
|
||||
// revision time is set to the last time document was touched as this
|
||||
// handler can be debounced in the case of an update
|
||||
createdAt: document.updatedAt,
|
||||
},
|
||||
options
|
||||
);
|
||||
// revision time is set to the last time document was touched as this
|
||||
// handler can be debounced in the case of an update
|
||||
createdAt: document.updatedAt,
|
||||
});
|
||||
};
|
||||
|
||||
Revision.prototype.migrateVersion = function () {
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
// @flow
|
||||
import "./bootstrap";
|
||||
import debug from "debug";
|
||||
import { Revision, Document, Event } from "../models";
|
||||
|
||||
const log = debug("server");
|
||||
let page = 0;
|
||||
let limit = 100;
|
||||
|
||||
export default async function main(exit = false) {
|
||||
const work = async (page: number) => {
|
||||
log(`Backfill revision events… page ${page}`);
|
||||
|
||||
const revisions = await Revision.findAll({
|
||||
limit,
|
||||
offset: page * limit,
|
||||
order: [["createdAt", "DESC"]],
|
||||
include: [
|
||||
{
|
||||
model: Document,
|
||||
as: "document",
|
||||
required: true,
|
||||
paranoid: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
for (const revision of revisions) {
|
||||
try {
|
||||
await Event.findOrCreate({
|
||||
where: {
|
||||
name: "revisions.create",
|
||||
modelId: revision.id,
|
||||
documentId: revision.documentId,
|
||||
actorId: revision.userId,
|
||||
teamId: revision.document.teamId,
|
||||
},
|
||||
defaults: {
|
||||
createdAt: revision.createdAt,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Failed at ${revision.id}:`, err);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return revisions.length === limit ? work(page + 1) : undefined;
|
||||
};
|
||||
|
||||
await work(page);
|
||||
|
||||
if (exit) {
|
||||
log("Backfill complete");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// In the test suite we import the script rather than run via node CLI
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
main(true);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// @flow
|
||||
import { Revision, Event } from "../models";
|
||||
import { buildDocument } from "../test/factories";
|
||||
import { flushdb } from "../test/support";
|
||||
import script from "./20210716000000-backfill-revisions";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
describe("#work", () => {
|
||||
it("should create events for revisions", async () => {
|
||||
const document = await buildDocument();
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
|
||||
await script();
|
||||
|
||||
const event = await Event.findOne();
|
||||
|
||||
expect(event.name).toEqual("revisions.create");
|
||||
expect(event.modelId).toEqual(revision.id);
|
||||
expect(event.documentId).toEqual(document.id);
|
||||
expect(event.teamId).toEqual(document.teamId);
|
||||
expect(event.createdAt).toEqual(revision.createdAt);
|
||||
});
|
||||
|
||||
it("should create events for revisions of deleted documents", async () => {
|
||||
const document = await buildDocument();
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
|
||||
await document.destroy();
|
||||
|
||||
await script();
|
||||
|
||||
const event = await Event.findOne();
|
||||
|
||||
expect(event.name).toEqual("revisions.create");
|
||||
expect(event.modelId).toEqual(revision.id);
|
||||
expect(event.documentId).toEqual(document.id);
|
||||
expect(event.teamId).toEqual(document.teamId);
|
||||
expect(event.createdAt).toEqual(revision.createdAt);
|
||||
});
|
||||
|
||||
it("should be idempotent", async () => {
|
||||
const document = await buildDocument();
|
||||
await Revision.createFromDocument(document);
|
||||
|
||||
await script();
|
||||
await script();
|
||||
|
||||
const count = await Event.count();
|
||||
expect(count).toEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -1,32 +1,60 @@
|
||||
// @flow
|
||||
import debug from "debug";
|
||||
import type { DocumentEvent, CollectionEvent, Event } from "../events";
|
||||
import type {
|
||||
DocumentEvent,
|
||||
RevisionEvent,
|
||||
CollectionEvent,
|
||||
Event,
|
||||
} from "../events";
|
||||
import mailer from "../mailer";
|
||||
import {
|
||||
View,
|
||||
Document,
|
||||
Team,
|
||||
Collection,
|
||||
Revision,
|
||||
User,
|
||||
NotificationSetting,
|
||||
Attachment,
|
||||
} from "../models";
|
||||
import { Op } from "../sequelize";
|
||||
import markdownDiff from "../utils/markdownDiff";
|
||||
|
||||
import parseAttachmentIds from "../utils/parseAttachmentIds";
|
||||
import { getSignedImageUrl } from "../utils/s3";
|
||||
|
||||
const log = debug("services");
|
||||
|
||||
async function replaceImageAttachments(text: string) {
|
||||
const attachmentIds = parseAttachmentIds(text);
|
||||
|
||||
await Promise.all(
|
||||
attachmentIds.map(async (id) => {
|
||||
const attachment = await Attachment.findByPk(id);
|
||||
if (attachment) {
|
||||
const accessUrl = await getSignedImageUrl(attachment.key, 86400 * 4);
|
||||
text = text.replace(attachment.redirectUrl, accessUrl);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export default class Notifications {
|
||||
async on(event: Event) {
|
||||
switch (event.name) {
|
||||
case "documents.publish":
|
||||
case "documents.update.debounced":
|
||||
return this.documentUpdated(event);
|
||||
return this.documentPublished(event);
|
||||
case "revisions.create":
|
||||
return this.revisionCreated(event);
|
||||
case "collections.create":
|
||||
return this.collectionCreated(event);
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
async documentUpdated(event: DocumentEvent) {
|
||||
async documentPublished(event: DocumentEvent) {
|
||||
// never send notifications when batch importing documents
|
||||
if (event.data && event.data.source === "import") return;
|
||||
|
||||
@@ -45,10 +73,7 @@ export default class Notifications {
|
||||
[Op.ne]: document.lastModifiedById,
|
||||
},
|
||||
teamId: document.teamId,
|
||||
event:
|
||||
event.name === "documents.publish"
|
||||
? "documents.publish"
|
||||
: "documents.update",
|
||||
event: "documents.publish",
|
||||
},
|
||||
include: [
|
||||
{
|
||||
@@ -59,25 +84,14 @@ export default class Notifications {
|
||||
],
|
||||
});
|
||||
|
||||
const eventName =
|
||||
event.name === "documents.publish" ? "published" : "updated";
|
||||
const eventName = "published";
|
||||
|
||||
for (const setting of notificationSettings) {
|
||||
// For document updates we only want to send notifications if
|
||||
// the document has been edited by the user with this notification setting
|
||||
// This could be replaced with ability to "follow" in the future
|
||||
if (
|
||||
eventName === "updated" &&
|
||||
!document.collaboratorIds.includes(setting.userId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check the user has access to the collection this document is in. Just
|
||||
// because they were a collaborator once doesn't mean they still are.
|
||||
const collectionIds = await setting.user.collectionIds();
|
||||
if (!collectionIds.includes(document.collectionId)) {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
// If this user has viewed the document since the last update was made
|
||||
@@ -96,7 +110,7 @@ export default class Notifications {
|
||||
log(
|
||||
`suppressing notification to ${setting.userId} because update viewed`
|
||||
);
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
mailer.documentNotification({
|
||||
@@ -105,12 +119,119 @@ export default class Notifications {
|
||||
document,
|
||||
team,
|
||||
collection,
|
||||
summary: document.getSummary(),
|
||||
actor: document.updatedBy,
|
||||
unsubscribeUrl: setting.unsubscribeUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async revisionCreated(event: RevisionEvent) {
|
||||
const revision = await Revision.findByPk(event.modelId, {
|
||||
include: [
|
||||
{
|
||||
model: Document,
|
||||
as: "document",
|
||||
include: [
|
||||
{
|
||||
model: Collection,
|
||||
as: "collection",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!revision) return;
|
||||
|
||||
const { document } = revision;
|
||||
const { collection } = document;
|
||||
if (!collection || !document) return;
|
||||
|
||||
const team = await Team.findByPk(document.teamId);
|
||||
if (!team) return;
|
||||
|
||||
const notificationSettings = await NotificationSetting.findAll({
|
||||
where: {
|
||||
userId: {
|
||||
[Op.ne]: revision.userId,
|
||||
},
|
||||
teamId: document.teamId,
|
||||
event: "documents.update",
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
required: true,
|
||||
as: "user",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const eventName = "updated";
|
||||
|
||||
for (const setting of notificationSettings) {
|
||||
// For document updates we only want to send notifications if
|
||||
// the document has been edited by the user with this notification setting
|
||||
// This could be replaced with ability to "follow" in the future
|
||||
if (!document.collaboratorIds.includes(setting.userId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check the user has access to the collection this document is in. Just
|
||||
// because they were a collaborator once doesn't mean they still are.
|
||||
const collectionIds = await setting.user.collectionIds();
|
||||
if (!collectionIds.includes(document.collectionId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If this user has viewed the document since the last update was made
|
||||
// then we can avoid sending them a useless notification, yay.
|
||||
const view = await View.findOne({
|
||||
where: {
|
||||
userId: setting.userId,
|
||||
documentId: event.documentId,
|
||||
updatedAt: {
|
||||
[Op.gt]: document.updatedAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (view) {
|
||||
log(
|
||||
`suppressing notification to ${setting.userId} because update viewed`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const previous = await Revision.findOne({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
createdAt: {
|
||||
[Op.lt]: revision.createdAt,
|
||||
},
|
||||
},
|
||||
order: [["createdAt", "DESC"]],
|
||||
});
|
||||
|
||||
let summary = markdownDiff(previous ? previous.text : "", revision.text);
|
||||
|
||||
console.log(summary);
|
||||
summary = await replaceImageAttachments(summary);
|
||||
console.log(summary);
|
||||
|
||||
mailer.documentNotification({
|
||||
to: setting.user.email,
|
||||
eventName,
|
||||
document,
|
||||
team,
|
||||
collection,
|
||||
summary,
|
||||
actor: revision.user,
|
||||
unsubscribeUrl: setting.unsubscribeUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async collectionCreated(event: CollectionEvent) {
|
||||
const collection = await Collection.findByPk(event.collectionId, {
|
||||
include: [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import mailer from "../mailer";
|
||||
import { View, NotificationSetting } from "../models";
|
||||
import { View, NotificationSetting, Revision } from "../models";
|
||||
import { buildDocument, buildCollection, buildUser } from "../test/factories";
|
||||
import { flushdb } from "../test/support";
|
||||
import NotificationsService from "./notifications";
|
||||
@@ -89,9 +89,10 @@ describe("documents.publish", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("documents.update.debounced", () => {
|
||||
describe("revisions.create", () => {
|
||||
test("should send a notification to other collaborator", async () => {
|
||||
const document = await buildDocument();
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
const collaborator = await buildUser({ teamId: document.teamId });
|
||||
document.collaboratorIds = [collaborator.id];
|
||||
await document.save();
|
||||
@@ -103,8 +104,9 @@ describe("documents.update.debounced", () => {
|
||||
});
|
||||
|
||||
await Notifications.on({
|
||||
name: "documents.update.debounced",
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
modelId: revision.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
@@ -115,6 +117,7 @@ describe("documents.update.debounced", () => {
|
||||
|
||||
test("should not send a notification if viewed since update", async () => {
|
||||
const document = await buildDocument();
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
const collaborator = await buildUser({ teamId: document.teamId });
|
||||
document.collaboratorIds = [collaborator.id];
|
||||
await document.save();
|
||||
@@ -128,9 +131,10 @@ describe("documents.update.debounced", () => {
|
||||
await View.touch(document.id, collaborator.id, true);
|
||||
|
||||
await Notifications.on({
|
||||
name: "documents.update.debounced",
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
modelId: revision.id,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
});
|
||||
@@ -138,12 +142,13 @@ describe("documents.update.debounced", () => {
|
||||
expect(mailer.documentNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should not send a notification to last editor", async () => {
|
||||
test("should not send a notification to the last user that modified", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
lastModifiedById: user.id,
|
||||
});
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
|
||||
await NotificationSetting.create({
|
||||
userId: user.id,
|
||||
@@ -152,8 +157,9 @@ describe("documents.update.debounced", () => {
|
||||
});
|
||||
|
||||
await Notifications.on({
|
||||
name: "documents.update.debounced",
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
modelId: revision.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import revisionCreator from "../commands/revisionCreator";
|
||||
import type { DocumentEvent, RevisionEvent } from "../events";
|
||||
import { Revision, Document, User } from "../models";
|
||||
import { Revision, Document, Event } from "../models";
|
||||
|
||||
export default class Revisions {
|
||||
async on(event: DocumentEvent | RevisionEvent) {
|
||||
@@ -10,7 +8,7 @@ export default class Revisions {
|
||||
case "documents.publish":
|
||||
case "documents.update.debounced": {
|
||||
const document = await Document.findByPk(event.documentId);
|
||||
invariant(document, "Document should exist");
|
||||
if (!document) return;
|
||||
|
||||
const previous = await Revision.findLatest(document.id);
|
||||
|
||||
@@ -24,10 +22,15 @@ export default class Revisions {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await User.findByPk(event.actorId);
|
||||
invariant(user, "User should exist");
|
||||
|
||||
await revisionCreator({ user, document });
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
Event.add({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
modelId: revision.id,
|
||||
teamId: document.teamId,
|
||||
actorId: revision.userId,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
Vendored
+34
@@ -0,0 +1,34 @@
|
||||
# Heading 1
|
||||
|
||||
## Heading 2
|
||||
|
||||
This is a test paragraph
|
||||
|
||||
This is a second test paragraph. This is a second sentence.
|
||||
|
||||
This is a another test paragraph. This is a another sentence.
|
||||
|
||||
- list item 1
|
||||
- list item 2
|
||||
|
||||
```
|
||||
this is a codeblock
|
||||
```
|
||||
|
||||
:::info
|
||||
This is an info block
|
||||
:::
|
||||
|
||||
!!This is a placeholder!!
|
||||
|
||||
==this is a highlight==
|
||||
|
||||
- [ ] checklist item 1
|
||||
- [ ] checklist item 2
|
||||
- [x] checklist item 3
|
||||
|
||||
same on both sides
|
||||
|
||||
same on both sides
|
||||
|
||||
same on both sides
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Heading 1
|
||||
|
||||
## Heading 2
|
||||
|
||||
This is a test paragraph
|
||||
|
||||
This is a second test paragraph. This is a second sentence.
|
||||
|
||||
This is a another test paragraph. This is a another sentence.
|
||||
|
||||
- list item 1
|
||||
|
||||
```
|
||||
this is a codeblock
|
||||
```
|
||||
|
||||
This is a new paragraph.
|
||||
|
||||
:::info
|
||||
This is an info block
|
||||
:::
|
||||
|
||||
!!This is a placeholder!!
|
||||
|
||||
==this is a highlight==
|
||||
|
||||
- [x] checklist item 1
|
||||
- [x] checklist item 2
|
||||
- [ ] checklist item 3
|
||||
- [ ] checklist item 4
|
||||
- [x] checklist item 5
|
||||
|
||||
same on both sides
|
||||
|
||||
same on both sides
|
||||
|
||||
same on both sides
|
||||
@@ -2,9 +2,11 @@
|
||||
require("dotenv").config({ silent: true });
|
||||
|
||||
// test environment variables
|
||||
process.env.URL = "http://localhost:3000";
|
||||
process.env.DATABASE_URL = process.env.DATABASE_URL_TEST;
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.GOOGLE_CLIENT_ID = "123";
|
||||
process.env.AZURE_CLIENT_ID = "";
|
||||
process.env.SLACK_KEY = "123";
|
||||
process.env.DEPLOYMENT = "";
|
||||
process.env.ALLOWED_DOMAINS = "allowed-domain.com";
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`should diff a complex document 1`] = `
|
||||
"<p>This is a second test paragraph. This is a second sentence.</p>
|
||||
<p>This is a another test paragraph. This is a another sentence.</p>
|
||||
<ul>
|
||||
<li>list item 1</li>
|
||||
<li data-diff-node=\\"del\\" data-operation-index=\\"1\\"><del data-operation-index=\\"1\\">list item 2</del></li></ul>
|
||||
<pre><code>this is a codeblock
|
||||
</code></pre><p data-diff-node=\\"ins\\" data-operation-index=\\"3\\"><ins data-operation-index=\\"3\\">This is a new paragraph.</ins></p>
|
||||
<div class=\\"notice notice-info\\">
|
||||
<p>This is an info block</p>
|
||||
</div>
|
||||
<p><span class=\\"placeholder\\">This is a placeholder</span></p>
|
||||
<p><span class=\\"highlight\\">this is a highlight</span></p>
|
||||
<ul>
|
||||
<li class=\\"checkbox-list-item\\"><span class=\\"checkbox checked\\">[<del data-operation-index=\\"5\\"> ]</del><ins data-operation-index=\\"5\\">x]</ins></span>checklist item 1</li><li class=\\"checkbox-list-item\\"><span class=\\"checkbox checked\\" data-diff-node=\\"ins\\" data-operation-index=\\"7\\"><ins data-operation-index=\\"7\\">[x]</ins></span><ins data-operation-index=\\"7\\">checklist item 2</ins></li>
|
||||
<li class=\\"checkbox-list-item\\"><span class=\\"checkbox \\">[ ]</span>checklist item <del data-operation-index=\\"9\\">2</del><ins data-operation-index=\\"9\\">3</ins></li><li class=\\"checkbox-list-item\\"><span class=\\"checkbox \\" data-diff-node=\\"ins\\" data-operation-index=\\"9\\"><ins data-operation-index=\\"9\\">[ ]</ins></span><ins data-operation-index=\\"9\\">checklist item 4</ins></li>
|
||||
<li class=\\"checkbox-list-item\\"><span class=\\"checkbox checked\\">[x]</span>checklist item <del data-operation-index=\\"11\\">3</del><ins data-operation-index=\\"11\\">5</ins></li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`should return everything inserted when previously empty 1`] = `
|
||||
"<h1 data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">Heading 1</ins></h1><h2 data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">Heading 2</ins></h2><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">This is a test paragraph</ins></p><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">This is a second test paragraph. This is a second sentence.</ins></p><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">This is a another test paragraph. This is a another sentence.</ins></p><ul data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><li data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">list item 1</ins></li><li data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">list item 2</ins></li></ul><pre data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><code data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">this is a codeblock
|
||||
</ins></code></pre><div class=\\"notice notice-info\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">This is an info block</ins></p></div><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><span class=\\"placeholder\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">This is a placeholder</ins></span></p><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><span class=\\"highlight\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">this is a highlight</ins></span></p><ul data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><li class=\\"checkbox-list-item\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><span class=\\"checkbox \\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">[ ]</ins></span><ins data-operation-index=\\"0\\">checklist item 1</ins></li><li class=\\"checkbox-list-item\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><span class=\\"checkbox \\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">[ ]</ins></span><ins data-operation-index=\\"0\\">checklist item 2</ins></li><li class=\\"checkbox-list-item\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><span class=\\"checkbox checked\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">[x]</ins></span><ins data-operation-index=\\"0\\">checklist item 3</ins></li></ul><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">same on both sides</ins></p><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">same on both sides</ins></p><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">same on both sides</ins></p>"
|
||||
`;
|
||||
@@ -0,0 +1,57 @@
|
||||
// @flow
|
||||
import { findIndex, findLastIndex } from "lodash";
|
||||
import diff from "node-htmldiff";
|
||||
import { renderToHtml } from "rich-markdown-editor";
|
||||
|
||||
export default function markdownDiff(
|
||||
before: string,
|
||||
after: string,
|
||||
fullDiff: boolean = false,
|
||||
buffer: number = 1
|
||||
) {
|
||||
// The basic idea here is to first render the Markdown to HTML, then diff the
|
||||
// HTML - both sides will have valid HTML so we should have a valid diff as well
|
||||
|
||||
const beforeHtml = renderToHtml(before);
|
||||
const afterHtml = renderToHtml(after);
|
||||
const diffHtml = diff(beforeHtml, afterHtml);
|
||||
|
||||
if (fullDiff) {
|
||||
return diffHtml;
|
||||
}
|
||||
|
||||
if (before === after) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Split diff at paragraphs and find the first and last changed tags
|
||||
// so we can chop around paragraphs rather than return the entire document.
|
||||
//
|
||||
// In an ideal world we'd use an AST here and parse that rather than be doing
|
||||
// operations on strings. I hope this can be revisted in the future with an
|
||||
// improved diffing library.
|
||||
const newParagraph = /(?:^|\n)<p>/;
|
||||
let lines = diffHtml.split(newParagraph);
|
||||
|
||||
const firstChangedLineIndex = findIndex(
|
||||
lines,
|
||||
(value) => value.includes("<ins ") || value.includes("<del ")
|
||||
);
|
||||
const lastChangedLineIndex = findLastIndex(
|
||||
lines,
|
||||
(value) => value.includes("</ins>") || value.includes("</del>")
|
||||
);
|
||||
|
||||
const start = Math.max(0, firstChangedLineIndex - buffer);
|
||||
const end = Math.min(lines.length, lastChangedLineIndex + buffer);
|
||||
lines = lines.slice(start, end);
|
||||
|
||||
if (!lines.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return [start > 0 ? "" : undefined, ...lines]
|
||||
.filter((x) => x !== undefined)
|
||||
.join("\n<p>")
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// @flow
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import markdownDiff from "./markdownDiff";
|
||||
|
||||
it("should diff a complex document", async () => {
|
||||
const before = await fs.promises.readFile(
|
||||
path.resolve(process.cwd(), "server", "test", "fixtures", "complex.md"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const after = await fs.promises.readFile(
|
||||
path.resolve(
|
||||
process.cwd(),
|
||||
"server",
|
||||
"test",
|
||||
"fixtures",
|
||||
"complexModified.md"
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const diff = markdownDiff(before, after);
|
||||
expect(diff).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should return empty string when both sides are empty", () => {
|
||||
const diff = markdownDiff("", "");
|
||||
expect(diff).toEqual("");
|
||||
});
|
||||
|
||||
it("should return everything inserted when previously empty", async () => {
|
||||
const content = await fs.promises.readFile(
|
||||
path.resolve(process.cwd(), "server", "test", "fixtures", "complex.md"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const diff = markdownDiff("", content);
|
||||
expect(diff).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should return empty for changed nodes", async () => {
|
||||
// Note: This isn't ideal behavior, but it is current behavior. If the diffing
|
||||
// library is improved then we could potentially render the old + new heading
|
||||
// with ins/del tags as appropriate.
|
||||
const diff = markdownDiff("# Heading", "## Heading");
|
||||
expect(diff).toEqual("");
|
||||
});
|
||||
|
||||
it("should return deleted nodes", async () => {
|
||||
const diff = markdownDiff("", "");
|
||||
expect(diff).toEqual(
|
||||
'<p><del data-operation-index="0"><img src="/image.png" alt="caption"></del></p>'
|
||||
);
|
||||
});
|
||||
+3
-7
@@ -147,11 +147,7 @@ export const uploadToS3FromUrl = async (
|
||||
return `${endpoint}/${key}`;
|
||||
} catch (err) {
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.captureException(err, {
|
||||
extra: {
|
||||
url,
|
||||
},
|
||||
});
|
||||
Sentry.captureException(err);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
@@ -167,13 +163,13 @@ export const deleteFromS3 = (key: string) => {
|
||||
.promise();
|
||||
};
|
||||
|
||||
export const getSignedImageUrl = async (key: string) => {
|
||||
export const getSignedImageUrl = async (key: string, expires: number = 60) => {
|
||||
const isDocker = process.env.AWS_S3_UPLOAD_BUCKET_URL.match(/http:\/\/s3:/);
|
||||
|
||||
const params = {
|
||||
Bucket: AWS_S3_UPLOAD_BUCKET_NAME,
|
||||
Key: key,
|
||||
Expires: 60,
|
||||
Expires: expires,
|
||||
};
|
||||
|
||||
return isDocker
|
||||
|
||||
@@ -11,10 +11,8 @@ export const languageOptions = [
|
||||
{ label: "繁體中文 (Chinese, Traditional)", value: "zh_TW" },
|
||||
{ label: "Deutsch (Deutschland)", value: "de_DE" },
|
||||
{ label: "Español (España)", value: "es_ES" },
|
||||
{ label: "فارسی (Persian)", value: "fa_IR" },
|
||||
{ label: "Français (France)", value: "fr_FR" },
|
||||
{ label: "Italiano (Italia)", value: "it_IT" },
|
||||
{ label: "日本語 (Japanese)", value: "ja_JP" },
|
||||
{ label: "한국어 (Korean)", value: "ko_KR" },
|
||||
{ label: "Português (Brazil)", value: "pt_BR" },
|
||||
{ label: "Português (Portugal)", value: "pt_PT" },
|
||||
|
||||
@@ -95,20 +95,10 @@
|
||||
"Tip notice": "Tipp Hinweis",
|
||||
"Warning": "Warnung",
|
||||
"Warning notice": "Warnhinweis",
|
||||
"Module failed to load": "Module failed to load",
|
||||
"Loading Failed": "Loading Failed",
|
||||
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.",
|
||||
"Reload": "Reload",
|
||||
"Something Unexpected Happened": "Something Unexpected Happened",
|
||||
"Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.",
|
||||
"our engineers have been notified": "our engineers have been notified",
|
||||
"Report a Bug": "Report a Bug",
|
||||
"Show Detail": "Show Detail",
|
||||
"Icon": "Icon",
|
||||
"Show menu": "Menü anzeigen",
|
||||
"Choose icon": "Icon auswählen",
|
||||
"Loading": "Laden",
|
||||
"Loading editor": "Loading editor",
|
||||
"Search": "Suche",
|
||||
"Default access": "Standardzugriff",
|
||||
"View and edit": "Anzeigen und bearbeiten",
|
||||
@@ -162,12 +152,12 @@
|
||||
"Path to document": "Pfad zum Dokument",
|
||||
"Group member options": "Optionen für Gruppenmitglieder",
|
||||
"Remove": "Entfernen",
|
||||
"Collection": "Sammlung",
|
||||
"New document": "Neues Dokument",
|
||||
"Import document": "Dokument importieren",
|
||||
"Edit": "Bearbeiten",
|
||||
"Permissions": "Berechtigungen",
|
||||
"Delete": "Löschen",
|
||||
"Collection": "Sammlung",
|
||||
"Collection permissions": "Berechtigungen für Sammlungen",
|
||||
"Edit collection": "Sammlung bearbeiten",
|
||||
"Delete collection": "Sammlung löschen",
|
||||
@@ -216,9 +206,6 @@
|
||||
"Share options": "Teilen-Einstellungen",
|
||||
"Go to document": "Zum Dokument gehen",
|
||||
"Revoke link": "Link widerrufen",
|
||||
"Contents": "Contents",
|
||||
"Headings you add to the document will appear here": "Headings you add to the document will appear here",
|
||||
"Table of contents": "Inhaltsverzeichnis",
|
||||
"By {{ author }}": "Von {{ author }}",
|
||||
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.": "Sind Sie sicher, dass Sie {{ userName }} zu einem Administrator machen möchten? Administratoren können Team- und Rechnungsinformationen ändern.",
|
||||
"Are you sure you want to make {{ userName }} a member?": "Sind Sie sicher, dass Sie {{ userName }} zu einem Mitglied machen möchten?",
|
||||
@@ -248,9 +235,6 @@
|
||||
"Least recently updated": "Am längsten nicht aktualisiert",
|
||||
"A–Z": "A - Z",
|
||||
"Drop documents to import": "Dokumente zum Importieren hier ablegen",
|
||||
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.",
|
||||
"Deleting": "Wird gelöscht",
|
||||
"I’m sure – Delete": "Ich bin mir sicher – Löschen",
|
||||
"The collection was updated": "Die Sammlung wurde aktualisiert",
|
||||
"You can edit the name and other details at any time, however doing so often might confuse your team mates.": "Sie können den Namen und andere Details jederzeit bearbeiten, allerdings könnte dies Ihre Teammitglieder verwirren.",
|
||||
"Name": "Name",
|
||||
@@ -260,9 +244,6 @@
|
||||
"Public sharing is currently disabled in the team security settings.": "Öffentliches Teilen ist derzeit in den Sicherheitseinstellungen des Teams deaktiviert.",
|
||||
"Saving": "Speichert",
|
||||
"Save": "Speichern",
|
||||
"Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format.": "Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format.",
|
||||
"Exporting": "Exporting",
|
||||
"Export Collection": "Export Collection",
|
||||
"Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.": "Sammlungen dienen zur Gruppierung von Dokumenten. Sie funktionieren am besten, wenn sie nach einem Thema oder nach internen Teams organisiert sind — z. B. Produkt oder Entwicklung.",
|
||||
"This is the default level of access given to team members, you can give specific users or groups more access once the collection is created.": "Dies ist die Standardstufe des Zugriffs für Teammitglieder. Du kannst bestimmten Nutzern oder Gruppen mehr Zugriff geben, sobald die Sammlung erstellt wurde.",
|
||||
"Creating": "Wird erstellt",
|
||||
@@ -329,16 +310,12 @@
|
||||
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Möchten du die Vorlage <em>{{ documentTitle }}</em> wirklich löschen?",
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.": "Bist du dir sicher? Durch Löschen des Dokuments <em>{{ documentTitle }}</em>, werden der gesamte Verlauf und alle Unterdokumente gelöscht.",
|
||||
"If you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "Wenn du {{noun}} in Zukunft noch referenzieren oder wiederherstellen möchtest, solltest du es stattdessen archivieren.",
|
||||
"Deleting": "Wird gelöscht",
|
||||
"I’m sure – Delete": "Ich bin mir sicher – Löschen",
|
||||
"Archiving": "Wird archiviert",
|
||||
"Document moved": "Document moved",
|
||||
"Current location": "Current location",
|
||||
"Choose a new location": "Choose a new location",
|
||||
"Search collections & documents": "Search collections & documents",
|
||||
"Couldn’t create the document, try again?": "Dokument konnte nicht erstellt werden. Erneut versuchen?",
|
||||
"Document permanently deleted": "Document permanently deleted",
|
||||
"Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.": "Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.",
|
||||
"Template created, go ahead and customize it": "Template created, go ahead and customize it",
|
||||
"Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.": "Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.",
|
||||
"Search documents": "Dokumente durchsuchen",
|
||||
"No documents found for your filters.": "Keine Dokumente anhand Ihre Filter gefunden.",
|
||||
"You’ve not got any drafts at the moment.": "Sie haben im Moment keine Entwürfe.",
|
||||
@@ -348,39 +325,19 @@
|
||||
"We were unable to load the document while offline.": "Wir konnten das Dokument nicht offline laden.",
|
||||
"Your account has been suspended": "Ihr Konto wurde gesperrt",
|
||||
"A team admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.": "Ein Administrator (<em>{{ suspendedContactEmail }}</em>) hat dein Konto gesperrt. Um dein Konto zu reaktivieren, wende dich bitte direkt an diesen.",
|
||||
"Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.": "Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.",
|
||||
"You can edit the name of this group at any time, however doing so too often might confuse your team mates.": "You can edit the name of this group at any time, however doing so too often might confuse your team mates.",
|
||||
"{{userName}} was added to the group": "{{userName}} wurde zur Gruppe hinzugefügt",
|
||||
"Add team members below to give them access to the group. Need to add someone who’s not yet on the team yet?": "Fügen Sie unten Teammitglieder hinzu, um ihnen Zugriff auf die Gruppe zu gewähren. Sie müssen jemanden hinzufügen, der noch nicht im Team ist?",
|
||||
"Invite them to {{teamName}}": "Personen zu {{teamName}} einladen",
|
||||
"{{userName}} was removed from the group": "{{userName}} wurde aus der Gruppe entfernt",
|
||||
"Add and remove team members in the <em>{{groupName}}</em> group. Adding people to the group will give them access to any collections this group has been added to.": "Add and remove team members in the <em>{{groupName}}</em> group. Adding people to the group will give them access to any collections this group has been added to.",
|
||||
"Listing team members in the <em>{{groupName}}</em> group.": "Listing team members in the <em>{{groupName}}</em> group.",
|
||||
"This group has no members.": "Diese Gruppe hat keine Mitglieder.",
|
||||
"Add people to {{groupName}}": "Add people to {{groupName}}",
|
||||
"Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.": "Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.",
|
||||
"You’ll be able to add people to the group next.": "You’ll be able to add people to the group next.",
|
||||
"Continue": "Continue",
|
||||
"Group members": "Group members",
|
||||
"Recently viewed": "Zuletzt angesehen",
|
||||
"Created by me": "Von mir erstellt",
|
||||
"We sent out your invites!": "We sent out your invites!",
|
||||
"Sorry, you can only send {{MAX_INVITES}} invites at a time": "Sorry, you can only send {{MAX_INVITES}} invites at a time",
|
||||
"Invite team members or guests to join your knowledge base. Team members can sign in with {{signinMethods}} or use their email address.": "Invite team members or guests to join your knowledge base. Team members can sign in with {{signinMethods}} or use their email address.",
|
||||
"Invite team members to join your knowledge base. They will need to sign in with {{signinMethods}}.": "Invite team members to join your knowledge base. They will need to sign in with {{signinMethods}}.",
|
||||
"As an admin you can also <2>enable email sign-in</2>.": "As an admin you can also <2>enable email sign-in</2>.",
|
||||
"Want a link to share directly with your team?": "Want a link to share directly with your team?",
|
||||
"Email": "E-Mail",
|
||||
"Full name": "Vollständiger Name",
|
||||
"Remove invite": "Remove invite",
|
||||
"Add another": "Add another",
|
||||
"Inviting": "Inviting",
|
||||
"Send Invites": "Send Invites",
|
||||
"Navigation": "Navigation",
|
||||
"Edit current document": "Aktuelles Dokument bearbeiten",
|
||||
"Move current document": "Aktuelles Dokument verschieben",
|
||||
"Jump to search": "Zur Suche springen",
|
||||
"Jump to home": "Zurück zum Dashboard",
|
||||
"Table of contents": "Inhaltsverzeichnis",
|
||||
"Toggle navigation": "Navigation ausblenden",
|
||||
"Focus search input": "Fokussiere Suchergebnis",
|
||||
"Open this guide": "Diese Anleitung öffnen",
|
||||
@@ -423,6 +380,7 @@
|
||||
"No documents found for your search filters. <1></1>": "Keine Dokumente für diese Suchfilter gefunden. <1></1>",
|
||||
"Create a new document?": "Neues Dokument erstellen?",
|
||||
"Clear filters": "Filter löschen",
|
||||
"Email": "E-Mail",
|
||||
"Last active": "Zuletzt aktiv",
|
||||
"Role": "Rolle",
|
||||
"Viewer": "Zuschauer",
|
||||
@@ -434,14 +392,6 @@
|
||||
"Active": "Aktiv",
|
||||
"Everyone": "Alle",
|
||||
"Admins": "Admins",
|
||||
"Settings saved": "Settings saved",
|
||||
"Unable to upload new logo": "Unable to upload new logo",
|
||||
"These details affect the way that your Outline appears to everyone on the team.": "These details affect the way that your Outline appears to everyone on the team.",
|
||||
"Logo": "Logo",
|
||||
"Crop logo": "Crop logo",
|
||||
"Upload": "Hochladen",
|
||||
"Subdomain": "Subdomain",
|
||||
"Your knowledge base will be accessible at": "Your knowledge base will be accessible at",
|
||||
"New group": "Neue Gruppe",
|
||||
"Groups can be used to organize and manage the people on your team.": "Gruppen können verwendet werden, um die Personen in Ihrem Team zu organisieren und zu verwalten.",
|
||||
"All groups": "Alle Gruppen",
|
||||
@@ -459,37 +409,19 @@
|
||||
"Export Requested": "Export angefordert",
|
||||
"Requesting Export": "Export wird angefordert",
|
||||
"Export Data": "Daten exportieren",
|
||||
"Document published": "Document published",
|
||||
"Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published",
|
||||
"Document updated": "Document updated",
|
||||
"Receive a notification when a document you created is edited": "Receive a notification when a document you created is edited",
|
||||
"Collection created": "Collection created",
|
||||
"Receive a notification whenever a new collection is created": "Receive a notification whenever a new collection is created",
|
||||
"Getting started": "Getting started",
|
||||
"Tips on getting started with Outline`s features and functionality": "Tips on getting started with Outline`s features and functionality",
|
||||
"New features": "New features",
|
||||
"Receive an email when new features of note are added": "Receive an email when new features of note are added",
|
||||
"Notifications saved": "Notifications saved",
|
||||
"Unsubscription successful. Your notification settings were updated": "Unsubscription successful. Your notification settings were updated",
|
||||
"Manage when and where you receive email notifications from Outline. Your email address can be updated in your SSO provider.": "Manage when and where you receive email notifications from Outline. Your email address can be updated in your SSO provider.",
|
||||
"Email address": "Email address",
|
||||
"Everyone that has signed into Outline appears here. It’s possible that there are other users who have access through {team.signinMethods} but haven’t signed in yet.": "Jeder, der sich bei Outline angemeldet hat, erscheint hier. Es ist möglich, dass es andere Benutzer gibt, die über {team.signinMethods} Zugriff haben, sich aber noch nicht angemeldet haben.",
|
||||
"Filter": "Filter",
|
||||
"Profile saved": "Profil gespeichert",
|
||||
"Profile picture updated": "Profilbild wurde aktualisiert",
|
||||
"Unable to upload new profile picture": "Neues Profilbild kann nicht hochgeladen werden",
|
||||
"Photo": "Foto",
|
||||
"Upload": "Hochladen",
|
||||
"Full name": "Vollständiger Name",
|
||||
"Language": "Sprache",
|
||||
"Please note that translations are currently in early access.<1></1>Community contributions are accepted though our <4>translation portal</4>": "Bitte beachten Sie, dass Übersetzungen derzeit in einer Test Phase verfügbar sind. <1></1> Community-Beiträge werden über unser <4> Übersetzungsportal akzeptiert</4>",
|
||||
"Delete Account": "Konto löschen",
|
||||
"You may delete your account at any time, note that this is unrecoverable": "Sie können Ihren Account jederzeit löschen, beachten Sie, dass dies nicht wiederhergestellt werden kann",
|
||||
"Delete account": "Konto löschen",
|
||||
"Settings that impact the access, security, and content of your knowledge base.": "Settings that impact the access, security, and content of your knowledge base.",
|
||||
"Allow email authentication": "Allow email authentication",
|
||||
"When enabled, users can sign-in using their email address": "When enabled, users can sign-in using their email address",
|
||||
"When enabled, documents can be shared publicly on the internet by any team member": "When enabled, documents can be shared publicly on the internet by any team member",
|
||||
"Rich service embeds": "Rich service embeds",
|
||||
"Links to supported services are shown as rich embeds within your documents": "Links to supported services are shown as rich embeds within your documents",
|
||||
"Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.": "Freigegebene Dokumente sind unten aufgeführt. Jeder, der über den öffentlichen Link verfügt, kann auf eine schreibgeschützte Version des Dokuments zugreifen, bis der Link widerrufen wurde.",
|
||||
"Sharing is currently disabled.": "Das Teilen ist momentan deaktiviert.",
|
||||
"You can globally enable and disable public document sharing in the <em>security settings</em>.": "Sie können die Freigabe öffentlicher Dokumente in den <em>Sicherheitseinstellungen </em> ein- und ausschalten.",
|
||||
@@ -506,16 +438,10 @@
|
||||
"You can create an unlimited amount of personal tokens to authenticate\n with the API. Tokens have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.": "You can create an unlimited amount of personal tokens to authenticate\n with the API. Tokens have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.",
|
||||
"Tokens": "Tokens",
|
||||
"Create a token": "Create a token",
|
||||
"Zapier": "Zapier",
|
||||
"Zapier is a platform that allows Outline to easily integrate with thousands of other business tools. Head over to Zapier to setup a \"Zap\" and start programmatically interacting with Outline.'": "Zapier is a platform that allows Outline to easily integrate with thousands of other business tools. Head over to Zapier to setup a \"Zap\" and start programmatically interacting with Outline.'",
|
||||
"Open Zapier": "Open Zapier",
|
||||
"You’ve not starred any documents yet.": "Keine Favoriten.",
|
||||
"There are no templates just yet.": "Es gibt noch keine Vorlagen.",
|
||||
"You can create templates to help your team create consistent and accurate documentation.": "Du kannst Vorlagen erstellen, um deinem Team zu helfen, eine konsistente und genaue Dokumentation zu schaffen.",
|
||||
"Trash is empty at the moment.": "Der Papierkorb ist im Moment leer.",
|
||||
"Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of Outline and all your API tokens will be revoked.": "Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of Outline and all your API tokens will be revoked.",
|
||||
"<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned.": "<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned.",
|
||||
"Delete My Account": "Delete My Account",
|
||||
"You joined": "Sie sind beigetreten",
|
||||
"Joined": "Beigetreten",
|
||||
"{{ time }} ago.": "Vor {{ time }}.",
|
||||
|
||||
@@ -95,20 +95,10 @@
|
||||
"Tip notice": "Tip notice",
|
||||
"Warning": "Warning",
|
||||
"Warning notice": "Warning notice",
|
||||
"Module failed to load": "Module failed to load",
|
||||
"Loading Failed": "Loading Failed",
|
||||
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.",
|
||||
"Reload": "Reload",
|
||||
"Something Unexpected Happened": "Something Unexpected Happened",
|
||||
"Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.",
|
||||
"our engineers have been notified": "our engineers have been notified",
|
||||
"Report a Bug": "Report a Bug",
|
||||
"Show Detail": "Show Detail",
|
||||
"Icon": "Icon",
|
||||
"Show menu": "Show menu",
|
||||
"Choose icon": "Choose icon",
|
||||
"Loading": "Loading",
|
||||
"Loading editor": "Loading editor",
|
||||
"Search": "Search",
|
||||
"Default access": "Default access",
|
||||
"View and edit": "View and edit",
|
||||
@@ -216,9 +206,6 @@
|
||||
"Share options": "Share options",
|
||||
"Go to document": "Go to document",
|
||||
"Revoke link": "Revoke link",
|
||||
"Contents": "Contents",
|
||||
"Headings you add to the document will appear here": "Headings you add to the document will appear here",
|
||||
"Table of contents": "Table of contents",
|
||||
"By {{ author }}": "By {{ author }}",
|
||||
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.": "Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.",
|
||||
"Are you sure you want to make {{ userName }} a member?": "Are you sure you want to make {{ userName }} a member?",
|
||||
@@ -248,9 +235,6 @@
|
||||
"Least recently updated": "Least recently updated",
|
||||
"A–Z": "A–Z",
|
||||
"Drop documents to import": "Drop documents to import",
|
||||
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.",
|
||||
"Deleting": "Deleting",
|
||||
"I’m sure – Delete": "I’m sure – Delete",
|
||||
"The collection was updated": "The collection was updated",
|
||||
"You can edit the name and other details at any time, however doing so often might confuse your team mates.": "You can edit the name and other details at any time, however doing so often might confuse your team mates.",
|
||||
"Name": "Name",
|
||||
@@ -260,9 +244,6 @@
|
||||
"Public sharing is currently disabled in the team security settings.": "Public sharing is currently disabled in the team security settings.",
|
||||
"Saving": "Saving",
|
||||
"Save": "Save",
|
||||
"Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format.": "Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format.",
|
||||
"Exporting": "Exporting",
|
||||
"Export Collection": "Export Collection",
|
||||
"Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.": "Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.",
|
||||
"This is the default level of access given to team members, you can give specific users or groups more access once the collection is created.": "This is the default level of access given to team members, you can give specific users or groups more access once the collection is created.",
|
||||
"Creating": "Creating",
|
||||
@@ -305,16 +286,6 @@
|
||||
"Add specific access for individual groups and team members": "Add specific access for individual groups and team members",
|
||||
"Add groups to {{ collectionName }}": "Add groups to {{ collectionName }}",
|
||||
"Add people to {{ collectionName }}": "Add people to {{ collectionName }}",
|
||||
"You have unsaved changes.\nAre you sure you want to discard them?": "You have unsaved changes.\nAre you sure you want to discard them?",
|
||||
"Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?",
|
||||
"You’re editing a template. Highlight some text and use the <2></2> control to add placeholders that can be filled out when creating new documents from this template.": "You’re editing a template. Highlight some text and use the <2></2> control to add placeholders that can be filled out when creating new documents from this template.",
|
||||
"Archived by {{userName}}": "Archived by {{userName}}",
|
||||
"Deleted by {{userName}}": "Deleted by {{userName}}",
|
||||
"This template will be permanently deleted in <2></2> unless restored.": "This template will be permanently deleted in <2></2> unless restored.",
|
||||
"This document will be permanently deleted in <2></2> unless restored.": "This document will be permanently deleted in <2></2> unless restored.",
|
||||
"Start your template…": "Start your template…",
|
||||
"Start with a title…": "Start with a title…",
|
||||
"…the rest is up to you": "…the rest is up to you",
|
||||
"Hide contents": "Hide contents",
|
||||
"Show contents": "Show contents",
|
||||
"Edit {{noun}}": "Edit {{noun}}",
|
||||
@@ -339,16 +310,12 @@
|
||||
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Are you sure you want to delete the <em>{{ documentTitle }}</em> template?",
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.",
|
||||
"If you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "If you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.",
|
||||
"Deleting": "Deleting",
|
||||
"I’m sure – Delete": "I’m sure – Delete",
|
||||
"Archiving": "Archiving",
|
||||
"Document moved": "Document moved",
|
||||
"Current location": "Current location",
|
||||
"Choose a new location": "Choose a new location",
|
||||
"Search collections & documents": "Search collections & documents",
|
||||
"Couldn’t create the document, try again?": "Couldn’t create the document, try again?",
|
||||
"Document permanently deleted": "Document permanently deleted",
|
||||
"Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.": "Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.",
|
||||
"Template created, go ahead and customize it": "Template created, go ahead and customize it",
|
||||
"Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.": "Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.",
|
||||
"Search documents": "Search documents",
|
||||
"No documents found for your filters.": "No documents found for your filters.",
|
||||
"You’ve not got any drafts at the moment.": "You’ve not got any drafts at the moment.",
|
||||
@@ -358,39 +325,19 @@
|
||||
"We were unable to load the document while offline.": "We were unable to load the document while offline.",
|
||||
"Your account has been suspended": "Your account has been suspended",
|
||||
"A team admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.": "A team admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.",
|
||||
"Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.": "Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.",
|
||||
"You can edit the name of this group at any time, however doing so too often might confuse your team mates.": "You can edit the name of this group at any time, however doing so too often might confuse your team mates.",
|
||||
"{{userName}} was added to the group": "{{userName}} was added to the group",
|
||||
"Add team members below to give them access to the group. Need to add someone who’s not yet on the team yet?": "Add team members below to give them access to the group. Need to add someone who’s not yet on the team yet?",
|
||||
"Invite them to {{teamName}}": "Invite them to {{teamName}}",
|
||||
"{{userName}} was removed from the group": "{{userName}} was removed from the group",
|
||||
"Add and remove team members in the <em>{{groupName}}</em> group. Adding people to the group will give them access to any collections this group has been added to.": "Add and remove team members in the <em>{{groupName}}</em> group. Adding people to the group will give them access to any collections this group has been added to.",
|
||||
"Listing team members in the <em>{{groupName}}</em> group.": "Listing team members in the <em>{{groupName}}</em> group.",
|
||||
"This group has no members.": "This group has no members.",
|
||||
"Add people to {{groupName}}": "Add people to {{groupName}}",
|
||||
"Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.": "Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.",
|
||||
"You’ll be able to add people to the group next.": "You’ll be able to add people to the group next.",
|
||||
"Continue": "Continue",
|
||||
"Group members": "Group members",
|
||||
"Recently viewed": "Recently viewed",
|
||||
"Created by me": "Created by me",
|
||||
"We sent out your invites!": "We sent out your invites!",
|
||||
"Sorry, you can only send {{MAX_INVITES}} invites at a time": "Sorry, you can only send {{MAX_INVITES}} invites at a time",
|
||||
"Invite team members or guests to join your knowledge base. Team members can sign in with {{signinMethods}} or use their email address.": "Invite team members or guests to join your knowledge base. Team members can sign in with {{signinMethods}} or use their email address.",
|
||||
"Invite team members to join your knowledge base. They will need to sign in with {{signinMethods}}.": "Invite team members to join your knowledge base. They will need to sign in with {{signinMethods}}.",
|
||||
"As an admin you can also <2>enable email sign-in</2>.": "As an admin you can also <2>enable email sign-in</2>.",
|
||||
"Want a link to share directly with your team?": "Want a link to share directly with your team?",
|
||||
"Email": "Email",
|
||||
"Full name": "Full name",
|
||||
"Remove invite": "Remove invite",
|
||||
"Add another": "Add another",
|
||||
"Inviting": "Inviting",
|
||||
"Send Invites": "Send Invites",
|
||||
"Navigation": "Navigation",
|
||||
"Edit current document": "Edit current document",
|
||||
"Move current document": "Move current document",
|
||||
"Jump to search": "Jump to search",
|
||||
"Jump to home": "Jump to home",
|
||||
"Table of contents": "Table of contents",
|
||||
"Toggle navigation": "Toggle navigation",
|
||||
"Focus search input": "Focus search input",
|
||||
"Open this guide": "Open this guide",
|
||||
@@ -433,6 +380,7 @@
|
||||
"No documents found for your search filters. <1></1>": "No documents found for your search filters. <1></1>",
|
||||
"Create a new document?": "Create a new document?",
|
||||
"Clear filters": "Clear filters",
|
||||
"Email": "Email",
|
||||
"Last active": "Last active",
|
||||
"Role": "Role",
|
||||
"Viewer": "Viewer",
|
||||
@@ -444,14 +392,6 @@
|
||||
"Active": "Active",
|
||||
"Everyone": "Everyone",
|
||||
"Admins": "Admins",
|
||||
"Settings saved": "Settings saved",
|
||||
"Unable to upload new logo": "Unable to upload new logo",
|
||||
"These details affect the way that your Outline appears to everyone on the team.": "These details affect the way that your Outline appears to everyone on the team.",
|
||||
"Logo": "Logo",
|
||||
"Crop logo": "Crop logo",
|
||||
"Upload": "Upload",
|
||||
"Subdomain": "Subdomain",
|
||||
"Your knowledge base will be accessible at": "Your knowledge base will be accessible at",
|
||||
"New group": "New group",
|
||||
"Groups can be used to organize and manage the people on your team.": "Groups can be used to organize and manage the people on your team.",
|
||||
"All groups": "All groups",
|
||||
@@ -469,37 +409,19 @@
|
||||
"Export Requested": "Export Requested",
|
||||
"Requesting Export": "Requesting Export",
|
||||
"Export Data": "Export Data",
|
||||
"Document published": "Document published",
|
||||
"Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published",
|
||||
"Document updated": "Document updated",
|
||||
"Receive a notification when a document you created is edited": "Receive a notification when a document you created is edited",
|
||||
"Collection created": "Collection created",
|
||||
"Receive a notification whenever a new collection is created": "Receive a notification whenever a new collection is created",
|
||||
"Getting started": "Getting started",
|
||||
"Tips on getting started with Outline`s features and functionality": "Tips on getting started with Outline`s features and functionality",
|
||||
"New features": "New features",
|
||||
"Receive an email when new features of note are added": "Receive an email when new features of note are added",
|
||||
"Notifications saved": "Notifications saved",
|
||||
"Unsubscription successful. Your notification settings were updated": "Unsubscription successful. Your notification settings were updated",
|
||||
"Manage when and where you receive email notifications from Outline. Your email address can be updated in your SSO provider.": "Manage when and where you receive email notifications from Outline. Your email address can be updated in your SSO provider.",
|
||||
"Email address": "Email address",
|
||||
"Everyone that has signed into Outline appears here. It’s possible that there are other users who have access through {team.signinMethods} but haven’t signed in yet.": "Everyone that has signed into Outline appears here. It’s possible that there are other users who have access through {team.signinMethods} but haven’t signed in yet.",
|
||||
"Filter": "Filter",
|
||||
"Profile saved": "Profile saved",
|
||||
"Profile picture updated": "Profile picture updated",
|
||||
"Unable to upload new profile picture": "Unable to upload new profile picture",
|
||||
"Photo": "Photo",
|
||||
"Upload": "Upload",
|
||||
"Full name": "Full name",
|
||||
"Language": "Language",
|
||||
"Please note that translations are currently in early access.<1></1>Community contributions are accepted though our <4>translation portal</4>": "Please note that translations are currently in early access.<1></1>Community contributions are accepted though our <4>translation portal</4>",
|
||||
"Delete Account": "Delete Account",
|
||||
"You may delete your account at any time, note that this is unrecoverable": "You may delete your account at any time, note that this is unrecoverable",
|
||||
"Delete account": "Delete account",
|
||||
"Settings that impact the access, security, and content of your knowledge base.": "Settings that impact the access, security, and content of your knowledge base.",
|
||||
"Allow email authentication": "Allow email authentication",
|
||||
"When enabled, users can sign-in using their email address": "When enabled, users can sign-in using their email address",
|
||||
"When enabled, documents can be shared publicly on the internet by any team member": "When enabled, documents can be shared publicly on the internet by any team member",
|
||||
"Rich service embeds": "Rich service embeds",
|
||||
"Links to supported services are shown as rich embeds within your documents": "Links to supported services are shown as rich embeds within your documents",
|
||||
"Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.": "Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.",
|
||||
"Sharing is currently disabled.": "Sharing is currently disabled.",
|
||||
"You can globally enable and disable public document sharing in the <em>security settings</em>.": "You can globally enable and disable public document sharing in the <em>security settings</em>.",
|
||||
@@ -516,16 +438,10 @@
|
||||
"You can create an unlimited amount of personal tokens to authenticate\n with the API. Tokens have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.": "You can create an unlimited amount of personal tokens to authenticate\n with the API. Tokens have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.",
|
||||
"Tokens": "Tokens",
|
||||
"Create a token": "Create a token",
|
||||
"Zapier": "Zapier",
|
||||
"Zapier is a platform that allows Outline to easily integrate with thousands of other business tools. Head over to Zapier to setup a \"Zap\" and start programmatically interacting with Outline.'": "Zapier is a platform that allows Outline to easily integrate with thousands of other business tools. Head over to Zapier to setup a \"Zap\" and start programmatically interacting with Outline.'",
|
||||
"Open Zapier": "Open Zapier",
|
||||
"You’ve not starred any documents yet.": "You’ve not starred any documents yet.",
|
||||
"There are no templates just yet.": "There are no templates just yet.",
|
||||
"You can create templates to help your team create consistent and accurate documentation.": "You can create templates to help your team create consistent and accurate documentation.",
|
||||
"Trash is empty at the moment.": "Trash is empty at the moment.",
|
||||
"Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of Outline and all your API tokens will be revoked.": "Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of Outline and all your API tokens will be revoked.",
|
||||
"<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned.": "<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned.",
|
||||
"Delete My Account": "Delete My Account",
|
||||
"You joined": "You joined",
|
||||
"Joined": "Joined",
|
||||
"{{ time }} ago.": "{{ time }} ago.",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"currently editing": "actualmente editando",
|
||||
"currently viewing": "viendo actualmente",
|
||||
"previously edited": "editado previamente",
|
||||
"previously edited": "previously edited",
|
||||
"You": "Usted",
|
||||
"Viewers": "Viewers",
|
||||
"Sorry, an error occurred saving the collection": "Lo sentimos, se produjo un error al guardar la colección",
|
||||
@@ -30,9 +30,9 @@
|
||||
"in": "en",
|
||||
"nested document": "documento anidado",
|
||||
"nested document_plural": "documentos anidados",
|
||||
"Viewed by": "Visto por",
|
||||
"only you": "sólo tú",
|
||||
"person": "persona",
|
||||
"Viewed by": "Viewed by",
|
||||
"only you": "only you",
|
||||
"person": "person",
|
||||
"people": "people",
|
||||
"Currently editing": "Currently editing",
|
||||
"Currently viewing": "Currently viewing",
|
||||
@@ -55,8 +55,8 @@
|
||||
"Delete column": "Eliminar columna",
|
||||
"Delete row": "Borrar fila",
|
||||
"Delete table": "Eliminar tabla",
|
||||
"Delete image": "Eliminar imagen",
|
||||
"Download image": "Descargar imagen",
|
||||
"Delete image": "Delete image",
|
||||
"Download image": "Download image",
|
||||
"Float left": "Float left",
|
||||
"Float right": "Float right",
|
||||
"Center large": "Center large",
|
||||
@@ -95,20 +95,10 @@
|
||||
"Tip notice": "Aviso de sugerencia",
|
||||
"Warning": "Atención",
|
||||
"Warning notice": "Aviso",
|
||||
"Module failed to load": "Module failed to load",
|
||||
"Loading Failed": "Loading Failed",
|
||||
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.",
|
||||
"Reload": "Reload",
|
||||
"Something Unexpected Happened": "Something Unexpected Happened",
|
||||
"Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.",
|
||||
"our engineers have been notified": "our engineers have been notified",
|
||||
"Report a Bug": "Report a Bug",
|
||||
"Show Detail": "Show Detail",
|
||||
"Icon": "Ícono",
|
||||
"Show menu": "Mostrar menú",
|
||||
"Choose icon": "Seleccionar icono",
|
||||
"Loading": "Cargando",
|
||||
"Loading editor": "Loading editor",
|
||||
"Search": "Buscar",
|
||||
"Default access": "Acceso predeterminado",
|
||||
"View and edit": "Ver y modificar",
|
||||
@@ -119,7 +109,7 @@
|
||||
"Dismiss": "Descartar",
|
||||
"Keyboard shortcuts": "Atajos del teclado",
|
||||
"Back": "Atras",
|
||||
"Collections could not be loaded, please reload the app": "No se pudieron cargar las colecciones, por favor recarga la aplicación",
|
||||
"Collections could not be loaded, please reload the app": "Collections could not be loaded, please reload the app",
|
||||
"New collection": "Nueva colección",
|
||||
"Collections": "Colecciones",
|
||||
"Untitled": "Sin título",
|
||||
@@ -146,8 +136,8 @@
|
||||
"Installation": "Instalación",
|
||||
"Unstar": "Eliminar de favoritos",
|
||||
"Star": "Favorito",
|
||||
"Previous page": "Página anterior",
|
||||
"Next page": "Página siguiente",
|
||||
"Previous page": "Previous page",
|
||||
"Next page": "Next page",
|
||||
"Could not import file": "No se pudo importar el archivo",
|
||||
"Appearance": "Apariencia",
|
||||
"System": "Sistema",
|
||||
@@ -162,12 +152,12 @@
|
||||
"Path to document": "Ruta al documento",
|
||||
"Group member options": "Opciones de miembros del grupo",
|
||||
"Remove": "Eliminar",
|
||||
"Collection": "Colección",
|
||||
"New document": "Nuevo documento",
|
||||
"Import document": "Importar documento",
|
||||
"Edit": "Editar",
|
||||
"Permissions": "Permisos",
|
||||
"Delete": "Borrar",
|
||||
"Collection": "Colección",
|
||||
"Collection permissions": "Permisos de colección",
|
||||
"Edit collection": "Editar colección",
|
||||
"Delete collection": "Eliminar colección",
|
||||
@@ -191,7 +181,7 @@
|
||||
"Create template": "Crear plantilla",
|
||||
"Duplicate": "Duplicar",
|
||||
"Unpublish": "Cancelar publicación",
|
||||
"Permanently delete": "Eliminar permanentemente",
|
||||
"Permanently delete": "Permanently delete",
|
||||
"Move": "Mover",
|
||||
"History": "Historial",
|
||||
"Download": "Descargar",
|
||||
@@ -216,22 +206,19 @@
|
||||
"Share options": "Share options",
|
||||
"Go to document": "Ir al documento",
|
||||
"Revoke link": "Revocar enlace",
|
||||
"Contents": "Contents",
|
||||
"Headings you add to the document will appear here": "Headings you add to the document will appear here",
|
||||
"Table of contents": "Tabla de contenido",
|
||||
"By {{ author }}": "Por {{ author }}",
|
||||
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.": "¿Estás seguro de que quieres convertir a {{ userName }} en administrador? Los administradores pueden modificar el equipo e información de facturación.",
|
||||
"Are you sure you want to make {{ userName }} a member?": "¿Estás seguro de que quieres convertir a {{ userName }} en miembro?",
|
||||
"Are you sure you want to make {{ userName }} a read-only viewer? They will not be able to edit any content": "Are you sure you want to make {{ userName }} a read-only viewer? They will not be able to edit any content",
|
||||
"Are you sure you want to suspend this account? Suspended users will be prevented from logging in.": "¿Está seguro que desea suspender esta cuenta? Los usuarios suspendidos no podrán iniciar sesión.",
|
||||
"User options": "Opciones de usuario",
|
||||
"User options": "User options",
|
||||
"Make {{ userName }} a member": "Make {{ userName }} a member",
|
||||
"Make {{ userName }} a viewer": "Make {{ userName }} a viewer",
|
||||
"Make {{ userName }} an admin…": "Hacer a {{ userName }} un administrador…",
|
||||
"Revoke invite": "Revocar Invitación",
|
||||
"Activate account": "Activar cuenta",
|
||||
"Suspend account": "Suspender cuenta",
|
||||
"API token created": "Token de API creado",
|
||||
"API token created": "API token created",
|
||||
"Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".": "Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".",
|
||||
"Documents": "Documentos",
|
||||
"The document archive is empty at the moment.": "El archivo de documento está vacío en este momento.",
|
||||
@@ -239,30 +226,24 @@
|
||||
"<em>{{ collectionName }}</em> doesn’t contain any\n documents yet.": "<em>{{ collectionName }}</em> aún no contiene\n documentos.",
|
||||
"Get started by creating a new one!": "¡Empiece creando uno nuevo!",
|
||||
"Create a document": "Crear documento",
|
||||
"Manage permissions": "Administrar permisos",
|
||||
"This collection is only visible to those given access": "Esta colección sólo es visible a aquellos con acceso",
|
||||
"Private": "Privado",
|
||||
"Manage permissions": "Manage permissions",
|
||||
"This collection is only visible to those given access": "This collection is only visible to those given access",
|
||||
"Private": "Private",
|
||||
"Pinned": "Fijado",
|
||||
"Recently updated": "Recientemente actualizado",
|
||||
"Recently published": "Recientemente publicado",
|
||||
"Least recently updated": "Menos recientemente actualizado",
|
||||
"A–Z": "A–Z",
|
||||
"Drop documents to import": "Drop documents to import",
|
||||
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.",
|
||||
"Deleting": "Deleting",
|
||||
"I’m sure – Delete": "I’m sure – Delete",
|
||||
"The collection was updated": "La colección fue actualizada",
|
||||
"You can edit the name and other details at any time, however doing so often might confuse your team mates.": "Puede editar el nombre y otros detalles en cualquier momento, sin embargo, hacerlo a menudo puede confundir a sus compañeros de equipo.",
|
||||
"Name": "Nombre",
|
||||
"Alphabetical": "Alfabético",
|
||||
"Public document sharing": "Public document sharing",
|
||||
"When enabled, documents can be shared publicly on the internet.": "When enabled, documents can be shared publicly on the internet.",
|
||||
"Public sharing is currently disabled in the team security settings.": "Compartir públicamente está desactivado en las configuraciones de seguridad del equipo.",
|
||||
"Public sharing is currently disabled in the team security settings.": "Public sharing is currently disabled in the team security settings.",
|
||||
"Saving": "Guardando",
|
||||
"Save": "Guardar",
|
||||
"Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format.": "Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format.",
|
||||
"Exporting": "Exporting",
|
||||
"Export Collection": "Export Collection",
|
||||
"Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.": "Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.",
|
||||
"This is the default level of access given to team members, you can give specific users or groups more access once the collection is created.": "This is the default level of access given to team members, you can give specific users or groups more access once the collection is created.",
|
||||
"Creating": "Creando",
|
||||
@@ -287,15 +268,15 @@
|
||||
"Never signed in": "No ha iniciado sesión nunca",
|
||||
"Invited": "Invitado",
|
||||
"Admin": "Admin",
|
||||
"{{ userName }} was removed from the collection": "{{ userName }} fue quitado de la colección",
|
||||
"{{ userName }} was removed from the collection": "{{ userName }} was removed from the collection",
|
||||
"Could not remove user": "No se pudo remover al usuario",
|
||||
"{{ userName }} permissions were updated": "Se actualizaron los permisos de {{ userName }}",
|
||||
"Could not update user": "No se pudo actualizar el usuario",
|
||||
"The {{ groupName }} group was removed from the collection": "El grupo {{ groupName }} fue quitado de la colección",
|
||||
"Could not remove group": "No se pudo eliminar el grupo",
|
||||
"{{ userName }} permissions were updated": "{{ userName }} permissions were updated",
|
||||
"Could not update user": "Could not update user",
|
||||
"The {{ groupName }} group was removed from the collection": "The {{ groupName }} group was removed from the collection",
|
||||
"Could not remove group": "Could not remove group",
|
||||
"{{ groupName }} permissions were updated": "{{ groupName }} permissions were updated",
|
||||
"Default access permissions were updated": "Default access permissions were updated",
|
||||
"Could not update permissions": "No se pudo actualizar los permisos",
|
||||
"Could not update permissions": "Could not update permissions",
|
||||
"The <em>{{ collectionName }}</em> collection is private. Team members have no access to it by default.": "The <em>{{ collectionName }}</em> collection is private. Team members have no access to it by default.",
|
||||
"Team members can view documents in the <em>{{ collectionName }}</em> collection by default.": "Team members can view documents in the <em>{{ collectionName }}</em> collection by default.",
|
||||
"Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by\n default.": "Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by\n default.",
|
||||
@@ -329,16 +310,12 @@
|
||||
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "¿Está seguro de que desea eliminar la plantilla <em>{{ documentTitle }}</em>?",
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.",
|
||||
"If you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "If you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.",
|
||||
"Deleting": "Deleting",
|
||||
"I’m sure – Delete": "I’m sure – Delete",
|
||||
"Archiving": "Archiving",
|
||||
"Document moved": "Document moved",
|
||||
"Current location": "Current location",
|
||||
"Choose a new location": "Choose a new location",
|
||||
"Search collections & documents": "Search collections & documents",
|
||||
"Couldn’t create the document, try again?": "Couldn’t create the document, try again?",
|
||||
"Document permanently deleted": "Document permanently deleted",
|
||||
"Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.": "Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.",
|
||||
"Template created, go ahead and customize it": "Template created, go ahead and customize it",
|
||||
"Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.": "Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.",
|
||||
"Search documents": "Search documents",
|
||||
"No documents found for your filters.": "No se encontraron documentos para sus filtros.",
|
||||
"You’ve not got any drafts at the moment.": "No tienes borradores en este momento.",
|
||||
@@ -348,39 +325,19 @@
|
||||
"We were unable to load the document while offline.": "No pudimos cargar el documento sin conexión.",
|
||||
"Your account has been suspended": "Tu cuenta ha sido suspendida",
|
||||
"A team admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.": "Un administrador de equipo (<em>{{ suspendedContactEmail }}</em>) ha suspendido tu cuenta. Para reactivar su cuenta, comuníquese con ellos directamente.",
|
||||
"Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.": "Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.",
|
||||
"You can edit the name of this group at any time, however doing so too often might confuse your team mates.": "You can edit the name of this group at any time, however doing so too often might confuse your team mates.",
|
||||
"{{userName}} was added to the group": "{{userName}} fue agregado al grupo",
|
||||
"Add team members below to give them access to the group. Need to add someone who’s not yet on the team yet?": "Añadir miembros del equipo a continuación para darles acceso al grupo. ¿Necesita añadir alguien que aún no esté en el equipo?",
|
||||
"Invite them to {{teamName}}": "Invítalos a {{teamName}}",
|
||||
"{{userName}} was removed from the group": "{{userName}} fue eliminado del grupo",
|
||||
"Add and remove team members in the <em>{{groupName}}</em> group. Adding people to the group will give them access to any collections this group has been added to.": "Add and remove team members in the <em>{{groupName}}</em> group. Adding people to the group will give them access to any collections this group has been added to.",
|
||||
"Listing team members in the <em>{{groupName}}</em> group.": "Listing team members in the <em>{{groupName}}</em> group.",
|
||||
"This group has no members.": "Este grupo no tiene miembros.",
|
||||
"Add people to {{groupName}}": "Add people to {{groupName}}",
|
||||
"Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.": "Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.",
|
||||
"You’ll be able to add people to the group next.": "You’ll be able to add people to the group next.",
|
||||
"Continue": "Continue",
|
||||
"Group members": "Group members",
|
||||
"Recently viewed": "Recientemente vistos",
|
||||
"Created by me": "Creado por mí",
|
||||
"We sent out your invites!": "We sent out your invites!",
|
||||
"Sorry, you can only send {{MAX_INVITES}} invites at a time": "Sorry, you can only send {{MAX_INVITES}} invites at a time",
|
||||
"Invite team members or guests to join your knowledge base. Team members can sign in with {{signinMethods}} or use their email address.": "Invite team members or guests to join your knowledge base. Team members can sign in with {{signinMethods}} or use their email address.",
|
||||
"Invite team members to join your knowledge base. They will need to sign in with {{signinMethods}}.": "Invite team members to join your knowledge base. They will need to sign in with {{signinMethods}}.",
|
||||
"As an admin you can also <2>enable email sign-in</2>.": "As an admin you can also <2>enable email sign-in</2>.",
|
||||
"Want a link to share directly with your team?": "Want a link to share directly with your team?",
|
||||
"Email": "Email",
|
||||
"Full name": "Nombre completo",
|
||||
"Remove invite": "Remove invite",
|
||||
"Add another": "Add another",
|
||||
"Inviting": "Inviting",
|
||||
"Send Invites": "Send Invites",
|
||||
"Navigation": "Navegación",
|
||||
"Edit current document": "Editar el documento actual",
|
||||
"Move current document": "Mover el documento actual",
|
||||
"Jump to search": "Ir a la búsqueda",
|
||||
"Jump to home": "Jump to home",
|
||||
"Table of contents": "Tabla de contenido",
|
||||
"Toggle navigation": "Toggle navigation",
|
||||
"Focus search input": "Focus search input",
|
||||
"Open this guide": "Abra esta guía",
|
||||
@@ -423,6 +380,7 @@
|
||||
"No documents found for your search filters. <1></1>": "No documents found for your search filters. <1></1>",
|
||||
"Create a new document?": "Create a new document?",
|
||||
"Clear filters": "Limpiar filtros",
|
||||
"Email": "Email",
|
||||
"Last active": "Last active",
|
||||
"Role": "Role",
|
||||
"Viewer": "Viewer",
|
||||
@@ -434,14 +392,6 @@
|
||||
"Active": "Active",
|
||||
"Everyone": "Everyone",
|
||||
"Admins": "Admins",
|
||||
"Settings saved": "Settings saved",
|
||||
"Unable to upload new logo": "Unable to upload new logo",
|
||||
"These details affect the way that your Outline appears to everyone on the team.": "These details affect the way that your Outline appears to everyone on the team.",
|
||||
"Logo": "Logo",
|
||||
"Crop logo": "Crop logo",
|
||||
"Upload": "Subir",
|
||||
"Subdomain": "Subdomain",
|
||||
"Your knowledge base will be accessible at": "Your knowledge base will be accessible at",
|
||||
"New group": "New group",
|
||||
"Groups can be used to organize and manage the people on your team.": "Groups can be used to organize and manage the people on your team.",
|
||||
"All groups": "All groups",
|
||||
@@ -459,37 +409,19 @@
|
||||
"Export Requested": "Export Requested",
|
||||
"Requesting Export": "Requesting Export",
|
||||
"Export Data": "Exportar datos",
|
||||
"Document published": "Document published",
|
||||
"Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published",
|
||||
"Document updated": "Document updated",
|
||||
"Receive a notification when a document you created is edited": "Receive a notification when a document you created is edited",
|
||||
"Collection created": "Collection created",
|
||||
"Receive a notification whenever a new collection is created": "Receive a notification whenever a new collection is created",
|
||||
"Getting started": "Getting started",
|
||||
"Tips on getting started with Outline`s features and functionality": "Tips on getting started with Outline`s features and functionality",
|
||||
"New features": "New features",
|
||||
"Receive an email when new features of note are added": "Receive an email when new features of note are added",
|
||||
"Notifications saved": "Notifications saved",
|
||||
"Unsubscription successful. Your notification settings were updated": "Unsubscription successful. Your notification settings were updated",
|
||||
"Manage when and where you receive email notifications from Outline. Your email address can be updated in your SSO provider.": "Manage when and where you receive email notifications from Outline. Your email address can be updated in your SSO provider.",
|
||||
"Email address": "Email address",
|
||||
"Everyone that has signed into Outline appears here. It’s possible that there are other users who have access through {team.signinMethods} but haven’t signed in yet.": "Everyone that has signed into Outline appears here. It’s possible that there are other users who have access through {team.signinMethods} but haven’t signed in yet.",
|
||||
"Filter": "Filter",
|
||||
"Profile saved": "Perfil guardado",
|
||||
"Profile picture updated": "Foto de perfil guardada",
|
||||
"Unable to upload new profile picture": "No se puede subir una nueva foto de perfil",
|
||||
"Photo": "Foto",
|
||||
"Upload": "Subir",
|
||||
"Full name": "Nombre completo",
|
||||
"Language": "Idioma",
|
||||
"Please note that translations are currently in early access.<1></1>Community contributions are accepted though our <4>translation portal</4>": "Por favor, ten en cuenta que las traducciones están actualmente en acceso anticipado.<1></1>Las contribuciones de la comunidad son aceptadas a través de nuestro <4>portal de traducción</4>",
|
||||
"Delete Account": "Eliminar cuenta",
|
||||
"You may delete your account at any time, note that this is unrecoverable": "Puede eliminar su cuenta en cualquier momento, tenga en cuenta que esto es irréversible",
|
||||
"Delete account": "Eliminar cuenta",
|
||||
"Settings that impact the access, security, and content of your knowledge base.": "Settings that impact the access, security, and content of your knowledge base.",
|
||||
"Allow email authentication": "Allow email authentication",
|
||||
"When enabled, users can sign-in using their email address": "When enabled, users can sign-in using their email address",
|
||||
"When enabled, documents can be shared publicly on the internet by any team member": "When enabled, documents can be shared publicly on the internet by any team member",
|
||||
"Rich service embeds": "Rich service embeds",
|
||||
"Links to supported services are shown as rich embeds within your documents": "Links to supported services are shown as rich embeds within your documents",
|
||||
"Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.": "Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.",
|
||||
"Sharing is currently disabled.": "Sharing is currently disabled.",
|
||||
"You can globally enable and disable public document sharing in the <em>security settings</em>.": "You can globally enable and disable public document sharing in the <em>security settings</em>.",
|
||||
@@ -506,16 +438,10 @@
|
||||
"You can create an unlimited amount of personal tokens to authenticate\n with the API. Tokens have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.": "You can create an unlimited amount of personal tokens to authenticate\n with the API. Tokens have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.",
|
||||
"Tokens": "Tokens",
|
||||
"Create a token": "Create a token",
|
||||
"Zapier": "Zapier",
|
||||
"Zapier is a platform that allows Outline to easily integrate with thousands of other business tools. Head over to Zapier to setup a \"Zap\" and start programmatically interacting with Outline.'": "Zapier is a platform that allows Outline to easily integrate with thousands of other business tools. Head over to Zapier to setup a \"Zap\" and start programmatically interacting with Outline.'",
|
||||
"Open Zapier": "Open Zapier",
|
||||
"You’ve not starred any documents yet.": "Todavía no has marcado documentos como favoritos.",
|
||||
"There are no templates just yet.": "There are no templates just yet.",
|
||||
"You can create templates to help your team create consistent and accurate documentation.": "You can create templates to help your team create consistent and accurate documentation.",
|
||||
"Trash is empty at the moment.": "La papelera está vacía en este momento.",
|
||||
"Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of Outline and all your API tokens will be revoked.": "Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of Outline and all your API tokens will be revoked.",
|
||||
"<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned.": "<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned.",
|
||||
"Delete My Account": "Delete My Account",
|
||||
"You joined": "Te uniste",
|
||||
"Joined": "Unido",
|
||||
"{{ time }} ago.": "Hace {{ time }}.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user