mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
287 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b92d71a61 | |||
| cfce55250e | |||
| 6421995b29 | |||
| 8cfd8e25db | |||
| 1282e9653e | |||
| f1edaecf49 | |||
| f7d737ca45 | |||
| 41c2c760d4 | |||
| f692d1bc3a | |||
| 5197d6e18c | |||
| b901ea7b30 | |||
| 3820499856 | |||
| 0cffde63ab | |||
| 449ba6488e | |||
| 62f3e6921f | |||
| bc259316f7 | |||
| 98e03cc227 | |||
| 633e547d3e | |||
| d5de69fd4b | |||
| feec01f160 | |||
| aa5813032e | |||
| a6ba189180 | |||
| 4c65bbc57c | |||
| c76b4f46aa | |||
| ca17b41c53 | |||
| 9747c6ba5d | |||
| 55ffd6d098 | |||
| 9b26ccda19 | |||
| 56b38b9dbd | |||
| 0a3a684493 | |||
| 24548dc7ee | |||
| 28cc83ad05 | |||
| c57b845093 | |||
| 62ee075a6f | |||
| 356b0916fd | |||
| 03160c44d4 | |||
| bf65d80fc8 | |||
| 3d0df9c612 | |||
| 9de95ff658 | |||
| 55bdd6fbc0 | |||
| fec91fb210 | |||
| abb1d3a923 | |||
| f5de2834d6 | |||
| 68377c3c46 | |||
| 9661e18cbd | |||
| 08d210e483 | |||
| 5a0ce58fa0 | |||
| 08eeac2049 | |||
| 08a49378ea | |||
| a4a068a3ba | |||
| 27cbf5a56a | |||
| f3daf45ccc | |||
| c1c20f1ff9 | |||
| e4d60382fd | |||
| 763dd28829 | |||
| 93f7fa8c89 | |||
| 24e50d9290 | |||
| c1b19ef86c | |||
| 5780959e93 | |||
| 74192040a2 | |||
| 7b3eba0f2f | |||
| 9b03b529f8 | |||
| aa579412d0 | |||
| 774402560e | |||
| 0a875d4738 | |||
| de04d1c0c5 | |||
| d87e1f6264 | |||
| 0e249951ab | |||
| 398be02091 | |||
| 83f0d34430 | |||
| 21723d3ca2 | |||
| bc4f0b926d | |||
| 4d67d47795 | |||
| d372ccf5b6 | |||
| d78eeaba84 | |||
| fc333abb86 | |||
| 73ef9f9a05 | |||
| 670ddda3a4 | |||
| 6e74ccf61f | |||
| f3f7189c93 | |||
| 50e680aaaf | |||
| 373ffba384 | |||
| b0182dfc76 | |||
| 2084c4ff8e | |||
| 29fce45a6e | |||
| e524699a8c | |||
| 5e74554f4b | |||
| 2a3909f65a | |||
| b91c06d26a | |||
| f8433bc65e | |||
| 7bdae0cbda | |||
| 3692d9c930 | |||
| 2e1a827157 | |||
| fe33871dfe | |||
| f22bd1d7c8 | |||
| 48ff0ad84b | |||
| 4f626c08c2 | |||
| 57e9abd77f | |||
| 0d7ce76c21 | |||
| c8d307c2d4 | |||
| 10c51ef08d | |||
| bb988b551d | |||
| 0e75edf7e3 | |||
| 3523ee4c35 | |||
| c0fba3913c | |||
| 597106cb48 | |||
| 02c29e06fb | |||
| 7226109989 | |||
| 85957c10b8 | |||
| 7250bd3bcb | |||
| 2ee7e0f832 | |||
| c5278a71de | |||
| e41519575f | |||
| 201ccf39a0 | |||
| ac3285a29a | |||
| fdaeb6602d | |||
| da4cd4ebcd | |||
| b6fc8fb4b1 | |||
| 4e6572d686 | |||
| 9e378899ff | |||
| 31dafc4258 | |||
| 6614b23eae | |||
| 9e54fd1bfb | |||
| f0add849f9 | |||
| b55915c257 | |||
| bdac4360b4 | |||
| 72bfbf2060 | |||
| db02b0ae6b | |||
| bb40e4079a | |||
| 198a96c78f | |||
| 1dd835bb87 | |||
| 25c504ceaf | |||
| 9680e57849 | |||
| 0f8ac54bcb | |||
| 936a8b2510 | |||
| b7b5e3edb9 | |||
| 1cea59abe2 | |||
| 8f0211057c | |||
| 2bfef05137 | |||
| d2a99b6872 | |||
| 6c9f265918 | |||
| 7a8d40b9e7 | |||
| 3ddffdda17 | |||
| 91396148ae | |||
| 1c2ea2aa92 | |||
| ba5eb60825 | |||
| a0e363799c | |||
| 3d457890cd | |||
| e857d00e3d | |||
| 98d8435b15 | |||
| b80463665b | |||
| b4ce4a2922 | |||
| 9bee54b07e | |||
| d3c8224839 | |||
| 0a1c614c55 | |||
| db4dad5e37 | |||
| 35ff70bf14 | |||
| 8b5fdba6f4 | |||
| e0a3ad92e0 | |||
| 10f4889737 | |||
| 7f66393e63 | |||
| 033b05f679 | |||
| 8356d44cae | |||
| 030c0fd40e | |||
| 1a02b0d9d7 | |||
| be5f092117 | |||
| 0ba423feb4 | |||
| a41e17f875 | |||
| db114fd966 | |||
| ce987d23ed | |||
| 5e61fcd336 | |||
| 4f84daf558 | |||
| f80842ca20 | |||
| 53758b69fb | |||
| cd86877cb0 | |||
| ffae5d2f20 | |||
| 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 | |||
| 5c56714bc8 | |||
| 895a88f934 | |||
| f32db08ef3 | |||
| 05a513b10c | |||
| bf3c6333b0 | |||
| 544554f106 | |||
| 37c90e1592 | |||
| 815abc8423 | |||
| b9ed7ddf58 | |||
| bc0b73e7a7 | |||
| 1218bc1f3c | |||
| ae3b05fdba | |||
| 549c8d9ed8 | |||
| 6bb798220b | |||
| e032bb5ab8 | |||
| 23b3b8aa54 | |||
| 738d943bd4 | |||
| ae5c737ed2 | |||
| 5116147ace | |||
| e6ba84e434 | |||
| 3b546a7935 | |||
| 9373da0da6 | |||
| 494ef2a6cd | |||
| c60703cc5a | |||
| f5b6d10a73 | |||
| 3b17926023 | |||
| 0c080038d7 | |||
| ae0bd5f59d | |||
| 7c9a2bbcf6 | |||
| b55a8ab54f | |||
| 1bc41b4d62 | |||
| 43b9eb0ad7 | |||
| 3f87912656 | |||
| c960804bb8 | |||
| 26fa70cbbd | |||
| ba749cac71 | |||
| df08a0063c | |||
| 6591bbebc9 | |||
| cb56941a17 | |||
| 209e5e20d5 | |||
| 2d0612a9d0 | |||
| fca4467bda | |||
| b77af9bda3 | |||
| f984ee0fcc | |||
| f3fe73057a | |||
| 4a009ed35b | |||
| cd419190ef | |||
| 7c309c7986 | |||
| 4a2707c74c | |||
| a6b9672779 | |||
| 3bce4853c3 | |||
| 6859b0cf62 | |||
| d10668de54 | |||
| f8535ff047 | |||
| e2355d63a2 | |||
| ed22891a69 | |||
| 363c416873 | |||
| 967594686e | |||
| ce85b8f94d | |||
| 81b7ac5776 | |||
| fe5d8b7158 | |||
| 7013a87c6e | |||
| 4ef7e95863 | |||
| f81a836549 | |||
| 97674471db | |||
| 5a3e97d6c5 | |||
| 273d6550ca |
@@ -108,8 +108,7 @@ jobs:
|
||||
executor: docker-publisher
|
||||
steps:
|
||||
- checkout
|
||||
- setup_remote_docker:
|
||||
version: 20.10.6
|
||||
- setup_remote_docker
|
||||
- run:
|
||||
name: Install Docker buildx
|
||||
command: |
|
||||
|
||||
@@ -20,6 +20,11 @@ COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
|
||||
COPY --from=base $APP_PATH/node_modules ./node_modules
|
||||
COPY --from=base $APP_PATH/package.json ./package.json
|
||||
|
||||
# Install wget to healthcheck the server
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create a non-root user compatible with Debian and BusyBox based images
|
||||
RUN addgroup --gid 1001 nodejs && \
|
||||
adduser --uid 1001 --ingroup nodejs nodejs && \
|
||||
@@ -36,5 +41,7 @@ VOLUME /var/lib/outline/data
|
||||
|
||||
USER nodejs
|
||||
|
||||
HEALTHCHECK --interval=1m CMD wget -qO- "http://localhost:${PORT:-3000}/_health" | grep -q "OK" || exit 1
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["yarn", "start"]
|
||||
|
||||
@@ -6,10 +6,6 @@ WORKDIR $APP_PATH
|
||||
COPY ./package.json ./yarn.lock ./
|
||||
COPY ./patches ./patches
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
|
||||
yarn cache clean
|
||||
|
||||
@@ -23,4 +19,3 @@ RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 &
|
||||
yarn cache clean
|
||||
|
||||
ENV PORT=3000
|
||||
HEALTHCHECK CMD wget -qO- http://localhost:${PORT}/_health | grep -q "OK" || exit 1
|
||||
|
||||
@@ -3,8 +3,8 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.71.0
|
||||
The Licensed Work is (c) 2020 General Outline, Inc.
|
||||
Licensed Work: Outline 0.81.0
|
||||
The Licensed Work is (c) 2024 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
Service.
|
||||
@@ -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: 2027-08-18
|
||||
Change Date: 2028-11-11
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
"description": "Open source wiki and knowledge base for growing teams",
|
||||
"website": "https://www.getoutline.com/",
|
||||
"repository": "https://github.com/outline/outline",
|
||||
"keywords": ["wiki", "team", "node", "markdown", "slack"],
|
||||
"keywords": [
|
||||
"wiki",
|
||||
"team",
|
||||
"node",
|
||||
"markdown",
|
||||
"slack"
|
||||
],
|
||||
"success_url": "/",
|
||||
"formation": {
|
||||
"web": {
|
||||
@@ -212,4 +218,4 @@
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
ArchiveIcon,
|
||||
CollectionIcon,
|
||||
EditIcon,
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
RestoreIcon,
|
||||
SearchIcon,
|
||||
ShapesIcon,
|
||||
StarredIcon,
|
||||
@@ -10,16 +12,18 @@ import {
|
||||
UnstarredIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import stores from "~/stores";
|
||||
import Collection from "~/models/Collection";
|
||||
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
|
||||
import { CollectionNew } from "~/components/Collection/CollectionNew";
|
||||
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
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 +74,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 +100,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,11 +131,22 @@ 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 &&
|
||||
stores.policies.abilities(activeCollectionId).readDocument,
|
||||
visible: ({ activeCollectionId }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
if (!collection?.isActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return stores.policies.abilities(activeCollectionId).readDocument;
|
||||
},
|
||||
|
||||
perform: ({ activeCollectionId }) => {
|
||||
history.push(searchPath(undefined, { collectionId: activeCollectionId }));
|
||||
},
|
||||
@@ -140,7 +155,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 +182,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 }) => {
|
||||
@@ -190,10 +205,76 @@ export const unstarCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const archiveCollection = createAction({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive collection",
|
||||
section: CollectionSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeCollectionId).archive;
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
const { dialogs, collections } = stores;
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
dialogs.openModal({
|
||||
title: t("Archive collection"),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
await collection.archive();
|
||||
toast.success(t("Collection archived"));
|
||||
}}
|
||||
submitText={t("Archive")}
|
||||
savingText={`${t("Archiving")}…`}
|
||||
>
|
||||
{t(
|
||||
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results."
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const restoreCollection = createAction({
|
||||
name: ({ t }) => t("Restore"),
|
||||
analyticsName: "Restore collection",
|
||||
section: CollectionSection,
|
||||
icon: <RestoreIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeCollectionId).restore;
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
await collection.restore();
|
||||
toast.success(t("Collection restored"));
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteCollection = createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete collection",
|
||||
section: CollectionSection,
|
||||
section: ActiveCollectionSection,
|
||||
dangerous: true,
|
||||
icon: <TrashIcon />,
|
||||
visible: ({ activeCollectionId }) => {
|
||||
@@ -227,7 +308,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 }) =>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { DoneIcon, TrashIcon } from "outline-icons";
|
||||
import { DoneIcon, SmileyIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import stores from "~/stores";
|
||||
import Comment from "~/models/Comment";
|
||||
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
|
||||
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
|
||||
import history from "~/utils/history";
|
||||
import { createAction } from "..";
|
||||
import { DocumentSection } from "../sections";
|
||||
@@ -88,3 +89,27 @@ export const unresolveCommentFactory = ({
|
||||
onUnresolve();
|
||||
},
|
||||
});
|
||||
|
||||
export const viewCommentReactionsFactory = ({
|
||||
comment,
|
||||
}: {
|
||||
comment: Comment;
|
||||
}) =>
|
||||
createAction({
|
||||
name: ({ t }) => `${t("View reactions")}`,
|
||||
analyticsName: "View comment reactions",
|
||||
section: DocumentSection,
|
||||
icon: <SmileyIcon />,
|
||||
visible: () =>
|
||||
stores.policies.abilities(comment.id).read &&
|
||||
comment.reactions.length > 0,
|
||||
perform: ({ t, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Reactions"),
|
||||
content: <ViewReactionsDialog model={comment} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,25 +24,39 @@ import {
|
||||
UnpublishIcon,
|
||||
PublishIcon,
|
||||
CommentIcon,
|
||||
GlobeIcon,
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
PadlockIcon,
|
||||
GlobeIcon,
|
||||
LogoutIcon,
|
||||
} 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 UserMembership from "~/models/UserMembership";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
import DocumentMove from "~/scenes/DocumentMove";
|
||||
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
|
||||
import DocumentPublish from "~/scenes/DocumentPublish";
|
||||
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DuplicateDialog from "~/components/DuplicateDialog";
|
||||
import 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";
|
||||
@@ -66,23 +80,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),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -133,7 +148,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 }) =>
|
||||
@@ -150,7 +165,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 }) => {
|
||||
@@ -176,7 +191,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 }) => {
|
||||
@@ -202,7 +217,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) {
|
||||
@@ -244,7 +259,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) {
|
||||
@@ -275,7 +290,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) {
|
||||
@@ -303,7 +318,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) {
|
||||
@@ -331,18 +346,20 @@ export const unsubscribeDocument = createAction({
|
||||
});
|
||||
|
||||
export const shareDocument = createAction({
|
||||
name: ({ t }) => t("Share"),
|
||||
name: ({ t }) => `${t("Permissions")}…`,
|
||||
analyticsName: "Share document",
|
||||
section: DocumentSection,
|
||||
icon: <GlobeIcon />,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <PadlockIcon />,
|
||||
visible: ({ stores, activeDocumentId }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId!);
|
||||
return can.manageUsers || can.share;
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
|
||||
if (!activeDocumentId || !currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
const share = stores.shares.getByDocumentId(activeDocumentId);
|
||||
const sharedParent = stores.shares.getByDocumentParents(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
@@ -353,8 +370,6 @@ export const shareDocument = createAction({
|
||||
content: (
|
||||
<SharePopover
|
||||
document={document}
|
||||
share={share}
|
||||
sharedParent={sharedParent}
|
||||
onRequestClose={stores.dialogs.closeAllModals}
|
||||
visible
|
||||
/>
|
||||
@@ -366,7 +381,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,
|
||||
@@ -385,7 +400,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,
|
||||
@@ -409,7 +424,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,
|
||||
@@ -429,9 +444,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,
|
||||
@@ -441,8 +458,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 }) => {
|
||||
@@ -456,10 +475,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
|
||||
@@ -475,17 +517,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 }) =>
|
||||
@@ -529,7 +571,7 @@ export const pinDocumentToCollection = createAction({
|
||||
});
|
||||
},
|
||||
analyticsName: "Pin document to collection",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <PinIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
|
||||
@@ -565,7 +607,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 }) => {
|
||||
@@ -597,7 +639,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],
|
||||
});
|
||||
@@ -605,7 +647,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) {
|
||||
@@ -623,7 +665,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: () => {
|
||||
@@ -682,7 +724,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 }) => {
|
||||
@@ -717,14 +759,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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -782,7 +824,7 @@ export const moveDocumentToCollection = createAction({
|
||||
: t("Move");
|
||||
},
|
||||
analyticsName: "Move document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
@@ -811,7 +853,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) {
|
||||
@@ -830,7 +872,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) {
|
||||
@@ -847,9 +889,9 @@ export const moveTemplate = createAction({
|
||||
});
|
||||
|
||||
export const archiveDocument = createAction({
|
||||
name: ({ t }) => t("Archive"),
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
@@ -858,14 +900,30 @@ export const archiveDocument = createAction({
|
||||
return !!stores.policies.abilities(activeDocumentId).archive;
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores, t }) => {
|
||||
const { dialogs, documents } = stores;
|
||||
|
||||
if (activeDocumentId) {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
const document = documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
await document.archive();
|
||||
toast.success(t("Document archived"));
|
||||
dialogs.openModal({
|
||||
title: t("Are you sure you want to archive this document?"),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
await document.archive();
|
||||
toast.success(t("Document archived"));
|
||||
}}
|
||||
savingText={`${t("Archiving")}…`}
|
||||
>
|
||||
{t(
|
||||
"Archiving this document will remove it from the collection and search results."
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -873,7 +931,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 }) => {
|
||||
@@ -907,7 +965,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 }) => {
|
||||
@@ -962,7 +1020,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 ?? "");
|
||||
@@ -984,7 +1042,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 ?? "");
|
||||
@@ -1005,7 +1063,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 ?? "");
|
||||
@@ -1042,7 +1100,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 ?? "");
|
||||
@@ -1063,6 +1121,42 @@ export const toggleViewerInsights = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const leaveDocument = createAction({
|
||||
name: ({ t }) => t("Leave document"),
|
||||
analyticsName: "Leave document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <LogoutIcon />,
|
||||
visible: ({ currentUserId, activeDocumentId, stores }) => {
|
||||
const membership = stores.userMemberships.orderedData.find(
|
||||
(m) => m.documentId === activeDocumentId && m.userId === currentUserId
|
||||
);
|
||||
|
||||
return !!membership;
|
||||
},
|
||||
perform: async ({ t, location, currentUserId, activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
try {
|
||||
if (document && location.pathname.startsWith(document.path)) {
|
||||
history.push(homePath());
|
||||
}
|
||||
|
||||
await stores.userMemberships.delete({
|
||||
documentId: activeDocumentId,
|
||||
userId: currentUserId,
|
||||
} as UserMembership);
|
||||
|
||||
toast.success(t("You have left the shared document"));
|
||||
} catch (err) {
|
||||
toast.error(t("Could not leave document"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const rootDocumentActions = [
|
||||
openDocument,
|
||||
archiveDocument,
|
||||
@@ -1072,6 +1166,7 @@ export const rootDocumentActions = [
|
||||
importDocument,
|
||||
downloadDocument,
|
||||
copyDocumentLink,
|
||||
copyDocumentShareLink,
|
||||
copyDocumentAsMarkdown,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
@@ -1080,6 +1175,7 @@ export const rootDocumentActions = [
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
duplicateDocument,
|
||||
leaveDocument,
|
||||
moveTemplateToWorkspace,
|
||||
moveDocumentToCollection,
|
||||
openRandomDocument,
|
||||
|
||||
@@ -91,6 +91,15 @@ export const navigateToSettings = createAction({
|
||||
perform: () => history.push(settingsPath()),
|
||||
});
|
||||
|
||||
export const navigateToWorkspaceSettings = createAction({
|
||||
name: ({ t }) => t("Settings"),
|
||||
analyticsName: "Navigate to workspace settings",
|
||||
section: NavigationSection,
|
||||
icon: <SettingsIcon />,
|
||||
visible: () => stores.policies.abilities(stores.auth.team?.id || "").update,
|
||||
perform: () => history.push(settingsPath("details")),
|
||||
});
|
||||
|
||||
export const navigateToProfileSettings = createAction({
|
||||
name: ({ t }) => t("Profile"),
|
||||
analyticsName: "Navigate to profile settings",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -106,6 +106,24 @@ const Analytics: React.FC = ({ children }: Props) => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Umami
|
||||
React.useEffect(() => {
|
||||
(env.analytics as PublicEnv["analytics"]).forEach((integration) => {
|
||||
if (integration.service !== IntegrationService.Umami) {
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.defer = true;
|
||||
script.src = `${integration.settings?.instanceUrl}${integration.settings?.scriptName}`;
|
||||
script.setAttribute(
|
||||
"data-website-id",
|
||||
integration.settings?.measurementId
|
||||
);
|
||||
document.getElementsByTagName("head")[0]?.appendChild(script);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ function ArrowKeyNavigation(
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Escape") {
|
||||
if (ev.key === "Escape" || ev.key === "Backspace") {
|
||||
ev.preventDefault();
|
||||
onEscape(ev);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import { DisclosureIcon } from "outline-icons";
|
||||
import { darken, lighten, transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
@@ -189,10 +189,14 @@ const Button = <T extends React.ElementType = "button">(
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
{hasIcon && ic}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
{disclosure && <ExpandedIcon />}
|
||||
{disclosure && <StyledDisclosureIcon />}
|
||||
</Inner>
|
||||
</RealButton>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledDisclosureIcon = styled(DisclosureIcon)`
|
||||
opacity: 0.8;
|
||||
`;
|
||||
|
||||
export default React.forwardRef(Button);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ArchiveIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Collection from "~/models/Collection";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import { archivePath, collectionPath } from "~/utils/routeHelpers";
|
||||
import Breadcrumb from "./Breadcrumb";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
};
|
||||
|
||||
export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const collectionNode: MenuInternalLink = {
|
||||
type: "route",
|
||||
title: collection.name,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: collectionPath(collection.path),
|
||||
};
|
||||
|
||||
const category: MenuInternalLink | undefined = collection.isArchived
|
||||
? {
|
||||
type: "route",
|
||||
icon: <ArchiveIcon />,
|
||||
title: t("Archive"),
|
||||
to: archivePath(),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const output = [];
|
||||
if (category) {
|
||||
output.push(category);
|
||||
}
|
||||
|
||||
output.push(collectionNode);
|
||||
|
||||
return output;
|
||||
}, [collection, t]);
|
||||
|
||||
return <Breadcrumb items={items} highlightFirstItem />;
|
||||
};
|
||||
@@ -226,7 +226,7 @@ const Input = styled.div`
|
||||
}
|
||||
|
||||
&[data-editing="true"] {
|
||||
background: ${s("secondaryBackground")};
|
||||
background: ${s("backgroundSecondary")};
|
||||
}
|
||||
|
||||
.block-menu-trigger,
|
||||
|
||||
@@ -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,13 +69,19 @@ 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")};
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${s("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;
|
||||
+12
-7
@@ -1,12 +1,16 @@
|
||||
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();
|
||||
|
||||
if (results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<KBarResults
|
||||
@@ -14,7 +18,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 +41,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,64 @@
|
||||
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: Record<Partial<CollectionPermission> | "null", string> =
|
||||
{
|
||||
[CollectionPermission.Admin]: t("manage access"),
|
||||
[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);
|
||||
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { DisconnectedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -11,7 +11,6 @@ import useStores from "~/hooks/useStores";
|
||||
|
||||
function ConnectionStatus() {
|
||||
const { ui } = useStores();
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const codeToMessage = {
|
||||
@@ -36,7 +35,7 @@ function ConnectionStatus() {
|
||||
};
|
||||
|
||||
const message = ui.multiplayerErrorCode
|
||||
? codeToMessage[ui.multiplayerErrorCode]
|
||||
? codeToMessage[ui.multiplayerErrorCode as keyof typeof codeToMessage]
|
||||
: undefined;
|
||||
|
||||
return ui.multiplayerStatus === "connecting" ||
|
||||
@@ -61,7 +60,7 @@ function ConnectionStatus() {
|
||||
>
|
||||
<Button>
|
||||
<Fade>
|
||||
<DisconnectedIcon color={theme.sidebarText} />
|
||||
<DisconnectedIcon />
|
||||
</Fade>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -72,7 +71,7 @@ const Button = styled(NudeButton)`
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
margin: 24px;
|
||||
margin: 20px;
|
||||
transform: translateX(-32px);
|
||||
|
||||
${breakpoint("tablet")`
|
||||
|
||||
@@ -6,6 +6,7 @@ import { mergeRefs } from "react-merge-refs";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Text from "../Text";
|
||||
import MenuIconWrapper from "./MenuIconWrapper";
|
||||
|
||||
@@ -74,9 +75,9 @@ const MenuItem = (
|
||||
])}
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<MenuIconWrapper aria-hidden>
|
||||
<SelectedWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon /> : <Spacer />}
|
||||
</MenuIconWrapper>
|
||||
</SelectedWrapper>
|
||||
)}
|
||||
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
|
||||
<Title>{children}</Title>
|
||||
@@ -196,4 +197,13 @@ export const MenuAnchor = styled.a`
|
||||
${MenuAnchorCSS}
|
||||
`;
|
||||
|
||||
const SelectedWrapper = styled.span`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 4px;
|
||||
margin-left: -8px;
|
||||
flex-shrink: 0;
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem);
|
||||
|
||||
@@ -51,6 +51,8 @@ type Props = MenuStateReturn & {
|
||||
onClick?: (ev: React.MouseEvent) => void;
|
||||
/** The maximum width of the context menu. */
|
||||
maxWidth?: number;
|
||||
/** The minimum height of the context menu. */
|
||||
minHeight?: number;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -135,6 +137,7 @@ type InnerContextMenuProps = MenuStateReturn & {
|
||||
menuProps: { style?: React.CSSProperties; placement: string };
|
||||
children: React.ReactNode;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -220,6 +223,7 @@ const InnerContextMenu = (props: InnerContextMenuProps) => {
|
||||
<Background
|
||||
dir="auto"
|
||||
maxWidth={props.maxWidth}
|
||||
minHeight={props.minHeight}
|
||||
topAnchor={topAnchor}
|
||||
rightAnchor={rightAnchor}
|
||||
ref={backgroundRef}
|
||||
@@ -257,6 +261,23 @@ export const Position = styled.div`
|
||||
transition-delay: 250ms;
|
||||
transition-property: outline-width;
|
||||
transition-duration: 0;
|
||||
outline: none;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
right: 1px;
|
||||
bottom: 1px;
|
||||
pointer-events: none;
|
||||
border-radius: 4px;
|
||||
|
||||
outline-color: ${s("accent")};
|
||||
outline-width: initial;
|
||||
outline-offset: -1px;
|
||||
outline-style: solid;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -277,6 +298,7 @@ type BackgroundProps = {
|
||||
topAnchor?: boolean;
|
||||
rightAnchor?: boolean;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
theme: DefaultTheme;
|
||||
};
|
||||
|
||||
@@ -288,7 +310,7 @@ export const Background = styled(Scrollable)<BackgroundProps>`
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
min-width: 180px;
|
||||
min-height: 44px;
|
||||
min-height: ${(props) => props.minHeight || 44}px;
|
||||
max-height: 75vh;
|
||||
font-weight: normal;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -71,7 +71,7 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
const can = usePolicy(collection);
|
||||
|
||||
React.useEffect(() => {
|
||||
void document.loadRelations();
|
||||
void document.loadRelations({ withoutPolicies: true });
|
||||
}, [document]);
|
||||
|
||||
let collectionNode: MenuInternalLink | undefined;
|
||||
|
||||
@@ -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,84 @@
|
||||
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
|
||||
isEditorInitialized: boolean = false;
|
||||
|
||||
@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
|
||||
setEditorInitialized = (initialized: boolean) => {
|
||||
this.isEditorInitialized = initialized;
|
||||
};
|
||||
|
||||
@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;
|
||||
|
||||
|
||||
+15
-39
@@ -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(
|
||||
@@ -107,7 +98,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
}
|
||||
|
||||
// default search for anything that doesn't look like a URL
|
||||
const results = await documents.searchTitles(term);
|
||||
const results = await documents.searchTitles({ query: term });
|
||||
|
||||
return sortBy(
|
||||
results.map(({ document }) => ({
|
||||
@@ -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,26 +237,25 @@ 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 (
|
||||
<ErrorBoundary component="div" reloadOnChunkMissing>
|
||||
<>
|
||||
<LazyLoadedEditor
|
||||
key={props.extensions?.length || 0}
|
||||
ref={mergeRefs([ref, localRef, handleRefChanged])}
|
||||
uploadFile={handleUploadFile}
|
||||
embeds={embeds}
|
||||
@@ -293,14 +268,15 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
{props.editorStyle?.paddingBottom && !props.readOnly && (
|
||||
<ClickablePadding
|
||||
onClick={focusAtEnd}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
minHeight={props.editorStyle.paddingBottom}
|
||||
/>
|
||||
)}
|
||||
{props.editorStyle?.paddingBottom &&
|
||||
(!props.readOnly || props.shareId) && (
|
||||
<ClickablePadding
|
||||
onClick={props.readOnly ? undefined : focusAtEnd}
|
||||
onDrop={props.readOnly ? undefined : handleDrop}
|
||||
onDragOver={props.readOnly ? undefined : handleDragOver}
|
||||
minHeight={props.editorStyle.paddingBottom}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
type Props = {
|
||||
/** Width of the containing element. */
|
||||
width?: number | string;
|
||||
/** Height of the containing element. */
|
||||
height?: number | string;
|
||||
/** Controls the rendered emoji size. */
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const Emoji = styled.span<Props>`
|
||||
font-family: ${s("fontFamilyEmoji")};
|
||||
width: ${({ width }) =>
|
||||
typeof width === "string" ? width : width ? `${width}px` : "auto"};
|
||||
height: ${({ height }) =>
|
||||
typeof height === "string" ? height : height ? `${height}px` : "auto"};
|
||||
font-size: ${({ size }) => size && `${size}px`};
|
||||
`;
|
||||
@@ -138,7 +138,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
}
|
||||
|
||||
const Pre = styled.pre`
|
||||
background: ${s("secondaryBackground")};
|
||||
background: ${s("backgroundSecondary")};
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -27,7 +27,7 @@ import { documentHistoryPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
event: Event;
|
||||
event: Event<Document>;
|
||||
latest?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import deburr from "lodash/deburr";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type { FetchPageParams } from "~/stores/base/Store";
|
||||
import Button, { Inner } from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import Text from "~/components/Text";
|
||||
import Input, { NativeInput, Outline } from "./Input";
|
||||
import PaginatedList, { PaginatedItem } from "./PaginatedList";
|
||||
|
||||
type TFilterOption = {
|
||||
interface TFilterOption extends PaginatedItem {
|
||||
key: string;
|
||||
label: string;
|
||||
note?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
options: TFilterOption[];
|
||||
@@ -21,6 +26,9 @@ type Props = {
|
||||
selectedPrefix?: string;
|
||||
className?: string;
|
||||
onSelect: (key: string | null | undefined) => void;
|
||||
showFilter?: boolean;
|
||||
fetchQuery?: (options: FetchPageParams) => Promise<PaginatedItem[]>;
|
||||
fetchQueryOptions?: Record<string, string>;
|
||||
};
|
||||
|
||||
const FilterOptions = ({
|
||||
@@ -30,13 +38,20 @@ const FilterOptions = ({
|
||||
selectedPrefix = "",
|
||||
className,
|
||||
onSelect,
|
||||
showFilter,
|
||||
fetchQuery,
|
||||
fetchQueryOptions,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const listRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const selectedItems = options.filter((option) =>
|
||||
selectedKeys.includes(option.key)
|
||||
);
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const selectedLabel = selectedItems.length
|
||||
? selectedItems
|
||||
@@ -44,6 +59,109 @@ const FilterOptions = ({
|
||||
.join(", ")
|
||||
: "";
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
(option: TFilterOption) => (
|
||||
<MenuItem
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
onSelect(option.key);
|
||||
menu.hide();
|
||||
}}
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
{...menu}
|
||||
>
|
||||
{option.icon && <Icon>{option.icon}</Icon>}
|
||||
{option.note ? (
|
||||
<LabelWithNote>
|
||||
{option.label}
|
||||
<Note>{option.note}</Note>
|
||||
</LabelWithNote>
|
||||
) : (
|
||||
option.label
|
||||
)}
|
||||
</MenuItem>
|
||||
),
|
||||
[menu, onSelect, selectedKeys]
|
||||
);
|
||||
|
||||
const handleFilter = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(ev.target.value);
|
||||
};
|
||||
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
const normalizedQuery = deburr(query.toLowerCase());
|
||||
|
||||
return query
|
||||
? options
|
||||
.filter((option) =>
|
||||
deburr(option.label).toLowerCase().includes(normalizedQuery)
|
||||
)
|
||||
// sort options starting with query first
|
||||
.sort((a, b) => {
|
||||
const aStartsWith = deburr(a.label)
|
||||
.toLowerCase()
|
||||
.startsWith(normalizedQuery);
|
||||
const bStartsWith = deburr(b.label)
|
||||
.toLowerCase()
|
||||
.startsWith(normalizedQuery);
|
||||
|
||||
if (aStartsWith && !bStartsWith) {
|
||||
return -1;
|
||||
}
|
||||
if (!aStartsWith && bStartsWith) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
: options;
|
||||
}, [options, query]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent) => {
|
||||
if (ev.nativeEvent.isComposing || ev.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (ev.key) {
|
||||
case "Escape":
|
||||
menu.hide();
|
||||
break;
|
||||
case "Enter":
|
||||
if (filteredOptions.length === 1) {
|
||||
ev.preventDefault();
|
||||
onSelect(filteredOptions[0].key);
|
||||
menu.hide();
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
ev.preventDefault();
|
||||
(listRef.current?.firstElementChild as HTMLElement)?.focus();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[filteredOptions, menu, onSelect]
|
||||
);
|
||||
|
||||
const handleEscapeFromList = React.useCallback((ev: React.KeyboardEvent) => {
|
||||
searchInputRef.current?.focus();
|
||||
|
||||
if (ev.key === "Backspace") {
|
||||
setQuery((prev) => prev.slice(0, -1));
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (menu.visible) {
|
||||
searchInputRef.current?.focus();
|
||||
} else {
|
||||
setQuery("");
|
||||
}
|
||||
}, [menu.visible]);
|
||||
|
||||
const showFilterInput = showFilter || options.length > 10;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<MenuButton {...menu}>
|
||||
@@ -53,33 +171,73 @@ const FilterOptions = ({
|
||||
</StyledButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu aria-label={defaultLabel} {...menu}>
|
||||
{options.map((option) => (
|
||||
<MenuItem
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
onSelect(option.key);
|
||||
menu.hide();
|
||||
}}
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
{...menu}
|
||||
>
|
||||
{option.icon && <Icon>{option.icon}</Icon>}
|
||||
{option.note ? (
|
||||
<LabelWithNote>
|
||||
{option.label}
|
||||
<Note>{option.note}</Note>
|
||||
</LabelWithNote>
|
||||
) : (
|
||||
option.label
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
<ContextMenu aria-label={defaultLabel} minHeight={66} {...menu}>
|
||||
<PaginatedList
|
||||
listRef={listRef}
|
||||
options={{ query, ...fetchQueryOptions }}
|
||||
items={filteredOptions}
|
||||
fetch={fetchQuery}
|
||||
renderItem={renderItem}
|
||||
onEscape={handleEscapeFromList}
|
||||
heading={showFilterInput ? <Spacer /> : undefined}
|
||||
empty={<Empty />}
|
||||
/>
|
||||
{showFilterInput && (
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
value={query}
|
||||
onChange={handleFilter}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</ContextMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Empty = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spacer />
|
||||
<Text size="small" type="tertiary" style={{ marginLeft: 6 }}>
|
||||
{t("No results")}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Spacer = styled.div`
|
||||
height: 30px;
|
||||
`;
|
||||
|
||||
const SearchInput = styled(Input)`
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
${Outline} {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
background: ${s("menuBackground")};
|
||||
}
|
||||
|
||||
${NativeInput} {
|
||||
font-size: 14px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Note = styled(Text)`
|
||||
display: block;
|
||||
margin: 2px 0;
|
||||
|
||||
@@ -75,7 +75,7 @@ const Image = styled(Flex)`
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: ${s("secondaryBackground")};
|
||||
background: ${s("backgroundSecondary")};
|
||||
border-radius: 32px;
|
||||
`;
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import useStores from "~/hooks/useStores";
|
||||
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
|
||||
export const HEADER_HEIGHT = 64;
|
||||
|
||||
type Props = {
|
||||
left?: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
@@ -130,7 +132,7 @@ const Wrapper = styled(Flex)<WrapperProps>`
|
||||
padding: 12px;
|
||||
transition: all 100ms ease-out;
|
||||
transform: translate3d(0, 0, 0);
|
||||
min-height: 64px;
|
||||
min-height: ${HEADER_HEIGHT}px;
|
||||
justify-content: flex-start;
|
||||
${draggableOnDesktop()}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
|
||||
color?: string;
|
||||
}>`
|
||||
background-color: ${(props) =>
|
||||
props.color ?? props.theme.secondaryBackground};
|
||||
props.color ?? props.theme.backgroundSecondary};
|
||||
color: ${(props) =>
|
||||
props.color ? getTextColor(props.color) : props.theme.text};
|
||||
width: fit-content;
|
||||
|
||||
@@ -125,6 +125,7 @@ function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
|
||||
avatarUrl={data.avatarUrl}
|
||||
color={data.color}
|
||||
lastActive={data.lastActive}
|
||||
email={data.email}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Document ? (
|
||||
<HoverPreviewDocument
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Preview, Title, Info, Card, CardContent } from "./Components";
|
||||
type Props = Omit<UnfurlResponse[UnfurlResourceType.Mention], "type">;
|
||||
|
||||
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
|
||||
{ avatarUrl, name, lastActive, color }: Props,
|
||||
{ avatarUrl, name, lastActive, color, email }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
@@ -25,6 +25,7 @@ const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
|
||||
/>
|
||||
<Flex column gap={2} justify="center">
|
||||
<Title>{name}</Title>
|
||||
{email && <Info>{email}</Info>}
|
||||
<Info>{lastActive}</Info>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -80,8 +80,8 @@ const BuiltinColors = ({
|
||||
{colorPalette.map((color) => (
|
||||
<ColorButton
|
||||
key={color}
|
||||
color={color}
|
||||
active={color === activeColor}
|
||||
$color={color}
|
||||
$active={color === activeColor}
|
||||
onClick={() => onClick(color)}
|
||||
>
|
||||
<Selected />
|
||||
@@ -156,22 +156,22 @@ const Selected = styled.span`
|
||||
transform: translateY(-25%) rotate(-45deg);
|
||||
`;
|
||||
|
||||
const ColorButton = styled(NudeButton)<{ color: string; active: boolean }>`
|
||||
const ColorButton = styled(NudeButton)<{ $color: string; $active: boolean }>`
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: ${({ color }) => color};
|
||||
background-color: ${({ $color }) => $color};
|
||||
|
||||
&: ${hover} {
|
||||
outline: 2px solid ${s("menuBackground")} !important;
|
||||
box-shadow: ${({ color }) => `0px 0px 3px 3px ${color}`};
|
||||
box-shadow: ${({ $color }) => `0px 0px 3px 3px ${$color}`};
|
||||
}
|
||||
|
||||
& ${Selected} {
|
||||
display: ${({ active }) => (active ? "block" : "none")};
|
||||
display: ${({ $active }) => ($active ? "block" : "none")};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
export const Emoji = styled.span`
|
||||
font-family: ${s("fontFamilyEmoji")};
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
@@ -18,11 +18,7 @@ import {
|
||||
import GridTemplate, { DataNode } from "./GridTemplate";
|
||||
import SkinTonePicker from "./SkinTonePicker";
|
||||
|
||||
/**
|
||||
* This is needed as a constant for react-window.
|
||||
* Calculated from the heights of TabPanel and InputSearch.
|
||||
*/
|
||||
const GRID_HEIGHT = 362;
|
||||
const GRID_HEIGHT = 410;
|
||||
|
||||
const useEmojiState = () => {
|
||||
const [emojiSkinTone, setEmojiSkinTone] = usePersistedState<EmojiSkinTone>(
|
||||
@@ -80,6 +76,7 @@ type Props = {
|
||||
panelWidth: number;
|
||||
query: string;
|
||||
panelActive: boolean;
|
||||
height?: number;
|
||||
onEmojiChange: (emoji: string) => void;
|
||||
onQueryChange: (query: string) => void;
|
||||
};
|
||||
@@ -90,6 +87,7 @@ const EmojiPanel = ({
|
||||
panelActive,
|
||||
onEmojiChange,
|
||||
onQueryChange,
|
||||
height = GRID_HEIGHT,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -159,7 +157,7 @@ const EmojiPanel = ({
|
||||
<GridTemplate
|
||||
ref={scrollableRef}
|
||||
width={panelWidth}
|
||||
height={GRID_HEIGHT}
|
||||
height={height - 48}
|
||||
data={templateData}
|
||||
onIconSelect={handleEmojiSelection}
|
||||
/>
|
||||
|
||||
@@ -4,9 +4,9 @@ import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { IconType } from "@shared/types";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import { Emoji } from "~/components/Emoji";
|
||||
import Text from "~/components/Text";
|
||||
import { TRANSLATED_CATEGORIES } from "../utils";
|
||||
import { Emoji } from "./Emoji";
|
||||
import Grid from "./Grid";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
@@ -71,7 +71,7 @@ const GridTemplate = (
|
||||
<IconButton
|
||||
key={item.name}
|
||||
onClick={() => onIconSelect({ id: item.name, value: item.name })}
|
||||
delay={item.delay}
|
||||
style={{ "--delay": `${item.delay}ms` } as React.CSSProperties}
|
||||
>
|
||||
<Icon as={IconLibrary.getComponent(item.name)} color={item.color}>
|
||||
{item.initial}
|
||||
@@ -85,7 +85,9 @@ const GridTemplate = (
|
||||
key={item.id}
|
||||
onClick={() => onIconSelect({ id: item.id, value: item.value })}
|
||||
>
|
||||
<Emoji>{item.value}</Emoji>
|
||||
<Emoji width={24} height={24}>
|
||||
{item.value}
|
||||
</Emoji>
|
||||
</IconButton>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ export const IconButton = styled(NudeButton)<{ delay?: number }>`
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 4px;
|
||||
--delay: ${({ delay }) => delay && `${delay}ms`};
|
||||
|
||||
&: ${hover} {
|
||||
background: ${s("listItemHoverBackground")};
|
||||
|
||||
@@ -5,10 +5,10 @@ import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { EmojiSkinTone } from "@shared/types";
|
||||
import { getEmojiVariants } from "@shared/utils/emoji";
|
||||
import { Emoji } from "~/components/Emoji";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { hover } from "~/styles";
|
||||
import { Emoji } from "./Emoji";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
const SkinTonePicker = ({
|
||||
@@ -26,7 +26,7 @@ const SkinTonePicker = ({
|
||||
);
|
||||
|
||||
const menu = useMenuState({
|
||||
placement: "bottom",
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
const handleSkinClick = React.useCallback(
|
||||
@@ -43,7 +43,9 @@ const SkinTonePicker = ({
|
||||
<MenuItem {...menu} key={emoji.value}>
|
||||
{(menuprops) => (
|
||||
<IconButton {...menuprops} onClick={() => handleSkinClick(eskin)}>
|
||||
<Emoji>{emoji.value}</Emoji>
|
||||
<Emoji width={24} height={24}>
|
||||
{emoji.value}
|
||||
</Emoji>
|
||||
</IconButton>
|
||||
)}
|
||||
</MenuItem>
|
||||
|
||||
@@ -82,6 +82,7 @@ const IconPicker = ({
|
||||
modal: true,
|
||||
unstable_offset: [0, 0],
|
||||
});
|
||||
const { hide, show, visible } = popover;
|
||||
const tab = useTabState({ selectedId: defaultTab });
|
||||
const previouslyVisible = usePrevious(popover.visible);
|
||||
|
||||
@@ -96,12 +97,12 @@ const IconPicker = ({
|
||||
|
||||
const handleIconChange = React.useCallback(
|
||||
(ic: string) => {
|
||||
popover.hide();
|
||||
hide();
|
||||
const icType = determineIconType(ic);
|
||||
const finalColor = icType === IconType.SVG ? chosenColor : null;
|
||||
onChange(ic, finalColor);
|
||||
},
|
||||
[popover, onChange, chosenColor]
|
||||
[hide, onChange, chosenColor]
|
||||
);
|
||||
|
||||
const handleIconColorChange = React.useCallback(
|
||||
@@ -118,32 +119,32 @@ const IconPicker = ({
|
||||
);
|
||||
|
||||
const handleIconRemove = React.useCallback(() => {
|
||||
popover.hide();
|
||||
hide();
|
||||
onChange(null, null);
|
||||
}, [popover, onChange]);
|
||||
}, [hide, onChange]);
|
||||
|
||||
const handlePopoverButtonClick = React.useCallback(
|
||||
(ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
if (popover.visible) {
|
||||
popover.hide();
|
||||
if (visible) {
|
||||
hide();
|
||||
} else {
|
||||
popover.show();
|
||||
show();
|
||||
}
|
||||
},
|
||||
[popover]
|
||||
[hide, show, visible]
|
||||
);
|
||||
|
||||
// Popover open effect
|
||||
React.useEffect(() => {
|
||||
if (popover.visible && !previouslyVisible) {
|
||||
if (visible && !previouslyVisible) {
|
||||
onOpen?.();
|
||||
} else if (!popover.visible && previouslyVisible) {
|
||||
} else if (!visible && previouslyVisible) {
|
||||
onClose?.();
|
||||
setQuery("");
|
||||
resetDefaultTab();
|
||||
}
|
||||
}, [popover.visible, previouslyVisible, onOpen, onClose, resetDefaultTab]);
|
||||
}, [visible, previouslyVisible, onOpen, onClose, resetDefaultTab]);
|
||||
|
||||
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
|
||||
// prevent event bubbling.
|
||||
@@ -198,7 +199,7 @@ const IconPicker = ({
|
||||
{...tab}
|
||||
id={TAB_NAMES["Icon"]}
|
||||
aria-label={t("Icons")}
|
||||
active={tab.selectedId === TAB_NAMES["Icon"]}
|
||||
$active={tab.selectedId === TAB_NAMES["Icon"]}
|
||||
>
|
||||
{t("Icons")}
|
||||
</StyledTab>
|
||||
@@ -206,7 +207,7 @@ const IconPicker = ({
|
||||
{...tab}
|
||||
id={TAB_NAMES["Emoji"]}
|
||||
aria-label={t("Emojis")}
|
||||
active={tab.selectedId === TAB_NAMES["Emoji"]}
|
||||
$active={tab.selectedId === TAB_NAMES["Emoji"]}
|
||||
>
|
||||
{t("Emojis")}
|
||||
</StyledTab>
|
||||
@@ -273,7 +274,7 @@ const TabActionsWrapper = styled(Flex)`
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
`;
|
||||
|
||||
const StyledTab = styled(Tab)<{ active: boolean }>`
|
||||
const StyledTab = styled(Tab)<{ $active: boolean }>`
|
||||
position: relative;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
@@ -282,15 +283,15 @@ const StyledTab = styled(Tab)<{ active: boolean }>`
|
||||
border: 0;
|
||||
padding: 8px 12px;
|
||||
user-select: none;
|
||||
color: ${({ active }) => (active ? s("textSecondary") : s("textTertiary"))};
|
||||
color: ${({ $active }) => ($active ? s("textSecondary") : s("textTertiary"))};
|
||||
transition: color 100ms ease-in-out;
|
||||
|
||||
&: ${hover} {
|
||||
color: ${s("textSecondary")};
|
||||
}
|
||||
|
||||
${({ active }) =>
|
||||
active &&
|
||||
${({ $active }) =>
|
||||
$active &&
|
||||
css`
|
||||
&:after {
|
||||
content: "";
|
||||
|
||||
@@ -10,7 +10,7 @@ import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Button, { Inner } from "~/components/Button";
|
||||
import Button, { Props as ButtonProps, Inner } from "~/components/Button";
|
||||
import Text from "~/components/Text";
|
||||
import useMenuHeight from "~/hooks/useMenuHeight";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
@@ -33,7 +33,7 @@ export type Option = {
|
||||
divider?: boolean;
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
export type Props = Omit<ButtonProps<any>, "onChange"> & {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: string | null;
|
||||
@@ -313,7 +313,7 @@ const StyledButton = styled(Button)<{ $nude?: boolean }>`
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
cursor: default;
|
||||
cursor: var(--pointer);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${s("buttonNeutralBackground")};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { $Diff } from "utility-types";
|
||||
import { s } from "@shared/styles";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
import InputSelect, { Props, Option, InputSelectRef } from "./InputSelect";
|
||||
@@ -19,7 +21,7 @@ function InputSelectPermission(
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<InputSelect
|
||||
<Select
|
||||
ref={ref}
|
||||
label={t("Permission")}
|
||||
options={[
|
||||
@@ -45,4 +47,8 @@ function InputSelectPermission(
|
||||
);
|
||||
}
|
||||
|
||||
const Select = styled(InputSelect)`
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
export default React.forwardRef(InputSelectPermission);
|
||||
|
||||
@@ -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";
|
||||
@@ -192,7 +192,7 @@ const Wrapper = styled.a<{
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
background: ${(props) =>
|
||||
props.$hover ? props.theme.secondaryBackground : "inherit"};
|
||||
props.$hover ? props.theme.backgroundSecondary : "inherit"};
|
||||
}
|
||||
|
||||
cursor: ${(props) =>
|
||||
|
||||
@@ -39,12 +39,15 @@ const LocaleTime: React.FC<Props> = ({
|
||||
relative,
|
||||
tooltipDelay,
|
||||
}: Props) => {
|
||||
const userLocale: string = useUserLocale() || "";
|
||||
const dateFormatLong = {
|
||||
const userLocale = useUserLocale();
|
||||
const dateFormatLong: Record<string, string> = {
|
||||
en_US: "MMMM do, yyyy h:mm a",
|
||||
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
|
||||
};
|
||||
const formatLocaleLong = dateFormatLong[userLocale] ?? "MMMM do, yyyy h:mm a";
|
||||
const formatLocaleLong =
|
||||
(userLocale ? dateFormatLong[userLocale] : undefined) ??
|
||||
"MMMM do, yyyy h:mm a";
|
||||
// @ts-expect-error fallback to formatLocaleLong
|
||||
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
|
||||
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
const callback = React.useRef<() => void>();
|
||||
|
||||
@@ -24,13 +24,15 @@ import NotificationListItem from "./NotificationListItem";
|
||||
type Props = {
|
||||
/** Callback when the notification panel wants to close. */
|
||||
onRequestClose: () => void;
|
||||
/** Whether the panel is open or not. */
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A panel containing a list of notifications and controls to manage them.
|
||||
*/
|
||||
function Notifications(
|
||||
{ onRequestClose }: Props,
|
||||
{ onRequestClose, isOpen }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const context = useActionContext();
|
||||
@@ -72,7 +74,7 @@ function Notifications(
|
||||
<PaginatedList
|
||||
fetch={notifications.fetchPage}
|
||||
options={{ archived: false }}
|
||||
items={notifications.orderedData}
|
||||
items={isOpen ? notifications.orderedData : undefined}
|
||||
renderItem={(item: Notification) => (
|
||||
<NotificationListItem
|
||||
key={item.id}
|
||||
|
||||
@@ -40,7 +40,11 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
|
||||
shrink
|
||||
flex
|
||||
>
|
||||
<Notifications onRequestClose={popover.hide} ref={scrollableRef} />
|
||||
<Notifications
|
||||
onRequestClose={popover.hide}
|
||||
isOpen={popover.visible}
|
||||
ref={scrollableRef}
|
||||
/>
|
||||
</StyledPopover>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,9 +6,11 @@ import PaginatedList from "~/components/PaginatedList";
|
||||
import EventListItem from "./EventListItem";
|
||||
|
||||
type Props = {
|
||||
events: Event[];
|
||||
events: Event<Document>[];
|
||||
document: Document;
|
||||
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
|
||||
fetch: (
|
||||
options: Record<string, any> | undefined
|
||||
) => Promise<Event<Document>[]>;
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
@@ -30,7 +32,7 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
|
||||
heading={heading}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item: Event, index) => (
|
||||
renderItem={(item: Event<Document>, index) => (
|
||||
<EventListItem
|
||||
key={item.id}
|
||||
event={item}
|
||||
|
||||
@@ -13,13 +13,14 @@ import withStores from "~/components/withStores";
|
||||
import { dateToHeading } from "~/utils/date";
|
||||
|
||||
export interface PaginatedItem {
|
||||
id: string;
|
||||
createdAt?: string;
|
||||
id?: string;
|
||||
updatedAt?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
type Props<T> = WithTranslation &
|
||||
RootStore & {
|
||||
RootStore &
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
fetch?: (
|
||||
options: Record<string, any> | undefined
|
||||
) => Promise<T[] | undefined> | undefined;
|
||||
@@ -36,6 +37,7 @@ type Props<T> = WithTranslation &
|
||||
}) => React.ReactNode;
|
||||
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
||||
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
listRef?: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -196,6 +198,7 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
|
||||
onEscape={onEscape}
|
||||
className={this.props.className}
|
||||
items={this.itemsToRender}
|
||||
ref={this.props.listRef}
|
||||
>
|
||||
{() => {
|
||||
let previousHeading = "";
|
||||
@@ -211,7 +214,11 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
|
||||
// Our models have standard date fields, updatedAt > createdAt.
|
||||
// Get what a heading would look like for this item
|
||||
const currentDate =
|
||||
item.updatedAt || item.createdAt || previousHeading;
|
||||
"updatedAt" in item && item.updatedAt
|
||||
? item.updatedAt
|
||||
: "createdAt" in item && item.createdAt
|
||||
? item.createdAt
|
||||
: previousHeading;
|
||||
const currentHeading = dateToHeading(
|
||||
currentDate,
|
||||
this.props.t,
|
||||
@@ -227,7 +234,9 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
|
||||
) {
|
||||
previousHeading = currentHeading;
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
<React.Fragment
|
||||
key={"id" in item && item.id ? item.id : index}
|
||||
>
|
||||
{renderHeading(currentHeading)}
|
||||
{children}
|
||||
</React.Fragment>
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type { ReactionSummary } from "@shared/types";
|
||||
import { getEmojiId } from "@shared/utils/emoji";
|
||||
import User from "~/models/User";
|
||||
import { Emoji } from "~/components/Emoji";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Text from "~/components/Text";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
type Props = {
|
||||
/** Thin reaction data - contains the emoji & active user ids for this reaction. */
|
||||
reaction: ReactionSummary;
|
||||
/** Users who reacted using this emoji. */
|
||||
reactedUsers: User[];
|
||||
/** Whether the emoji button should be disabled (prevents add/remove events). */
|
||||
disabled: boolean;
|
||||
/** Callback when the user intends to add the reaction. */
|
||||
onAddReaction: (emoji: string) => Promise<void>;
|
||||
/** Callback when the user intends to remove the reaction. */
|
||||
onRemoveReaction: (emoji: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const useTooltipContent = ({
|
||||
reactedUsers,
|
||||
currUser,
|
||||
emoji,
|
||||
active,
|
||||
}: {
|
||||
reactedUsers: User[];
|
||||
currUser: User;
|
||||
emoji: string;
|
||||
active: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!reactedUsers.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transformedEmoji = `:${getEmojiId(emoji)}:`;
|
||||
|
||||
switch (reactedUsers.length) {
|
||||
case 1: {
|
||||
return t("{{ username }} reacted with {{ emoji }}", {
|
||||
username: active ? t("You") : reactedUsers[0].name,
|
||||
emoji: transformedEmoji,
|
||||
});
|
||||
}
|
||||
|
||||
case 2: {
|
||||
const firstUsername = active ? t("You") : reactedUsers[0].name;
|
||||
const secondUsername = active
|
||||
? reactedUsers.find((user) => user.id !== currUser.id)?.name
|
||||
: reactedUsers[1].name;
|
||||
|
||||
return t(
|
||||
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
|
||||
{
|
||||
firstUsername,
|
||||
secondUsername,
|
||||
emoji: transformedEmoji,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
default: {
|
||||
const firstUsername = active ? t("You") : reactedUsers[0].name;
|
||||
const count = reactedUsers.length - 1;
|
||||
|
||||
return t(
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}",
|
||||
{
|
||||
firstUsername,
|
||||
count,
|
||||
emoji: transformedEmoji,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const Reaction: React.FC<Props> = ({
|
||||
reaction,
|
||||
reactedUsers,
|
||||
disabled,
|
||||
onAddReaction,
|
||||
onRemoveReaction,
|
||||
}) => {
|
||||
const user = useCurrentUser();
|
||||
|
||||
const active = reaction.userIds.includes(user.id);
|
||||
|
||||
const tooltipContent = useTooltipContent({
|
||||
reactedUsers,
|
||||
currUser: user,
|
||||
emoji: reaction.emoji,
|
||||
active,
|
||||
});
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
(event: React.SyntheticEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
active
|
||||
? void onRemoveReaction(reaction.emoji)
|
||||
: void onAddReaction(reaction.emoji);
|
||||
},
|
||||
[reaction, active, onAddReaction, onRemoveReaction]
|
||||
);
|
||||
|
||||
const DisplayedEmoji = React.useMemo(
|
||||
() => (
|
||||
<EmojiButton disabled={disabled} $active={active} onClick={handleClick}>
|
||||
<Flex gap={6} justify="center" align="center">
|
||||
<Emoji size={15}>{reaction.emoji}</Emoji>
|
||||
<Count weight="xbold">{reaction.userIds.length}</Count>
|
||||
</Flex>
|
||||
</EmojiButton>
|
||||
),
|
||||
[reaction.emoji, reaction.userIds, disabled, active, handleClick]
|
||||
);
|
||||
|
||||
return tooltipContent ? (
|
||||
<Tooltip content={tooltipContent} delay={250} placement="bottom">
|
||||
{DisplayedEmoji}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>{DisplayedEmoji}</>
|
||||
);
|
||||
};
|
||||
|
||||
const EmojiButton = styled(NudeButton)<{
|
||||
$active: boolean;
|
||||
disabled: boolean;
|
||||
}>`
|
||||
width: auto;
|
||||
height: 28px;
|
||||
padding: 6px;
|
||||
border-radius: 12px;
|
||||
transition: ${s("backgroundTransition")};
|
||||
background: ${s("backgroundTertiary")};
|
||||
pointer-events: ${({ disabled }) => disabled && "none"};
|
||||
|
||||
&: ${hover} {
|
||||
background: ${s("backgroundQuaternary")};
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$active &&
|
||||
css`
|
||||
background: ${transparentize(0.7, props.theme.accent)};
|
||||
|
||||
&: ${hover} {
|
||||
background: ${transparentize(0.5, props.theme.accent)};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Count = styled(Text)`
|
||||
font-size: 11px;
|
||||
color: ${s("buttonNeutralText")};
|
||||
padding-right: 1px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
`;
|
||||
|
||||
export default observer(Reaction);
|
||||
@@ -0,0 +1,87 @@
|
||||
import compact from "lodash/compact";
|
||||
import { observer } from "mobx-react";
|
||||
import React from "react";
|
||||
import Comment from "~/models/Comment";
|
||||
import useHover from "~/hooks/useHover";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Logger from "~/utils/Logger";
|
||||
import Flex from "../Flex";
|
||||
import { ResizingHeightContainer } from "../ResizingHeightContainer";
|
||||
import Reaction from "./Reaction";
|
||||
|
||||
type Props = {
|
||||
/** Model for which to show the reactions. */
|
||||
model: Comment;
|
||||
/** Callback when the user intends to add a reaction. */
|
||||
onAddReaction: (emoji: string) => Promise<void>;
|
||||
/** Callback when the user intends to remove a reaction. */
|
||||
onRemoveReaction: (emoji: string) => Promise<void>;
|
||||
/** classname generated by styled-components. */
|
||||
className?: string;
|
||||
/** Picker to render as the last element */
|
||||
picker?: React.ReactElement;
|
||||
};
|
||||
|
||||
const ReactionList: React.FC<Props> = ({
|
||||
model,
|
||||
onAddReaction,
|
||||
onRemoveReaction,
|
||||
className,
|
||||
picker,
|
||||
}) => {
|
||||
const { users } = useStores();
|
||||
const listRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const hovered = useHover({
|
||||
ref: listRef,
|
||||
duration: 250,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const loadReactedUsersData = async () => {
|
||||
try {
|
||||
await model.loadReactedUsersData();
|
||||
} catch (err) {
|
||||
Logger.warn("Could not prefetch reaction data");
|
||||
}
|
||||
};
|
||||
|
||||
if (hovered) {
|
||||
void loadReactedUsersData();
|
||||
}
|
||||
}, [hovered, model]);
|
||||
|
||||
const hasReactions = !!model.reactions.length;
|
||||
const style = React.useMemo(() => {
|
||||
if (hasReactions) {
|
||||
return { minHeight: 28 };
|
||||
}
|
||||
return undefined;
|
||||
}, [hasReactions]);
|
||||
|
||||
return (
|
||||
<ResizingHeightContainer style={style}>
|
||||
<Flex ref={listRef} className={className} align="center" gap={6} wrap>
|
||||
{model.reactions.map((reaction) => {
|
||||
const reactedUsers = compact(
|
||||
reaction.userIds.map((id) => users.get(id))
|
||||
);
|
||||
|
||||
return (
|
||||
<Reaction
|
||||
key={reaction.emoji}
|
||||
reaction={reaction}
|
||||
reactedUsers={reactedUsers}
|
||||
disabled={model.isResolved}
|
||||
onAddReaction={onAddReaction}
|
||||
onRemoveReaction={onRemoveReaction}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{picker}
|
||||
</Flex>
|
||||
</ResizingHeightContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(ReactionList);
|
||||
@@ -0,0 +1,158 @@
|
||||
import { ReactionIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopoverDisclosure, usePopoverState } from "reakit";
|
||||
import styled from "styled-components";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Popover from "~/components/Popover";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
|
||||
const EmojiPanel = React.lazy(
|
||||
() => import("~/components/IconPicker/components/EmojiPanel")
|
||||
);
|
||||
|
||||
type Props = {
|
||||
/** Callback when an emoji is selected by the user. */
|
||||
onSelect: (emoji: string) => Promise<void>;
|
||||
/** Callback when the picker is opened. */
|
||||
onOpen?: () => void;
|
||||
/** Callback when the picker is closed. */
|
||||
onClose?: () => void;
|
||||
/** Optional classname. */
|
||||
className?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
const ReactionPicker: React.FC<Props> = ({
|
||||
onSelect,
|
||||
onOpen,
|
||||
onClose,
|
||||
className,
|
||||
size,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const popover = usePopoverState({
|
||||
modal: true,
|
||||
unstable_offset: [0, 0],
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
const isMobile = useMobile();
|
||||
|
||||
const [query, setQuery] = React.useState("");
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const popoverWidth = isMobile ? windowWidth : 300;
|
||||
// In mobile, popover is absolutely positioned to leave 8px on both sides.
|
||||
const panelWidth = isMobile ? windowWidth - 16 : popoverWidth;
|
||||
const { toggle, hide } = popover;
|
||||
const handlePopoverButtonClick = React.useCallback(
|
||||
(ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
toggle();
|
||||
},
|
||||
[toggle]
|
||||
);
|
||||
|
||||
const handleEmojiSelect = React.useCallback(
|
||||
(emoji: string) => {
|
||||
hide();
|
||||
void onSelect(emoji);
|
||||
},
|
||||
[hide, onSelect]
|
||||
);
|
||||
|
||||
// Popover open effect
|
||||
React.useEffect(() => {
|
||||
if (popover.visible) {
|
||||
onOpen?.();
|
||||
} else {
|
||||
onClose?.();
|
||||
}
|
||||
}, [popover.visible, onOpen, onClose]);
|
||||
|
||||
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
|
||||
// prevent event bubbling.
|
||||
useOnClickOutside(
|
||||
contentRef,
|
||||
(event) => {
|
||||
if (
|
||||
popover.visible &&
|
||||
!popover.unstable_disclosureRef.current?.contains(event.target as Node)
|
||||
) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
popover.hide();
|
||||
}
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<PopoverButton
|
||||
{...props}
|
||||
aria-label={t("Reaction picker")}
|
||||
className={className}
|
||||
onClick={handlePopoverButtonClick}
|
||||
size={size}
|
||||
>
|
||||
<ReactionIcon size={22} />
|
||||
</PopoverButton>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
<Popover
|
||||
{...popover}
|
||||
ref={contentRef}
|
||||
width={popoverWidth}
|
||||
shrink
|
||||
aria-label={t("Reaction picker")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
hideOnClickOutside={false}
|
||||
>
|
||||
{popover.visible && (
|
||||
<React.Suspense fallback={<Placeholder />}>
|
||||
<EventBoundary>
|
||||
<EmojiPanel
|
||||
height={300}
|
||||
panelWidth={panelWidth}
|
||||
query={query}
|
||||
onEmojiChange={handleEmojiSelect}
|
||||
onQueryChange={setQuery}
|
||||
panelActive
|
||||
/>
|
||||
</EventBoundary>
|
||||
</React.Suspense>
|
||||
)}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Placeholder = React.memo(
|
||||
() => (
|
||||
<Flex column gap={6} style={{ height: "300px", padding: "6px 12px" }}>
|
||||
<Flex gap={8}>
|
||||
<PlaceholderText height={32} minWidth={90} />
|
||||
<PlaceholderText height={32} width={32} />
|
||||
</Flex>
|
||||
<PlaceholderText height={24} width={120} />
|
||||
</Flex>
|
||||
),
|
||||
() => true
|
||||
);
|
||||
Placeholder.displayName = "ReactionPickerPlaceholder";
|
||||
|
||||
const PopoverButton = styled(NudeButton)`
|
||||
border-radius: 50%;
|
||||
`;
|
||||
|
||||
export default ReactionPicker;
|
||||
@@ -0,0 +1,146 @@
|
||||
import compact from "lodash/compact";
|
||||
import { observer } from "mobx-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tab, TabPanel, useTabState } from "reakit";
|
||||
import { toast } from "sonner";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Comment from "~/models/Comment";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import { Emoji } from "~/components/Emoji";
|
||||
import Flex from "~/components/Flex";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
type Props = {
|
||||
/** Model for which to show the reactions. */
|
||||
model: Comment;
|
||||
};
|
||||
|
||||
const ViewReactionsDialog: React.FC<Props> = ({ model }) => {
|
||||
const { t } = useTranslation();
|
||||
const { users } = useStores();
|
||||
const tab = useTabState();
|
||||
const { reactedUsersLoaded } = model;
|
||||
|
||||
React.useEffect(() => {
|
||||
const loadReactedUsersData = async () => {
|
||||
try {
|
||||
await model.loadReactedUsersData();
|
||||
} catch (err) {
|
||||
toast.error(t("Could not load reactions"));
|
||||
}
|
||||
};
|
||||
|
||||
void loadReactedUsersData();
|
||||
}, [t, model]);
|
||||
|
||||
if (!reactedUsersLoaded) {
|
||||
return <PlaceHolder />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabActionsWrapper>
|
||||
{model.reactions.map((reaction) => (
|
||||
<StyledTab
|
||||
{...tab}
|
||||
key={reaction.emoji}
|
||||
id={reaction.emoji}
|
||||
aria-label={t("Reaction")}
|
||||
$active={tab.selectedId === reaction.emoji}
|
||||
>
|
||||
<Emoji size={16}>{reaction.emoji}</Emoji>
|
||||
</StyledTab>
|
||||
))}
|
||||
</TabActionsWrapper>
|
||||
{model.reactions.map((reaction) => {
|
||||
const reactedUsers = compact(
|
||||
reaction.userIds.map((id) => users.get(id))
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledTabPanel {...tab} key={reaction.emoji}>
|
||||
{reactedUsers.map((user) => (
|
||||
<UserInfo key={user.name} align="center" gap={8}>
|
||||
<Avatar model={user} size={AvatarSize.Medium} />
|
||||
<Text size="medium">{user.name}</Text>
|
||||
</UserInfo>
|
||||
))}
|
||||
</StyledTabPanel>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const PlaceHolder = React.memo(
|
||||
() => (
|
||||
<>
|
||||
<TabActionsWrapper gap={8} style={{ paddingBottom: "10px" }}>
|
||||
<PlaceholderText width={40} height={32} />
|
||||
<PlaceholderText width={40} height={32} />
|
||||
</TabActionsWrapper>
|
||||
<UserInfo align="center" gap={12}>
|
||||
<PlaceholderText width={AvatarSize.Medium} height={AvatarSize.Medium} />
|
||||
<PlaceholderText height={34} />
|
||||
</UserInfo>
|
||||
<UserInfo align="center" gap={12}>
|
||||
<PlaceholderText width={AvatarSize.Medium} height={AvatarSize.Medium} />
|
||||
<PlaceholderText height={34} />
|
||||
</UserInfo>
|
||||
</>
|
||||
),
|
||||
() => true
|
||||
);
|
||||
PlaceHolder.displayName = "ViewReactionsPlaceholder";
|
||||
|
||||
const TabActionsWrapper = styled(Flex)`
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
`;
|
||||
|
||||
const StyledTab = styled(Tab)<{ $active: boolean }>`
|
||||
position: relative;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
cursor: var(--pointer);
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: 4px 4px 0 0;
|
||||
padding: 8px 12px 10px;
|
||||
user-select: none;
|
||||
transition: background-color 100ms ease;
|
||||
|
||||
&: ${hover} {
|
||||
background-color: ${s("listItemHoverBackground")};
|
||||
}
|
||||
|
||||
${({ $active }) =>
|
||||
$active &&
|
||||
css`
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: ${s("textSecondary")};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const StyledTabPanel = styled(TabPanel)`
|
||||
height: 300px;
|
||||
padding: 5px 0;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const UserInfo = styled(Flex)`
|
||||
padding: 10px 8px;
|
||||
`;
|
||||
|
||||
export default observer(ViewReactionsDialog);
|
||||
@@ -1,5 +1,6 @@
|
||||
import { m, TargetAndTransition } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
|
||||
type Props = {
|
||||
@@ -18,35 +19,37 @@ type Props = {
|
||||
/**
|
||||
* Automatically animates the height of a container based on it's contents.
|
||||
*/
|
||||
export function ResizingHeightContainer(props: Props) {
|
||||
const {
|
||||
hideOverflow,
|
||||
children,
|
||||
config = {
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
ease: "easeInOut",
|
||||
export const ResizingHeightContainer = React.forwardRef<HTMLDivElement, Props>(
|
||||
function ResizingHeightContainer_(props, forwardedRef) {
|
||||
const {
|
||||
hideOverflow,
|
||||
children,
|
||||
config = {
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
},
|
||||
},
|
||||
style,
|
||||
} = props;
|
||||
style,
|
||||
} = props;
|
||||
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const { height } = useComponentSize(ref);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const { height } = useComponentSize(ref);
|
||||
|
||||
return (
|
||||
<m.div
|
||||
animate={{
|
||||
...config,
|
||||
height: Math.round(height),
|
||||
}}
|
||||
style={{
|
||||
...style,
|
||||
overflow: hideOverflow ? "hidden" : "inherit",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div ref={ref}>{children}</div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<m.div
|
||||
animate={{
|
||||
...config,
|
||||
height: Math.round(height),
|
||||
}}
|
||||
style={{
|
||||
...style,
|
||||
overflow: hideOverflow ? "hidden" : "inherit",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div ref={mergeRefs([ref, forwardedRef])}>{children}</div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import Empty from "~/components/Empty";
|
||||
import { Outline } from "~/components/Input";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Placeholder from "~/components/List/Placeholder";
|
||||
import PaginatedList, { PaginatedItem } from "~/components/PaginatedList";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import Popover from "~/components/Popover";
|
||||
import { id as bodyContentId } from "~/components/SkipNavContent";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
@@ -19,9 +19,10 @@ import SearchListItem from "./SearchListItem";
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLInputElement> {
|
||||
shareId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function SearchPopover({ shareId }: Props) {
|
||||
function SearchPopover({ shareId, className }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const focusRef = React.useRef<HTMLElement | null>(null);
|
||||
@@ -36,11 +37,11 @@ function SearchPopover({ shareId }: Props) {
|
||||
const { show, hide } = popover;
|
||||
|
||||
const [searchResults, setSearchResults] = React.useState<
|
||||
PaginatedItem[] | undefined
|
||||
SearchResult[] | undefined
|
||||
>();
|
||||
const [cachedQuery, setCachedQuery] = React.useState(query);
|
||||
const [cachedSearchResults, setCachedSearchResults] = React.useState<
|
||||
PaginatedItem[] | undefined
|
||||
SearchResult[] | undefined
|
||||
>(searchResults);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -54,7 +55,8 @@ function SearchPopover({ shareId }: Props) {
|
||||
const performSearch = React.useCallback(
|
||||
async ({ query, ...options }) => {
|
||||
if (query?.length > 0) {
|
||||
const response: PaginatedItem[] = await documents.search(query, {
|
||||
const response = await documents.search({
|
||||
query,
|
||||
shareId,
|
||||
...options,
|
||||
});
|
||||
@@ -186,6 +188,7 @@ function SearchPopover({ shareId }: Props) {
|
||||
onChange={handleSearchInputChange}
|
||||
onFocus={handleSearchInputFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={className}
|
||||
/>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
|
||||
@@ -137,6 +137,7 @@ export const AccessControlList = observer(
|
||||
}
|
||||
/>
|
||||
{groupMembershipsInCollection
|
||||
.filter((membership) => membership.group)
|
||||
.sort((a, b) =>
|
||||
(
|
||||
(invitedInSession.includes(a.group.id) ? "_" : "") +
|
||||
@@ -189,6 +190,7 @@ export const AccessControlList = observer(
|
||||
/>
|
||||
))}
|
||||
{membershipsInCollection
|
||||
.filter((membership) => membership.user)
|
||||
.sort((a, b) =>
|
||||
(
|
||||
(invitedInSession.includes(a.user.id) ? "_" : "") +
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import invariant from "invariant";
|
||||
import debounce from "lodash/debounce";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { observer } from "mobx-react";
|
||||
import { CopyIcon, GlobeIcon, InfoIcon } from "outline-icons";
|
||||
import { CopyIcon, GlobeIcon, InfoIcon, QuestionMarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { s } from "@shared/styles";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
@@ -17,7 +17,6 @@ import Input, { NativeInput } from "~/components/Input";
|
||||
import Switch from "~/components/Switch";
|
||||
import env from "~/env";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { AvatarSize } from "../../Avatar";
|
||||
import CopyToClipboard from "../../CopyToClipboard";
|
||||
import NudeButton from "../../NudeButton";
|
||||
@@ -39,7 +38,6 @@ type Props = {
|
||||
};
|
||||
|
||||
function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
const { shares } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [validationError, setValidationError] = React.useState("");
|
||||
@@ -53,20 +51,30 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
setUrlId(share?.urlId);
|
||||
}, [share?.urlId]);
|
||||
|
||||
const handleIndexingChanged = React.useCallback(
|
||||
async (event) => {
|
||||
try {
|
||||
await share?.save({
|
||||
allowIndexing: event.currentTarget.checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handlePublishedChange = React.useCallback(
|
||||
async (event) => {
|
||||
const share = shares.getByDocumentId(document.id);
|
||||
invariant(share, "Share must exist");
|
||||
|
||||
try {
|
||||
await share.save({
|
||||
await share?.save({
|
||||
published: event.currentTarget.checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[document.id, shares]
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleUrlChange = React.useMemo(
|
||||
@@ -159,6 +167,32 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
/>
|
||||
|
||||
<ResizingHeightContainer>
|
||||
{share?.published && (
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Search engine indexing")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Disable this setting to discourage search engines from indexing the page"
|
||||
)}
|
||||
>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Search engine indexing")}
|
||||
checked={share?.allowIndexing ?? false}
|
||||
onChange={handleIndexingChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sharedParent?.published ? (
|
||||
<ShareLinkInput type="text" disabled defaultValue={shareUrl}>
|
||||
{copyButton}
|
||||
@@ -203,7 +237,7 @@ const StyledInfoIcon = styled(InfoIcon)`
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
`;
|
||||
|
||||
const DomainPrefix = styled.span`
|
||||
|
||||
@@ -8,7 +8,6 @@ import { toast } from "sonner";
|
||||
import { DocumentPermission } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import Group from "~/models/Group";
|
||||
import Share from "~/models/Share";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, GroupAvatar, AvatarSize } from "~/components/Avatar";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -32,26 +31,19 @@ import { AccessControlList } from "./AccessControlList";
|
||||
type Props = {
|
||||
/** The document to share. */
|
||||
document: Document;
|
||||
/** The existing share model, if any. */
|
||||
share: Share | null | undefined;
|
||||
/** The existing share parent model, if any. */
|
||||
sharedParent: Share | null | undefined;
|
||||
/** Callback fired when the popover requests to be closed. */
|
||||
onRequestClose: () => void;
|
||||
/** Whether the popover is visible. */
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
function SharePopover({
|
||||
document,
|
||||
share,
|
||||
sharedParent,
|
||||
onRequestClose,
|
||||
visible,
|
||||
}: Props) {
|
||||
function SharePopover({ document, onRequestClose, visible }: Props) {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(document);
|
||||
const { shares } = useStores();
|
||||
const share = shares.getByDocumentId(document.id);
|
||||
const sharedParent = shares.getByDocumentParents(document.id);
|
||||
const [hasRendered, setHasRendered] = React.useState(visible);
|
||||
const { users, userMemberships, groups, groupMemberships } = useStores();
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
@@ -98,7 +98,7 @@ export const Suggestions = observer(
|
||||
: collection
|
||||
? users.notInCollection(collection.id, query)
|
||||
: users.orderedData
|
||||
).filter((u) => !u.isSuspended);
|
||||
).filter((u) => !u.isSuspended && u.id !== user.id);
|
||||
|
||||
if (isEmail(query)) {
|
||||
filtered.push(getSuggestionForEmail(query));
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -94,7 +94,7 @@ function AppSidebar() {
|
||||
</SidebarButton>
|
||||
)}
|
||||
</OrganizationMenu>
|
||||
<Scrollable flex shadow>
|
||||
<Overflow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to={homePath()}
|
||||
@@ -117,7 +117,9 @@ function AppSidebar() {
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 ? (
|
||||
<Drafts size="xsmall" type="tertiary">
|
||||
{documents.totalDrafts}
|
||||
{documents.totalDrafts > 25
|
||||
? "25+"
|
||||
: documents.totalDrafts}
|
||||
</Drafts>
|
||||
) : null}
|
||||
</Flex>
|
||||
@@ -125,22 +127,24 @@ function AppSidebar() {
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Overflow>
|
||||
<Scrollable flex shadow>
|
||||
<Section>
|
||||
<Starred />
|
||||
</Section>
|
||||
<Section>
|
||||
<SharedWithMe />
|
||||
</Section>
|
||||
<Section auto>
|
||||
<Section>
|
||||
<Collections />
|
||||
</Section>
|
||||
{can.createDocument && (
|
||||
<Section auto>
|
||||
<ArchiveLink />
|
||||
</Section>
|
||||
)}
|
||||
<Section>
|
||||
{can.createDocument && (
|
||||
<>
|
||||
<ArchiveLink />
|
||||
<TrashLink />
|
||||
</>
|
||||
)}
|
||||
{can.createDocument && <TrashLink />}
|
||||
<SidebarAction action={inviteUser} />
|
||||
</Section>
|
||||
</Scrollable>
|
||||
@@ -150,6 +154,11 @@ function AppSidebar() {
|
||||
);
|
||||
}
|
||||
|
||||
const Overflow = styled.div`
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Drafts = styled(Text)`
|
||||
margin: 0 4px;
|
||||
`;
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { SidebarIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import SearchPopover from "~/components/SearchPopover";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
import history from "~/utils/history";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { useTeamContext } from "../TeamContext";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
@@ -15,6 +20,7 @@ import Sidebar from "./Sidebar";
|
||||
import Section from "./components/Section";
|
||||
import DocumentLink from "./components/SharedDocumentLink";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
|
||||
type Props = {
|
||||
rootNode: NavigationNode;
|
||||
@@ -27,9 +33,11 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
const { ui, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const teamAvailable = !!team?.name;
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
{team?.name && (
|
||||
<StyledSidebar $hoverTransition={!teamAvailable}>
|
||||
{teamAvailable && (
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
image={<TeamLogo model={team} size={32} alt={t("Logo")} />}
|
||||
@@ -38,11 +46,20 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
user ? homePath() : sharedDocumentPath(shareId, rootNode.url)
|
||||
)
|
||||
}
|
||||
/>
|
||||
>
|
||||
<ToggleSidebar />
|
||||
</SidebarButton>
|
||||
)}
|
||||
<ScrollContainer topShadow flex>
|
||||
<TopSection>
|
||||
<SearchPopover shareId={shareId} />
|
||||
<SearchWrapper>
|
||||
<StyledSearchPopover shareId={shareId} />
|
||||
</SearchWrapper>
|
||||
{!teamAvailable && (
|
||||
<ToggleWrapper>
|
||||
<ToggleSidebar />
|
||||
</ToggleWrapper>
|
||||
)}
|
||||
</TopSection>
|
||||
<Section>
|
||||
<DocumentLink
|
||||
@@ -55,23 +72,74 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
/>
|
||||
</Section>
|
||||
</ScrollContainer>
|
||||
</Sidebar>
|
||||
</StyledSidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const ToggleSidebar = () => {
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
delay={500}
|
||||
>
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const ScrollContainer = styled(Scrollable)`
|
||||
padding-bottom: 16px;
|
||||
`;
|
||||
|
||||
const TopSection = styled(Section)`
|
||||
// this weird looking && increases the specificity of the style rule
|
||||
&&:first-child {
|
||||
margin-top: 16px;
|
||||
}
|
||||
const TopSection = styled(Flex)`
|
||||
padding: 8px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
&& {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
const SearchWrapper = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledSearchPopover = styled(SearchPopover)`
|
||||
width: 100%;
|
||||
transition: width 100ms ease-out;
|
||||
margin: 8px 0;
|
||||
`;
|
||||
|
||||
const ToggleWrapper = styled.div`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
transition: opacity 100ms ease-out, transform 100ms ease-out;
|
||||
`;
|
||||
|
||||
const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
|
||||
${({ $hoverTransition }) =>
|
||||
$hoverTransition &&
|
||||
`
|
||||
&: ${hover} {
|
||||
${StyledSearchPopover} {
|
||||
width: 85%;
|
||||
}
|
||||
|
||||
${ToggleWrapper} {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export default observer(SharedSidebar);
|
||||
|
||||
@@ -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;
|
||||
@@ -92,9 +94,17 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
}
|
||||
}, [ui, isSmallerThanMinimum, minWidth, width, setWidth]);
|
||||
|
||||
const handleBlur = React.useCallback(() => {
|
||||
setHovering(false);
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = React.useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
if (!document.hasFocus()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOffset(event.pageX - width);
|
||||
setResizing(true);
|
||||
setAnimating(false);
|
||||
@@ -102,9 +112,9 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
[width]
|
||||
);
|
||||
|
||||
const handlePointerMove = React.useCallback(() => {
|
||||
const handlePointerActivity = React.useCallback(() => {
|
||||
if (ui.sidebarIsClosed) {
|
||||
setHovering(true);
|
||||
setHovering(document.hasFocus());
|
||||
setPointerMoved(true);
|
||||
}
|
||||
}, [ui.sidebarIsClosed]);
|
||||
@@ -113,7 +123,10 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
(ev) => {
|
||||
if (hasPointerMoved) {
|
||||
setHovering(
|
||||
ev.pageX < width && ev.pageY < window.innerHeight && ev.pageY > 0
|
||||
document.hasFocus() &&
|
||||
ev.pageX < width &&
|
||||
ev.pageY < window.innerHeight &&
|
||||
ev.pageY > 0
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -151,11 +164,14 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
document.body.style.cursor = "initial";
|
||||
}
|
||||
|
||||
window.addEventListener("blur", handleBlur);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
document.removeEventListener("mousemove", handleDrag);
|
||||
document.removeEventListener("mouseup", handleStopDrag);
|
||||
};
|
||||
}, [isResizing, handleDrag, handleStopDrag]);
|
||||
}, [isResizing, handleDrag, handleBlur, handleStopDrag]);
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
ui.setSidebarWidth(theme.sidebarWidth);
|
||||
@@ -189,8 +205,10 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
$isSmallerThanMinimum={isSmallerThanMinimum}
|
||||
$mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
$collapsed={collapsed}
|
||||
$isMobile={isMobile}
|
||||
className={className}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerDown={handlePointerActivity}
|
||||
onPointerMove={handlePointerActivity}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
column
|
||||
>
|
||||
@@ -214,15 +232,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NotificationsPopover>
|
||||
{(rest: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{...rest}
|
||||
position="bottom"
|
||||
image={<NotificationIcon />}
|
||||
/>
|
||||
)}
|
||||
</NotificationsPopover>
|
||||
<Notifications />
|
||||
</SidebarButton>
|
||||
)}
|
||||
</AccountMenu>
|
||||
@@ -237,6 +247,14 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
);
|
||||
});
|
||||
|
||||
const Notifications = () => (
|
||||
<NotificationsPopover>
|
||||
{(rest: SidebarButtonProps) => (
|
||||
<SidebarButton {...rest} position="bottom" image={<NotificationIcon />} />
|
||||
)}
|
||||
</NotificationsPopover>
|
||||
);
|
||||
|
||||
const Backdrop = styled.a`
|
||||
animation: ${fadeIn} 250ms ease-in-out;
|
||||
position: fixed;
|
||||
@@ -256,6 +274,7 @@ type ContainerProps = {
|
||||
$isHovering: boolean;
|
||||
$collapsed: boolean;
|
||||
$hidden: boolean;
|
||||
$isMobile: boolean;
|
||||
};
|
||||
|
||||
const hoverStyles = (props: ContainerProps) => `
|
||||
@@ -298,8 +317,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")`
|
||||
|
||||
@@ -1,41 +1,101 @@
|
||||
import isUndefined from "lodash/isUndefined";
|
||||
import { observer } from "mobx-react";
|
||||
import { ArchiveIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import Collection from "~/models/Collection";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { archivePath } from "~/utils/routeHelpers";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
import { useDropToArchive } from "../hooks/useDragAndDrop";
|
||||
import { ArchivedCollectionLink } from "./ArchivedCollectionLink";
|
||||
import { StyledError } from "./Collections";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
function ArchiveLink() {
|
||||
const { policies, documents } = useStores();
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [{ isDocumentDropping }, dropToArchiveDocument] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item: DragObject) => {
|
||||
const document = documents.get(item.id);
|
||||
await document?.archive();
|
||||
toast.success(t("Document archived"));
|
||||
},
|
||||
canDrop: (item) => policies.abilities(item.id).archive,
|
||||
collect: (monitor) => ({
|
||||
isDocumentDropping: monitor.isOver(),
|
||||
}),
|
||||
});
|
||||
const [disclosure, setDisclosure] = React.useState<boolean>(false);
|
||||
const [expanded, setExpanded] = React.useState<boolean | undefined>();
|
||||
|
||||
const { request, data, loading, error } = useRequest(
|
||||
collections.fetchArchived,
|
||||
true
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isUndefined(data) && !loading && isUndefined(error)) {
|
||||
setDisclosure(data.length > 0);
|
||||
}
|
||||
}, [data, loading, error]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setDisclosure(collections.archived.length > 0);
|
||||
}, [collections.archived]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (disclosure && isUndefined(expanded)) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [disclosure]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (expanded) {
|
||||
void request();
|
||||
}
|
||||
}, [expanded, request]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
setExpanded(true);
|
||||
}, []);
|
||||
|
||||
const [{ isOverArchiveSection, isDragging }, dropToArchiveRef] =
|
||||
useDropToArchive();
|
||||
|
||||
return (
|
||||
<div ref={dropToArchiveDocument}>
|
||||
<SidebarLink
|
||||
to={archivePath()}
|
||||
icon={<ArchiveIcon open={isDocumentDropping} />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
active={documents.active?.isArchived && !documents.active?.isDeleted}
|
||||
isActiveDrop={isDocumentDropping}
|
||||
/>
|
||||
</div>
|
||||
<Flex column>
|
||||
<div ref={dropToArchiveRef}>
|
||||
<SidebarLink
|
||||
to={archivePath()}
|
||||
icon={<ArchiveIcon open={isOverArchiveSection && isDragging} />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
isActiveDrop={isOverArchiveSection && isDragging}
|
||||
depth={0}
|
||||
expanded={disclosure ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
</div>
|
||||
{expanded === true ? (
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
aria-label={t("Archived collections")}
|
||||
items={collections.archived}
|
||||
loading={<PlaceholderCollections />}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection) => (
|
||||
<ArchivedCollectionLink
|
||||
key={item.id}
|
||||
depth={1}
|
||||
collection={item}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Relative>
|
||||
) : null}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from "react";
|
||||
import Collection from "~/models/Collection";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
import Relative from "./Relative";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
depth?: number;
|
||||
};
|
||||
|
||||
export function ArchivedCollectionLink({ collection, depth }: Props) {
|
||||
const { documents } = useStores();
|
||||
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
setExpanded(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CollectionLink
|
||||
depth={depth ? depth : 0}
|
||||
collection={collection}
|
||||
expanded={expanded}
|
||||
activeDocument={documents.active}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
<Relative>
|
||||
<CollectionLinkChildren
|
||||
collection={collection}
|
||||
expanded={expanded}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
/>
|
||||
</Relative>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,10 @@ import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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";
|
||||
@@ -30,6 +29,8 @@ type Props = {
|
||||
onDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
activeDocument: Document | undefined;
|
||||
isDraggingAnyCollection?: boolean;
|
||||
depth?: number;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const CollectionLink: React.FC<Props> = ({
|
||||
@@ -37,13 +38,14 @@ const CollectionLink: React.FC<Props> = ({
|
||||
expanded,
|
||||
onDisclosureClick,
|
||||
isDraggingAnyCollection,
|
||||
depth,
|
||||
onClick,
|
||||
}: Props) => {
|
||||
const { dialogs, documents, collections } = useStores();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const can = usePolicy(collection);
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const editableTitleRef = React.useRef<RefHandle>(null);
|
||||
|
||||
@@ -52,9 +54,8 @@ const CollectionLink: React.FC<Props> = ({
|
||||
await collection.save({
|
||||
name,
|
||||
});
|
||||
history.replace(collection.path, history.location.state);
|
||||
},
|
||||
[collection, history]
|
||||
[collection]
|
||||
);
|
||||
|
||||
// Drop to re-parent document
|
||||
@@ -78,20 +79,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 });
|
||||
@@ -119,10 +112,15 @@ const CollectionLink: React.FC<Props> = ({
|
||||
sidebarContext,
|
||||
});
|
||||
|
||||
const handleRename = React.useCallback(() => {
|
||||
editableTitleRef.current?.setIsEditing(true);
|
||||
}, [editableTitleRef]);
|
||||
|
||||
return (
|
||||
<Relative ref={drop}>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
onClick={onClick}
|
||||
to={{
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
@@ -148,7 +146,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
depth={0}
|
||||
depth={depth ? depth : 0}
|
||||
menu={
|
||||
!isEditing &&
|
||||
!isDraggingAnyCollection && (
|
||||
@@ -163,7 +161,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
</NudeButton>
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onRename={() => editableTitleRef.current?.setIsEditing(true)}
|
||||
onRename={handleRename}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
|
||||
@@ -2,25 +2,31 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
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>;
|
||||
};
|
||||
|
||||
@@ -29,12 +35,13 @@ function CollectionLinkChildren({
|
||||
expanded,
|
||||
prefetchDocument,
|
||||
}: Props) {
|
||||
const pageSize = 250;
|
||||
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);
|
||||
const [showing, setShowing] = React.useState(pageSize);
|
||||
|
||||
// Drop to reorder document
|
||||
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] = useDrop({
|
||||
@@ -52,11 +59,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(),
|
||||
@@ -64,6 +86,18 @@ function CollectionLinkChildren({
|
||||
}),
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!expanded) {
|
||||
setShowing(pageSize);
|
||||
}
|
||||
}, [expanded]);
|
||||
|
||||
const showMore = React.useCallback(() => {
|
||||
if (childDocuments && childDocuments.length > showing) {
|
||||
setShowing((value) => value + pageSize);
|
||||
}
|
||||
}, [childDocuments, showing]);
|
||||
|
||||
return (
|
||||
<Folder expanded={expanded}>
|
||||
{isDraggingAnyDocument && can.createDocument && manualSort && (
|
||||
@@ -79,7 +113,7 @@ function CollectionLinkChildren({
|
||||
<Loading />
|
||||
</ResizingHeightContainer>
|
||||
)}
|
||||
{childDocuments?.map((node, index) => (
|
||||
{childDocuments?.slice(0, showing).map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
@@ -91,7 +125,18 @@ 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}
|
||||
/>
|
||||
)}
|
||||
<Waypoint key={showing} onEnter={showMore} fireOnRapidScroll />
|
||||
</DocumentsLoader>
|
||||
</Folder>
|
||||
);
|
||||
|
||||
@@ -55,7 +55,7 @@ function Collections() {
|
||||
<PaginatedList
|
||||
options={params}
|
||||
aria-label={t("Collections")}
|
||||
items={collections.orderedData}
|
||||
items={collections.allActive}
|
||||
loading={<PlaceholderCollections />}
|
||||
heading={
|
||||
isDraggingAnyCollection ? (
|
||||
@@ -84,7 +84,7 @@ function Collections() {
|
||||
);
|
||||
}
|
||||
|
||||
const StyledError = styled(Error)`
|
||||
export const StyledError = styled(Error)`
|
||||
font-size: 15px;
|
||||
padding: 0 8px;
|
||||
`;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Location } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
@@ -20,14 +19,18 @@ 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";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
import { useDragDocument, useDropToReorderDocument } from "./useDragAndDrop";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
node: NavigationNode;
|
||||
@@ -80,11 +83,6 @@ function InnerDocumentLink(
|
||||
isActiveDocument,
|
||||
]);
|
||||
|
||||
const pathToNode = React.useMemo(
|
||||
() => collection?.pathToDocument(node.id).map((entry) => entry.id),
|
||||
[collection, node]
|
||||
);
|
||||
|
||||
const showChildren = React.useMemo(
|
||||
() =>
|
||||
!!(
|
||||
@@ -100,27 +98,27 @@ function InnerDocumentLink(
|
||||
[hasChildDocuments, activeDocument, isActiveDocument, node, collection]
|
||||
);
|
||||
|
||||
const [expanded, setExpanded] = React.useState(showChildren);
|
||||
const [expanded, setExpanded, setCollapsed] = useBoolean(showChildren);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (showChildren) {
|
||||
setExpanded(showChildren);
|
||||
setExpanded();
|
||||
}
|
||||
}, [showChildren]);
|
||||
}, [setExpanded, showChildren]);
|
||||
|
||||
// when the last child document is removed auto-close the local folder state
|
||||
React.useEffect(() => {
|
||||
if (expanded && !hasChildDocuments) {
|
||||
setExpanded(false);
|
||||
setCollapsed();
|
||||
}
|
||||
}, [expanded, hasChildDocuments]);
|
||||
}, [setCollapsed, expanded, hasChildDocuments]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev) => {
|
||||
ev?.preventDefault();
|
||||
setExpanded(!expanded);
|
||||
expanded ? setCollapsed() : setExpanded();
|
||||
},
|
||||
[expanded]
|
||||
[setCollapsed, setExpanded, expanded]
|
||||
);
|
||||
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
@@ -148,72 +146,10 @@ function InnerDocumentLink(
|
||||
// Draggable
|
||||
const [{ isDragging }, drag] = useDragDocument(node, depth, document);
|
||||
|
||||
const hoverExpanding = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// We set a timeout when the user first starts hovering over the document link,
|
||||
// to trigger expansion of children. Clear this timeout when they stop hovering.
|
||||
const resetHoverExpanding = React.useCallback(() => {
|
||||
if (hoverExpanding.current) {
|
||||
clearTimeout(hoverExpanding.current);
|
||||
hoverExpanding.current = undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Drop to re-parent
|
||||
const [{ isOverReparent, canDropToReparent }, dropToReparent] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item: DragObject, monitor) => {
|
||||
if (monitor.didDrop()) {
|
||||
return;
|
||||
}
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: node.id,
|
||||
});
|
||||
setExpanded(true);
|
||||
},
|
||||
canDrop: (item, monitor) =>
|
||||
!!pathToNode &&
|
||||
!pathToNode.includes(monitor.getItem<DragObject>().id) &&
|
||||
item.id !== node.id &&
|
||||
policies.abilities(node.id).update &&
|
||||
policies.abilities(item.id).move,
|
||||
hover: (_item, monitor) => {
|
||||
// Enables expansion of document children when hovering over the document
|
||||
// for more than half a second.
|
||||
if (
|
||||
hasChildDocuments &&
|
||||
monitor.canDrop() &&
|
||||
monitor.isOver({
|
||||
shallow: true,
|
||||
})
|
||||
) {
|
||||
if (!hoverExpanding.current) {
|
||||
hoverExpanding.current = setTimeout(() => {
|
||||
hoverExpanding.current = undefined;
|
||||
|
||||
if (
|
||||
monitor.isOver({
|
||||
shallow: true,
|
||||
})
|
||||
) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReparent: monitor.isOver({
|
||||
shallow: true,
|
||||
}),
|
||||
canDropToReparent: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
const [{ isOverReparent, canDropToReparent }, dropToReparent] =
|
||||
useDropToReparentDocument(node, setExpanded, parentRef);
|
||||
|
||||
// Drop to reorder
|
||||
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] =
|
||||
@@ -271,18 +207,18 @@ function InnerDocumentLink(
|
||||
return;
|
||||
}
|
||||
if (ev.key === "ArrowRight" && !expanded) {
|
||||
setExpanded(true);
|
||||
setExpanded();
|
||||
}
|
||||
if (ev.key === "ArrowLeft" && expanded) {
|
||||
setExpanded(false);
|
||||
setCollapsed();
|
||||
}
|
||||
},
|
||||
[hasChildren, expanded]
|
||||
[setExpanded, setCollapsed, hasChildren, expanded]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Relative onDragLeave={resetHoverExpanding}>
|
||||
<Relative ref={parentRef}>
|
||||
<Draggable
|
||||
key={node.id}
|
||||
ref={drag}
|
||||
|
||||
@@ -41,16 +41,6 @@ function EditableTitle(
|
||||
setIsEditing(true);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsEditing(false);
|
||||
setValue(originalValue);
|
||||
}
|
||||
},
|
||||
[originalValue]
|
||||
);
|
||||
|
||||
const stopPropagation = React.useCallback((event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -63,6 +53,7 @@ function EditableTitle(
|
||||
const handleSave = React.useCallback(
|
||||
async (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setIsEditing(false);
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
@@ -85,6 +76,22 @@ function EditableTitle(
|
||||
[originalValue, value, onSubmit]
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
async (ev) => {
|
||||
if (ev.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
if (ev.key === "Escape") {
|
||||
setIsEditing(false);
|
||||
setValue(originalValue);
|
||||
}
|
||||
if (ev.key === "Enter") {
|
||||
await handleSave(ev);
|
||||
}
|
||||
},
|
||||
[handleSave, originalValue]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
onEditing?.(isEditing);
|
||||
}, [onEditing, isEditing]);
|
||||
|
||||
@@ -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;
|
||||
@@ -44,12 +44,12 @@ function HistoryNavigation(props: React.ComponentProps<typeof Flex>) {
|
||||
return (
|
||||
<Navigation gap={4} {...props}>
|
||||
<Tooltip content={t("Go back")} delay={500}>
|
||||
<NudeButton onClick={() => Desktop.bridge.goBack()}>
|
||||
<NudeButton onClick={() => Desktop.bridge?.goBack()}>
|
||||
<Back $active={back} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("Go forward")} delay={500}>
|
||||
<NudeButton onClick={() => Desktop.bridge.goForward()}>
|
||||
<NudeButton onClick={() => Desktop.bridge?.goForward()}>
|
||||
<Forward $active={forward} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
|
||||
@@ -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,18 +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,
|
||||
} from "./useDragAndDrop";
|
||||
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
|
||||
|
||||
type Props = {
|
||||
membership: UserMembership | GroupMembership;
|
||||
@@ -37,8 +38,9 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
const isActiveDocument = documentId === ui.activeDocumentId;
|
||||
const locationSidebarContext = useLocationState();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const document = documentId ? documents.get(documentId) : undefined;
|
||||
|
||||
const [expanded, setExpanded] = React.useState(
|
||||
const [expanded, setExpanded, setCollapsed] = useBoolean(
|
||||
membership.documentId === ui.activeDocumentId &&
|
||||
locationSidebarContext === sidebarContext
|
||||
);
|
||||
@@ -48,13 +50,14 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
membership.documentId === ui.activeDocumentId &&
|
||||
locationSidebarContext === sidebarContext
|
||||
) {
|
||||
setExpanded(true);
|
||||
setExpanded();
|
||||
}
|
||||
}, [
|
||||
membership.documentId,
|
||||
ui.activeDocumentId,
|
||||
sidebarContext,
|
||||
locationSidebarContext,
|
||||
setExpanded,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -73,11 +76,20 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
(ev: React.MouseEvent<HTMLButtonElement>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setExpanded((prevExpanded) => !prevExpanded);
|
||||
if (expanded) {
|
||||
setCollapsed();
|
||||
} else {
|
||||
setExpanded();
|
||||
}
|
||||
},
|
||||
[]
|
||||
[expanded, setExpanded, setCollapsed]
|
||||
);
|
||||
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
const node = React.useMemo(() => document?.asNavigationNode, [document]);
|
||||
const [{ isOverReparent, canDropToReparent }, dropToReparent] =
|
||||
useDropToReparentDocument(node, setExpanded, parentRef);
|
||||
|
||||
const { icon } = useSidebarLabelAndIcon(membership);
|
||||
const [{ isDragging }, draggableRef] = useDragMembership(membership);
|
||||
|
||||
@@ -93,12 +105,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
|
||||
const displayChildDocuments = expanded && !isDragging;
|
||||
|
||||
if (documentId) {
|
||||
const document = documents.get(documentId);
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (document) {
|
||||
const { icon: docIcon } = document;
|
||||
const label =
|
||||
determineIconType(docIcon) === IconType.Emoji
|
||||
@@ -114,67 +121,75 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Draggable
|
||||
key={membership.id}
|
||||
ref={draggableRef}
|
||||
$isDragging={isDragging}
|
||||
>
|
||||
<SidebarLink
|
||||
depth={depth}
|
||||
to={{
|
||||
pathname: document.path,
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
icon={icon}
|
||||
isActive={(
|
||||
match,
|
||||
location: Location<{ sidebarContext?: SidebarContextType }>
|
||||
) => !!match && location.state?.sidebarContext === sidebarContext}
|
||||
label={label}
|
||||
exact={false}
|
||||
unreadBadge={
|
||||
document.unreadNotifications.filter(
|
||||
(notification) =>
|
||||
notification.event === NotificationEventType.AddUserToDocument
|
||||
).length > 0
|
||||
}
|
||||
showActions={menuOpen}
|
||||
menu={
|
||||
document && !isDragging ? (
|
||||
<Fade>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Draggable>
|
||||
<Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
<Relative ref={parentRef}>
|
||||
<Draggable
|
||||
key={membership.id}
|
||||
ref={draggableRef}
|
||||
$isDragging={isDragging}
|
||||
>
|
||||
<div ref={dropToReparent}>
|
||||
<SidebarLink
|
||||
isActiveDrop={isOverReparent && canDropToReparent}
|
||||
depth={depth}
|
||||
to={{
|
||||
pathname: document.path,
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
expanded={
|
||||
hasChildDocuments && !isDragging ? expanded : undefined
|
||||
}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
icon={icon}
|
||||
isActive={(
|
||||
match,
|
||||
location: Location<{ sidebarContext?: SidebarContextType }>
|
||||
) =>
|
||||
!!match && location.state?.sidebarContext === sidebarContext
|
||||
}
|
||||
label={label}
|
||||
exact={false}
|
||||
unreadBadge={
|
||||
document.unreadNotifications.filter(
|
||||
(notification) =>
|
||||
notification.event ===
|
||||
NotificationEventType.AddUserToDocument
|
||||
).length > 0
|
||||
}
|
||||
showActions={menuOpen}
|
||||
menu={
|
||||
document && !isDragging ? (
|
||||
<Fade>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
{reorderProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderProps.isOverCursor}
|
||||
innerRef={dropToReorderRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Draggable>
|
||||
</Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
{reorderProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderProps.isOverCursor}
|
||||
innerRef={dropToReorderRef}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ const StyledMoreIcon = styled(MoreIcon)`
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)<{ $position: "top" | "bottom" }>`
|
||||
overflow: hidden;
|
||||
padding-top: ${(props) =>
|
||||
props.$position === "top" && Desktop.hasInsetTitlebar() ? 36 : 0}px;
|
||||
${draggableOnDesktop()}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -2,41 +2,29 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Badge from "~/components/Badge";
|
||||
import { version } from "../../../../package.json";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { version as currentVersion } from "../../../../package.json";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
export default function Version() {
|
||||
const [releasesBehind, setReleasesBehind] = React.useState(-1);
|
||||
const [versionsBehind, setVersionsBehind] = React.useState(-1);
|
||||
const { t } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
async function loadReleases() {
|
||||
const res = await fetch(
|
||||
"https://api.github.com/repos/outline/outline/releases"
|
||||
);
|
||||
const releases = await res.json();
|
||||
|
||||
if (Array.isArray(releases)) {
|
||||
const everyNewRelease = releases
|
||||
.map((release) => release.tag_name)
|
||||
.findIndex((tagName) => tagName === `v${version}`);
|
||||
|
||||
const onlyFullNewRelease = releases
|
||||
.filter((release) => !release.prerelease)
|
||||
.map((release) => release.tag_name)
|
||||
.findIndex((tagName) => tagName === `v${version}`);
|
||||
|
||||
const computedReleasesBehind = version.includes("pre")
|
||||
? everyNewRelease
|
||||
: onlyFullNewRelease;
|
||||
|
||||
if (computedReleasesBehind >= 0) {
|
||||
setReleasesBehind(computedReleasesBehind);
|
||||
async function loadVersionInfo() {
|
||||
try {
|
||||
// Fetch version info from the server-side proxy
|
||||
const res = await client.post("/installation.info");
|
||||
if (res.data && res.data.versionsBehind >= 0) {
|
||||
setVersionsBehind(res.data.versionsBehind);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Failed to load version info", error);
|
||||
}
|
||||
}
|
||||
|
||||
void loadReleases();
|
||||
void loadVersionInfo();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -45,16 +33,16 @@ export default function Version() {
|
||||
href="https://github.com/outline/outline/releases"
|
||||
label={
|
||||
<>
|
||||
v{version}
|
||||
{releasesBehind >= 0 && (
|
||||
v{currentVersion}
|
||||
{versionsBehind >= 0 && (
|
||||
<>
|
||||
<br />
|
||||
<LilBadge>
|
||||
{releasesBehind === 0
|
||||
{versionsBehind === 0
|
||||
? t("Up to date")
|
||||
: t(`{{ releasesBehind }} versions behind`, {
|
||||
releasesBehind,
|
||||
count: releasesBehind,
|
||||
releasesBehind: versionsBehind,
|
||||
count: versionsBehind,
|
||||
})}
|
||||
</LilBadge>
|
||||
</>
|
||||
|
||||
+197
-5
@@ -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";
|
||||
|
||||
/**
|
||||
@@ -148,6 +149,7 @@ export function useDragDocument(
|
||||
icon: icon ? <Icon value={icon} color={color} /> : undefined,
|
||||
collectionId: document?.collectionId || "",
|
||||
} as DragObject),
|
||||
canDrag: () => !!document?.isActive,
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
@@ -160,6 +162,121 @@ export function useDragDocument(
|
||||
return [{ isDragging }, draggableRef] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for shared logic that allows dropping documents to reparent
|
||||
*
|
||||
* @param node The NavigationNode model to drop.
|
||||
* @param setExpanded A function to expand the parent node.
|
||||
* @param parentRef A ref to the parent element that will be used to detect when the user is no longer hovering..
|
||||
*/
|
||||
export function useDropToReparentDocument(
|
||||
node: NavigationNode | undefined,
|
||||
setExpanded: () => void,
|
||||
parentRef: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
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(
|
||||
() => document?.pathTo.map((item) => item.id),
|
||||
[document]
|
||||
);
|
||||
|
||||
const hoverExpanding = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// We set a timeout when the user first starts hovering over the document link,
|
||||
// to trigger expansion of children. Clear this timeout when they stop hovering.
|
||||
React.useEffect(() => {
|
||||
const resetHoverExpanding = () => {
|
||||
if (hoverExpanding.current) {
|
||||
clearTimeout(hoverExpanding.current);
|
||||
hoverExpanding.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const element = parentRef.current;
|
||||
element?.addEventListener("dragleave", resetHoverExpanding);
|
||||
|
||||
return () => {
|
||||
element?.removeEventListener("dragleave", resetHoverExpanding);
|
||||
};
|
||||
}, [parentRef]);
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
{ isOverReparent: boolean; canDropToReparent: boolean }
|
||||
>({
|
||||
accept: "document",
|
||||
drop: async (item, monitor) => {
|
||||
if (monitor.didDrop() || !node) {
|
||||
return;
|
||||
}
|
||||
|
||||
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) =>
|
||||
!!node &&
|
||||
!!pathToNode &&
|
||||
!pathToNode.includes(monitor.getItem().id) &&
|
||||
item.id !== node.id &&
|
||||
!!document?.isActive &&
|
||||
policies.abilities(node.id).update &&
|
||||
policies.abilities(item.id).move,
|
||||
hover: (_item, monitor) => {
|
||||
// Enables expansion of document children when hovering over the document
|
||||
// for more than half a second.
|
||||
if (
|
||||
hasChildDocuments &&
|
||||
monitor.canDrop() &&
|
||||
monitor.isOver({
|
||||
shallow: true,
|
||||
})
|
||||
) {
|
||||
if (!hoverExpanding.current) {
|
||||
hoverExpanding.current = setTimeout(() => {
|
||||
hoverExpanding.current = undefined;
|
||||
|
||||
if (monitor.isOver({ shallow: true })) {
|
||||
setExpanded();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReparent: monitor.isOver({ shallow: true }),
|
||||
canDropToReparent: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for shared logic that allows dropping documents to reorder
|
||||
*
|
||||
@@ -180,7 +297,9 @@ export function useDropToReorderDocument(
|
||||
}
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { documents, policies } = useStores();
|
||||
const { documents, collections, dialogs, policies } = useStores();
|
||||
|
||||
const document = documents.get(node.id);
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
@@ -189,11 +308,23 @@ export function useDropToReorderDocument(
|
||||
>({
|
||||
accept: "document",
|
||||
canDrop: (item: DragObject) => {
|
||||
if (item.id === node.id) {
|
||||
if (
|
||||
item.id === node.id ||
|
||||
!policies.abilities(item.id)?.move ||
|
||||
!document?.isActive
|
||||
) {
|
||||
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) {
|
||||
@@ -206,8 +337,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) => ({
|
||||
@@ -284,3 +435,44 @@ export function useDropToReorderUserMembership(getIndex?: () => string) {
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for shared logic that allows dropping documents and collections onto archive section
|
||||
*/
|
||||
export function useDropToArchive() {
|
||||
const accept = ["document", "collection"];
|
||||
const { documents, collections, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
{ isOverArchiveSection: boolean; isDragging: boolean }
|
||||
>({
|
||||
accept,
|
||||
drop: async (item, monitor) => {
|
||||
const type = monitor.getItemType();
|
||||
let model;
|
||||
|
||||
if (type === "collection") {
|
||||
model = collections.get(item.id);
|
||||
} else {
|
||||
model = documents.get(item.id);
|
||||
}
|
||||
|
||||
if (model) {
|
||||
await model.archive();
|
||||
toast.success(
|
||||
type === "collection"
|
||||
? t("Collection archived")
|
||||
: t("Document archived")
|
||||
);
|
||||
}
|
||||
},
|
||||
canDrop: (item) => policies.abilities(item.id).archive,
|
||||
collect: (monitor) => ({
|
||||
isOverArchiveSection: !!monitor.isOver(),
|
||||
isDragging: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
+27
-24
@@ -120,30 +120,33 @@ function Table({
|
||||
<Anchor ref={topRef} />
|
||||
<InnerTable {...getTableProps()}>
|
||||
<thead>
|
||||
{headerGroups.map((headerGroup) => (
|
||||
<tr {...headerGroup.getHeaderGroupProps()} key={headerGroup.id}>
|
||||
{headerGroup.headers.map((column) => (
|
||||
<Head
|
||||
{...column.getHeaderProps(column.getSortByToggleProps())}
|
||||
key={column.id}
|
||||
>
|
||||
<SortWrapper
|
||||
align="center"
|
||||
$sortable={!column.disableSortBy}
|
||||
gap={4}
|
||||
{headerGroups.map((headerGroup) => {
|
||||
const groupProps = headerGroup.getHeaderGroupProps();
|
||||
return (
|
||||
<tr {...groupProps} key={groupProps.key}>
|
||||
{headerGroup.headers.map((column) => (
|
||||
<Head
|
||||
{...column.getHeaderProps(column.getSortByToggleProps())}
|
||||
key={column.id}
|
||||
>
|
||||
{column.render("Header")}
|
||||
{column.isSorted &&
|
||||
(column.isSortedDesc ? (
|
||||
<DescSortIcon />
|
||||
) : (
|
||||
<AscSortIcon />
|
||||
))}
|
||||
</SortWrapper>
|
||||
</Head>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
<SortWrapper
|
||||
align="center"
|
||||
$sortable={!column.disableSortBy}
|
||||
gap={4}
|
||||
>
|
||||
{column.render("Header")}
|
||||
{column.isSorted &&
|
||||
(column.isSortedDesc ? (
|
||||
<DescSortIcon />
|
||||
) : (
|
||||
<AscSortIcon />
|
||||
))}
|
||||
</SortWrapper>
|
||||
</Head>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</thead>
|
||||
<tbody {...getTableBodyProps()}>
|
||||
{rows.map((row) => {
|
||||
@@ -253,7 +256,7 @@ const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
|
||||
|
||||
&:hover {
|
||||
background: ${(props) =>
|
||||
props.$sortable ? props.theme.secondaryBackground : "none"};
|
||||
props.$sortable ? props.theme.backgroundSecondary : "none"};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user