mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dfc5857e01 | |||
| 954a909749 | |||
| 577de24290 | |||
| 8de59f0a2f | |||
| d8fbe35455 | |||
| 514a724d9d | |||
| d66f41c854 | |||
| b2d6c40ea8 | |||
| c98d6aa33a | |||
| 554c2a5cdb | |||
| ee426de942 | |||
| 746e65e658 | |||
| 8a3a3453e7 | |||
| c7d339ded5 | |||
| ed25554607 | |||
| 29329daf15 | |||
| 3f6390ff18 | |||
| 54b43c6e6f | |||
| 8c9c83eb5a | |||
| 63171e5da2 | |||
| bfd84681d7 | |||
| 7d6a47ce86 | |||
| 68f715b607 | |||
| ea2e7a4d0f | |||
| 26948af1b8 | |||
| 816a6715c5 | |||
| 4579594c63 | |||
| 88f7705fd4 | |||
| 8393847910 | |||
| b9adfa175d | |||
| 7fff8161ff | |||
| 0ef9f1aea1 | |||
| fe63c5d706 | |||
| 7749f0ab9f | |||
| 763b911dfd | |||
| 99e541ede8 | |||
| 06f48ec79a | |||
| 5566d995bd | |||
| 921e89d7b7 | |||
| 32602f89dd | |||
| 2cce95488c | |||
| 0663d191fc | |||
| 84eb1b801d | |||
| 5102cfe8eb | |||
| 1d0617dbd6 | |||
| eedfd549b3 | |||
| 28cb5aa379 |
@@ -45,7 +45,7 @@ import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
|
||||
import DocumentPublish from "~/scenes/DocumentPublish";
|
||||
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DuplicateDialog from "~/components/DuplicateDialog";
|
||||
import DocumentCopy from "~/components/DocumentCopy";
|
||||
import Icon from "~/components/Icon";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import SharePopover from "~/components/Sharing/Document";
|
||||
@@ -562,7 +562,7 @@ export const duplicateDocument = createAction({
|
||||
stores.dialogs.openModal({
|
||||
title: t("Copy document"),
|
||||
content: (
|
||||
<DuplicateDialog
|
||||
<DocumentCopy
|
||||
document={document}
|
||||
onSubmit={(response) => {
|
||||
stores.dialogs.closeAllModals();
|
||||
@@ -1054,7 +1054,7 @@ export const openDocumentComments = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
stores.ui.toggleComments(activeDocumentId);
|
||||
stores.ui.toggleComments();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ const Actions = styled(Flex)`
|
||||
left: 0;
|
||||
border-radius: 3px;
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
padding: 12px;
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Redirect } from "react-router-dom";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { changeLanguage } from "~/utils/language";
|
||||
import { logoutPath } from "~/utils/routeHelpers";
|
||||
import LoadingIndicator from "./LoadingIndicator";
|
||||
|
||||
type Props = {
|
||||
@@ -32,7 +33,7 @@ const Authenticated = ({ children }: Props) => {
|
||||
}
|
||||
|
||||
void auth.logout(true);
|
||||
return <Redirect to="/" />;
|
||||
return <Redirect to={logoutPath()} />;
|
||||
};
|
||||
|
||||
export default observer(Authenticated);
|
||||
|
||||
@@ -94,7 +94,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
!showHistory &&
|
||||
can.comment &&
|
||||
ui.activeDocumentId &&
|
||||
ui.commentsExpanded.includes(ui.activeDocumentId) &&
|
||||
ui.commentsExpanded &&
|
||||
team.getPreference(TeamPreference.Commenting);
|
||||
|
||||
const sidebarRight = (
|
||||
|
||||
@@ -201,7 +201,6 @@ const Input = styled.div`
|
||||
margin: -8px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
transition: ${s("backgroundTransition")};
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
|
||||
@@ -182,7 +182,6 @@ function placeCaret(element: HTMLElement, atStart: boolean) {
|
||||
|
||||
const Content = styled.span`
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
color: ${s("text")};
|
||||
-webkit-text-fill-color: ${s("text")};
|
||||
outline: none;
|
||||
|
||||
@@ -262,22 +262,6 @@ export const Position = styled.div`
|
||||
transition-property: outline-width;
|
||||
transition-duration: 0;
|
||||
outline: none;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
right: 1px;
|
||||
bottom: 1px;
|
||||
pointer-events: none;
|
||||
border-radius: 4px;
|
||||
|
||||
outline-color: ${s("accent")};
|
||||
outline-width: initial;
|
||||
outline-offset: -1px;
|
||||
outline-style: solid;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import flatten from "lodash/flatten";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import { FlexContainer, Footer, StyledText } from "~/scenes/DocumentMove";
|
||||
import Button from "~/components/Button";
|
||||
import DocumentExplorer from "~/components/DocumentExplorer";
|
||||
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { flattenTree } from "~/utils/tree";
|
||||
import Switch from "./Switch";
|
||||
import Text from "./Text";
|
||||
|
||||
type Props = {
|
||||
/** The original document to duplicate */
|
||||
document: Document;
|
||||
onSubmit: (documents: Document[]) => void;
|
||||
};
|
||||
|
||||
function DocumentCopy({ document, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { policies } = useStores();
|
||||
const collectionTrees = useCollectionTrees();
|
||||
const [publish, setPublish] = React.useState<boolean>(!!document.publishedAt);
|
||||
const [recursive, setRecursive] = React.useState<boolean>(true);
|
||||
const [selectedPath, selectPath] = React.useState<NavigationNode | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const nodes = flatten(collectionTrees.map(flattenTree)).filter((node) =>
|
||||
node.collectionId
|
||||
? policies.get(node.collectionId)?.abilities.createDocument
|
||||
: true
|
||||
);
|
||||
|
||||
if (document.isTemplate) {
|
||||
return nodes
|
||||
.filter((node) => node.type === "collection")
|
||||
.map((node) => ({ ...node, children: [] }));
|
||||
}
|
||||
return nodes;
|
||||
}, [policies, collectionTrees, document.isTemplate]);
|
||||
|
||||
const handlePublishChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPublish(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleRecursiveChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRecursive(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const copy = async () => {
|
||||
if (!selectedPath) {
|
||||
toast.message(t("Select a location to copy"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await document.duplicate({
|
||||
publish,
|
||||
recursive,
|
||||
title: document.title,
|
||||
collectionId: selectedPath.collectionId,
|
||||
...(selectedPath.type === "document"
|
||||
? { parentDocumentId: selectedPath.id }
|
||||
: {}),
|
||||
});
|
||||
|
||||
toast.success(t("Document copied"));
|
||||
onSubmit(result);
|
||||
} catch (err) {
|
||||
toast.error(t("Couldn’t copy the document, try again?"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FlexContainer column>
|
||||
<DocumentExplorer
|
||||
items={items}
|
||||
onSubmit={copy}
|
||||
onSelect={selectPath}
|
||||
defaultValue={document.parentDocumentId || document.collectionId || ""}
|
||||
/>
|
||||
<OptionsContainer>
|
||||
{!document.isTemplate && (
|
||||
<>
|
||||
{document.collectionId && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="publish"
|
||||
label={t("Publish")}
|
||||
labelPosition="right"
|
||||
checked={publish}
|
||||
onChange={handlePublishChange}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{document.publishedAt && document.childDocuments.length > 0 && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="recursive"
|
||||
label={t("Include nested documents")}
|
||||
labelPosition="right"
|
||||
checked={recursive}
|
||||
onChange={handleRecursiveChange}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</OptionsContainer>
|
||||
<Footer justify="space-between" align="center" gap={8}>
|
||||
<StyledText type="secondary">
|
||||
{selectedPath ? (
|
||||
<Trans
|
||||
defaults="Copy to <em>{{ location }}</em>"
|
||||
values={{ location: selectedPath.title }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
) : (
|
||||
t("Select a location to copy")
|
||||
)}
|
||||
</StyledText>
|
||||
<Button disabled={!selectedPath} onClick={copy}>
|
||||
{t("Copy")}
|
||||
</Button>
|
||||
</Footer>
|
||||
</FlexContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const OptionsContainer = styled.div`
|
||||
margin: 16px 0 8px 0;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
`;
|
||||
|
||||
export default observer(DocumentCopy);
|
||||
@@ -31,15 +31,15 @@ import { ancestors, descendants } from "~/utils/tree";
|
||||
type Props = {
|
||||
/** Action taken upon submission of selected item, could be publish, move etc. */
|
||||
onSubmit: () => void;
|
||||
|
||||
/** A side-effect of item selection */
|
||||
onSelect: (item: NavigationNode | null) => void;
|
||||
|
||||
/** Items to be shown in explorer */
|
||||
items: NavigationNode[];
|
||||
/** Automatically expand to and select item with the given id */
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
const isMobile = useMobile();
|
||||
const { collections, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
@@ -47,12 +47,25 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
|
||||
const [searchTerm, setSearchTerm] = React.useState<string>();
|
||||
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
|
||||
null
|
||||
() => {
|
||||
const node =
|
||||
defaultValue && items.find((item) => item.id === defaultValue);
|
||||
return node || null;
|
||||
}
|
||||
);
|
||||
const [initialScrollOffset, setInitialScrollOffset] =
|
||||
React.useState<number>(0);
|
||||
const [activeNode, setActiveNode] = React.useState<number>(0);
|
||||
const [expandedNodes, setExpandedNodes] = React.useState<string[]>([]);
|
||||
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
|
||||
if (defaultValue) {
|
||||
const node = items.find((item) => item.id === defaultValue);
|
||||
if (node) {
|
||||
return ancestors(node).map((node) => node.id);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const [itemRefs, setItemRefs] = React.useState<
|
||||
React.RefObject<HTMLSpanElement>[]
|
||||
>([]);
|
||||
@@ -94,6 +107,15 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
onSelect(selectedNode);
|
||||
}, [selectedNode, onSelect]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (defaultValue && selectedNode && listRef) {
|
||||
const index = nodes.findIndex((node) => node.id === selectedNode.id);
|
||||
if (index > 0) {
|
||||
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
function getNodes() {
|
||||
function includeDescendants(item: NavigationNode): NavigationNode[] {
|
||||
return expandedNodes.includes(item.id)
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import Document from "~/models/Document";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Input from "./Input";
|
||||
import Switch from "./Switch";
|
||||
import Text from "./Text";
|
||||
|
||||
type Props = {
|
||||
/** The original document to duplicate */
|
||||
document: Document;
|
||||
onSubmit: (documents: Document[]) => void;
|
||||
};
|
||||
|
||||
function DuplicateDialog({ document, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const defaultTitle = t(`Copy of {{ documentName }}`, {
|
||||
documentName: document.title,
|
||||
});
|
||||
const [publish, setPublish] = React.useState<boolean>(!!document.publishedAt);
|
||||
const [recursive, setRecursive] = React.useState<boolean>(true);
|
||||
const [title, setTitle] = React.useState<string>(defaultTitle);
|
||||
|
||||
const handlePublishChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPublish(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleRecursiveChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRecursive(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleTitleChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTitle(ev.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const result = await document.duplicate({
|
||||
publish,
|
||||
recursive,
|
||||
title,
|
||||
});
|
||||
onSubmit(result);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog onSubmit={handleSubmit} submitText={t("Duplicate")}>
|
||||
<Input
|
||||
autoFocus
|
||||
autoSelect
|
||||
name="title"
|
||||
label={t("Title")}
|
||||
onChange={handleTitleChange}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
defaultValue={defaultTitle}
|
||||
/>
|
||||
{!document.isTemplate && (
|
||||
<>
|
||||
{document.collectionId && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="publish"
|
||||
label={t("Publish")}
|
||||
labelPosition="right"
|
||||
checked={publish}
|
||||
onChange={handlePublishChange}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{document.publishedAt && document.childDocuments.length > 0 && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="recursive"
|
||||
label={t("Include nested documents")}
|
||||
labelPosition="right"
|
||||
checked={recursive}
|
||||
onChange={handleRecursiveChange}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(DuplicateDialog);
|
||||
@@ -229,7 +229,7 @@ const SearchInput = styled(Input)`
|
||||
${Outline} {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
border-bottom: 1px solid rgb(34 40 52);
|
||||
background: ${s("menuBackground")};
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,6 @@ const Scene = styled.div`
|
||||
align-items: flex-start;
|
||||
width: 350px;
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
opacity: 0;
|
||||
|
||||
@@ -130,7 +130,6 @@ const Wrapper = styled(Flex)<WrapperProps>`
|
||||
`};
|
||||
|
||||
padding: 12px;
|
||||
transition: all 100ms ease-out;
|
||||
transform: translate3d(0, 0, 0);
|
||||
min-height: ${HEADER_HEIGHT}px;
|
||||
justify-content: flex-start;
|
||||
|
||||
@@ -76,7 +76,6 @@ const Layout = React.forwardRef(function Layout_(
|
||||
|
||||
const Container = styled(Flex)`
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
|
||||
@@ -174,7 +174,6 @@ const Fullscreen = styled.div<FullscreenProps>`
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
outline: none;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
@@ -265,7 +264,6 @@ const Small = styled.div`
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
background: ${s("modalBackground")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
box-shadow: ${s("modalShadow")};
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
|
||||
@@ -144,7 +144,6 @@ const EmojiButton = styled(NudeButton)<{
|
||||
height: 28px;
|
||||
padding: 6px;
|
||||
border-radius: 12px;
|
||||
transition: ${s("backgroundTransition")};
|
||||
background: ${s("backgroundTertiary")};
|
||||
pointer-events: ${({ disabled }) => disabled && "none"};
|
||||
|
||||
|
||||
@@ -298,9 +298,8 @@ const Container = styled(Flex)<ContainerProps>`
|
||||
width: 100%;
|
||||
background: ${s("sidebarBackground")};
|
||||
transition: box-shadow 150ms ease-in-out, transform 150ms ease-out,
|
||||
${s("backgroundTransition")}
|
||||
${(props: ContainerProps) =>
|
||||
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
|
||||
${(props: ContainerProps) =>
|
||||
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
|
||||
transform: translateX(
|
||||
${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")}
|
||||
);
|
||||
|
||||
@@ -112,8 +112,9 @@ const NavLink = ({
|
||||
!rest.target &&
|
||||
!event.altKey &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey,
|
||||
[rest.target]
|
||||
!event.ctrlKey &&
|
||||
!isActive,
|
||||
[rest.target, isActive]
|
||||
);
|
||||
|
||||
const navigateTo = React.useCallback(() => {
|
||||
@@ -153,14 +154,13 @@ const NavLink = ({
|
||||
<Link
|
||||
key={isActive ? "active" : "inactive"}
|
||||
ref={linkRef}
|
||||
// onMouseDown={handleClick}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(event) => {
|
||||
if (["Enter", " "].includes(event.key)) {
|
||||
navigateTo();
|
||||
event.currentTarget?.blur();
|
||||
}
|
||||
}}
|
||||
onClick={handleClick}
|
||||
aria-current={(isActive && ariaCurrent) || undefined}
|
||||
className={className}
|
||||
style={style}
|
||||
|
||||
@@ -105,7 +105,6 @@ const Button = styled(Flex)<{
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
color: ${s("sidebarText")};
|
||||
transition: background 100ms ease-in-out;
|
||||
background: ${s("sidebarActiveBackground")};
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,6 @@ function SidebarLink(
|
||||
|
||||
const activeStyle = React.useMemo(
|
||||
() => ({
|
||||
fontWeight: 600,
|
||||
color: theme.text,
|
||||
background: theme.sidebarActiveBackground,
|
||||
...style,
|
||||
@@ -202,10 +201,10 @@ const Link = styled(NavLink)<{
|
||||
display: flex;
|
||||
position: relative;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 475;
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
min-height: 32px;
|
||||
transition: background 50ms, color 50ms;
|
||||
user-select: none;
|
||||
background: ${(props) =>
|
||||
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
|
||||
|
||||
@@ -48,13 +48,20 @@ function StarredLink({ star }: Props) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
star.documentId === ui.activeDocumentId &&
|
||||
sidebarContext === locationSidebarContext
|
||||
) {
|
||||
setExpanded(true);
|
||||
} else if (
|
||||
star.collectionId === ui.activeCollectionId &&
|
||||
sidebarContext === locationSidebarContext
|
||||
) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [
|
||||
star.documentId,
|
||||
star.collectionId,
|
||||
ui.activeDocumentId,
|
||||
ui.activeCollectionId,
|
||||
sidebarContext,
|
||||
locationSidebarContext,
|
||||
|
||||
@@ -31,7 +31,6 @@ const Background = styled.div<{ sticky?: boolean }>`
|
||||
margin: 0 -8px;
|
||||
padding: 0 8px;
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
|
||||
@@ -253,6 +253,7 @@ const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
|
||||
white-space: nowrap;
|
||||
margin: 0 -4px;
|
||||
padding: 0 4px;
|
||||
cursor: ${(props) => (props.$sortable ? `var(--pointer)` : "")};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) =>
|
||||
@@ -309,15 +310,13 @@ const Row = styled.tr`
|
||||
|
||||
const Head = styled.th`
|
||||
text-align: left;
|
||||
padding: 6px 6px 0;
|
||||
padding: 6px 6px 2px;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
font-size: 14px;
|
||||
color: ${s("textSecondary")};
|
||||
font-weight: 500;
|
||||
z-index: 1;
|
||||
cursor: var(--pointer) !important;
|
||||
|
||||
:first-child {
|
||||
padding-left: 0;
|
||||
|
||||
@@ -45,7 +45,6 @@ const Sticky = styled.div`
|
||||
margin: 0 -8px;
|
||||
padding: 0 8px;
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
type Props = {
|
||||
/** The size to render the indicator, defaults to 24px */
|
||||
size?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A component to show an animated typing indicator.
|
||||
*/
|
||||
export default function Typing({ size = 24 }: Props) {
|
||||
return (
|
||||
<Wrapper height={size} width={size}>
|
||||
<Circle cx={size / 4} cy={size / 2} r="2" />
|
||||
<Circle cx={size / 2} cy={size / 2} r="2" />
|
||||
<Circle cx={size / 1.33333} cy={size / 2} r="2" />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.svg`
|
||||
fill: ${s("textTertiary")};
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
fill: transparent;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Circle = styled.circle`
|
||||
animation: 1s blink infinite;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 250ms;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation-delay: 500ms;
|
||||
}
|
||||
`;
|
||||
@@ -529,13 +529,6 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
stars.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on(
|
||||
"user.typing",
|
||||
(event: { userId: string; documentId: string; commentId: string }) => {
|
||||
comments.setTyping(event);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("collections.add_user", async (event: Membership) => {
|
||||
memberships.add(event);
|
||||
await collections.fetch(event.collectionId, {
|
||||
|
||||
@@ -131,13 +131,15 @@ function usePosition({
|
||||
|
||||
// Images need their own positioning to get the toolbar in the center
|
||||
if (isImageSelection) {
|
||||
const element = view.nodeDOM(selection.from) as HTMLElement;
|
||||
const element = view.nodeDOM(selection.from);
|
||||
|
||||
// Images are wrapped which impacts positioning - need to get the element
|
||||
// specifically tagged as the handle
|
||||
const imageElement = element.getElementsByClassName(
|
||||
EditorStyleHelper.imageHandle
|
||||
)[0];
|
||||
const imageElement = element
|
||||
? (element as HTMLElement).getElementsByClassName(
|
||||
EditorStyleHelper.imageHandle
|
||||
)[0]
|
||||
: undefined;
|
||||
if (imageElement) {
|
||||
const { left, top, width } = imageElement.getBoundingClientRect();
|
||||
|
||||
|
||||
@@ -92,6 +92,10 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
|
||||
public replace(replace: string): Command {
|
||||
return (state, dispatch) => {
|
||||
// Redo the search to ensure we have the latest results, the document may
|
||||
// have changed underneath us since the last search.
|
||||
this.search(state.doc);
|
||||
|
||||
const result = this.results[this.currentResultIndex];
|
||||
|
||||
if (!result) {
|
||||
@@ -106,7 +110,12 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
}
|
||||
|
||||
public replaceAll(replace: string): Command {
|
||||
return ({ tr }, dispatch) => {
|
||||
return (state, dispatch) => {
|
||||
// Redo the search to ensure we have the latest results, the document may
|
||||
// have changed underneath us since the last search.
|
||||
this.search(state.doc);
|
||||
|
||||
const tr = state.tr;
|
||||
let offset: number | undefined;
|
||||
|
||||
if (!this.results.length) {
|
||||
@@ -256,12 +265,17 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
}
|
||||
|
||||
// Reconstruct the correct match position
|
||||
const i = m.index > text.length ? m.index - text.length : m.index;
|
||||
const i = m.index >= text.length ? m.index - text.length : m.index;
|
||||
const from = pos + i;
|
||||
const to = from + m[0].length;
|
||||
|
||||
this.results.push({
|
||||
from: pos + i,
|
||||
to: pos + i + m[0].length,
|
||||
});
|
||||
// Check if already exists in results, possible due to duplicated
|
||||
// search string on L257
|
||||
if (this.results.some((r) => r.from === from && r.to === to)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.results.push({ from, to });
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid RegExp
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { keymap } from "prosemirror-keymap";
|
||||
import {
|
||||
ySyncPlugin,
|
||||
yCursorPlugin,
|
||||
@@ -104,11 +103,13 @@ export default class Multiplayer extends Extension {
|
||||
selectionBuilder,
|
||||
}),
|
||||
yUndoPlugin(),
|
||||
keymap({
|
||||
"Mod-z": undo,
|
||||
"Mod-y": redo,
|
||||
"Mod-Shift-z": redo,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
commands() {
|
||||
return {
|
||||
undo: () => undo,
|
||||
redo: () => redo,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { darken, transparentize } from "polished";
|
||||
import { baseKeymap } from "prosemirror-commands";
|
||||
import { dropCursor } from "prosemirror-dropcursor";
|
||||
import { gapCursor } from "prosemirror-gapcursor";
|
||||
import { redo, undo } from "prosemirror-history";
|
||||
import { inputRules, InputRule } from "prosemirror-inputrules";
|
||||
import { keymap } from "prosemirror-keymap";
|
||||
import { MarkdownParser } from "prosemirror-markdown";
|
||||
@@ -608,20 +607,6 @@ export class Editor extends React.PureComponent<
|
||||
this.props
|
||||
);
|
||||
|
||||
/**
|
||||
* Undo the last change in the editor.
|
||||
*
|
||||
* @returns True if the undo was successful
|
||||
*/
|
||||
public undo = () => undo(this.view.state, this.view.dispatch, this.view);
|
||||
|
||||
/**
|
||||
* Redo the last change in the editor.
|
||||
*
|
||||
* @returns True if the change was successful
|
||||
*/
|
||||
public redo = () => redo(this.view.state, this.view.dispatch, this.view);
|
||||
|
||||
/**
|
||||
* Returns true if the trimmed content of the editor is an empty string.
|
||||
*
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function useAutoRefresh() {
|
||||
return;
|
||||
}
|
||||
if (isReloaded) {
|
||||
Logger.error("lifecycle", new Error("Attempted to reload twice"));
|
||||
Logger.warn("Attempted to reload twice");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,7 @@ export default function useComponentSize(
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const [size, setSize] = useState({
|
||||
width: ref.current?.clientWidth || 0,
|
||||
height: ref.current?.clientHeight || 0,
|
||||
});
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const sizeObserver = new ResizeObserver((entries) => {
|
||||
@@ -24,6 +21,10 @@ export default function useComponentSize(
|
||||
});
|
||||
|
||||
if (ref.current) {
|
||||
setSize({
|
||||
width: ref.current?.clientWidth,
|
||||
height: ref.current?.clientHeight,
|
||||
});
|
||||
sizeObserver.observe(ref.current);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import useCurrentUser from "./useCurrentUser";
|
||||
import usePolicy from "./usePolicy";
|
||||
|
||||
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
|
||||
const PersonalApiKeys = lazy(() => import("~/scenes/Settings/PersonalApiKeys"));
|
||||
const Details = lazy(() => import("~/scenes/Settings/Details"));
|
||||
const Export = lazy(() => import("~/scenes/Settings/Export"));
|
||||
const Features = lazy(() => import("~/scenes/Settings/Features"));
|
||||
@@ -87,10 +88,10 @@ const useSettingsConfig = () => {
|
||||
icon: EmailIcon,
|
||||
},
|
||||
{
|
||||
name: t("API"),
|
||||
path: settingsPath("tokens"),
|
||||
component: ApiKeys,
|
||||
enabled: can.createApiKey,
|
||||
name: t("API Keys"),
|
||||
path: settingsPath("personal-api-keys"),
|
||||
component: PersonalApiKeys,
|
||||
enabled: can.createApiKey && !can.listApiKeys,
|
||||
group: t("Account"),
|
||||
icon: CodeIcon,
|
||||
},
|
||||
@@ -143,6 +144,14 @@ const useSettingsConfig = () => {
|
||||
group: t("Workspace"),
|
||||
icon: ShapesIcon,
|
||||
},
|
||||
{
|
||||
name: t("API Keys"),
|
||||
path: settingsPath("api-keys"),
|
||||
component: ApiKeys,
|
||||
enabled: can.listApiKeys,
|
||||
group: t("Workspace"),
|
||||
icon: CodeIcon,
|
||||
},
|
||||
{
|
||||
name: t("Shared Links"),
|
||||
path: settingsPath("shares"),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import Document from "~/models/Document";
|
||||
import Button from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
@@ -11,7 +12,6 @@ import Icon from "~/components/Icon";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuItem } from "~/types";
|
||||
import { replaceTitleVariables } from "~/utils/date";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -29,7 +29,7 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {
|
||||
const templateToMenuItem = React.useCallback(
|
||||
(tmpl: Document): MenuItem => ({
|
||||
type: "button",
|
||||
title: replaceTitleVariables(tmpl.titleWithDefault, user),
|
||||
title: TextHelper.replaceTemplateVariables(tmpl.titleWithDefault, user),
|
||||
icon: tmpl.icon ? (
|
||||
<Icon value={tmpl.icon} color={tmpl.color ?? undefined} />
|
||||
) : (
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { subSeconds } from "date-fns";
|
||||
import invariant from "invariant";
|
||||
import uniq from "lodash/uniq";
|
||||
import { action, computed, observable } from "mobx";
|
||||
import { now } from "mobx-utils";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import type { ProsemirrorData, ReactionSummary } from "@shared/types";
|
||||
import User from "~/models/User";
|
||||
@@ -15,17 +13,6 @@ import Relation from "./decorators/Relation";
|
||||
class Comment extends Model {
|
||||
static modelName = "Comment";
|
||||
|
||||
/**
|
||||
* Map to keep track of which users are currently typing a reply in this
|
||||
* comments thread.
|
||||
*/
|
||||
@observable
|
||||
typingUsers: Map<string, Date> = new Map();
|
||||
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The Prosemirror data representing the comment content
|
||||
*/
|
||||
@@ -107,17 +94,6 @@ class Comment extends Model {
|
||||
*/
|
||||
private reactedUsersLoading = false;
|
||||
|
||||
/**
|
||||
* An array of users that are currently typing a reply in this comments thread.
|
||||
*/
|
||||
@computed
|
||||
public get currentlyTypingUsers(): User[] {
|
||||
return Array.from(this.typingUsers.entries())
|
||||
.filter(([, lastReceivedDate]) => lastReceivedDate > subSeconds(now(), 3))
|
||||
.map(([userId]) => this.store.rootStore.users.get(userId))
|
||||
.filter(Boolean) as User[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the comment is resolved
|
||||
*/
|
||||
|
||||
@@ -65,10 +65,6 @@ export default class Document extends ArchivableModel {
|
||||
|
||||
store: DocumentsStore;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
|
||||
@observable.shallow
|
||||
data: ProsemirrorData;
|
||||
|
||||
@@ -577,6 +573,8 @@ export default class Document extends ArchivableModel {
|
||||
title?: string;
|
||||
publish?: boolean;
|
||||
recursive?: boolean;
|
||||
collectionId?: string | null;
|
||||
parentDocumentId?: string;
|
||||
}) => this.store.duplicate(this, options);
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,10 +6,6 @@ import Field from "./decorators/Field";
|
||||
class Group extends Model {
|
||||
static modelName = "Group";
|
||||
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
@@ -18,10 +18,6 @@ import Relation from "./decorators/Relation";
|
||||
class Notification extends Model {
|
||||
static modelName = "Notification";
|
||||
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The date the notification was marked as read.
|
||||
*/
|
||||
|
||||
@@ -8,10 +8,6 @@ import Field from "./decorators/Field";
|
||||
class Team extends Model {
|
||||
static modelName = "Team";
|
||||
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
@@ -22,10 +22,6 @@ import Field from "./decorators/Field";
|
||||
class User extends ParanoidModel {
|
||||
static modelName = "User";
|
||||
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
avatarUrl: string;
|
||||
|
||||
@@ -5,10 +5,6 @@ import Field from "./decorators/Field";
|
||||
class WebhookSubscription extends Model {
|
||||
static modelName = "WebhookSubscription";
|
||||
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
@@ -49,8 +49,6 @@ type Props = {
|
||||
highlightedText?: string;
|
||||
/** The text direction of the editor */
|
||||
dir?: "rtl" | "ltr";
|
||||
/** Callback when the user is typing in the editor */
|
||||
onTyping?: () => void;
|
||||
/** Callback when the editor is focused */
|
||||
onFocus?: () => void;
|
||||
/** Callback when the editor is blurred */
|
||||
@@ -63,7 +61,6 @@ function CommentForm({
|
||||
draft,
|
||||
onSubmit,
|
||||
onSaveDraft,
|
||||
onTyping,
|
||||
onFocus,
|
||||
onBlur,
|
||||
autoFocus,
|
||||
@@ -182,7 +179,6 @@ function CommentForm({
|
||||
) => {
|
||||
const text = value(true, true);
|
||||
onSaveDraft(text ? value(false, true) : undefined);
|
||||
onTyping?.();
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import throttle from "lodash/throttle";
|
||||
import { observer } from "mobx-react";
|
||||
import { darken } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
@@ -10,14 +10,11 @@ import { s } from "@shared/styles";
|
||||
import { ProsemirrorData } from "@shared/types";
|
||||
import Comment from "~/models/Comment";
|
||||
import Document from "~/models/Document";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import Typing from "~/components/Typing";
|
||||
import { WebsocketContext } from "~/components/WebsocketProvider";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -40,28 +37,12 @@ type Props = {
|
||||
enableScroll: () => void;
|
||||
/** Disable scroll for the comments container */
|
||||
disableScroll: () => void;
|
||||
/** Number of replies before collapsing */
|
||||
collapseThreshold?: number;
|
||||
/** Number of replies to display when collapsed */
|
||||
collapseNumDisplayed?: number;
|
||||
};
|
||||
|
||||
function useTypingIndicator({
|
||||
document,
|
||||
comment,
|
||||
}: Pick<Props, "document" | "comment">): [undefined, () => void] {
|
||||
const socket = React.useContext(WebsocketContext);
|
||||
|
||||
const setIsTyping = React.useMemo(
|
||||
() =>
|
||||
throttle(() => {
|
||||
socket?.emit("typing", {
|
||||
documentId: document.id,
|
||||
commentId: comment.id,
|
||||
});
|
||||
}, 500),
|
||||
[socket, document.id, comment.id]
|
||||
);
|
||||
|
||||
return [undefined, setIsTyping];
|
||||
}
|
||||
|
||||
function CommentThread({
|
||||
comment: thread,
|
||||
document,
|
||||
@@ -69,21 +50,19 @@ function CommentThread({
|
||||
focused,
|
||||
enableScroll,
|
||||
disableScroll,
|
||||
collapseThreshold = 5,
|
||||
collapseNumDisplayed = 3,
|
||||
}: Props) {
|
||||
const [focusedOnMount] = React.useState(focused);
|
||||
const { editor } = useDocumentContext();
|
||||
const { comments } = useStores();
|
||||
const topRef = React.useRef<HTMLDivElement>(null);
|
||||
const replyRef = React.useRef<HTMLDivElement>(null);
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
|
||||
const [, setIsTyping] = useTypingIndicator({
|
||||
document,
|
||||
comment: thread,
|
||||
});
|
||||
|
||||
const can = usePolicy(document);
|
||||
|
||||
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
|
||||
@@ -102,6 +81,17 @@ function CommentThread({
|
||||
.inThread(thread.id)
|
||||
.filter((comment) => !comment.isNew);
|
||||
|
||||
const [collapse, setCollapse] = React.useState(() => {
|
||||
const numReplies = commentsInThread.length - 1;
|
||||
if (numReplies >= collapseThreshold) {
|
||||
return {
|
||||
begin: 1,
|
||||
final: commentsInThread.length - collapseNumDisplayed - 1,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
useOnClickOutside(topRef, (event) => {
|
||||
if (
|
||||
focused &&
|
||||
@@ -129,6 +119,36 @@ function CommentThread({
|
||||
});
|
||||
};
|
||||
|
||||
const handleClickExpand = (ev: React.SyntheticEvent) => {
|
||||
ev.stopPropagation();
|
||||
setCollapse(null);
|
||||
};
|
||||
|
||||
const renderShowMore = (collapse: { begin: number; final: number }) => {
|
||||
const count = collapse.final - collapse.begin + 1;
|
||||
const createdBy = commentsInThread
|
||||
.slice(collapse.begin, collapse.final + 1)
|
||||
.map((c) => c.createdBy);
|
||||
const users = Array.from(new Set(createdBy));
|
||||
const limit = 3;
|
||||
const overflow = users.length - limit;
|
||||
|
||||
return (
|
||||
<ShowMore onClick={handleClickExpand} key="show-more">
|
||||
{t("Show {{ count }} reply", { count })}
|
||||
<Facepile
|
||||
users={users}
|
||||
limit={limit}
|
||||
overflow={overflow}
|
||||
size={AvatarSize.Medium}
|
||||
renderAvatar={(item) => (
|
||||
<Avatar size={AvatarSize.Medium} model={item} />
|
||||
)}
|
||||
/>
|
||||
</ShowMore>
|
||||
);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!focused && autoFocus) {
|
||||
setAutoFocus(false);
|
||||
@@ -192,8 +212,17 @@ function CommentThread({
|
||||
onClick={handleClickThread}
|
||||
>
|
||||
{commentsInThread.map((comment, index) => {
|
||||
if (collapse !== null) {
|
||||
if (index === collapse.begin) {
|
||||
return renderShowMore(collapse);
|
||||
} else if (index > collapse.begin && index <= collapse.final) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const firstOfAuthor =
|
||||
index === 0 ||
|
||||
(collapse && index === collapse.final + 1) ||
|
||||
comment.createdById !== commentsInThread[index - 1].createdById;
|
||||
const lastOfAuthor =
|
||||
index === commentsInThread.length - 1 ||
|
||||
@@ -219,15 +248,6 @@ function CommentThread({
|
||||
);
|
||||
})}
|
||||
|
||||
{thread.currentlyTypingUsers
|
||||
.filter((typing) => typing.id !== user.id)
|
||||
.map((typing) => (
|
||||
<Flex gap={8} key={typing.id}>
|
||||
<Avatar model={typing} size={24} />
|
||||
<Typing />
|
||||
</Flex>
|
||||
))}
|
||||
|
||||
<ResizingHeightContainer hideOverflow={false} ref={replyRef}>
|
||||
{(focused || draft || commentsInThread.length === 0) && canReply && (
|
||||
<Fade timing={100}>
|
||||
@@ -237,7 +257,6 @@ function CommentThread({
|
||||
draft={draft}
|
||||
documentId={document.id}
|
||||
thread={thread}
|
||||
onTyping={setIsTyping}
|
||||
standalone={commentsInThread.length === 0}
|
||||
dir={document.dir}
|
||||
autoFocus={autoFocus}
|
||||
@@ -276,6 +295,29 @@ const Reply = styled.button`
|
||||
`}
|
||||
`;
|
||||
|
||||
const ShowMore = styled.div<{ $dir?: "rtl" | "ltr" }>`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1px;
|
||||
margin-left: ${(props) => (props.$dir === "rtl" ? 0 : 32)}px;
|
||||
margin-right: ${(props) => (props.$dir !== "rtl" ? 0 : 32)}px;
|
||||
padding: 8px 12px;
|
||||
color: ${s("textTertiary")};
|
||||
background: ${(props) => darken(0.015, props.theme.backgroundSecondary)};
|
||||
cursor: var(--pointer);
|
||||
font-size: 13px;
|
||||
|
||||
&: ${hover} {
|
||||
color: ${s("textSecondary")};
|
||||
background: ${s("backgroundTertiary")};
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: ${(props) => darken(0.015, props.theme.backgroundSecondary)};
|
||||
}
|
||||
`;
|
||||
|
||||
const Thread = styled.div<{
|
||||
$focused: boolean;
|
||||
$recessed: boolean;
|
||||
|
||||
@@ -264,17 +264,19 @@ function CommentThreadItem({
|
||||
<EventBoundary>
|
||||
{!isEditing && (
|
||||
<Actions gap={4} dir={dir}>
|
||||
{firstOfThread && (
|
||||
<ResolveButton onUpdate={handleUpdate} comment={comment} />
|
||||
)}
|
||||
{!comment.isResolved && (
|
||||
<Action
|
||||
as={ReactionPicker}
|
||||
onSelect={handleAddReaction}
|
||||
onOpen={disableScroll}
|
||||
onClose={enableScroll}
|
||||
rounded
|
||||
/>
|
||||
<>
|
||||
{firstOfThread && (
|
||||
<ResolveButton onUpdate={handleUpdate} comment={comment} />
|
||||
)}
|
||||
<Action
|
||||
as={ReactionPicker}
|
||||
onSelect={handleAddReaction}
|
||||
onOpen={disableScroll}
|
||||
onClose={enableScroll}
|
||||
rounded
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Action
|
||||
as={CommentMenu}
|
||||
@@ -419,7 +421,7 @@ export const Bubble = styled(Flex)<{
|
||||
min-width: 2em;
|
||||
margin-bottom: 1px;
|
||||
padding: 8px 12px;
|
||||
transition: color 100ms ease-out, ${s("backgroundTransition")};
|
||||
transition: color 100ms ease-out, background 100ms ease-out;
|
||||
|
||||
${({ $lastOfThread, $canReply }) =>
|
||||
$lastOfThread &&
|
||||
|
||||
@@ -44,7 +44,7 @@ function Comments() {
|
||||
const isAtBottom = React.useRef(true);
|
||||
const [showJumpToRecentBtn, setShowJumpToRecentBtn] = React.useState(false);
|
||||
|
||||
useKeyDown("Escape", () => document && ui.collapseComments(document?.id));
|
||||
useKeyDown("Escape", () => document && ui.set({ commentsExpanded: false }));
|
||||
|
||||
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
|
||||
`draft-${document?.id}-new`,
|
||||
@@ -126,7 +126,7 @@ function Comments() {
|
||||
<CommentSortMenu />
|
||||
</Flex>
|
||||
}
|
||||
onClose={() => ui.collapseComments(document?.id)}
|
||||
onClose={() => ui.set({ commentsExpanded: false })}
|
||||
scrollable={false}
|
||||
>
|
||||
<Scrollable
|
||||
|
||||
@@ -87,7 +87,6 @@ const StickyWrapper = styled.div`
|
||||
border-radius: 8px;
|
||||
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
|
||||
@supports (backdrop-filter: blur(20px)) {
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
TeamPreference,
|
||||
} from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
@@ -44,7 +45,6 @@ import withStores from "~/components/withStores";
|
||||
import type { Editor as TEditor } from "~/editor";
|
||||
import { SearchResult } from "~/editor/components/LinkEditor";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { replaceTitleVariables } from "~/utils/date";
|
||||
import { emojiToUrl } from "~/utils/emoji";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
|
||||
@@ -151,7 +151,13 @@ class DocumentScene extends React.Component<Props> {
|
||||
}
|
||||
|
||||
const { view, schema } = editorRef;
|
||||
const doc = Node.fromJSON(schema, template.data);
|
||||
const doc = Node.fromJSON(
|
||||
schema,
|
||||
ProsemirrorHelper.replaceTemplateVariables(
|
||||
template.data,
|
||||
this.props.auth.user!
|
||||
)
|
||||
);
|
||||
|
||||
if (doc) {
|
||||
view.dispatch(
|
||||
@@ -168,9 +174,9 @@ class DocumentScene extends React.Component<Props> {
|
||||
}
|
||||
|
||||
if (!this.title) {
|
||||
const title = replaceTitleVariables(
|
||||
const title = TextHelper.replaceTemplateVariables(
|
||||
template.title,
|
||||
this.props.auth.user || undefined
|
||||
this.props.auth.user!
|
||||
);
|
||||
this.title = title;
|
||||
this.props.document.title = title;
|
||||
@@ -215,13 +221,15 @@ class DocumentScene extends React.Component<Props> {
|
||||
|
||||
onUndoRedo = (event: KeyboardEvent) => {
|
||||
if (isModKey(event)) {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (this.editor.current?.redo()) {
|
||||
event.preventDefault();
|
||||
if (!this.props.readOnly) {
|
||||
this.editor.current?.commands.redo();
|
||||
}
|
||||
} else {
|
||||
if (this.editor.current?.undo()) {
|
||||
event.preventDefault();
|
||||
if (!this.props.readOnly) {
|
||||
this.editor.current?.commands.undo();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -410,7 +418,8 @@ class DocumentScene extends React.Component<Props> {
|
||||
(team && team.documentEmbeds === false) || document.embedsDisabled;
|
||||
|
||||
const showContents =
|
||||
ui.tocVisible === true || (isShare && ui.tocVisible !== false);
|
||||
(ui.tocVisible === true && !document.isTemplate) ||
|
||||
(isShare && ui.tocVisible !== false);
|
||||
const tocPos =
|
||||
tocPosition ??
|
||||
((team?.getPreference(TeamPreference.TocPosition) as TOCPosition) ||
|
||||
@@ -695,7 +704,6 @@ const Footer = styled.div`
|
||||
const Background = styled(Container)`
|
||||
position: relative;
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
`;
|
||||
|
||||
const ReferencesWrapper = styled.div`
|
||||
|
||||
@@ -46,7 +46,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
•
|
||||
<CommentLink
|
||||
to={documentPath(document)}
|
||||
onClick={() => ui.toggleComments(document.id)}
|
||||
onClick={() => ui.toggleComments()}
|
||||
>
|
||||
<CommentIcon size={18} />
|
||||
{commentsCount
|
||||
|
||||
@@ -116,7 +116,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
state: { commentId: focusedComment.id },
|
||||
});
|
||||
}
|
||||
ui.expandComments(document.id);
|
||||
ui.set({ commentsExpanded: true });
|
||||
}
|
||||
}, [focusedComment, ui, document.id, history, params]);
|
||||
|
||||
|
||||
@@ -117,7 +117,8 @@ function DocumentHeader({
|
||||
const canToggleEmbeds = team?.documentEmbeds;
|
||||
const isShare = !!shareId;
|
||||
const showContents =
|
||||
ui.tocVisible === true || (isShare && ui.tocVisible !== false);
|
||||
(ui.tocVisible === true && !document.isTemplate) ||
|
||||
(isShare && ui.tocVisible !== false);
|
||||
|
||||
const toc = (
|
||||
<Tooltip
|
||||
@@ -236,7 +237,11 @@ function DocumentHeader({
|
||||
<TableOfContentsMenu />
|
||||
) : (
|
||||
<DocumentBreadcrumb document={document}>
|
||||
{toc} <Star document={document} color={theme.textSecondary} />
|
||||
{document.isTemplate ? null : (
|
||||
<>
|
||||
{toc} <Star document={document} color={theme.textSecondary} />
|
||||
</>
|
||||
)}
|
||||
</DocumentBreadcrumb>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -273,6 +273,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
<>
|
||||
{showCache && (
|
||||
<Editor
|
||||
editorStyle={props.editorStyle}
|
||||
embedsDisabled={props.embedsDisabled}
|
||||
defaultValue={props.defaultValue}
|
||||
extensions={props.extensions}
|
||||
@@ -290,8 +291,8 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
style={
|
||||
showCache
|
||||
? {
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -31,11 +31,13 @@ function References({ document }: Props) {
|
||||
: [];
|
||||
const showBacklinks = !!backlinks.length;
|
||||
const showChildDocuments = !!children.length;
|
||||
const shouldFade = React.useRef(!showBacklinks && !showChildDocuments);
|
||||
const isBacklinksTab = location.hash === "#backlinks" || !showChildDocuments;
|
||||
const height = Math.max(backlinks.length, children.length) * 40;
|
||||
const Component = shouldFade.current ? Fade : React.Fragment;
|
||||
|
||||
return showBacklinks || showChildDocuments ? (
|
||||
<Fade>
|
||||
<Component>
|
||||
<Tabs>
|
||||
{showChildDocuments && (
|
||||
<Tab to="#children" isActive={() => !isBacklinksTab}>
|
||||
@@ -80,7 +82,7 @@ function References({ document }: Props) {
|
||||
</List>
|
||||
)}
|
||||
</Content>
|
||||
</Fade>
|
||||
</Component>
|
||||
) : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,12 +28,24 @@ function DocumentMove({ document }: Props) {
|
||||
);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
// Recursively filter out the document itself and its existing parent doc, if any.
|
||||
const filterSourceDocument = (node: NavigationNode): NavigationNode => ({
|
||||
...node,
|
||||
children: node.children
|
||||
?.filter(
|
||||
(c) => c.id !== document.id && c.id !== document.parentDocumentId
|
||||
)
|
||||
.map(filterSourceDocument),
|
||||
});
|
||||
|
||||
// Filter out the document itself and its existing parent doc, if any.
|
||||
const nodes = flatten(collectionTrees.map(flattenTree))
|
||||
.filter(
|
||||
(node) =>
|
||||
node.id !== document.id && node.id !== document.parentDocumentId
|
||||
)
|
||||
.map(filterSourceDocument)
|
||||
// Filter out collections that we don't have permission to create documents in.
|
||||
.filter((node) =>
|
||||
node.collectionId
|
||||
? policies.get(node.collectionId)?.abilities.createDocument
|
||||
@@ -108,21 +120,21 @@ function DocumentMove({ document }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const FlexContainer = styled(Flex)`
|
||||
export const FlexContainer = styled(Flex)`
|
||||
margin-left: -24px;
|
||||
margin-right: -24px;
|
||||
margin-bottom: -24px;
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
const Footer = styled(Flex)`
|
||||
export const Footer = styled(Flex)`
|
||||
height: 64px;
|
||||
border-top: 1px solid ${(props) => props.theme.horizontalRule};
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
`;
|
||||
|
||||
const StyledText = styled(Text)`
|
||||
export const StyledText = styled(Text)`
|
||||
${ellipsis()}
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
@@ -118,7 +118,6 @@ function Home() {
|
||||
const Documents = styled.div`
|
||||
position: relative;
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
`;
|
||||
|
||||
export default observer(Home);
|
||||
|
||||
@@ -231,7 +231,8 @@ function Login({ children }: Props) {
|
||||
config.providers.length === 1 &&
|
||||
config.providers[0].id === "oidc" &&
|
||||
!env.OIDC_DISABLE_REDIRECT &&
|
||||
!query.get("notice")
|
||||
!query.get("notice") &&
|
||||
!query.get("logout")
|
||||
) {
|
||||
window.location.href = getRedirectUrl(config.providers[0].authUrl);
|
||||
return null;
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import env from "~/env";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { logoutPath } from "~/utils/routeHelpers";
|
||||
|
||||
const Logout = () => {
|
||||
const { auth } = useStores();
|
||||
@@ -17,7 +18,7 @@ const Logout = () => {
|
||||
if (env.OIDC_LOGOUT_URI) {
|
||||
return null; // user will be redirected to logout URI after logout
|
||||
}
|
||||
return <Redirect to="/" />;
|
||||
return <Redirect to={logoutPath()} />;
|
||||
};
|
||||
|
||||
export default Logout;
|
||||
|
||||
@@ -59,7 +59,6 @@ const StyledInput = styled.input`
|
||||
outline: none;
|
||||
border: 0;
|
||||
background: ${s("sidebarBackground")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
border-radius: 4px;
|
||||
|
||||
color: ${s("text")};
|
||||
|
||||
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
|
||||
import { CodeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import ApiKey from "~/models/ApiKey";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
@@ -13,36 +12,17 @@ import Text from "~/components/Text";
|
||||
import { createApiKey } from "~/actions/definitions/apiKeys";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import ApiKeyListItem from "./components/ApiKeyListItem";
|
||||
|
||||
function ApiKeys() {
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const { apiKeys } = useStores();
|
||||
const can = usePolicy(team);
|
||||
const context = useActionContext();
|
||||
|
||||
const [copiedKeyId, setCopiedKeyId] = React.useState<string | null>();
|
||||
const copyTimeoutIdRef = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const handleCopy = React.useCallback(
|
||||
(keyId: string) => {
|
||||
if (copyTimeoutIdRef.current) {
|
||||
clearTimeout(copyTimeoutIdRef.current);
|
||||
}
|
||||
setCopiedKeyId(keyId);
|
||||
copyTimeoutIdRef.current = setTimeout(() => {
|
||||
setCopiedKeyId(null);
|
||||
}, 3000);
|
||||
toast.message(t("API key copied to clipboard"));
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={t("API")}
|
||||
@@ -62,12 +42,11 @@ function ApiKeys() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Heading>{t("API")}</Heading>
|
||||
<Heading>{t("API Keys")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Create personal API keys to authenticate with the API and programatically control
|
||||
your workspace's data. API keys have the same permissions as your user account.
|
||||
For more details see the <em>developer documentation</em>."
|
||||
defaults="API keys can be used to authenticate with the API and programatically control
|
||||
your workspace's data. For more details see the <em>developer documentation</em>."
|
||||
components={{
|
||||
em: (
|
||||
<a
|
||||
@@ -81,16 +60,10 @@ function ApiKeys() {
|
||||
</Text>
|
||||
<PaginatedList
|
||||
fetch={apiKeys.fetchPage}
|
||||
items={apiKeys.personalApiKeys}
|
||||
options={{ userId: user.id }}
|
||||
heading={<h2>{t("Personal keys")}</h2>}
|
||||
items={apiKeys.orderedData}
|
||||
heading={<h2>{t("All")}</h2>}
|
||||
renderItem={(apiKey: ApiKey) => (
|
||||
<ApiKeyListItem
|
||||
key={apiKey.id}
|
||||
apiKey={apiKey}
|
||||
isCopied={apiKey.id === copiedKeyId}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
|
||||
)}
|
||||
/>
|
||||
</Scene>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CodeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import ApiKey from "~/models/ApiKey";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import { createApiKey } from "~/actions/definitions/apiKeys";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import ApiKeyListItem from "./components/ApiKeyListItem";
|
||||
|
||||
function PersonalApiKeys() {
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const { apiKeys } = useStores();
|
||||
const can = usePolicy(team);
|
||||
const context = useActionContext();
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={t("API")}
|
||||
icon={<CodeIcon />}
|
||||
actions={
|
||||
<>
|
||||
{can.createApiKey && (
|
||||
<Action>
|
||||
<Button
|
||||
type="submit"
|
||||
value={`${t("New API key")}…`}
|
||||
action={createApiKey}
|
||||
context={context}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Heading>{t("API")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Create personal API keys to authenticate with the API and programatically control
|
||||
your workspace's data. API keys have the same permissions as your user account.
|
||||
For more details see the <em>developer documentation</em>."
|
||||
components={{
|
||||
em: (
|
||||
<a
|
||||
href="https://www.getoutline.com/developers"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<PaginatedList
|
||||
fetch={apiKeys.fetchPage}
|
||||
items={apiKeys.personalApiKeys}
|
||||
options={{ userId: user.id }}
|
||||
heading={<h2>{t("Personal keys")}</h2>}
|
||||
renderItem={(apiKey: ApiKey) => (
|
||||
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
|
||||
)}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(PersonalApiKeys);
|
||||
@@ -220,9 +220,6 @@ function Security() {
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{!data.inviteRequired && (
|
||||
<DomainManagement onSuccess={showSuccessMessage} />
|
||||
)}
|
||||
{!data.inviteRequired && (
|
||||
<SettingRow
|
||||
label={t("Default role")}
|
||||
@@ -252,6 +249,8 @@ function Security() {
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
<DomainManagement onSuccess={showSuccessMessage} />
|
||||
|
||||
<h2>{t("Behavior")}</h2>
|
||||
<SettingRow
|
||||
label={t("Public document sharing")}
|
||||
|
||||
@@ -12,7 +12,6 @@ export const ActionRow = styled.div`
|
||||
margin: 0 -50vw;
|
||||
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
|
||||
@supports (backdrop-filter: blur(20px)) {
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CopyIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import ApiKey from "~/models/ApiKey";
|
||||
import Button from "~/components/Button";
|
||||
import CopyToClipboard from "~/components/CopyToClipboard";
|
||||
@@ -8,24 +10,29 @@ import Flex from "~/components/Flex";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import ApiKeyMenu from "~/menus/ApiKeyMenu";
|
||||
import { dateToExpiry } from "~/utils/date";
|
||||
|
||||
type Props = {
|
||||
/** The API key to display */
|
||||
apiKey: ApiKey;
|
||||
isCopied: boolean;
|
||||
onCopy: (keyId: string) => void;
|
||||
};
|
||||
|
||||
const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
|
||||
const ApiKeyListItem = ({ apiKey }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const userLocale = useUserLocale();
|
||||
const user = useCurrentUser();
|
||||
|
||||
const subtitle = (
|
||||
<>
|
||||
<Text type="tertiary">
|
||||
{t(`Created`)} <Time dateTime={apiKey.createdAt} addSuffix /> ·{" "}
|
||||
{t(`Created`)} <Time dateTime={apiKey.createdAt} addSuffix />{" "}
|
||||
{apiKey.userId === user.id
|
||||
? ""
|
||||
: t(`by {{ name }}`, { name: user.name })}{" "}
|
||||
·{" "}
|
||||
</Text>
|
||||
{apiKey.lastActiveAt && (
|
||||
<Text type={"tertiary"}>
|
||||
@@ -41,9 +48,19 @@ const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
|
||||
</>
|
||||
);
|
||||
|
||||
const [copied, setCopied] = React.useState<boolean>(false);
|
||||
const copyTimeoutIdRef = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const handleCopy = React.useCallback(() => {
|
||||
onCopy(apiKey.id);
|
||||
}, [apiKey.id, onCopy]);
|
||||
if (copyTimeoutIdRef.current) {
|
||||
clearTimeout(copyTimeoutIdRef.current);
|
||||
}
|
||||
setCopied(true);
|
||||
copyTimeoutIdRef.current = setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 3000);
|
||||
toast.message(t("API key copied to clipboard"));
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
@@ -52,10 +69,10 @@ const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
<Flex align="center" gap={8}>
|
||||
{apiKey.value && (
|
||||
{apiKey.value && handleCopy && (
|
||||
<CopyToClipboard text={apiKey.value} onCopy={handleCopy}>
|
||||
<Button type="button" icon={<CopyIcon />} neutral borderOnHover>
|
||||
{isCopied ? t("Copied") : t("Copy")}
|
||||
{copied ? t("Copied") : t("Copy")}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
)}
|
||||
@@ -74,4 +91,4 @@ const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeyListItem;
|
||||
export default observer(ApiKeyListItem);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import Button from "~/components/Button";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -110,29 +109,25 @@ function DomainManagement({ onSuccess }: Props) {
|
||||
<Flex justify="space-between" gap={4} style={{ flexWrap: "wrap" }}>
|
||||
{!allowedDomains.length ||
|
||||
allowedDomains[allowedDomains.length - 1] !== "" ? (
|
||||
<Fade>
|
||||
<Button type="button" onClick={handleAddDomain} neutral>
|
||||
{allowedDomains.length ? (
|
||||
<Trans>Add another</Trans>
|
||||
) : (
|
||||
<Trans>Add a domain</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</Fade>
|
||||
<Button type="button" onClick={handleAddDomain} neutral>
|
||||
{allowedDomains.length ? (
|
||||
<Trans>Add another</Trans>
|
||||
) : (
|
||||
<Trans>Add a domain</Trans>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
|
||||
{showSaveChanges && (
|
||||
<Fade>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSaveDomains}
|
||||
disabled={team.isSaving}
|
||||
>
|
||||
<Trans>Save changes</Trans>
|
||||
</Button>
|
||||
</Fade>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSaveDomains}
|
||||
disabled={team.isSaving}
|
||||
>
|
||||
<Trans>Save changes</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</SettingRow>
|
||||
|
||||
@@ -150,20 +150,6 @@ export default class CommentsStore extends Store<Comment> {
|
||||
return this.data.get(res.data.id) as Comment;
|
||||
};
|
||||
|
||||
@action
|
||||
setTyping({
|
||||
commentId,
|
||||
userId,
|
||||
}: {
|
||||
commentId: string;
|
||||
userId: string;
|
||||
}): void {
|
||||
const comment = this.get(commentId);
|
||||
if (comment) {
|
||||
comment.typingUsers.set(userId, new Date());
|
||||
}
|
||||
}
|
||||
|
||||
@computed
|
||||
get orderedData(): Comment[] {
|
||||
return orderBy(Array.from(this.data.values()), "createdAt", "asc");
|
||||
|
||||
+5
-25
@@ -75,7 +75,7 @@ class UiStore {
|
||||
sidebarCollapsed = false;
|
||||
|
||||
@observable
|
||||
commentsExpanded: string[] = [];
|
||||
commentsExpanded = false;
|
||||
|
||||
@observable
|
||||
sidebarIsResizing = false;
|
||||
@@ -99,7 +99,7 @@ class UiStore {
|
||||
this.sidebarRightWidth =
|
||||
data.sidebarRightWidth || defaultTheme.sidebarRightWidth;
|
||||
this.tocVisible = data.tocVisible;
|
||||
this.commentsExpanded = data.commentsExpanded || [];
|
||||
this.commentsExpanded = !!data.commentsExpanded;
|
||||
this.theme = data.theme || Theme.System;
|
||||
|
||||
// system theme listeners
|
||||
@@ -142,9 +142,9 @@ class UiStore {
|
||||
startViewTransition(() => {
|
||||
flushSync(() => {
|
||||
this.theme = theme;
|
||||
this.persist();
|
||||
});
|
||||
});
|
||||
this.persist();
|
||||
};
|
||||
|
||||
@action
|
||||
@@ -218,28 +218,8 @@ class UiStore {
|
||||
};
|
||||
|
||||
@action
|
||||
collapseComments = (documentId: string) => {
|
||||
this.commentsExpanded = this.commentsExpanded.filter(
|
||||
(id) => id !== documentId
|
||||
);
|
||||
this.persist();
|
||||
};
|
||||
|
||||
@action
|
||||
expandComments = (documentId: string) => {
|
||||
if (!this.commentsExpanded.includes(documentId)) {
|
||||
this.commentsExpanded.push(documentId);
|
||||
}
|
||||
this.persist();
|
||||
};
|
||||
|
||||
@action
|
||||
toggleComments = (documentId: string) => {
|
||||
if (this.commentsExpanded.includes(documentId)) {
|
||||
this.collapseComments(documentId);
|
||||
} else {
|
||||
this.expandComments(documentId);
|
||||
}
|
||||
toggleComments = () => {
|
||||
this.set({ commentsExpanded: !this.commentsExpanded });
|
||||
};
|
||||
|
||||
@action
|
||||
|
||||
Vendored
-1
@@ -124,7 +124,6 @@ declare module "styled-components" {
|
||||
backgroundSecondary: string;
|
||||
backgroundTertiary: string;
|
||||
backgroundQuaternary: string;
|
||||
backgroundTransition: string;
|
||||
accent: string;
|
||||
accentText: string;
|
||||
link: string;
|
||||
|
||||
+1
-28
@@ -10,16 +10,7 @@ import {
|
||||
isPast,
|
||||
} from "date-fns";
|
||||
import { TFunction } from "i18next";
|
||||
import startCase from "lodash/startCase";
|
||||
import {
|
||||
getCurrentDateAsString,
|
||||
getCurrentDateTimeAsString,
|
||||
getCurrentTimeAsString,
|
||||
unicodeCLDRtoBCP47,
|
||||
dateLocale,
|
||||
locales,
|
||||
} from "@shared/utils/date";
|
||||
import User from "~/models/User";
|
||||
import { dateLocale, locales } from "@shared/utils/date";
|
||||
|
||||
export function dateToHeading(
|
||||
dateTime: string,
|
||||
@@ -121,21 +112,3 @@ export function dateToExpiry(
|
||||
date: formatDate(date, "MMM dd, yyyy", { locale }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces template variables in the given text with the current date and time.
|
||||
*
|
||||
* @param text The text to replace the variables in
|
||||
* @param user The user to get the language/locale from
|
||||
* @returns The text with the variables replaced
|
||||
*/
|
||||
export function replaceTitleVariables(text: string, user?: User) {
|
||||
const locales = user?.language
|
||||
? unicodeCLDRtoBCP47(user.language)
|
||||
: undefined;
|
||||
|
||||
return text
|
||||
.replace("{date}", startCase(getCurrentDateAsString(locales)))
|
||||
.replace("{time}", startCase(getCurrentTimeAsString(locales)))
|
||||
.replace("{datetime}", startCase(getCurrentDateTimeAsString(locales)));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,13 @@ export function homePath(): string {
|
||||
return env.ROOT_SHARE_ID ? "/" : "/home";
|
||||
}
|
||||
|
||||
export function logoutPath() {
|
||||
return {
|
||||
pathname: "/",
|
||||
search: "logout=true",
|
||||
};
|
||||
}
|
||||
|
||||
export function draftsPath(): string {
|
||||
return "/drafts";
|
||||
}
|
||||
|
||||
+2
-2
@@ -240,7 +240,7 @@
|
||||
"utility-types": "^3.10.0",
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "13.12.0",
|
||||
"vite": "^5.4.10",
|
||||
"vite": "^5.4.11",
|
||||
"vite-plugin-pwa": "^0.20.3",
|
||||
"winston": "^3.13.0",
|
||||
"ws": "^7.5.10",
|
||||
@@ -306,7 +306,7 @@
|
||||
"@types/react-table": "^7.7.18",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.4",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/readable-stream": "^4.0.15",
|
||||
"@types/readable-stream": "^4.0.18",
|
||||
"@types/redis-info": "^3.0.3",
|
||||
"@types/refractor": "^3.4.1",
|
||||
"@types/resolve-path": "^1.4.2",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Optional } from "utility-types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import { Document, Event, User } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { TextHelper } from "@server/models/helpers/TextHelper";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
type Props = Optional<
|
||||
|
||||
@@ -43,7 +43,7 @@ export default async function documentDuplicator({
|
||||
};
|
||||
|
||||
const duplicated = await documentCreator({
|
||||
parentDocumentId: parentDocumentId ?? document.parentDocumentId,
|
||||
parentDocumentId,
|
||||
icon: document.icon,
|
||||
color: document.color,
|
||||
template: document.template,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Event } from "@server/models";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import { withAPIContext } from "@server/test/support";
|
||||
import pinCreator from "./pinCreator";
|
||||
|
||||
describe("pinCreator", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should create pin to home", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
@@ -12,11 +11,13 @@ describe("pinCreator", () => {
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const pin = await pinCreator({
|
||||
documentId: document.id,
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
const pin = await withAPIContext(user, (ctx) =>
|
||||
pinCreator({
|
||||
ctx,
|
||||
user,
|
||||
documentId: document.id,
|
||||
})
|
||||
);
|
||||
|
||||
const event = await Event.findLatest({
|
||||
teamId: user.teamId,
|
||||
@@ -36,12 +37,14 @@ describe("pinCreator", () => {
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const pin = await pinCreator({
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
const pin = await withAPIContext(user, (ctx) =>
|
||||
pinCreator({
|
||||
ctx,
|
||||
user,
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
})
|
||||
);
|
||||
|
||||
const event = await Event.findLatest({
|
||||
teamId: user.teamId,
|
||||
|
||||
@@ -2,10 +2,12 @@ import fractionalIndex from "fractional-index";
|
||||
import { Sequelize, Op, WhereOptions } from "sequelize";
|
||||
import { PinValidation } from "@shared/validations";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import { Pin, User, Event } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { Pin, User } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
type Props = {
|
||||
/** The request context */
|
||||
ctx: APIContext;
|
||||
/** The user creating the pin */
|
||||
user: User;
|
||||
/** The document to pin */
|
||||
@@ -14,8 +16,6 @@ type Props = {
|
||||
collectionId?: string | null;
|
||||
/** The index to pin the document at. If no index is provided then it will be pinned to the end of the collection */
|
||||
index?: string;
|
||||
/** The IP address of the user creating the pin */
|
||||
ip: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -26,10 +26,10 @@ type Props = {
|
||||
* @returns Pin The pin that was created
|
||||
*/
|
||||
export default async function pinCreator({
|
||||
ctx,
|
||||
user,
|
||||
documentId,
|
||||
collectionId,
|
||||
ip,
|
||||
...rest
|
||||
}: Props): Promise<Pin> {
|
||||
let { index } = rest;
|
||||
@@ -62,38 +62,13 @@ export default async function pinCreator({
|
||||
index = fractionalIndex(pins.length ? pins[0].index : null, null);
|
||||
}
|
||||
|
||||
const transaction = await sequelize.transaction();
|
||||
let pin;
|
||||
|
||||
try {
|
||||
pin = await Pin.create(
|
||||
{
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId,
|
||||
documentId,
|
||||
index,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "pins.create",
|
||||
modelId: pin.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
documentId,
|
||||
collectionId,
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await transaction.commit();
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
throw err;
|
||||
}
|
||||
const pin = await Pin.createWithCtx(ctx, {
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId,
|
||||
documentId,
|
||||
index,
|
||||
});
|
||||
|
||||
return pin;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated use pin.destroyWithCtx instead. This will be removed once document routes migrate to auto event insertion using APIContext.
|
||||
*
|
||||
* This command destroys a document pin. This just removes the pin itself and
|
||||
* does not touch the document
|
||||
*
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Event, Pin, User } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
|
||||
type Props = {
|
||||
/** The user updating the pin */
|
||||
user: User;
|
||||
/** The existing pin */
|
||||
pin: Pin;
|
||||
/** The index to pin the document at */
|
||||
index: string;
|
||||
/** The IP address of the user creating the pin */
|
||||
ip: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* This command updates a "pinned" document. A pin can only be moved to a new
|
||||
* index (reordered) once created.
|
||||
*
|
||||
* @param Props The properties of the pin to update
|
||||
* @returns Pin The updated pin
|
||||
*/
|
||||
export default async function pinUpdater({
|
||||
user,
|
||||
pin,
|
||||
index,
|
||||
ip,
|
||||
}: Props): Promise<Pin> {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
pin.index = index;
|
||||
await pin.save({ transaction });
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "pins.update",
|
||||
modelId: pin.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
documentId: pin.documentId,
|
||||
collectionId: pin.collectionId,
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await transaction.commit();
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
throw err;
|
||||
}
|
||||
|
||||
return pin;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ const useTestEmailService = env.isDevelopment && !env.SMTP_USERNAME;
|
||||
|
||||
type SendMailOptions = {
|
||||
to: string;
|
||||
from?: EmailAddress | string;
|
||||
from: EmailAddress;
|
||||
replyTo?: string;
|
||||
messageId?: string;
|
||||
references?: string[];
|
||||
@@ -143,7 +143,7 @@ export class Mailer {
|
||||
Logger.info("email", `Sending email "${data.subject}" to ${data.to}`);
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: env.isCloudHosted && data.from ? data.from : env.SMTP_FROM_EMAIL,
|
||||
from: data.from,
|
||||
replyTo: data.replyTo ?? env.SMTP_REPLY_EMAIL ?? env.SMTP_FROM_EMAIL,
|
||||
to: data.to,
|
||||
messageId: data.messageId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import addressparser from "addressparser";
|
||||
import addressparser, { EmailAddress } from "addressparser";
|
||||
import Bull from "bull";
|
||||
import invariant from "invariant";
|
||||
import { Node } from "prosemirror-model";
|
||||
@@ -184,26 +184,22 @@ export default abstract class BaseEmail<
|
||||
}
|
||||
}
|
||||
|
||||
private from(props: S & T) {
|
||||
private from(props: S & T): EmailAddress {
|
||||
invariant(
|
||||
env.SMTP_FROM_EMAIL,
|
||||
"SMTP_FROM_EMAIL is required to send emails"
|
||||
);
|
||||
|
||||
const parsedFrom = addressparser(env.SMTP_FROM_EMAIL)[0];
|
||||
const name = this.fromName?.(props);
|
||||
|
||||
if (this.category === EmailMessageCategory.Authentication) {
|
||||
const domain = parsedFrom.address.split("@")[1];
|
||||
return {
|
||||
name: name ?? parsedFrom.name,
|
||||
address: `noreply-${randomstring.generate(24)}@${domain}`,
|
||||
};
|
||||
}
|
||||
const domain = parsedFrom.address.split("@")[1];
|
||||
|
||||
return {
|
||||
name: name ?? parsedFrom.name,
|
||||
address: parsedFrom.address,
|
||||
name: this.fromName?.(props) ?? parsedFrom.name,
|
||||
address:
|
||||
env.isCloudHosted &&
|
||||
this.category === EmailMessageCategory.Authentication
|
||||
? `noreply-${randomstring.generate(24)}@${domain}`
|
||||
: parsedFrom.address,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async transaction => {
|
||||
await queryInterface.addColumn("teams", "approximateTotalAttachmentsSize", {
|
||||
type: Sequelize.BIGINT,
|
||||
defaultValue: 0,
|
||||
}, { transaction });
|
||||
await queryInterface.addIndex("attachments", ["createdAt"], { transaction });
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async transaction => {
|
||||
await queryInterface.removeIndex("attachments", ["createdAt"], { transaction });
|
||||
await queryInterface.removeColumn("teams", "approximateTotalAttachmentsSize", { transaction });
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -20,6 +20,8 @@ class Pin extends IdModel<
|
||||
InferAttributes<Pin>,
|
||||
Partial<InferCreationAttributes<Pin>>
|
||||
> {
|
||||
static eventNamespace = "pins";
|
||||
|
||||
@Length({
|
||||
max: 256,
|
||||
msg: `index must be 256 characters or less`,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
AfterUpdate,
|
||||
BeforeUpdate,
|
||||
BeforeCreate,
|
||||
IsNumeric,
|
||||
} from "sequelize-typescript";
|
||||
import { TeamPreferenceDefaults } from "@shared/constants";
|
||||
import { TeamPreference, TeamPreferences, UserRole } from "@shared/types";
|
||||
@@ -151,6 +152,11 @@ class Team extends ParanoidModel<
|
||||
@Column(DataType.STRING)
|
||||
defaultUserRole: UserRole;
|
||||
|
||||
/** Approximate size in bytes of all attachments in the team. */
|
||||
@IsNumeric
|
||||
@Column(DataType.BIGINT)
|
||||
approximateTotalAttachmentsSize: number;
|
||||
|
||||
@AllowNull
|
||||
@Column(DataType.JSONB)
|
||||
preferences: TeamPreferences | null;
|
||||
|
||||
@@ -21,9 +21,7 @@ import { schema, parser } from "@server/editor";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { trace } from "@server/logging/tracing";
|
||||
import Attachment from "@server/models/Attachment";
|
||||
import User from "@server/models/User";
|
||||
import FileStorage from "@server/storage/files";
|
||||
import { TextHelper } from "./TextHelper";
|
||||
|
||||
export type HTMLOptions = {
|
||||
/** A title, if it should be included */
|
||||
@@ -264,29 +262,6 @@ export class ProsemirrorHelper {
|
||||
return removeMarksInner(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces all template variables in the node.
|
||||
*
|
||||
* @param data The ProsemirrorData object to replace variables in
|
||||
* @param user The user to use for replacing variables
|
||||
* @returns The content with variables replaced
|
||||
*/
|
||||
static replaceTemplateVariables(data: ProsemirrorData, user: User) {
|
||||
function replace(node: ProsemirrorData) {
|
||||
if (node.type === "text" && node.text) {
|
||||
node.text = TextHelper.replaceTemplateVariables(node.text, user);
|
||||
}
|
||||
|
||||
if (node.content) {
|
||||
node.content.forEach(replace);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
return replace(data);
|
||||
}
|
||||
|
||||
static async replaceInternalUrls(
|
||||
doc: Node | ProsemirrorData,
|
||||
basePath: string
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import chunk from "lodash/chunk";
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import startCase from "lodash/startCase";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import {
|
||||
getCurrentDateAsString,
|
||||
getCurrentDateTimeAsString,
|
||||
getCurrentTimeAsString,
|
||||
unicodeCLDRtoBCP47,
|
||||
} from "@shared/utils/date";
|
||||
import attachmentCreator from "@server/commands/attachmentCreator";
|
||||
import env from "@server/env";
|
||||
import { trace } from "@server/logging/tracing";
|
||||
@@ -19,25 +12,6 @@ import parseImages from "@server/utils/parseImages";
|
||||
|
||||
@trace()
|
||||
export class TextHelper {
|
||||
/**
|
||||
* Replaces template variables in the given text with the current date and time.
|
||||
*
|
||||
* @param text The text to replace the variables in
|
||||
* @param user The user to get the language/locale from
|
||||
* @returns The text with the variables replaced
|
||||
*/
|
||||
static replaceTemplateVariables(text: string, user: User) {
|
||||
const locales = user.language
|
||||
? unicodeCLDRtoBCP47(user.language)
|
||||
: undefined;
|
||||
|
||||
return text
|
||||
.replace(/{date}/g, startCase(getCurrentDateAsString(locales)))
|
||||
.replace(/{time}/g, startCase(getCurrentTimeAsString(locales)))
|
||||
.replace(/{datetime}/g, startCase(getCurrentDateTimeAsString(locales)))
|
||||
.replace(/{author}/g, user.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts attachment urls in documents to signed equivalents that allow
|
||||
* direct access without a session cookie
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { ApiKey, User, Team } from "@server/models";
|
||||
import { allow } from "./cancan";
|
||||
import { and, isOwner, isTeamModel, isTeamMutable } from "./utils";
|
||||
import {
|
||||
and,
|
||||
isCloudHosted,
|
||||
isOwner,
|
||||
isTeamModel,
|
||||
isTeamMutable,
|
||||
} from "./utils";
|
||||
|
||||
allow(User, "createApiKey", Team, (actor, team) =>
|
||||
and(
|
||||
@@ -18,6 +24,7 @@ allow(User, "createApiKey", Team, (actor, team) =>
|
||||
allow(User, "listApiKeys", Team, (actor, team) =>
|
||||
and(
|
||||
//
|
||||
isCloudHosted(),
|
||||
isTeamModel(actor, team),
|
||||
actor.isAdmin
|
||||
)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Attachment, Team } from "@server/models";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
|
||||
type Props = {
|
||||
/** The teamId to operate on */
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A task that updates the team stats.
|
||||
*/
|
||||
export default class UpdateTeamAttachmentsSizeTask extends BaseTask<Props> {
|
||||
public async perform({ teamId }: Props) {
|
||||
const sizeInBytes = await Attachment.getTotalSizeForTeam(teamId);
|
||||
if (!sizeInBytes) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Team.update(
|
||||
{ approximateTotalAttachmentsSize: sizeInBytes },
|
||||
{ where: { id: teamId } }
|
||||
);
|
||||
}
|
||||
|
||||
public get options() {
|
||||
return {
|
||||
attempts: 1,
|
||||
priority: TaskPriority.Background,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { subDays } from "date-fns";
|
||||
import { Op } from "sequelize";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Attachment } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import BaseTask, { TaskPriority, TaskSchedule } from "./BaseTask";
|
||||
import UpdateTeamAttachmentsSizeTask from "./UpdateTeamAttachmentsSizeTask";
|
||||
|
||||
type Props = {
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export default class UpdateTeamsAttachmentsSizeTask extends BaseTask<Props> {
|
||||
static cron = TaskSchedule.Daily;
|
||||
|
||||
public async perform({ limit }: Props) {
|
||||
Logger.info(
|
||||
"task",
|
||||
`Recalculating attachment sizes for upto ${limit} teams…`
|
||||
);
|
||||
|
||||
// Find unique attachment teamIds created in the last day, update only
|
||||
// those teams' approximate attachment sizes
|
||||
await Attachment.findAllInBatches<Attachment>(
|
||||
{
|
||||
attributes: [
|
||||
[sequelize.fn("DISTINCT", sequelize.col("teamId")), "teamId"],
|
||||
],
|
||||
where: {
|
||||
createdAt: {
|
||||
[Op.gt]: subDays(new Date(), 1),
|
||||
},
|
||||
},
|
||||
batchLimit: 100,
|
||||
raw: true,
|
||||
},
|
||||
async (rows) => {
|
||||
const teamIds = rows.map((row) => row.teamId);
|
||||
|
||||
for (const teamId of teamIds) {
|
||||
await UpdateTeamAttachmentsSizeTask.schedule({ teamId });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public get options() {
|
||||
return {
|
||||
attempts: 1,
|
||||
priority: TaskPriority.Background,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,9 @@ export const AttachmentsCreateSchema = BaseSchema.extend({
|
||||
contentType: z.string().optional().default("application/octet-stream"),
|
||||
|
||||
/** Attachment type */
|
||||
preset: z.nativeEnum(AttachmentPreset),
|
||||
preset: z
|
||||
.nativeEnum(AttachmentPreset)
|
||||
.default(AttachmentPreset.DocumentAttachment),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import Router from "koa-router";
|
||||
import { Sequelize, Op } from "sequelize";
|
||||
import { Sequelize, Op, Transaction } from "sequelize";
|
||||
import pinCreator from "@server/commands/pinCreator";
|
||||
import pinDestroyer from "@server/commands/pinDestroyer";
|
||||
import pinUpdater from "@server/commands/pinUpdater";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Collection, Document, Pin } from "@server/models";
|
||||
import { authorize } from "@server/policies";
|
||||
@@ -22,18 +21,21 @@ router.post(
|
||||
"pins.create",
|
||||
auth(),
|
||||
validate(T.PinsCreateSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.PinsCreateReq>) => {
|
||||
const { documentId, collectionId, index } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
const document = await Document.findByPk(documentId, {
|
||||
userId: user.id,
|
||||
transaction,
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
|
||||
if (collectionId) {
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId);
|
||||
}).findByPk(collectionId, { transaction });
|
||||
authorize(user, "update", collection);
|
||||
authorize(user, "pin", document);
|
||||
} else {
|
||||
@@ -41,10 +43,10 @@ router.post(
|
||||
}
|
||||
|
||||
const pin = await pinCreator({
|
||||
ctx,
|
||||
user,
|
||||
documentId,
|
||||
collectionId,
|
||||
ip: ctx.request.ip,
|
||||
index,
|
||||
});
|
||||
|
||||
@@ -108,13 +110,20 @@ router.post(
|
||||
"pins.update",
|
||||
auth(),
|
||||
validate(T.PinsUpdateSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.PinsUpdateReq>) => {
|
||||
const { id, index } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
let pin = await Pin.findByPk(id, { rejectOnEmpty: true });
|
||||
const { transaction } = ctx.state;
|
||||
const pin = await Pin.findByPk(id, {
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
const document = await Document.findByPk(pin.documentId, {
|
||||
userId: user.id,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (pin.collectionId) {
|
||||
@@ -123,12 +132,7 @@ router.post(
|
||||
authorize(user, "update", pin);
|
||||
}
|
||||
|
||||
pin = await pinUpdater({
|
||||
user,
|
||||
pin,
|
||||
ip: ctx.request.ip,
|
||||
index,
|
||||
});
|
||||
await pin.updateWithCtx(ctx, { index });
|
||||
|
||||
ctx.body = {
|
||||
data: presentPin(pin),
|
||||
@@ -141,14 +145,21 @@ router.post(
|
||||
"pins.delete",
|
||||
auth(),
|
||||
validate(T.PinsDeleteSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.PinsDeleteReq>) => {
|
||||
const { id } = ctx.input.body;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const { user } = ctx.state.auth;
|
||||
const pin = await Pin.findByPk(id, { rejectOnEmpty: true });
|
||||
const pin = await Pin.findByPk(id, {
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
const document = await Document.findByPk(pin.documentId, {
|
||||
userId: user.id,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (pin.collectionId) {
|
||||
@@ -157,7 +168,7 @@ router.post(
|
||||
authorize(user, "delete", pin);
|
||||
}
|
||||
|
||||
await pinDestroyer({ user, pin, ip: ctx.request.ip });
|
||||
await pin.destroyWithCtx(ctx);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
|
||||
@@ -145,14 +145,13 @@ export default class S3Storage extends BaseStorage {
|
||||
const params = {
|
||||
Bucket: this.getBucket(),
|
||||
Key: key,
|
||||
Expires: expiresIn,
|
||||
};
|
||||
|
||||
if (isDocker) {
|
||||
return `${this.getPublicEndpoint()}/${key}`;
|
||||
} else {
|
||||
const command = new GetObjectCommand(params);
|
||||
const url = await getSignedUrl(this.client, command);
|
||||
const url = await getSignedUrl(this.client, command, { expiresIn });
|
||||
|
||||
if (env.AWS_S3_ACCELERATE_URL) {
|
||||
return url.replace(
|
||||
@@ -231,6 +230,9 @@ export default class S3Storage extends BaseStorage {
|
||||
if (env.AWS_S3_UPLOAD_BUCKET_NAME) {
|
||||
const url = new URL(env.AWS_S3_UPLOAD_BUCKET_URL);
|
||||
if (url.hostname.startsWith(env.AWS_S3_UPLOAD_BUCKET_NAME + ".")) {
|
||||
Logger.warn(
|
||||
"AWS_S3_UPLOAD_BUCKET_URL contains the bucket name, this configuration combination will always point to AWS.\nRename your bucket or hostname if not using AWS S3.\nSee: https://github.com/outline/outline/issues/8025"
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import Redlock from "redlock";
|
||||
import Redis from "@server/storage/redis";
|
||||
|
||||
export class MutexLock {
|
||||
// Default expiry time for qcuiring lock in milliseconds
|
||||
public static defaultLockTimeout = 5000;
|
||||
// Default expiry time for acquiring lock in milliseconds
|
||||
public static defaultLockTimeout = 4000;
|
||||
|
||||
/**
|
||||
* Returns the redlock instance
|
||||
@@ -11,6 +11,8 @@ export class MutexLock {
|
||||
public static get lock(): Redlock {
|
||||
this.redlock ??= new Redlock([Redis.defaultClient], {
|
||||
retryJitter: 10,
|
||||
retryCount: 20,
|
||||
retryDelay: 200,
|
||||
});
|
||||
|
||||
return this.redlock;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getCookieDomain } from "@shared/utils/domains";
|
||||
import env from "@server/env";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Event, Collection, View } from "@server/models";
|
||||
import { AuthenticationResult } from "@server/types";
|
||||
import { AuthenticationResult, AuthenticationType } from "@server/types";
|
||||
|
||||
/**
|
||||
* Parse and return the details from the "sessions" cookie in the request, if
|
||||
@@ -68,6 +68,7 @@ export async function signIn(
|
||||
actorId: user.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
authType: AuthenticationType.APP,
|
||||
data: {
|
||||
name: user.name,
|
||||
service,
|
||||
|
||||
@@ -52,6 +52,8 @@ const mathStyle = (props: Props) => css`
|
||||
font-size: 0.95em;
|
||||
font-family: ${props.theme.fontFamilyMono};
|
||||
cursor: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.math-node.empty-math .math-render::before {
|
||||
@@ -701,7 +703,10 @@ img.ProseMirror-separator {
|
||||
}
|
||||
|
||||
.heading-name:first-child,
|
||||
.heading-name:first-child + .ProseMirror-yjs-cursor {
|
||||
// Edge case where multiplayer cursor is between start of cell and heading
|
||||
.heading-name:first-child + .ProseMirror-yjs-cursor,
|
||||
// Edge case where table grips are between start of cell and heading
|
||||
.heading-name:first-child + [role=button] + [role=button] {
|
||||
& + h1,
|
||||
& + h2,
|
||||
& + h3,
|
||||
@@ -1065,11 +1070,11 @@ a:hover {
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 0 0.1em 0 -26px;
|
||||
margin: 0 0.1em 0 ${props.staticHTML ? "0" : "-26px"};
|
||||
padding: 0 0 0 48px;
|
||||
|
||||
&:dir(rtl) {
|
||||
margin: 0 -26px 0 0.1em;
|
||||
margin: 0 ${props.staticHTML ? "0" : "-26px"} 0 0.1em;
|
||||
padding: 0 48px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { history, undo, redo } from "prosemirror-history";
|
||||
import { undoInputRule } from "prosemirror-inputrules";
|
||||
import Extension from "../lib/Extension";
|
||||
import { Command } from "prosemirror-state";
|
||||
import Extension, { CommandFactory } from "../lib/Extension";
|
||||
|
||||
export default class History extends Extension {
|
||||
get name() {
|
||||
return "history";
|
||||
}
|
||||
|
||||
keys() {
|
||||
commands(): Record<string, CommandFactory> {
|
||||
return {
|
||||
"Mod-z": undo,
|
||||
"Mod-y": redo,
|
||||
"Shift-Mod-z": redo,
|
||||
undo: () => undo,
|
||||
redo: () => redo,
|
||||
};
|
||||
}
|
||||
|
||||
keys(): Record<string, Command | CommandFactory> {
|
||||
return {
|
||||
"Mod-z": () => this.editor.commands.undo(),
|
||||
"Mod-y": () => this.editor.commands.redo(),
|
||||
"Shift-Mod-z": () => this.editor.commands.redo(),
|
||||
Backspace: undoInputRule,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,9 +5,7 @@ import { Command, Plugin } from "prosemirror-state";
|
||||
import { Primitive } from "utility-types";
|
||||
import type { Editor } from "../../../app/editor";
|
||||
|
||||
export type CommandFactory = (
|
||||
attrs?: Record<string, Primitive>
|
||||
) => Command | void;
|
||||
export type CommandFactory = (attrs?: Record<string, Primitive>) => Command;
|
||||
|
||||
export type WidgetProps = { rtl: boolean; readOnly: boolean | undefined };
|
||||
|
||||
@@ -73,7 +71,7 @@ export default class Extension {
|
||||
keys(_options: {
|
||||
type?: NodeType | MarkType;
|
||||
schema: Schema;
|
||||
}): Record<string, Command> {
|
||||
}): Record<string, Command | CommandFactory> {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
@@ -251,13 +251,13 @@ export default class ExtensionManager {
|
||||
};
|
||||
|
||||
const handle = (_name: string, _value: CommandFactory) => {
|
||||
if (Array.isArray(_value)) {
|
||||
commands[_name] = (attrs: Record<string, Primitive>) =>
|
||||
_value.forEach((callback) => apply(callback, attrs));
|
||||
} else if (typeof _value === "function") {
|
||||
commands[_name] = ((attrs: Record<string, Primitive>) =>
|
||||
apply(_value, attrs)) as CommandFactory;
|
||||
}
|
||||
const values: CommandFactory[] = Array.isArray(_value)
|
||||
? _value
|
||||
: [_value];
|
||||
|
||||
// @ts-expect-error FIXME
|
||||
commands[_name] = (attrs: Record<string, Primitive>) =>
|
||||
values.forEach((callback) => apply(callback, attrs));
|
||||
};
|
||||
|
||||
if (typeof value === "object") {
|
||||
|
||||
@@ -308,6 +308,7 @@
|
||||
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} and {{ count }} other reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}_plural": "{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}",
|
||||
"Add reaction": "Add reaction",
|
||||
"Reaction picker": "Reaction picker",
|
||||
"Could not load reactions": "Could not load reactions",
|
||||
"Reaction": "Reaction",
|
||||
@@ -601,6 +602,8 @@
|
||||
"Most recent": "Most recent",
|
||||
"Order in doc": "Order in doc",
|
||||
"Resolved": "Resolved",
|
||||
"Show {{ count }} reply": "Show {{ count }} reply",
|
||||
"Show {{ count }} reply_plural": "Show {{ count }} replies",
|
||||
"Error updating comment": "Chyba při aktualizaci komentáře",
|
||||
"Document restored": "Dokument obnoven",
|
||||
"Images are still uploading.\nAre you sure you want to discard them?": "Obrázky se stále nahrávají.\nOpravdu je chcete zahodit?",
|
||||
|
||||
@@ -308,6 +308,7 @@
|
||||
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} and {{ count }} other reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}_plural": "{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}",
|
||||
"Add reaction": "Add reaction",
|
||||
"Reaction picker": "Reaction picker",
|
||||
"Could not load reactions": "Could not load reactions",
|
||||
"Reaction": "Reaction",
|
||||
@@ -601,6 +602,8 @@
|
||||
"Most recent": "Most recent",
|
||||
"Order in doc": "Order in doc",
|
||||
"Resolved": "Resolved",
|
||||
"Show {{ count }} reply": "Show {{ count }} reply",
|
||||
"Show {{ count }} reply_plural": "Show {{ count }} replies",
|
||||
"Error updating comment": "Error updating comment",
|
||||
"Document restored": "Document restored",
|
||||
"Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?",
|
||||
|
||||
@@ -308,6 +308,7 @@
|
||||
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} and {{ count }} other reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}_plural": "{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}",
|
||||
"Add reaction": "Add reaction",
|
||||
"Reaction picker": "Reaction picker",
|
||||
"Could not load reactions": "Could not load reactions",
|
||||
"Reaction": "Reaction",
|
||||
@@ -601,6 +602,8 @@
|
||||
"Most recent": "Most recent",
|
||||
"Order in doc": "Order in doc",
|
||||
"Resolved": "Resolved",
|
||||
"Show {{ count }} reply": "Show {{ count }} reply",
|
||||
"Show {{ count }} reply_plural": "Show {{ count }} replies",
|
||||
"Error updating comment": "Fehler beim Aktualisieren des Kommentars",
|
||||
"Document restored": "Dokument wiederhergestellt",
|
||||
"Images are still uploading.\nAre you sure you want to discard them?": "Bilder werden noch hochgeladen.\nMöchtest du sie wirklich verwerfen?",
|
||||
|
||||
@@ -196,6 +196,11 @@
|
||||
"Install now": "Install now",
|
||||
"Deleted Collection": "Deleted Collection",
|
||||
"Unpin": "Unpin",
|
||||
"Select a location to copy": "Select a location to copy",
|
||||
"Document copied": "Document copied",
|
||||
"Couldn’t copy the document, try again?": "Couldn’t copy the document, try again?",
|
||||
"Include nested documents": "Include nested documents",
|
||||
"Copy to <em>{{ location }}</em>": "Copy to <em>{{ location }}</em>",
|
||||
"Search collections & documents": "Search collections & documents",
|
||||
"No results found": "No results found",
|
||||
"Untitled": "Untitled",
|
||||
@@ -227,9 +232,6 @@
|
||||
"Currently editing": "Currently editing",
|
||||
"Currently viewing": "Currently viewing",
|
||||
"Viewed {{ timeAgo }}": "Viewed {{ timeAgo }}",
|
||||
"Copy of {{ documentName }}": "Copy of {{ documentName }}",
|
||||
"Title": "Title",
|
||||
"Include nested documents": "Include nested documents",
|
||||
"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.",
|
||||
@@ -495,7 +497,7 @@
|
||||
"Could not import file": "Could not import file",
|
||||
"Unsubscribed from document": "Unsubscribed from document",
|
||||
"Account": "Account",
|
||||
"API": "API",
|
||||
"API Keys": "API Keys",
|
||||
"Details": "Details",
|
||||
"Security": "Security",
|
||||
"Features": "Features",
|
||||
@@ -602,6 +604,8 @@
|
||||
"Most recent": "Most recent",
|
||||
"Order in doc": "Order in doc",
|
||||
"Resolved": "Resolved",
|
||||
"Show {{ count }} reply": "Show {{ count }} reply",
|
||||
"Show {{ count }} reply_plural": "Show {{ count }} replies",
|
||||
"Error updating comment": "Error updating comment",
|
||||
"Document restored": "Document restored",
|
||||
"Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?",
|
||||
@@ -825,11 +829,12 @@
|
||||
"Something went wrong": "Something went wrong",
|
||||
"Please try again or contact support if the problem persists": "Please try again or contact support if the problem persists",
|
||||
"No documents found for your search filters.": "No documents found for your search filters.",
|
||||
"API key copied to clipboard": "API key copied to clipboard",
|
||||
"Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.": "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.",
|
||||
"Personal keys": "Personal keys",
|
||||
"API": "API",
|
||||
"API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.": "API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.",
|
||||
"by {{ name }}": "by {{ name }}",
|
||||
"Last used": "Last used",
|
||||
"No expiry": "No expiry",
|
||||
"API key copied to clipboard": "API key copied to clipboard",
|
||||
"Copied": "Copied",
|
||||
"Revoking": "Revoking",
|
||||
"Are you sure you want to revoke the {{ tokenName }} token?": "Are you sure you want to revoke the {{ tokenName }} token?",
|
||||
@@ -954,6 +959,8 @@
|
||||
"Email address": "Email address",
|
||||
"Your email address should be updated in your SSO provider.": "Your email address should be updated in your SSO provider.",
|
||||
"The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.": "The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.",
|
||||
"Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.": "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.",
|
||||
"Personal keys": "Personal keys",
|
||||
"Preferences saved": "Preferences saved",
|
||||
"Delete account": "Delete account",
|
||||
"Manage settings that affect your personal experience.": "Manage settings that affect your personal experience.",
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
"Star": "Marcar como favorito",
|
||||
"Unstar": "Eliminar de favoritos",
|
||||
"Archive": "Archivar",
|
||||
"Archive collection": "Archive collection",
|
||||
"Collection archived": "Collection archived",
|
||||
"Archive collection": "Archivar colección",
|
||||
"Collection archived": "Colección archivada",
|
||||
"Archiving": "Archivando",
|
||||
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.": "Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.",
|
||||
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.": "Archivar esta colección también archivará todos los documentos dentro de ella. Los documentos de la colección dejarán de ser visibles en los resultados de búsqueda.",
|
||||
"Restore": "Restaurar",
|
||||
"Collection restored": "Collection restored",
|
||||
"Collection restored": "Colección restaurada",
|
||||
"Delete": "Eliminar",
|
||||
"Delete collection": "Eliminar colección",
|
||||
"New template": "Nueva plantilla",
|
||||
@@ -25,8 +25,8 @@
|
||||
"Mark as resolved": "Marcar como resuelto",
|
||||
"Thread resolved": "Hilo resuelto",
|
||||
"Mark as unresolved": "Marcar como no resuelto",
|
||||
"View reactions": "View reactions",
|
||||
"Reactions": "Reactions",
|
||||
"View reactions": "Ver reacciones",
|
||||
"Reactions": "Reacciones",
|
||||
"Copy ID": "Copiar ID",
|
||||
"Clear IndexedDB cache": "Borrar caché de IndexedDB",
|
||||
"IndexedDB cache cleared": "Caché de IndexedDB borrado",
|
||||
@@ -94,9 +94,9 @@
|
||||
"Insights": "Estadísticas",
|
||||
"Disable viewer insights": "Deshabilitar estadísticas",
|
||||
"Enable viewer insights": "Habilitar estadísticas",
|
||||
"Leave document": "Leave document",
|
||||
"You have left the shared document": "You have left the shared document",
|
||||
"Could not leave document": "Could not leave document",
|
||||
"Leave document": "Abandonar documento",
|
||||
"You have left the shared document": "Has abandonado el documento compartido",
|
||||
"Could not leave document": "No se pudo abandonar el documento",
|
||||
"Home": "Inicio",
|
||||
"Drafts": "Borradores",
|
||||
"Trash": "Papelera",
|
||||
@@ -128,9 +128,9 @@
|
||||
"Select a workspace": "Seleccionar un espacio de trabajo",
|
||||
"New workspace": "Nuevo espacio de trabajo",
|
||||
"Create a workspace": "Crear un espacio de trabajo",
|
||||
"Login to workspace": "Iniciar sesión en el área de trabajo",
|
||||
"Login to workspace": "Iniciar sesión al espacio de trabajo",
|
||||
"Invite people": "Invitar personas",
|
||||
"Invite to workspace": "Invitar a área de trabajo",
|
||||
"Invite to workspace": "Invitar al espacio de trabajo",
|
||||
"Promote to {{ role }}": "Promocionar a {{ role }}",
|
||||
"Demote to {{ role }}": "Degradar a {{ role }}",
|
||||
"Update role": "Actualizar rol",
|
||||
@@ -173,7 +173,7 @@
|
||||
"Are you sure you want to permanently delete this entire comment thread?": "¿Estás seguro de que quieres eliminar permanentemente todo este hilo de comentarios?",
|
||||
"Are you sure you want to permanently delete this comment?": "¿Estás seguro de que quieres eliminar este comentario permanentemente?",
|
||||
"Confirm": "Confirmar",
|
||||
"manage access": "manage access",
|
||||
"manage access": "administrar acceso",
|
||||
"view and edit access": "acceso de lectura y edición",
|
||||
"view only access": "acceso de solo lectura",
|
||||
"no access": "sin acceso",
|
||||
@@ -244,7 +244,7 @@
|
||||
"{{userName}} restored": "{{userName}} ha restaurado",
|
||||
"{{userName}} deleted": "{{userName}} ha eliminado",
|
||||
"{{userName}} added {{addedUserName}}": "{{userName}} añadió {{addedUserName}}",
|
||||
"{{userName}} removed {{removedUserName}}": "{{userName}} eliminó {{removedUserName}}",
|
||||
"{{userName}} removed {{removedUserName}}": "{{userName}} eliminó a {{removedUserName}}",
|
||||
"{{userName}} moved from trash": "{{userName}} ha movido de la papelera",
|
||||
"{{userName}} published": "{{userName}} ha publicado",
|
||||
"{{userName}} unpublished": "{{userName}} ha despublicado",
|
||||
@@ -268,15 +268,15 @@
|
||||
"{{authorName}} created <3></3>": "{{authorName}} ha creado <3></3>",
|
||||
"{{authorName}} opened <3></3>": "{{authorName}} abrió <3></3>",
|
||||
"Search emoji": "Buscar emoji",
|
||||
"Search icons": "Buscar iconos",
|
||||
"Search icons": "Buscar íconos",
|
||||
"Choose default skin tone": "Elige un tono de piel predeterminado",
|
||||
"Show menu": "Mostrar menú",
|
||||
"Icon Picker": "Selector de icono",
|
||||
"Icons": "Iconos",
|
||||
"Icon Picker": "Selector de ícono",
|
||||
"Icons": "Íconos",
|
||||
"Emojis": "Emojis",
|
||||
"Remove": "Eliminar",
|
||||
"All": "Todos",
|
||||
"Frequently Used": "Usados con frecuencia",
|
||||
"Frequently Used": "Usado Frecuentemente",
|
||||
"Search Results": "Resultados de Búsqueda",
|
||||
"Smileys & People": "Smileys y Personas",
|
||||
"Animals & Nature": "Animales y Naturaleza",
|
||||
@@ -304,18 +304,19 @@
|
||||
"Mark all as read": "Marcar todas como leídas",
|
||||
"You're all caught up": "Estás al día",
|
||||
"Documents": "Documentos",
|
||||
"{{ username }} reacted with {{ emoji }}": "{{ username }} reacted with {{ emoji }}",
|
||||
"{{ username }} reacted with {{ emoji }}": "{{ username }} reaccionó con {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} and {{ count }} other reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}_plural": "{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}",
|
||||
"Reaction picker": "Reaction picker",
|
||||
"Could not load reactions": "Could not load reactions",
|
||||
"Reaction": "Reaction",
|
||||
"Add reaction": "Add reaction",
|
||||
"Reaction picker": "Selector de reacción",
|
||||
"Could not load reactions": "No se pudo cargar las reacciones",
|
||||
"Reaction": "Reacción",
|
||||
"Results": "Resultados",
|
||||
"No results for {{query}}": "Sin resultados para {{query}}",
|
||||
"Manage": "Administrar",
|
||||
"All members": "Todos los miembros",
|
||||
"Everyone in the workspace": "Todos en el área de trabajo",
|
||||
"Everyone in the workspace": "Todos en el espacio de trabajo",
|
||||
"Invite": "Invitar",
|
||||
"{{ userName }} was added to the collection": "{{ userName }} fue agregado a la colección",
|
||||
"{{ count }} people added to the collection": "{{ count }} persona añadida a la colección",
|
||||
@@ -326,14 +327,14 @@
|
||||
"Add or invite": "Añadir o invitar",
|
||||
"Viewer": "Lector",
|
||||
"Editor": "Editor",
|
||||
"Suggestions for invitation": "Sugerencias para la invitación",
|
||||
"Suggestions for invitation": "Sugerencias para invitar",
|
||||
"No matches": "No hay coincidencias",
|
||||
"Can view": "Puede visualizar",
|
||||
"Everyone in the collection": "Todos en la colección",
|
||||
"You have full access": "Tienes acceso total",
|
||||
"Created the document": "Documento creado",
|
||||
"Other people": "Otras personas",
|
||||
"Other workspace members may have access": "Otros miembros del área de trabajo pueden tener acceso",
|
||||
"Other workspace members may have access": "Otros miembros del espacio de trabajo pueden tener acceso",
|
||||
"This document may be shared with more workspace members through a parent document or collection you do not have access to": "Este documento puede ser compartido con más miembros del área de trabajo a través de un documento padre o colección a la que no tienes acceso",
|
||||
"Access inherited from collection": "Acceso heredado de la colección",
|
||||
"{{ userName }} was removed from the document": "{{ userName }} fue quitado del documento",
|
||||
@@ -353,7 +354,7 @@
|
||||
"Anyone with the link can access because the parent document, <2>{{documentTitle}}</2>, is shared": "Cualquiera con el enlace puede acceder porque el documento padre, <2>{{documentTitle}}</2>, es compartido",
|
||||
"Allow anyone with the link to access": "Permitir acceso a cualquiera con el enlace",
|
||||
"Publish to internet": "Publicar en Internet",
|
||||
"Search engine indexing": "Search engine indexing",
|
||||
"Search engine indexing": "Indexación del motor de búsqueda",
|
||||
"Disable this setting to discourage search engines from indexing the page": "Disable this setting to discourage search engines from indexing the page",
|
||||
"Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future": "Los documentos anidados no son compartidos en la web. Cambia las reglas de compartir para habilitar el acceso, este será el comportamiento predeterminado en el futuro",
|
||||
"{{ userName }} was added to the document": "{{ userName }} ha sido añadido al documento",
|
||||
@@ -362,8 +363,8 @@
|
||||
"{{ count }} groups added to the document": "{{ count }} grupo invitado al documento",
|
||||
"{{ count }} groups added to the document_plural": "{{ count }} grupos invitados al documento",
|
||||
"Logo": "Logo",
|
||||
"Archived collections": "Archived collections",
|
||||
"Change permissions?": "Change permissions?",
|
||||
"Archived collections": "Colecciones archivadas",
|
||||
"Change permissions?": "¿Cambiar permisos?",
|
||||
"New doc": "Nuevo doc",
|
||||
"You can't reorder documents in an alphabetically sorted collection": "No puedes reordenar documentos en una colección ordenada alfabéticamente",
|
||||
"Empty": "Vacío",
|
||||
@@ -419,7 +420,7 @@
|
||||
"Align center": "Centrar",
|
||||
"Align left": "Alinear a la izquierda",
|
||||
"Align right": "Alinear a la derecha",
|
||||
"Default width": "Ancho por Defecto",
|
||||
"Default width": "Ancho predeterminado",
|
||||
"Full width": "Ancho total",
|
||||
"Bulleted list": "Lista con viñetas",
|
||||
"Todo list": "Lista de tareas",
|
||||
@@ -430,7 +431,7 @@
|
||||
"Create link": "Nuevo enlace",
|
||||
"Sorry, an error occurred creating the link": "Lo sentimos, se ha producido un error al crear el enlace",
|
||||
"Create a new doc": "Crea un nuevo documento",
|
||||
"Create a new child doc": "Crea un nuevo documento hijo",
|
||||
"Create a new child doc": "Crear un nuevo documento hijo",
|
||||
"Delete table": "Eliminar tabla",
|
||||
"Delete file": "Eliminar el archivo",
|
||||
"Download file": "Descargar el archivo",
|
||||
@@ -471,11 +472,11 @@
|
||||
"Strikethrough": "Tachado",
|
||||
"Bold": "Negrita",
|
||||
"Subheading": "Sub-encabezado",
|
||||
"Sort ascending": "Orden ascendente",
|
||||
"Sort descending": "Orden descendiente",
|
||||
"Sort ascending": "Ordenar ascendentemente",
|
||||
"Sort descending": "Ordenar descendientemente",
|
||||
"Table": "Tabla",
|
||||
"Export as CSV": "Export as CSV",
|
||||
"Toggle header": "Conmutar cabecera",
|
||||
"Export as CSV": "Exportar como CSV",
|
||||
"Toggle header": "Mostrar/Ocultar cabecera",
|
||||
"Math inline (LaTeX)": "Fórmula matemática en línea (LaTeX)",
|
||||
"Math block (LaTeX)": "Bloque de matemáticas (LaTeX)",
|
||||
"Tip": "Sugerencia",
|
||||
@@ -512,11 +513,11 @@
|
||||
"Export collection": "Exportar colección",
|
||||
"Rename": "Renombrar",
|
||||
"Sort in sidebar": "Ordenar en barra lateral",
|
||||
"A-Z sort": "A-Z sort",
|
||||
"Z-A sort": "Z-A sort",
|
||||
"A-Z sort": "Ordenado A-Z",
|
||||
"Z-A sort": "Ordenado Z-A",
|
||||
"Manual sort": "Orden manual",
|
||||
"Comment options": "Opciones de los comentarios",
|
||||
"Show document menu": "Mostrar menú de documento",
|
||||
"Show document menu": "Mostrar menú del documento",
|
||||
"{{ documentName }} restored": "{{ documentName }} restaurado",
|
||||
"Document options": "Opciones del documento",
|
||||
"Choose a collection": "Elige una colección",
|
||||
@@ -559,14 +560,14 @@
|
||||
"Choose a date": "Elige una fecha",
|
||||
"API key created. Please copy the value now as it will not be shown again.": "API key created. Please copy the value now as it will not be shown again.",
|
||||
"Name your key something that will help you to remember it's use in the future, for example \"local development\" or \"continuous integration\".": "Dale a tu token un nombre que te permita recordar su uso en el futuro, por ejemplo \"desarrollo local\", \"producción\" o \"integración continua\".",
|
||||
"Expiration": "Caducidad",
|
||||
"Never expires": "Nunca caduca",
|
||||
"Expiration": "Expiración",
|
||||
"Never expires": "Nunca expira",
|
||||
"7 days": "7 días",
|
||||
"30 days": "30 días",
|
||||
"60 days": "60 días",
|
||||
"90 days": "90 días",
|
||||
"Custom": "Personalizado",
|
||||
"No expiration": "Sin caducidad",
|
||||
"No expiration": "Sin expiración",
|
||||
"The document archive is empty at the moment.": "El archivo está vacío en este momento.",
|
||||
"Collection menu": "Menú de la colección",
|
||||
"Drop documents to import": "Arrastra los documentos para importar",
|
||||
@@ -596,11 +597,13 @@
|
||||
"Upload image": "Subir una imagen",
|
||||
"No resolved comments": "No hay comentarios resueltos",
|
||||
"No comments yet": "Aún no hay comentarios",
|
||||
"New comments": "New comments",
|
||||
"Sort comments": "Sort comments",
|
||||
"Most recent": "Most recent",
|
||||
"New comments": "Nuevos comentarios",
|
||||
"Sort comments": "Ordenar comentarios",
|
||||
"Most recent": "Más reciente",
|
||||
"Order in doc": "Order in doc",
|
||||
"Resolved": "Resolved",
|
||||
"Resolved": "Resuelto",
|
||||
"Show {{ count }} reply": "Mostrar {{ count }} respuesta",
|
||||
"Show {{ count }} reply_plural": "Mostrar {{ count }} respuestas",
|
||||
"Error updating comment": "Error actualizando el comentario",
|
||||
"Document restored": "Documento restaurado",
|
||||
"Images are still uploading.\nAre you sure you want to discard them?": "Las imágenes aún se están cargando.\n¿Estás seguro de que quieres descartarlas?",
|
||||
@@ -719,7 +722,7 @@
|
||||
"Can manage all workspace settings": "Puede administrar todos los ajustes del área de trabajo",
|
||||
"Can create, edit, and delete documents": "Puede crear, editar y eliminar documentos",
|
||||
"Can view and comment": "Puede ver y comentar",
|
||||
"Invite people to join your workspace. They can sign in with {{signinMethods}} or use their email address.": "Invita personas a unirse a tu área de trabajo. Pueden iniciar sesión con {{signinMethods}} o usar su dirección de correo electrónico.",
|
||||
"Invite people to join your workspace. They can sign in with {{signinMethods}} or use their email address.": "Invita personas a unirse a tu espacio de trabajo. Pueden iniciar sesión con {{signinMethods}} o utilizar su dirección de correo electrónico.",
|
||||
"Invite members to join your workspace. They will need to sign in with {{signinMethods}}.": "Invita a miembros del equipo o invitados a unirse a tu espacio de trabajo. Ellos deberán iniciar sesión con {{signinMethods}}.",
|
||||
"As an admin you can also <2>enable email sign-in</2>.": "Como administrador, también puedes <2>habilitar el inicio de sesión por correo electrónico</2>.",
|
||||
"Invite as": "Invitar como",
|
||||
@@ -759,7 +762,7 @@
|
||||
"Move list item down": "Mover elemento de la lista abajo",
|
||||
"Tables": "Tablas",
|
||||
"Insert row": "Insertar fila",
|
||||
"Next cell": "Siguiente celda",
|
||||
"Next cell": "Celda siguiente",
|
||||
"Previous cell": "Celda anterior",
|
||||
"Space": "Espacio",
|
||||
"Numbered list": "Lista numerada",
|
||||
@@ -966,7 +969,7 @@
|
||||
"When enabled, documents have a separate editing mode. When disabled, documents are always editable when you have permission.": "Cuando está activada, los documentos tienen un modo de edición separada. Cuando está desactivada, los documentos son siempre editables si tienes permiso de edición.",
|
||||
"Remember previous location": "Recordar ubicación anterior",
|
||||
"Automatically return to the document you were last viewing when the app is re-opened.": "Volver automáticamente al documento que estabas viendo cuando la aplicación se vuelva a abrir.",
|
||||
"Smart text replacements": "Smart text replacements",
|
||||
"Smart text replacements": "Sustituciones inteligentes de texto",
|
||||
"Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.": "Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.",
|
||||
"You may delete your account at any time, note that this is unrecoverable": "Puedes eliminar tu cuenta en cualquier momento, ten en cuenta que esta es una operación irreversible",
|
||||
"Profile saved": "Perfil guardado",
|
||||
@@ -1032,11 +1035,11 @@
|
||||
"This month": "Este mes",
|
||||
"Last month": "El mes pasado",
|
||||
"This year": "Este año",
|
||||
"Expired yesterday": "Caducó ayer",
|
||||
"Expired {{ date }}": "Caducó {{ date }}",
|
||||
"Expires today": "Caduca hoy",
|
||||
"Expires tomorrow": "Caduca mañana",
|
||||
"Expires {{ date }}": "Caduca el {{ date }}",
|
||||
"Expired yesterday": "Expiró ayer",
|
||||
"Expired {{ date }}": "Expiró el {{ date }}",
|
||||
"Expires today": "Expira hoy",
|
||||
"Expires tomorrow": "Expira mañana",
|
||||
"Expires {{ date }}": "Expira el {{ date }}",
|
||||
"Connect": "Conectar",
|
||||
"Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. Try again?": "Ups, necesitas aceptar los permisos en GitHub para conectar {{appName}} a tu área de trabajo. ¿Intentar de nuevo?",
|
||||
"Something went wrong while authenticating your request. Please try logging in again.": "Ocurrió un error autenticando tu solicitud. Por favor, intenta iniciar sesión de nuevo.",
|
||||
@@ -1044,15 +1047,15 @@
|
||||
"Enable previews of GitHub issues and pull requests in documents by connecting a GitHub organization or specific repositories to {appName}.": "Habilitar vistas previas de incidencias de GitHub y pull requests en documentos conectando una organización de GitHub o repositorios específicos a {appName}.",
|
||||
"Enabled by {{integrationCreatedBy}}": "Habilitado por {{integrationCreatedBy}}",
|
||||
"Disconnecting will prevent previewing GitHub links from this organization in documents. Are you sure?": "Desconectar impedirá previsualizar los enlaces de GitHub de esta organización en documentos. ¿Estás seguro?",
|
||||
"The GitHub integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "La integración de GitHub está actualmente desactivada. Configura las variables de entorno asociadas y reinicia el servidor para habilitar la integración.",
|
||||
"The GitHub integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "La integración de GitHub está desactivada. Configura las variables de entorno asociadas y reinicia el servidor para habilitar la integración.",
|
||||
"Google Analytics": "Google Analytics",
|
||||
"Add a Google Analytics 4 measurement ID to send document views and analytics from the workspace to your own Google Analytics account.": "Añade un ID de seguimiento de Google Analytics 4 para enviar vistas y análisis de documentos desde el espacio de trabajo a tu propia cuenta de Google Analytics.",
|
||||
"Measurement ID": "ID de seguimiento",
|
||||
"Create a \"Web\" stream in your Google Analytics admin dashboard and copy the measurement ID from the generated code snippet to install.": "Crea un flujo \"Web\" en tu panel de administración de Google Analytics y copia el ID de seguimiento del fragmento de código generado para instalar.",
|
||||
"Configure a Matomo installation to send views and analytics from the workspace to your own Matomo instance.": "Configura una instalación de Matomo para enviar vistas y análisis desde el espacio de trabajo a tu propia instancia de Matomo.",
|
||||
"Configure a Matomo installation to send views and analytics from the workspace to your own Matomo instance.": "Configura una instalación de Matomo para enviar vistas y analíticas desde el espacio de trabajo a tu propia instancia de Matomo.",
|
||||
"Instance URL": "URL de la instancia",
|
||||
"The URL of your Matomo instance. If you are using Matomo Cloud it will end in matomo.cloud/": "La URL de tu instancia Matomo. Si estás usando Matomo Cloud terminará en matomo.cloud/",
|
||||
"Site ID": "Identificador del sitio",
|
||||
"The URL of your Matomo instance. If you are using Matomo Cloud it will end in matomo.cloud/": "La URL de tu instancia Matomo. Si estás usando Matomo Cloud, esta terminará en matomo.cloud/",
|
||||
"Site ID": "ID del sitio",
|
||||
"An ID that uniquely identifies the website in your Matomo instance.": "Un ID que identifica de forma única el sitio web en tu instancia de Matomo.",
|
||||
"Add to Slack": "Añadir a Slack",
|
||||
"document published": "documento publicado",
|
||||
@@ -1064,11 +1067,11 @@
|
||||
"Personal account": "Cuenta personal",
|
||||
"Link your {{appName}} account to Slack to enable searching and previewing the documents you have access to, directly within chat.": "Vincula tu cuenta {{appName}} a Slack para permitir buscar y previsualizar los documentos a los que tienes acceso, directamente dentro del chat.",
|
||||
"Disconnecting your personal account will prevent searching for documents from Slack. Are you sure?": "Desconectar tu cuenta personal impedirá la búsqueda de documentos desde Slack. ¿Estás seguro?",
|
||||
"Slash command": "Comando de barra",
|
||||
"Get rich previews of {{ appName }} links shared in Slack and use the <em>{{ command }}</em> slash command to search for documents without leaving your chat.": "Obtén vistas previas enriquecidas de los enlaces de {{ appName }} compartidos en Slack y usa el comando <em>{{ command }}</em> para buscar documentos sin salir de tu chat.",
|
||||
"This will remove the Outline slash command from your Slack workspace. Are you sure?": "Esto eliminará el comando de barra de Outline de tu espacio de trabajo Slack. ¿Estás seguro?",
|
||||
"Slash command": "Comando de barra inclinada",
|
||||
"Get rich previews of {{ appName }} links shared in Slack and use the <em>{{ command }}</em> slash command to search for documents without leaving your chat.": "Obtén vistas previas enriquecidas de los enlaces de {{ appName }} compartidos en Slack y usa el comando de barra inclinada <em>{{ command }}</em> para buscar documentos sin salir de tu chat.",
|
||||
"This will remove the Outline slash command from your Slack workspace. Are you sure?": "Esto eliminará el comando de barra inclinada de Outline de tu espacio de trabajo de Slack. ¿Estás seguro?",
|
||||
"Connect {{appName}} collections to Slack channels. Messages will be automatically posted to Slack when documents are published or updated.": "Conecta las colecciones de {{appName}} a canales de Slack. Se publicarán mensajes automáticamente en Slack cuando los documentos se publiquen o actualicen.",
|
||||
"Comment by {{ author }} on \"{{ title }}\"": "Comentario de {{ author }} sobre \"{{ title }}\"",
|
||||
"Comment by {{ author }} on \"{{ title }}\"": "Comentario de {{ author }} en \"{{ title }}\"",
|
||||
"How to use {{ command }}": "Cómo utilizar {{ command }}",
|
||||
"To search your workspace use {{ command }}. \nType {{ command2 }} help to display this help text.": "Para buscar en tu espacio de trabajo usa {{ command }}. \nEscribe {{ command2 }} help para mostrar este texto de ayuda.",
|
||||
"Post to Channel": "Publicar en Canal",
|
||||
@@ -1077,10 +1080,10 @@
|
||||
"It looks like you haven’t linked your {{ appName }} account to Slack yet": "Parece que aún no has vinculado tu cuenta {{ appName }} a Slack",
|
||||
"Link your account": "Vincula tu cuenta",
|
||||
"Link your account in {{ appName }} settings to search from Slack": "Vincula tu cuenta en la configuración de {{ appName }} para buscar desde Slack",
|
||||
"Configure a Umami installation to send views and analytics from the workspace to your own Umami instance.": "Configura una instalación de Umami para enviar vistas y análisis desde el espacio de trabajo a tu propia instancia de Umami.",
|
||||
"The URL of your Umami instance. If you are using Umami Cloud it will begin with {{ url }}": "La URL de tu instancia Umami. Si estás usando Umami Cloud terminará en {{ url }}",
|
||||
"Configure a Umami installation to send views and analytics from the workspace to your own Umami instance.": "Configura una instalación de Umami para enviar vistas y analíticas desde el espacio de trabajo a tu propia instancia de Umami.",
|
||||
"The URL of your Umami instance. If you are using Umami Cloud it will begin with {{ url }}": "La URL de tu instancia Umami. Si estás usando Umami Cloud, esta empezará con {{ url }}",
|
||||
"Script name": "Nombre del script",
|
||||
"The name of the script file that Umami uses to track analytics.": "El nombre del archivo de script que Umami utiliza para el seguimiento de analíticas",
|
||||
"The name of the script file that Umami uses to track analytics.": "El nombre del archivo script que Umami utiliza para el seguimiento de analíticas.",
|
||||
"An ID that uniquely identifies the website in your Umami instance.": "Un ID que identifica de forma única el sitio web en tu instancia de Umami.",
|
||||
"Are you sure you want to delete the {{ name }} webhook?": "¿Estás seguro de que quieres eliminar el webhook {{ name }}?",
|
||||
"Webhook updated": "Webhook actualizado",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"New API key": "New API key",
|
||||
"New API key": "کلید API جدید",
|
||||
"Open collection": "باز کردن مجموعه",
|
||||
"New collection": "مجموعه جدید",
|
||||
"Create a collection": "ایجاد مجموعه",
|
||||
"Edit": "ویرایش",
|
||||
"Edit collection": "ویرایش مجموعه",
|
||||
"Permissions": "دسترسیها",
|
||||
"Permissions": "دسترسی ها",
|
||||
"Collection permissions": "دسترسیهای مجموعه",
|
||||
"Share this collection": "Share this collection",
|
||||
"Search in collection": "جستجو در مجموعه",
|
||||
@@ -22,7 +22,7 @@
|
||||
"Delete collection": "حذف مجموعه",
|
||||
"New template": "قالب جدید",
|
||||
"Delete comment": "Delete comment",
|
||||
"Mark as resolved": "Mark as resolved",
|
||||
"Mark as resolved": "",
|
||||
"Thread resolved": "Thread resolved",
|
||||
"Mark as unresolved": "Mark as unresolved",
|
||||
"View reactions": "View reactions",
|
||||
@@ -81,14 +81,14 @@
|
||||
"Move": "انتقال",
|
||||
"Move to collection": "Move to collection",
|
||||
"Move {{ documentType }}": "انتقال {{ documentType }}",
|
||||
"Are you sure you want to archive this document?": "Are you sure you want to archive this document?",
|
||||
"Are you sure you want to archive this document?": "آیا اطمینان دارید که میخواهید این سند را بایگانی کنید ؟",
|
||||
"Document archived": "سند آرشیو شد",
|
||||
"Archiving this document will remove it from the collection and search results.": "Archiving this document will remove it from the collection and search results.",
|
||||
"Archiving this document will remove it from the collection and search results.": "بایگانی کردن این سند به حذف آن نتایج جستجو و مجموعه های خواهد انجامید.",
|
||||
"Delete {{ documentName }}": "حذف {{ documentName }}",
|
||||
"Permanently delete": "حذف برای همیشه",
|
||||
"Permanently delete {{ documentName }}": "حذف همیشگی {{ documentName }}",
|
||||
"Empty trash": "خالی کردن زباله دان",
|
||||
"Permanently delete documents in trash": "Permanently delete documents in trash",
|
||||
"Permanently delete documents in trash": "",
|
||||
"Comments": "نظرات",
|
||||
"History": "تاریخچه",
|
||||
"Insights": "بینش ها",
|
||||
@@ -128,7 +128,7 @@
|
||||
"Select a workspace": "انتخاب فضای کاری",
|
||||
"New workspace": "فضای کار جدید",
|
||||
"Create a workspace": "فضای کاری ایجاد کنید",
|
||||
"Login to workspace": "Login to workspace",
|
||||
"Login to workspace": "وارد شدن به workspace",
|
||||
"Invite people": "دعوت از افراد",
|
||||
"Invite to workspace": "Invite to workspace",
|
||||
"Promote to {{ role }}": "Promote to {{ role }}",
|
||||
@@ -308,6 +308,7 @@
|
||||
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} and {{ count }} other reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}_plural": "{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}",
|
||||
"Add reaction": "Add reaction",
|
||||
"Reaction picker": "Reaction picker",
|
||||
"Could not load reactions": "Could not load reactions",
|
||||
"Reaction": "Reaction",
|
||||
@@ -601,6 +602,8 @@
|
||||
"Most recent": "Most recent",
|
||||
"Order in doc": "Order in doc",
|
||||
"Resolved": "Resolved",
|
||||
"Show {{ count }} reply": "Show {{ count }} reply",
|
||||
"Show {{ count }} reply_plural": "Show {{ count }} replies",
|
||||
"Error updating comment": "Error updating comment",
|
||||
"Document restored": "سند بازیابی شد",
|
||||
"Images are still uploading.\nAre you sure you want to discard them?": "تصاویر هنوز در حال بارگذاری هستند.\nآیا مطمئن هستید که می خواهید آنها را نادیده بگیرید؟",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user