mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
367 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 75a78cd1c7 | |||
| ff11a3c667 | |||
| 1236cc9c16 | |||
| 8da5afc394 | |||
| 7f17a51e11 | |||
| bf1580a459 | |||
| 3e991c71c4 | |||
| e90b4d8871 | |||
| ac6b4fcb4f | |||
| 0ac5139730 | |||
| 53d2d68a21 | |||
| 783122186a | |||
| 9b24482c46 | |||
| 29fdd7e566 | |||
| bb074fc8cf | |||
| 19b6ee832b | |||
| 63e667d6d3 | |||
| 2afec241a0 | |||
| d65037d4e7 | |||
| 58eb55efb3 | |||
| d930824b27 | |||
| 167cc1adbf | |||
| 1491fc2eb4 | |||
| b95eb114f1 | |||
| 20799c941e | |||
| 2290dff1f2 | |||
| f61689abdc | |||
| fefb9200f1 | |||
| 0a4d67d96f | |||
| 5374784df6 | |||
| d090316065 | |||
| 0fc3099f75 | |||
| 264dda25a5 | |||
| 8031b2906d | |||
| 0642396264 | |||
| 88f405375c | |||
| 7bd8738ecd | |||
| 043a8623b9 | |||
| 5d85a3a093 | |||
| 2578a1f75f | |||
| 9578611d8f | |||
| 65b1fd9a1f | |||
| 282b0f486b | |||
| e3cd9af6df | |||
| a1373f8078 | |||
| 6a85d7444d | |||
| 24222ddbb1 | |||
| a59215d27c | |||
| 23ad780672 | |||
| ac26fd2be7 | |||
| 6a09af16a4 | |||
| ce2fc94289 | |||
| 3b01551e1a | |||
| d2b3e50a48 | |||
| 5549676185 | |||
| 976db13ea0 | |||
| 510a756378 | |||
| d399ffa9f8 | |||
| 932e5bf121 | |||
| 330ee819c5 | |||
| 61e29d91bf | |||
| 5e0c773826 | |||
| 1f7e8c158d | |||
| eb6cc62630 | |||
| 5380d8c7ac | |||
| fd379dddba | |||
| 5e825cef10 | |||
| ad07f4b5f7 | |||
| 2a502e43ef | |||
| 18d4eaee07 | |||
| 058e413a6e | |||
| 1c527c97a7 | |||
| 8ac3c17310 | |||
| 5687514fd5 | |||
| 27ea4332eb | |||
| 382a0155a2 | |||
| 24b07698a7 | |||
| e9380f270e | |||
| 16badaea5d | |||
| b82a379c0e | |||
| 300d0c56ac | |||
| f887a5b4f1 | |||
| 0ab8b52582 | |||
| c9d4f5038b | |||
| 53cc2d8154 | |||
| 82651737b8 | |||
| 7a06d94548 | |||
| 516c5082c8 | |||
| 7269201bca | |||
| 86be197049 | |||
| a65d126ccf | |||
| eee14d98a7 | |||
| d738081880 | |||
| c3cefc40e5 | |||
| 695476a038 | |||
| 1a31cf562e | |||
| d7c13bc8b0 | |||
| 36e8c4796d | |||
| d3ddb25c76 | |||
| 51839dd780 | |||
| fc2967d080 | |||
| aba0297bd5 | |||
| dd1df68e74 | |||
| d2f5ac3d53 | |||
| 5eae8734c1 | |||
| 542f01e36f | |||
| e0dfda6f7e | |||
| 6b7837a8d6 | |||
| b6c7a61243 | |||
| a54218d9cc | |||
| a432a7caa3 | |||
| bb8aa3616a | |||
| 949d93bbfd | |||
| d28e23dd8e | |||
| d8145ac370 | |||
| fbc4a7fcbd | |||
| 04ecf14cc8 | |||
| 4eae1f1db3 | |||
| fd4ab0077d | |||
| d6c074102b | |||
| 2beab0c274 | |||
| 4f35b8ea0d | |||
| e4cbf0a34a | |||
| d79ce99629 | |||
| 8bf488de0b | |||
| d420319b28 | |||
| 413bcfa7de | |||
| 363f1fffca | |||
| 3e7b61c9d7 | |||
| ac0488a4d6 | |||
| 41af3a107e | |||
| 964ba78d75 | |||
| 340109d9a3 | |||
| 6c430dc747 | |||
| 93f12d8846 | |||
| a93655bf6e | |||
| e2b4fa456b | |||
| cd04c4a8bf | |||
| bf7fb8aa68 | |||
| 08a6376947 | |||
| a120427943 | |||
| 59e97eba2b | |||
| 80b59b1174 | |||
| 6a17e8deec | |||
| cd0aba119b | |||
| eca17ec63d | |||
| e164c4e7ca | |||
| bead9ae79a | |||
| 336e424b8b | |||
| 0bb993634a | |||
| 2f26e76b1e | |||
| 93a89eeef3 | |||
| 6e6a5014af | |||
| 3da1945bea | |||
| c2fbb31e77 | |||
| 4c999d00d2 | |||
| 738449a7d0 | |||
| ae80128396 | |||
| 1da5ac0bfe | |||
| f56f240d9b | |||
| 7de0ffb7f7 | |||
| 0e667c5d3d | |||
| 465c935879 | |||
| 9c628dfc54 | |||
| 38b11b3f1e | |||
| d7f53acfa2 | |||
| e033b20d6f | |||
| 87aacae479 | |||
| bbe6df19ea | |||
| fd851dfbd1 | |||
| 1426c4e6ab | |||
| 10b33ff91f | |||
| 15f58d0e15 | |||
| 6f5859a175 | |||
| 52776d5add | |||
| 474baf7b42 | |||
| 516ccce51d | |||
| 16c51c10fe | |||
| d312d00bca | |||
| e4c1281bf7 | |||
| bbbc00baaa | |||
| 78d5992ad4 | |||
| f58ce49e3d | |||
| f0ba98e936 | |||
| ff34a46361 | |||
| 7849af6887 | |||
| 95a87878c3 | |||
| 011ffc450c | |||
| 51c5512902 | |||
| 76d64f90c1 | |||
| f48c05bef3 | |||
| f675a04735 | |||
| e52719c38e | |||
| cd854d4adb | |||
| 7bb7f96008 | |||
| 6d70b4b9c7 | |||
| 78fd39a0fb | |||
| 8186e23d45 | |||
| e4492a32d2 | |||
| cb4f610bb4 | |||
| 14a96e7262 | |||
| e2213dbfa2 | |||
| e3d7fba239 | |||
| 33a43077d5 | |||
| 4ced6c3b7f | |||
| 06a40e8b7c | |||
| 6a6c069e4e | |||
| cf3ef2a839 | |||
| a49f89bda6 | |||
| 82b2defac1 | |||
| c05a8cf73a | |||
| 7f5e2cacd8 | |||
| d8e97e0c1f | |||
| 74bcf152f0 | |||
| 57431720b2 | |||
| b8c6cc603b | |||
| 71c48bcd47 | |||
| 4ad1148aa7 | |||
| 67c0335099 | |||
| c600148f4b | |||
| 299e49625b | |||
| 82d84e4859 | |||
| e7d3dac36c | |||
| 0d0932a6f6 | |||
| 37e68413f0 | |||
| 01bbc48c01 | |||
| f02bbc3942 | |||
| 9419e65837 | |||
| 28cfeba99a | |||
| 2c138687c7 | |||
| f38c948573 | |||
| 1f5ccc3055 | |||
| 58008d84c4 | |||
| ea678e40cc | |||
| 53caddd930 | |||
| 6ab182433b | |||
| f01a45a80a | |||
| 44366e1462 | |||
| b3a24e4917 | |||
| 43cf33fc0a | |||
| 07b6441655 | |||
| 49198aafe9 | |||
| ddd103542a | |||
| 4654dfb658 | |||
| bdcde1aa53 | |||
| c484d1defe | |||
| 1efd3b6f96 | |||
| e07be1ee5e | |||
| 0067d1a58d | |||
| 17451c180a | |||
| 4e989e5c44 | |||
| 335957d914 | |||
| 1711d17e25 | |||
| de90f879f1 | |||
| 303125b682 | |||
| f33026f7b3 | |||
| 06b5efd18a | |||
| c8e67b969d | |||
| b84851a4c3 | |||
| c6408f7b3f | |||
| 18f729b970 | |||
| 2f9a7f9a21 | |||
| c5b94e50df | |||
| 8a8dad15ef | |||
| a8d4a5b587 | |||
| a67c35257e | |||
| f9dadf5548 | |||
| 117c4f5009 | |||
| f34557337d | |||
| 9b25e623b4 | |||
| fe41824ef2 | |||
| e9755faf9a | |||
| 9dcb04b58a | |||
| d8e571d82d | |||
| 3f4027c6fa | |||
| e507f09ff9 | |||
| 63ddc31710 | |||
| 5aa5ba0aa1 | |||
| ed496bdf60 | |||
| 7201bdb9d8 | |||
| 53e3245b15 | |||
| 2c666ddde2 | |||
| 6bb2953e8d | |||
| 6a1a3eee91 | |||
| d03c7b33d3 | |||
| 355bc33f7c | |||
| bf2378ec81 | |||
| 5c999f5327 | |||
| 29a653aaeb | |||
| beabd32e6a | |||
| 77d6797d85 | |||
| fbd8f5981b | |||
| 8336207c23 | |||
| 3054f34a90 | |||
| 07a805696d | |||
| 142493ddcc | |||
| bd18b33b9d | |||
| 03373804fa | |||
| daba308440 | |||
| 1451f70b9e |
@@ -12,7 +12,7 @@
|
||||
"legacy": true
|
||||
}
|
||||
],
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
"@babel/plugin-transform-class-properties",
|
||||
[
|
||||
"transform-inline-environment-variables",
|
||||
{
|
||||
@@ -60,4 +60,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
- run:
|
||||
name: test
|
||||
command: |
|
||||
TESTFILES=$(circleci tests glob "server/**/*.test.ts" | circleci tests split)
|
||||
TESTFILES=$(circleci tests glob "**/server/**/*.test.ts" | circleci tests split)
|
||||
yarn test --maxWorkers=2 $TESTFILES
|
||||
bundle-size:
|
||||
<<: *defaults
|
||||
|
||||
+5
-1
@@ -131,7 +131,7 @@ GITHUB_APP_PRIVATE_KEY=
|
||||
# => https://discord.com/developers/applications/
|
||||
#
|
||||
# When configuring the Client ID, add a redirect URL under "OAuth2":
|
||||
# https://<URL>/api/discord.callback
|
||||
# https://<URL>/auth/discord.callback
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
|
||||
@@ -189,6 +189,10 @@ SLACK_VERIFICATION_TOKEN=your_token
|
||||
SLACK_APP_ID=A0XXXXXXX
|
||||
SLACK_MESSAGE_ACTIONS=true
|
||||
|
||||
# For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup
|
||||
# and do not forget to whitelist your domain name in the app settings
|
||||
DROPBOX_APP_KEY=
|
||||
|
||||
# Optionally enable Sentry (sentry.io) to track errors and performance,
|
||||
# and optionally add a Sentry proxy tunnel for bypassing ad blockers in the UI:
|
||||
# https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option)
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@typescript-eslint/no-shadow": [
|
||||
"warn",
|
||||
{
|
||||
"allow": ["transaction"],
|
||||
"hoist": "all",
|
||||
"ignoreTypeValueShadow": true
|
||||
}
|
||||
@@ -139,4 +140,4 @@
|
||||
"typescript": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Configuration for probot-no-response - https://github.com/probot/no-response
|
||||
|
||||
# Number of days of inactivity before an Issue is closed for lack of response
|
||||
daysUntilClose: 14
|
||||
daysUntilClose: 7
|
||||
|
||||
# Label requiring a response
|
||||
responseRequiredLabel: more information needed
|
||||
|
||||
+10
-3
@@ -1,5 +1,5 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM outlinewiki/outline-base as base
|
||||
FROM outlinewiki/outline-base AS base
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
@@ -11,7 +11,7 @@ LABEL org.opencontainers.image.source="https://github.com/outline/outline"
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
ENV NODE_ENV production
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=base $APP_PATH/build ./build
|
||||
COPY --from=base $APP_PATH/server ./server
|
||||
@@ -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 && \
|
||||
@@ -27,7 +32,7 @@ RUN addgroup --gid 1001 nodejs && \
|
||||
mkdir -p /var/lib/outline && \
|
||||
chown -R nodejs:nodejs /var/lib/outline
|
||||
|
||||
ENV FILE_STORAGE_LOCAL_ROOT_DIR /var/lib/outline/data
|
||||
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
|
||||
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
||||
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
||||
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
|
||||
@@ -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"]
|
||||
|
||||
+1
-6
@@ -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
|
||||
|
||||
@@ -22,5 +18,4 @@ RUN rm -rf node_modules
|
||||
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
|
||||
ENV PORT=3000
|
||||
|
||||
@@ -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.79.1
|
||||
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-09-05
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
ShapesIcon,
|
||||
StarredIcon,
|
||||
TrashIcon,
|
||||
UnstarredIcon,
|
||||
@@ -21,7 +22,7 @@ import { createAction } from "~/actions";
|
||||
import { CollectionSection } from "~/actions/sections";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import history from "~/utils/history";
|
||||
import { searchPath } from "~/utils/routeHelpers";
|
||||
import { newTemplatePath, searchPath } from "~/utils/routeHelpers";
|
||||
|
||||
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
|
||||
<DynamicCollectionIcon collection={collection} />
|
||||
@@ -71,7 +72,7 @@ export const editCollection = createAction({
|
||||
analyticsName: "Edit collection",
|
||||
section: CollectionSection,
|
||||
icon: <EditIcon />,
|
||||
visible: ({ stores, activeCollectionId }) =>
|
||||
visible: ({ activeCollectionId }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ t, activeCollectionId }) => {
|
||||
@@ -97,10 +98,10 @@ export const editCollectionPermissions = createAction({
|
||||
analyticsName: "Collection permissions",
|
||||
section: CollectionSection,
|
||||
icon: <PadlockIcon />,
|
||||
visible: ({ stores, activeCollectionId }) =>
|
||||
visible: ({ activeCollectionId }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ t, stores, activeCollectionId }) => {
|
||||
perform: ({ t, activeCollectionId }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -111,6 +112,7 @@ export const editCollectionPermissions = createAction({
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Share this collection"),
|
||||
style: { marginBottom: -12 },
|
||||
content: (
|
||||
<SharePopover
|
||||
collection={collection}
|
||||
@@ -127,7 +129,9 @@ export const searchInCollection = createAction({
|
||||
analyticsName: "Search collection",
|
||||
section: CollectionSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: ({ activeCollectionId }) => !!activeCollectionId,
|
||||
visible: ({ activeCollectionId }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).readDocument,
|
||||
perform: ({ activeCollectionId }) => {
|
||||
history.push(searchPath(undefined, { collectionId: activeCollectionId }));
|
||||
},
|
||||
@@ -139,7 +143,7 @@ export const starCollection = createAction({
|
||||
section: CollectionSection,
|
||||
icon: <StarredIcon />,
|
||||
keywords: "favorite bookmark",
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
visible: ({ activeCollectionId }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
@@ -149,7 +153,7 @@ export const starCollection = createAction({
|
||||
stores.policies.abilities(activeCollectionId).star
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
perform: async ({ activeCollectionId }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -166,7 +170,7 @@ export const unstarCollection = createAction({
|
||||
section: CollectionSection,
|
||||
icon: <UnstarredIcon />,
|
||||
keywords: "unfavorite unbookmark",
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
visible: ({ activeCollectionId }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
@@ -176,7 +180,7 @@ export const unstarCollection = createAction({
|
||||
stores.policies.abilities(activeCollectionId).unstar
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
perform: async ({ activeCollectionId }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -192,13 +196,13 @@ export const deleteCollection = createAction({
|
||||
section: CollectionSection,
|
||||
dangerous: true,
|
||||
icon: <TrashIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
visible: ({ activeCollectionId }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return stores.policies.abilities(activeCollectionId).delete;
|
||||
},
|
||||
perform: ({ activeCollectionId, stores, t }) => {
|
||||
perform: ({ activeCollectionId, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -220,6 +224,27 @@ export const deleteCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const createTemplate = createAction({
|
||||
name: ({ t }) => t("New template"),
|
||||
analyticsName: "New template",
|
||||
section: CollectionSection,
|
||||
icon: <ShapesIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ activeCollectionId }) =>
|
||||
!!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createDocument
|
||||
),
|
||||
perform: ({ activeCollectionId, event }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
history.push(newTemplatePath(activeCollectionId));
|
||||
},
|
||||
});
|
||||
|
||||
export const rootCollectionActions = [
|
||||
openCollection,
|
||||
createCollection,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { DoneIcon, 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 history from "~/utils/history";
|
||||
import { createAction } from "..";
|
||||
import { DocumentSection } from "../sections";
|
||||
|
||||
export const deleteCommentFactory = ({
|
||||
comment,
|
||||
onDelete,
|
||||
}: {
|
||||
comment: Comment;
|
||||
onDelete: () => void;
|
||||
}) =>
|
||||
createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete comment",
|
||||
section: DocumentSection,
|
||||
icon: <TrashIcon />,
|
||||
keywords: "trash",
|
||||
dangerous: true,
|
||||
visible: () => stores.policies.abilities(comment.id).delete,
|
||||
perform: ({ t, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Delete comment"),
|
||||
content: <CommentDeleteDialog comment={comment} onSubmit={onDelete} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const resolveCommentFactory = ({
|
||||
comment,
|
||||
onResolve,
|
||||
}: {
|
||||
comment: Comment;
|
||||
onResolve: () => void;
|
||||
}) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Mark as resolved"),
|
||||
analyticsName: "Resolve thread",
|
||||
section: DocumentSection,
|
||||
icon: <DoneIcon outline />,
|
||||
visible: () =>
|
||||
stores.policies.abilities(comment.id).resolve &&
|
||||
stores.policies.abilities(comment.documentId).update,
|
||||
perform: async ({ t }) => {
|
||||
await comment.resolve();
|
||||
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: null,
|
||||
});
|
||||
|
||||
onResolve();
|
||||
toast.success(t("Thread resolved"));
|
||||
},
|
||||
});
|
||||
|
||||
export const unresolveCommentFactory = ({
|
||||
comment,
|
||||
onUnresolve,
|
||||
}: {
|
||||
comment: Comment;
|
||||
onUnresolve: () => void;
|
||||
}) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Mark as unresolved"),
|
||||
analyticsName: "Unresolve thread",
|
||||
section: DocumentSection,
|
||||
icon: <DoneIcon outline />,
|
||||
visible: () =>
|
||||
stores.policies.abilities(comment.id).unresolve &&
|
||||
stores.policies.abilities(comment.documentId).update,
|
||||
perform: async () => {
|
||||
await comment.unresolve();
|
||||
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: null,
|
||||
});
|
||||
|
||||
onUnresolve();
|
||||
},
|
||||
});
|
||||
@@ -24,9 +24,9 @@ import {
|
||||
UnpublishIcon,
|
||||
PublishIcon,
|
||||
CommentIcon,
|
||||
GlobeIcon,
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
PadlockIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -37,10 +37,11 @@ import DocumentMove from "~/scenes/DocumentMove";
|
||||
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
|
||||
import DocumentPublish from "~/scenes/DocumentPublish";
|
||||
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
|
||||
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DuplicateDialog from "~/components/DuplicateDialog";
|
||||
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 env from "~/env";
|
||||
@@ -56,7 +57,6 @@ import {
|
||||
documentPath,
|
||||
urlify,
|
||||
trashPath,
|
||||
newTemplatePath,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
export const openDocument = createAction({
|
||||
@@ -105,9 +105,9 @@ export const createDocument = createAction({
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, inStarredSection }) =>
|
||||
perform: ({ activeCollectionId, sidebarContext }) =>
|
||||
history.push(newDocumentPath(activeCollectionId), {
|
||||
starred: inStarredSection,
|
||||
sidebarContext,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -122,11 +122,11 @@ export const createDocumentFromTemplate = createAction({
|
||||
!!activeDocumentId &&
|
||||
!!stores.documents.get(activeDocumentId)?.template &&
|
||||
stores.policies.abilities(currentTeamId).createDocument,
|
||||
perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) =>
|
||||
perform: ({ activeCollectionId, activeDocumentId, sidebarContext }) =>
|
||||
history.push(
|
||||
newDocumentPath(activeCollectionId, { templateId: activeDocumentId }),
|
||||
{
|
||||
starred: inStarredSection,
|
||||
sidebarContext,
|
||||
}
|
||||
),
|
||||
});
|
||||
@@ -142,9 +142,9 @@ export const createNestedDocument = createAction({
|
||||
!!activeDocumentId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument &&
|
||||
stores.policies.abilities(activeDocumentId).createChildDocument,
|
||||
perform: ({ activeDocumentId, inStarredSection }) =>
|
||||
perform: ({ activeDocumentId, sidebarContext }) =>
|
||||
history.push(newNestedDocumentPath(activeDocumentId), {
|
||||
starred: inStarredSection,
|
||||
sidebarContext,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -224,7 +224,7 @@ export const publishDocument = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
if (document?.collectionId) {
|
||||
if (document?.collectionId || document?.template) {
|
||||
await document.save(undefined, {
|
||||
publish: true,
|
||||
});
|
||||
@@ -332,10 +332,14 @@ export const unsubscribeDocument = createAction({
|
||||
});
|
||||
|
||||
export const shareDocument = createAction({
|
||||
name: ({ t }) => t("Share"),
|
||||
name: ({ t }) => `${t("Permissions")}…`,
|
||||
analyticsName: "Share document",
|
||||
section: DocumentSection,
|
||||
icon: <GlobeIcon />,
|
||||
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;
|
||||
@@ -349,6 +353,7 @@ export const shareDocument = createAction({
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
style: { marginBottom: -12 },
|
||||
title: t("Share this document"),
|
||||
content: (
|
||||
<SharePopover
|
||||
@@ -658,52 +663,55 @@ export const importDocument = createAction({
|
||||
const files = getEventFiles(ev);
|
||||
|
||||
const file = files[0];
|
||||
const document = await documents.import(
|
||||
file,
|
||||
activeDocumentId,
|
||||
activeCollectionId,
|
||||
{
|
||||
publish: true,
|
||||
}
|
||||
);
|
||||
history.push(document.url);
|
||||
|
||||
try {
|
||||
const document = await documents.import(
|
||||
file,
|
||||
activeDocumentId,
|
||||
activeCollectionId,
|
||||
{
|
||||
publish: true,
|
||||
}
|
||||
);
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
},
|
||||
});
|
||||
|
||||
export const createTemplate = createAction({
|
||||
name: ({ t, activeDocumentId }) =>
|
||||
activeDocumentId ? t("Templatize") : t("New template"),
|
||||
export const createTemplateFromDocument = createAction({
|
||||
name: ({ t }) => t("Templatize"),
|
||||
analyticsName: "Templatize document",
|
||||
section: DocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
|
||||
if (activeDocumentId) {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (document?.isTemplate || !document?.isActive) {
|
||||
return false;
|
||||
}
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document?.isTemplate || !document?.isActive) {
|
||||
return false;
|
||||
}
|
||||
return !!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update
|
||||
stores.policies.abilities(activeCollectionId).updateDocument
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, activeDocumentId, stores, t, event }) => {
|
||||
perform: ({ activeDocumentId, stores, t, event }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
if (activeDocumentId) {
|
||||
stores.dialogs.openModal({
|
||||
title: t("Create template"),
|
||||
content: <DocumentTemplatizeDialog documentId={activeDocumentId} />,
|
||||
});
|
||||
} else if (activeCollectionId) {
|
||||
history.push(newTemplatePath(activeCollectionId));
|
||||
}
|
||||
stores.dialogs.openModal({
|
||||
title: t("Create template"),
|
||||
content: <DocumentTemplatizeDialog documentId={activeDocumentId} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -717,11 +725,11 @@ export const openRandomDocument = createAction({
|
||||
const documentPaths = stores.collections.pathsToDocuments.filter(
|
||||
(path) => path.type === "document" && path.id !== activeDocumentId
|
||||
);
|
||||
const documentPath =
|
||||
const randomPath =
|
||||
documentPaths[Math.round(Math.random() * documentPaths.length)];
|
||||
|
||||
if (documentPath) {
|
||||
history.push(documentPath.url);
|
||||
if (randomPath) {
|
||||
history.push(randomPath.url);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -738,11 +746,50 @@ export const searchDocumentsForQuery = (searchQuery: string) =>
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
export const moveDocument = createAction({
|
||||
name: ({ t }) => t("Move"),
|
||||
export const moveTemplateToWorkspace = createAction({
|
||||
name: ({ t }) => t("Move to workspace"),
|
||||
analyticsName: "Move template to workspace",
|
||||
section: DocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document || !document.template || document.isWorkspaceTemplate) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).move;
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
if (activeDocumentId) {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
await document.move({
|
||||
collectionId: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const moveDocumentToCollection = createAction({
|
||||
name: ({ activeDocumentId, stores, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return t("Move");
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return document?.template && document?.collectionId
|
||||
? t("Move to collection")
|
||||
: t("Move");
|
||||
},
|
||||
analyticsName: "Move document",
|
||||
section: DocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
@@ -766,8 +813,46 @@ export const moveDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const moveDocument = createAction({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: DocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
// Don't show the button if this is a non-workspace template.
|
||||
if (!document || (document.template && !document.isWorkspaceTemplate)) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).move;
|
||||
},
|
||||
perform: moveDocumentToCollection.perform,
|
||||
});
|
||||
|
||||
export const moveTemplate = createAction({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: DocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
// Don't show the menu if this is not a template (or) a workspace template.
|
||||
if (!document || !document.template || document.isWorkspaceTemplate) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).move;
|
||||
},
|
||||
children: [moveTemplateToWorkspace, moveDocumentToCollection],
|
||||
});
|
||||
|
||||
export const archiveDocument = createAction({
|
||||
name: ({ t }) => t("Archive"),
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive document",
|
||||
section: DocumentSection,
|
||||
icon: <ArchiveIcon />,
|
||||
@@ -778,14 +863,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>
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -987,7 +1088,7 @@ export const rootDocumentActions = [
|
||||
openDocument,
|
||||
archiveDocument,
|
||||
createDocument,
|
||||
createTemplate,
|
||||
createTemplateFromDocument,
|
||||
deleteDocument,
|
||||
importDocument,
|
||||
downloadDocument,
|
||||
@@ -1000,7 +1101,8 @@ export const rootDocumentActions = [
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
duplicateDocument,
|
||||
moveDocument,
|
||||
moveTemplateToWorkspace,
|
||||
moveDocumentToCollection,
|
||||
openRandomDocument,
|
||||
permanentlyDeleteDocument,
|
||||
permanentlyDeleteDocumentsInTrash,
|
||||
|
||||
@@ -216,7 +216,9 @@ export const logout = createAction({
|
||||
perform: async () => {
|
||||
await stores.auth.logout();
|
||||
if (env.OIDC_LOGOUT_URI) {
|
||||
window.location.replace(env.OIDC_LOGOUT_URI);
|
||||
setTimeout(() => {
|
||||
window.location.replace(env.OIDC_LOGOUT_URI);
|
||||
}, 200);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ export const restoreRevision = createAction({
|
||||
analyticsName: "Restore revision",
|
||||
icon: <RestoreIcon />,
|
||||
section: RevisionSection,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
visible: ({ activeDocumentId }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
|
||||
perform: async ({ event, location, activeDocumentId }) => {
|
||||
event?.preventDefault();
|
||||
@@ -47,7 +47,7 @@ export const copyLinkToRevision = createAction({
|
||||
analyticsName: "Copy link to revision",
|
||||
icon: <LinkIcon />,
|
||||
section: RevisionSection,
|
||||
perform: async ({ activeDocumentId, stores, t }) => {
|
||||
perform: async ({ activeDocumentId, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export const inviteUser = createAction({
|
||||
icon: <PlusIcon />,
|
||||
keywords: "team member workspace user",
|
||||
section: UserSection,
|
||||
visible: ({ stores }) =>
|
||||
visible: () =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
|
||||
perform: ({ t }) => {
|
||||
stores.dialogs.openModal({
|
||||
@@ -40,7 +40,7 @@ export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
|
||||
})}…`,
|
||||
analyticsName: "Update user role",
|
||||
section: UserSection,
|
||||
visible: ({ stores }) => {
|
||||
visible: () => {
|
||||
const can = stores.policies.abilities(user.id);
|
||||
|
||||
return UserRoleHelper.isRoleHigher(role, user.role)
|
||||
@@ -70,7 +70,7 @@ export const deleteUserActionFactory = (userId: string) =>
|
||||
keywords: "leave",
|
||||
dangerous: true,
|
||||
section: UserSection,
|
||||
visible: ({ stores }) => stores.policies.abilities(userId).delete,
|
||||
visible: () => stores.policies.abilities(userId).delete,
|
||||
perform: ({ t }) => {
|
||||
const user = stores.users.get(userId);
|
||||
if (!user) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Avatar from "./Avatar";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import Group from "~/models/Group";
|
||||
import { AvatarSize } from "../Avatar/Avatar";
|
||||
|
||||
type Props = {
|
||||
/** The group to show an avatar for */
|
||||
group: Group;
|
||||
/** The size of the icon, 24px is default to match standard avatars */
|
||||
size?: number;
|
||||
/** The color of the avatar */
|
||||
color?: string;
|
||||
/** The background color of the avatar */
|
||||
backgroundColor?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function GroupAvatar({
|
||||
color,
|
||||
backgroundColor,
|
||||
size = AvatarSize.Medium,
|
||||
className,
|
||||
}: Props) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Squircle color={color ?? theme.text} size={size} className={className}>
|
||||
<GroupIcon
|
||||
color={backgroundColor ?? theme.background}
|
||||
size={size * 0.75}
|
||||
/>
|
||||
</Squircle>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
const Initials = styled(Flex)<{
|
||||
@@ -11,7 +12,7 @@ const Initials = styled(Flex)<{
|
||||
border-radius: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #fff;
|
||||
color: ${s("white75")};
|
||||
background-color: ${(props) => props.color};
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Avatar from "./Avatar";
|
||||
import Avatar, { IAvatar, AvatarSize } from "./Avatar";
|
||||
import AvatarWithPresence from "./AvatarWithPresence";
|
||||
import { GroupAvatar } from "./GroupAvatar";
|
||||
|
||||
export { AvatarWithPresence };
|
||||
export { Avatar, GroupAvatar, AvatarSize, AvatarWithPresence };
|
||||
|
||||
export default Avatar;
|
||||
export type { IAvatar };
|
||||
|
||||
@@ -25,7 +25,7 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
background: ${s("accent")};
|
||||
color: ${s("accentText")};
|
||||
box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 2px;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
height: 32px;
|
||||
@@ -105,7 +105,7 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
background: ${lighten(0.05, props.theme.danger)};
|
||||
}
|
||||
|
||||
&.focus-visible {
|
||||
&:focus-visible {
|
||||
outline-color: ${darken(0.2, props.theme.danger)} !important;
|
||||
}
|
||||
`};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import filter from "lodash/filter";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import uniq from "lodash/uniq";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import Document from "~/models/Document";
|
||||
import AvatarWithPresence from "~/components/Avatar/AvatarWithPresence";
|
||||
import { AvatarWithPresence } from "~/components/Avatar";
|
||||
import DocumentViews from "~/components/DocumentViews";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -16,9 +16,14 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
/** The document to display live collaborators for */
|
||||
document: Document;
|
||||
};
|
||||
|
||||
/**
|
||||
* Displays a list of live collaborators for a document, including their avatars
|
||||
* and presence status.
|
||||
*/
|
||||
function Collaborators(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
@@ -39,15 +44,16 @@ function Collaborators(props: Props) {
|
||||
// ensure currently present via websocket are always ordered first
|
||||
const collaborators = React.useMemo(
|
||||
() =>
|
||||
sortBy(
|
||||
orderBy(
|
||||
filter(
|
||||
users.orderedData,
|
||||
(user) =>
|
||||
(presentIds.includes(user.id) ||
|
||||
document.collaboratorIds.includes(user.id)) &&
|
||||
!user.isSuspended
|
||||
(u) =>
|
||||
(presentIds.includes(u.id) ||
|
||||
document.collaboratorIds.includes(u.id)) &&
|
||||
!u.isSuspended
|
||||
),
|
||||
(user) => presentIds.includes(user.id)
|
||||
[(u) => presentIds.includes(u.id), "id"],
|
||||
["asc", "asc"]
|
||||
),
|
||||
[document.collaboratorIds, users.orderedData, presentIds]
|
||||
);
|
||||
@@ -69,12 +75,19 @@ function Collaborators(props: Props) {
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
const limit = 8;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<NudeButton width={collaborators.length * 32} height={32} {...props}>
|
||||
{(popoverProps) => (
|
||||
<NudeButton
|
||||
width={Math.min(collaborators.length, limit) * 32}
|
||||
height={32}
|
||||
{...popoverProps}
|
||||
>
|
||||
<Facepile
|
||||
limit={limit}
|
||||
users={collaborators}
|
||||
renderAvatar={(collaborator) => {
|
||||
const isPresent = presentIds.includes(collaborator.id);
|
||||
|
||||
@@ -18,6 +18,7 @@ import Switch from "~/components/Switch";
|
||||
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"));
|
||||
@@ -142,8 +143,10 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
<InputSelectPermission
|
||||
ref={field.ref}
|
||||
value={field.value}
|
||||
onChange={(value: CollectionPermission) => {
|
||||
field.onChange(value);
|
||||
onChange={(
|
||||
value: CollectionPermission | typeof EmptySelectValue
|
||||
) => {
|
||||
field.onChange(value === EmptySelectValue ? null : value);
|
||||
}}
|
||||
note={t(
|
||||
"The default access for workspace members, you can share with more users or groups later."
|
||||
|
||||
@@ -69,8 +69,8 @@ function CommandBarItem(
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{sc.split("+").map((s) => (
|
||||
<Key key={s}>{s}</Key>
|
||||
{sc.split("+").map((key) => (
|
||||
<Key key={key}>{key}</Key>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
@@ -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 = {
|
||||
@@ -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,12 +6,13 @@ 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";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
onClick?: (event: React.SyntheticEvent) => void | Promise<void>;
|
||||
onClick?: (event: React.MouseEvent) => void | Promise<void>;
|
||||
active?: boolean;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
@@ -43,39 +44,40 @@ const MenuItem = (
|
||||
) => {
|
||||
const content = React.useCallback(
|
||||
(props) => {
|
||||
// Preventing default mousedown otherwise menu items do not work in Firefox,
|
||||
// which triggers the hideOnClickOutside handler first via mousedown – hiding
|
||||
// and un-rendering the menu contents.
|
||||
const preventDefault = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
const handleClick = async (ev: React.MouseEvent) => {
|
||||
hide?.();
|
||||
|
||||
if (onClick) {
|
||||
ev.preventDefault();
|
||||
preventDefault(ev);
|
||||
await onClick(ev);
|
||||
}
|
||||
};
|
||||
|
||||
// Preventing default mousedown otherwise menu items do not work in Firefox,
|
||||
// which triggers the hideOnClickOutside handler first via mousedown – hiding
|
||||
// and un-rendering the menu contents.
|
||||
const handleMouseDown = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<MenuAnchor
|
||||
{...props}
|
||||
$active={active}
|
||||
as={onClick ? "button" : as}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onPointerDown={preventDefault}
|
||||
onMouseDown={preventDefault}
|
||||
ref={mergeRefs([
|
||||
ref,
|
||||
props.ref as React.RefObject<HTMLAnchorElement>,
|
||||
])}
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<MenuIconWrapper aria-hidden>
|
||||
<SelectedWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon /> : <Spacer />}
|
||||
</MenuIconWrapper>
|
||||
</SelectedWrapper>
|
||||
)}
|
||||
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
|
||||
<Title>{children}</Title>
|
||||
@@ -151,7 +153,7 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.focus-visible {
|
||||
&:focus-visible {
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
|
||||
box-shadow: none;
|
||||
@@ -195,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);
|
||||
|
||||
@@ -30,6 +30,7 @@ type Props = Omit<MenuStateReturn, "items"> & {
|
||||
actions?: (Action | MenuSeparator | MenuHeading)[];
|
||||
context?: Partial<ActionContext>;
|
||||
items?: TMenuItem[];
|
||||
showIcons?: boolean;
|
||||
};
|
||||
|
||||
const Disclosure = styled(ExpandedIcon)`
|
||||
@@ -98,7 +99,7 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
});
|
||||
}
|
||||
|
||||
function Template({ items, actions, context, ...menu }: Props) {
|
||||
function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
const ctx = useActionContext({
|
||||
isContextMenu: true,
|
||||
});
|
||||
@@ -124,7 +125,8 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
if (
|
||||
iconIsPresentInAnyMenuItem &&
|
||||
item.type !== "separator" &&
|
||||
item.type !== "heading"
|
||||
item.type !== "heading" &&
|
||||
showIcons !== false
|
||||
) {
|
||||
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
|
||||
}
|
||||
@@ -138,7 +140,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
icon={item.icon}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
@@ -156,7 +158,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
selected={item.selected}
|
||||
level={item.level}
|
||||
target={item.href.startsWith("#") ? undefined : "_blank"}
|
||||
icon={item.icon}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
@@ -174,7 +176,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
selected={item.selected}
|
||||
dangerous={item.dangerous}
|
||||
key={index}
|
||||
icon={item.icon}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
@@ -190,7 +192,12 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
id={`${item.title}-${index}`}
|
||||
templateItems={item.items}
|
||||
parentMenuState={menu}
|
||||
title={<Title title={item.title} icon={item.icon} />}
|
||||
title={
|
||||
<Title
|
||||
title={item.title}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
/>
|
||||
}
|
||||
{...menu}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import styled, { DefaultTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMenuContext from "~/hooks/useMenuContext";
|
||||
import useMenuHeight from "~/hooks/useMenuHeight";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
@@ -50,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;
|
||||
};
|
||||
|
||||
@@ -134,6 +137,7 @@ type InnerContextMenuProps = MenuStateReturn & {
|
||||
menuProps: { style?: React.CSSProperties; placement: string };
|
||||
children: React.ReactNode;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -171,6 +175,32 @@ const InnerContextMenu = (props: InnerContextMenuProps) => {
|
||||
};
|
||||
}, [props.isSubMenu, props.visible]);
|
||||
|
||||
useEventListener(
|
||||
"animationstart",
|
||||
(event) => {
|
||||
if (event.target instanceof HTMLElement) {
|
||||
const parent = event.target.parentElement;
|
||||
if (parent) {
|
||||
parent.style.pointerEvents = "none";
|
||||
}
|
||||
}
|
||||
},
|
||||
backgroundRef.current
|
||||
);
|
||||
|
||||
useEventListener(
|
||||
"animationend",
|
||||
(event) => {
|
||||
if (event.target instanceof HTMLElement) {
|
||||
const parent = event.target.parentElement;
|
||||
if (parent) {
|
||||
parent.style.pointerEvents = "auto";
|
||||
}
|
||||
}
|
||||
},
|
||||
backgroundRef.current
|
||||
);
|
||||
|
||||
const style =
|
||||
topAnchor && !isMobile
|
||||
? {
|
||||
@@ -193,6 +223,7 @@ const InnerContextMenu = (props: InnerContextMenuProps) => {
|
||||
<Background
|
||||
dir="auto"
|
||||
maxWidth={props.maxWidth}
|
||||
minHeight={props.minHeight}
|
||||
topAnchor={topAnchor}
|
||||
rightAnchor={rightAnchor}
|
||||
ref={backgroundRef}
|
||||
@@ -223,10 +254,30 @@ export const Position = styled.div`
|
||||
position: absolute;
|
||||
z-index: ${depths.menu};
|
||||
|
||||
&.focus-visible {
|
||||
// Note: pointer events are re-enabled after the animation ends, see event listeners above
|
||||
pointer-events: none;
|
||||
|
||||
&:focus-visible {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -247,6 +298,7 @@ type BackgroundProps = {
|
||||
topAnchor?: boolean;
|
||||
rightAnchor?: boolean;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
theme: DefaultTheme;
|
||||
};
|
||||
|
||||
@@ -258,9 +310,8 @@ 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;
|
||||
pointer-events: all;
|
||||
font-weight: normal;
|
||||
|
||||
@media print {
|
||||
|
||||
@@ -25,6 +25,7 @@ function Dialogs() {
|
||||
fullscreen={modal.fullscreen ?? false}
|
||||
onRequestClose={() => dialogs.closeModal(id)}
|
||||
title={modal.title}
|
||||
style={modal.style}
|
||||
>
|
||||
{modal.content}
|
||||
</Modal>
|
||||
|
||||
@@ -8,6 +8,7 @@ import Document from "~/models/Document";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import Icon from "~/components/Icon";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import {
|
||||
@@ -67,14 +68,15 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const can = usePolicy(collection);
|
||||
|
||||
React.useEffect(() => {
|
||||
void document.loadRelations();
|
||||
void document.loadRelations({ withoutPolicies: true });
|
||||
}, [document]);
|
||||
|
||||
let collectionNode: MenuInternalLink | undefined;
|
||||
|
||||
if (collection) {
|
||||
if (collection && can.readDocument) {
|
||||
collectionNode = {
|
||||
type: "route",
|
||||
title: collection.name,
|
||||
|
||||
@@ -178,12 +178,11 @@ const DocumentSquircle = ({
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const iconType = determineIconType(icon)!;
|
||||
const squircleColor =
|
||||
iconType === IconType.Outline ? color : theme.slateLight;
|
||||
const squircleColor = iconType === IconType.SVG ? color : theme.slateLight;
|
||||
|
||||
return (
|
||||
<Squircle color={squircleColor}>
|
||||
<Icon value={icon} color={theme.white} />
|
||||
<Icon value={icon} color={theme.white} forceColor />
|
||||
</Squircle>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -230,7 +230,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
title = node.title;
|
||||
} else {
|
||||
const doc = documents.get(node.id);
|
||||
icon = doc?.icon ?? node.icon;
|
||||
icon = doc?.icon ?? node.icon ?? node.emoji;
|
||||
color = doc?.color ?? node.color;
|
||||
title = doc?.title ?? node.title;
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ export const Node = styled.span<{
|
||||
color: ${props.theme.white};
|
||||
|
||||
svg {
|
||||
color: ${props.theme.white};
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -76,8 +76,7 @@ function DocumentListItem(
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar =
|
||||
!document.isDraft && !document.isArchived && !document.isTemplate;
|
||||
const canStar = !document.isArchived && !document.isTemplate;
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
@@ -111,11 +110,6 @@ function DocumentListItem(
|
||||
{document.isBadgedNew && document.createdBy?.id !== user.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip
|
||||
content={t("Only visible to you")}
|
||||
@@ -125,6 +119,11 @@ function DocumentListItem(
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
@@ -275,6 +274,8 @@ const ResultContext = styled(Highlight)`
|
||||
font-size: 15px;
|
||||
margin-top: -0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
max-height: 90px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export default observer(React.forwardRef(DocumentListItem));
|
||||
|
||||
@@ -128,15 +128,6 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
<Time dateTime={publishedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (isDraft) {
|
||||
content = (
|
||||
<span>
|
||||
{lastUpdatedByCurrentUser
|
||||
? t("You saved")
|
||||
: t("{{ userName }} saved", { userName })}{" "}
|
||||
<Time dateTime={updatedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<Modified highlight={modifiedSinceViewed && !lastUpdatedByCurrentUser}>
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import invariant from "invariant";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
};
|
||||
|
||||
function DocumentTemplatizeDialog({ documentId }: Props) {
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const document = documents.get(documentId);
|
||||
invariant(document, "Document must exist");
|
||||
|
||||
const handleSubmit = React.useCallback(async () => {
|
||||
const template = await document?.templatize();
|
||||
if (template) {
|
||||
history.push(documentPath(template));
|
||||
toast.success(t("Template created, go ahead and customize it"));
|
||||
}
|
||||
}, [document, history, t]);
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("Create template")}
|
||||
savingText={`${t("Creating")}…`}
|
||||
>
|
||||
<Trans
|
||||
defaults="Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents."
|
||||
values={{
|
||||
titleWithDefault: document.titleWithDefault,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(DocumentTemplatizeDialog);
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
|
||||
@@ -70,14 +70,14 @@ function DuplicateDialog({ document, onSubmit }: Props) {
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="publish"
|
||||
label={t("Published")}
|
||||
label={t("Publish")}
|
||||
labelPosition="right"
|
||||
checked={publish}
|
||||
onChange={handlePublishChange}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{document.publishedAt && (
|
||||
{document.publishedAt && document.childDocuments.length > 0 && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="recursive"
|
||||
|
||||
@@ -28,6 +28,7 @@ import { NotFoundError } from "~/utils/errors";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import DocumentBreadcrumb from "./DocumentBreadcrumb";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
|
||||
|
||||
@@ -89,6 +90,12 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
title: document.title,
|
||||
subtitle: `Updated ${time}`,
|
||||
url: document.url,
|
||||
icon: document.icon ? (
|
||||
<Icon
|
||||
value={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
/>
|
||||
) : undefined,
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
@@ -107,6 +114,9 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
title: document.title,
|
||||
subtitle: <DocumentBreadcrumb document={document} onlyText />,
|
||||
url: document.url,
|
||||
icon: document.icon ? (
|
||||
<Icon value={document.icon} color={document.color ?? undefined} />
|
||||
) : undefined,
|
||||
})),
|
||||
(document) =>
|
||||
deburr(document.title)
|
||||
|
||||
@@ -12,10 +12,11 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { s } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Event from "~/models/Event";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -158,7 +159,9 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
}
|
||||
actions={
|
||||
isRevision && isActive && event.modelId && !latest ? (
|
||||
<RevisionMenu document={document} revisionId={event.modelId} />
|
||||
<StyledEventBoundary>
|
||||
<RevisionMenu document={document} revisionId={event.modelId} />
|
||||
</StyledEventBoundary>
|
||||
) : undefined
|
||||
}
|
||||
onMouseEnter={prefetchRevision}
|
||||
@@ -175,6 +178,10 @@ const BaseItem = React.forwardRef(function _BaseItem(
|
||||
return <ListItem to={to} ref={ref} {...rest} />;
|
||||
});
|
||||
|
||||
const StyledEventBoundary = styled(EventBoundary)`
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.span`
|
||||
svg {
|
||||
margin: -3px;
|
||||
|
||||
@@ -3,9 +3,8 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { AvatarSize } from "./Avatar/Avatar";
|
||||
|
||||
type Props = {
|
||||
users: User[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,36 +5,31 @@ import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
|
||||
import { s } from "@shared/styles";
|
||||
import CollectionGroupMembership from "~/models/CollectionGroupMembership";
|
||||
import Group from "~/models/Group";
|
||||
import GroupMembership from "~/models/GroupMembership";
|
||||
import GroupMembers from "~/scenes/GroupMembers";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Flex from "~/components/Flex";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Modal from "~/components/Modal";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
import NudeButton from "./NudeButton";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
membership?: CollectionGroupMembership;
|
||||
membership?: GroupMembership;
|
||||
showFacepile?: boolean;
|
||||
showAvatar?: boolean;
|
||||
renderActions: (params: { openMembersModal: () => void }) => React.ReactNode;
|
||||
};
|
||||
|
||||
function GroupListItem({ group, showFacepile, renderActions }: Props) {
|
||||
const { groupMemberships } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [membersModalOpen, setMembersModalOpen, setMembersModalClosed] =
|
||||
useBoolean();
|
||||
const memberCount = group.memberCount;
|
||||
const membershipsInGroup = groupMemberships.inGroup(group.id);
|
||||
const users = membershipsInGroup
|
||||
.slice(0, MAX_AVATAR_DISPLAY)
|
||||
.map((gm) => gm.user);
|
||||
const users = group.users.slice(0, MAX_AVATAR_DISPLAY);
|
||||
const overflow = memberCount - users.length;
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import escapeRegExp from "lodash/escapeRegExp";
|
||||
import * as React from "react";
|
||||
import replace from "string-replace-to-array";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
type Props = React.HTMLAttributes<HTMLSpanElement> & {
|
||||
highlight: (string | null | undefined) | RegExp;
|
||||
@@ -43,7 +44,7 @@ function Highlight({
|
||||
}
|
||||
|
||||
export const Mark = styled.mark`
|
||||
color: inherit;
|
||||
color: ${s("text")};
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import * as React from "react";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import Editor from "~/components/Editor";
|
||||
import Flex from "~/components/Flex";
|
||||
import ErrorBoundary from "../ErrorBoundary";
|
||||
import {
|
||||
Preview,
|
||||
Title,
|
||||
@@ -21,20 +23,23 @@ const HoverPreviewDocument = React.forwardRef(function _HoverPreviewDocument(
|
||||
<Preview to={url}>
|
||||
<Card ref={ref}>
|
||||
<CardContent>
|
||||
<Flex column gap={2}>
|
||||
<Title>{title}</Title>
|
||||
<Info>{lastActivityByViewer}</Info>
|
||||
<Description as="div">
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
key={id}
|
||||
defaultValue={summary}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Description>
|
||||
</Flex>
|
||||
<ErrorBoundary showTitle={false} reloadOnChunkMissing={false}>
|
||||
<Flex column gap={2}>
|
||||
<Title>{title}</Title>
|
||||
<Info>{lastActivityByViewer}</Info>
|
||||
<Description as="div">
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
key={id}
|
||||
extensions={richExtensions}
|
||||
defaultValue={summary}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Description>
|
||||
</Flex>
|
||||
</ErrorBoundary>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Preview>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import Avatar from "../Avatar";
|
||||
import { IssueStatusIcon } from "../Icons/IssueStatusIcon";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { AvatarSize } from "~/components/Avatar/Avatar";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { Preview, Title, Info, Card, CardContent } from "./Components";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import Avatar from "../Avatar";
|
||||
import { PullRequestIcon } from "../Icons/PullRequestIcon";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
|
||||
+68
-32
@@ -1,6 +1,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { getLuminance } from "polished";
|
||||
import * as React from "react";
|
||||
import { randomElement } from "@shared/random";
|
||||
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";
|
||||
@@ -8,13 +10,24 @@ import { determineIconType } from "@shared/utils/icon";
|
||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Logger from "~/utils/Logger";
|
||||
import Flex from "./Flex";
|
||||
|
||||
type IconProps = {
|
||||
export type Props = {
|
||||
/** The icon to render */
|
||||
value: string;
|
||||
/** The color of the icon */
|
||||
color?: string;
|
||||
/** The size of the icon */
|
||||
size?: number;
|
||||
/** The initial to display if the icon is a letter icon */
|
||||
initial?: string;
|
||||
/** Optional additional class name */
|
||||
className?: string;
|
||||
/**
|
||||
* Ensure the color does not change in response to theme and contrast. Should only be
|
||||
* used in color picker UI.
|
||||
*/
|
||||
forceColor?: boolean;
|
||||
};
|
||||
|
||||
const Icon = ({
|
||||
@@ -22,8 +35,9 @@ const Icon = ({
|
||||
color,
|
||||
size = 24,
|
||||
initial,
|
||||
forceColor,
|
||||
className,
|
||||
}: IconProps) => {
|
||||
}: Props) => {
|
||||
const iconType = determineIconType(icon);
|
||||
|
||||
if (!iconType) {
|
||||
@@ -34,14 +48,15 @@ const Icon = ({
|
||||
}
|
||||
|
||||
try {
|
||||
if (iconType === IconType.Outline) {
|
||||
if (iconType === IconType.SVG) {
|
||||
return (
|
||||
<OutlineIcon
|
||||
<SVGIcon
|
||||
value={icon}
|
||||
color={color}
|
||||
size={size}
|
||||
initial={initial}
|
||||
className={className}
|
||||
forceColor={forceColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -56,38 +71,59 @@ const Icon = ({
|
||||
return null;
|
||||
};
|
||||
|
||||
type OutlineIconProps = {
|
||||
value: string;
|
||||
color?: string;
|
||||
size?: number;
|
||||
initial?: string;
|
||||
className?: string;
|
||||
};
|
||||
const SVGIcon = observer(
|
||||
({
|
||||
value: icon,
|
||||
color: inputColor,
|
||||
initial,
|
||||
size,
|
||||
className,
|
||||
forceColor,
|
||||
}: Props) => {
|
||||
const { ui } = useStores();
|
||||
|
||||
const OutlineIcon = ({
|
||||
value: icon,
|
||||
color: inputColor,
|
||||
initial,
|
||||
size,
|
||||
className,
|
||||
}: OutlineIconProps) => {
|
||||
const { ui } = useStores();
|
||||
let color = inputColor ?? colorPalette[0];
|
||||
|
||||
let color = inputColor ?? randomElement(colorPalette);
|
||||
// If the chosen icon color is very dark then we invert it in dark mode
|
||||
if (!forceColor) {
|
||||
if (ui.resolvedTheme === "dark" && color !== "currentColor") {
|
||||
color = getLuminance(color) > 0.09 ? color : "currentColor";
|
||||
}
|
||||
|
||||
// If the chosen icon color is very dark then we invert it in dark mode
|
||||
// otherwise it will be impossible to see against the dark background.
|
||||
if (!inputColor && ui.resolvedTheme === "dark" && color !== "currentColor") {
|
||||
color = getLuminance(color) > 0.09 ? color : "currentColor";
|
||||
// If the chosen icon color is very light then we invert it in light mode
|
||||
if (ui.resolvedTheme === "light" && color !== "currentColor") {
|
||||
color = getLuminance(color) < 0.9 ? color : "currentColor";
|
||||
}
|
||||
}
|
||||
|
||||
const Component = IconLibrary.getComponent(icon);
|
||||
|
||||
return (
|
||||
<Component color={color} size={size} className={className}>
|
||||
{initial}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const Component = IconLibrary.getComponent(icon);
|
||||
export const IconTitleWrapper = styled(Flex)<{ dir?: string }>`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
|
||||
return (
|
||||
<Component color={color} size={size} className={className}>
|
||||
{initial}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
// Always move above TOC
|
||||
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"};
|
||||
`}
|
||||
`;
|
||||
|
||||
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 />
|
||||
@@ -149,29 +149,29 @@ const Container = styled(Flex)`
|
||||
`;
|
||||
|
||||
const Selected = styled.span`
|
||||
width: 8px;
|
||||
height: 4px;
|
||||
border-left: 1px solid white;
|
||||
border-bottom: 1px solid white;
|
||||
width: 10px;
|
||||
height: 5px;
|
||||
border-left: 2px solid white;
|
||||
border-bottom: 2px solid white;
|
||||
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")};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ const Row = ({ index, style, data }: ListChildComponentProps<RowProps>) => {
|
||||
|
||||
const Container = styled(FixedSizeList<RowProps>)`
|
||||
padding: 0px 12px;
|
||||
overflow-x: hidden !important;
|
||||
|
||||
// Needed for the absolutely positioned children
|
||||
// to respect the VirtualList's padding
|
||||
|
||||
@@ -16,7 +16,7 @@ import { IconButton } from "./IconButton";
|
||||
const BUTTON_SIZE = 32;
|
||||
|
||||
type OutlineNode = {
|
||||
type: IconType.Outline;
|
||||
type: IconType.SVG;
|
||||
name: string;
|
||||
color: string;
|
||||
initial: string;
|
||||
@@ -66,7 +66,7 @@ const GridTemplate = (
|
||||
);
|
||||
|
||||
const items = node.icons.map((item) => {
|
||||
if (item.type === IconType.Outline) {
|
||||
if (item.type === IconType.SVG) {
|
||||
return (
|
||||
<IconButton
|
||||
key={item.name}
|
||||
|
||||
@@ -129,7 +129,7 @@ const IconPanel = ({
|
||||
const baseIcons: DataNode = {
|
||||
category,
|
||||
icons: filteredIcons.map((name, index) => ({
|
||||
type: IconType.Outline,
|
||||
type: IconType.SVG,
|
||||
name,
|
||||
color,
|
||||
initial,
|
||||
@@ -144,7 +144,7 @@ const IconPanel = ({
|
||||
{
|
||||
category: DisplayCategory.Frequent,
|
||||
icons: freqIcons.map((name, index) => ({
|
||||
type: IconType.Outline,
|
||||
type: IconType.SVG,
|
||||
name,
|
||||
color,
|
||||
initial,
|
||||
|
||||
@@ -20,6 +20,7 @@ import NudeButton from "~/components/NudeButton";
|
||||
import Popover from "~/components/Popover";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
import { hover } from "~/styles";
|
||||
import EmojiPanel from "./components/EmojiPanel";
|
||||
@@ -82,6 +83,7 @@ const IconPicker = ({
|
||||
unstable_offset: [0, 0],
|
||||
});
|
||||
const tab = useTabState({ selectedId: defaultTab });
|
||||
const previouslyVisible = usePrevious(popover.visible);
|
||||
|
||||
const popoverWidth = isMobile ? windowWidth : POPOVER_WIDTH;
|
||||
// In mobile, popover is absolutely positioned to leave 8px on both sides.
|
||||
@@ -96,7 +98,7 @@ const IconPicker = ({
|
||||
(ic: string) => {
|
||||
popover.hide();
|
||||
const icType = determineIconType(ic);
|
||||
const finalColor = icType === IconType.Outline ? chosenColor : null;
|
||||
const finalColor = icType === IconType.SVG ? chosenColor : null;
|
||||
onChange(ic, finalColor);
|
||||
},
|
||||
[popover, onChange, chosenColor]
|
||||
@@ -108,7 +110,7 @@ const IconPicker = ({
|
||||
|
||||
const icType = determineIconType(icon);
|
||||
// Outline icon set; propagate color change
|
||||
if (icType === IconType.Outline) {
|
||||
if (icType === IconType.SVG) {
|
||||
onChange(icon, c);
|
||||
}
|
||||
},
|
||||
@@ -120,11 +122,6 @@ const IconPicker = ({
|
||||
onChange(null, null);
|
||||
}, [popover, onChange]);
|
||||
|
||||
const handleQueryChange = React.useCallback(
|
||||
(q: string) => setQuery(q),
|
||||
[setQuery]
|
||||
);
|
||||
|
||||
const handlePopoverButtonClick = React.useCallback(
|
||||
(ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
@@ -139,14 +136,14 @@ const IconPicker = ({
|
||||
|
||||
// Popover open effect
|
||||
React.useEffect(() => {
|
||||
if (popover.visible) {
|
||||
if (popover.visible && !previouslyVisible) {
|
||||
onOpen?.();
|
||||
} else {
|
||||
} else if (!popover.visible && previouslyVisible) {
|
||||
onClose?.();
|
||||
setQuery("");
|
||||
resetDefaultTab();
|
||||
}
|
||||
}, [popover.visible, onOpen, onClose, setQuery, resetDefaultTab]);
|
||||
}, [popover.visible, previouslyVisible, onOpen, onClose, resetDefaultTab]);
|
||||
|
||||
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
|
||||
// prevent event bubbling.
|
||||
@@ -180,7 +177,7 @@ const IconPicker = ({
|
||||
{iconType && icon ? (
|
||||
<Icon value={icon} color={color} size={size} initial={initial} />
|
||||
) : (
|
||||
<StyledSmileyIcon color={theme.textTertiary} size={size} />
|
||||
<StyledSmileyIcon color={theme.placeholder} size={size} />
|
||||
)}
|
||||
</PopoverButton>
|
||||
)}
|
||||
@@ -231,7 +228,7 @@ const IconPicker = ({
|
||||
}
|
||||
onIconChange={handleIconChange}
|
||||
onColorChange={handleIconColorChange}
|
||||
onQueryChange={handleQueryChange}
|
||||
onQueryChange={setQuery}
|
||||
/>
|
||||
</StyledTabPanel>
|
||||
<StyledTabPanel {...tab}>
|
||||
@@ -242,7 +239,7 @@ const IconPicker = ({
|
||||
popover.visible && tab.selectedId === TAB_NAMES["Emoji"]
|
||||
}
|
||||
onEmojiChange={handleIconChange}
|
||||
onQueryChange={handleQueryChange}
|
||||
onQueryChange={setQuery}
|
||||
/>
|
||||
</StyledTabPanel>
|
||||
</>
|
||||
@@ -312,4 +309,4 @@ const StyledTabPanel = styled(TabPanel)`
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
export default IconPicker;
|
||||
export default React.memo(IconPicker);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CollectionIcon } from "outline-icons";
|
||||
import { CollectionIcon, PrivateCollectionIcon } from "outline-icons";
|
||||
import { getLuminance } from "polished";
|
||||
import * as React from "react";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
@@ -40,8 +40,11 @@ function ResolvedCollectionIcon({
|
||||
: "currentColor"
|
||||
: collectionColor);
|
||||
|
||||
const Component = collection.isPrivate
|
||||
? PrivateCollectionIcon
|
||||
: CollectionIcon;
|
||||
return (
|
||||
<CollectionIcon
|
||||
<Component
|
||||
color={color}
|
||||
expanded={expanded}
|
||||
size={size}
|
||||
@@ -57,6 +60,7 @@ function ResolvedCollectionIcon({
|
||||
size={size}
|
||||
initial={collection.initial}
|
||||
className={className}
|
||||
forceColor={inputColor ? true : false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,13 +11,21 @@ import { searchPath } from "~/utils/routeHelpers";
|
||||
import Input, { Outline } from "./Input";
|
||||
|
||||
type Props = {
|
||||
/** A string representing where the search started, for tracking. */
|
||||
source: string;
|
||||
/** Placeholder text for the input. */
|
||||
placeholder?: string;
|
||||
/** Label for the input. */
|
||||
label?: string;
|
||||
/** Whether the label should be hidden. */
|
||||
labelHidden?: boolean;
|
||||
/** An optional ID of a collection to search within. */
|
||||
collectionId?: string;
|
||||
/** The current value of the input. */
|
||||
value?: string;
|
||||
/** Event handler for when the input value changes. */
|
||||
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => unknown;
|
||||
/** Event handler for when a key is pressed. */
|
||||
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => unknown;
|
||||
};
|
||||
|
||||
|
||||
@@ -352,7 +352,9 @@ const Wrapper = styled.label<{ short?: boolean }>`
|
||||
`;
|
||||
|
||||
export const Positioner = styled(Position)`
|
||||
&.focus-visible {
|
||||
pointer-events: all;
|
||||
|
||||
&:focus-visible {
|
||||
${StyledSelectOption} {
|
||||
&[aria-selected="true"] {
|
||||
color: ${(props) => props.theme.white};
|
||||
|
||||
@@ -41,7 +41,6 @@ const Layout = React.forwardRef(function Layout_(
|
||||
<Container column auto ref={ref}>
|
||||
<Helmet>
|
||||
<title>{title ? title : env.APP_NAME}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</Helmet>
|
||||
|
||||
<SkipNavLink />
|
||||
|
||||
@@ -4,6 +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 styled, { useTheme } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -47,22 +48,33 @@ const ListItem = (
|
||||
keyboardNavigation,
|
||||
...rest
|
||||
}: Props,
|
||||
ref?: React.Ref<HTMLAnchorElement>
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
const compact = !subtitle;
|
||||
|
||||
let itemRef: React.Ref<HTMLAnchorElement> =
|
||||
let itemRef: React.RefObject<HTMLAnchorElement> =
|
||||
React.useRef<HTMLAnchorElement>(null);
|
||||
if (ref) {
|
||||
itemRef = ref;
|
||||
}
|
||||
|
||||
const { focused, ...rovingTabIndex } = useRovingTabIndex(
|
||||
itemRef as React.RefObject<HTMLAnchorElement>,
|
||||
itemRef,
|
||||
keyboardNavigation || to ? false : true
|
||||
);
|
||||
useFocusEffect(focused, itemRef as React.RefObject<HTMLAnchorElement>);
|
||||
useFocusEffect(focused, itemRef);
|
||||
|
||||
const handleFocus = React.useCallback(() => {
|
||||
if (itemRef.current) {
|
||||
scrollIntoView(itemRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
block: "center",
|
||||
boundary: window.document.body,
|
||||
});
|
||||
}
|
||||
}, [itemRef]);
|
||||
|
||||
const content = (selected: boolean) => (
|
||||
<>
|
||||
@@ -110,6 +122,10 @@ const ListItem = (
|
||||
}
|
||||
rovingTabIndex.onKeyDown(ev);
|
||||
}}
|
||||
onFocus={(ev) => {
|
||||
rovingTabIndex.onFocus(ev);
|
||||
handleFocus();
|
||||
}}
|
||||
as={NavLink}
|
||||
to={to}
|
||||
>
|
||||
@@ -126,14 +142,22 @@ const ListItem = (
|
||||
$hover={!!rest.onClick}
|
||||
{...rest}
|
||||
{...rovingTabIndex}
|
||||
onClick={(ev) => {
|
||||
rest.onClick?.(ev);
|
||||
rovingTabIndex.onClick(ev);
|
||||
}}
|
||||
onClick={
|
||||
rest.onClick
|
||||
? (ev) => {
|
||||
rest.onClick?.(ev);
|
||||
rovingTabIndex.onClick(ev);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onKeyDown={(ev) => {
|
||||
rest.onKeyDown?.(ev);
|
||||
rovingTabIndex.onKeyDown(ev);
|
||||
}}
|
||||
onFocus={(ev) => {
|
||||
rovingTabIndex.onFocus(ev);
|
||||
handleFocus();
|
||||
}}
|
||||
>
|
||||
{content(false)}
|
||||
</Wrapper>
|
||||
|
||||
@@ -25,6 +25,7 @@ type Props = {
|
||||
isOpen: boolean;
|
||||
fullscreen?: boolean;
|
||||
title?: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
onRequestClose: () => void;
|
||||
};
|
||||
|
||||
@@ -33,6 +34,7 @@ const Modal: React.FC<Props> = ({
|
||||
isOpen,
|
||||
fullscreen = true,
|
||||
title = "Untitled",
|
||||
style,
|
||||
onRequestClose,
|
||||
}: Props) => {
|
||||
const dialog = useDialogState({
|
||||
@@ -115,7 +117,7 @@ const Modal: React.FC<Props> = ({
|
||||
column
|
||||
reverse
|
||||
>
|
||||
<SmallContent shadow>
|
||||
<SmallContent style={style} shadow>
|
||||
<ErrorBoundary component="div">{children}</ErrorBoundary>
|
||||
</SmallContent>
|
||||
<Header>
|
||||
@@ -254,7 +256,7 @@ const Header = styled(Flex)`
|
||||
const Small = styled.div`
|
||||
animation: ${fadeAndScaleIn} 250ms ease;
|
||||
|
||||
margin: auto auto;
|
||||
margin: 25vh auto auto auto;
|
||||
width: 75vw;
|
||||
min-width: 350px;
|
||||
max-width: 450px;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LocationDescriptor, LocationDescriptorObject } from "history";
|
||||
import * as React from "react";
|
||||
import { match, NavLink, Route } from "react-router-dom";
|
||||
import { type match, NavLink, Route } from "react-router-dom";
|
||||
|
||||
type Props = React.ComponentProps<typeof NavLink> & {
|
||||
children?: (
|
||||
|
||||
@@ -9,8 +9,7 @@ import Notification from "~/models/Notification";
|
||||
import CommentEditor from "~/scenes/Document/components/CommentEditor";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover, truncateMultiline } from "~/styles";
|
||||
import Avatar from "../Avatar";
|
||||
import { AvatarSize } from "../Avatar/Avatar";
|
||||
import { Avatar, AvatarSize } from "../Avatar";
|
||||
import Flex from "../Flex";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
|
||||
@@ -29,7 +29,6 @@ const PageTitle = ({ title, favicon }: Props) => {
|
||||
href={favicon ?? originalShortcutHref}
|
||||
key={favicon ?? originalShortcutHref}
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</Helmet>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,9 +13,9 @@ 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 &
|
||||
@@ -36,6 +36,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 +197,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 +213,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 +233,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>
|
||||
|
||||
@@ -30,13 +30,24 @@ type Props = {
|
||||
pins: Pin[];
|
||||
/** Maximum number of pins to display */
|
||||
limit?: number;
|
||||
/** Number of placeholder pins to display */
|
||||
placeholderCount?: number;
|
||||
/** Whether the user has permission to update pins */
|
||||
canUpdate?: boolean;
|
||||
};
|
||||
|
||||
function PinnedDocuments({ limit, pins, canUpdate, ...rest }: Props) {
|
||||
const { documents, collections } = useStores();
|
||||
function PinnedDocuments({
|
||||
limit,
|
||||
pins,
|
||||
placeholderCount,
|
||||
canUpdate,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { documents } = useStores();
|
||||
const [items, setItems] = React.useState(pins.map((pin) => pin.documentId));
|
||||
const showPlaceholderRef = React.useRef(true);
|
||||
const showPlaceholder =
|
||||
placeholderCount && !items.length && showPlaceholderRef.current;
|
||||
|
||||
React.useEffect(() => {
|
||||
setItems(pins.map((pin) => pin.documentId));
|
||||
@@ -59,9 +70,9 @@ function PinnedDocuments({ limit, pins, canUpdate, ...rest }: Props) {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
setItems((items) => {
|
||||
const activePos = items.indexOf(active.id as string);
|
||||
const overPos = items.indexOf(over.id as string);
|
||||
setItems((existing) => {
|
||||
const activePos = existing.indexOf(active.id as string);
|
||||
const overPos = existing.indexOf(over.id as string);
|
||||
|
||||
const overIndex = pins[overPos]?.index || null;
|
||||
const nextIndex = pins[overPos + 1]?.index || null;
|
||||
@@ -78,20 +89,16 @@ function PinnedDocuments({ limit, pins, canUpdate, ...rest }: Props) {
|
||||
? fractionalIndex(prevIndex, overIndex)
|
||||
: fractionalIndex(overIndex, nextIndex),
|
||||
})
|
||||
.catch(() => setItems(items));
|
||||
.catch(() => setItems(existing));
|
||||
|
||||
// Update the order in state immediately
|
||||
return arrayMove(items, activePos, overPos);
|
||||
return arrayMove(existing, activePos, overPos);
|
||||
});
|
||||
}
|
||||
},
|
||||
[pins]
|
||||
);
|
||||
|
||||
if (collections.orderedData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
@@ -109,23 +116,34 @@ function PinnedDocuments({ limit, pins, canUpdate, ...rest }: Props) {
|
||||
>
|
||||
<SortableContext items={items} strategy={rectSortingStrategy}>
|
||||
<List>
|
||||
<AnimatePresence initial={false}>
|
||||
{items.map((documentId) => {
|
||||
const document = documents.get(documentId);
|
||||
const pin = pins.find((pin) => pin.documentId === documentId);
|
||||
{showPlaceholder ? (
|
||||
Array(placeholderCount)
|
||||
.fill(undefined)
|
||||
.map((_, index) => (
|
||||
<div key={index} style={{ width: 170, height: 180 }} />
|
||||
))
|
||||
) : (
|
||||
<AnimatePresence initial={false}>
|
||||
{items.map((documentId) => {
|
||||
const document = documents.get(documentId);
|
||||
const pin = pins.find((p) => p.documentId === documentId);
|
||||
|
||||
return document ? (
|
||||
<DocumentCard
|
||||
key={documentId}
|
||||
document={document}
|
||||
canUpdatePin={canUpdate}
|
||||
isDraggable={items.length > 1}
|
||||
pin={pin}
|
||||
{...rest}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</AnimatePresence>
|
||||
// Once any document is loaded, never render the placeholder again
|
||||
showPlaceholderRef.current = false;
|
||||
|
||||
return document ? (
|
||||
<DocumentCard
|
||||
key={documentId}
|
||||
document={document}
|
||||
canUpdatePin={canUpdate}
|
||||
isDraggable={items.length > 1}
|
||||
pin={pin}
|
||||
{...rest}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</List>
|
||||
</SortableContext>
|
||||
</ResizingHeightContainer>
|
||||
|
||||
@@ -5,8 +5,9 @@ import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import { pulsate } from "~/styles/animations";
|
||||
|
||||
export type Props = {
|
||||
export type Props = React.ComponentProps<typeof Flex> & {
|
||||
header?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
@@ -17,16 +18,22 @@ function PlaceholderText({ minWidth, maxWidth, ...restProps }: Props) {
|
||||
// We only want to compute the width once so we are storing it inside ref
|
||||
const widthRef = React.useRef(randomInteger(minWidth || 75, maxWidth || 100));
|
||||
|
||||
return <Mask width={widthRef.current} {...restProps} />;
|
||||
return (
|
||||
<Mask
|
||||
width={`${widthRef.current / (restProps.header ? 2 : 1)}%`}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Mask = styled(Flex)<{
|
||||
width: number;
|
||||
width: number | string;
|
||||
height?: number;
|
||||
delay?: number;
|
||||
header?: boolean;
|
||||
}>`
|
||||
width: ${(props) => (props.header ? props.width / 2 : props.width)}%;
|
||||
width: ${(props) =>
|
||||
typeof props.width === "number" ? `${props.width}px` : props.width};
|
||||
height: ${(props) =>
|
||||
props.height ? props.height : props.header ? 24 : 18}px;
|
||||
margin-bottom: 6px;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
@@ -36,11 +36,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 +54,7 @@ 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,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { UserIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import { Avatar, GroupAvatar, AvatarSize } from "~/components/Avatar";
|
||||
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
|
||||
import InputSelectPermission from "~/components/InputSelectPermission";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useMaxHeight from "~/hooks/useMaxHeight";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { EmptySelectValue, Permission } from "~/types";
|
||||
import { ListItem } from "../components/ListItem";
|
||||
import { Placeholder } from "../components/Placeholder";
|
||||
|
||||
type Props = {
|
||||
/** Collection to which team members are supposed to be invited */
|
||||
collection: Collection;
|
||||
/** Children to be rendered before the list of members */
|
||||
children?: React.ReactNode;
|
||||
/** List of users and groups that have been invited during the current editing session */
|
||||
invitedInSession: string[];
|
||||
};
|
||||
|
||||
export const AccessControlList = observer(
|
||||
({ collection, invitedInSession }: Props) => {
|
||||
const { memberships, groupMemberships } = useStores();
|
||||
const can = usePolicy(collection);
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const collectionId = collection.id;
|
||||
|
||||
const { request: fetchMemberships, loading: membershipLoading } =
|
||||
useRequest(
|
||||
React.useCallback(
|
||||
() => memberships.fetchAll({ id: collectionId }),
|
||||
[memberships, collectionId]
|
||||
)
|
||||
);
|
||||
|
||||
const { request: fetchGroupMemberships, loading: groupMembershipLoading } =
|
||||
useRequest(
|
||||
React.useCallback(
|
||||
() => groupMemberships.fetchAll({ collectionId }),
|
||||
[groupMemberships, collectionId]
|
||||
)
|
||||
);
|
||||
|
||||
const groupMembershipsInCollection =
|
||||
groupMemberships.inCollection(collectionId);
|
||||
const membershipsInCollection = memberships.inCollection(collectionId);
|
||||
const hasMemberships =
|
||||
groupMembershipsInCollection.length > 0 ||
|
||||
membershipsInCollection.length > 0;
|
||||
const showLoading =
|
||||
!hasMemberships && (membershipLoading || groupMembershipLoading);
|
||||
|
||||
React.useEffect(() => {
|
||||
void fetchMemberships();
|
||||
void fetchGroupMemberships();
|
||||
}, [fetchMemberships, fetchGroupMemberships]);
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const { maxHeight, calcMaxHeight } = useMaxHeight({
|
||||
elementRef: containerRef,
|
||||
maxViewportPercentage: 70,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
calcMaxHeight();
|
||||
});
|
||||
|
||||
const permissions = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
label: t("View only"),
|
||||
value: CollectionPermission.Read,
|
||||
},
|
||||
{
|
||||
label: t("Can edit"),
|
||||
value: CollectionPermission.ReadWrite,
|
||||
},
|
||||
{
|
||||
label: t("Manage"),
|
||||
value: CollectionPermission.Admin,
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
label: t("Remove"),
|
||||
value: EmptySelectValue,
|
||||
},
|
||||
] as Permission[],
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollableContainer
|
||||
ref={containerRef}
|
||||
hiddenScrollbars
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
{showLoading ? (
|
||||
<Placeholder count={2} />
|
||||
) : (
|
||||
<>
|
||||
<ListItem
|
||||
image={
|
||||
<Squircle color={theme.accent} size={AvatarSize.Medium}>
|
||||
<UserIcon color={theme.accentText} size={16} />
|
||||
</Squircle>
|
||||
}
|
||||
title={t("All members")}
|
||||
subtitle={t("Everyone in the workspace")}
|
||||
actions={
|
||||
<div style={{ marginRight: -8 }}>
|
||||
<InputSelectPermission
|
||||
style={{ margin: 0 }}
|
||||
onChange={(
|
||||
value: CollectionPermission | typeof EmptySelectValue
|
||||
) => {
|
||||
void collection.save({
|
||||
permission: value === EmptySelectValue ? null : value,
|
||||
});
|
||||
}}
|
||||
disabled={!can.update}
|
||||
value={collection?.permission}
|
||||
labelHidden
|
||||
nude
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{groupMembershipsInCollection
|
||||
.filter((membership) => membership.group)
|
||||
.sort((a, b) =>
|
||||
(
|
||||
(invitedInSession.includes(a.group.id) ? "_" : "") +
|
||||
a.group.name
|
||||
).localeCompare(b.group.name)
|
||||
)
|
||||
.map((membership) => (
|
||||
<ListItem
|
||||
key={membership.id}
|
||||
image={
|
||||
<GroupAvatar
|
||||
group={membership.group}
|
||||
backgroundColor={theme.modalBackground}
|
||||
/>
|
||||
}
|
||||
title={membership.group.name}
|
||||
subtitle={t("{{ count }} member", {
|
||||
count: membership.group.memberCount,
|
||||
})}
|
||||
actions={
|
||||
<div style={{ marginRight: -8 }}>
|
||||
<InputMemberPermissionSelect
|
||||
style={{ margin: 0 }}
|
||||
permissions={permissions}
|
||||
onChange={async (
|
||||
permission:
|
||||
| CollectionPermission
|
||||
| typeof EmptySelectValue
|
||||
) => {
|
||||
if (permission === EmptySelectValue) {
|
||||
await groupMemberships.delete({
|
||||
collectionId: collection.id,
|
||||
groupId: membership.groupId,
|
||||
});
|
||||
} else {
|
||||
await groupMemberships.create({
|
||||
collectionId: collection.id,
|
||||
groupId: membership.groupId,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!can.update}
|
||||
value={membership.permission}
|
||||
labelHidden
|
||||
nude
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{membershipsInCollection
|
||||
.filter((membership) => membership.user)
|
||||
.sort((a, b) =>
|
||||
(
|
||||
(invitedInSession.includes(a.user.id) ? "_" : "") +
|
||||
a.user.name
|
||||
).localeCompare(b.user.name)
|
||||
)
|
||||
.map((membership) => (
|
||||
<ListItem
|
||||
key={membership.id}
|
||||
image={
|
||||
<Avatar
|
||||
model={membership.user}
|
||||
size={AvatarSize.Medium}
|
||||
showBorder={false}
|
||||
/>
|
||||
}
|
||||
title={membership.user.name}
|
||||
subtitle={membership.user.email}
|
||||
actions={
|
||||
<div style={{ marginRight: -8 }}>
|
||||
<InputMemberPermissionSelect
|
||||
style={{ margin: 0 }}
|
||||
permissions={permissions}
|
||||
onChange={async (
|
||||
permission:
|
||||
| CollectionPermission
|
||||
| typeof EmptySelectValue
|
||||
) => {
|
||||
if (permission === EmptySelectValue) {
|
||||
await memberships.delete({
|
||||
collectionId: collection.id,
|
||||
userId: membership.userId,
|
||||
});
|
||||
} else {
|
||||
await memberships.create({
|
||||
collectionId: collection.id,
|
||||
userId: membership.userId,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!can.update}
|
||||
value={membership.permission}
|
||||
labelHidden
|
||||
nude
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ScrollableContainer>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const ScrollableContainer = styled(Scrollable)`
|
||||
padding: 12px 24px;
|
||||
margin: -12px -24px;
|
||||
`;
|
||||
@@ -1,180 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Avatar, { AvatarSize } from "~/components/Avatar/Avatar";
|
||||
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { EmptySelectValue, Permission } from "~/types";
|
||||
import { ListItem } from "../components/ListItem";
|
||||
|
||||
type Props = {
|
||||
/** Collection to which team members are supposed to be invited */
|
||||
collection: Collection;
|
||||
/** Children to be rendered before the list of members */
|
||||
children?: React.ReactNode;
|
||||
/** List of users and groups that have been invited during the current editing session */
|
||||
invitedInSession: string[];
|
||||
};
|
||||
|
||||
function CollectionMemberList({ collection, invitedInSession }: Props) {
|
||||
const { memberships, collectionGroupMemberships } = useStores();
|
||||
const can = usePolicy(collection);
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const collectionId = collection.id;
|
||||
|
||||
const { request: fetchMemberships } = useRequest(
|
||||
React.useCallback(
|
||||
() => memberships.fetchAll({ id: collectionId }),
|
||||
[memberships, collectionId]
|
||||
)
|
||||
);
|
||||
|
||||
const { request: fetchGroupMemberships } = useRequest(
|
||||
React.useCallback(
|
||||
() => collectionGroupMemberships.fetchAll({ id: collectionId }),
|
||||
[collectionGroupMemberships, collectionId]
|
||||
)
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
void fetchMemberships();
|
||||
void fetchGroupMemberships();
|
||||
}, [fetchMemberships, fetchGroupMemberships]);
|
||||
|
||||
const permissions = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
label: t("View only"),
|
||||
value: CollectionPermission.Read,
|
||||
},
|
||||
{
|
||||
label: t("Can edit"),
|
||||
value: CollectionPermission.ReadWrite,
|
||||
},
|
||||
{
|
||||
label: t("Manage"),
|
||||
value: CollectionPermission.Admin,
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
label: t("Remove"),
|
||||
value: EmptySelectValue,
|
||||
},
|
||||
] as Permission[],
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{collectionGroupMemberships
|
||||
.inCollection(collection.id)
|
||||
.sort((a, b) =>
|
||||
(
|
||||
(invitedInSession.includes(a.group.id) ? "_" : "") + a.group.name
|
||||
).localeCompare(b.group.name)
|
||||
)
|
||||
.map((membership) => (
|
||||
<ListItem
|
||||
key={membership.id}
|
||||
image={
|
||||
<Squircle color={theme.text} size={AvatarSize.Medium}>
|
||||
<GroupIcon color={theme.background} size={16} />
|
||||
</Squircle>
|
||||
}
|
||||
title={membership.group.name}
|
||||
subtitle={t("{{ count }} member", {
|
||||
count: membership.group.memberCount,
|
||||
})}
|
||||
actions={
|
||||
<div style={{ marginRight: -8 }}>
|
||||
<InputMemberPermissionSelect
|
||||
style={{ margin: 0 }}
|
||||
permissions={permissions}
|
||||
onChange={async (
|
||||
permission: CollectionPermission | typeof EmptySelectValue
|
||||
) => {
|
||||
if (permission === EmptySelectValue) {
|
||||
await collectionGroupMemberships.delete({
|
||||
collectionId: collection.id,
|
||||
groupId: membership.groupId,
|
||||
});
|
||||
} else {
|
||||
await collectionGroupMemberships.create({
|
||||
collectionId: collection.id,
|
||||
groupId: membership.groupId,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!can.update}
|
||||
value={membership.permission}
|
||||
labelHidden
|
||||
nude
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{memberships
|
||||
.inCollection(collection.id)
|
||||
.sort((a, b) =>
|
||||
(
|
||||
(invitedInSession.includes(a.user.id) ? "_" : "") + a.user.name
|
||||
).localeCompare(b.user.name)
|
||||
)
|
||||
.map((membership) => (
|
||||
<ListItem
|
||||
key={membership.id}
|
||||
image={
|
||||
<Avatar
|
||||
model={membership.user}
|
||||
size={AvatarSize.Medium}
|
||||
showBorder={false}
|
||||
/>
|
||||
}
|
||||
title={membership.user.name}
|
||||
subtitle={membership.user.email}
|
||||
actions={
|
||||
<div style={{ marginRight: -8 }}>
|
||||
<InputMemberPermissionSelect
|
||||
style={{ margin: 0 }}
|
||||
permissions={permissions}
|
||||
onChange={async (
|
||||
permission: CollectionPermission | typeof EmptySelectValue
|
||||
) => {
|
||||
if (permission === EmptySelectValue) {
|
||||
await memberships.delete({
|
||||
collectionId: collection.id,
|
||||
userId: membership.userId,
|
||||
});
|
||||
} else {
|
||||
await memberships.create({
|
||||
collectionId: collection.id,
|
||||
userId: membership.userId,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!can.update}
|
||||
value={membership.permission}
|
||||
labelHidden
|
||||
nude
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(CollectionMemberList);
|
||||
@@ -1,18 +1,15 @@
|
||||
import { isEmail } from "class-validator";
|
||||
import { m } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import { BackIcon, UserIcon } from "outline-icons";
|
||||
import { BackIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Group from "~/models/Group";
|
||||
import User from "~/models/User";
|
||||
import Avatar, { AvatarSize } from "~/components/Avatar/Avatar";
|
||||
import InputSelectPermission from "~/components/InputSelectPermission";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { createAction } from "~/actions";
|
||||
import { UserSection } from "~/actions/sections";
|
||||
@@ -22,15 +19,14 @@ import useKeyDown from "~/hooks/useKeyDown";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { EmptySelectValue, Permission } from "~/types";
|
||||
import { Permission } from "~/types";
|
||||
import { collectionPath, urlify } from "~/utils/routeHelpers";
|
||||
import { Wrapper, presence } from "../components";
|
||||
import { CopyLinkButton } from "../components/CopyLinkButton";
|
||||
import { ListItem } from "../components/ListItem";
|
||||
import { PermissionAction } from "../components/PermissionAction";
|
||||
import { SearchInput } from "../components/SearchInput";
|
||||
import { Suggestions } from "../components/Suggestions";
|
||||
import CollectionMemberList from "./CollectionMemberList";
|
||||
import { AccessControlList } from "./AccessControlList";
|
||||
|
||||
type Props = {
|
||||
/** The collection to share. */
|
||||
@@ -42,10 +38,8 @@ type Props = {
|
||||
};
|
||||
|
||||
function SharePopover({ collection, visible, onRequestClose }: Props) {
|
||||
const theme = useTheme();
|
||||
const team = useCurrentTeam();
|
||||
const { collectionGroupMemberships, users, groups, memberships } =
|
||||
useStores();
|
||||
const { groupMemberships, users, groups, memberships } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(collection);
|
||||
const [query, setQuery] = React.useState("");
|
||||
@@ -206,10 +200,10 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
|
||||
}
|
||||
|
||||
if (group) {
|
||||
await collectionGroupMemberships.create({
|
||||
await groupMemberships.create({
|
||||
collectionId: collection.id,
|
||||
groupId: group.id,
|
||||
permission: CollectionPermission.Read,
|
||||
permission,
|
||||
});
|
||||
return group;
|
||||
}
|
||||
@@ -268,7 +262,7 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
|
||||
}),
|
||||
[
|
||||
collection.id,
|
||||
collectionGroupMemberships,
|
||||
groupMemberships,
|
||||
groups,
|
||||
hidePicker,
|
||||
memberships,
|
||||
@@ -355,49 +349,19 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
|
||||
)}
|
||||
|
||||
{picker && (
|
||||
<div>
|
||||
<Suggestions
|
||||
ref={suggestionsRef}
|
||||
query={query}
|
||||
collection={collection}
|
||||
pendingIds={pendingIds}
|
||||
addPendingId={handleAddPendingId}
|
||||
removePendingId={handleRemovePendingId}
|
||||
onEscape={handleEscape}
|
||||
/>
|
||||
</div>
|
||||
<Suggestions
|
||||
ref={suggestionsRef}
|
||||
query={query}
|
||||
collection={collection}
|
||||
pendingIds={pendingIds}
|
||||
addPendingId={handleAddPendingId}
|
||||
removePendingId={handleRemovePendingId}
|
||||
onEscape={handleEscape}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: picker ? "none" : "block" }}>
|
||||
<ListItem
|
||||
image={
|
||||
<Squircle color={theme.accent} size={AvatarSize.Medium}>
|
||||
<UserIcon color={theme.accentText} size={16} />
|
||||
</Squircle>
|
||||
}
|
||||
title={t("All members")}
|
||||
subtitle={t("Everyone in the workspace")}
|
||||
actions={
|
||||
<div style={{ marginRight: -8 }}>
|
||||
<InputSelectPermission
|
||||
style={{ margin: 0 }}
|
||||
onChange={(
|
||||
value: CollectionPermission | typeof EmptySelectValue
|
||||
) => {
|
||||
void collection.save({
|
||||
permission: value === EmptySelectValue ? null : value,
|
||||
});
|
||||
}}
|
||||
disabled={!can.update}
|
||||
value={collection?.permission}
|
||||
labelHidden
|
||||
nude
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<CollectionMemberList
|
||||
<AccessControlList
|
||||
collection={collection}
|
||||
invitedInSession={invitedInSession}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { MoreIcon, QuestionMarkIcon, UserIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import { CollectionPermission, IconType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import type Collection from "~/models/Collection";
|
||||
import type Document from "~/models/Document";
|
||||
import Share from "~/models/Share";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Text from "~/components/Text";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMaxHeight from "~/hooks/useMaxHeight";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Avatar, AvatarSize } from "../../Avatar";
|
||||
import CollectionIcon from "../../Icons/CollectionIcon";
|
||||
import Tooltip from "../../Tooltip";
|
||||
import { Separator } from "../components";
|
||||
import { ListItem } from "../components/ListItem";
|
||||
import { Placeholder } from "../components/Placeholder";
|
||||
import DocumentMemberList from "./DocumentMemberList";
|
||||
import PublicAccess from "./PublicAccess";
|
||||
|
||||
type Props = {
|
||||
/** The document being shared. */
|
||||
document: Document;
|
||||
/** List of users that have been invited during the current editing session */
|
||||
invitedInSession: string[];
|
||||
/** 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;
|
||||
};
|
||||
|
||||
export const AccessControlList = observer(
|
||||
({
|
||||
document,
|
||||
invitedInSession,
|
||||
share,
|
||||
sharedParent,
|
||||
onRequestClose,
|
||||
visible,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const collection = document.collection;
|
||||
const usersInCollection = useUsersInCollection(collection);
|
||||
const user = useCurrentUser();
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
const collectionSharingDisabled = document.collection?.sharing === false;
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(document);
|
||||
const canCollection = usePolicy(collection);
|
||||
const documentId = document.id;
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const { maxHeight, calcMaxHeight } = useMaxHeight({
|
||||
elementRef: containerRef,
|
||||
maxViewportPercentage: 70,
|
||||
margin: 24,
|
||||
});
|
||||
|
||||
const { loading: userMembershipLoading, request: fetchUserMemberships } =
|
||||
useRequest(
|
||||
React.useCallback(
|
||||
() =>
|
||||
userMemberships.fetchDocumentMemberships({
|
||||
id: documentId,
|
||||
limit: Pagination.defaultLimit,
|
||||
}),
|
||||
[userMemberships, documentId]
|
||||
)
|
||||
);
|
||||
|
||||
const { loading: groupMembershipLoading, request: fetchGroupMemberships } =
|
||||
useRequest(
|
||||
React.useCallback(
|
||||
() => groupMemberships.fetchAll({ documentId }),
|
||||
[groupMemberships, documentId]
|
||||
)
|
||||
);
|
||||
|
||||
const hasMemberships =
|
||||
groupMemberships.inDocument(documentId)?.length > 0 ||
|
||||
document.members.length > 0;
|
||||
const showLoading =
|
||||
!hasMemberships && (groupMembershipLoading || userMembershipLoading);
|
||||
|
||||
React.useEffect(() => {
|
||||
void fetchUserMemberships();
|
||||
void fetchGroupMemberships();
|
||||
}, [fetchUserMemberships, fetchGroupMemberships]);
|
||||
|
||||
React.useEffect(() => {
|
||||
calcMaxHeight();
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollableContainer
|
||||
ref={containerRef}
|
||||
hiddenScrollbars
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
{showLoading ? (
|
||||
<Placeholder />
|
||||
) : (
|
||||
<>
|
||||
{collection && canCollection.readDocument ? (
|
||||
<>
|
||||
{collection.permission ? (
|
||||
<ListItem
|
||||
image={
|
||||
<Squircle color={theme.accent} size={AvatarSize.Medium}>
|
||||
<UserIcon color={theme.accentText} size={16} />
|
||||
</Squircle>
|
||||
}
|
||||
title={t("All members")}
|
||||
subtitle={t("Everyone in the workspace")}
|
||||
actions={
|
||||
<AccessTooltip>
|
||||
{collection?.permission ===
|
||||
CollectionPermission.ReadWrite
|
||||
? t("Can edit")
|
||||
: t("Can view")}
|
||||
</AccessTooltip>
|
||||
}
|
||||
/>
|
||||
) : usersInCollection ? (
|
||||
<ListItem
|
||||
image={<CollectionSquircle collection={collection} />}
|
||||
title={collection.name}
|
||||
subtitle={t("Everyone in the collection")}
|
||||
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
|
||||
/>
|
||||
) : (
|
||||
<ListItem
|
||||
image={<Avatar model={user} showBorder={false} />}
|
||||
title={user.name}
|
||||
subtitle={t("You have full access")}
|
||||
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
|
||||
/>
|
||||
)}
|
||||
<DocumentMemberList
|
||||
document={document}
|
||||
invitedInSession={invitedInSession}
|
||||
/>
|
||||
</>
|
||||
) : document.isDraft ? (
|
||||
<>
|
||||
<ListItem
|
||||
image={
|
||||
<Avatar model={document.createdBy} showBorder={false} />
|
||||
}
|
||||
title={document.createdBy?.name}
|
||||
actions={
|
||||
<AccessTooltip content={t("Created the document")}>
|
||||
{t("Can edit")}
|
||||
</AccessTooltip>
|
||||
}
|
||||
/>
|
||||
<DocumentMemberList
|
||||
document={document}
|
||||
invitedInSession={invitedInSession}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DocumentMemberList
|
||||
document={document}
|
||||
invitedInSession={invitedInSession}
|
||||
/>
|
||||
<ListItem
|
||||
image={
|
||||
<Squircle color={theme.accent} size={AvatarSize.Medium}>
|
||||
<MoreIcon color={theme.accentText} size={16} />
|
||||
</Squircle>
|
||||
}
|
||||
title={t("Other people")}
|
||||
subtitle={t("Other workspace members may have access")}
|
||||
actions={
|
||||
<AccessTooltip
|
||||
content={t(
|
||||
"This document may be shared with more workspace members through a parent document or collection you do not have access to"
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{team.sharing && can.share && !collectionSharingDisabled && visible && (
|
||||
<>
|
||||
{document.members.length ? <Separator /> : null}
|
||||
<PublicAccess
|
||||
document={document}
|
||||
share={share}
|
||||
sharedParent={sharedParent}
|
||||
onRequestClose={onRequestClose}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ScrollableContainer>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const AccessTooltip = ({
|
||||
children,
|
||||
content,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
content?: string;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Flex align="center" gap={2}>
|
||||
<Text type="secondary" size="small">
|
||||
{children}
|
||||
</Text>
|
||||
<Tooltip content={content ?? t("Access inherited from collection")}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const CollectionSquircle = ({ collection }: { collection: Collection }) => {
|
||||
const theme = useTheme();
|
||||
const iconType = determineIconType(collection.icon)!;
|
||||
const squircleColor =
|
||||
iconType === IconType.SVG ? collection.color! : theme.slateLight;
|
||||
const iconSize = iconType === IconType.SVG ? 16 : 22;
|
||||
|
||||
return (
|
||||
<Squircle color={squircleColor} size={AvatarSize.Medium}>
|
||||
<CollectionIcon
|
||||
collection={collection}
|
||||
color={theme.white}
|
||||
size={iconSize}
|
||||
/>
|
||||
</Squircle>
|
||||
);
|
||||
};
|
||||
|
||||
function useUsersInCollection(collection?: Collection) {
|
||||
const { users, memberships } = useStores();
|
||||
const { request } = useRequest(() =>
|
||||
memberships.fetchPage({ limit: 1, id: collection!.id })
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (collection && !collection.permission) {
|
||||
void request();
|
||||
}
|
||||
}, [collection]);
|
||||
|
||||
return collection
|
||||
? collection.permission
|
||||
? true
|
||||
: users.inCollection(collection.id).length > 1
|
||||
: false;
|
||||
}
|
||||
|
||||
const ScrollableContainer = styled(Scrollable)`
|
||||
padding: 12px 24px;
|
||||
margin: -12px -24px;
|
||||
`;
|
||||
@@ -1,19 +1,23 @@
|
||||
import orderBy from "lodash/orderBy";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { Link, useHistory } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { DocumentPermission } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import { GroupAvatar } from "~/components/Avatar";
|
||||
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { EmptySelectValue, Permission } from "~/types";
|
||||
import { homePath } from "~/utils/routeHelpers";
|
||||
import MemberListItem from "./DocumentMemberListItem";
|
||||
import { ListItem } from "../components/ListItem";
|
||||
import DocumentMemberListItem from "./DocumentMemberListItem";
|
||||
|
||||
type Props = {
|
||||
/** Document to which team members are supposed to be invited */
|
||||
@@ -25,27 +29,13 @@ type Props = {
|
||||
};
|
||||
|
||||
function DocumentMembersList({ document, invitedInSession }: Props) {
|
||||
const { userMemberships } = useStores();
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
|
||||
const user = useCurrentUser();
|
||||
const history = useHistory();
|
||||
const can = usePolicy(document);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { loading: loadingDocumentMembers, request: fetchDocumentMembers } =
|
||||
useRequest(
|
||||
React.useCallback(
|
||||
() =>
|
||||
userMemberships.fetchDocumentMemberships({
|
||||
id: document.id,
|
||||
limit: Pagination.defaultLimit,
|
||||
}),
|
||||
[userMemberships, document.id]
|
||||
)
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
void fetchDocumentMembers();
|
||||
}, [fetchDocumentMembers]);
|
||||
const theme = useTheme();
|
||||
|
||||
const handleRemoveUser = React.useCallback(
|
||||
async (item) => {
|
||||
@@ -68,7 +58,7 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
|
||||
toast.error(t("Could not remove user"));
|
||||
}
|
||||
},
|
||||
[history, userMemberships, user, document]
|
||||
[t, history, userMemberships, user, document]
|
||||
);
|
||||
|
||||
const handleUpdateUser = React.useCallback(
|
||||
@@ -88,7 +78,7 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
|
||||
toast.error(t("Could not update user"));
|
||||
}
|
||||
},
|
||||
[userMemberships, document]
|
||||
[t, userMemberships, document]
|
||||
);
|
||||
|
||||
// Order newly added users first during the current editing session, on reload members are
|
||||
@@ -105,14 +95,101 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
|
||||
[document.members, invitedInSession]
|
||||
);
|
||||
|
||||
if (loadingDocumentMembers) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
const permissions = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
label: t("View only"),
|
||||
value: DocumentPermission.Read,
|
||||
},
|
||||
{
|
||||
label: t("Can edit"),
|
||||
value: DocumentPermission.ReadWrite,
|
||||
},
|
||||
{
|
||||
label: t("Manage"),
|
||||
value: DocumentPermission.Admin,
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
label: t("Remove"),
|
||||
value: EmptySelectValue,
|
||||
},
|
||||
] as Permission[],
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{groupMemberships
|
||||
.inDocument(document.id)
|
||||
.sort((a, b) =>
|
||||
(
|
||||
(invitedInSession.includes(a.group.id) ? "_" : "") + a.group.name
|
||||
).localeCompare(b.group.name)
|
||||
)
|
||||
.map((membership) => {
|
||||
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
|
||||
return (
|
||||
<ListItem
|
||||
key={membership.id}
|
||||
image={
|
||||
<GroupAvatar
|
||||
group={membership.group}
|
||||
backgroundColor={theme.modalBackground}
|
||||
/>
|
||||
}
|
||||
title={membership.group.name}
|
||||
subtitle={
|
||||
membership.sourceId ? (
|
||||
<Trans>
|
||||
Has access through{" "}
|
||||
<MaybeLink
|
||||
// @ts-expect-error to prop does not exist on React.Fragment
|
||||
to={membership.source?.document?.path ?? ""}
|
||||
>
|
||||
parent
|
||||
</MaybeLink>
|
||||
</Trans>
|
||||
) : (
|
||||
t("{{ count }} member", {
|
||||
count: membership.group.memberCount,
|
||||
})
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<div style={{ marginRight: -8 }}>
|
||||
<InputMemberPermissionSelect
|
||||
style={{ margin: 0 }}
|
||||
permissions={permissions}
|
||||
onChange={async (
|
||||
permission: DocumentPermission | typeof EmptySelectValue
|
||||
) => {
|
||||
if (permission === EmptySelectValue) {
|
||||
await groupMemberships.delete({
|
||||
documentId: document.id,
|
||||
groupId: membership.groupId,
|
||||
});
|
||||
} else {
|
||||
await groupMemberships.create({
|
||||
documentId: document.id,
|
||||
groupId: membership.groupId,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!can.manageUsers}
|
||||
value={membership.permission}
|
||||
labelHidden
|
||||
nude
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{members.map((item) => (
|
||||
<MemberListItem
|
||||
<DocumentMemberListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
membership={item.getMembership(document)}
|
||||
@@ -131,4 +208,9 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const StyledLink = styled(Link)`
|
||||
color: ${s("textTertiary")};
|
||||
text-decoration: underline;
|
||||
`;
|
||||
|
||||
export default observer(DocumentMembersList);
|
||||
|
||||
@@ -7,9 +7,9 @@ import { s } from "@shared/styles";
|
||||
import { DocumentPermission } from "@shared/types";
|
||||
import User from "~/models/User";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { AvatarSize } from "~/components/Avatar/Avatar";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
|
||||
import Time from "~/components/Time";
|
||||
import { EmptySelectValue, Permission } from "~/types";
|
||||
import { ListItem } from "../components/ListItem";
|
||||
|
||||
@@ -68,7 +68,6 @@ const DocumentMemberListItem = ({
|
||||
if (!currentPermission) {
|
||||
return null;
|
||||
}
|
||||
const disabled = !onUpdate && !onLeave;
|
||||
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
|
||||
|
||||
return (
|
||||
@@ -90,36 +89,35 @@ const DocumentMemberListItem = ({
|
||||
</Trans>
|
||||
) : user.isSuspended ? (
|
||||
t("Suspended")
|
||||
) : user.email ? (
|
||||
user.email
|
||||
) : user.isInvited ? (
|
||||
t("Invited")
|
||||
) : user.isViewer ? (
|
||||
t("Viewer")
|
||||
) : user.lastActiveAt ? (
|
||||
<Trans>
|
||||
Active <Time dateTime={user.lastActiveAt} /> ago
|
||||
</Trans>
|
||||
) : (
|
||||
t("Editor")
|
||||
t("Never signed in")
|
||||
)
|
||||
}
|
||||
actions={
|
||||
disabled ? null : (
|
||||
<div style={{ marginRight: -8 }}>
|
||||
<InputMemberPermissionSelect
|
||||
permissions={
|
||||
onLeave
|
||||
? [
|
||||
currentPermission,
|
||||
{
|
||||
label: `${t("Leave")}…`,
|
||||
value: EmptySelectValue,
|
||||
},
|
||||
]
|
||||
: permissions
|
||||
}
|
||||
value={membership?.permission}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
<div style={{ marginRight: -8 }}>
|
||||
<InputMemberPermissionSelect
|
||||
permissions={
|
||||
onLeave
|
||||
? [
|
||||
currentPermission,
|
||||
{
|
||||
label: `${t("Leave")}…`,
|
||||
value: EmptySelectValue,
|
||||
},
|
||||
]
|
||||
: permissions
|
||||
}
|
||||
value={membership?.permission}
|
||||
onChange={handleChange}
|
||||
disabled={!onUpdate && !onLeave}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { MoreIcon, QuestionMarkIcon, UserIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { CollectionPermission, IconType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import type Collection from "~/models/Collection";
|
||||
import type Document from "~/models/Document";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Avatar from "../../Avatar";
|
||||
import { AvatarSize } from "../../Avatar/Avatar";
|
||||
import CollectionIcon from "../../Icons/CollectionIcon";
|
||||
import Tooltip from "../../Tooltip";
|
||||
import { ListItem } from "../components/ListItem";
|
||||
|
||||
type Props = {
|
||||
/** The document being shared. */
|
||||
document: Document;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const OtherAccess = observer(({ document, children }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const collection = document.collection;
|
||||
const usersInCollection = useUsersInCollection(collection);
|
||||
const user = useCurrentUser();
|
||||
|
||||
return (
|
||||
<>
|
||||
{collection ? (
|
||||
<>
|
||||
{collection.permission ? (
|
||||
<ListItem
|
||||
image={
|
||||
<Squircle color={theme.accent} size={AvatarSize.Medium}>
|
||||
<UserIcon color={theme.accentText} size={16} />
|
||||
</Squircle>
|
||||
}
|
||||
title={t("All members")}
|
||||
subtitle={t("Everyone in the workspace")}
|
||||
actions={
|
||||
<AccessTooltip>
|
||||
{collection?.permission === CollectionPermission.ReadWrite
|
||||
? t("Can edit")
|
||||
: t("Can view")}
|
||||
</AccessTooltip>
|
||||
}
|
||||
/>
|
||||
) : usersInCollection ? (
|
||||
<ListItem
|
||||
image={<CollectionSquircle collection={collection} />}
|
||||
title={collection.name}
|
||||
subtitle={t("Everyone in the collection")}
|
||||
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
|
||||
/>
|
||||
) : (
|
||||
<ListItem
|
||||
image={<Avatar model={user} showBorder={false} />}
|
||||
title={user.name}
|
||||
subtitle={t("You have full access")}
|
||||
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</>
|
||||
) : document.isDraft ? (
|
||||
<>
|
||||
<ListItem
|
||||
image={<Avatar model={document.createdBy} showBorder={false} />}
|
||||
title={document.createdBy?.name}
|
||||
actions={
|
||||
<AccessTooltip content={t("Created the document")}>
|
||||
{t("Can edit")}
|
||||
</AccessTooltip>
|
||||
}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{children}
|
||||
<ListItem
|
||||
image={
|
||||
<Squircle color={theme.accent} size={AvatarSize.Medium}>
|
||||
<MoreIcon color={theme.accentText} size={16} />
|
||||
</Squircle>
|
||||
}
|
||||
title={t("Other people")}
|
||||
subtitle={t("Other workspace members may have access")}
|
||||
actions={
|
||||
<AccessTooltip
|
||||
content={t(
|
||||
"This document may be shared with more workspace members through a parent document or collection you do not have access to"
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const AccessTooltip = ({
|
||||
children,
|
||||
content,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
content?: string;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Flex align="center" gap={2}>
|
||||
<Text type="secondary" size="small">
|
||||
{children}
|
||||
</Text>
|
||||
<Tooltip content={content ?? t("Access inherited from collection")}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const CollectionSquircle = ({ collection }: { collection: Collection }) => {
|
||||
const theme = useTheme();
|
||||
const iconType = determineIconType(collection.icon)!;
|
||||
const squircleColor =
|
||||
iconType === IconType.Outline ? collection.color! : theme.slateLight;
|
||||
const iconSize = iconType === IconType.Outline ? 16 : 22;
|
||||
|
||||
return (
|
||||
<Squircle color={squircleColor} size={AvatarSize.Medium}>
|
||||
<CollectionIcon
|
||||
collection={collection}
|
||||
color={theme.white}
|
||||
size={iconSize}
|
||||
/>
|
||||
</Squircle>
|
||||
);
|
||||
};
|
||||
|
||||
function useUsersInCollection(collection?: Collection) {
|
||||
const { users, memberships } = useStores();
|
||||
const { request } = useRequest(() =>
|
||||
memberships.fetchPage({ limit: 1, id: collection!.id })
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (collection && !collection.permission) {
|
||||
void request();
|
||||
}
|
||||
}, [collection]);
|
||||
|
||||
return collection
|
||||
? collection.permission
|
||||
? true
|
||||
: users.inCollection(collection.id).length > 1
|
||||
: false;
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import Switch from "~/components/Switch";
|
||||
import env from "~/env";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { AvatarSize } from "../../Avatar/Avatar";
|
||||
import { AvatarSize } from "../../Avatar";
|
||||
import CopyToClipboard from "../../CopyToClipboard";
|
||||
import NudeButton from "../../NudeButton";
|
||||
import { ResizingHeightContainer } from "../../ResizingHeightContainer";
|
||||
|
||||
@@ -7,10 +7,10 @@ import { useTranslation } from "react-i18next";
|
||||
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 from "~/components/Avatar";
|
||||
import { AvatarSize } from "~/components/Avatar/Avatar";
|
||||
import { Avatar, GroupAvatar, AvatarSize } from "~/components/Avatar";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { createAction } from "~/actions";
|
||||
import { UserSection } from "~/actions/sections";
|
||||
@@ -22,14 +22,12 @@ import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Permission } from "~/types";
|
||||
import { documentPath, urlify } from "~/utils/routeHelpers";
|
||||
import { Separator, Wrapper, presence } from "../components";
|
||||
import { Wrapper, presence } from "../components";
|
||||
import { CopyLinkButton } from "../components/CopyLinkButton";
|
||||
import { PermissionAction } from "../components/PermissionAction";
|
||||
import { SearchInput } from "../components/SearchInput";
|
||||
import { Suggestions } from "../components/Suggestions";
|
||||
import DocumentMembersList from "./DocumentMemberList";
|
||||
import { OtherAccess } from "./OtherAccess";
|
||||
import PublicAccess from "./PublicAccess";
|
||||
import { AccessControlList } from "./AccessControlList";
|
||||
|
||||
type Props = {
|
||||
/** The document to share. */
|
||||
@@ -55,12 +53,11 @@ function SharePopover({
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(document);
|
||||
const [hasRendered, setHasRendered] = React.useState(visible);
|
||||
const { users, userMemberships } = useStores();
|
||||
const { users, userMemberships, groups, groupMemberships } = useStores();
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [picker, showPicker, hidePicker] = useBoolean();
|
||||
const [invitedInSession, setInvitedInSession] = React.useState<string[]>([]);
|
||||
const [pendingIds, setPendingIds] = React.useState<string[]>([]);
|
||||
const collectionSharingDisabled = document.collection?.sharing === false;
|
||||
const [permission, setPermission] = React.useState<DocumentPermission>(
|
||||
DocumentPermission.Read
|
||||
);
|
||||
@@ -132,9 +129,9 @@ function SharePopover({
|
||||
name: t("Invite"),
|
||||
section: UserSection,
|
||||
perform: async () => {
|
||||
const usersInvited = await Promise.all(
|
||||
const invited = await Promise.all(
|
||||
pendingIds.map(async (idOrEmail) => {
|
||||
let user;
|
||||
let user, group;
|
||||
|
||||
// convert email to user
|
||||
if (isEmail(idOrEmail)) {
|
||||
@@ -148,38 +145,77 @@ function SharePopover({
|
||||
user = response[0];
|
||||
} else {
|
||||
user = users.get(idOrEmail);
|
||||
group = groups.get(idOrEmail);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
if (user) {
|
||||
await userMemberships.create({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
permission,
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
await userMemberships.create({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
permission,
|
||||
});
|
||||
if (group) {
|
||||
await groupMemberships.create({
|
||||
documentId: document.id,
|
||||
groupId: group.id,
|
||||
permission,
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
return user;
|
||||
return;
|
||||
})
|
||||
);
|
||||
|
||||
if (usersInvited.length === 1) {
|
||||
const user = usersInvited[0] as User;
|
||||
toast.message(
|
||||
t("{{ userName }} was invited to the document", {
|
||||
userName: user.name,
|
||||
}),
|
||||
{
|
||||
icon: <Avatar model={user} size={AvatarSize.Toast} />,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
t("{{ count }} people invited to the document", {
|
||||
count: pendingIds.length,
|
||||
})
|
||||
);
|
||||
const invitedUsers = invited.filter(
|
||||
(item) => item instanceof User
|
||||
) as User[];
|
||||
const invitedGroups = invited.filter(
|
||||
(item) => item instanceof Group
|
||||
) as Group[];
|
||||
|
||||
if (invitedUsers.length > 0) {
|
||||
// Special case for the common action of adding a single user.
|
||||
if (invitedUsers.length === 1) {
|
||||
const user = invitedUsers[0];
|
||||
toast.message(
|
||||
t("{{ userName }} was added to the document", {
|
||||
userName: user.name,
|
||||
}),
|
||||
{
|
||||
icon: <Avatar model={user} size={AvatarSize.Toast} />,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
toast.message(
|
||||
t("{{ count }} people added to the document", {
|
||||
count: invitedUsers.length,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
if (invitedGroups.length > 0) {
|
||||
// Special case for the common action of adding a single group.
|
||||
if (invitedGroups.length === 1) {
|
||||
const group = invitedGroups[0];
|
||||
toast.message(
|
||||
t("{{ userName }} was added to the document", {
|
||||
userName: group.name,
|
||||
}),
|
||||
{
|
||||
icon: <GroupAvatar group={group} size={AvatarSize.Toast} />,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
toast.message(
|
||||
t("{{ count }} groups added to the document", {
|
||||
count: invitedGroups.length,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setInvitedInSession((prev) => [...prev, ...pendingIds]);
|
||||
@@ -188,14 +224,16 @@ function SharePopover({
|
||||
},
|
||||
}),
|
||||
[
|
||||
t,
|
||||
pendingIds,
|
||||
document.id,
|
||||
groupMemberships,
|
||||
groups,
|
||||
hidePicker,
|
||||
userMemberships,
|
||||
document.id,
|
||||
pendingIds,
|
||||
permission,
|
||||
users,
|
||||
t,
|
||||
team.defaultUserRole,
|
||||
users,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -341,24 +379,14 @@ function SharePopover({
|
||||
)}
|
||||
|
||||
<div style={{ display: picker ? "none" : "block" }}>
|
||||
<OtherAccess document={document}>
|
||||
<DocumentMembersList
|
||||
document={document}
|
||||
invitedInSession={invitedInSession}
|
||||
/>
|
||||
</OtherAccess>
|
||||
|
||||
{team.sharing && can.share && !collectionSharingDisabled && visible && (
|
||||
<>
|
||||
{document.members.length ? <Separator /> : null}
|
||||
<PublicAccess
|
||||
document={document}
|
||||
share={share}
|
||||
sharedParent={sharedParent}
|
||||
onRequestClose={onRequestClose}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<AccessControlList
|
||||
document={document}
|
||||
invitedInSession={invitedInSession}
|
||||
share={share}
|
||||
sharedParent={sharedParent}
|
||||
visible={visible}
|
||||
onRequestClose={onRequestClose}
|
||||
/>
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import times from "lodash/times";
|
||||
import * as React from "react";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import Fade from "~/components/Fade";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import { ListItem } from "../components/ListItem";
|
||||
|
||||
type Props = {
|
||||
count?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Placeholder for a list item in the share popover.
|
||||
*/
|
||||
export function Placeholder({ count = 1 }: Props) {
|
||||
return (
|
||||
<Fade>
|
||||
{times(count, (index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
image={
|
||||
<PlaceholderText
|
||||
width={AvatarSize.Medium}
|
||||
height={AvatarSize.Medium}
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<PlaceholderText
|
||||
maxWidth={50}
|
||||
minWidth={30}
|
||||
height={14}
|
||||
style={{ marginTop: 4, marginBottom: 4 }}
|
||||
/>
|
||||
}
|
||||
subtitle={
|
||||
<PlaceholderText
|
||||
maxWidth={75}
|
||||
minWidth={50}
|
||||
height={12}
|
||||
style={{ marginBottom: 4 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { isEmail } from "class-validator";
|
||||
import concat from "lodash/concat";
|
||||
import { observer } from "mobx-react";
|
||||
import { CheckmarkIcon, CloseIcon, GroupIcon } from "outline-icons";
|
||||
import { CheckmarkIcon, CloseIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { stringToColor } from "@shared/utils/color";
|
||||
import Collection from "~/models/Collection";
|
||||
@@ -13,11 +12,12 @@ import Document from "~/models/Document";
|
||||
import Group from "~/models/Group";
|
||||
import User from "~/models/User";
|
||||
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { AvatarSize, IAvatar } from "~/components/Avatar/Avatar";
|
||||
import { Avatar, GroupAvatar, AvatarSize, IAvatar } from "~/components/Avatar";
|
||||
import Empty from "~/components/Empty";
|
||||
import Placeholder from "~/components/List/Placeholder";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMaxHeight from "~/hooks/useMaxHeight";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useThrottledCallback from "~/hooks/useThrottledCallback";
|
||||
import { hover } from "~/styles";
|
||||
@@ -40,8 +40,6 @@ type Props = {
|
||||
addPendingId: (id: string) => void;
|
||||
/** Callback to remove a user from the pending list. */
|
||||
removePendingId: (id: string) => void;
|
||||
/** Show group suggestions. */
|
||||
showGroups?: boolean;
|
||||
/** Handles escape from suggestions list */
|
||||
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
@@ -55,7 +53,6 @@ export const Suggestions = observer(
|
||||
pendingIds,
|
||||
addPendingId,
|
||||
removePendingId,
|
||||
showGroups,
|
||||
onEscape,
|
||||
}: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
@@ -64,15 +61,16 @@ export const Suggestions = observer(
|
||||
const { users, groups } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const theme = useTheme();
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const { maxHeight } = useMaxHeight({
|
||||
elementRef: containerRef,
|
||||
maxViewportPercentage: 70,
|
||||
});
|
||||
|
||||
const fetchUsersByQuery = useThrottledCallback(
|
||||
(query: string) => {
|
||||
void users.fetchPage({ query });
|
||||
|
||||
if (showGroups) {
|
||||
void groups.fetchPage({ query });
|
||||
}
|
||||
void groups.fetchPage({ query });
|
||||
},
|
||||
250,
|
||||
undefined,
|
||||
@@ -100,21 +98,26 @@ export const Suggestions = observer(
|
||||
: collection
|
||||
? users.notInCollection(collection.id, query)
|
||||
: users.orderedData
|
||||
).filter((u) => u.id !== user.id && !u.isSuspended);
|
||||
).filter((u) => !u.isSuspended && u.id !== user.id);
|
||||
|
||||
if (isEmail(query)) {
|
||||
filtered.push(getSuggestionForEmail(query));
|
||||
}
|
||||
|
||||
if (collection?.id) {
|
||||
return [...groups.notInCollection(collection.id, query), ...filtered];
|
||||
}
|
||||
|
||||
return filtered;
|
||||
return [
|
||||
...(document
|
||||
? groups.notInDocument(document.id, query)
|
||||
: collection
|
||||
? groups.notInCollection(collection.id, query)
|
||||
: []),
|
||||
...filtered,
|
||||
];
|
||||
}, [
|
||||
getSuggestionForEmail,
|
||||
users,
|
||||
users.orderedData,
|
||||
groups,
|
||||
groups.orderedData,
|
||||
document?.id,
|
||||
document?.members,
|
||||
collection?.id,
|
||||
@@ -132,7 +135,7 @@ export const Suggestions = observer(
|
||||
: users.get(id) ?? groups.get(id)
|
||||
)
|
||||
.filter(Boolean) as User[],
|
||||
[users, getSuggestionForEmail, pendingIds]
|
||||
[users, groups, getSuggestionForEmail, pendingIds]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -146,11 +149,7 @@ export const Suggestions = observer(
|
||||
subtitle: t("{{ count }} member", {
|
||||
count: suggestion.memberCount,
|
||||
}),
|
||||
image: (
|
||||
<Squircle color={theme.text} size={AvatarSize.Medium}>
|
||||
<GroupIcon color={theme.background} size={16} />
|
||||
</Squircle>
|
||||
),
|
||||
image: <GroupAvatar group={suggestion} />,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -182,55 +181,63 @@ export const Suggestions = observer(
|
||||
neverRenderedList.current = false;
|
||||
|
||||
return (
|
||||
<ArrowKeyNavigation
|
||||
ref={ref}
|
||||
onEscape={onEscape}
|
||||
aria-label={t("Suggestions for invitation")}
|
||||
items={concat(pending, suggestionsWithPending)}
|
||||
<ScrollableContainer
|
||||
ref={containerRef}
|
||||
hiddenScrollbars
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
{() => [
|
||||
...pending.map((suggestion) => (
|
||||
<PendingListItem
|
||||
keyboardNavigation
|
||||
{...getListItemProps(suggestion)}
|
||||
key={suggestion.id}
|
||||
onClick={() => removePendingId(suggestion.id)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
removePendingId(suggestion.id);
|
||||
<ArrowKeyNavigation
|
||||
ref={ref}
|
||||
onEscape={onEscape}
|
||||
aria-label={t("Suggestions for invitation")}
|
||||
items={concat(pending, suggestionsWithPending)}
|
||||
>
|
||||
{() => [
|
||||
...pending.map((suggestion) => (
|
||||
<PendingListItem
|
||||
keyboardNavigation
|
||||
{...getListItemProps(suggestion)}
|
||||
key={suggestion.id}
|
||||
onClick={() => removePendingId(suggestion.id)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
removePendingId(suggestion.id);
|
||||
}
|
||||
}}
|
||||
actions={
|
||||
<>
|
||||
<InvitedIcon />
|
||||
<RemoveIcon />
|
||||
</>
|
||||
}
|
||||
}}
|
||||
actions={
|
||||
<>
|
||||
<InvitedIcon />
|
||||
<RemoveIcon />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)),
|
||||
pending.length > 0 &&
|
||||
(suggestionsWithPending.length > 0 || isEmpty) && <Separator />,
|
||||
...suggestionsWithPending.map((suggestion) => (
|
||||
<ListItem
|
||||
keyboardNavigation
|
||||
{...getListItemProps(suggestion as User)}
|
||||
key={suggestion.id}
|
||||
onClick={() => addPendingId(suggestion.id)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
addPendingId(suggestion.id);
|
||||
}
|
||||
}}
|
||||
actions={<InviteIcon />}
|
||||
/>
|
||||
)),
|
||||
isEmpty && <Empty style={{ marginTop: 22 }}>{t("No matches")}</Empty>,
|
||||
]}
|
||||
</ArrowKeyNavigation>
|
||||
/>
|
||||
)),
|
||||
pending.length > 0 &&
|
||||
(suggestionsWithPending.length > 0 || isEmpty) && <Separator />,
|
||||
...suggestionsWithPending.map((suggestion) => (
|
||||
<ListItem
|
||||
keyboardNavigation
|
||||
{...getListItemProps(suggestion as User)}
|
||||
key={suggestion.id}
|
||||
onClick={() => addPendingId(suggestion.id)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
addPendingId(suggestion.id);
|
||||
}
|
||||
}}
|
||||
actions={<InviteIcon />}
|
||||
/>
|
||||
)),
|
||||
isEmpty && (
|
||||
<Empty style={{ marginTop: 22 }}>{t("No matches")}</Empty>
|
||||
),
|
||||
]}
|
||||
</ArrowKeyNavigation>
|
||||
</ScrollableContainer>
|
||||
);
|
||||
})
|
||||
);
|
||||
@@ -259,3 +266,8 @@ const Separator = styled.div`
|
||||
border-top: 1px dashed ${s("divider")};
|
||||
margin: 12px 0;
|
||||
`;
|
||||
|
||||
const ScrollableContainer = styled(Scrollable)`
|
||||
padding: 12px 24px;
|
||||
margin: -12px -24px;
|
||||
`;
|
||||
|
||||
@@ -34,16 +34,18 @@ import TrashLink from "./components/TrashLink";
|
||||
|
||||
function AppSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { documents, ui } = useStores();
|
||||
const { documents, ui, collections } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const can = usePolicy(team);
|
||||
|
||||
React.useEffect(() => {
|
||||
void collections.fetchAll();
|
||||
|
||||
if (!user.isViewer) {
|
||||
void documents.fetchDrafts();
|
||||
}
|
||||
}, [documents, user.isViewer]);
|
||||
}, [documents, collections, user.isViewer]);
|
||||
|
||||
const [dndArea, setDndArea] = React.useState();
|
||||
const handleSidebarRef = React.useCallback((node) => setDndArea(node), []);
|
||||
@@ -92,43 +94,43 @@ function AppSidebar() {
|
||||
</SidebarButton>
|
||||
)}
|
||||
</OrganizationMenu>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to={homePath()}
|
||||
icon={<HomeIcon />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to={searchPath()}
|
||||
icon={<SearchIcon />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
/>
|
||||
{can.createDocument && (
|
||||
<SidebarLink
|
||||
to={draftsPath()}
|
||||
icon={<DraftsIcon />}
|
||||
label={
|
||||
<Flex align="center" justify="space-between">
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 ? (
|
||||
<Drafts size="xsmall" type="tertiary">
|
||||
{documents.totalDrafts}
|
||||
</Drafts>
|
||||
) : null}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
<Scrollable flex shadow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to={homePath()}
|
||||
icon={<HomeIcon />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to={searchPath()}
|
||||
icon={<SearchIcon />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
/>
|
||||
{can.createDocument && (
|
||||
<SidebarLink
|
||||
to={draftsPath()}
|
||||
icon={<DraftsIcon />}
|
||||
label={
|
||||
<Flex align="center" justify="space-between">
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 ? (
|
||||
<Drafts size="xsmall" type="tertiary">
|
||||
{documents.totalDrafts}
|
||||
</Drafts>
|
||||
) : null}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Starred />
|
||||
</Section>
|
||||
<Section>
|
||||
<SharedWithMe />
|
||||
</Section>
|
||||
<Section>
|
||||
<Starred />
|
||||
</Section>
|
||||
<Section auto>
|
||||
<Collections />
|
||||
</Section>
|
||||
|
||||
@@ -50,7 +50,8 @@ function Right({ children, border, className }: Props) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = React.useCallback(() => {
|
||||
const handleMouseDown = React.useCallback((event) => {
|
||||
event.preventDefault();
|
||||
setResizing(true);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
{team && (
|
||||
{team?.name && (
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
image={<TeamLogo model={team} size={32} alt={t("Logo")} />}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useLocation } from "react-router-dom";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMenuContext from "~/hooks/useMenuContext";
|
||||
@@ -13,7 +14,6 @@ import AccountMenu from "~/menus/AccountMenu";
|
||||
import { fadeOnDesktopBackgrounded } from "~/styles";
|
||||
import { fadeIn } from "~/styles/animations";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import Avatar from "../Avatar";
|
||||
import NotificationIcon from "../Notifications/NotificationIcon";
|
||||
import NotificationsPopover from "../Notifications/NotificationsPopover";
|
||||
import ResizeBorder from "./components/ResizeBorder";
|
||||
@@ -94,6 +94,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
|
||||
const handleMouseDown = React.useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
setOffset(event.pageX - width);
|
||||
setResizing(true);
|
||||
setAnimating(false);
|
||||
@@ -143,8 +144,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isResizing) {
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.addEventListener("mousemove", handleDrag);
|
||||
document.addEventListener("mouseup", handleStopDrag);
|
||||
} else {
|
||||
document.body.style.cursor = "initial";
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -270,14 +274,12 @@ const hoverStyles = (props: ContainerProps) => `
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)<ContainerProps>`
|
||||
opacity: ${(props) => (props.$hidden ? 0 : 1)};
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background: ${s("sidebarBackground")};
|
||||
transition: box-shadow 150ms ease-in-out, opacity 150ms ease-in-out,
|
||||
transform 150ms ease-out,
|
||||
transition: box-shadow 150ms ease-in-out, transform 150ms ease-out,
|
||||
${s("backgroundTransition")}
|
||||
${(props: ContainerProps) =>
|
||||
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
|
||||
@@ -295,7 +297,9 @@ const Container = styled(Flex)<ContainerProps>`
|
||||
}
|
||||
|
||||
& > div {
|
||||
opacity: ${(props) => (props.$collapsed && !props.$isHovering ? "0" : "1")};
|
||||
transition: opacity 150ms ease-in-out;
|
||||
opacity: ${(props) =>
|
||||
props.$hidden || (props.$collapsed && !props.$isHovering) ? "0" : "1"};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
|
||||
@@ -5,7 +5,6 @@ import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
@@ -22,8 +21,8 @@ import CollectionMenu from "~/menus/CollectionMenu";
|
||||
import DropToImport from "./DropToImport";
|
||||
import EditableTitle, { RefHandle } from "./EditableTitle";
|
||||
import Relative from "./Relative";
|
||||
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
import { useStarredContext } from "./StarredContext";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -39,16 +38,13 @@ const CollectionLink: React.FC<Props> = ({
|
||||
onDisclosureClick,
|
||||
isDraggingAnyCollection,
|
||||
}: Props) => {
|
||||
const itemRef = React.useRef<
|
||||
NavigationNode & { depth: number; active: boolean; collectionId: string }
|
||||
>();
|
||||
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 inStarredSection = useStarredContext();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const editableTitleRef = React.useRef<RefHandle>(null);
|
||||
|
||||
const handleTitleChange = React.useCallback(
|
||||
@@ -86,8 +82,6 @@ const CollectionLink: React.FC<Props> = ({
|
||||
prevCollection.permission !== collection.permission &&
|
||||
!document?.isDraft
|
||||
) {
|
||||
itemRef.current = item;
|
||||
|
||||
dialogs.openModal({
|
||||
title: t("Move document"),
|
||||
content: (
|
||||
@@ -100,7 +94,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
),
|
||||
});
|
||||
} else {
|
||||
await documents.move(id, collection.id);
|
||||
await documents.move({ documentId: id, collectionId: collection.id });
|
||||
|
||||
if (!expanded) {
|
||||
onDisclosureClick();
|
||||
@@ -116,78 +110,69 @@ const CollectionLink: React.FC<Props> = ({
|
||||
}),
|
||||
});
|
||||
|
||||
const handleTitleEditing = React.useCallback((isEditing: boolean) => {
|
||||
setIsEditing(isEditing);
|
||||
}, []);
|
||||
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
void collection.fetchDocuments();
|
||||
}, [collection]);
|
||||
|
||||
const context = useActionContext({
|
||||
activeCollectionId: collection.id,
|
||||
inStarredSection,
|
||||
sidebarContext,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Relative ref={drop}>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
to={{
|
||||
pathname: collection.path,
|
||||
state: { starred: inStarredSection },
|
||||
}}
|
||||
expanded={expanded}
|
||||
onDisclosureClick={onDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
icon={
|
||||
<CollectionIcon collection={collection} expanded={expanded} />
|
||||
}
|
||||
showActions={menuOpen}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
isActive={(match, location: Location<{ starred?: boolean }>) =>
|
||||
!!match && location.state?.starred === inStarredSection
|
||||
}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={collection.name}
|
||||
onSubmit={handleTitleChange}
|
||||
onEditing={handleTitleEditing}
|
||||
canUpdate={can.update}
|
||||
maxLength={CollectionValidation.maxNameLength}
|
||||
ref={editableTitleRef}
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
depth={0}
|
||||
menu={
|
||||
!isEditing &&
|
||||
!isDraggingAnyCollection && (
|
||||
<Fade>
|
||||
<NudeButton
|
||||
tooltip={{ content: t("New doc"), delay: 500 }}
|
||||
action={createDocument}
|
||||
context={context}
|
||||
hideOnActionDisabled
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onRename={() =>
|
||||
editableTitleRef.current?.setIsEditing(true)
|
||||
}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</DropToImport>
|
||||
</Relative>
|
||||
</>
|
||||
<Relative ref={drop}>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
to={{
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
expanded={expanded}
|
||||
onDisclosureClick={onDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
icon={<CollectionIcon collection={collection} expanded={expanded} />}
|
||||
showActions={menuOpen}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
isActive={(
|
||||
match,
|
||||
location: Location<{ sidebarContext?: SidebarContextType }>
|
||||
) => !!match && location.state?.sidebarContext === sidebarContext}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={collection.name}
|
||||
onSubmit={handleTitleChange}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={can.update}
|
||||
maxLength={CollectionValidation.maxNameLength}
|
||||
ref={editableTitleRef}
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
depth={0}
|
||||
menu={
|
||||
!isEditing &&
|
||||
!isDraggingAnyCollection && (
|
||||
<Fade>
|
||||
<NudeButton
|
||||
tooltip={{ content: t("New doc"), delay: 500 }}
|
||||
action={createDocument}
|
||||
context={context}
|
||||
hideOnActionDisabled
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onRename={() => editableTitleRef.current?.setIsEditing(true)}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</DropToImport>
|
||||
</Relative>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -52,7 +52,11 @@ function CollectionLinkChildren({
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
void documents.move(item.id, collection.id, undefined, 0);
|
||||
void documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
index: 0,
|
||||
});
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: !!monitor.isOver(),
|
||||
|
||||
@@ -53,7 +53,6 @@ function Collections() {
|
||||
<Header id="collections" title={t("Collections")}>
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
fetch={collections.fetchPage}
|
||||
options={params}
|
||||
aria-label={t("Collections")}
|
||||
items={collections.orderedData}
|
||||
|
||||
@@ -42,7 +42,7 @@ const Button = styled(NudeButton)<{ $root?: boolean }>`
|
||||
props.$root &&
|
||||
css`
|
||||
opacity: 0;
|
||||
left: -16px;
|
||||
left: -18px;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
|
||||
@@ -2,11 +2,8 @@ import { Location } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
import { getEmptyImage } from "react-dnd-html5-backend";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
@@ -27,9 +24,13 @@ import DropToImport from "./DropToImport";
|
||||
import EditableTitle, { RefHandle } from "./EditableTitle";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import { useSharedContext } from "./SharedContext";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
import { useStarredContext } from "./StarredContext";
|
||||
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import {
|
||||
useDragDocument,
|
||||
useDropToReorderDocument,
|
||||
useDropToReparentDocument,
|
||||
} from "./useDragAndDrop";
|
||||
|
||||
type Props = {
|
||||
node: NavigationNode;
|
||||
@@ -65,26 +66,23 @@ function InnerDocumentLink(
|
||||
const { fetchChildDocuments } = documents;
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const editableTitleRef = React.useRef<RefHandle>(null);
|
||||
const inStarredSection = useStarredContext();
|
||||
const inSharedSection = useSharedContext();
|
||||
const sidebarContext = useSidebarContext();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isActiveDocument && (hasChildDocuments || inSharedSection)) {
|
||||
if (
|
||||
isActiveDocument &&
|
||||
(hasChildDocuments || sidebarContext !== "collections")
|
||||
) {
|
||||
void fetchChildDocuments(node.id);
|
||||
}
|
||||
}, [
|
||||
fetchChildDocuments,
|
||||
node.id,
|
||||
hasChildDocuments,
|
||||
inSharedSection,
|
||||
sidebarContext,
|
||||
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(() => {
|
||||
@@ -128,135 +126,52 @@ function InnerDocumentLink(
|
||||
}, [prefetchDocument, node]);
|
||||
|
||||
const handleTitleChange = React.useCallback(
|
||||
async (title: string) => {
|
||||
async (value: string) => {
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
await documents.update({
|
||||
id: document.id,
|
||||
title,
|
||||
title: value,
|
||||
});
|
||||
},
|
||||
[documents, document]
|
||||
);
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const isMoving = documents.movingDocumentId === node.id;
|
||||
const manualSort = collection?.sort.field === "index";
|
||||
const can = policies.abilities(node.id);
|
||||
const icon = document?.icon || node.icon || node.emoji;
|
||||
const color = document?.color || node.color;
|
||||
|
||||
// Draggable
|
||||
const [{ isDragging }, drag, preview] = useDrag({
|
||||
type: "document",
|
||||
item: () => ({
|
||||
...node,
|
||||
depth,
|
||||
active: isActiveDocument,
|
||||
collectionId: collection?.id || "",
|
||||
}),
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
canDrag: () => can.move || can.archive || can.delete,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
preview(getEmptyImage(), { captureDraggingState: true });
|
||||
}, [preview]);
|
||||
|
||||
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;
|
||||
}
|
||||
}, []);
|
||||
const [{ isDragging }, drag] = useDragDocument(node, depth, document);
|
||||
|
||||
// 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(item.id, collection.id, node.id);
|
||||
setExpanded(true);
|
||||
},
|
||||
canDrop: (item, monitor) =>
|
||||
!isDraft &&
|
||||
!!pathToNode &&
|
||||
!pathToNode.includes(monitor.getItem<DragObject>().id) &&
|
||||
item.id !== node.id,
|
||||
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] = useDrop({
|
||||
accept: "document",
|
||||
drop: (item: DragObject) => {
|
||||
if (!manualSort) {
|
||||
toast.message(
|
||||
t(
|
||||
"You can't reorder documents in an alphabetically sorted collection"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] =
|
||||
useDropToReorderDocument(node, collection, (item) => {
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
if (item.id === node.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
void documents.move(item.id, collection.id, node.id, 0);
|
||||
return;
|
||||
return {
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: node.id,
|
||||
index: 0,
|
||||
};
|
||||
}
|
||||
|
||||
void documents.move(item.id, collection.id, parentId, index + 1);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: monitor.isOver(),
|
||||
isDraggingAnyDocument: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
return {
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: parentId,
|
||||
index: index + 1,
|
||||
};
|
||||
});
|
||||
|
||||
const nodeChildren = React.useMemo(() => {
|
||||
const insertDraftDocument =
|
||||
@@ -280,11 +195,8 @@ function InnerDocumentLink(
|
||||
node,
|
||||
]);
|
||||
|
||||
const title =
|
||||
(activeDocument?.id === node.id ? activeDocument.title : node.title) ||
|
||||
t("Untitled");
|
||||
const icon = document?.icon || node.icon;
|
||||
const color = document?.color || node.color;
|
||||
const doc = documents.get(node.id);
|
||||
const title = doc?.title || node.title || t("Untitled");
|
||||
|
||||
const isExpanded = expanded && !isDragging;
|
||||
const hasChildren = nodeChildren.length > 0;
|
||||
@@ -295,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}
|
||||
@@ -324,7 +236,7 @@ function InnerDocumentLink(
|
||||
pathname: node.url,
|
||||
state: {
|
||||
title: node.title,
|
||||
starred: inStarredSection,
|
||||
sidebarContext,
|
||||
},
|
||||
}}
|
||||
icon={icon && <Icon value={icon} color={color} />}
|
||||
@@ -338,16 +250,25 @@ function InnerDocumentLink(
|
||||
ref={editableTitleRef}
|
||||
/>
|
||||
}
|
||||
isActive={(match, location: Location<{ starred?: boolean }>) =>
|
||||
((document && location.pathname.endsWith(document.urlId)) ||
|
||||
!!match) &&
|
||||
location.state?.starred === inStarredSection
|
||||
}
|
||||
isActive={(
|
||||
match,
|
||||
location: Location<{
|
||||
sidebarContext?: SidebarContextType;
|
||||
}>
|
||||
) => {
|
||||
if (sidebarContext !== location.state?.sidebarContext) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(document && location.pathname.endsWith(document.urlId)) ||
|
||||
!!match
|
||||
);
|
||||
}}
|
||||
isActiveDrop={isOverReparent && canDropToReparent}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
showActions={menuOpen}
|
||||
scrollIntoViewIfNeeded={!inStarredSection}
|
||||
scrollIntoViewIfNeeded={sidebarContext === "collections"}
|
||||
isDraft={isDraft}
|
||||
ref={ref}
|
||||
menu={
|
||||
@@ -383,7 +304,7 @@ function InnerDocumentLink(
|
||||
</DropToImport>
|
||||
</div>
|
||||
</Draggable>
|
||||
{isDraggingAnyDocument && manualSort && (
|
||||
{isDraggingAnyDocument && collection?.isManualSort && (
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
)}
|
||||
</Relative>
|
||||
|
||||
@@ -3,17 +3,17 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useDrop, useDrag, DropTargetMonitor } from "react-dnd";
|
||||
import { getEmptyImage } from "react-dnd-html5-backend";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useLocationState } from "../hooks/useLocationState";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Relative from "./Relative";
|
||||
import { useSidebarContext } from "./SidebarContext";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
@@ -23,25 +23,19 @@ type Props = {
|
||||
belowCollection: Collection | void;
|
||||
};
|
||||
|
||||
function useLocationStateStarred() {
|
||||
const location = useLocation<{
|
||||
starred?: boolean;
|
||||
}>();
|
||||
return location.state?.starred;
|
||||
}
|
||||
|
||||
function DraggableCollectionLink({
|
||||
collection,
|
||||
activeDocument,
|
||||
prefetchDocument,
|
||||
belowCollection,
|
||||
}: Props) {
|
||||
const locationStateStarred = useLocationStateStarred();
|
||||
const { ui, collections } = useStores();
|
||||
const locationSidebarContext = useLocationState();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const { ui, policies, collections } = useStores();
|
||||
const [expanded, setExpanded] = React.useState(
|
||||
collection.id === ui.activeCollectionId && !locationStateStarred
|
||||
collection.id === ui.activeCollectionId &&
|
||||
sidebarContext === locationSidebarContext
|
||||
);
|
||||
const can = usePolicy(collection);
|
||||
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
|
||||
|
||||
// Drop to reorder collection
|
||||
@@ -58,7 +52,8 @@ function DraggableCollectionLink({
|
||||
},
|
||||
canDrop: (item) =>
|
||||
collection.id !== item.id &&
|
||||
(!belowCollection || item.id !== belowCollection.id),
|
||||
(!belowCollection || item.id !== belowCollection.id) &&
|
||||
policies.abilities(item.id)?.move,
|
||||
collect: (monitor: DropTargetMonitor<Collection, Collection>) => ({
|
||||
isCollectionDropping: monitor.isOver(),
|
||||
isDraggingAnyCollection: monitor.canDrop(),
|
||||
@@ -76,7 +71,6 @@ function DraggableCollectionLink({
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
canDrag: () => can.move,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -86,10 +80,18 @@ function DraggableCollectionLink({
|
||||
// If the current collection is active and relevant to the sidebar section we
|
||||
// are in then expand it automatically
|
||||
React.useEffect(() => {
|
||||
if (collection.id === ui.activeCollectionId && !locationStateStarred) {
|
||||
if (
|
||||
collection.id === ui.activeCollectionId &&
|
||||
sidebarContext === locationSidebarContext
|
||||
) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [collection.id, ui.activeCollectionId, locationStateStarred]);
|
||||
}, [
|
||||
collection.id,
|
||||
ui.activeCollectionId,
|
||||
sidebarContext,
|
||||
locationSidebarContext,
|
||||
]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev?.preventDefault();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Group from "~/models/Group";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import SharedWithMeLink from "./SharedWithMeLink";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
/** The group to render */
|
||||
group: Group;
|
||||
};
|
||||
|
||||
const GroupLink: React.FC<Props> = ({ group }) => {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev?.preventDefault();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Relative>
|
||||
<SidebarLink
|
||||
label={group.name}
|
||||
icon={<GroupIcon />}
|
||||
expanded={expanded}
|
||||
onClick={handleDisclosureClick}
|
||||
depth={0}
|
||||
/>
|
||||
<SidebarContext.Provider value={group.id}>
|
||||
<Folder expanded={expanded}>
|
||||
{group.documentMemberships.map((membership) => (
|
||||
<SharedWithMeLink
|
||||
key={membership.id}
|
||||
membership={membership}
|
||||
depth={1}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
</SidebarContext.Provider>
|
||||
</Relative>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(GroupLink);
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
const SharedContext = React.createContext<boolean | undefined>(undefined);
|
||||
|
||||
export const useSharedContext = () => React.useContext(SharedContext);
|
||||
|
||||
export default SharedContext;
|
||||
@@ -101,6 +101,8 @@ function DocumentLink(
|
||||
(activeDocument?.id === node.id ? activeDocument.title : node.title) ||
|
||||
t("Untitled");
|
||||
|
||||
const icon = node.icon ?? node.emoji;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarLink
|
||||
@@ -112,7 +114,7 @@ function DocumentLink(
|
||||
}}
|
||||
expanded={hasChildDocuments && depth !== 0 ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
icon={node.icon && <Icon value={node.icon} color={node.color} />}
|
||||
icon={icon && <Icon value={icon} color={node.color} />}
|
||||
label={title}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import GroupMembership from "~/models/GroupMembership";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -11,27 +12,30 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePaginatedRequest from "~/hooks/usePaginatedRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DropCursor from "./DropCursor";
|
||||
import GroupLink from "./GroupLink";
|
||||
import Header from "./Header";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SharedContext from "./SharedContext";
|
||||
import SharedWithMeLink from "./SharedWithMeLink";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import { useDropToReorderUserMembership } from "./useDragAndDrop";
|
||||
|
||||
function SharedWithMe() {
|
||||
const { userMemberships } = useStores();
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
|
||||
usePaginatedRequest<GroupMembership>(groupMemberships.fetchAll);
|
||||
|
||||
const { loading, next, end, error, page } =
|
||||
usePaginatedRequest<UserMembership>(userMemberships.fetchPage, {
|
||||
limit: Pagination.sidebarLimit,
|
||||
});
|
||||
|
||||
// Drop to reorder document
|
||||
const [reorderMonitor, dropToReorderRef] = useDropToReorderUserMembership(
|
||||
() => fractionalIndex(null, user.memberships[0].index)
|
||||
const [reorderProps, dropToReorderRef] = useDropToReorderUserMembership(() =>
|
||||
fractionalIndex(null, user.documentMemberships[0].index)
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -40,29 +44,32 @@ function SharedWithMe() {
|
||||
}
|
||||
}, [error, t]);
|
||||
|
||||
if (!user.memberships.length) {
|
||||
if (
|
||||
!user.documentMemberships.length &&
|
||||
!user.groupsWithDocumentMemberships.length
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SharedContext.Provider value={true}>
|
||||
<SidebarContext.Provider value="shared">
|
||||
<Flex column>
|
||||
<Header id="shared" title={t("Shared with me")}>
|
||||
{user.groupsWithDocumentMemberships.map((group) => (
|
||||
<GroupLink key={group.id} group={group} />
|
||||
))}
|
||||
<Relative>
|
||||
{reorderMonitor.isDragging && (
|
||||
{reorderProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderMonitor.isOverCursor}
|
||||
isActiveDrop={reorderProps.isOverCursor}
|
||||
innerRef={dropToReorderRef}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{user.memberships
|
||||
{user.documentMemberships
|
||||
.slice(0, page * Pagination.sidebarLimit)
|
||||
.map((membership) => (
|
||||
<SharedWithMeLink
|
||||
key={membership.id}
|
||||
userMembership={membership}
|
||||
/>
|
||||
<SharedWithMeLink key={membership.id} membership={membership} />
|
||||
))}
|
||||
{!end && (
|
||||
<SidebarLink
|
||||
@@ -82,7 +89,7 @@ function SharedWithMe() {
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
</SharedContext.Provider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,44 +1,64 @@
|
||||
import fractionalIndex from "fractional-index";
|
||||
import { Location } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { IconType, NotificationEventType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import GroupMembership from "~/models/GroupMembership";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import Fade from "~/components/Fade";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { useLocationState } from "../hooks/useLocationState";
|
||||
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 {
|
||||
useDragUserMembership,
|
||||
useDragMembership,
|
||||
useDropToReorderUserMembership,
|
||||
useDropToReparentDocument,
|
||||
} from "./useDragAndDrop";
|
||||
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
|
||||
|
||||
type Props = {
|
||||
userMembership: UserMembership;
|
||||
membership: UserMembership | GroupMembership;
|
||||
depth?: number;
|
||||
};
|
||||
|
||||
function SharedWithMeLink({ userMembership }: Props) {
|
||||
function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
const { ui, collections, documents } = useStores();
|
||||
const { fetchChildDocuments } = documents;
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const { documentId } = userMembership;
|
||||
const { documentId } = membership;
|
||||
const isActiveDocument = documentId === ui.activeDocumentId;
|
||||
const [expanded, setExpanded] = React.useState(
|
||||
userMembership.documentId === ui.activeDocumentId
|
||||
const locationSidebarContext = useLocationState();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const document = documentId ? documents.get(documentId) : undefined;
|
||||
|
||||
const [expanded, setExpanded, setCollapsed] = useBoolean(
|
||||
membership.documentId === ui.activeDocumentId &&
|
||||
locationSidebarContext === sidebarContext
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (userMembership.documentId === ui.activeDocumentId) {
|
||||
setExpanded(true);
|
||||
if (
|
||||
membership.documentId === ui.activeDocumentId &&
|
||||
locationSidebarContext === sidebarContext
|
||||
) {
|
||||
setExpanded();
|
||||
}
|
||||
}, [userMembership.documentId, ui.activeDocumentId]);
|
||||
}, [
|
||||
membership.documentId,
|
||||
ui.activeDocumentId,
|
||||
sidebarContext,
|
||||
locationSidebarContext,
|
||||
setExpanded,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (documentId) {
|
||||
@@ -47,38 +67,45 @@ function SharedWithMeLink({ userMembership }: Props) {
|
||||
}, [documentId, documents]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isActiveDocument && userMembership.documentId) {
|
||||
void fetchChildDocuments(userMembership.documentId);
|
||||
if (isActiveDocument && membership.documentId) {
|
||||
void fetchChildDocuments(membership.documentId);
|
||||
}
|
||||
}, [fetchChildDocuments, isActiveDocument, userMembership.documentId]);
|
||||
}, [fetchChildDocuments, isActiveDocument, membership.documentId]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev: React.MouseEvent<HTMLButtonElement>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setExpanded((prevExpanded) => !prevExpanded);
|
||||
if (expanded) {
|
||||
setCollapsed();
|
||||
} else {
|
||||
setExpanded();
|
||||
}
|
||||
},
|
||||
[]
|
||||
[expanded, setExpanded, setCollapsed]
|
||||
);
|
||||
|
||||
const { icon } = useSidebarLabelAndIcon(userMembership);
|
||||
const [{ isDragging }, draggableRef] = useDragUserMembership(userMembership);
|
||||
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);
|
||||
|
||||
const getIndex = () => {
|
||||
const next = userMembership?.next();
|
||||
return fractionalIndex(userMembership?.index || null, next?.index || null);
|
||||
if (membership instanceof UserMembership) {
|
||||
const next = membership?.next();
|
||||
return fractionalIndex(membership?.index || null, next?.index || null);
|
||||
}
|
||||
return "";
|
||||
};
|
||||
const [reorderMonitor, dropToReorderRef] =
|
||||
const [reorderProps, dropToReorderRef] =
|
||||
useDropToReorderUserMembership(getIndex);
|
||||
|
||||
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
|
||||
@@ -94,63 +121,75 @@ function SharedWithMeLink({ userMembership }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Draggable
|
||||
key={userMembership.id}
|
||||
ref={draggableRef}
|
||||
$isDragging={isDragging}
|
||||
>
|
||||
<SidebarLink
|
||||
depth={0}
|
||||
to={{
|
||||
pathname: document.path,
|
||||
state: { starred: true },
|
||||
}}
|
||||
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
icon={icon}
|
||||
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>
|
||||
{reorderMonitor.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderMonitor.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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as React from "react";
|
||||
|
||||
export type SidebarContextType = "collections" | "starred" | string | undefined;
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextType>(undefined);
|
||||
|
||||
export const useSidebarContext = () => React.useContext(SidebarContext);
|
||||
|
||||
export default SidebarContext;
|
||||
@@ -14,7 +14,6 @@ import NavLink, { Props as NavLinkProps } from "./NavLink";
|
||||
|
||||
export type DragObject = NavigationNode & {
|
||||
depth: number;
|
||||
active: boolean;
|
||||
collectionId: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import DropCursor from "./DropCursor";
|
||||
import Header from "./Header";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import StarredContext from "./StarredContext";
|
||||
import StarredLink from "./StarredLink";
|
||||
import { useDropToCreateStar, useDropToReorderStar } from "./useDragAndDrop";
|
||||
|
||||
@@ -25,8 +25,8 @@ function Starred() {
|
||||
const { loading, next, end, error, page } = usePaginatedRequest<Star>(
|
||||
stars.fetchPage
|
||||
);
|
||||
const [reorderStarMonitor, dropToReorder] = useDropToReorderStar();
|
||||
const [createStarMonitor, dropToStarRef] = useDropToCreateStar();
|
||||
const [reorderStarProps, dropToReorder] = useDropToReorderStar();
|
||||
const [createStarProps, dropToStarRef] = useDropToCreateStar();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
@@ -39,20 +39,20 @@ function Starred() {
|
||||
}
|
||||
|
||||
return (
|
||||
<StarredContext.Provider value={true}>
|
||||
<SidebarContext.Provider value="starred">
|
||||
<Flex column>
|
||||
<Header id="starred" title={t("Starred")}>
|
||||
<Relative>
|
||||
{reorderStarMonitor.isDragging && (
|
||||
{reorderStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderStarMonitor.isOverCursor}
|
||||
isActiveDrop={reorderStarProps.isOverCursor}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{createStarMonitor.isDragging && (
|
||||
{createStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={createStarMonitor.isOverCursor}
|
||||
isActiveDrop={createStarProps.isOverCursor}
|
||||
innerRef={dropToStarRef}
|
||||
position="top"
|
||||
/>
|
||||
@@ -80,7 +80,7 @@ function Starred() {
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
</StarredContext.Provider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
const StarredContext = React.createContext<boolean | undefined>(undefined);
|
||||
|
||||
export const useStarredContext = () => React.useContext(StarredContext);
|
||||
|
||||
export default StarredContext;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user