mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
103 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 | |||
| fd5391cbb6 | |||
| 6e685ee8d9 | |||
| b595a0d427 | |||
| 1c86119065 | |||
| c629006642 | |||
| 326f733d4c | |||
| d4d683c046 | |||
| 8204ac343f | |||
| cae8de7c7a | |||
| 8efa601967 | |||
| 86c3ea8e9d | |||
| c222782534 | |||
| 19ea7ee52b | |||
| d1de84a07e | |||
| d73b4c55bf | |||
| 9843c4c995 | |||
| 685397b057 | |||
| 13d37d4207 | |||
| 7bedfab301 | |||
| db5850ac0d | |||
| a4c40ce25e | |||
| f5457e79cd | |||
| 73eeeefb25 | |||
| 54f82cac96 | |||
| bb43c24efe | |||
| acf3d7cd08 | |||
| 5245f93642 | |||
| cfce55250e | |||
| 6421995b29 | |||
| 8cfd8e25db | |||
| 1282e9653e | |||
| f1edaecf49 | |||
| f7d737ca45 | |||
| 41c2c760d4 | |||
| f692d1bc3a | |||
| 5197d6e18c | |||
| b901ea7b30 | |||
| 3820499856 | |||
| 0cffde63ab | |||
| 449ba6488e | |||
| 62f3e6921f | |||
| bc259316f7 | |||
| 98e03cc227 | |||
| 633e547d3e | |||
| d5de69fd4b | |||
| feec01f160 | |||
| aa5813032e | |||
| a6ba189180 | |||
| 4c65bbc57c | |||
| c76b4f46aa | |||
| ca17b41c53 | |||
| 9747c6ba5d | |||
| 55ffd6d098 | |||
| 9b26ccda19 | |||
| 56b38b9dbd | |||
| 0a3a684493 |
@@ -4,12 +4,6 @@ defaults: &defaults
|
||||
working_directory: ~/outline
|
||||
docker:
|
||||
- image: cimg/node:20.10
|
||||
- image: cimg/redis:5.0
|
||||
- image: cimg/postgres:14.2
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: circle_test
|
||||
resource_class: large
|
||||
environment:
|
||||
NODE_ENV: test
|
||||
@@ -78,6 +72,14 @@ jobs:
|
||||
test-server:
|
||||
<<: *defaults
|
||||
parallelism: 3
|
||||
docker:
|
||||
- image: cimg/node:20.10
|
||||
- image: cimg/redis:5.0
|
||||
- image: cimg/postgres:14.2
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: circle_test
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.80.2
|
||||
Licensed Work: Outline 0.81.0
|
||||
The Licensed Work is (c) 2024 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2028-09-26
|
||||
Change Date: 2028-11-11
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
EyeIcon,
|
||||
PadlockIcon,
|
||||
GlobeIcon,
|
||||
LogoutIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -37,13 +38,14 @@ import {
|
||||
NavigationNode,
|
||||
} from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
import DocumentMove from "~/scenes/DocumentMove";
|
||||
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";
|
||||
@@ -129,11 +131,30 @@ export const createDocumentFromTemplate = createAction({
|
||||
section: DocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) =>
|
||||
!!currentTeamId &&
|
||||
!!activeDocumentId &&
|
||||
!!stores.documents.get(activeDocumentId)?.template &&
|
||||
stores.policies.abilities(currentTeamId).createDocument,
|
||||
visible: ({
|
||||
currentTeamId,
|
||||
activeCollectionId,
|
||||
activeDocumentId,
|
||||
stores,
|
||||
}) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
!currentTeamId ||
|
||||
!document?.isTemplate ||
|
||||
!!document?.isDraft ||
|
||||
!!document?.isDeleted
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (activeCollectionId) {
|
||||
return stores.policies.abilities(activeCollectionId).createDocument;
|
||||
}
|
||||
return stores.policies.abilities(currentTeamId).createDocument;
|
||||
},
|
||||
perform: ({ activeCollectionId, activeDocumentId, sidebarContext }) =>
|
||||
history.push(
|
||||
newDocumentPath(activeCollectionId, { templateId: activeDocumentId }),
|
||||
@@ -541,7 +562,7 @@ export const duplicateDocument = createAction({
|
||||
stores.dialogs.openModal({
|
||||
title: t("Copy document"),
|
||||
content: (
|
||||
<DuplicateDialog
|
||||
<DocumentCopy
|
||||
document={document}
|
||||
onSubmit={(response) => {
|
||||
stores.dialogs.closeAllModals();
|
||||
@@ -1033,7 +1054,7 @@ export const openDocumentComments = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
stores.ui.toggleComments(activeDocumentId);
|
||||
stores.ui.toggleComments();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1119,6 +1140,42 @@ export const toggleViewerInsights = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const leaveDocument = createAction({
|
||||
name: ({ t }) => t("Leave document"),
|
||||
analyticsName: "Leave document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <LogoutIcon />,
|
||||
visible: ({ currentUserId, activeDocumentId, stores }) => {
|
||||
const membership = stores.userMemberships.orderedData.find(
|
||||
(m) => m.documentId === activeDocumentId && m.userId === currentUserId
|
||||
);
|
||||
|
||||
return !!membership;
|
||||
},
|
||||
perform: async ({ t, location, currentUserId, activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
try {
|
||||
if (document && location.pathname.startsWith(document.path)) {
|
||||
history.push(homePath());
|
||||
}
|
||||
|
||||
await stores.userMemberships.delete({
|
||||
documentId: activeDocumentId,
|
||||
userId: currentUserId,
|
||||
} as UserMembership);
|
||||
|
||||
toast.success(t("You have left the shared document"));
|
||||
} catch (err) {
|
||||
toast.error(t("Could not leave document"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const rootDocumentActions = [
|
||||
openDocument,
|
||||
archiveDocument,
|
||||
@@ -1137,6 +1194,7 @@ export const rootDocumentActions = [
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
duplicateDocument,
|
||||
leaveDocument,
|
||||
moveTemplateToWorkspace,
|
||||
moveDocumentToCollection,
|
||||
openRandomDocument,
|
||||
|
||||
@@ -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: "";
|
||||
|
||||
@@ -2,7 +2,11 @@ import { NewDocumentIcon, ShapesIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Icon from "~/components/Icon";
|
||||
import { createAction } from "~/actions";
|
||||
import { DocumentSection } from "~/actions/sections";
|
||||
import {
|
||||
ActiveCollectionSection,
|
||||
DocumentSection,
|
||||
TeamSection,
|
||||
} from "~/actions/sections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
@@ -11,26 +15,42 @@ const useTemplatesAction = () => {
|
||||
const { documents } = useStores();
|
||||
|
||||
React.useEffect(() => {
|
||||
void documents.fetchTemplates();
|
||||
void documents.fetchAllTemplates();
|
||||
}, [documents]);
|
||||
|
||||
const actions = React.useMemo(
|
||||
() =>
|
||||
documents.templatesAlphabetical.map((item) =>
|
||||
documents.templatesAlphabetical.map((template) =>
|
||||
createAction({
|
||||
name: item.titleWithDefault,
|
||||
name: template.titleWithDefault,
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
icon: item.icon ? (
|
||||
<Icon value={item.icon} color={item.color ?? undefined} />
|
||||
section: template.isWorkspaceTemplate
|
||||
? TeamSection
|
||||
: ActiveCollectionSection,
|
||||
icon: template.icon ? (
|
||||
<Icon value={template.icon} color={template.color ?? undefined} />
|
||||
) : (
|
||||
<NewDocumentIcon />
|
||||
),
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, activeCollectionId, stores }) => {
|
||||
if (activeCollectionId) {
|
||||
return (
|
||||
stores.policies.abilities(activeCollectionId).createDocument &&
|
||||
(template.collectionId === activeCollectionId ||
|
||||
template.isWorkspaceTemplate)
|
||||
);
|
||||
}
|
||||
return (
|
||||
!!currentTeamId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument &&
|
||||
template.isWorkspaceTemplate
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, sidebarContext }) =>
|
||||
history.push(
|
||||
newDocumentPath(item.collectionId ?? activeCollectionId, {
|
||||
templateId: item.id,
|
||||
newDocumentPath(template.collectionId ?? activeCollectionId, {
|
||||
templateId: template.id,
|
||||
}),
|
||||
{
|
||||
sidebarContext,
|
||||
@@ -49,9 +69,15 @@ const useTemplatesAction = () => {
|
||||
placeholder: ({ t }) => t("Choose a template"),
|
||||
section: DocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!currentTeamId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument,
|
||||
visible: ({ currentTeamId, activeCollectionId, stores }) => {
|
||||
if (activeCollectionId) {
|
||||
return stores.policies.abilities(activeCollectionId).createDocument;
|
||||
}
|
||||
return (
|
||||
!!currentTeamId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
children: () => actions,
|
||||
}),
|
||||
[actions]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -39,6 +39,7 @@ function DocumentCard(props: Props) {
|
||||
const { collections } = useStores();
|
||||
const theme = useTheme();
|
||||
const { document, pin, canUpdatePin, isDraggable } = props;
|
||||
const pinnedToHome = React.useRef(!pin?.collectionId).current;
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
@@ -122,13 +123,13 @@ function DocumentCard(props: Props) {
|
||||
<Squircle
|
||||
color={
|
||||
collection?.color ??
|
||||
(!pin?.collectionId ? theme.slateLight : theme.slateDark)
|
||||
(pinnedToHome ? theme.slateLight : theme.slateDark)
|
||||
}
|
||||
>
|
||||
{collection?.icon &&
|
||||
collection?.icon !== "letter" &&
|
||||
collection?.icon !== "collection" &&
|
||||
!pin?.collectionId ? (
|
||||
pinnedToHome ? (
|
||||
<CollectionIcon collection={collection} color="white" />
|
||||
) : (
|
||||
<DocumentIcon color="white" />
|
||||
|
||||
@@ -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);
|
||||
@@ -268,14 +268,15 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
{props.editorStyle?.paddingBottom && (
|
||||
<ClickablePadding
|
||||
onClick={props.readOnly ? undefined : focusAtEnd}
|
||||
onDrop={props.readOnly ? undefined : handleDrop}
|
||||
onDragOver={props.readOnly ? undefined : handleDragOver}
|
||||
minHeight={props.editorStyle.paddingBottom}
|
||||
/>
|
||||
)}
|
||||
{props.editorStyle?.paddingBottom &&
|
||||
(!props.readOnly || props.shareId) && (
|
||||
<ClickablePadding
|
||||
onClick={props.readOnly ? undefined : focusAtEnd}
|
||||
onDrop={props.readOnly ? undefined : handleDrop}
|
||||
onDragOver={props.readOnly ? undefined : handleDragOver}
|
||||
minHeight={props.editorStyle.paddingBottom}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ import { documentHistoryPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
event: Event;
|
||||
event: Event<Document>;
|
||||
latest?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -47,14 +47,16 @@ export default function LanguagePrompt() {
|
||||
<br />
|
||||
<Link
|
||||
onClick={async () => {
|
||||
ui.setLanguagePromptDismissed();
|
||||
ui.set({ languagePromptDismissed: true });
|
||||
await user.save({ language });
|
||||
}}
|
||||
>
|
||||
{t("Change Language")}
|
||||
</Link>{" "}
|
||||
·{" "}
|
||||
<Link onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</Link>
|
||||
<Link onClick={() => ui.set({ languagePromptDismissed: true })}>
|
||||
{t("Dismiss")}
|
||||
</Link>
|
||||
</span>
|
||||
</Flex>
|
||||
</Wrapper>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,9 +6,11 @@ import PaginatedList from "~/components/PaginatedList";
|
||||
import EventListItem from "./EventListItem";
|
||||
|
||||
type Props = {
|
||||
events: Event[];
|
||||
events: Event<Document>[];
|
||||
document: Document;
|
||||
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
|
||||
fetch: (
|
||||
options: Record<string, any> | undefined
|
||||
) => Promise<Event<Document>[]>;
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
@@ -30,7 +32,7 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
|
||||
heading={heading}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item: Event, index) => (
|
||||
renderItem={(item: Event<Document>, index) => (
|
||||
<EventListItem
|
||||
key={item.id}
|
||||
event={item}
|
||||
|
||||
@@ -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"};
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { ReactionIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopoverDisclosure, usePopoverState } from "reakit";
|
||||
import styled from "styled-components";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -11,6 +10,7 @@ import Popover from "~/components/Popover";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
import Tooltip from "../Tooltip";
|
||||
|
||||
const EmojiPanel = React.lazy(
|
||||
() => import("~/components/IconPicker/components/EmojiPanel")
|
||||
@@ -98,15 +98,22 @@ const ReactionPicker: React.FC<Props> = ({
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<PopoverButton
|
||||
{...props}
|
||||
aria-label={t("Reaction picker")}
|
||||
className={className}
|
||||
onClick={handlePopoverButtonClick}
|
||||
size={size}
|
||||
<Tooltip
|
||||
content={t("Add reaction")}
|
||||
placement="top"
|
||||
delay={500}
|
||||
hideOnClick
|
||||
>
|
||||
<ReactionIcon size={22} />
|
||||
</PopoverButton>
|
||||
<NudeButton
|
||||
{...props}
|
||||
aria-label={t("Reaction picker")}
|
||||
className={className}
|
||||
onClick={handlePopoverButtonClick}
|
||||
size={size}
|
||||
>
|
||||
<ReactionIcon size={22} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
<Popover
|
||||
@@ -151,8 +158,4 @@ const Placeholder = React.memo(
|
||||
);
|
||||
Placeholder.displayName = "ReactionPickerPlaceholder";
|
||||
|
||||
const PopoverButton = styled(NudeButton)`
|
||||
border-radius: 50%;
|
||||
`;
|
||||
|
||||
export default ReactionPicker;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { observer } from "mobx-react";
|
||||
import { CopyIcon, GlobeIcon, InfoIcon } from "outline-icons";
|
||||
import { CopyIcon, GlobeIcon, InfoIcon, QuestionMarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { s } from "@shared/styles";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
@@ -50,6 +51,19 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
setUrlId(share?.urlId);
|
||||
}, [share?.urlId]);
|
||||
|
||||
const handleIndexingChanged = React.useCallback(
|
||||
async (event) => {
|
||||
try {
|
||||
await share?.save({
|
||||
allowIndexing: event.currentTarget.checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handlePublishedChange = React.useCallback(
|
||||
async (event) => {
|
||||
try {
|
||||
@@ -153,6 +167,32 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
/>
|
||||
|
||||
<ResizingHeightContainer>
|
||||
{share?.published && (
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Search engine indexing")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Disable this setting to discourage search engines from indexing the page"
|
||||
)}
|
||||
>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Search engine indexing")}
|
||||
checked={share?.allowIndexing ?? false}
|
||||
onChange={handleIndexingChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sharedParent?.published ? (
|
||||
<ShareLinkInput type="text" disabled defaultValue={shareUrl}>
|
||||
{copyButton}
|
||||
|
||||
@@ -32,13 +32,13 @@ function Right({ children, border, className }: Props) {
|
||||
Math.min(window.innerWidth - event.pageX, maxWidth),
|
||||
minWidth
|
||||
);
|
||||
ui.setRightSidebarWidth(width);
|
||||
ui.set({ sidebarRightWidth: width });
|
||||
},
|
||||
[minWidth, maxWidth, ui]
|
||||
);
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
ui.setRightSidebarWidth(theme.sidebarRightWidth);
|
||||
ui.set({ sidebarRightWidth: theme.sidebarRightWidth });
|
||||
}, [ui, theme.sidebarRightWidth]);
|
||||
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
|
||||
@@ -46,7 +46,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
|
||||
const setWidth = ui.setSidebarWidth;
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [isHovering, setHovering] = React.useState(false);
|
||||
const [isAnimating, setAnimating] = React.useState(false);
|
||||
@@ -62,13 +61,13 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
const width = Math.min(event.pageX - offset, maxWidth);
|
||||
const isSmallerThanCollapsePoint = width < minWidth / 2;
|
||||
|
||||
if (isSmallerThanCollapsePoint) {
|
||||
setWidth(theme.sidebarCollapsedWidth);
|
||||
} else {
|
||||
setWidth(width);
|
||||
}
|
||||
ui.set({
|
||||
sidebarWidth: isSmallerThanCollapsePoint
|
||||
? theme.sidebarCollapsedWidth
|
||||
: width,
|
||||
});
|
||||
},
|
||||
[theme, offset, minWidth, maxWidth, setWidth]
|
||||
[ui, theme, offset, minWidth, maxWidth]
|
||||
);
|
||||
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
@@ -86,13 +85,13 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
setCollapsing(true);
|
||||
ui.collapseSidebar();
|
||||
} else {
|
||||
setWidth(minWidth);
|
||||
ui.set({ sidebarWidth: minWidth });
|
||||
setAnimating(true);
|
||||
}
|
||||
} else {
|
||||
setWidth(width);
|
||||
ui.set({ sidebarWidth: width });
|
||||
}
|
||||
}, [ui, isSmallerThanMinimum, minWidth, width, setWidth]);
|
||||
}, [ui, isSmallerThanMinimum, minWidth, width]);
|
||||
|
||||
const handleBlur = React.useCallback(() => {
|
||||
setHovering(false);
|
||||
@@ -149,11 +148,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
React.useEffect(() => {
|
||||
if (isCollapsing) {
|
||||
setTimeout(() => {
|
||||
setWidth(minWidth);
|
||||
ui.set({ sidebarWidth: minWidth });
|
||||
setCollapsing(false);
|
||||
}, ANIMATION_MS);
|
||||
}
|
||||
}, [setWidth, minWidth, isCollapsing]);
|
||||
}, [ui, minWidth, isCollapsing]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isResizing) {
|
||||
@@ -174,7 +173,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
}, [isResizing, handleDrag, handleBlur, handleStopDrag]);
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
ui.setSidebarWidth(theme.sidebarWidth);
|
||||
ui.set({ sidebarWidth: theme.sidebarWidth });
|
||||
}, [ui, theme.sidebarWidth]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -299,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();
|
||||
|
||||
|
||||
@@ -216,8 +216,7 @@ export default function SelectionToolbar(props: Props) {
|
||||
const colIndex = getColumnIndex(state);
|
||||
const rowIndex = getRowIndex(state);
|
||||
const isTableSelection = colIndex !== undefined && rowIndex !== undefined;
|
||||
const link = isMarkActive(state.schema.marks.link)(state);
|
||||
const range = getMarkRange(selection.$from, state.schema.marks.link);
|
||||
const link = getMarkRange(selection.$from, state.schema.marks.link);
|
||||
const isImageSelection =
|
||||
selection instanceof NodeSelection && selection.node.type.name === "image";
|
||||
const isAttachmentSelection =
|
||||
@@ -266,7 +265,8 @@ export default function SelectionToolbar(props: Props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showLinkToolbar = link && range;
|
||||
const showLinkToolbar =
|
||||
link && link.from === selection.from && link.to === selection.to;
|
||||
|
||||
return (
|
||||
<FloatingToolbar
|
||||
@@ -276,12 +276,12 @@ export default function SelectionToolbar(props: Props) {
|
||||
>
|
||||
{showLinkToolbar ? (
|
||||
<LinkEditor
|
||||
key={`${range.from}-${range.to}`}
|
||||
key={`${link.from}-${link.to}`}
|
||||
dictionary={dictionary}
|
||||
view={view}
|
||||
mark={range.mark}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
mark={link.mark}
|
||||
from={link.from}
|
||||
to={link.to}
|
||||
onClickLink={props.onClickLink}
|
||||
onSearchLink={props.onSearchLink}
|
||||
onCreateLink={onCreateLink ? handleOnCreateLink : undefined}
|
||||
|
||||
@@ -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) {
|
||||
@@ -248,15 +257,25 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
let m;
|
||||
const search = this.findRegExp;
|
||||
|
||||
while ((m = search.exec(deburr(text)))) {
|
||||
// We construct a string with the text stripped of diacritics plus the original text for
|
||||
// search allowing to search for diacritics-insensitive matches easily.
|
||||
while ((m = search.exec(deburr(text) + text))) {
|
||||
if (m[0] === "") {
|
||||
break;
|
||||
}
|
||||
|
||||
this.results.push({
|
||||
from: pos + m.index,
|
||||
to: pos + m.index + m[0].length,
|
||||
});
|
||||
// Reconstruct the correct match position
|
||||
const i = m.index >= text.length ? m.index - text.length : m.index;
|
||||
const from = pos + i;
|
||||
const to = from + 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
-16
@@ -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.
|
||||
*
|
||||
@@ -690,7 +675,10 @@ export class Editor extends React.PureComponent<
|
||||
* @param commentId The id of the comment to remove
|
||||
* @param attrs The attributes to update
|
||||
*/
|
||||
public updateComment = (commentId: string, attrs: { resolved: boolean }) => {
|
||||
public updateComment = (
|
||||
commentId: string,
|
||||
attrs: { resolved?: boolean; draft?: boolean }
|
||||
) => {
|
||||
const { state, dispatch } = this.view;
|
||||
const tr = state.tr;
|
||||
|
||||
|
||||
@@ -214,7 +214,6 @@ export default function formattingMenuItems(
|
||||
name: "link",
|
||||
tooltip: dictionary.createLink,
|
||||
icon: <LinkIcon />,
|
||||
active: isMarkActive(schema.marks.link),
|
||||
attrs: { href: "" },
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
shareDocument,
|
||||
copyDocument,
|
||||
searchInDocument,
|
||||
leaveDocument,
|
||||
moveTemplate,
|
||||
} from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
@@ -298,6 +299,7 @@ const MenuContent: React.FC<MenuContentProps> = ({
|
||||
},
|
||||
actionToMenuItem(deleteDocument, context),
|
||||
actionToMenuItem(permanentlyDeleteDocument, context),
|
||||
actionToMenuItem(leaveDocument, context),
|
||||
]}
|
||||
/>
|
||||
{(showDisplayOptions || showToggleEmbeds) && can.update && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -254,7 +250,8 @@ export default class Document extends ArchivableModel {
|
||||
|
||||
@computed
|
||||
get path(): string {
|
||||
const prefix = this.template ? settingsPath("templates") : "/doc";
|
||||
const prefix =
|
||||
this.template && !this.isDeleted ? settingsPath("templates") : "/doc";
|
||||
|
||||
if (!this.title) {
|
||||
return `${prefix}/untitled-${this.urlId}`;
|
||||
@@ -576,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);
|
||||
|
||||
/**
|
||||
|
||||
+18
-11
@@ -1,21 +1,29 @@
|
||||
import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
class Event extends Model {
|
||||
class Event<T extends Model> extends Model {
|
||||
static modelName = "Event";
|
||||
|
||||
id: string;
|
||||
|
||||
name: string;
|
||||
|
||||
modelId: string | null | undefined;
|
||||
modelId: string | undefined;
|
||||
|
||||
actorIpAddress: string | null | undefined;
|
||||
|
||||
documentId: string;
|
||||
@Relation(() => Document)
|
||||
document: Document;
|
||||
|
||||
collectionId: string | null | undefined;
|
||||
documentId: string | undefined;
|
||||
|
||||
@Relation(() => Collection)
|
||||
collection: Collection;
|
||||
|
||||
collectionId: string | undefined;
|
||||
|
||||
@Relation(() => User)
|
||||
user: User;
|
||||
@@ -27,13 +35,12 @@ class Event extends Model {
|
||||
|
||||
actorId: string;
|
||||
|
||||
data: {
|
||||
name: string;
|
||||
email: string;
|
||||
title: string;
|
||||
published: boolean;
|
||||
templateId: string;
|
||||
};
|
||||
data: Partial<T> | null;
|
||||
|
||||
changes: {
|
||||
attributes: Partial<T>;
|
||||
previous: Partial<T>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default Event;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -55,6 +55,10 @@ class Share extends Model {
|
||||
@observable
|
||||
url: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
allowIndexing: boolean;
|
||||
|
||||
/** The user that shared the document. */
|
||||
@Relation(() => User, { onDelete: "null" })
|
||||
createdBy: User;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -27,6 +27,8 @@ import { Bubble } from "./CommentThreadItem";
|
||||
import { HighlightedText } from "./HighlightText";
|
||||
|
||||
type Props = {
|
||||
/** Callback when the form is submitted. */
|
||||
onSubmit?: () => void;
|
||||
/** Callback when the draft should be saved. */
|
||||
onSaveDraft: (data: ProsemirrorData | undefined) => void;
|
||||
/** A draft comment for this thread. */
|
||||
@@ -47,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 */
|
||||
@@ -59,8 +59,8 @@ function CommentForm({
|
||||
documentId,
|
||||
thread,
|
||||
draft,
|
||||
onSubmit,
|
||||
onSaveDraft,
|
||||
onTyping,
|
||||
onFocus,
|
||||
onBlur,
|
||||
autoFocus,
|
||||
@@ -119,6 +119,7 @@ function CommentForm({
|
||||
documentId,
|
||||
data: draft,
|
||||
})
|
||||
.then(() => onSubmit?.())
|
||||
.catch(() => {
|
||||
comment.isNew = true;
|
||||
toast.error(t("Error creating comment"));
|
||||
@@ -153,11 +154,14 @@ function CommentForm({
|
||||
comment.id = uuidv4();
|
||||
comments.add(comment);
|
||||
|
||||
comment.save().catch(() => {
|
||||
comments.remove(comment.id);
|
||||
comment.isNew = true;
|
||||
toast.error(t("Error creating comment"));
|
||||
});
|
||||
comment
|
||||
.save()
|
||||
.then(() => onSubmit?.())
|
||||
.catch(() => {
|
||||
comments.remove(comment.id);
|
||||
comment.isNew = true;
|
||||
toast.error(t("Error creating comment"));
|
||||
});
|
||||
|
||||
// optimistically update the comment model
|
||||
comment.isNew = false;
|
||||
@@ -175,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,23 +50,26 @@ 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>(
|
||||
`draft-${document.id}-${thread.id}`,
|
||||
undefined
|
||||
);
|
||||
|
||||
const canReply = can.comment && !thread.isResolved;
|
||||
|
||||
const highlightedCommentMarks = editor
|
||||
@@ -97,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 &&
|
||||
@@ -111,6 +106,10 @@ function CommentThread({
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = React.useCallback(() => {
|
||||
editor?.updateComment(thread.id, { draft: false });
|
||||
}, [editor, thread.id]);
|
||||
|
||||
const handleClickThread = () => {
|
||||
history.replace({
|
||||
// Clear any commentId from the URL when explicitly focusing a thread
|
||||
@@ -120,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);
|
||||
@@ -174,11 +203,6 @@ function CommentThread({
|
||||
}
|
||||
}, [focused, focusedOnMount, thread.id]);
|
||||
|
||||
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
|
||||
`draft-${document.id}-${thread.id}`,
|
||||
undefined
|
||||
);
|
||||
|
||||
return (
|
||||
<Thread
|
||||
ref={topRef}
|
||||
@@ -188,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 ||
|
||||
@@ -215,24 +248,15 @@ 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}>
|
||||
<CommentForm
|
||||
onSubmit={handleSubmit}
|
||||
onSaveDraft={onSaveDraft}
|
||||
draft={draft}
|
||||
documentId={document.id}
|
||||
thread={thread}
|
||||
onTyping={setIsTyping}
|
||||
standalone={commentsInThread.length === 0}
|
||||
dir={document.dir}
|
||||
autoFocus={autoFocus}
|
||||
@@ -271,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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { differenceInMilliseconds } from "date-fns";
|
||||
import { action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { DoneIcon } from "outline-icons";
|
||||
import { darken } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -16,10 +17,14 @@ import Comment from "~/models/Comment";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import ButtonSmall from "~/components/ButtonSmall";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import ReactionList from "~/components/Reactions/ReactionList";
|
||||
import ReactionPicker from "~/components/Reactions/ReactionPicker";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { resolveCommentFactory } from "~/actions/definitions/comments";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import CommentMenu from "~/menus/CommentMenu";
|
||||
@@ -242,11 +247,13 @@ function CommentThreadItem({
|
||||
onRemoveReaction={handleRemoveReaction}
|
||||
picker={
|
||||
!comment.isResolved ? (
|
||||
<StyledReactionPicker
|
||||
<Action
|
||||
as={ReactionPicker}
|
||||
onSelect={handleAddReaction}
|
||||
onOpen={disableScroll}
|
||||
onClose={enableScroll}
|
||||
size={28}
|
||||
rounded
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
@@ -258,13 +265,21 @@ function CommentThreadItem({
|
||||
{!isEditing && (
|
||||
<Actions gap={4} dir={dir}>
|
||||
{!comment.isResolved && (
|
||||
<StyledReactionPicker
|
||||
onSelect={handleAddReaction}
|
||||
onOpen={disableScroll}
|
||||
onClose={enableScroll}
|
||||
/>
|
||||
<>
|
||||
{firstOfThread && (
|
||||
<ResolveButton onUpdate={handleUpdate} comment={comment} />
|
||||
)}
|
||||
<Action
|
||||
as={ReactionPicker}
|
||||
onSelect={handleAddReaction}
|
||||
onOpen={disableScroll}
|
||||
onClose={enableScroll}
|
||||
rounded
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<StyledMenu
|
||||
<Action
|
||||
as={CommentMenu}
|
||||
comment={comment}
|
||||
onEdit={setEditing}
|
||||
onDelete={handleDelete}
|
||||
@@ -278,6 +293,38 @@ function CommentThreadItem({
|
||||
);
|
||||
}
|
||||
|
||||
const ResolveButton = ({
|
||||
comment,
|
||||
onUpdate,
|
||||
}: {
|
||||
comment: Comment;
|
||||
onUpdate: (attrs: { resolved: boolean }) => void;
|
||||
}) => {
|
||||
const context = useActionContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={t("Mark as resolved")}
|
||||
placement="top"
|
||||
delay={500}
|
||||
hideOnClick
|
||||
>
|
||||
<Action
|
||||
as={NudeButton}
|
||||
context={context}
|
||||
action={resolveCommentFactory({
|
||||
comment,
|
||||
onResolve: () => onUpdate({ resolved: true }),
|
||||
})}
|
||||
rounded
|
||||
>
|
||||
<DoneIcon size={22} outline />
|
||||
</Action>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledCommentEditor = styled(CommentEditor)`
|
||||
${(props) =>
|
||||
!props.readOnly &&
|
||||
@@ -308,25 +355,13 @@ const Body = styled.form`
|
||||
border-radius: 2px;
|
||||
`;
|
||||
|
||||
const StyledMenu = styled(CommentMenu)`
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&: ${hover}, &[aria-expanded= "true"] {
|
||||
background: ${s("backgroundQuaternary")};
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledReactionPicker = styled(ReactionPicker)`
|
||||
const Action = styled.span<{ rounded?: boolean }>`
|
||||
color: ${s("textSecondary")};
|
||||
${(props) =>
|
||||
props.rounded &&
|
||||
css`
|
||||
border-radius: 50%;
|
||||
`}
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
@@ -352,7 +387,7 @@ const Actions = styled(Flex)<{ dir?: "rtl" | "ltr" }>`
|
||||
background: ${s("backgroundSecondary")};
|
||||
padding-left: 4px;
|
||||
|
||||
&:has(${StyledReactionPicker}[aria-expanded="true"], ${StyledMenu}[aria-expanded="true"]) {
|
||||
&:has(${Action}[aria-expanded="true"]) {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
@@ -386,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);
|
||||
|
||||
@@ -218,11 +218,11 @@ function DataLoader({ match, children }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const readOnly =
|
||||
!isEditing || !can.update || document.isArchived || !!revisionId;
|
||||
const canEdit = can.update && !document.isArchived && !revisionId;
|
||||
const readOnly = !isEditing || !canEdit;
|
||||
|
||||
return (
|
||||
<React.Fragment key={readOnly ? "readOnly" : ""}>
|
||||
<React.Fragment key={canEdit ? "edit" : "read"}>
|
||||
{children({
|
||||
document,
|
||||
revision,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,6 +27,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useFocusedComment from "~/hooks/useFocusedComment";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import {
|
||||
documentHistoryPath,
|
||||
@@ -81,6 +82,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const team = useCurrentTeam({ rejectOnEmpty: false });
|
||||
const history = useHistory();
|
||||
const params = useQuery();
|
||||
const {
|
||||
document,
|
||||
onChangeTitle,
|
||||
@@ -103,9 +105,20 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (focusedComment) {
|
||||
ui.expandComments(document.id);
|
||||
const viewingResolved = params.get("resolved") === "";
|
||||
if (
|
||||
(focusedComment.isResolved && !viewingResolved) ||
|
||||
(!focusedComment.isResolved && viewingResolved)
|
||||
) {
|
||||
history.replace({
|
||||
search: focusedComment.isResolved ? "resolved=" : "",
|
||||
pathname: location.pathname,
|
||||
state: { commentId: focusedComment.id },
|
||||
});
|
||||
}
|
||||
ui.set({ commentsExpanded: true });
|
||||
}
|
||||
}, [focusedComment, ui, document.id]);
|
||||
}, [focusedComment, ui, document.id, history, params]);
|
||||
|
||||
// Save document when blurring title, but delay so that if clicking on a
|
||||
// button this is allowed to execute first.
|
||||
|
||||
@@ -103,6 +103,10 @@ function DocumentHeader({
|
||||
});
|
||||
}, [onSave]);
|
||||
|
||||
const handleToggle = React.useCallback(() => {
|
||||
ui.set({ tocVisible: !ui.tocVisible });
|
||||
}, [ui]);
|
||||
|
||||
const context = useActionContext({
|
||||
activeDocumentId: document?.id,
|
||||
});
|
||||
@@ -113,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
|
||||
@@ -129,7 +134,7 @@ function DocumentHeader({
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
onClick={showContents ? ui.hideTableOfContents : ui.showTableOfContents}
|
||||
onClick={handleToggle}
|
||||
icon={<TableOfContentsIcon />}
|
||||
borderOnHover
|
||||
neutral
|
||||
@@ -180,7 +185,7 @@ function DocumentHeader({
|
||||
|
||||
useKeyDown(
|
||||
(event) => event.ctrlKey && event.altKey && event.key === "˙",
|
||||
ui.tocVisible ? ui.hideTableOfContents : ui.showTableOfContents,
|
||||
handleToggle,
|
||||
{
|
||||
allowInInput: true,
|
||||
}
|
||||
@@ -232,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useRouteMatch } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { RevisionHelper } from "@shared/utils/RevisionHelper";
|
||||
import Document from "~/models/Document";
|
||||
import Event from "~/models/Event";
|
||||
import Empty from "~/components/Empty";
|
||||
import PaginatedEventList from "~/components/PaginatedEventList";
|
||||
@@ -12,7 +13,7 @@ import useStores from "~/hooks/useStores";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import Sidebar from "./SidebarLayout";
|
||||
|
||||
const EMPTY_ARRAY: Event[] = [];
|
||||
const EMPTY_ARRAY: Event<Document>[] = [];
|
||||
|
||||
function History() {
|
||||
const { events, documents } = useStores();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ function ReferenceListItem({
|
||||
}: Props) {
|
||||
const { icon, color } = document;
|
||||
const isEmoji = determineIconType(icon) === IconType.Emoji;
|
||||
const title =
|
||||
document instanceof Document ? document.titleWithDefault : document.title;
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
@@ -81,9 +83,7 @@ function ReferenceListItem({
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
)}
|
||||
<Title>
|
||||
{isEmoji ? document.title.replace(icon!, "") : document.title}
|
||||
</Title>
|
||||
<Title>{isEmoji ? title.replace(icon!, "") : title}</Title>
|
||||
</Content>
|
||||
</DocumentLink>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -127,7 +127,7 @@ function Invite({ onSubmit }: Props) {
|
||||
<Trans>{{ collectionCount }} collections</Trans>
|
||||
</strong>
|
||||
</Tooltip>
|
||||
.
|
||||
.{" "}
|
||||
</span>
|
||||
) : undefined;
|
||||
|
||||
|
||||
@@ -5,6 +5,124 @@ import { Trans } from "react-i18next";
|
||||
import Notice from "~/components/Notice";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
|
||||
function Message({ notice }: { notice: string }) {
|
||||
switch (notice) {
|
||||
case "domain-not-allowed":
|
||||
return (
|
||||
<Trans>
|
||||
The domain associated with your email address has not been allowed for
|
||||
this workspace.
|
||||
</Trans>
|
||||
);
|
||||
case "domain-required":
|
||||
return (
|
||||
<Trans>
|
||||
Unable to sign-in. Please navigate to your workspace's custom URL,
|
||||
then try to sign-in again.
|
||||
<hr />
|
||||
If you were invited to a workspace, you will find a link to it in the
|
||||
invite email.
|
||||
</Trans>
|
||||
);
|
||||
case "gmail-account-creation":
|
||||
return (
|
||||
<Trans>
|
||||
Sorry, a new account cannot be created with a personal Gmail address.
|
||||
<hr />
|
||||
Please use a Google Workspaces account instead.
|
||||
</Trans>
|
||||
);
|
||||
case "pending-deletion":
|
||||
return (
|
||||
<Trans>
|
||||
The workspace associated with your user is scheduled for deletion and
|
||||
cannot be accessed at this time.
|
||||
</Trans>
|
||||
);
|
||||
case "maximum-reached":
|
||||
return (
|
||||
<Trans>
|
||||
The workspace you authenticated with is not authorized on this
|
||||
installation. Try another?
|
||||
</Trans>
|
||||
);
|
||||
case "malformed-user-info":
|
||||
return (
|
||||
<Trans>
|
||||
We could not read the user info supplied by your identity provider.
|
||||
</Trans>
|
||||
);
|
||||
case "email-auth-required":
|
||||
return (
|
||||
<Trans>
|
||||
Your account uses email sign-in, please sign-in with email to
|
||||
continue.
|
||||
</Trans>
|
||||
);
|
||||
case "email-auth-ratelimit":
|
||||
return (
|
||||
<Trans>
|
||||
An email sign-in link was recently sent, please check your inbox or
|
||||
try again in a few minutes.
|
||||
</Trans>
|
||||
);
|
||||
case "auth-error":
|
||||
case "state-mismatch":
|
||||
return (
|
||||
<Trans>
|
||||
Authentication failed – we were unable to sign you in at this time.
|
||||
Please try again.
|
||||
</Trans>
|
||||
);
|
||||
case "invalid-authentication":
|
||||
return (
|
||||
<Trans>
|
||||
Authentication failed – you do not have permission to access this
|
||||
workspace.
|
||||
</Trans>
|
||||
);
|
||||
case "expired-token":
|
||||
return (
|
||||
<Trans>
|
||||
Sorry, it looks like that sign-in link is no longer valid, please try
|
||||
requesting another.
|
||||
</Trans>
|
||||
);
|
||||
case "user-suspended":
|
||||
return (
|
||||
<Trans>
|
||||
Your account has been suspended. To re-activate your account, please
|
||||
contact a workspace admin.
|
||||
</Trans>
|
||||
);
|
||||
case "team-suspended":
|
||||
return (
|
||||
<Trans>
|
||||
This workspace has been suspended. Please contact support to restore
|
||||
access.
|
||||
</Trans>
|
||||
);
|
||||
case "authentication-provider-disabled":
|
||||
return (
|
||||
<Trans>
|
||||
Authentication failed – this login method was disabled by a team
|
||||
admin.
|
||||
</Trans>
|
||||
);
|
||||
case "invite-required":
|
||||
return (
|
||||
<Trans>
|
||||
The workspace you are trying to join requires an invite before you can
|
||||
create an account.
|
||||
<hr />
|
||||
Please request an invite from your workspace admin and try again.
|
||||
</Trans>
|
||||
);
|
||||
default:
|
||||
return <Trans>Sorry, an unknown error occurred.</Trans>;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Notices() {
|
||||
const query = useQuery();
|
||||
const notice = query.get("notice");
|
||||
@@ -15,107 +133,7 @@ export default function Notices() {
|
||||
|
||||
return (
|
||||
<Notice icon={<WarningIcon color="currentcolor" />}>
|
||||
{notice === "domain-not-allowed" && (
|
||||
<Trans>
|
||||
The domain associated with your email address has not been allowed for
|
||||
this workspace.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "domain-required" && (
|
||||
<Trans>
|
||||
Unable to sign-in. Please navigate to your workspace's custom URL,
|
||||
then try to sign-in again.
|
||||
<hr />
|
||||
If you were invited to a workspace, you will find a link to it in the
|
||||
invite email.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "gmail-account-creation" && (
|
||||
<Trans>
|
||||
Sorry, a new account cannot be created with a personal Gmail address.
|
||||
<hr />
|
||||
Please use a Google Workspaces account instead.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "pending-deletion" && (
|
||||
<Trans>
|
||||
The workspace associated with your user is scheduled for deletion and
|
||||
cannot be accessed at this time.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "maximum-reached" && (
|
||||
<Trans>
|
||||
The workspace you authenticated with is not authorized on this
|
||||
installation. Try another?
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "malformed-user-info" && (
|
||||
<Trans>
|
||||
We could not read the user info supplied by your identity provider.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "email-auth-required" && (
|
||||
<Trans>
|
||||
Your account uses email sign-in, please sign-in with email to
|
||||
continue.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "email-auth-ratelimit" && (
|
||||
<Trans>
|
||||
An email sign-in link was recently sent, please check your inbox or
|
||||
try again in a few minutes.
|
||||
</Trans>
|
||||
)}
|
||||
{(notice === "auth-error" || notice === "state-mismatch") && (
|
||||
<Trans>
|
||||
Authentication failed – we were unable to sign you in at this time.
|
||||
Please try again.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "invalid-authentication" && (
|
||||
<Trans>
|
||||
Authentication failed – you do not have permission to access this
|
||||
workspace.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "expired-token" && (
|
||||
<Trans>
|
||||
Sorry, it looks like that sign-in link is no longer valid, please try
|
||||
requesting another.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "user-suspended" && (
|
||||
<Trans>
|
||||
Your account has been suspended. To re-activate your account, please
|
||||
contact a workspace admin.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "team-suspended" && (
|
||||
<Trans>
|
||||
This workspace has been suspended. Please contact support to restore
|
||||
access.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "authentication-provider-disabled" && (
|
||||
<Trans>
|
||||
Authentication failed – this login method was disabled by a team
|
||||
admin.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "invite-required" && (
|
||||
<Trans>
|
||||
The workspace you are trying to join requires an invite before you can
|
||||
create an account.
|
||||
<hr />
|
||||
Please request an invite from your workspace admin and try again.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "domain-not-allowed" && (
|
||||
<Trans>
|
||||
Sorry, your domain is not allowed. Please try again with an allowed
|
||||
workspace domain.
|
||||
</Trans>
|
||||
)}
|
||||
<Message notice={notice} />
|
||||
</Notice>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -52,7 +52,7 @@ function PeopleTable({ canManage, ...rest }: Props) {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "isAdmin",
|
||||
id: "role",
|
||||
Header: t("Role"),
|
||||
accessor: "rank",
|
||||
Cell: observer(({ row }: { row: { original: User } }) => (
|
||||
|
||||
+9
-20
@@ -7,31 +7,19 @@ import { CustomTheme } from "@shared/types";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Policy from "~/models/Policy";
|
||||
import Team from "~/models/Team";
|
||||
import User from "~/models/User";
|
||||
import env from "~/env";
|
||||
import { setPostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
import { PartialExcept } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import Logger from "~/utils/Logger";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import Store from "./base/Store";
|
||||
|
||||
type PersistedData = {
|
||||
user?: PartialExcept<User, "id">;
|
||||
team?: PartialExcept<Team, "id">;
|
||||
collaborationToken?: string;
|
||||
availableTeams?: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
url: string;
|
||||
isSignedIn: boolean;
|
||||
}[];
|
||||
policies?: Policy[];
|
||||
};
|
||||
type PersistedData = Pick<
|
||||
AuthStore,
|
||||
"user" | "team" | "collaborationToken" | "availableTeams" | "policies"
|
||||
>;
|
||||
|
||||
type Provider = {
|
||||
id: string;
|
||||
@@ -165,9 +153,10 @@ export default class AuthStore extends Store<Team> {
|
||||
/** The current team's policies */
|
||||
@computed
|
||||
get policies() {
|
||||
return this.currentTeamId
|
||||
? [this.rootStore.policies.get(this.currentTeamId)]
|
||||
: [];
|
||||
const policy = this.currentTeamId
|
||||
? this.rootStore.policies.get(this.currentTeamId)
|
||||
: undefined;
|
||||
return policy ? [policy] : [];
|
||||
}
|
||||
|
||||
/** Whether the user is signed in */
|
||||
@@ -177,7 +166,7 @@ export default class AuthStore extends Store<Team> {
|
||||
}
|
||||
|
||||
@computed
|
||||
get asJson() {
|
||||
get asJson(): PersistedData {
|
||||
return {
|
||||
user: this.user,
|
||||
team: this.team,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -68,12 +68,16 @@ export default class PresenceStore {
|
||||
|
||||
@action
|
||||
private update(documentId: string, userId: string, isEditing: boolean) {
|
||||
const existing = this.data.get(documentId) || new Map();
|
||||
existing.set(userId, {
|
||||
isEditing,
|
||||
userId,
|
||||
});
|
||||
this.data.set(documentId, existing);
|
||||
const presence = this.data.get(documentId) || new Map();
|
||||
const existing = presence.get(userId);
|
||||
|
||||
if (!existing || existing.isEditing !== isEditing) {
|
||||
presence.set(userId, {
|
||||
isEditing,
|
||||
userId,
|
||||
});
|
||||
this.data.set(documentId, presence);
|
||||
}
|
||||
}
|
||||
|
||||
public get(documentId: string): DocumentPresence | null | undefined {
|
||||
|
||||
@@ -63,6 +63,7 @@ export default class DocumentsStore extends Store<Document> {
|
||||
".md",
|
||||
".doc",
|
||||
".docx",
|
||||
"text/csv",
|
||||
"text/markdown",
|
||||
"text/plain",
|
||||
"text/html",
|
||||
@@ -367,6 +368,10 @@ export default class DocumentsStore extends Store<Document> {
|
||||
fetchTemplates = async (options?: PaginationParams): Promise<Document[]> =>
|
||||
this.fetchNamedPage("list", { ...options, template: true });
|
||||
|
||||
@action
|
||||
fetchAllTemplates = async (options?: PaginationParams): Promise<Document[]> =>
|
||||
this.fetchAll({ ...options, template: true });
|
||||
|
||||
@action
|
||||
fetchAlphabetical = async (options?: PaginationParams): Promise<Document[]> =>
|
||||
this.fetchNamedPage("list", {
|
||||
|
||||
@@ -4,7 +4,7 @@ import Event from "~/models/Event";
|
||||
import RootStore from "./RootStore";
|
||||
import Store, { RPCAction } from "./base/Store";
|
||||
|
||||
export default class EventsStore extends Store<Event> {
|
||||
export default class EventsStore extends Store<Event<any>> {
|
||||
actions = [RPCAction.List];
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
@@ -12,7 +12,7 @@ export default class EventsStore extends Store<Event> {
|
||||
}
|
||||
|
||||
@computed
|
||||
get orderedData(): Event[] {
|
||||
get orderedData(): Event<any>[] {
|
||||
return orderBy(Array.from(this.data.values()), "createdAt", "desc");
|
||||
}
|
||||
}
|
||||
|
||||
+28
-61
@@ -1,4 +1,4 @@
|
||||
import { action, autorun, computed, observable } from "mobx";
|
||||
import { action, computed, observable } from "mobx";
|
||||
import { flushSync } from "react-dom";
|
||||
import { light as defaultTheme } from "@shared/styles/theme";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
@@ -23,15 +23,16 @@ export enum SystemTheme {
|
||||
Dark = "dark",
|
||||
}
|
||||
|
||||
type PersistedData = {
|
||||
languagePromptDismissed: boolean | undefined;
|
||||
theme: Theme;
|
||||
sidebarCollapsed: boolean;
|
||||
sidebarWidth: number;
|
||||
sidebarRightWidth: number;
|
||||
tocVisible: boolean | undefined;
|
||||
commentsExpanded: string[];
|
||||
};
|
||||
type PersistedData = Pick<
|
||||
UiStore,
|
||||
| "languagePromptDismissed"
|
||||
| "commentsExpanded"
|
||||
| "theme"
|
||||
| "sidebarWidth"
|
||||
| "sidebarRightWidth"
|
||||
| "sidebarCollapsed"
|
||||
| "tocVisible"
|
||||
>;
|
||||
|
||||
class UiStore {
|
||||
// has the user seen the prompt to change the UI language and actioned it
|
||||
@@ -74,7 +75,7 @@ class UiStore {
|
||||
sidebarCollapsed = false;
|
||||
|
||||
@observable
|
||||
commentsExpanded: string[] = [];
|
||||
commentsExpanded = false;
|
||||
|
||||
@observable
|
||||
sidebarIsResizing = false;
|
||||
@@ -98,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
|
||||
@@ -134,10 +135,6 @@ class UiStore {
|
||||
this.tocVisible = newData.tocVisible;
|
||||
}
|
||||
});
|
||||
|
||||
autorun(() => {
|
||||
Storage.set(UI_STORE, this.asJson);
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -145,14 +142,9 @@ class UiStore {
|
||||
startViewTransition(() => {
|
||||
flushSync(() => {
|
||||
this.theme = theme;
|
||||
this.persist();
|
||||
});
|
||||
});
|
||||
Storage.set("theme", this.theme);
|
||||
};
|
||||
|
||||
@action
|
||||
setLanguagePromptDismissed = () => {
|
||||
this.languagePromptDismissed = true;
|
||||
};
|
||||
|
||||
@action
|
||||
@@ -205,64 +197,35 @@ class UiStore {
|
||||
this.activeCollectionId = undefined;
|
||||
};
|
||||
|
||||
@action
|
||||
setSidebarWidth = (width: number): void => {
|
||||
this.sidebarWidth = width;
|
||||
};
|
||||
|
||||
@action
|
||||
setRightSidebarWidth = (width: number): void => {
|
||||
this.sidebarRightWidth = width;
|
||||
};
|
||||
|
||||
@action
|
||||
collapseSidebar = () => {
|
||||
this.sidebarCollapsed = true;
|
||||
this.set({ sidebarCollapsed: true });
|
||||
};
|
||||
|
||||
@action
|
||||
expandSidebar = () => {
|
||||
sidebarHidden = false;
|
||||
this.sidebarCollapsed = false;
|
||||
this.set({ sidebarCollapsed: false });
|
||||
};
|
||||
|
||||
@action
|
||||
collapseComments = (documentId: string) => {
|
||||
this.commentsExpanded = this.commentsExpanded.filter(
|
||||
(id) => id !== documentId
|
||||
);
|
||||
};
|
||||
|
||||
@action
|
||||
expandComments = (documentId: string) => {
|
||||
if (!this.commentsExpanded.includes(documentId)) {
|
||||
this.commentsExpanded.push(documentId);
|
||||
set = (data: Partial<PersistedData>) => {
|
||||
for (const key in data) {
|
||||
// @ts-expect-error doesn't understand PersistedData is subset of keys
|
||||
this[key] = data[key];
|
||||
}
|
||||
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
|
||||
toggleCollapsedSidebar = () => {
|
||||
sidebarHidden = false;
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed;
|
||||
};
|
||||
|
||||
@action
|
||||
showTableOfContents = () => {
|
||||
this.tocVisible = true;
|
||||
};
|
||||
|
||||
@action
|
||||
hideTableOfContents = () => {
|
||||
this.tocVisible = false;
|
||||
this.set({ sidebarCollapsed: !this.sidebarCollapsed });
|
||||
};
|
||||
|
||||
@action
|
||||
@@ -324,6 +287,10 @@ class UiStore {
|
||||
theme: this.theme,
|
||||
};
|
||||
}
|
||||
|
||||
private persist = () => {
|
||||
Storage.set(UI_STORE, this.asJson);
|
||||
};
|
||||
}
|
||||
|
||||
export default UiStore;
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
+20
-18
@@ -48,11 +48,11 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.616.0",
|
||||
"@aws-sdk/lib-storage": "3.616.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.616.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.616.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.616.0",
|
||||
"@aws-sdk/client-s3": "3.693.0",
|
||||
"@aws-sdk/lib-storage": "3.693.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.693.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.693.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.693.0",
|
||||
"@babel/core": "^7.24.7",
|
||||
"@babel/plugin-proposal-decorators": "^7.24.7",
|
||||
"@babel/plugin-transform-class-properties": "^7.24.7",
|
||||
@@ -68,6 +68,7 @@
|
||||
"@dnd-kit/modifiers": "^6.0.1",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@fast-csv/parse": "^5.0.2",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.5.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
||||
@@ -78,11 +79,11 @@
|
||||
"@hocuspocus/server": "1.1.2",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.49",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@octokit/auth-app": "^6.1.2",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
"@outlinewiki/koa-passport": "^4.2.1",
|
||||
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
||||
"@renderlesskit/react": "^0.11.0",
|
||||
"@sentry/node": "^7.117.0",
|
||||
"@sentry/node": "^7.119.0",
|
||||
"@sentry/react": "^7.119.0",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@types/form-data": "^2.5.0",
|
||||
@@ -149,7 +150,7 @@
|
||||
"markdown-it": "^13.0.2",
|
||||
"markdown-it-container": "^3.0.0",
|
||||
"markdown-it-emoji": "^2.0.0",
|
||||
"mermaid": "9.3.0",
|
||||
"mermaid": "11.4.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"mobx": "^4.15.4",
|
||||
"mobx-react": "^6.3.1",
|
||||
@@ -183,7 +184,7 @@
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.4.0",
|
||||
"prosemirror-transform": "1.10.0",
|
||||
"prosemirror-view": "^1.34.3",
|
||||
"prosemirror-view": "^1.36.0",
|
||||
"query-string": "^7.1.3",
|
||||
"randomstring": "1.3.0",
|
||||
"rate-limiter-flexible": "^2.4.2",
|
||||
@@ -207,6 +208,7 @@
|
||||
"react-waypoint": "^10.3.0",
|
||||
"react-window": "^1.8.10",
|
||||
"reakit": "^1.3.11",
|
||||
"redlock": "^5.0.0-beta.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"refractor": "^3.6.0",
|
||||
"request-filtering-agent": "^1.1.2",
|
||||
@@ -238,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",
|
||||
@@ -247,13 +249,13 @@
|
||||
"y-protocols": "^1.0.6",
|
||||
"yauzl": "^2.10.0",
|
||||
"yjs": "^13.6.1",
|
||||
"zod": "^3.22.4"
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.25.9",
|
||||
"@babel/preset-typescript": "^7.24.1",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@relative-ci/agent": "^4.2.12",
|
||||
"@relative-ci/agent": "^4.2.13",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
"@types/addressparser": "^1.0.3",
|
||||
"@types/body-scroll-lock": "^3.1.2",
|
||||
@@ -269,7 +271,7 @@
|
||||
"@types/glob": "^8.0.1",
|
||||
"@types/google.analytics": "^0.0.46",
|
||||
"@types/invariant": "^2.2.37",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/koa": "^2.15.0",
|
||||
@@ -284,7 +286,6 @@
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/markdown-it-container": "^2.0.9",
|
||||
"@types/markdown-it-emoji": "^2.0.4",
|
||||
"@types/mermaid": "^9.2.0",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/natural-sort": "^0.0.24",
|
||||
"@types/node": "20.14.2",
|
||||
@@ -305,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",
|
||||
@@ -340,7 +341,7 @@
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "^7.35.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"husky": "^8.0.3",
|
||||
"i18next-parser": "^7.9.0",
|
||||
"jest-cli": "^29.7.0",
|
||||
@@ -353,12 +354,13 @@
|
||||
"react-refresh": "^0.14.0",
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "^0.4.1",
|
||||
"terser": "^5.32.0",
|
||||
"terser": "^5.36.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite-plugin-static-copy": "^0.17.0",
|
||||
"yarn-deduplicate": "^6.0.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"prosemirror-transform": "1.10.0",
|
||||
"body-scroll-lock": "^4.0.0-beta.0",
|
||||
"d3": "^7.0.0",
|
||||
"debug": "4.3.4",
|
||||
@@ -367,5 +369,5 @@
|
||||
"qs": "6.9.7",
|
||||
"rollup": "^4.5.1"
|
||||
},
|
||||
"version": "0.80.2"
|
||||
"version": "0.81.0"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -1,9 +1,9 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { Attachment, Event, User } from "@server/models";
|
||||
import { Attachment, User } from "@server/models";
|
||||
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
|
||||
import FileStorage from "@server/storage/files";
|
||||
import { APIContext } from "@server/types";
|
||||
import { RequestInit } from "@server/utils/fetch";
|
||||
|
||||
type BaseProps = {
|
||||
@@ -17,10 +17,8 @@ type BaseProps = {
|
||||
source?: "import";
|
||||
/** The preset to use for the attachment */
|
||||
preset: AttachmentPreset;
|
||||
/** The IP address of the user creating the attachment, if available. */
|
||||
ip?: string;
|
||||
/** The database transaction to use for the creation */
|
||||
transaction?: Transaction;
|
||||
/** The request context */
|
||||
ctx: APIContext;
|
||||
/** Options to pass to fetch when downloading the attachment */
|
||||
fetchOptions?: RequestInit;
|
||||
};
|
||||
@@ -42,8 +40,7 @@ export default async function attachmentCreator({
|
||||
user,
|
||||
source,
|
||||
preset,
|
||||
ip,
|
||||
transaction,
|
||||
ctx,
|
||||
fetchOptions,
|
||||
...rest
|
||||
}: Props): Promise<Attachment | undefined> {
|
||||
@@ -64,20 +61,15 @@ export default async function attachmentCreator({
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
attachment = await Attachment.create(
|
||||
{
|
||||
id,
|
||||
key,
|
||||
acl,
|
||||
size: res.contentLength,
|
||||
contentType: res.contentType,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
attachment = await Attachment.createWithCtx(ctx, {
|
||||
id,
|
||||
key,
|
||||
acl,
|
||||
size: res.contentLength,
|
||||
contentType: res.contentType,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
} else {
|
||||
const { buffer, type } = rest;
|
||||
await FileStorage.store({
|
||||
@@ -88,38 +80,16 @@ export default async function attachmentCreator({
|
||||
acl,
|
||||
});
|
||||
|
||||
attachment = await Attachment.create(
|
||||
{
|
||||
id,
|
||||
key,
|
||||
acl,
|
||||
size: buffer.length,
|
||||
contentType: type,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "attachments.create",
|
||||
data: {
|
||||
name,
|
||||
source,
|
||||
},
|
||||
modelId: attachment.id,
|
||||
attachment = await Attachment.createWithCtx(ctx, {
|
||||
id,
|
||||
key,
|
||||
acl,
|
||||
size: buffer.length,
|
||||
contentType: type,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
ip,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
FileOperationFormat,
|
||||
@@ -6,8 +5,9 @@ import {
|
||||
FileOperationState,
|
||||
} from "@shared/types";
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
import { Collection, Event, Team, User, FileOperation } from "@server/models";
|
||||
import { Collection, Team, User, FileOperation } from "@server/models";
|
||||
import { Buckets } from "@server/models/helpers/AttachmentHelper";
|
||||
import { type APIContext } from "@server/types";
|
||||
|
||||
type Props = {
|
||||
collection?: Collection;
|
||||
@@ -15,8 +15,7 @@ type Props = {
|
||||
user: User;
|
||||
format?: FileOperationFormat;
|
||||
includeAttachments?: boolean;
|
||||
ip: string;
|
||||
transaction: Transaction;
|
||||
ctx: APIContext;
|
||||
};
|
||||
|
||||
function getKeyForFileOp(
|
||||
@@ -35,8 +34,7 @@ async function collectionExporter({
|
||||
user,
|
||||
format = FileOperationFormat.MarkdownZip,
|
||||
includeAttachments = true,
|
||||
ip,
|
||||
transaction,
|
||||
ctx,
|
||||
}: Props) {
|
||||
const collectionId = collection?.id;
|
||||
const key = getKeyForFileOp(
|
||||
@@ -44,43 +42,20 @@ async function collectionExporter({
|
||||
format,
|
||||
collection?.name || team.name
|
||||
);
|
||||
const fileOperation = await FileOperation.create(
|
||||
{
|
||||
type: FileOperationType.Export,
|
||||
state: FileOperationState.Creating,
|
||||
format,
|
||||
key,
|
||||
url: null,
|
||||
size: 0,
|
||||
collectionId,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
options: {
|
||||
includeAttachments,
|
||||
},
|
||||
const fileOperation = await FileOperation.createWithCtx(ctx, {
|
||||
type: FileOperationType.Export,
|
||||
state: FileOperationState.Creating,
|
||||
format,
|
||||
key,
|
||||
url: null,
|
||||
size: 0,
|
||||
collectionId,
|
||||
options: {
|
||||
includeAttachments,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "fileOperations.create",
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
modelId: fileOperation.id,
|
||||
collectionId,
|
||||
ip,
|
||||
data: {
|
||||
type: FileOperationType.Export,
|
||||
format,
|
||||
},
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
fileOperation.user = user;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Transaction } from "sequelize";
|
||||
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<
|
||||
Pick<
|
||||
@@ -31,8 +31,7 @@ type Props = Optional<
|
||||
publish?: boolean;
|
||||
templateDocument?: Document | null;
|
||||
user: User;
|
||||
ip?: string;
|
||||
transaction?: Transaction;
|
||||
ctx: APIContext;
|
||||
};
|
||||
|
||||
export default async function documentCreator({
|
||||
@@ -58,9 +57,9 @@ export default async function documentCreator({
|
||||
editorVersion,
|
||||
publishedAt,
|
||||
sourceMetadata,
|
||||
ip,
|
||||
transaction,
|
||||
ctx,
|
||||
}: Props): Promise<Document> {
|
||||
const { transaction, ip } = ctx.context;
|
||||
const templateId = templateDocument ? templateDocument.id : undefined;
|
||||
|
||||
if (state && templateDocument) {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { createContext } from "@server/context";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import documentDuplicator from "./documentDuplicator";
|
||||
|
||||
describe("documentDuplicator", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should duplicate existing document", async () => {
|
||||
const user = await buildUser();
|
||||
const original = await buildDocument({
|
||||
@@ -16,9 +15,8 @@ describe("documentDuplicator", () => {
|
||||
documentDuplicator({
|
||||
document: original,
|
||||
collection: original.collection,
|
||||
transaction,
|
||||
user,
|
||||
ip,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -43,9 +41,8 @@ describe("documentDuplicator", () => {
|
||||
document: original,
|
||||
collection: original.collection,
|
||||
title: "New title",
|
||||
transaction,
|
||||
user,
|
||||
ip,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -77,9 +74,8 @@ describe("documentDuplicator", () => {
|
||||
document: original,
|
||||
collection: original.collection,
|
||||
user,
|
||||
transaction,
|
||||
recursive: true,
|
||||
ip,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -97,10 +93,9 @@ describe("documentDuplicator", () => {
|
||||
documentDuplicator({
|
||||
document: original,
|
||||
collection: original.collection,
|
||||
transaction,
|
||||
publish: false,
|
||||
user,
|
||||
ip,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user