mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e07aa877f | |||
| 19d5ef5694 | |||
| b37074304a | |||
| 35c7cc2086 | |||
| 82f9600d9e | |||
| 686f9aeb5c | |||
| a41e17f875 | |||
| db114fd966 | |||
| ce987d23ed | |||
| 5e61fcd336 | |||
| 4f84daf558 | |||
| f80842ca20 | |||
| 53758b69fb | |||
| cd86877cb0 | |||
| a2d5598b96 | |||
| ffae5d2f20 | |||
| 53272c8c3d | |||
| 65ff9bde3e | |||
| 21adfdd1bf | |||
| 91c2f60827 | |||
| a253d2921a | |||
| b83d218fbe | |||
| dce96955a1 | |||
| ba7c446f59 | |||
| 7b9ec4c43a | |||
| faaf0a6733 | |||
| c58aafeb32 | |||
| 3f73c9d2bf | |||
| b6e43e1990 | |||
| 0a2c066253 | |||
| 840db4692e | |||
| fa961d7464 | |||
| 3e75b24f7a | |||
| ce91071995 | |||
| 9b807f7a9e | |||
| 17493ca0cf | |||
| 1d4b05c9f6 | |||
| 8a5e42071f | |||
| 6b53755f5a | |||
| 709e4f44fd | |||
| c37646b5ad | |||
| 36ca667c50 | |||
| 009e66a466 | |||
| 7adda26c6d | |||
| 62860c593b | |||
| bdc2357984 | |||
| 4fc1ed0d7e | |||
| 5d068361cc | |||
| 176cfff7f8 | |||
| 2fd18f7fdb | |||
| 34f951c511 | |||
| f0c26cf8c8 | |||
| d77ddbd7de | |||
| 4e1038837b | |||
| c54fcc3536 | |||
| c4fa63df3d | |||
| 2b42ce0c0f | |||
| 3208156591 | |||
| e8577ef2a8 | |||
| ca66dec22b | |||
| 41ccad7cce | |||
| bd52b364dd |
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.79.1
|
||||
Licensed Work: Outline 0.80.2
|
||||
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-05
|
||||
Change Date: 2028-09-26
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import SharePopover from "~/components/Sharing/Collection/SharePopover";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import { createAction } from "~/actions";
|
||||
import { CollectionSection } from "~/actions/sections";
|
||||
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import history from "~/utils/history";
|
||||
import { newTemplatePath, searchPath } from "~/utils/routeHelpers";
|
||||
@@ -70,7 +70,7 @@ export const editCollection = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? `${t("Edit")}…` : t("Edit collection"),
|
||||
analyticsName: "Edit collection",
|
||||
section: CollectionSection,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <EditIcon />,
|
||||
visible: ({ activeCollectionId }) =>
|
||||
!!activeCollectionId &&
|
||||
@@ -96,7 +96,7 @@ export const editCollectionPermissions = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? `${t("Permissions")}…` : t("Collection permissions"),
|
||||
analyticsName: "Collection permissions",
|
||||
section: CollectionSection,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <PadlockIcon />,
|
||||
visible: ({ activeCollectionId }) =>
|
||||
!!activeCollectionId &&
|
||||
@@ -127,7 +127,7 @@ export const editCollectionPermissions = createAction({
|
||||
export const searchInCollection = createAction({
|
||||
name: ({ t }) => t("Search in collection"),
|
||||
analyticsName: "Search collection",
|
||||
section: CollectionSection,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: ({ activeCollectionId }) =>
|
||||
!!activeCollectionId &&
|
||||
@@ -140,7 +140,7 @@ export const searchInCollection = createAction({
|
||||
export const starCollection = createAction({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star collection",
|
||||
section: CollectionSection,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <StarredIcon />,
|
||||
keywords: "favorite bookmark",
|
||||
visible: ({ activeCollectionId }) => {
|
||||
@@ -167,7 +167,7 @@ export const starCollection = createAction({
|
||||
export const unstarCollection = createAction({
|
||||
name: ({ t }) => t("Unstar"),
|
||||
analyticsName: "Unstar collection",
|
||||
section: CollectionSection,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <UnstarredIcon />,
|
||||
keywords: "unfavorite unbookmark",
|
||||
visible: ({ activeCollectionId }) => {
|
||||
@@ -193,7 +193,7 @@ export const unstarCollection = createAction({
|
||||
export const deleteCollection = createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete collection",
|
||||
section: CollectionSection,
|
||||
section: ActiveCollectionSection,
|
||||
dangerous: true,
|
||||
icon: <TrashIcon />,
|
||||
visible: ({ activeCollectionId }) => {
|
||||
@@ -227,7 +227,7 @@ export const deleteCollection = createAction({
|
||||
export const createTemplate = createAction({
|
||||
name: ({ t }) => t("New template"),
|
||||
analyticsName: "New template",
|
||||
section: CollectionSection,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ShapesIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ activeCollectionId }) =>
|
||||
|
||||
@@ -27,10 +27,15 @@ import {
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
PadlockIcon,
|
||||
GlobeIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ExportContentType, TeamPreference } from "@shared/types";
|
||||
import {
|
||||
ExportContentType,
|
||||
TeamPreference,
|
||||
NavigationNode,
|
||||
} from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
import DocumentMove from "~/scenes/DocumentMove";
|
||||
@@ -39,11 +44,17 @@ import DocumentPublish from "~/scenes/DocumentPublish";
|
||||
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DuplicateDialog from "~/components/DuplicateDialog";
|
||||
import Icon from "~/components/Icon";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import SharePopover from "~/components/Sharing/Document";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
|
||||
import { createAction } from "~/actions";
|
||||
import { DocumentSection, TrashSection } from "~/actions/sections";
|
||||
import {
|
||||
ActiveDocumentSection,
|
||||
DocumentSection,
|
||||
TrashSection,
|
||||
} from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import history from "~/utils/history";
|
||||
@@ -67,23 +78,24 @@ export const openDocument = createAction({
|
||||
keywords: "go to",
|
||||
icon: <DocumentIcon />,
|
||||
children: ({ stores }) => {
|
||||
const paths = stores.collections.pathsToDocuments;
|
||||
const nodes = stores.collections.navigationNodes.reduce(
|
||||
(acc, node) => [...acc, ...node.children],
|
||||
[] as NavigationNode[]
|
||||
);
|
||||
|
||||
return paths
|
||||
.filter((path) => path.type === "document")
|
||||
.map((path) => ({
|
||||
// Note: using url which includes the slug rather than id here to bust
|
||||
// cache if the document is renamed
|
||||
id: path.url,
|
||||
name: path.title,
|
||||
icon: function _Icon() {
|
||||
return stores.documents.get(path.id)?.isStarred ? (
|
||||
<StarredIcon />
|
||||
) : null;
|
||||
},
|
||||
section: DocumentSection,
|
||||
perform: () => history.push(path.url),
|
||||
}));
|
||||
return nodes.map((item) => ({
|
||||
// Note: using url which includes the slug rather than id here to bust
|
||||
// cache if the document is renamed
|
||||
id: item.url,
|
||||
name: item.title,
|
||||
icon: item.icon ? (
|
||||
<Icon value={item.icon} color={item.color ?? undefined} />
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
section: DocumentSection,
|
||||
perform: () => history.push(item.url),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -134,7 +146,7 @@ export const createDocumentFromTemplate = createAction({
|
||||
export const createNestedDocument = createAction({
|
||||
name: ({ t }) => t("New nested document"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) =>
|
||||
@@ -151,7 +163,7 @@ export const createNestedDocument = createAction({
|
||||
export const starDocument = createAction({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <StarredIcon />,
|
||||
keywords: "favorite bookmark",
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
@@ -177,7 +189,7 @@ export const starDocument = createAction({
|
||||
export const unstarDocument = createAction({
|
||||
name: ({ t }) => t("Unstar"),
|
||||
analyticsName: "Unstar document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <UnstarredIcon />,
|
||||
keywords: "unfavorite unbookmark",
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
@@ -203,7 +215,7 @@ export const unstarDocument = createAction({
|
||||
export const publishDocument = createAction({
|
||||
name: ({ t }) => t("Publish"),
|
||||
analyticsName: "Publish document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <PublishIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
@@ -245,7 +257,7 @@ export const publishDocument = createAction({
|
||||
export const unpublishDocument = createAction({
|
||||
name: ({ t }) => t("Unpublish"),
|
||||
analyticsName: "Unpublish document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <UnpublishIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
@@ -276,7 +288,7 @@ export const unpublishDocument = createAction({
|
||||
export const subscribeDocument = createAction({
|
||||
name: ({ t }) => t("Subscribe"),
|
||||
analyticsName: "Subscribe to document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <SubscribeIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
@@ -304,7 +316,7 @@ export const subscribeDocument = createAction({
|
||||
export const unsubscribeDocument = createAction({
|
||||
name: ({ t }) => t("Unsubscribe"),
|
||||
analyticsName: "Unsubscribe from document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <UnsubscribeIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
@@ -334,7 +346,7 @@ export const unsubscribeDocument = createAction({
|
||||
export const shareDocument = createAction({
|
||||
name: ({ t }) => `${t("Permissions")}…`,
|
||||
analyticsName: "Share document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <PadlockIcon />,
|
||||
visible: ({ stores, activeDocumentId }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId!);
|
||||
@@ -371,7 +383,7 @@ export const shareDocument = createAction({
|
||||
export const downloadDocumentAsHTML = createAction({
|
||||
name: ({ t }) => t("HTML"),
|
||||
analyticsName: "Download document as HTML",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "html export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
@@ -390,7 +402,7 @@ export const downloadDocumentAsHTML = createAction({
|
||||
export const downloadDocumentAsPDF = createAction({
|
||||
name: ({ t }) => t("PDF"),
|
||||
analyticsName: "Download document as PDF",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
@@ -414,7 +426,7 @@ export const downloadDocumentAsPDF = createAction({
|
||||
export const downloadDocumentAsMarkdown = createAction({
|
||||
name: ({ t }) => t("Markdown"),
|
||||
analyticsName: "Download document as Markdown",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "md markdown export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
@@ -434,9 +446,11 @@ export const downloadDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Download") : t("Download document"),
|
||||
analyticsName: "Download document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <DownloadIcon />,
|
||||
keywords: "export",
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
children: [
|
||||
downloadDocumentAsHTML,
|
||||
downloadDocumentAsPDF,
|
||||
@@ -446,8 +460,10 @@ export const downloadDocument = createAction({
|
||||
|
||||
export const copyDocumentAsMarkdown = createAction({
|
||||
name: ({ t }) => t("Copy as Markdown"),
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard",
|
||||
icon: <MarkdownIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ stores, activeDocumentId, t }) => {
|
||||
@@ -461,10 +477,33 @@ export const copyDocumentAsMarkdown = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentShareLink = createAction({
|
||||
name: ({ t }) => t("Copy public link"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard share",
|
||||
icon: <GlobeIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId &&
|
||||
!!stores.shares.getByDocumentId(activeDocumentId)?.published,
|
||||
perform: ({ stores, activeDocumentId, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
const share = stores.shares.getByDocumentId(activeDocumentId);
|
||||
if (share) {
|
||||
copy(share.url);
|
||||
toast.success(t("Link copied to clipboard"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentLink = createAction({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard",
|
||||
icon: <CopyIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId }) => !!activeDocumentId,
|
||||
perform: ({ stores, activeDocumentId, t }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -480,17 +519,17 @@ export const copyDocumentLink = createAction({
|
||||
export const copyDocument = createAction({
|
||||
name: ({ t }) => t("Copy"),
|
||||
analyticsName: "Copy document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <CopyIcon />,
|
||||
keywords: "clipboard",
|
||||
children: [copyDocumentLink, copyDocumentAsMarkdown],
|
||||
children: [copyDocumentLink, copyDocumentShareLink, copyDocumentAsMarkdown],
|
||||
});
|
||||
|
||||
export const duplicateDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Duplicate") : t("Duplicate document"),
|
||||
analyticsName: "Duplicate document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <DuplicateIcon />,
|
||||
keywords: "copy",
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
@@ -534,7 +573,7 @@ export const pinDocumentToCollection = createAction({
|
||||
});
|
||||
},
|
||||
analyticsName: "Pin document to collection",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <PinIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
|
||||
@@ -570,7 +609,7 @@ export const pinDocumentToCollection = createAction({
|
||||
export const pinDocumentToHome = createAction({
|
||||
name: ({ t }) => t("Pin to home"),
|
||||
analyticsName: "Pin document to home",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <PinIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, currentTeamId, stores }) => {
|
||||
@@ -602,7 +641,7 @@ export const pinDocumentToHome = createAction({
|
||||
export const pinDocument = createAction({
|
||||
name: ({ t }) => t("Pin"),
|
||||
analyticsName: "Pin document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <PinIcon />,
|
||||
children: [pinDocumentToCollection, pinDocumentToHome],
|
||||
});
|
||||
@@ -610,7 +649,7 @@ export const pinDocument = createAction({
|
||||
export const searchInDocument = createAction({
|
||||
name: ({ t }) => t("Search in document"),
|
||||
analyticsName: "Search document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: ({ stores, activeDocumentId }) => {
|
||||
if (!activeDocumentId) {
|
||||
@@ -628,7 +667,7 @@ export const printDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Print") : t("Print document"),
|
||||
analyticsName: "Print document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <PrintIcon />,
|
||||
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
|
||||
perform: () => {
|
||||
@@ -687,7 +726,7 @@ export const importDocument = createAction({
|
||||
export const createTemplateFromDocument = createAction({
|
||||
name: ({ t }) => t("Templatize"),
|
||||
analyticsName: "Templatize document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
|
||||
@@ -722,14 +761,14 @@ export const openRandomDocument = createAction({
|
||||
section: DocumentSection,
|
||||
icon: <ShuffleIcon />,
|
||||
perform: ({ stores, activeDocumentId }) => {
|
||||
const documentPaths = stores.collections.pathsToDocuments.filter(
|
||||
(path) => path.type === "document" && path.id !== activeDocumentId
|
||||
);
|
||||
const randomPath =
|
||||
documentPaths[Math.round(Math.random() * documentPaths.length)];
|
||||
const nodes = stores.collections.navigationNodes
|
||||
.reduce((acc, node) => [...acc, ...node.children], [] as NavigationNode[])
|
||||
.filter((node) => node.id !== activeDocumentId);
|
||||
|
||||
if (randomPath) {
|
||||
history.push(randomPath.url);
|
||||
const random = nodes[Math.round(Math.random() * nodes.length)];
|
||||
|
||||
if (random) {
|
||||
history.push(random.url);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -787,7 +826,7 @@ export const moveDocumentToCollection = createAction({
|
||||
: t("Move");
|
||||
},
|
||||
analyticsName: "Move document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
@@ -816,7 +855,7 @@ export const moveDocumentToCollection = createAction({
|
||||
export const moveDocument = createAction({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
@@ -835,7 +874,7 @@ export const moveDocument = createAction({
|
||||
export const moveTemplate = createAction({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
@@ -854,7 +893,7 @@ export const moveTemplate = createAction({
|
||||
export const archiveDocument = createAction({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
@@ -894,7 +933,7 @@ export const archiveDocument = createAction({
|
||||
export const deleteDocument = createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
@@ -928,7 +967,7 @@ export const deleteDocument = createAction({
|
||||
export const permanentlyDeleteDocument = createAction({
|
||||
name: ({ t }) => t("Permanently delete"),
|
||||
analyticsName: "Permanently delete document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <CrossIcon />,
|
||||
dangerous: true,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
@@ -983,7 +1022,7 @@ export const permanentlyDeleteDocumentsInTrash = createAction({
|
||||
export const openDocumentComments = createAction({
|
||||
name: ({ t }) => t("Comments"),
|
||||
analyticsName: "Open comments",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <CommentIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
@@ -1005,7 +1044,7 @@ export const openDocumentComments = createAction({
|
||||
export const openDocumentHistory = createAction({
|
||||
name: ({ t }) => t("History"),
|
||||
analyticsName: "Open document history",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <HistoryIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
@@ -1026,7 +1065,7 @@ export const openDocumentHistory = createAction({
|
||||
export const openDocumentInsights = createAction({
|
||||
name: ({ t }) => t("Insights"),
|
||||
analyticsName: "Open document insights",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <GraphIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
@@ -1063,7 +1102,7 @@ export const toggleViewerInsights = createAction({
|
||||
: t("Enable viewer insights");
|
||||
},
|
||||
analyticsName: "Toggle viewer insights",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <EyeIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
@@ -1093,6 +1132,7 @@ export const rootDocumentActions = [
|
||||
importDocument,
|
||||
downloadDocument,
|
||||
copyDocumentLink,
|
||||
copyDocumentShareLink,
|
||||
copyDocumentAsMarkdown,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
|
||||
@@ -98,6 +98,11 @@ export function actionToKBar(
|
||||
)
|
||||
: [];
|
||||
|
||||
const sectionPriority =
|
||||
typeof action.section !== "string" && "priority" in action.section
|
||||
? (action.section.priority as number) ?? 0
|
||||
: 0;
|
||||
|
||||
return [
|
||||
{
|
||||
id: action.id,
|
||||
@@ -108,6 +113,7 @@ export function actionToKBar(
|
||||
keywords: action.keywords ?? "",
|
||||
shortcut: action.shortcut || [],
|
||||
icon: resolvedIcon,
|
||||
priority: (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0)),
|
||||
perform: action.perform
|
||||
? () => performAction(action, context)
|
||||
: undefined,
|
||||
|
||||
@@ -2,10 +2,28 @@ import { ActionContext } from "~/types";
|
||||
|
||||
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
|
||||
|
||||
export const ActiveCollectionSection = ({ t, stores }: ActionContext) => {
|
||||
const activeCollection = stores.collections.active;
|
||||
return `${t("Collection")} · ${activeCollection?.name}`;
|
||||
};
|
||||
|
||||
ActiveCollectionSection.priority = 0.8;
|
||||
|
||||
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
|
||||
|
||||
export const DocumentSection = ({ t }: ActionContext) => t("Document");
|
||||
|
||||
export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
|
||||
const activeDocument = stores.documents.active;
|
||||
return `${t("Document")} · ${activeDocument?.titleWithDefault}`;
|
||||
};
|
||||
|
||||
ActiveDocumentSection.priority = 0.9;
|
||||
|
||||
export const RecentSection = ({ t }: ActionContext) => t("Recently viewed");
|
||||
|
||||
RecentSection.priority = 1;
|
||||
|
||||
export const RevisionSection = ({ t }: ActionContext) => t("Revision");
|
||||
|
||||
export const SettingsSection = ({ t }: ActionContext) => t("Settings");
|
||||
@@ -21,4 +39,6 @@ export const TeamSection = ({ t }: ActionContext) => t("Workspace");
|
||||
export const RecentSearchesSection = ({ t }: ActionContext) =>
|
||||
t("Recent searches");
|
||||
|
||||
RecentSearchesSection.priority = -0.1;
|
||||
|
||||
export const TrashSection = ({ t }: ActionContext) => t("Trash");
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer, useLocalStore } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import ErrorSuspended from "~/scenes/ErrorSuspended";
|
||||
import DocumentContext from "~/components/DocumentContext";
|
||||
import type { DocumentContextValue } from "~/components/DocumentContext";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
import SidebarRight from "~/components/Sidebar/Right";
|
||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||
import type { Editor as TEditor } from "~/editor";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -25,6 +22,7 @@ import {
|
||||
matchDocumentSlug as slug,
|
||||
matchDocumentInsights,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { DocumentContextProvider } from "./DocumentContext";
|
||||
import Fade from "./Fade";
|
||||
import { PortalContext } from "./Portal";
|
||||
|
||||
@@ -50,12 +48,6 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
const can = usePolicy(ui.activeDocumentId);
|
||||
const canCollection = usePolicy(ui.activeCollectionId);
|
||||
const team = useCurrentTeam();
|
||||
const documentContext = useLocalStore<DocumentContextValue>(() => ({
|
||||
editor: null,
|
||||
setEditor: (editor: TEditor) => {
|
||||
documentContext.editor = editor;
|
||||
},
|
||||
}));
|
||||
|
||||
const goToSearch = (ev: KeyboardEvent) => {
|
||||
if (!ev.metaKey && !ev.ctrlKey) {
|
||||
@@ -125,7 +117,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<DocumentContext.Provider value={documentContext}>
|
||||
<DocumentContextProvider>
|
||||
<PortalContext.Provider value={layoutRef.current}>
|
||||
<Layout
|
||||
title={team.name}
|
||||
@@ -142,7 +134,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
</React.Suspense>
|
||||
</Layout>
|
||||
</PortalContext.Provider>
|
||||
</DocumentContext.Provider>
|
||||
</DocumentContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import Text from "~/components/Text";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||
|
||||
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
||||
|
||||
@@ -156,18 +155,16 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
/>
|
||||
)}
|
||||
|
||||
{team.sharing &&
|
||||
(!collection ||
|
||||
FeatureFlags.isEnabled(Feature.newCollectionSharing)) && (
|
||||
<Switch
|
||||
id="sharing"
|
||||
label={t("Public document sharing")}
|
||||
note={t(
|
||||
"Allow documents within this collection to be shared publicly on the internet."
|
||||
)}
|
||||
{...register("sharing")}
|
||||
/>
|
||||
)}
|
||||
{team.sharing && (
|
||||
<Switch
|
||||
id="sharing"
|
||||
label={t("Public document sharing")}
|
||||
note={t(
|
||||
"Allow documents within this collection to be shared publicly on the internet."
|
||||
)}
|
||||
{...register("sharing")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { runInAction } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import Collection from "~/models/Collection";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import { CollectionForm, FormData } from "./CollectionForm";
|
||||
@@ -17,8 +17,11 @@ export const CollectionNew = observer(function CollectionNew_({
|
||||
const handleSubmit = React.useCallback(
|
||||
async (data: FormData) => {
|
||||
try {
|
||||
const collection = new Collection(data, collections);
|
||||
await collection.save();
|
||||
const collection = await collections.save(data);
|
||||
// Avoid flash of loading state for the new collection, we know it's empty.
|
||||
runInAction(() => {
|
||||
collection.documents = [];
|
||||
});
|
||||
onSubmit?.();
|
||||
history.push(collection.path);
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,20 +6,27 @@ import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import CommandBarResults from "~/components/CommandBarResults";
|
||||
import SearchActions from "~/components/SearchActions";
|
||||
import rootActions from "~/actions/root";
|
||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||
import useSettingsActions from "~/hooks/useSettingsActions";
|
||||
import useTemplateActions from "~/hooks/useTemplateActions";
|
||||
import CommandBarResults from "./CommandBarResults";
|
||||
import useRecentDocumentActions from "./useRecentDocumentActions";
|
||||
import useSettingsAction from "./useSettingsAction";
|
||||
import useTemplatesAction from "./useTemplatesAction";
|
||||
|
||||
function CommandBar() {
|
||||
const { t } = useTranslation();
|
||||
const settingsActions = useSettingsActions();
|
||||
const templateActions = useTemplateActions();
|
||||
const recentDocumentActions = useRecentDocumentActions();
|
||||
const settingsAction = useSettingsAction();
|
||||
const templatesAction = useTemplatesAction();
|
||||
const commandBarActions = React.useMemo(
|
||||
() => [...rootActions, templateActions, settingsActions],
|
||||
[settingsActions, templateActions]
|
||||
() => [
|
||||
...recentDocumentActions,
|
||||
...rootActions,
|
||||
templatesAction,
|
||||
settingsAction,
|
||||
],
|
||||
[recentDocumentActions, settingsAction, templatesAction]
|
||||
);
|
||||
|
||||
useCommandBarActions(commandBarActions);
|
||||
@@ -30,7 +37,9 @@ function CommandBar() {
|
||||
<Positioner>
|
||||
<Animator>
|
||||
<SearchActions />
|
||||
<SearchInput defaultPlaceholder={t("Type a command or search")} />
|
||||
<SearchInput
|
||||
defaultPlaceholder={`${t("Type a command or search")}…`}
|
||||
/>
|
||||
<CommandBarResults />
|
||||
</Animator>
|
||||
</Positioner>
|
||||
@@ -60,12 +69,15 @@ const Positioner = styled(KBarPositioner)`
|
||||
`;
|
||||
|
||||
const SearchInput = styled(KBarSearch)`
|
||||
padding: 16px 20px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
padding: 16px 12px;
|
||||
margin: 0 8px;
|
||||
width: calc(100% - 16px);
|
||||
outline: none;
|
||||
border: none;
|
||||
background: ${s("menuBackground")};
|
||||
color: ${s("text")};
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
@@ -5,7 +5,7 @@ import styled, { css, useTheme } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import Key from "~/components/Key";
|
||||
import Text from "./Text";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
type Props = {
|
||||
action: ActionImpl;
|
||||
+8
-7
@@ -1,8 +1,8 @@
|
||||
import { useMatches, KBarResults } from "kbar";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import CommandBarItem from "~/components/CommandBarItem";
|
||||
import Text from "~/components/Text";
|
||||
import CommandBarItem from "./CommandBarItem";
|
||||
|
||||
export default function CommandBarResults() {
|
||||
const { results, rootActionId } = useMatches();
|
||||
@@ -14,7 +14,9 @@ export default function CommandBarResults() {
|
||||
maxHeight={400}
|
||||
onRender={({ item, active }) =>
|
||||
typeof item === "string" ? (
|
||||
<Header>{item}</Header>
|
||||
<Header type="tertiary" size="xsmall" ellipsis>
|
||||
{item}
|
||||
</Header>
|
||||
) : (
|
||||
<CommandBarItem
|
||||
action={item}
|
||||
@@ -35,11 +37,10 @@ const Container = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const Header = styled.h3`
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.04em;
|
||||
const Header = styled(Text).attrs({ as: "h3" })`
|
||||
letter-spacing: 0.03em;
|
||||
margin: 0;
|
||||
padding: 16px 0 4px 20px;
|
||||
color: ${s("textTertiary")};
|
||||
height: 36px;
|
||||
cursor: default;
|
||||
`;
|
||||
@@ -0,0 +1,3 @@
|
||||
import CommandBar from "./CommandBar";
|
||||
|
||||
export default CommandBar;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Icon from "~/components/Icon";
|
||||
import { createAction } from "~/actions";
|
||||
import { RecentSection } from "~/actions/sections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
|
||||
const useRecentDocumentActions = (count = 6) => {
|
||||
const { documents, ui } = useStores();
|
||||
|
||||
return React.useMemo(
|
||||
() =>
|
||||
documents.recentlyViewed
|
||||
.filter((document) => document.id !== ui.activeDocumentId)
|
||||
.slice(0, count)
|
||||
.map((item) =>
|
||||
createAction({
|
||||
name: item.titleWithDefault,
|
||||
analyticsName: "Recently viewed document",
|
||||
section: RecentSection,
|
||||
icon: item.icon ? (
|
||||
<Icon value={item.icon} color={item.color ?? undefined} />
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
perform: () => history.push(documentPath(item)),
|
||||
})
|
||||
),
|
||||
[count, ui.activeDocumentId, documents.recentlyViewed]
|
||||
);
|
||||
};
|
||||
|
||||
export default useRecentDocumentActions;
|
||||
@@ -2,10 +2,10 @@ import { SettingsIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { createAction } from "~/actions";
|
||||
import { NavigationSection } from "~/actions/sections";
|
||||
import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
import history from "~/utils/history";
|
||||
import useSettingsConfig from "./useSettingsConfig";
|
||||
|
||||
const useSettingsActions = () => {
|
||||
const useSettingsAction = () => {
|
||||
const config = useSettingsConfig();
|
||||
const actions = React.useMemo(
|
||||
() =>
|
||||
@@ -38,4 +38,4 @@ const useSettingsActions = () => {
|
||||
return navigateToSettings;
|
||||
};
|
||||
|
||||
export default useSettingsActions;
|
||||
export default useSettingsAction;
|
||||
@@ -3,11 +3,11 @@ import * as React from "react";
|
||||
import Icon from "~/components/Icon";
|
||||
import { createAction } from "~/actions";
|
||||
import { DocumentSection } from "~/actions/sections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
import useStores from "./useStores";
|
||||
|
||||
const useTemplatesActions = () => {
|
||||
const useTemplatesAction = () => {
|
||||
const { documents } = useStores();
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -60,4 +60,4 @@ const useTemplatesActions = () => {
|
||||
return newFromTemplate;
|
||||
};
|
||||
|
||||
export default useTemplatesActions;
|
||||
export default useTemplatesAction;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { CollectionPermission, NavigationNode } from "@shared/types";
|
||||
import type Collection from "~/models/Collection";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
/** The navigation node to move, must represent a document. */
|
||||
item: NavigationNode;
|
||||
/** The collection to move the document to. */
|
||||
collection: Collection;
|
||||
/** The parent document to move the document under. */
|
||||
parentDocumentId?: string | null;
|
||||
/** The index to move the document to. */
|
||||
index?: number | null;
|
||||
};
|
||||
|
||||
function ConfirmMoveDialog({ collection, item, ...rest }: Props) {
|
||||
const { documents, dialogs, collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const prevCollection = collections.get(item.collectionId!);
|
||||
const accessMapping = {
|
||||
[CollectionPermission.ReadWrite]: t("view and edit access"),
|
||||
[CollectionPermission.Read]: t("view only access"),
|
||||
null: t("no access"),
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
...rest,
|
||||
});
|
||||
dialogs.closeAllModals();
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("Move document")}
|
||||
savingText={`${t("Moving")}…`}
|
||||
>
|
||||
<Trans
|
||||
defaults="Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>."
|
||||
values={{
|
||||
title: item.title,
|
||||
prevCollectionName: prevCollection?.name,
|
||||
newCollectionName: collection.name,
|
||||
prevPermission: accessMapping[prevCollection?.permission || "null"],
|
||||
newPermission: accessMapping[collection.permission || "null"],
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ConfirmMoveDialog);
|
||||
@@ -49,7 +49,7 @@ const DefaultCollectionInputSelect = ({
|
||||
|
||||
const options = React.useMemo(
|
||||
() =>
|
||||
collections.publicCollections.reduce(
|
||||
collections.nonPrivate.reduce(
|
||||
(acc, collection) => [
|
||||
...acc,
|
||||
{
|
||||
@@ -78,7 +78,7 @@ const DefaultCollectionInputSelect = ({
|
||||
},
|
||||
]
|
||||
),
|
||||
[collections.publicCollections, t]
|
||||
[collections.nonPrivate, t]
|
||||
);
|
||||
|
||||
if (fetching) {
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { Editor } from "~/editor";
|
||||
import useIdle from "~/hooks/useIdle";
|
||||
|
||||
export type DocumentContextValue = {
|
||||
/** The current editor instance for this document. */
|
||||
editor: Editor | null;
|
||||
/** Set the current editor instance for this document. */
|
||||
setEditor: (editor: Editor) => void;
|
||||
};
|
||||
|
||||
const DocumentContext = React.createContext<DocumentContextValue>({
|
||||
editor: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
setEditor() {},
|
||||
});
|
||||
|
||||
export const useDocumentContext = () => React.useContext(DocumentContext);
|
||||
|
||||
const activityEvents = [
|
||||
"click",
|
||||
"mousemove",
|
||||
"DOMMouseScroll",
|
||||
"mousewheel",
|
||||
"mousedown",
|
||||
"touchstart",
|
||||
"touchmove",
|
||||
"focus",
|
||||
];
|
||||
|
||||
export const useEditingFocus = () => {
|
||||
const { editor } = useDocumentContext();
|
||||
const isIdle = useIdle(3000, activityEvents);
|
||||
return isIdle && !!editor?.view.hasFocus();
|
||||
};
|
||||
|
||||
export default DocumentContext;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { action, computed, observable } from "mobx";
|
||||
import React, { PropsWithChildren } from "react";
|
||||
import { Heading } from "@shared/utils/ProsemirrorHelper";
|
||||
import Document from "~/models/Document";
|
||||
import { Editor } from "~/editor";
|
||||
|
||||
class DocumentContext {
|
||||
/** The current document */
|
||||
document?: Document;
|
||||
|
||||
/** The editor instance for this document */
|
||||
editor?: Editor;
|
||||
|
||||
@observable
|
||||
headings: Heading[] = [];
|
||||
|
||||
@computed
|
||||
get hasHeadings() {
|
||||
return this.headings.length > 0;
|
||||
}
|
||||
|
||||
@action
|
||||
setDocument = (document: Document) => {
|
||||
this.document = document;
|
||||
this.updateState();
|
||||
};
|
||||
|
||||
@action
|
||||
setEditor = (editor: Editor) => {
|
||||
this.editor = editor;
|
||||
this.updateState();
|
||||
};
|
||||
|
||||
@action
|
||||
updateState = () => {
|
||||
this.updateHeadings();
|
||||
this.updateTasks();
|
||||
};
|
||||
|
||||
private updateHeadings() {
|
||||
const currHeadings = this.editor?.getHeadings() ?? [];
|
||||
const hasChanged =
|
||||
currHeadings.map((h) => h.level + h.title).join("") !==
|
||||
this.headings.map((h) => h.level + h.title).join("");
|
||||
|
||||
if (hasChanged) {
|
||||
this.headings = currHeadings;
|
||||
}
|
||||
}
|
||||
|
||||
private updateTasks() {
|
||||
const tasks = this.editor?.getTasks() ?? [];
|
||||
const total = tasks.length ?? 0;
|
||||
const completed = tasks.filter((t) => t.completed).length ?? 0;
|
||||
this.document?.updateTasks(total, completed);
|
||||
}
|
||||
}
|
||||
|
||||
const Context = React.createContext<DocumentContext | null>(null);
|
||||
|
||||
export const useDocumentContext = () => {
|
||||
const ctx = React.useContext(Context);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useDocumentContext must be used within DocumentContextProvider"
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const DocumentContextProvider = ({
|
||||
children,
|
||||
}: PropsWithChildren<unknown>) => {
|
||||
const context = React.useMemo(() => new DocumentContext(), []);
|
||||
return <Context.Provider value={context}>{children}</Context.Provider>;
|
||||
};
|
||||
@@ -11,7 +11,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { FixedSizeList as List } from "react-window";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled from "styled-components";
|
||||
import { ellipsis } from "@shared/styles";
|
||||
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
|
||||
|
||||
@@ -140,7 +140,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
}
|
||||
|
||||
const nestedDocumentsCount = collection
|
||||
? collection.getDocumentChildren(document.id).length
|
||||
? collection.getChildrenForDocument(document.id).length
|
||||
: 0;
|
||||
const canShowProgressBar = isTasks && !isTemplate;
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { mergeRefs } from "react-merge-refs";
|
||||
import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { Heading } from "@shared/utils/ProsemirrorHelper";
|
||||
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
@@ -43,21 +42,14 @@ export type Props = Optional<
|
||||
> & {
|
||||
shareId?: string | undefined;
|
||||
embedsDisabled?: boolean;
|
||||
onHeadingsChange?: (headings: Heading[]) => void;
|
||||
onSynced?: () => Promise<void>;
|
||||
onPublish?: (event: React.MouseEvent) => void;
|
||||
editorStyle?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const {
|
||||
id,
|
||||
shareId,
|
||||
onChange,
|
||||
onHeadingsChange,
|
||||
onCreateCommentMark,
|
||||
onDeleteCommentMark,
|
||||
} = props;
|
||||
const { id, shareId, onChange, onCreateCommentMark, onDeleteCommentMark } =
|
||||
props;
|
||||
const userLocale = useUserLocale();
|
||||
const locale = dateLocale(userLocale);
|
||||
const { comments, documents } = useStores();
|
||||
@@ -65,7 +57,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const embeds = useEmbeds(!shareId);
|
||||
const localRef = React.useRef<SharedEditor>();
|
||||
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
|
||||
const previousHeadings = React.useRef<Heading[] | null>(null);
|
||||
const previousCommentIds = React.useRef<string[]>();
|
||||
|
||||
const handleSearchLink = React.useCallback(
|
||||
@@ -212,21 +203,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
[]
|
||||
);
|
||||
|
||||
// Calculate if headings have changed and trigger callback if so
|
||||
const updateHeadings = React.useCallback(() => {
|
||||
if (onHeadingsChange) {
|
||||
const headings = localRef?.current?.getHeadings();
|
||||
if (
|
||||
headings &&
|
||||
headings.map((h) => h.level + h.title).join("") !==
|
||||
previousHeadings.current?.map((h) => h.level + h.title).join("")
|
||||
) {
|
||||
previousHeadings.current = headings;
|
||||
onHeadingsChange(headings);
|
||||
}
|
||||
}
|
||||
}, [localRef, onHeadingsChange]);
|
||||
|
||||
const updateComments = React.useCallback(() => {
|
||||
if (onCreateCommentMark && onDeleteCommentMark && localRef.current) {
|
||||
const commentMarks = localRef.current.getComments();
|
||||
@@ -261,20 +237,18 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const handleChange = React.useCallback(
|
||||
(event) => {
|
||||
onChange?.(event);
|
||||
updateHeadings();
|
||||
updateComments();
|
||||
},
|
||||
[onChange, updateComments, updateHeadings]
|
||||
[onChange, updateComments]
|
||||
);
|
||||
|
||||
const handleRefChanged = React.useCallback(
|
||||
(node: SharedEditor | null) => {
|
||||
if (node) {
|
||||
updateHeadings();
|
||||
updateComments();
|
||||
}
|
||||
},
|
||||
[updateComments, updateHeadings]
|
||||
[updateComments]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
|
||||
import { getLuminance } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { IconType } from "@shared/types";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
@@ -118,12 +117,7 @@ export const IconTitleWrapper = styled(Flex)<{ dir?: string }>`
|
||||
z-index: 1;
|
||||
|
||||
${(props: { dir?: string }) =>
|
||||
props.dir === "rtl" ? "right: -40px" : "left: -40px"};
|
||||
|
||||
${breakpoint("desktop")`
|
||||
${(props: { dir?: string }) =>
|
||||
props.dir === "rtl" ? "right: -44px" : "left: -44px"};
|
||||
`}
|
||||
props.dir === "rtl" ? "right: -44px" : "left: -44px"};
|
||||
`;
|
||||
|
||||
export default Icon;
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from "@getoutline/react-roving-tabindex";
|
||||
import { LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function SearchActions() {
|
||||
const { searches } = useStores();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!searches.isLoaded) {
|
||||
if (!searches.isLoaded && !searches.isFetching) {
|
||||
void searches.fetchPage({
|
||||
source: "app",
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import { s } from "@shared/styles";
|
||||
import { CollectionPermission, IconType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import type Collection from "~/models/Collection";
|
||||
@@ -67,7 +68,7 @@ export const AccessControlList = observer(
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const { maxHeight, calcMaxHeight } = useMaxHeight({
|
||||
elementRef: containerRef,
|
||||
maxViewportPercentage: 70,
|
||||
maxViewportPercentage: 65,
|
||||
margin: 24,
|
||||
});
|
||||
|
||||
@@ -201,7 +202,7 @@ export const AccessControlList = observer(
|
||||
</>
|
||||
)}
|
||||
{team.sharing && can.share && !collectionSharingDisabled && visible && (
|
||||
<>
|
||||
<Sticky>
|
||||
{document.members.length ? <Separator /> : null}
|
||||
<PublicAccess
|
||||
document={document}
|
||||
@@ -209,7 +210,7 @@ export const AccessControlList = observer(
|
||||
sharedParent={sharedParent}
|
||||
onRequestClose={onRequestClose}
|
||||
/>
|
||||
</>
|
||||
</Sticky>
|
||||
)}
|
||||
</ScrollableContainer>
|
||||
);
|
||||
@@ -274,6 +275,12 @@ function useUsersInCollection(collection?: Collection) {
|
||||
: false;
|
||||
}
|
||||
|
||||
const Sticky = styled.div`
|
||||
background: ${s("menuBackground")};
|
||||
position: sticky;
|
||||
bottom: -12px;
|
||||
`;
|
||||
|
||||
const ScrollableContainer = styled(Scrollable)`
|
||||
padding: 12px 24px;
|
||||
margin: -12px -24px;
|
||||
|
||||
@@ -203,7 +203,7 @@ const StyledInfoIcon = styled(InfoIcon)`
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
`;
|
||||
|
||||
const DomainPrefix = styled.span`
|
||||
|
||||
@@ -15,7 +15,7 @@ export const Wrapper = styled.div`
|
||||
|
||||
export const Separator = styled.div`
|
||||
border-top: 1px dashed ${s("divider")};
|
||||
margin: 12px 0;
|
||||
margin: 8px 0;
|
||||
`;
|
||||
|
||||
export const HeaderInput = styled(Flex)`
|
||||
|
||||
@@ -116,7 +116,9 @@ function AppSidebar() {
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 ? (
|
||||
<Drafts size="xsmall" type="tertiary">
|
||||
{documents.totalDrafts}
|
||||
{documents.totalDrafts > 25
|
||||
? "25+"
|
||||
: documents.totalDrafts}
|
||||
</Drafts>
|
||||
) : null}
|
||||
</Flex>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMenuContext from "~/hooks/useMenuContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import AccountMenu from "~/menus/AccountMenu";
|
||||
@@ -39,6 +40,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
const previousLocation = usePrevious(location);
|
||||
const { isMenuOpen } = useMenuContext();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const isMobile = useMobile();
|
||||
const width = ui.sidebarWidth;
|
||||
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
@@ -189,6 +191,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
$isSmallerThanMinimum={isSmallerThanMinimum}
|
||||
$mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
$collapsed={collapsed}
|
||||
$isMobile={isMobile}
|
||||
className={className}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
@@ -256,6 +259,7 @@ type ContainerProps = {
|
||||
$isHovering: boolean;
|
||||
$collapsed: boolean;
|
||||
$hidden: boolean;
|
||||
$isMobile: boolean;
|
||||
};
|
||||
|
||||
const hoverStyles = (props: ContainerProps) => `
|
||||
@@ -298,8 +302,19 @@ const Container = styled(Flex)<ContainerProps>`
|
||||
|
||||
& > div {
|
||||
transition: opacity 150ms ease-in-out;
|
||||
opacity: ${(props) =>
|
||||
props.$hidden || (props.$collapsed && !props.$isHovering) ? "0" : "1"};
|
||||
opacity: ${(props) => {
|
||||
if (props.$hidden) {
|
||||
return "0";
|
||||
}
|
||||
if (props.$isHovering) {
|
||||
return "1";
|
||||
}
|
||||
if (props.$isMobile) {
|
||||
return props.$mobileSidebarVisible ? "1" : "0";
|
||||
} else {
|
||||
return props.$collapsed ? "0" : "1";
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useHistory } from "react-router-dom";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import DocumentReparent from "~/scenes/DocumentReparent";
|
||||
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
|
||||
import Fade from "~/components/Fade";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -78,20 +78,12 @@ const CollectionLink: React.FC<Props> = ({
|
||||
|
||||
if (
|
||||
prevCollection &&
|
||||
prevCollection.permission === null &&
|
||||
prevCollection.permission !== collection.permission &&
|
||||
!document?.isDraft
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Move document"),
|
||||
content: (
|
||||
<DocumentReparent
|
||||
item={item}
|
||||
collection={collection}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
onCancel={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
title: t("Change permissions?"),
|
||||
content: <ConfirmMoveDialog item={item} collection={collection} />,
|
||||
});
|
||||
} else {
|
||||
await documents.move({ documentId: id, collectionId: collection.id });
|
||||
|
||||
@@ -6,21 +6,26 @@ import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
|
||||
import DocumentsLoader from "~/components/DocumentsLoader";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import Text from "~/components/Text";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import useCollectionDocuments from "../hooks/useCollectionDocuments";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import EmptyCollectionPlaceholder from "./EmptyCollectionPlaceholder";
|
||||
import Folder from "./Folder";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
import useCollectionDocuments from "./useCollectionDocuments";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
/** The collection to render the children of. */
|
||||
collection: Collection;
|
||||
/** Whether the children are shown in an expanded state. */
|
||||
expanded: boolean;
|
||||
/** Function to prefetch a document by ID. */
|
||||
prefetchDocument?: (documentId: string) => Promise<Document | void>;
|
||||
};
|
||||
|
||||
@@ -31,9 +36,8 @@ function CollectionLinkChildren({
|
||||
}: Props) {
|
||||
const can = usePolicy(collection);
|
||||
const manualSort = collection.sort.field === "index";
|
||||
const { documents } = useStores();
|
||||
const { documents, dialogs, collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const childDocuments = useCollectionDocuments(collection, documents.active);
|
||||
|
||||
// Drop to reorder document
|
||||
@@ -52,11 +56,26 @@ function CollectionLinkChildren({
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
void documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
index: 0,
|
||||
});
|
||||
|
||||
const prevCollection = collections.get(item.collectionId);
|
||||
|
||||
if (
|
||||
prevCollection &&
|
||||
prevCollection.permission !== collection.permission
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Change permissions?"),
|
||||
content: (
|
||||
<ConfirmMoveDialog item={item} collection={collection} index={0} />
|
||||
),
|
||||
});
|
||||
} else {
|
||||
void documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
index: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: !!monitor.isOver(),
|
||||
@@ -91,7 +110,17 @@ function CollectionLinkChildren({
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
{childDocuments?.length === 0 && <EmptyCollectionPlaceholder />}
|
||||
{childDocuments?.length === 0 && (
|
||||
<SidebarLink
|
||||
label={
|
||||
<Text type="tertiary" size="small" italic>
|
||||
{t("Empty")}
|
||||
</Text>
|
||||
}
|
||||
onClick={() => history.push(collection.url)}
|
||||
depth={2}
|
||||
/>
|
||||
)}
|
||||
</DocumentsLoader>
|
||||
</Folder>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,11 @@ import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { newNestedDocumentPath } from "~/utils/routeHelpers";
|
||||
import {
|
||||
useDragDocument,
|
||||
useDropToReorderDocument,
|
||||
useDropToReparentDocument,
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import DropCursor from "./DropCursor";
|
||||
import DropToImport from "./DropToImport";
|
||||
import EditableTitle, { RefHandle } from "./EditableTitle";
|
||||
@@ -26,11 +31,6 @@ import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import {
|
||||
useDragDocument,
|
||||
useDropToReorderDocument,
|
||||
useDropToReparentDocument,
|
||||
} from "./useDragAndDrop";
|
||||
|
||||
type Props = {
|
||||
node: NavigationNode;
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
const EmptyCollectionPlaceholder = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Empty type="tertiary" size="small">
|
||||
{t("Empty")}
|
||||
</Empty>
|
||||
);
|
||||
};
|
||||
|
||||
const Empty = styled(Text)`
|
||||
margin-left: 46px;
|
||||
margin-bottom: 0;
|
||||
line-height: 34px;
|
||||
font-style: italic;
|
||||
`;
|
||||
|
||||
export default EmptyCollectionPlaceholder;
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
match,
|
||||
} from "react-router";
|
||||
import { Link } from "react-router-dom";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import history from "~/utils/history";
|
||||
|
||||
const resolveToLocation = (
|
||||
|
||||
@@ -11,6 +11,7 @@ import Flex from "~/components/Flex";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePaginatedRequest from "~/hooks/usePaginatedRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useDropToReorderUserMembership } from "../hooks/useDragAndDrop";
|
||||
import DropCursor from "./DropCursor";
|
||||
import GroupLink from "./GroupLink";
|
||||
import Header from "./Header";
|
||||
@@ -19,7 +20,6 @@ import Relative from "./Relative";
|
||||
import SharedWithMeLink from "./SharedWithMeLink";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import { useDropToReorderUserMembership } from "./useDragAndDrop";
|
||||
|
||||
function SharedWithMe() {
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
|
||||
@@ -11,19 +11,19 @@ import Fade from "~/components/Fade";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import {
|
||||
useDragMembership,
|
||||
useDropToReorderUserMembership,
|
||||
useDropToReparentDocument,
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import { useLocationState } from "../hooks/useLocationState";
|
||||
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import { useSidebarContext, type SidebarContextType } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import {
|
||||
useDragMembership,
|
||||
useDropToReorderUserMembership,
|
||||
useDropToReparentDocument,
|
||||
} from "./useDragAndDrop";
|
||||
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
|
||||
|
||||
type Props = {
|
||||
membership: UserMembership | GroupMembership;
|
||||
|
||||
@@ -7,6 +7,10 @@ import DelayedMount from "~/components/DelayedMount";
|
||||
import Flex from "~/components/Flex";
|
||||
import usePaginatedRequest from "~/hooks/usePaginatedRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import {
|
||||
useDropToCreateStar,
|
||||
useDropToReorderStar,
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Header from "./Header";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
@@ -14,7 +18,6 @@ import Relative from "./Relative";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import StarredLink from "./StarredLink";
|
||||
import { useDropToCreateStar, useDropToReorderStar } from "./useDragAndDrop";
|
||||
|
||||
const STARRED_PAGINATION_LIMIT = 10;
|
||||
|
||||
|
||||
@@ -10,7 +10,13 @@ import Fade from "~/components/Fade";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import {
|
||||
useDragStar,
|
||||
useDropToCreateStar,
|
||||
useDropToReorderStar,
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import { useLocationState } from "../hooks/useLocationState";
|
||||
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
@@ -22,12 +28,6 @@ import SidebarContext, {
|
||||
useSidebarContext,
|
||||
} from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import {
|
||||
useDragStar,
|
||||
useDropToCreateStar,
|
||||
useDropToReorderStar,
|
||||
} from "./useDragAndDrop";
|
||||
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
|
||||
|
||||
type Props = {
|
||||
star: Star;
|
||||
@@ -116,7 +116,7 @@ function StarredLink({ star }: Props) {
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const childDocuments = collection
|
||||
? collection.getDocumentChildren(documentId)
|
||||
? collection.getChildrenForDocument(documentId)
|
||||
: [];
|
||||
const hasChildDocuments = childDocuments.length > 0;
|
||||
|
||||
|
||||
+65
-12
@@ -12,10 +12,11 @@ import Document from "~/models/Document";
|
||||
import GroupMembership from "~/models/GroupMembership";
|
||||
import Star from "~/models/Star";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
|
||||
import Icon from "~/components/Icon";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
import { DragObject } from "../components/SidebarLink";
|
||||
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
|
||||
|
||||
/**
|
||||
@@ -172,7 +173,8 @@ export function useDropToReparentDocument(
|
||||
setExpanded: () => void,
|
||||
parentRef: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { documents, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { documents, collections, dialogs, policies } = useStores();
|
||||
const hasChildDocuments = !!node?.children.length;
|
||||
const document = node ? documents.get(node.id) : undefined;
|
||||
const pathToNode = React.useMemo(
|
||||
@@ -192,10 +194,11 @@ export function useDropToReparentDocument(
|
||||
}
|
||||
};
|
||||
|
||||
parentRef.current?.addEventListener("dragleave", resetHoverExpanding);
|
||||
const element = parentRef.current;
|
||||
element?.addEventListener("dragleave", resetHoverExpanding);
|
||||
|
||||
return () => {
|
||||
parentRef.current?.removeEventListener("dragleave", resetHoverExpanding);
|
||||
element?.removeEventListener("dragleave", resetHoverExpanding);
|
||||
};
|
||||
}, [parentRef]);
|
||||
|
||||
@@ -209,10 +212,32 @@ export function useDropToReparentDocument(
|
||||
if (monitor.didDrop() || !node) {
|
||||
return;
|
||||
}
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
parentDocumentId: node.id,
|
||||
});
|
||||
|
||||
const collection = documents.get(node.id)?.collection;
|
||||
const prevCollection = collections.get(item.collectionId);
|
||||
|
||||
if (
|
||||
collection &&
|
||||
prevCollection &&
|
||||
prevCollection.permission !== collection.permission
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Change permissions?"),
|
||||
content: (
|
||||
<ConfirmMoveDialog
|
||||
item={item}
|
||||
collection={collection}
|
||||
parentDocumentId={node.id}
|
||||
/>
|
||||
),
|
||||
});
|
||||
} else {
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
parentDocumentId: node.id,
|
||||
});
|
||||
}
|
||||
|
||||
setExpanded();
|
||||
},
|
||||
canDrop: (item, monitor) =>
|
||||
@@ -270,7 +295,7 @@ export function useDropToReorderDocument(
|
||||
}
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { documents, policies } = useStores();
|
||||
const { documents, collections, dialogs, policies } = useStores();
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
@@ -279,11 +304,19 @@ export function useDropToReorderDocument(
|
||||
>({
|
||||
accept: "document",
|
||||
canDrop: (item: DragObject) => {
|
||||
if (item.id === node.id) {
|
||||
if (item.id === node.id || !policies.abilities(item.id)?.move) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return policies.abilities(item.id)?.move;
|
||||
const params = getMoveParams(item);
|
||||
if (params?.collectionId) {
|
||||
return policies.abilities(params.collectionId)?.updateDocument;
|
||||
}
|
||||
if (params?.parentDocumentId) {
|
||||
return policies.abilities(params.parentDocumentId)?.update;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
drop: async (item) => {
|
||||
if (!collection?.isManualSort && item.collectionId === collection?.id) {
|
||||
@@ -296,8 +329,28 @@ export function useDropToReorderDocument(
|
||||
}
|
||||
|
||||
const params = getMoveParams(item);
|
||||
|
||||
if (params) {
|
||||
void documents.move(params);
|
||||
const prevCollection = collections.get(item.collectionId);
|
||||
|
||||
if (
|
||||
collection &&
|
||||
prevCollection &&
|
||||
prevCollection.permission !== collection.permission
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Change permissions?"),
|
||||
content: (
|
||||
<ConfirmMoveDialog
|
||||
item={item}
|
||||
collection={collection}
|
||||
{...params}
|
||||
/>
|
||||
),
|
||||
});
|
||||
} else {
|
||||
void documents.move(params);
|
||||
}
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
@@ -1,7 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import type { Props } from "./Table";
|
||||
|
||||
@@ -12,8 +12,12 @@ type Props = {
|
||||
selectable?: boolean;
|
||||
/** The font weight of the text */
|
||||
weight?: "xbold" | "bold" | "normal";
|
||||
/** Whether the text should be italic */
|
||||
italic?: boolean;
|
||||
/** Whether the text should be truncated with an ellipsis */
|
||||
ellipsis?: boolean;
|
||||
/** Whether the text should be monospaced */
|
||||
monospace?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -56,6 +60,10 @@ const Text = styled.span<Props>`
|
||||
: "inherit"};
|
||||
`}
|
||||
|
||||
font-style: ${(props) => (props.italic ? "italic" : "normal")};
|
||||
font-family: ${(props) =>
|
||||
props.monospace ? props.theme.fontFamilyMono : "inherit"};
|
||||
|
||||
white-space: normal;
|
||||
user-select: ${(props) => (props.selectable ? "text" : "none")};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
|
||||
@@ -22,7 +22,7 @@ function LinkSearchResult({
|
||||
const ref = React.useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (selected && node) {
|
||||
void scrollIntoView(node, {
|
||||
scrollIntoView(node, {
|
||||
scrollMode: "if-needed",
|
||||
block: "center",
|
||||
boundary: (parent) =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled from "styled-components";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import { usePortalContext } from "~/components/Portal";
|
||||
@@ -31,7 +31,7 @@ function SuggestionsMenuItem({
|
||||
const ref = React.useCallback(
|
||||
(node) => {
|
||||
if (selected && node) {
|
||||
void scrollIntoView(node, {
|
||||
scrollIntoView(node, {
|
||||
scrollMode: "if-needed",
|
||||
block: "nearest",
|
||||
boundary: (parent) =>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Node } from "prosemirror-model";
|
||||
import { Command, Plugin, PluginKey } from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
|
||||
import FindAndReplace from "../components/FindAndReplace";
|
||||
|
||||
@@ -184,7 +184,7 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
`.${this.options.resultCurrentClassName}`
|
||||
);
|
||||
if (element) {
|
||||
void scrollIntoView(element, {
|
||||
scrollIntoView(element, {
|
||||
scrollMode: "if-needed",
|
||||
block: "center",
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
Heading4Icon,
|
||||
HorizontalRuleIcon,
|
||||
OrderedListIcon,
|
||||
PageBreakIcon,
|
||||
@@ -63,6 +64,14 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
|
||||
shortcut: "^ ⇧ 3",
|
||||
attrs: { level: 3 },
|
||||
},
|
||||
{
|
||||
name: "heading",
|
||||
title: dictionary.h4,
|
||||
keywords: "h4 heading4",
|
||||
icon: <Heading4Icon />,
|
||||
shortcut: "^ ⇧ 4",
|
||||
attrs: { level: 4 },
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
|
||||
@@ -43,6 +43,7 @@ export default function useDictionary() {
|
||||
h1: t("Big heading"),
|
||||
h2: t("Medium heading"),
|
||||
h3: t("Small heading"),
|
||||
h4: t("Extra small heading"),
|
||||
heading: t("Heading"),
|
||||
hr: t("Divider"),
|
||||
image: t("Image"),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import useIdle from "./useIdle";
|
||||
|
||||
const activityEvents = [
|
||||
"click",
|
||||
"mousemove",
|
||||
"DOMMouseScroll",
|
||||
"mousewheel",
|
||||
"mousedown",
|
||||
"touchstart",
|
||||
"touchmove",
|
||||
"focus",
|
||||
];
|
||||
|
||||
export default function useEditingFocus() {
|
||||
const { editor } = useDocumentContext();
|
||||
const isIdle = useIdle(3000, activityEvents);
|
||||
return isIdle && !!editor?.view.hasFocus();
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { getCookie, removeCookie, setCookie } from "tiny-cookie";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import Logger from "~/utils/Logger";
|
||||
import history from "~/utils/history";
|
||||
import { isValidPostLoginRedirect } from "~/utils/urls";
|
||||
import { isAllowedLoginRedirect } from "~/utils/urls";
|
||||
|
||||
/**
|
||||
* Hook to set locally and return the document or collection that the user last visited. This is
|
||||
@@ -20,7 +20,9 @@ export function useLastVisitedPath(): [string, (path: string) => void] {
|
||||
|
||||
const setPathAsLastVisitedPath = React.useCallback(
|
||||
(path: string) => {
|
||||
path !== lastVisitedPath && setLastVisitedPath(path);
|
||||
if (isAllowedLoginRedirect(path) && path !== lastVisitedPath) {
|
||||
setLastVisitedPath(path);
|
||||
}
|
||||
},
|
||||
[lastVisitedPath, setLastVisitedPath]
|
||||
);
|
||||
@@ -36,7 +38,7 @@ export function useLastVisitedPath(): [string, (path: string) => void] {
|
||||
export function setPostLoginPath(path: string) {
|
||||
const key = "postLoginRedirectPath";
|
||||
|
||||
if (isValidPostLoginRedirect(path)) {
|
||||
if (isAllowedLoginRedirect(path)) {
|
||||
setCookie(key, path, { expires: 1 });
|
||||
|
||||
try {
|
||||
@@ -61,7 +63,7 @@ export function usePostLoginPath() {
|
||||
try {
|
||||
path = sessionStorage.getItem(key) || getCookie(key);
|
||||
} catch (e) {
|
||||
// If the session storage is inaccessible, we can't do anything about it.
|
||||
// Expected error if the session storage is full or inaccessible.
|
||||
}
|
||||
|
||||
if (path) {
|
||||
@@ -70,11 +72,16 @@ export function usePostLoginPath() {
|
||||
// Remove the cookie once the app has been navigated to the post login path. We dont
|
||||
// do this immediately as React StrictMode will render multiple times.
|
||||
const cleanup = history.listen(() => {
|
||||
try {
|
||||
sessionStorage.removeItem(key);
|
||||
} catch (e) {
|
||||
// Expected error if the session storage is full or inaccessible.
|
||||
}
|
||||
removeCookie(key);
|
||||
cleanup?.();
|
||||
});
|
||||
|
||||
if (isValidPostLoginRedirect(path)) {
|
||||
if (isAllowedLoginRedirect(path)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ImportIcon,
|
||||
ExportIcon,
|
||||
AlphabeticalSortIcon,
|
||||
AlphabeticalReverseSortIcon,
|
||||
ManualSortIcon,
|
||||
InputIcon,
|
||||
} from "outline-icons";
|
||||
@@ -127,12 +128,12 @@ function CollectionMenu({
|
||||
);
|
||||
|
||||
const handleChangeSort = React.useCallback(
|
||||
(field: string) => {
|
||||
(field: string, direction = "asc") => {
|
||||
menu.hide();
|
||||
return collection.save({
|
||||
sort: {
|
||||
field,
|
||||
direction: "asc",
|
||||
direction,
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -144,7 +145,8 @@ function CollectionMenu({
|
||||
activeCollectionId: collection.id,
|
||||
});
|
||||
|
||||
const alphabeticalSort = collection.sort.field === "title";
|
||||
const sortAlphabetical = collection.sort.field === "title";
|
||||
const sortDir = collection.sort.direction;
|
||||
const can = usePolicy(collection);
|
||||
const canUserInTeam = usePolicy(team);
|
||||
const items: MenuItem[] = React.useMemo(
|
||||
@@ -185,19 +187,33 @@ function CollectionMenu({
|
||||
type: "submenu",
|
||||
title: t("Sort in sidebar"),
|
||||
visible: can.update,
|
||||
icon: alphabeticalSort ? <AlphabeticalSortIcon /> : <ManualSortIcon />,
|
||||
icon: sortAlphabetical ? (
|
||||
sortDir === "asc" ? (
|
||||
<AlphabeticalSortIcon />
|
||||
) : (
|
||||
<AlphabeticalReverseSortIcon />
|
||||
)
|
||||
) : (
|
||||
<ManualSortIcon />
|
||||
),
|
||||
items: [
|
||||
{
|
||||
type: "button",
|
||||
title: t("Alphabetical sort"),
|
||||
onClick: () => handleChangeSort("title"),
|
||||
selected: alphabeticalSort,
|
||||
title: t("A-Z sort"),
|
||||
onClick: () => handleChangeSort("title", "asc"),
|
||||
selected: sortAlphabetical && sortDir === "asc",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Z-A sort"),
|
||||
onClick: () => handleChangeSort("title", "desc"),
|
||||
selected: sortAlphabetical && sortDir === "desc",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Manual sort"),
|
||||
onClick: () => handleChangeSort("index"),
|
||||
selected: !alphabeticalSort,
|
||||
selected: !sortAlphabetical,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -216,6 +232,7 @@ function CollectionMenu({
|
||||
],
|
||||
[
|
||||
t,
|
||||
onRename,
|
||||
collection,
|
||||
can.createDocument,
|
||||
can.update,
|
||||
@@ -223,7 +240,7 @@ function CollectionMenu({
|
||||
handleNewDocument,
|
||||
handleImportDocument,
|
||||
context,
|
||||
alphabeticalSort,
|
||||
sortAlphabetical,
|
||||
canUserInTeam.createExport,
|
||||
handleExport,
|
||||
handleChangeSort,
|
||||
|
||||
@@ -6,17 +6,11 @@ import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import Button from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
type Props = {
|
||||
headings: {
|
||||
title: string;
|
||||
level: number;
|
||||
id: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
function TableOfContentsMenu({ headings }: Props) {
|
||||
function TableOfContentsMenu() {
|
||||
const { headings } = useDocumentContext();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
unstable_preventOverflow: true,
|
||||
|
||||
+19
-19
@@ -1,44 +1,44 @@
|
||||
import { isPast } from "date-fns";
|
||||
import { computed, observable } from "mobx";
|
||||
import Model from "./base/Model";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import Field from "./decorators/Field";
|
||||
|
||||
class ApiKey extends Model {
|
||||
class ApiKey extends ParanoidModel {
|
||||
static modelName = "ApiKey";
|
||||
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The user chosen name of the API key.
|
||||
*/
|
||||
/** The user chosen name of the API key. */
|
||||
@Field
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* An optional datetime that the API key expires.
|
||||
*/
|
||||
/** An optional datetime that the API key expires. */
|
||||
@Field
|
||||
@observable
|
||||
expiresAt?: string;
|
||||
|
||||
/**
|
||||
* An optional datetime that the API key was last used at.
|
||||
*/
|
||||
/** An optional datetime that the API key was last used at. */
|
||||
@observable
|
||||
lastActiveAt?: string;
|
||||
|
||||
secret: string;
|
||||
/** The plain text value of the API key, only available on creation. */
|
||||
value: string;
|
||||
|
||||
/**
|
||||
* Whether the API key has an expiry in the past.
|
||||
*/
|
||||
/** A preview of the last 4 characters of the API key. */
|
||||
last4: string;
|
||||
|
||||
/** Whether the API key has an expiry in the past. */
|
||||
@computed
|
||||
get isExpired() {
|
||||
return this.expiresAt ? isPast(new Date(this.expiresAt)) : false;
|
||||
}
|
||||
|
||||
@computed
|
||||
get obfuscatedValue() {
|
||||
if (this.createdAt < new Date("2022-12-03").toISOString()) {
|
||||
return `...${this.last4}`;
|
||||
}
|
||||
return `ol...${this.last4}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default ApiKey;
|
||||
|
||||
+37
-59
@@ -1,9 +1,10 @@
|
||||
import invariant from "invariant";
|
||||
import { action, computed, observable, reaction, runInAction } from "mobx";
|
||||
import { action, computed, observable, runInAction } from "mobx";
|
||||
import {
|
||||
CollectionPermission,
|
||||
FileOperationFormat,
|
||||
NavigationNode,
|
||||
type NavigationNode,
|
||||
NavigationNodeType,
|
||||
type ProsemirrorData,
|
||||
} from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
@@ -21,43 +22,27 @@ export default class Collection extends ParanoidModel {
|
||||
|
||||
store: CollectionsStore;
|
||||
|
||||
@observable
|
||||
isSaving: boolean;
|
||||
|
||||
isFetching = false;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The name of the collection.
|
||||
*/
|
||||
/** The name of the collection. */
|
||||
@Field
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
/** Collection description in Prosemirror format. */
|
||||
@Field
|
||||
@observable.shallow
|
||||
data: ProsemirrorData;
|
||||
|
||||
/**
|
||||
* An icon (or) emoji to use as the collection icon.
|
||||
*/
|
||||
/** An icon (or) emoji to use as the collection icon. */
|
||||
@Field
|
||||
@observable
|
||||
icon: string;
|
||||
|
||||
/**
|
||||
* The color to use for the collection icon and other highlights.
|
||||
*/
|
||||
/** The color to use for the collection icon and other highlights. */
|
||||
@Field
|
||||
@observable
|
||||
color?: string | null;
|
||||
|
||||
/**
|
||||
* The default permission for workspace users.
|
||||
*/
|
||||
/** The default permission for workspace users. */
|
||||
@Field
|
||||
@observable
|
||||
permission?: CollectionPermission;
|
||||
@@ -70,16 +55,12 @@ export default class Collection extends ParanoidModel {
|
||||
@observable
|
||||
sharing: boolean;
|
||||
|
||||
/**
|
||||
* The sort index for the collection.
|
||||
*/
|
||||
/** The sort index for the collection. */
|
||||
@Field
|
||||
@observable
|
||||
index: string;
|
||||
|
||||
/**
|
||||
* The sort field and direction for documents in the collection.
|
||||
*/
|
||||
/** The sort field and direction for documents in the collection. */
|
||||
@Field
|
||||
@observable
|
||||
sort: {
|
||||
@@ -87,33 +68,19 @@ export default class Collection extends ParanoidModel {
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
|
||||
/** The child documents of the collection. */
|
||||
@observable
|
||||
documents?: NavigationNode[];
|
||||
|
||||
/**
|
||||
* @deprecated Use path instead.
|
||||
*/
|
||||
/** @deprecated Use path instead. */
|
||||
@observable
|
||||
url: string;
|
||||
|
||||
/** The ID that appears in the collection slug. */
|
||||
@observable
|
||||
urlId: string;
|
||||
|
||||
constructor(fields: Partial<Collection>, store: CollectionsStore) {
|
||||
super(fields, store);
|
||||
|
||||
const resetDocumentPolicies = () => {
|
||||
this.store.rootStore.documents
|
||||
.inCollection(this.id)
|
||||
.forEach((document) => {
|
||||
this.store.rootStore.policies.remove(document.id);
|
||||
});
|
||||
};
|
||||
|
||||
reaction(() => this.permission, resetDocumentPolicies);
|
||||
reaction(() => this.sharing, resetDocumentPolicies);
|
||||
}
|
||||
|
||||
/** Returns whether the collection is empty, or undefined if not loaded. */
|
||||
@computed
|
||||
get isEmpty(): boolean | undefined {
|
||||
if (!this.documents) {
|
||||
@@ -137,11 +104,7 @@ export default class Collection extends ParanoidModel {
|
||||
return !this.permission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether this collection has a description.
|
||||
*
|
||||
* @returns boolean
|
||||
*/
|
||||
/** Returns whether the collection description is not empty. */
|
||||
@computed
|
||||
get hasDescription(): boolean {
|
||||
return this.data ? !ProsemirrorHelper.isEmptyData(this.data) : false;
|
||||
@@ -167,11 +130,7 @@ export default class Collection extends ParanoidModel {
|
||||
return sortNavigationNodes(this.documents, this.sort);
|
||||
}
|
||||
|
||||
/**
|
||||
* The initial letter of the collection name.
|
||||
*
|
||||
* @returns string
|
||||
*/
|
||||
/** The initial letter of the collection name as a string. */
|
||||
@computed
|
||||
get initial() {
|
||||
return (this.name ? this.name[0] : "?").toUpperCase();
|
||||
@@ -277,7 +236,7 @@ export default class Collection extends ParanoidModel {
|
||||
this.index = index;
|
||||
}
|
||||
|
||||
getDocumentChildren(documentId: string) {
|
||||
getChildrenForDocument(documentId: string) {
|
||||
let result: NavigationNode[] = [];
|
||||
|
||||
const travelNodes = (nodes: NavigationNode[]) => {
|
||||
@@ -298,6 +257,19 @@ export default class Collection extends ParanoidModel {
|
||||
return result;
|
||||
}
|
||||
|
||||
@computed
|
||||
get asNavigationNode(): NavigationNode {
|
||||
return {
|
||||
type: NavigationNodeType.Collection,
|
||||
id: this.id,
|
||||
title: this.name,
|
||||
color: this.color ?? undefined,
|
||||
icon: this.icon ?? undefined,
|
||||
children: this.documents ?? [],
|
||||
url: this.url,
|
||||
};
|
||||
}
|
||||
|
||||
pathToDocument(documentId: string) {
|
||||
let path: NavigationNode[] | undefined = [];
|
||||
const document = this.store.rootStore.documents.get(documentId);
|
||||
@@ -356,7 +328,11 @@ export default class Collection extends ParanoidModel {
|
||||
model: Collection,
|
||||
previousAttributes: Partial<Collection>
|
||||
) {
|
||||
if (previousAttributes && model.sharing !== previousAttributes?.sharing) {
|
||||
if (
|
||||
previousAttributes &&
|
||||
(model.sharing !== previousAttributes?.sharing ||
|
||||
model.permission !== previousAttributes?.permission)
|
||||
) {
|
||||
const { documents, policies } = model.store.rootStore;
|
||||
|
||||
documents.inCollection(model.id).forEach((document) => {
|
||||
@@ -364,4 +340,6 @@ export default class Collection extends ParanoidModel {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private isFetching = false;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
import {
|
||||
ExportContentType,
|
||||
FileOperationFormat,
|
||||
NavigationNodeType,
|
||||
NotificationEventType,
|
||||
} from "@shared/types";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
@@ -619,6 +620,7 @@ export default class Document extends ParanoidModel {
|
||||
@computed
|
||||
get asNavigationNode(): NavigationNode {
|
||||
return {
|
||||
type: NavigationNodeType.Document,
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
color: this.color ?? undefined,
|
||||
|
||||
+11
-1
@@ -1,5 +1,7 @@
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { computed, observable } from "mobx";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import { AfterChange } from "./decorators/Lifecycle";
|
||||
|
||||
class Policy extends Model {
|
||||
@@ -9,6 +11,7 @@ class Policy extends Model {
|
||||
* An object containing keys representing abilities and values that are either
|
||||
* a boolean or an array of membership IDs that have provided access to the ability.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
abilities: Record<string, boolean | string[]>;
|
||||
|
||||
@@ -30,9 +33,16 @@ class Policy extends Model {
|
||||
}
|
||||
|
||||
@AfterChange
|
||||
public static removeChildPolicies(model: Policy) {
|
||||
public static removeChildPolicies(
|
||||
model: Policy,
|
||||
previousAttributes: Partial<Policy>
|
||||
) {
|
||||
const { documents, collections, policies } = model.store.rootStore;
|
||||
|
||||
if (isEqual(model.abilities, previousAttributes.abilities)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = collections.get(model.id);
|
||||
if (collection) {
|
||||
documents.inCollection(collection.id).forEach((i) => {
|
||||
|
||||
@@ -141,6 +141,10 @@ export default abstract class Model {
|
||||
|
||||
for (const key in data) {
|
||||
try {
|
||||
// Some models are serialized with the initialized flag, this should be ignored.
|
||||
if (key === "initialized") {
|
||||
continue;
|
||||
}
|
||||
this[key] = data[key];
|
||||
} catch (error) {
|
||||
Logger.warn(`Error setting ${key} on model`, error);
|
||||
@@ -150,7 +154,7 @@ export default abstract class Model {
|
||||
this.isNew = false;
|
||||
this.persistedAttributes = this.toAPI();
|
||||
|
||||
if (!this.initialized) {
|
||||
if (this.initialized) {
|
||||
LifecycleManager.executeHooks(
|
||||
this.constructor,
|
||||
"afterChange",
|
||||
|
||||
@@ -71,7 +71,11 @@ function ApiKeyNew({ onSubmit }: Props) {
|
||||
name,
|
||||
expiresAt: expiresAt?.toISOString(),
|
||||
});
|
||||
toast.success(t("API key created"));
|
||||
toast.success(
|
||||
t(
|
||||
"API key created. Please copy the value now as it will not be shown again."
|
||||
)
|
||||
);
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
|
||||
@@ -1,81 +1,47 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { NewDocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import { editCollectionPermissions } from "~/actions/definitions/collections";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
/** The collection to display the empty state for. */
|
||||
collection: Collection;
|
||||
};
|
||||
|
||||
function EmptyCollection({ collection }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(collection);
|
||||
const context = useActionContext();
|
||||
const collectionName = collection ? collection.name : "";
|
||||
|
||||
return (
|
||||
<Centered column>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="<em>{{ collectionName }}</em> doesn’t contain any
|
||||
<Fade>
|
||||
<Centered column>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="<em>{{ collectionName }}</em> doesn’t contain any
|
||||
documents yet."
|
||||
values={{
|
||||
collectionName,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
<br />
|
||||
{can.createDocument && (
|
||||
<Trans>Get started by creating a new one!</Trans>
|
||||
)}
|
||||
</Text>
|
||||
{can.createDocument && (
|
||||
<Empty>
|
||||
<Link to={newDocumentPath(collection.id)}>
|
||||
<Button icon={<NewDocumentIcon />} neutral>
|
||||
{t("Create a document")}
|
||||
</Button>
|
||||
</Link>
|
||||
{FeatureFlags.isEnabled(Feature.newCollectionSharing) ? null : (
|
||||
<Button
|
||||
action={editCollectionPermissions}
|
||||
context={context}
|
||||
hideOnActionDisabled
|
||||
neutral
|
||||
>
|
||||
{t("Manage permissions")}…
|
||||
</Button>
|
||||
)}
|
||||
</Empty>
|
||||
)}
|
||||
</Centered>
|
||||
values={{
|
||||
collectionName,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
</Centered>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
|
||||
const Centered = styled(Flex)`
|
||||
text-align: center;
|
||||
margin: 40vh auto 0;
|
||||
max-width: 380px;
|
||||
transform: translateY(-50%);
|
||||
`;
|
||||
|
||||
const Empty = styled(Flex)`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 10px 0;
|
||||
gap: 8px;
|
||||
margin: 0 auto;
|
||||
max-width: 380px;
|
||||
height: 50vh;
|
||||
`;
|
||||
|
||||
export default observer(EmptyCollection);
|
||||
|
||||
@@ -8,11 +8,9 @@ import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { editCollectionPermissions } from "~/actions/definitions/collections";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -71,11 +69,6 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
|
||||
return (
|
||||
<NudeButton
|
||||
context={context}
|
||||
action={
|
||||
FeatureFlags.isEnabled(Feature.newCollectionSharing)
|
||||
? undefined
|
||||
: editCollectionPermissions
|
||||
}
|
||||
tooltip={{
|
||||
content:
|
||||
usersCount > 0
|
||||
|
||||
+117
-139
@@ -17,7 +17,6 @@ import { colorPalette } from "@shared/utils/collections";
|
||||
import Collection from "~/models/Collection";
|
||||
import Search from "~/scenes/Search";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Badge from "~/components/Badge";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import CollectionDescription from "~/components/CollectionDescription";
|
||||
import Heading from "~/components/Heading";
|
||||
@@ -31,14 +30,12 @@ import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Scene from "~/components/Scene";
|
||||
import Tab from "~/components/Tab";
|
||||
import Tabs from "~/components/Tabs";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { editCollection } from "~/actions/definitions/collections";
|
||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||
import { useLastVisitedPath } from "~/hooks/useLastVisitedPath";
|
||||
import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||
import { collectionPath, updateCollectionPath } from "~/utils/routeHelpers";
|
||||
import Actions from "./components/Actions";
|
||||
import DropToImport from "./components/DropToImport";
|
||||
@@ -154,8 +151,7 @@ function CollectionScene() {
|
||||
<>
|
||||
<MembershipPreview collection={collection} />
|
||||
<Action>
|
||||
{FeatureFlags.isEnabled(Feature.newCollectionSharing) &&
|
||||
can.update && <ShareButton collection={collection} />}
|
||||
{can.update && <ShareButton collection={collection} />}
|
||||
</Action>
|
||||
<Actions collection={collection} />
|
||||
</>
|
||||
@@ -167,142 +163,124 @@ function CollectionScene() {
|
||||
collectionId={collection.id}
|
||||
>
|
||||
<CenteredContent withStickyHeader>
|
||||
{collection.isEmpty ? (
|
||||
<Empty collection={collection} />
|
||||
) : (
|
||||
<>
|
||||
<CollectionHeading>
|
||||
<IconTitleWrapper>
|
||||
{can.update ? (
|
||||
<React.Suspense fallback={fallbackIcon}>
|
||||
<IconPicker
|
||||
icon={collection.icon ?? "collection"}
|
||||
color={collection.color ?? colorPalette[0]}
|
||||
initial={collection.name[0]}
|
||||
size={40}
|
||||
popoverPosition="bottom-start"
|
||||
onChange={handleIconChange}
|
||||
borderOnHover
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
fallbackIcon
|
||||
)}
|
||||
</IconTitleWrapper>
|
||||
{collection.name}
|
||||
{collection.isPrivate &&
|
||||
!FeatureFlags.isEnabled(Feature.newCollectionSharing) && (
|
||||
<Tooltip
|
||||
content={t(
|
||||
"This collection is only visible to those given access"
|
||||
)}
|
||||
placement="bottom"
|
||||
>
|
||||
<Badge>{t("Private")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CollectionHeading>
|
||||
<CollectionHeading>
|
||||
<IconTitleWrapper>
|
||||
{can.update ? (
|
||||
<React.Suspense fallback={fallbackIcon}>
|
||||
<IconPicker
|
||||
icon={collection.icon ?? "collection"}
|
||||
color={collection.color ?? colorPalette[0]}
|
||||
initial={collection.name[0]}
|
||||
size={40}
|
||||
popoverPosition="bottom-start"
|
||||
onChange={handleIconChange}
|
||||
borderOnHover
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
fallbackIcon
|
||||
)}
|
||||
</IconTitleWrapper>
|
||||
{collection.name}
|
||||
</CollectionHeading>
|
||||
|
||||
<PinnedDocuments
|
||||
pins={pins}
|
||||
canUpdate={can.update}
|
||||
placeholderCount={count}
|
||||
/>
|
||||
<CollectionDescription collection={collection} />
|
||||
<PinnedDocuments
|
||||
pins={pins}
|
||||
canUpdate={can.update}
|
||||
placeholderCount={count}
|
||||
/>
|
||||
<CollectionDescription collection={collection} />
|
||||
|
||||
<Documents>
|
||||
<Tabs>
|
||||
<Tab to={collectionPath(collection.path)} exact>
|
||||
{t("Documents")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "updated")} exact>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "published")} exact>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "old")} exact>
|
||||
{t("Least recently updated")}
|
||||
</Tab>
|
||||
<Tab
|
||||
to={collectionPath(collection.path, "alphabetical")}
|
||||
exact
|
||||
>
|
||||
{t("A–Z")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Switch>
|
||||
<Route path={collectionPath(collection.path, "alphabetical")}>
|
||||
<PaginatedDocumentList
|
||||
key="alphabetical"
|
||||
documents={documents.alphabeticalInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchAlphabetical}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "old")}>
|
||||
<PaginatedDocumentList
|
||||
key="old"
|
||||
documents={documents.leastRecentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchLeastRecentlyUpdated}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "recent")}>
|
||||
<Redirect
|
||||
to={collectionPath(collection.path, "published")}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "published")}>
|
||||
<PaginatedDocumentList
|
||||
key="published"
|
||||
documents={documents.recentlyPublishedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyPublished}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
showPublished
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "updated")}>
|
||||
<PaginatedDocumentList
|
||||
key="updated"
|
||||
documents={documents.recentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyUpdated}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path)} exact>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.rootInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
sort: collection.sort.field,
|
||||
direction: collection.sort.direction,
|
||||
}}
|
||||
showParentDocuments
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Documents>
|
||||
</>
|
||||
)}
|
||||
<Documents>
|
||||
<Tabs>
|
||||
<Tab to={collectionPath(collection.path)} exact>
|
||||
{t("Documents")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "updated")} exact>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "published")} exact>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "old")} exact>
|
||||
{t("Least recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "alphabetical")} exact>
|
||||
{t("A–Z")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
{collection.isEmpty ? (
|
||||
<Empty collection={collection} />
|
||||
) : (
|
||||
<Switch>
|
||||
<Route path={collectionPath(collection.path, "alphabetical")}>
|
||||
<PaginatedDocumentList
|
||||
key="alphabetical"
|
||||
documents={documents.alphabeticalInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchAlphabetical}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "old")}>
|
||||
<PaginatedDocumentList
|
||||
key="old"
|
||||
documents={documents.leastRecentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchLeastRecentlyUpdated}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "recent")}>
|
||||
<Redirect to={collectionPath(collection.path, "published")} />
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "published")}>
|
||||
<PaginatedDocumentList
|
||||
key="published"
|
||||
documents={documents.recentlyPublishedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyPublished}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
showPublished
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "updated")}>
|
||||
<PaginatedDocumentList
|
||||
key="updated"
|
||||
documents={documents.recentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyUpdated}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path)} exact>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.rootInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
sort: collection.sort.field,
|
||||
direction: collection.sort.direction,
|
||||
}}
|
||||
showParentDocuments
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
)}
|
||||
</Documents>
|
||||
</CenteredContent>
|
||||
</DropToImport>
|
||||
</Scene>
|
||||
|
||||
@@ -12,6 +12,10 @@ import DocumentModel from "~/models/Document";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
import ErrorOffline from "~/scenes/ErrorOffline";
|
||||
import ClickablePadding from "~/components/ClickablePadding";
|
||||
import {
|
||||
DocumentContextProvider,
|
||||
useDocumentContext,
|
||||
} from "~/components/DocumentContext";
|
||||
import Layout from "~/components/Layout";
|
||||
import Sidebar from "~/components/Sidebar/Shared";
|
||||
import { TeamContext } from "~/components/TeamContext";
|
||||
@@ -104,7 +108,6 @@ function SharedDocumentScene(props: Props) {
|
||||
? (searchParams.get("theme") as Theme)
|
||||
: undefined;
|
||||
const theme = useBuildTheme(response?.team?.customTheme, themeOverride);
|
||||
const tocPosition = response?.team?.tocPosition ?? TOCPosition.Left;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!user) {
|
||||
@@ -183,32 +186,53 @@ function SharedDocumentScene(props: Props) {
|
||||
</Helmet>
|
||||
<TeamContext.Provider value={response.team}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<Layout
|
||||
title={response.document?.title}
|
||||
sidebar={
|
||||
response.sharedTree?.children.length ? (
|
||||
<Sidebar rootNode={response.sharedTree} shareId={shareId!} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{response.document && (
|
||||
<Document
|
||||
abilities={EMPTY_OBJECT}
|
||||
document={response.document}
|
||||
sharedTree={response.sharedTree}
|
||||
shareId={shareId}
|
||||
tocPosition={tocPosition}
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
<ClickablePadding minHeight="20vh" />
|
||||
<DocumentContextProvider>
|
||||
<Layout
|
||||
title={response.document?.title}
|
||||
sidebar={
|
||||
response.sharedTree?.children.length ? (
|
||||
<Sidebar rootNode={response.sharedTree} shareId={shareId!} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<SharedDocument shareId={shareId} response={response} />
|
||||
</Layout>
|
||||
<ClickablePadding minHeight="20vh" />
|
||||
</DocumentContextProvider>
|
||||
</ThemeProvider>
|
||||
</TeamContext.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SharedDocument = ({
|
||||
shareId,
|
||||
response,
|
||||
}: {
|
||||
shareId?: string;
|
||||
response: Response;
|
||||
}) => {
|
||||
const { setDocument } = useDocumentContext();
|
||||
|
||||
if (!response.document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tocPosition = response.team?.tocPosition ?? TOCPosition.Left;
|
||||
setDocument(response.document);
|
||||
|
||||
return (
|
||||
<Document
|
||||
abilities={EMPTY_OBJECT}
|
||||
document={response.document}
|
||||
sharedTree={response.sharedTree}
|
||||
shareId={shareId}
|
||||
tocPosition={tocPosition}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Content = styled(Text)`
|
||||
color: ${s("textSecondary")};
|
||||
text-align: center;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
@@ -64,6 +64,7 @@ function CommentThread({
|
||||
recessed,
|
||||
focused,
|
||||
}: Props) {
|
||||
const [focusedOnMount] = React.useState(focused);
|
||||
const { editor } = useDocumentContext();
|
||||
const { comments } = useStores();
|
||||
const topRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -103,7 +104,8 @@ function CommentThread({
|
||||
|
||||
const handleClickThread = () => {
|
||||
history.replace({
|
||||
search: location.search,
|
||||
// Clear any commentId from the URL when explicitly focusing a thread
|
||||
search: "",
|
||||
pathname: location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId: thread.id },
|
||||
});
|
||||
@@ -117,17 +119,26 @@ function CommentThread({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (focused) {
|
||||
// If the thread is already visible, scroll it into view immediately,
|
||||
// otherwise wait for the sidebar to appear.
|
||||
const isThreadVisible =
|
||||
(topRef.current?.getBoundingClientRect().left ?? 0) < window.innerWidth;
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
if (focusedOnMount) {
|
||||
setTimeout(() => {
|
||||
if (!topRef.current) {
|
||||
return;
|
||||
}
|
||||
scrollIntoView(topRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
block: "nearest",
|
||||
boundary: (parent) =>
|
||||
// Prevents body and other parent elements from being scrolled
|
||||
parent.id !== "comments",
|
||||
});
|
||||
}, sidebarAppearDuration);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
if (!replyRef.current) {
|
||||
return;
|
||||
}
|
||||
return scrollIntoView(replyRef.current, {
|
||||
scrollIntoView(replyRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
@@ -135,9 +146,8 @@ function CommentThread({
|
||||
// Prevents body and other parent elements from being scrolled
|
||||
parent.id !== "comments",
|
||||
});
|
||||
},
|
||||
isThreadVisible ? 0 : sidebarAppearDuration
|
||||
);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const getCommentMarkElement = () =>
|
||||
window.document?.getElementById(`comment-${thread.id}`);
|
||||
@@ -153,7 +163,7 @@ function CommentThread({
|
||||
isMarkVisible ? 0 : sidebarAppearDuration
|
||||
);
|
||||
}
|
||||
}, [focused, thread.id]);
|
||||
}, [focused, focusedOnMount, thread.id]);
|
||||
|
||||
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
|
||||
`draft-${document.id}-${thread.id}`,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -5,25 +6,18 @@ import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import useWindowScrollPosition from "~/hooks/useWindowScrollPosition";
|
||||
import { decodeURIComponentSafe } from "~/utils/urls";
|
||||
|
||||
const HEADING_OFFSET = 20;
|
||||
|
||||
type Props = {
|
||||
/** The headings to render in the contents. */
|
||||
headings: {
|
||||
title: string;
|
||||
level: number;
|
||||
id: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export default function Contents({ headings }: Props) {
|
||||
function Contents() {
|
||||
const [activeSlug, setActiveSlug] = React.useState<string>();
|
||||
const scrollPosition = useWindowScrollPosition({
|
||||
throttle: 100,
|
||||
});
|
||||
const { headings } = useDocumentContext();
|
||||
|
||||
React.useEffect(() => {
|
||||
let activeId = headings.length > 0 ? headings[0].id : undefined;
|
||||
@@ -139,3 +133,5 @@ const List = styled.ol`
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
`;
|
||||
|
||||
export default observer(Contents);
|
||||
|
||||
@@ -9,6 +9,7 @@ import Revision from "~/models/Revision";
|
||||
import Error402 from "~/scenes/Error402";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
import ErrorOffline from "~/scenes/ErrorOffline";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -56,6 +57,7 @@ function DataLoader({ match, children }: Props) {
|
||||
const { ui, views, shares, comments, documents, revisions } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const { setDocument } = useDocumentContext();
|
||||
const [error, setError] = React.useState<Error | null>(null);
|
||||
const { revisionId, shareId, documentSlug } = match.params;
|
||||
|
||||
@@ -64,6 +66,10 @@ function DataLoader({ match, children }: Props) {
|
||||
documents.getByUrl(match.params.documentSlug) ??
|
||||
documents.get(match.params.documentSlug);
|
||||
|
||||
if (document) {
|
||||
setDocument(document);
|
||||
}
|
||||
|
||||
const revision = revisionId
|
||||
? revisions.get(
|
||||
revisionId === "latest"
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
TOCPosition,
|
||||
TeamPreference,
|
||||
} from "@shared/types";
|
||||
import { ProsemirrorHelper, Heading } from "@shared/utils/ProsemirrorHelper";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
@@ -116,9 +116,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
@observable
|
||||
title: string = this.props.document.title;
|
||||
|
||||
@observable
|
||||
headings: Heading[] = [];
|
||||
|
||||
componentDidMount() {
|
||||
this.updateIsDirty();
|
||||
}
|
||||
@@ -376,20 +373,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
this.isUploading = false;
|
||||
};
|
||||
|
||||
handleChange = () => {
|
||||
const { document } = this.props;
|
||||
|
||||
// Keep derived task list in sync
|
||||
const tasks = this.editor.current?.getTasks();
|
||||
const total = tasks?.length ?? 0;
|
||||
const completed = tasks?.filter((t) => t.completed).length ?? 0;
|
||||
document.updateTasks(total, completed);
|
||||
};
|
||||
|
||||
onHeadingsChange = (headings: Heading[]) => {
|
||||
this.headings = headings;
|
||||
};
|
||||
|
||||
handleChangeTitle = action((value: string) => {
|
||||
this.title = value;
|
||||
this.props.document.title = value;
|
||||
@@ -426,7 +409,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
const embedsDisabled =
|
||||
(team && team.documentEmbeds === false) || document.embedsDisabled;
|
||||
|
||||
const hasHeadings = this.headings.length > 0;
|
||||
const showContents =
|
||||
ui.tocVisible === true || (isShare && ui.tocVisible !== false);
|
||||
const tocPos =
|
||||
@@ -493,7 +475,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
)}
|
||||
<Header
|
||||
document={document}
|
||||
documentHasHeadings={hasHeadings}
|
||||
revision={revision}
|
||||
shareId={shareId}
|
||||
isDraft={document.isDraft}
|
||||
@@ -507,7 +488,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
sharedTree={this.props.sharedTree}
|
||||
onSelectTemplate={this.replaceDocument}
|
||||
onSave={this.onSave}
|
||||
headings={this.headings}
|
||||
/>
|
||||
<Main fullWidth={document.fullWidth} tocPosition={tocPos}>
|
||||
<React.Suspense
|
||||
@@ -536,7 +516,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
docFullWidth={document.fullWidth}
|
||||
position={tocPos}
|
||||
>
|
||||
<Contents headings={this.headings} />
|
||||
<Contents />
|
||||
</ContentsContainer>
|
||||
)}
|
||||
<MeasuredContainer
|
||||
@@ -567,8 +547,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
onChangeTitle={this.handleChangeTitle}
|
||||
onChangeIcon={this.handleChangeIcon}
|
||||
onChange={this.handleChange}
|
||||
onHeadingsChange={this.onHeadingsChange}
|
||||
onSave={this.onSave}
|
||||
onPublish={this.onPublish}
|
||||
onCancel={this.goBack}
|
||||
@@ -632,7 +610,7 @@ const Main = styled.div<MainProps>`
|
||||
? tocPosition === TOCPosition.Left
|
||||
? `${EditorStyleHelper.tocWidth}px minmax(0, 1fr)`
|
||||
: `minmax(0, 1fr) ${EditorStyleHelper.tocWidth}px`
|
||||
: `1fr minmax(0, ${`calc(46em + 76px)`}) 1fr`};
|
||||
: `1fr minmax(0, ${`calc(46em + 88px)`}) 1fr`};
|
||||
`};
|
||||
|
||||
${breakpoint("desktopLarge")`
|
||||
@@ -641,7 +619,7 @@ const Main = styled.div<MainProps>`
|
||||
? tocPosition === TOCPosition.Left
|
||||
? `${EditorStyleHelper.tocWidth}px minmax(0, 1fr)`
|
||||
: `minmax(0, 1fr) ${EditorStyleHelper.tocWidth}px`
|
||||
: `1fr minmax(0, ${`calc(52em + 76px)`}) 1fr`};
|
||||
: `1fr minmax(0, ${`calc(52em + 88px)`}) 1fr`};
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -670,7 +648,7 @@ type EditorContainerProps = {
|
||||
|
||||
const EditorContainer = styled.div<EditorContainerProps>`
|
||||
// Adds space to the gutter to make room for icon & heading annotations
|
||||
padding: 0 40px;
|
||||
padding: 0 44px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
grid-row: 1;
|
||||
|
||||
@@ -175,7 +175,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
[comments]
|
||||
);
|
||||
|
||||
const { setEditor } = useDocumentContext();
|
||||
const { setEditor, updateState: updateDocState } = useDocumentContext();
|
||||
const handleRefChanged = React.useCallback(setEditor, [setEditor]);
|
||||
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
|
||||
|
||||
@@ -241,6 +241,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
? handleRemoveComment
|
||||
: undefined
|
||||
}
|
||||
onChange={updateDocState}
|
||||
extensions={extensions}
|
||||
editorStyle={editorStyle}
|
||||
{...rest}
|
||||
|
||||
@@ -20,10 +20,7 @@ import Badge from "~/components/Badge";
|
||||
import Button from "~/components/Button";
|
||||
import Collaborators from "~/components/Collaborators";
|
||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import {
|
||||
useDocumentContext,
|
||||
useEditingFocus,
|
||||
} from "~/components/DocumentContext";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Flex from "~/components/Flex";
|
||||
import Header from "~/components/Header";
|
||||
import Icon from "~/components/Icon";
|
||||
@@ -35,6 +32,7 @@ import { restoreRevision } from "~/actions/definitions/revisions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useEditingFocus from "~/hooks/useEditingFocus";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -51,7 +49,6 @@ import ShareButton from "./ShareButton";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
documentHasHeadings: boolean;
|
||||
revision: Revision | undefined;
|
||||
sharedTree: NavigationNode | undefined;
|
||||
shareId: string | null | undefined;
|
||||
@@ -67,16 +64,10 @@ type Props = {
|
||||
publish?: boolean;
|
||||
autosave?: boolean;
|
||||
}) => void;
|
||||
headings: {
|
||||
title: string;
|
||||
level: number;
|
||||
id: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
function DocumentHeader({
|
||||
document,
|
||||
documentHasHeadings,
|
||||
revision,
|
||||
shareId,
|
||||
isEditing,
|
||||
@@ -88,7 +79,6 @@ function DocumentHeader({
|
||||
sharedTree,
|
||||
onSelectTemplate,
|
||||
onSave,
|
||||
headings,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
@@ -100,6 +90,7 @@ function DocumentHeader({
|
||||
const isRevision = !!revision;
|
||||
const isEditingFocus = useEditingFocus();
|
||||
const { editor } = useDocumentContext();
|
||||
const { hasHeadings } = useDocumentContext();
|
||||
|
||||
// We cache this value for as long as the component is mounted so that if you
|
||||
// apply a template there is still the option to replace it until the user
|
||||
@@ -129,7 +120,7 @@ function DocumentHeader({
|
||||
content={
|
||||
showContents
|
||||
? t("Hide contents")
|
||||
: documentHasHeadings
|
||||
: hasHeadings
|
||||
? t("Show contents")
|
||||
: `${t("Show contents")} (${t("available when headings are added")})`
|
||||
}
|
||||
@@ -210,14 +201,14 @@ function DocumentHeader({
|
||||
hasSidebar={sharedTree && sharedTree.children?.length > 0}
|
||||
left={
|
||||
isMobile ? (
|
||||
<TableOfContentsMenu headings={headings} />
|
||||
<TableOfContentsMenu />
|
||||
) : (
|
||||
<PublicBreadcrumb
|
||||
documentId={document.id}
|
||||
shareId={shareId}
|
||||
sharedTree={sharedTree}
|
||||
>
|
||||
{documentHasHeadings ? toc : null}
|
||||
{hasHeadings ? toc : null}
|
||||
</PublicBreadcrumb>
|
||||
)
|
||||
}
|
||||
@@ -238,7 +229,7 @@ function DocumentHeader({
|
||||
hasSidebar
|
||||
left={
|
||||
isMobile ? (
|
||||
<TableOfContentsMenu headings={headings} />
|
||||
<TableOfContentsMenu />
|
||||
) : (
|
||||
<DocumentBreadcrumb document={document}>
|
||||
{toc} <Star document={document} color={theme.textSecondary} />
|
||||
|
||||
@@ -5,9 +5,9 @@ import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||
import { useEditingFocus } from "~/components/DocumentContext";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useEditingFocus from "~/hooks/useEditingFocus";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
function KeyboardShortcutsButton() {
|
||||
|
||||
@@ -27,7 +27,7 @@ function References({ document }: Props) {
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const children = collection
|
||||
? collection.getDocumentChildren(document.id)
|
||||
? collection.getChildrenForDocument(document.id)
|
||||
: [];
|
||||
const showBacklinks = !!backlinks.length;
|
||||
const showChildDocuments = !!children.length;
|
||||
|
||||
@@ -31,7 +31,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const nestedDocumentsCount = collection
|
||||
? collection.getDocumentChildren(document.id).length
|
||||
? collection.getChildrenForDocument(document.id).length
|
||||
: 0;
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useState } from "react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { CollectionPermission, NavigationNode } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
item:
|
||||
| {
|
||||
active: boolean | null | undefined;
|
||||
children: Array<NavigationNode>;
|
||||
collectionId: string;
|
||||
depth: number;
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
collectionId: string;
|
||||
title: string;
|
||||
};
|
||||
collection: Collection;
|
||||
onCancel: () => void;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
function DocumentReparent({ collection, item, onSubmit, onCancel }: Props) {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { documents, collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const prevCollection = collections.get(item.collectionId);
|
||||
const accessMapping = {
|
||||
[CollectionPermission.ReadWrite]: t("view and edit access"),
|
||||
[CollectionPermission.Read]: t("view only access"),
|
||||
null: t("no access"),
|
||||
};
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
toast.message(t("Document moved"));
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[documents, item.id, collection.id, t, onSubmit]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Heads up – moving the document <em>{{ title }}</em> to the <em>{{ newCollectionName }}</em> collection will grant all members of the workspace <em>{{ newPermission }}</em>, they currently have {{ prevPermission }}."
|
||||
values={{
|
||||
title: item.title,
|
||||
prevCollectionName: prevCollection?.name,
|
||||
newCollectionName: collection.name,
|
||||
prevPermission:
|
||||
accessMapping[prevCollection?.permission || "null"],
|
||||
newPermission: accessMapping[collection.permission || "null"],
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<Button type="submit">
|
||||
{isSaving ? `${t("Moving")}…` : t("Move document")}
|
||||
</Button>{" "}
|
||||
<Button type="button" onClick={onCancel} neutral>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(DocumentReparent);
|
||||
@@ -52,11 +52,21 @@ const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
<Flex align="center" gap={8}>
|
||||
<CopyToClipboard text={apiKey.secret} onCopy={handleCopy}>
|
||||
<Button type="button" icon={<CopyIcon />} neutral borderOnHover>
|
||||
{isCopied ? t("Copied") : t("Copy")}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
{apiKey.value && (
|
||||
<CopyToClipboard text={apiKey.value} onCopy={handleCopy}>
|
||||
<Button type="button" icon={<CopyIcon />} neutral borderOnHover>
|
||||
{isCopied ? t("Copied") : t("Copy")}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
)}
|
||||
<Text
|
||||
type="tertiary"
|
||||
size="xsmall"
|
||||
style={{ marginRight: 8 }}
|
||||
monospace
|
||||
>
|
||||
{apiKey.obfuscatedValue}
|
||||
</Text>
|
||||
<ApiKeyMenu apiKey={apiKey} />
|
||||
</Flex>
|
||||
}
|
||||
|
||||
@@ -1,38 +1,15 @@
|
||||
import invariant from "invariant";
|
||||
import concat from "lodash/concat";
|
||||
import find from "lodash/find";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import last from "lodash/last";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { computed, action } from "mobx";
|
||||
import {
|
||||
CollectionPermission,
|
||||
FileOperationFormat,
|
||||
NavigationNode,
|
||||
} from "@shared/types";
|
||||
import { CollectionPermission, FileOperationFormat } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import { Properties } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import RootStore from "./RootStore";
|
||||
import Store from "./base/Store";
|
||||
|
||||
enum DocumentPathItemType {
|
||||
Collection = "collection",
|
||||
Document = "document",
|
||||
}
|
||||
|
||||
export type DocumentPathItem = {
|
||||
type: DocumentPathItemType;
|
||||
id: string;
|
||||
collectionId: string;
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type DocumentPath = DocumentPathItem & {
|
||||
path: DocumentPathItem[];
|
||||
};
|
||||
|
||||
export default class CollectionsStore extends Store<Collection> {
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, Collection);
|
||||
@@ -95,55 +72,6 @@ export default class CollectionsStore extends Store<Collection> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* List of paths to each of the documents, where paths are composed of id and title/name pairs
|
||||
*/
|
||||
@computed
|
||||
get pathsToDocuments(): DocumentPath[] {
|
||||
const results: DocumentPathItem[][] = [];
|
||||
|
||||
const travelDocuments = (
|
||||
documentList: NavigationNode[],
|
||||
collectionId: string,
|
||||
path: DocumentPathItem[]
|
||||
) =>
|
||||
documentList.forEach((document: NavigationNode) => {
|
||||
const { id, title, url } = document;
|
||||
const node = {
|
||||
type: DocumentPathItemType.Document,
|
||||
id,
|
||||
collectionId,
|
||||
title,
|
||||
url,
|
||||
};
|
||||
results.push(concat(path, node));
|
||||
travelDocuments(document.children, collectionId, concat(path, [node]));
|
||||
});
|
||||
|
||||
if (this.isLoaded) {
|
||||
this.data.forEach((collection) => {
|
||||
const { id, name, path } = collection;
|
||||
const node = {
|
||||
type: DocumentPathItemType.Collection,
|
||||
id,
|
||||
collectionId: id,
|
||||
title: name,
|
||||
url: path,
|
||||
};
|
||||
results.push([node]);
|
||||
|
||||
if (collection.documents) {
|
||||
travelDocuments(collection.documents, id, [node]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return results.map((result) => {
|
||||
const tail = last(result) as DocumentPathItem;
|
||||
return { ...tail, path: result };
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
import = async (
|
||||
attachmentId: string,
|
||||
@@ -191,15 +119,6 @@ export default class CollectionsStore extends Store<Collection> {
|
||||
return model;
|
||||
}
|
||||
|
||||
@computed
|
||||
get publicCollections() {
|
||||
return this.orderedData.filter(
|
||||
(collection) =>
|
||||
collection.permission &&
|
||||
Object.values(CollectionPermission).includes(collection.permission)
|
||||
);
|
||||
}
|
||||
|
||||
star = async (collection: Collection, index?: string) => {
|
||||
await this.rootStore.stars.create({
|
||||
collectionId: collection.id,
|
||||
@@ -209,24 +128,14 @@ export default class CollectionsStore extends Store<Collection> {
|
||||
|
||||
unstar = async (collection: Collection) => {
|
||||
const star = this.rootStore.stars.orderedData.find(
|
||||
(star) => star.collectionId === collection.id
|
||||
(s) => s.collectionId === collection.id
|
||||
);
|
||||
await star?.delete();
|
||||
};
|
||||
|
||||
getPathForDocument(documentId: string): DocumentPath | undefined {
|
||||
return this.pathsToDocuments.find((path) => path.id === documentId);
|
||||
}
|
||||
|
||||
titleForDocument(documentPath: string): string | undefined {
|
||||
const path = this.pathsToDocuments.find(
|
||||
(path) => path.url === documentPath
|
||||
);
|
||||
if (path) {
|
||||
return path.title;
|
||||
}
|
||||
|
||||
return;
|
||||
@computed
|
||||
get navigationNodes() {
|
||||
return this.orderedData.map((collection) => collection.asNavigationNode);
|
||||
}
|
||||
|
||||
getByUrl(url: string): Collection | null | undefined {
|
||||
|
||||
@@ -367,8 +367,8 @@ export default class DocumentsStore extends Store<Document> {
|
||||
this.fetchNamedPage("starred", options);
|
||||
|
||||
@action
|
||||
fetchDrafts = (options?: PaginationParams): Promise<Document[]> =>
|
||||
this.fetchNamedPage("drafts", options);
|
||||
fetchDrafts = (options: PaginationParams = {}): Promise<Document[]> =>
|
||||
this.fetchNamedPage("drafts", { limit: 100, ...options });
|
||||
|
||||
@action
|
||||
fetchOwned = (options?: PaginationParams): Promise<Document[]> =>
|
||||
|
||||
+2
-9
@@ -103,6 +103,8 @@ export type Action = {
|
||||
shortcut?: string[];
|
||||
keywords?: string;
|
||||
dangerous?: boolean;
|
||||
/** Higher number is higher in results, default is 0. */
|
||||
priority?: number;
|
||||
iconInContextMenu?: boolean;
|
||||
icon?: React.ReactElement | React.FC;
|
||||
placeholder?: ((context: ActionContext) => string) | string;
|
||||
@@ -140,15 +142,6 @@ export type FetchOptions = {
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export type NavigationNode = {
|
||||
id: string;
|
||||
title: string;
|
||||
emoji?: string | null;
|
||||
url: string;
|
||||
children: NavigationNode[];
|
||||
isDraft?: boolean;
|
||||
};
|
||||
|
||||
export type CollectionSort = {
|
||||
field: string;
|
||||
direction: "asc" | "desc";
|
||||
|
||||
+3
-3
@@ -47,10 +47,10 @@ export function redirectTo(url: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the path is a valid redirect after login
|
||||
* Check if the path is a valid path for redirect after login.
|
||||
*
|
||||
* @param path
|
||||
* @returns boolean indicating if the path is a valid redirect
|
||||
*/
|
||||
export const isValidPostLoginRedirect = (path: string) =>
|
||||
!["/", "/create", "/home", "/logout"].includes(path);
|
||||
export const isAllowedLoginRedirect = (path: string) =>
|
||||
!["/", "/create", "/home", "/logout", "/auth/"].includes(path);
|
||||
|
||||
+10
-9
@@ -17,6 +17,7 @@
|
||||
"prepare": "husky install",
|
||||
"postinstall": "yarn patch-package",
|
||||
"install-local-ssl": "node ./server/scripts/install-local-ssl.js",
|
||||
"release": "node ./server/scripts/release.js",
|
||||
"heroku-postbuild": "yarn build && yarn db:migrate",
|
||||
"db:create-migration": "sequelize migration:create",
|
||||
"db:create": "sequelize db:create",
|
||||
@@ -94,7 +95,7 @@
|
||||
"babel-plugin-styled-components": "^2.1.4",
|
||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
||||
"body-scroll-lock": "^4.0.0-beta.0",
|
||||
"bull": "^4.12.2",
|
||||
"bull": "^4.16.3",
|
||||
"chalk": "^4.1.0",
|
||||
"class-validator": "^0.14.1",
|
||||
"command-score": "^0.1.2",
|
||||
@@ -158,7 +159,7 @@
|
||||
"node-fetch": "2.7.0",
|
||||
"nodemailer": "^6.9.14",
|
||||
"octokit": "^3.2.1",
|
||||
"outline-icons": "^3.6.0",
|
||||
"outline-icons": "^3.8.0",
|
||||
"oy-vey": "^0.12.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth2": "^0.2.0",
|
||||
@@ -183,7 +184,7 @@
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.4.0",
|
||||
"prosemirror-transform": "^1.10.0",
|
||||
"prosemirror-view": "^1.34.2",
|
||||
"prosemirror-view": "^1.34.3",
|
||||
"query-string": "^7.1.3",
|
||||
"randomstring": "1.3.0",
|
||||
"rate-limiter-flexible": "^2.4.2",
|
||||
@@ -220,9 +221,9 @@
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"slug": "^5.3.0",
|
||||
"slugify": "^1.6.6",
|
||||
"smooth-scroll-into-view-if-needed": "^1.1.33",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"socket.io": "^4.7.5",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"socket.io-redis": "^6.1.1",
|
||||
"sonner": "^1.0.3",
|
||||
"stoppable": "^1.1.0",
|
||||
@@ -255,7 +256,7 @@
|
||||
"@relative-ci/agent": "^4.2.9",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
"@types/addressparser": "^1.0.3",
|
||||
"@types/body-scroll-lock": "^3.1.0",
|
||||
"@types/body-scroll-lock": "^3.1.2",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/diff": "^5.0.9",
|
||||
"@types/dotenv": "^8.2.0",
|
||||
@@ -268,7 +269,7 @@
|
||||
"@types/glob": "^8.0.1",
|
||||
"@types/google.analytics": "^0.0.46",
|
||||
"@types/invariant": "^2.2.37",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/koa": "^2.15.0",
|
||||
@@ -328,7 +329,7 @@
|
||||
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
|
||||
"browserslist-to-esbuild": "^1.2.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"discord-api-types": "^0.37.87",
|
||||
"discord-api-types": "^0.37.101",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.3",
|
||||
@@ -366,5 +367,5 @@
|
||||
"qs": "6.9.7",
|
||||
"rollup": "^4.5.1"
|
||||
},
|
||||
"version": "0.79.1"
|
||||
"version": "0.80.2"
|
||||
}
|
||||
|
||||
@@ -93,22 +93,29 @@ if (
|
||||
}
|
||||
const team = await getTeamFromContext(ctx);
|
||||
const client = getClientFromContext(ctx);
|
||||
const { domain } = parseEmail(profile.email);
|
||||
|
||||
// Only a single OIDC provider is supported – find the existing, if any.
|
||||
const authenticationProvider = team
|
||||
? await AuthenticationProvider.findOne({
|
||||
? (await AuthenticationProvider.findOne({
|
||||
where: {
|
||||
name: "oidc",
|
||||
teamId: team.id,
|
||||
providerId: domain,
|
||||
},
|
||||
})) ??
|
||||
(await AuthenticationProvider.findOne({
|
||||
where: {
|
||||
name: "oidc",
|
||||
teamId: team.id,
|
||||
},
|
||||
})
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
// Derive a providerId from the OIDC location if there is no existing provider.
|
||||
const oidcURL = new URL(env.OIDC_AUTH_URI!);
|
||||
const providerId =
|
||||
authenticationProvider?.providerId ?? oidcURL.hostname;
|
||||
const { domain } = parseEmail(profile.email);
|
||||
|
||||
if (!domain) {
|
||||
throw OIDCMalformedUserInfoError();
|
||||
|
||||
@@ -111,16 +111,14 @@ export default class PersistenceExtension implements Extension {
|
||||
return;
|
||||
}
|
||||
|
||||
const collaboratorIds = Array.from(documentCollaboratorIds.values());
|
||||
const sessionCollaboratorIds = Array.from(documentCollaboratorIds.values());
|
||||
this.documentCollaboratorIds.delete(documentName);
|
||||
|
||||
try {
|
||||
await documentCollaborativeUpdater({
|
||||
documentId,
|
||||
ydoc: document,
|
||||
// TODO: Right now we're attributing all changes to the last editor,
|
||||
// It would be nice in the future to have multiple editors per revision.
|
||||
userId: collaboratorIds.pop(),
|
||||
sessionCollaboratorIds,
|
||||
isLastConnection: clientsCount === 0,
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -10,20 +10,20 @@ import { Document, Event } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
|
||||
type Props = {
|
||||
/** The document ID to update */
|
||||
/** The document ID to update. */
|
||||
documentId: string;
|
||||
/** Current collaobrative state */
|
||||
/** Current collaobrative state. */
|
||||
ydoc: Y.Doc;
|
||||
/** The user ID that is performing the update, if known */
|
||||
userId?: string;
|
||||
/** Whether the last connection to the document left */
|
||||
/** The user IDs that have modified the document since it was last persisted. */
|
||||
sessionCollaboratorIds: string[];
|
||||
/** Whether the last connection to the document left. */
|
||||
isLastConnection: boolean;
|
||||
};
|
||||
|
||||
export default async function documentCollaborativeUpdater({
|
||||
documentId,
|
||||
ydoc,
|
||||
userId,
|
||||
sessionCollaboratorIds,
|
||||
isLastConnection,
|
||||
}: Props) {
|
||||
return sequelize.transaction(async (transaction) => {
|
||||
@@ -47,7 +47,9 @@ export default async function documentCollaborativeUpdater({
|
||||
const node = Node.fromJSON(schema, content);
|
||||
const text = serializer.serialize(node, undefined);
|
||||
const isUnchanged = isEqual(document.content, content);
|
||||
const lastModifiedById = userId ?? document.lastModifiedById;
|
||||
const lastModifiedById =
|
||||
sessionCollaboratorIds[sessionCollaboratorIds.length - 1] ??
|
||||
document.lastModifiedById;
|
||||
|
||||
if (isUnchanged) {
|
||||
return;
|
||||
@@ -61,7 +63,11 @@ export default async function documentCollaborativeUpdater({
|
||||
// extract collaborators from doc user data
|
||||
const pud = new Y.PermanentUserData(ydoc);
|
||||
const pudIds = Array.from(pud.clients.values());
|
||||
const collaboratorIds = uniq([...document.collaboratorIds, ...pudIds]);
|
||||
const collaboratorIds = uniq([
|
||||
...document.collaboratorIds,
|
||||
...sessionCollaboratorIds,
|
||||
...pudIds,
|
||||
]);
|
||||
|
||||
await document.update(
|
||||
{
|
||||
|
||||
@@ -48,4 +48,37 @@ describe("documentUpdater", () => {
|
||||
|
||||
expect(document.lastModifiedById).not.toEqual(user.id);
|
||||
});
|
||||
|
||||
it("should update document content when changing text", async () => {
|
||||
const user = await buildUser();
|
||||
let document = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
document = await sequelize.transaction(async (transaction) =>
|
||||
documentUpdater({
|
||||
text: "Changed",
|
||||
document,
|
||||
user,
|
||||
ip,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
|
||||
expect(document.text).toEqual("Changed");
|
||||
expect(document.content).toEqual({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Changed",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -233,7 +233,7 @@ class Logger {
|
||||
}
|
||||
|
||||
if (isArray(input)) {
|
||||
return input.map(this.sanitize) as any as T;
|
||||
return input.map((item) => this.sanitize(item, level + 1)) as any as T;
|
||||
}
|
||||
|
||||
if (isObject(input)) {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { DefaultState } from "koa";
|
||||
import randomstring from "randomstring";
|
||||
import ApiKey from "@server/models/ApiKey";
|
||||
import { buildUser, buildTeam, buildAdmin } from "@server/test/factories";
|
||||
import {
|
||||
buildUser,
|
||||
buildTeam,
|
||||
buildAdmin,
|
||||
buildApiKey,
|
||||
} from "@server/test/factories";
|
||||
import auth from "./authentication";
|
||||
|
||||
describe("Authentication middleware", () => {
|
||||
@@ -51,14 +55,12 @@ describe("Authentication middleware", () => {
|
||||
const state = {} as DefaultState;
|
||||
const user = await buildUser();
|
||||
const authMiddleware = auth();
|
||||
const key = await ApiKey.create({
|
||||
userId: user.id,
|
||||
});
|
||||
const key = await buildApiKey({ userId: user.id });
|
||||
await authMiddleware(
|
||||
{
|
||||
// @ts-expect-error mock request
|
||||
request: {
|
||||
get: jest.fn(() => `Bearer ${key.secret}`),
|
||||
get: jest.fn(() => `Bearer ${key.value}`),
|
||||
},
|
||||
state,
|
||||
cache: {},
|
||||
|
||||
@@ -70,11 +70,7 @@ export default function auth(options: AuthenticationOptions = {}) {
|
||||
let apiKey;
|
||||
|
||||
try {
|
||||
apiKey = await ApiKey.findOne({
|
||||
where: {
|
||||
secret: token,
|
||||
},
|
||||
});
|
||||
apiKey = await ApiKey.findByToken(token);
|
||||
} catch (err) {
|
||||
throw AuthenticationError("Invalid API key");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up (queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async transaction => {
|
||||
await queryInterface.addColumn("apiKeys", "hash", {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
unique: true,
|
||||
}, { transaction });
|
||||
|
||||
await queryInterface.addColumn("apiKeys", "last4", {
|
||||
type: Sequelize.STRING(4),
|
||||
allowNull: true,
|
||||
}, { transaction });
|
||||
|
||||
await queryInterface.changeColumn("apiKeys", "secret", {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
}, { transaction });
|
||||
});
|
||||
},
|
||||
|
||||
async down (queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async transaction => {
|
||||
await queryInterface.removeColumn("apiKeys", "hash", { transaction });
|
||||
await queryInterface.removeColumn("apiKeys", "last4", { transaction });
|
||||
await queryInterface.changeColumn("apiKeys", "secret", {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
}, { transaction });
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
"use strict";
|
||||
|
||||
const { execFileSync } = require("child_process");
|
||||
const path = require("path");
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up() {
|
||||
if (
|
||||
process.env.NODE_ENV === "test" ||
|
||||
process.env.DEPLOYMENT === "hosted"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scriptName = path.basename(__filename);
|
||||
const scriptPath = path.join(
|
||||
process.cwd(),
|
||||
"build",
|
||||
`server/scripts/${scriptName}`
|
||||
);
|
||||
|
||||
execFileSync("node", [scriptPath], { stdio: "inherit" });
|
||||
},
|
||||
|
||||
async down() {
|
||||
// noop
|
||||
},
|
||||
};
|
||||
@@ -5,10 +5,8 @@ import ApiKey from "./ApiKey";
|
||||
describe("#ApiKey", () => {
|
||||
describe("match", () => {
|
||||
test("should match an API secret", async () => {
|
||||
const apiKey = await buildApiKey({
|
||||
name: "Dev",
|
||||
});
|
||||
expect(ApiKey.match(apiKey?.secret)).toBe(true);
|
||||
const apiKey = await buildApiKey();
|
||||
expect(ApiKey.match(apiKey.value!)).toBe(true);
|
||||
expect(ApiKey.match(`${randomstring.generate(38)}`)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -20,17 +18,13 @@ describe("#ApiKey", () => {
|
||||
|
||||
describe("lastActiveAt", () => {
|
||||
test("should update lastActiveAt", async () => {
|
||||
const apiKey = await buildApiKey({
|
||||
name: "Dev",
|
||||
});
|
||||
const apiKey = await buildApiKey();
|
||||
await apiKey.updateActiveAt();
|
||||
expect(apiKey.lastActiveAt).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should not update lastActiveAt within 5 minutes", async () => {
|
||||
const apiKey = await buildApiKey({
|
||||
name: "Dev",
|
||||
});
|
||||
const apiKey = await buildApiKey();
|
||||
await apiKey.updateActiveAt();
|
||||
expect(apiKey.lastActiveAt).toBeTruthy();
|
||||
|
||||
@@ -39,4 +33,15 @@ describe("#ApiKey", () => {
|
||||
expect(apiKey.lastActiveAt).toEqual(lastActiveAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findByToken", () => {
|
||||
test("should find by hash", async () => {
|
||||
const apiKey = await buildApiKey({
|
||||
name: "Dev",
|
||||
});
|
||||
const found = await ApiKey.findByToken(apiKey.value!);
|
||||
expect(found?.id).toEqual(apiKey.id);
|
||||
expect(found?.last4).toEqual(apiKey.value!.slice(-4));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+72
-6
@@ -1,6 +1,7 @@
|
||||
import crypto from "crypto";
|
||||
import { subMinutes } from "date-fns";
|
||||
import randomstring from "randomstring";
|
||||
import { InferAttributes, InferCreationAttributes } from "sequelize";
|
||||
import { InferAttributes, InferCreationAttributes, Op } from "sequelize";
|
||||
import {
|
||||
Column,
|
||||
Table,
|
||||
@@ -9,6 +10,9 @@ import {
|
||||
BelongsTo,
|
||||
ForeignKey,
|
||||
IsDate,
|
||||
DataType,
|
||||
AfterFind,
|
||||
BeforeSave,
|
||||
} from "sequelize-typescript";
|
||||
import { ApiKeyValidation } from "@shared/validations";
|
||||
import User from "./User";
|
||||
@@ -32,10 +36,24 @@ class ApiKey extends ParanoidModel<
|
||||
@Column
|
||||
name: string;
|
||||
|
||||
/** @deprecated The plain text value of the API key, removed soon. */
|
||||
@Unique
|
||||
@Column
|
||||
secret: string;
|
||||
|
||||
/** The cached plain text value. Only available when creating the API key */
|
||||
@Column(DataType.VIRTUAL)
|
||||
value: string | null;
|
||||
|
||||
/** The hashed value of the API key */
|
||||
@Unique
|
||||
@Column
|
||||
hash: string;
|
||||
|
||||
/** The last 4 characters of the API key */
|
||||
@Column
|
||||
last4: string;
|
||||
|
||||
@IsDate
|
||||
@Column
|
||||
expiresAt: Date | null;
|
||||
@@ -46,13 +64,43 @@ class ApiKey extends ParanoidModel<
|
||||
|
||||
// hooks
|
||||
|
||||
@BeforeValidate
|
||||
static async generateSecret(model: ApiKey) {
|
||||
if (!model.secret) {
|
||||
model.secret = `${ApiKey.prefix}${randomstring.generate(38)}`;
|
||||
@AfterFind
|
||||
public static async afterFindHook(models: ApiKey | ApiKey[]) {
|
||||
const modelsArray = Array.isArray(models) ? models : [models];
|
||||
for (const model of modelsArray) {
|
||||
if (model?.secret) {
|
||||
model.last4 = model.secret.slice(-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeValidate
|
||||
public static async generateSecret(model: ApiKey) {
|
||||
if (!model.hash) {
|
||||
const secret = `${ApiKey.prefix}${randomstring.generate(38)}`;
|
||||
model.value = model.secret || secret;
|
||||
model.hash = this.hash(model.value);
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeSave
|
||||
public static async updateLast4(model: ApiKey) {
|
||||
const value = model.value || model.secret;
|
||||
if (value) {
|
||||
model.last4 = value.slice(-4);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a hashed API key for the given input key.
|
||||
*
|
||||
* @param key The input string to hash
|
||||
* @returns The hashed API key
|
||||
*/
|
||||
public static hash(key: string) {
|
||||
return crypto.createHash("sha256").update(key).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the input touch could be an API key, this does not check
|
||||
* that the key exists in the database.
|
||||
@@ -60,10 +108,26 @@ class ApiKey extends ParanoidModel<
|
||||
* @param text The text to validate
|
||||
* @returns True if likely an API key
|
||||
*/
|
||||
static match(text: string) {
|
||||
public static match(text: string) {
|
||||
// cannot guarantee prefix here as older keys do not include it.
|
||||
return !!text.replace(ApiKey.prefix, "").match(/^[\w]{38}$/);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an API key by the given input string. This will check both the
|
||||
* secret and hash fields.
|
||||
*
|
||||
* @param input The input string to search for
|
||||
* @returns The API key if found
|
||||
*/
|
||||
public static findByToken(input: string) {
|
||||
return this.findOne({
|
||||
where: {
|
||||
[Op.or]: [{ secret: input }, { hash: this.hash(input) }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
@@ -73,6 +137,8 @@ class ApiKey extends ParanoidModel<
|
||||
@Column
|
||||
userId: string;
|
||||
|
||||
// methods
|
||||
|
||||
updateActiveAt = async () => {
|
||||
const fiveMinutesAgo = subMinutes(new Date(), 5);
|
||||
|
||||
|
||||
+10
-11
@@ -23,7 +23,6 @@ import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
Default,
|
||||
PrimaryKey,
|
||||
Table,
|
||||
BeforeValidate,
|
||||
BeforeCreate,
|
||||
@@ -39,6 +38,7 @@ import {
|
||||
IsDate,
|
||||
AllowNull,
|
||||
BelongsToMany,
|
||||
Unique,
|
||||
} from "sequelize-typescript";
|
||||
import isUUID from "validator/lib/isUUID";
|
||||
import type {
|
||||
@@ -268,7 +268,7 @@ class Document extends ParanoidModel<
|
||||
max: 10,
|
||||
msg: `urlId must be 10 characters`,
|
||||
})
|
||||
@PrimaryKey
|
||||
@Unique
|
||||
@Column
|
||||
urlId: string;
|
||||
|
||||
@@ -807,15 +807,14 @@ class Document extends ParanoidModel<
|
||||
* @param options FindOptions
|
||||
* @returns A promise that resolve to a list of users
|
||||
*/
|
||||
collaborators = async (options?: FindOptions<User>): Promise<User[]> => {
|
||||
const users = await Promise.all(
|
||||
this.collaboratorIds.map((collaboratorId) =>
|
||||
User.findByPk(collaboratorId, options)
|
||||
)
|
||||
);
|
||||
|
||||
return compact(users);
|
||||
};
|
||||
collaborators = async (options?: FindOptions<User>): Promise<User[]> =>
|
||||
await User.findAll({
|
||||
...options,
|
||||
where: {
|
||||
...options?.where,
|
||||
id: this.collaboratorIds,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Find all of the child documents for this document
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
updateYFragment,
|
||||
yDocToProsemirror,
|
||||
yDocToProsemirrorJSON,
|
||||
} from "@getoutline/y-prosemirror";
|
||||
import { JSDOM } from "jsdom";
|
||||
@@ -442,6 +441,7 @@ export class DocumentHelper {
|
||||
) {
|
||||
document.text = append ? document.text + text : text;
|
||||
const doc = parser.parse(document.text);
|
||||
document.content = doc.toJSON();
|
||||
|
||||
if (document.state) {
|
||||
const ydoc = new Y.Doc();
|
||||
@@ -456,13 +456,9 @@ export class DocumentHelper {
|
||||
updateYFragment(type.doc, type, doc, new Map());
|
||||
|
||||
const state = Y.encodeStateAsUpdate(ydoc);
|
||||
const node = yDocToProsemirror(schema, ydoc);
|
||||
|
||||
document.content = node.toJSON();
|
||||
document.state = Buffer.from(state);
|
||||
document.changed("state", true);
|
||||
} else if (doc) {
|
||||
document.content = doc.toJSON();
|
||||
}
|
||||
|
||||
return document;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import ApiKey from "@server/models/ApiKey";
|
||||
|
||||
export default function presentApiKey(key: ApiKey) {
|
||||
export default function presentApiKey(apiKey: ApiKey) {
|
||||
return {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
secret: key.secret,
|
||||
createdAt: key.createdAt,
|
||||
updatedAt: key.updatedAt,
|
||||
expiresAt: key.expiresAt,
|
||||
lastActiveAt: key.lastActiveAt,
|
||||
id: apiKey.id,
|
||||
name: apiKey.name,
|
||||
value: apiKey.value,
|
||||
last4: apiKey.last4,
|
||||
createdAt: apiKey.createdAt,
|
||||
updatedAt: apiKey.updatedAt,
|
||||
expiresAt: apiKey.expiresAt,
|
||||
lastActiveAt: apiKey.lastActiveAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import NotificationHelper from "@server/models/helpers/NotificationHelper";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { CommentEvent } from "@server/types";
|
||||
import { canUserAccessDocument } from "@server/utils/policies";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
|
||||
export default class CommentCreatedNotificationsTask extends BaseTask<CommentEvent> {
|
||||
@@ -52,7 +53,8 @@ export default class CommentCreatedNotificationsTask extends BaseTask<CommentEve
|
||||
recipient.id !== mention.actorId &&
|
||||
recipient.subscribedToEventType(
|
||||
NotificationEventType.MentionedInComment
|
||||
)
|
||||
) &&
|
||||
(await canUserAccessDocument(recipient, document.id))
|
||||
) {
|
||||
await Notification.create({
|
||||
event: NotificationEventType.MentionedInComment,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NotificationEventType } from "@shared/types";
|
||||
import { Comment, Document, Notification, User } from "@server/models";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { CommentEvent, CommentUpdateEvent } from "@server/types";
|
||||
import { canUserAccessDocument } from "@server/utils/policies";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
|
||||
export default class CommentUpdatedNotificationsTask extends BaseTask<CommentEvent> {
|
||||
@@ -41,7 +42,8 @@ export default class CommentUpdatedNotificationsTask extends BaseTask<CommentEve
|
||||
recipient.id !== mention.actorId &&
|
||||
recipient.subscribedToEventType(
|
||||
NotificationEventType.MentionedInComment
|
||||
)
|
||||
) &&
|
||||
(await canUserAccessDocument(recipient, document.id))
|
||||
) {
|
||||
await Notification.create({
|
||||
event: NotificationEventType.MentionedInComment,
|
||||
@@ -49,6 +51,7 @@ export default class CommentUpdatedNotificationsTask extends BaseTask<CommentEve
|
||||
actorId: mention.actorId,
|
||||
teamId: document.teamId,
|
||||
documentId: document.id,
|
||||
commentId: comment.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Document, Notification, User } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import NotificationHelper from "@server/models/helpers/NotificationHelper";
|
||||
import { DocumentEvent } from "@server/types";
|
||||
import { canUserAccessDocument } from "@server/utils/policies";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
|
||||
export default class DocumentPublishedNotificationsTask extends BaseTask<DocumentEvent> {
|
||||
@@ -33,7 +34,8 @@ export default class DocumentPublishedNotificationsTask extends BaseTask<Documen
|
||||
recipient.id !== mention.actorId &&
|
||||
recipient.subscribedToEventType(
|
||||
NotificationEventType.MentionedInDocument
|
||||
)
|
||||
) &&
|
||||
(await canUserAccessDocument(recipient, document.id))
|
||||
) {
|
||||
await Notification.create({
|
||||
event: NotificationEventType.MentionedInDocument,
|
||||
|
||||
@@ -171,7 +171,7 @@ export default class ImportMarkdownZipTask extends ImportTask {
|
||||
document.text = document.text
|
||||
.replace(new RegExp(escapeRegExp(encodedPath), "g"), reference)
|
||||
.replace(
|
||||
new RegExp(`/?${escapeRegExp(normalizedAttachmentPath)}`, "g"),
|
||||
new RegExp(`\.?/?${escapeRegExp(normalizedAttachmentPath)}`, "g"),
|
||||
reference
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import Logger from "@server/logging/Logger";
|
||||
import { Document, Revision, Notification, User, View } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import NotificationHelper from "@server/models/helpers/NotificationHelper";
|
||||
import { authorize } from "@server/policies";
|
||||
import { RevisionEvent } from "@server/types";
|
||||
import { canUserAccessDocument } from "@server/utils/policies";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
|
||||
export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionEvent> {
|
||||
@@ -54,7 +54,7 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
|
||||
recipient.subscribedToEventType(
|
||||
NotificationEventType.MentionedInDocument
|
||||
) &&
|
||||
(await this.canAccess(recipient, document))
|
||||
(await canUserAccessDocument(recipient, document.id))
|
||||
) {
|
||||
await Notification.create({
|
||||
event: NotificationEventType.MentionedInDocument,
|
||||
@@ -151,18 +151,6 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
|
||||
return true;
|
||||
};
|
||||
|
||||
private canAccess = async (user: User, model: Document) => {
|
||||
try {
|
||||
const document = await Document.findByPk(model.id, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
public get options() {
|
||||
return {
|
||||
priority: TaskPriority.Background,
|
||||
|
||||
@@ -580,7 +580,7 @@ router.post(
|
||||
|
||||
router.post(
|
||||
"collections.export",
|
||||
rateLimiter(RateLimiterStrategy.TenPerHour),
|
||||
rateLimiter(RateLimiterStrategy.FiftyPerHour),
|
||||
auth(),
|
||||
validate(T.CollectionsExportSchema),
|
||||
transaction(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user