Compare commits

..

15 Commits

Author SHA1 Message Date
Tom Moor d4c594423f More email styling 2021-07-08 22:38:42 -04:00
Tom Moor 2bf237d54b Merge branch 'main' into email-diff 2021-07-08 21:20:18 -04:00
Tom Moor 3565e68725 Merge branch 'main' into email-diff 2021-07-06 20:43:51 -04:00
Tom Moor 61039e9d0d Allow images in email diff 2021-06-25 09:41:34 -07:00
Tom Moor 6d09122d56 test: Deletion 2021-06-24 20:10:42 -07:00
Tom Moor 5fb6097153 Improved diff 2021-06-23 23:58:32 -07:00
Tom Moor ec17874568 Remove test harness 2021-06-22 07:35:38 -07:00
Tom Moor 40c3e9e85f test 2021-06-22 07:27:55 -07:00
Tom Moor 9f739f3788 Merge main 2021-06-22 07:26:45 -07:00
Tom Moor f6837b4742 wip 2021-06-20 23:15:04 -07:00
Tom Moor 1560e3c9f7 refactor 2021-06-20 12:49:15 -07:00
Tom Moor ca74908dc5 test 2021-06-20 00:20:37 -07:00
Tom Moor de7ec1119b Integrate into mailer, basic styling 2021-06-19 23:50:36 -07:00
Tom Moor 2093b4297f Merge main 2021-06-19 17:05:19 -07:00
Nan Yu 3df82c500b wip 2021-02-21 11:52:00 -08:00
118 changed files with 2694 additions and 3935 deletions
-2
View File
@@ -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;
+41 -9
View File
@@ -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;
});
}
+12 -27
View File
@@ -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
+3 -4
View File
@@ -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>
+17 -36
View File
@@ -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;
+1 -1
View File
@@ -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;
`;
+1 -5
View File
@@ -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";
+2 -2
View File
@@ -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;
-4
View File
@@ -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
View File
@@ -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));
+5 -6
View File
@@ -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;
@@ -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 };
-4
View File
@@ -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;
+20 -21
View File
@@ -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;
+2 -2
View File
@@ -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>
)}
</>
+31 -34
View File
@@ -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
View File
@@ -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);
+2 -2
View File
@@ -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
View File
@@ -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>
);
};
-39
View File
@@ -1,39 +0,0 @@
// @flow
import * as React from "react";
import Image from "components/Image";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp(
"^https?://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
/>
);
}
}
-19
View File
@@ -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);
});
});
-8
View File
@@ -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",
-23
View File
@@ -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
View File
@@ -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>
+5 -8
View File
@@ -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">
-76
View File
@@ -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);
+6
View File
@@ -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 });
+2 -2
View File
@@ -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
View File
@@ -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>
);
}
+44 -42
View File
@@ -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("Im 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…" : "Im sure  Delete"}
</Button>
</form>
</Flex>
);
}
}
export default observer(CollectionDelete);
export default inject("collections", "ui")(withRouter(CollectionDelete));
+36 -32
View File
@@ -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);
+8 -17
View File
@@ -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;
+27 -46
View File
@@ -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>
Youre 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>
Youre 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 -10
View File
@@ -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;
-13
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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);
+2 -2
View File
@@ -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>
);
+45 -46
View File
@@ -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
View File
@@ -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("Im 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…" : "Im sure  Delete"}
</Button>
</form>
</Flex>
);
}
}
export default observer(GroupDelete);
export default inject("ui")(withRouter(GroupDelete));
+48 -49
View File
@@ -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));
+95 -76
View File
@@ -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
View File
@@ -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>Youll 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>Youll 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
View File
@@ -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
/>
&nbsp;&nbsp;
<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
/>
&nbsp;&nbsp;
<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
/>
&nbsp;&nbsp;
<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
View File
@@ -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);
+9 -6
View File
@@ -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
+105 -108
View File
@@ -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);
+9 -6
View File
@@ -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) {
+80 -77
View File
@@ -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);
+9 -2
View File
@@ -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")}
+6 -10
View File
@@ -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
View File
@@ -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);
-2
View File
@@ -7,5 +7,3 @@ import Adapter from "enzyme-adapter-react-16";
Enzyme.configure({ adapter: new Adapter() });
global.localStorage = localStorage;
require("jest-fetch-mock").enableMocks();
-41
View File
@@ -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 -4
View File
@@ -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,
-3
View File
@@ -1,3 +0,0 @@
// @flow
import { domMax } from "framer-motion";
export default domMax;
+8 -9
View File
@@ -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

+2 -2
View File
@@ -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
View File
@@ -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
-44
View File
@@ -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;
}
}
-28
View File
@@ -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);
});
});
+4 -2
View File
@@ -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>
+225 -6
View File
@@ -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;
}
}
`;
+1 -1
View File
@@ -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>
+3 -1
View File
@@ -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>
+25
View File
@@ -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>
);
};
+2 -2
View File
@@ -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">
+2
View File
@@ -100,6 +100,8 @@ export type RevisionEvent = {
documentId: string,
collectionId: string,
teamId: string,
actorId: string,
modelId: string,
};
export type CollectionImportEvent = {
+3 -1
View File
@@ -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"]);
}
};
-2
View File
@@ -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
View File
@@ -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);
});
});
+143 -22
View File
@@ -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: [
+12 -6
View File
@@ -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,
+11 -8
View File
@@ -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;
}
+34
View File
@@ -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
View File
@@ -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
View File
@@ -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>"
`;
+57
View File
@@ -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();
}
+55
View File
@@ -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("![caption](/image.png)", "");
expect(diff).toEqual(
'<p><del data-operation-index="0"><img src="/image.png" alt="caption"></del></p>'
);
});
+3 -7
View File
@@ -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
-2
View File
@@ -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" },
+7 -81
View File
@@ -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",
"AZ": "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",
"Im 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 youd 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",
"Im 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",
"Couldnt 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.",
"Youve 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 whos 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.",
"Youll be able to add people to the group next.": "Youll 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. Its possible that there are other users who have access through {team.signinMethods} but havent 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",
"Youve 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 }}.",
+6 -90
View File
@@ -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",
"AZ": "AZ",
"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",
"Im sure  Delete": "Im 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?",
"Youre 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.": "Youre 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 youd like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "If youd like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.",
"Deleting": "Deleting",
"Im sure  Delete": "Im 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",
"Couldnt create the document, try again?": "Couldnt 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.",
"Youve not got any drafts at the moment.": "Youve 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 whos not yet on the team yet?": "Add team members below to give them access to the group. Need to add someone whos 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.",
"Youll be able to add people to the group next.": "Youll 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. Its possible that there are other users who have access through {team.signinMethods} but havent signed in yet.": "Everyone that has signed into Outline appears here. Its possible that there are other users who have access through {team.signinMethods} but havent 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",
"Youve not starred any documents yet.": "Youve 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.",
+29 -103
View File
@@ -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> doesnt 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",
"AZ": "AZ",
"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",
"Im sure  Delete": "Im 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 youd like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "If youd like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.",
"Deleting": "Deleting",
"Im sure  Delete": "Im 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",
"Couldnt create the document, try again?": "Couldnt 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.",
"Youve 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 whos 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.",
"Youll be able to add people to the group next.": "Youll 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. Its possible that there are other users who have access through {team.signinMethods} but havent signed in yet.": "Everyone that has signed into Outline appears here. Its possible that there are other users who have access through {team.signinMethods} but havent 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",
"Youve 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