mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
321 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 514566ee75 | |||
| 908d0408f5 | |||
| 269bd60b5a | |||
| 87c03fd088 | |||
| b9a8b0f6d6 | |||
| 34ee3b7ea7 | |||
| 5ffe02bcc0 | |||
| 670428d322 | |||
| 3e58a6ca46 | |||
| b21d548d06 | |||
| cadbd0d698 | |||
| 6fdba0ecba | |||
| bb72774f2d | |||
| 76868a3083 | |||
| 0865052bb8 | |||
| de6bc9beca | |||
| e97944ab40 | |||
| 5cfea207e6 | |||
| 95f0c42d56 | |||
| ee7738c141 | |||
| 76701e35ec | |||
| ae8c2aae15 | |||
| a9fa2ed72b | |||
| 0deb7e7f09 | |||
| a544559de2 | |||
| 79fe08e9b6 | |||
| c8d8ba3914 | |||
| 7a148b0353 | |||
| ca891a56da | |||
| 294d3e896a | |||
| d947f8fda2 | |||
| 6dd228a533 | |||
| c7d847215c | |||
| 6995ca8521 | |||
| 8a3452e664 | |||
| f6315875b4 | |||
| f4e53da1bf | |||
| 643188b2f3 | |||
| 6f8f25b0d1 | |||
| 10c3edded7 | |||
| 398943d084 | |||
| a02677c2b1 | |||
| ebf2029539 | |||
| 0df42cb4c7 | |||
| 72c9091b7e | |||
| 740e33156d | |||
| d8ef7b2892 | |||
| 0f9146066c | |||
| 06a1428cbc | |||
| e71a425268 | |||
| 12d31468f8 | |||
| 211c57f6aa | |||
| bb475f3e4e | |||
| 9b95a58822 | |||
| fce02996f9 | |||
| 1aa05b797c | |||
| b69feb50a7 | |||
| 640ecca9ca | |||
| 5fbaa32f18 | |||
| 50b2cf2706 | |||
| db9deb2a46 | |||
| 72cc740b1c | |||
| 4d9717631d | |||
| 69e07a9c21 | |||
| 7b27b74e24 | |||
| 92db179230 | |||
| fa93092f79 | |||
| 63c5938a43 | |||
| a4f77e4438 | |||
| 7bb8ff4797 | |||
| bcdedd53d8 | |||
| 37b18ab940 | |||
| 42d699fabe | |||
| 8026dac146 | |||
| a5e1f613fc | |||
| 3d1f55b605 | |||
| f25ba44477 | |||
| cc052f75d1 | |||
| 38beca412e | |||
| fec16346db | |||
| ca084c485d | |||
| 5d871beee2 | |||
| cf46f3e6ee | |||
| 0784b9569c | |||
| f3076ed418 | |||
| 70321350d4 | |||
| fc6d1a9be4 | |||
| 07694e06ff | |||
| 5d1987fb0d | |||
| 9bc6ae44ad | |||
| 792fd96f38 | |||
| b1011af016 | |||
| 69d6140ab3 | |||
| 01f1de21a8 | |||
| 491442d20f | |||
| 7303970118 | |||
| bf68a1d2bf | |||
| e1b29bd854 | |||
| d07453d108 | |||
| c40ccd32f5 | |||
| 3ab3117e11 | |||
| 7d69198c91 | |||
| d29089c2ae | |||
| b39f231927 | |||
| f57a189077 | |||
| fc469ef9c2 | |||
| 24c01b1a9a | |||
| f1bc5f6216 | |||
| f3fe7283f8 | |||
| 839bf5cb91 | |||
| 6fa98ffe3a | |||
| a35d84976e | |||
| 19f9245e17 | |||
| 1da18c3101 | |||
| be194558bf | |||
| b945ac8999 | |||
| 6ec557cd20 | |||
| 866d30638e | |||
| 75df8fc18b | |||
| a44a612387 | |||
| 97fc848044 | |||
| ec0e7aaba4 | |||
| 5337770adb | |||
| b1b7b2b6fc | |||
| 1dcb8f8052 | |||
| 569c4b4849 | |||
| 5d5bed8270 | |||
| 58a41a6fde | |||
| 0bde1d5ef4 | |||
| 4a01fb7094 | |||
| a4ff9aa45c | |||
| 1777e9b556 | |||
| 59e57d6171 | |||
| 9b17f91c9a | |||
| 9854ce7c31 | |||
| e28dfbe0bc | |||
| e0e00bd93d | |||
| 9df6b9d1a5 | |||
| e2dfc4dd00 | |||
| 80f48152de | |||
| 57ae4fd4fb | |||
| be9a2b120b | |||
| 6c190ec308 | |||
| e326e6c8f3 | |||
| 46401701a0 | |||
| 2f2e7c3556 | |||
| fedd983649 | |||
| 3b2833c752 | |||
| f1dee53dc4 | |||
| 7fb8706c30 | |||
| 617504d8bb | |||
| 95537af5f3 | |||
| 1765a19aab | |||
| a73a8626c5 | |||
| 88054a3899 | |||
| 409313639d | |||
| 78ad61c9fb | |||
| 2d9de26041 | |||
| 0a9bd39aac | |||
| f614f3dd3f | |||
| 7f818c7329 | |||
| 27d116c8e2 | |||
| 7e962d36e6 | |||
| f09450e7ea | |||
| 05b9c69da8 | |||
| ac55ad55dd | |||
| 8c11b6cfc8 | |||
| d858289159 | |||
| 52d420bd98 | |||
| 386eebb117 | |||
| d0993c3393 | |||
| 54d17503bf | |||
| 0de2a3dc98 | |||
| 73ac18bbde | |||
| 18dcef8ce4 | |||
| 7458228df0 | |||
| 7c93f8a039 | |||
| d6a126d974 | |||
| 779fb1d568 | |||
| a0ce14f2a2 | |||
| 091abf0b9d | |||
| 342c42194e | |||
| 8383a0ee1e | |||
| 19a696942e | |||
| f1a5e95f77 | |||
| 99fedfa354 | |||
| 9da73202c7 | |||
| 30db7bc554 | |||
| b40eaf4184 | |||
| 3aff344501 | |||
| 0f812d70c1 | |||
| 125e9c2e0b | |||
| 95402b4b52 | |||
| d01e3ad09c | |||
| edb6d44bdc | |||
| bd5de2e185 | |||
| b8eefe4b78 | |||
| cc8a3d8b5e | |||
| dd061790a8 | |||
| 7fddd99c28 | |||
| 7ab7e6efb7 | |||
| 4464d3c8b4 | |||
| e0f40f9bc1 | |||
| c83a6b4f41 | |||
| 82994c7b7b | |||
| e891de7f49 | |||
| fc2648becf | |||
| ff548eae5c | |||
| 866a7f264b | |||
| 8fcb629bdf | |||
| 99655c65d4 | |||
| 5e176415ab | |||
| 34555bce86 | |||
| dcd7a050bd | |||
| dc0df7c7e9 | |||
| e2c8ee7b54 | |||
| d2a0ddab12 | |||
| 31b254ff09 | |||
| 025af4f9fd | |||
| 221169db51 | |||
| d2a50256b0 | |||
| 6bc80720c9 | |||
| 23106bfce8 | |||
| e8046f0d2f | |||
| ba8ade0244 | |||
| 119eb92f27 | |||
| c26a75af27 | |||
| 10f508a8dd | |||
| d872293551 | |||
| e419aa6c3a | |||
| ba60d4bc0a | |||
| b1dffc3486 | |||
| 4fc6ac1f15 | |||
| 289302fd2e | |||
| 00db4010d0 | |||
| bc14699994 | |||
| 013a7a6d39 | |||
| c3f93a3e9d | |||
| c5cd4d9335 | |||
| c2f84466df | |||
| 00d7239601 | |||
| 5c070d0428 | |||
| 4822261187 | |||
| cda503e7af | |||
| 45cc5ee20f | |||
| ecf8783af0 | |||
| 7b7af55826 | |||
| 5c0fc87504 | |||
| 93cda9327a | |||
| 888add7920 | |||
| 7dfc4fb57d | |||
| 6ef03d3aa7 | |||
| d5510f4454 | |||
| 5b19f7c711 | |||
| f28f41eb76 | |||
| 80b9fb1daa | |||
| 80c547c1fc | |||
| 04c3d81b1f | |||
| f41f93d6c9 | |||
| 74acf9601b | |||
| 5bdabc13a6 | |||
| 6a6e77b52a | |||
| a97908518a | |||
| a48aabdd66 | |||
| 84f45b7fb7 | |||
| 9bb802c35c | |||
| 8ad097aef2 | |||
| 0cfd968f1e | |||
| c6fc6230ff | |||
| ca2258ecc7 | |||
| bf87f2dfee | |||
| b4757dfc6c | |||
| b028b541a8 | |||
| 8dc6bcba22 | |||
| 2f418e7c2e | |||
| 02e5c7663b | |||
| ca0c7d256b | |||
| 2a16d76cb3 | |||
| 3c251adf96 | |||
| 129f8f0520 | |||
| 6cde7fed51 | |||
| 34059ce0ef | |||
| 08a0b72706 | |||
| 6c2313919b | |||
| 73f079f32b | |||
| d3eb3db7ba | |||
| f7826c7d91 | |||
| 09e835d935 | |||
| a139d599b7 | |||
| 114e240958 | |||
| ce3bba9f33 | |||
| 6ee275acc7 | |||
| bbe16f26e4 | |||
| a0308cf809 | |||
| e2d6c89549 | |||
| 1d0c2225d0 | |||
| 6efd23ad41 | |||
| da86f38e6f | |||
| 86e76dbe9b | |||
| a4d59dfc88 | |||
| 481adc6ecb | |||
| 3f4950031b | |||
| 737e7ba4b4 | |||
| af041a5a35 | |||
| d4d91c33f3 | |||
| 0439a2ac27 | |||
| 69516f7ab9 | |||
| b5165f231e | |||
| 7e11b23f87 | |||
| 520a2e363e | |||
| 32d894bf84 | |||
| 64550ae790 | |||
| f5d795dbce | |||
| cdd4cc56c2 | |||
| 48fcb4e1ca | |||
| f8e6acc0bd | |||
| cc591ebc66 | |||
| 27d0fe86d1 | |||
| 807b26c762 | |||
| c678701bd0 | |||
| 8c8f7774af |
@@ -6,6 +6,7 @@ __mocks__
|
||||
.DS_Store
|
||||
.env*
|
||||
.eslint*
|
||||
.oxlintrc*
|
||||
.log
|
||||
Makefile
|
||||
Procfile
|
||||
|
||||
@@ -26,3 +26,6 @@ updates:
|
||||
aws:
|
||||
patterns:
|
||||
- "@aws-sdk/*"
|
||||
radix-ui:
|
||||
patterns:
|
||||
- "@radix-ui/*"
|
||||
|
||||
@@ -25,15 +25,14 @@ jobs:
|
||||
node-version: [20.x, 22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile --prefer-offline
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "yarn"
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile --prefer-offline
|
||||
|
||||
lint:
|
||||
needs: build
|
||||
@@ -45,7 +44,7 @@ jobs:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- run: yarn lint
|
||||
- run: yarn lint --quiet
|
||||
|
||||
types:
|
||||
needs: build
|
||||
|
||||
+4
-9
@@ -7,12 +7,10 @@
|
||||
"roots": ["<rootDir>/server", "<rootDir>/plugins"],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/console.js"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
|
||||
"globalSetup": "<rootDir>/server/test/globalSetup.js",
|
||||
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
@@ -23,8 +21,7 @@
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js",
|
||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
},
|
||||
"modulePaths": ["<rootDir>/app"],
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
@@ -39,8 +36,7 @@
|
||||
"roots": ["<rootDir>/shared"],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/console.js"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
|
||||
@@ -53,8 +49,7 @@
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js",
|
||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
"testEnvironment": "jsdom",
|
||||
|
||||
+5
-34
@@ -1,9 +1,5 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["eslint", "typescript"],
|
||||
"env": {
|
||||
"builtin": true
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"build/**",
|
||||
"node_modules/**",
|
||||
@@ -35,6 +31,7 @@
|
||||
"no-empty-pattern": "error",
|
||||
"no-empty-static-block": "error",
|
||||
"no-ex-assign": "error",
|
||||
"no-explicit-any": "warn",
|
||||
"no-extra-boolean-cast": "error",
|
||||
"no-fallthrough": "error",
|
||||
"no-func-assign": "error",
|
||||
@@ -71,31 +68,12 @@
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"**/*.{js,jsx,ts,tsx}"
|
||||
],
|
||||
"files": ["**/*.{js,jsx,ts,tsx}"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "reakit/Menu",
|
||||
"importNames": [
|
||||
"useMenuState"
|
||||
],
|
||||
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"eqeqeq": "error",
|
||||
"curly": "error",
|
||||
"no-console": "error",
|
||||
"arrow-body-style": [
|
||||
"error",
|
||||
"as-needed"
|
||||
],
|
||||
"arrow-body-style": ["error", "as-needed"],
|
||||
"no-useless-escape": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/self-closing-comp": [
|
||||
@@ -106,11 +84,10 @@
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"import/no-named-as-default": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
"no-unused-vars": [
|
||||
"warn",
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
@@ -119,13 +96,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
"eslint",
|
||||
"oxc",
|
||||
"react",
|
||||
"typescript",
|
||||
"import"
|
||||
]
|
||||
"plugins": ["eslint", "oxc", "react", "typescript", "import"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
require("dotenv").config({
|
||||
require("@dotenvx/dotenvx").config({
|
||||
path: process.env.NODE_ENV === "test" ? ".env.test" : ".env",
|
||||
});
|
||||
|
||||
var path = require('path');
|
||||
|
||||
module.exports = {
|
||||
'config': path.resolve('server/config', 'database.json'),
|
||||
'config': path.resolve('server/config', 'database.js'),
|
||||
'migrations-path': path.resolve('server', 'migrations'),
|
||||
'models-path': path.resolve('server', 'models'),
|
||||
}
|
||||
|
||||
+7
-8
@@ -14,7 +14,13 @@ ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=base $APP_PATH/build ./build
|
||||
# Create a non-root user compatible with Debian and BusyBox based images
|
||||
RUN addgroup --gid 1001 nodejs && \
|
||||
adduser --uid 1001 --ingroup nodejs nodejs && \
|
||||
mkdir -p /var/lib/outline && \
|
||||
chown -R nodejs:nodejs /var/lib/outline
|
||||
|
||||
COPY --from=base --chown=nodejs:nodejs $APP_PATH/build ./build
|
||||
COPY --from=base $APP_PATH/server ./server
|
||||
COPY --from=base $APP_PATH/public ./public
|
||||
COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
|
||||
@@ -26,13 +32,6 @@ 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 && \
|
||||
chown -R nodejs:nodejs $APP_PATH/build && \
|
||||
mkdir -p /var/lib/outline && \
|
||||
chown -R nodejs:nodejs /var/lib/outline
|
||||
|
||||
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" && \
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:20 AS deps
|
||||
FROM node:22 AS deps
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.85.0
|
||||
Licensed Work: Outline 0.87.4
|
||||
The Licensed Work is (c) 2025 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2029-07-03
|
||||
Change Date: 2029-09-18
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -7,14 +7,13 @@
|
||||
<img width="1640" alt="screenshot" src="https://user-images.githubusercontent.com/380914/110356468-26374600-7fef-11eb-9f6a-f2cc2c8c6590.png">
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield"></a>
|
||||
<a href="http://www.typescriptlang.org" rel="nofollow"><img src="https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg" alt="TypeScript"></a>
|
||||
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat" alt="Prettier"></a>
|
||||
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg" alt="Styled Components"></a>
|
||||
<a href="https://translate.getoutline.com/project/outline" alt="Localized"><img src="https://badges.crowdin.net/outline/localized.svg"></a>
|
||||
</p>
|
||||
|
||||
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
|
||||
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, A hosted version of the app is offered at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
|
||||
|
||||
If you'd like to run your own copy of Outline or contribute to development then this is the place for you.
|
||||
|
||||
@@ -51,13 +50,14 @@ please refer to the [architecture document](docs/ARCHITECTURE.md) first for a hi
|
||||
|
||||
In development Outline outputs simple logging to the console, prefixed by categories. In production it outputs JSON logs, these can be easily parsed by your preferred log ingestion pipeline.
|
||||
|
||||
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable.
|
||||
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable. logging
|
||||
can be enabled for all categories by setting `DEBUG=*` or for specific categories such as `DEBUG=database` and `LOG_LEVEL=debug`, or `LOG_LEVEL=silly` for very verbose logging.
|
||||
|
||||
## Tests
|
||||
|
||||
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
|
||||
|
||||
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.js` extension next to the tested code.
|
||||
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.ts` extension next to the tested code.
|
||||
|
||||
```shell
|
||||
# To run all tests
|
||||
@@ -68,14 +68,14 @@ make watch
|
||||
```
|
||||
|
||||
Once the test database is created with `make test` you may individually run
|
||||
frontend and backend tests directly.
|
||||
frontend and backend tests directly with jest:
|
||||
|
||||
```shell
|
||||
# To run backend tests
|
||||
yarn test:server
|
||||
|
||||
# To run a specific backend test
|
||||
yarn test:server myTestFile
|
||||
# To run a specific backend test in watch mode
|
||||
yarn test path/to/file.test.ts --watch
|
||||
|
||||
# To run frontend tests
|
||||
yarn test:app
|
||||
@@ -86,14 +86,15 @@ yarn test:app
|
||||
Sequelize is used to create and run migrations, for example:
|
||||
|
||||
```shell
|
||||
yarn sequelize migration:generate --name my-migration
|
||||
yarn sequelize db:migrate
|
||||
yarn db:create-migration --name my-migration
|
||||
yarn db:migrate
|
||||
yarn db:rollback
|
||||
```
|
||||
|
||||
Or to run migrations on test database:
|
||||
Or, to run migrations on test database:
|
||||
|
||||
```shell
|
||||
yarn sequelize db:migrate --env test
|
||||
yarn db:migrate --env test
|
||||
```
|
||||
|
||||
# Activity
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"../.eslintrc",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"eslint-plugin-react-hooks"
|
||||
],
|
||||
"rules": {
|
||||
"react/react-in-jsx-scope": "off"
|
||||
},
|
||||
"env": {
|
||||
"jest": true,
|
||||
"browser": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": ["../.oxlintrc.json"],
|
||||
"plugins": ["oxc", "eslint", "typescript", "react"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.{jsx,tsx}"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["mime-types"],
|
||||
"message": "Do not use the mime-types package in the browser."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugins": ["import"]
|
||||
}
|
||||
],
|
||||
"env": {
|
||||
"jest": true,
|
||||
"browser": true
|
||||
}
|
||||
}
|
||||
@@ -27,17 +27,26 @@ export const createApiKey = createAction({
|
||||
|
||||
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
|
||||
createActionV2({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Revoke") : t("Revoke API key"),
|
||||
name: ({ t, isMenu }) =>
|
||||
isMenu
|
||||
? apiKey.isExpired
|
||||
? t("Delete")
|
||||
: `${t("Revoke")}…`
|
||||
: t("Revoke API key"),
|
||||
analyticsName: "Revoke API key",
|
||||
section: SettingsSection,
|
||||
icon: <TrashIcon />,
|
||||
keywords: "revoke",
|
||||
keywords: "revoke delete remove",
|
||||
dangerous: true,
|
||||
perform: ({ t, event }) => {
|
||||
perform: async ({ t, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
if (apiKey.isExpired) {
|
||||
await apiKey.delete();
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Revoke token"),
|
||||
content: (
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import {
|
||||
AlphabeticalReverseSortIcon,
|
||||
AlphabeticalSortIcon,
|
||||
ArchiveIcon,
|
||||
CollectionIcon,
|
||||
EditIcon,
|
||||
ExportIcon,
|
||||
ImportIcon,
|
||||
ManualSortIcon,
|
||||
NewDocumentIcon,
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
RestoreIcon,
|
||||
@@ -20,17 +26,31 @@ import { CollectionNew } from "~/components/Collection/CollectionNew";
|
||||
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import SharePopover from "~/components/Sharing/Collection/SharePopover";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import { createAction } from "~/actions";
|
||||
import {
|
||||
createAction,
|
||||
createActionV2,
|
||||
createActionV2WithChildren,
|
||||
createInternalLinkActionV2,
|
||||
} from "~/actions";
|
||||
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import {
|
||||
newDocumentPath,
|
||||
newTemplatePath,
|
||||
searchPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import ExportDialog from "~/components/ExportDialog";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import history from "~/utils/history";
|
||||
import { newTemplatePath, searchPath } from "~/utils/routeHelpers";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
|
||||
<DynamicCollectionIcon collection={collection} />
|
||||
);
|
||||
const SharePopover = lazyWithRetry(
|
||||
() => import("~/components/Sharing/Collection/SharePopover")
|
||||
);
|
||||
|
||||
export const openCollection = createAction({
|
||||
name: ({ t }) => t("Open collection"),
|
||||
@@ -70,9 +90,8 @@ export const createCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const editCollection = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? `${t("Edit")}…` : t("Edit collection"),
|
||||
export const editCollection = createActionV2({
|
||||
name: ({ t, isMenu }) => (isMenu ? `${t("Edit")}…` : t("Edit collection")),
|
||||
analyticsName: "Edit collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <EditIcon />,
|
||||
@@ -96,9 +115,9 @@ export const editCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const editCollectionPermissions = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? `${t("Permissions")}…` : t("Collection permissions"),
|
||||
export const editCollectionPermissions = createActionV2({
|
||||
name: ({ t, isMenu }) =>
|
||||
isMenu ? `${t("Permissions")}…` : t("Collection permissions"),
|
||||
analyticsName: "Collection permissions",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <PadlockIcon />,
|
||||
@@ -128,7 +147,130 @@ export const editCollectionPermissions = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const searchInCollection = createAction({
|
||||
export const importDocument = createActionV2({
|
||||
name: ({ t }) => t("Import document"),
|
||||
analyticsName: "Import document",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ImportIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (activeCollectionId) {
|
||||
return !!stores.policies.abilities(activeCollectionId).createDocument;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const { documents } = stores;
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = documents.importFileTypes.join(", ");
|
||||
|
||||
input.onchange = async (ev) => {
|
||||
const files = getEventFiles(ev);
|
||||
const file = files[0];
|
||||
|
||||
try {
|
||||
const document = await documents.import(
|
||||
file,
|
||||
null,
|
||||
activeCollectionId,
|
||||
{
|
||||
publish: true,
|
||||
}
|
||||
);
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
},
|
||||
});
|
||||
|
||||
export const sortCollection = createActionV2WithChildren({
|
||||
name: ({ t }) => t("Sort in sidebar"),
|
||||
section: ActiveCollectionSection,
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
!!stores.policies.abilities(activeCollectionId).update,
|
||||
icon: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
const sortAlphabetical = collection?.sort.field === "title";
|
||||
const sortDir = collection?.sort.direction;
|
||||
|
||||
return sortAlphabetical ? (
|
||||
sortDir === "asc" ? (
|
||||
<AlphabeticalSortIcon />
|
||||
) : (
|
||||
<AlphabeticalReverseSortIcon />
|
||||
)
|
||||
) : (
|
||||
<ManualSortIcon />
|
||||
);
|
||||
},
|
||||
children: [
|
||||
createActionV2({
|
||||
name: ({ t }) => t("A-Z sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return (
|
||||
collection?.sort.field === "title" &&
|
||||
collection?.sort.direction === "asc"
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "title",
|
||||
direction: "asc",
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
createActionV2({
|
||||
name: ({ t }) => t("Z-A sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return (
|
||||
collection?.sort.field === "title" &&
|
||||
collection?.sort.direction === "desc"
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "title",
|
||||
direction: "desc",
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
createActionV2({
|
||||
name: ({ t }) => t("Manual sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.sort.field !== "title";
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "index",
|
||||
direction: "asc",
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export const searchInCollection = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Search in collection"),
|
||||
analyticsName: "Search collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -146,13 +288,20 @@ export const searchInCollection = createAction({
|
||||
|
||||
return stores.policies.abilities(activeCollectionId).readDocument;
|
||||
},
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = searchPath({
|
||||
collectionId: activeCollectionId,
|
||||
}).split("?");
|
||||
|
||||
perform: ({ activeCollectionId }) => {
|
||||
history.push(searchPath({ collectionId: activeCollectionId }));
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const starCollection = createAction({
|
||||
export const starCollection = createActionV2({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -179,7 +328,7 @@ export const starCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const unstarCollection = createAction({
|
||||
export const unstarCollection = createActionV2({
|
||||
name: ({ t }) => t("Unstar"),
|
||||
analyticsName: "Unstar collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -205,7 +354,7 @@ export const unstarCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const subscribeCollection = createAction({
|
||||
export const subscribeCollection = createActionV2({
|
||||
name: ({ t }) => t("Subscribe"),
|
||||
analyticsName: "Subscribe to collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -218,6 +367,7 @@ export const subscribeCollection = createAction({
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
return (
|
||||
!!collection?.isActive &&
|
||||
!collection?.isSubscribed &&
|
||||
stores.policies.abilities(activeCollectionId).subscribe
|
||||
);
|
||||
@@ -235,7 +385,7 @@ export const subscribeCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const unsubscribeCollection = createAction({
|
||||
export const unsubscribeCollection = createActionV2({
|
||||
name: ({ t }) => t("Unsubscribe"),
|
||||
analyticsName: "Unsubscribe from collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -248,6 +398,7 @@ export const unsubscribeCollection = createAction({
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
return (
|
||||
!!collection?.isActive &&
|
||||
!!collection?.isSubscribed &&
|
||||
stores.policies.abilities(activeCollectionId).unsubscribe
|
||||
);
|
||||
@@ -265,10 +416,10 @@ export const unsubscribeCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const archiveCollection = createAction({
|
||||
export const archiveCollection = createActionV2({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive collection",
|
||||
section: CollectionSection,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
@@ -306,7 +457,7 @@ export const archiveCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const restoreCollection = createAction({
|
||||
export const restoreCollection = createActionV2({
|
||||
name: ({ t }) => t("Restore"),
|
||||
analyticsName: "Restore collection",
|
||||
section: CollectionSection,
|
||||
@@ -331,7 +482,7 @@ export const restoreCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteCollection = createAction({
|
||||
export const deleteCollection = createActionV2({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -365,7 +516,65 @@ export const deleteCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const createTemplate = createAction({
|
||||
export const exportCollection = createActionV2({
|
||||
name: ({ t }) => `${t("Export")}…`,
|
||||
analyticsName: "Export collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ExportIcon />,
|
||||
visible: ({ currentTeamId, activeCollectionId, stores }) => {
|
||||
if (!currentTeamId || !activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!!stores.policies.abilities(currentTeamId).createExport &&
|
||||
!!stores.policies.abilities(activeCollectionId).export
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Export collection"),
|
||||
content: (
|
||||
<ExportDialog
|
||||
collection={collection}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const createDocument = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "new create document",
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createDocument
|
||||
),
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const createTemplate = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("New template"),
|
||||
analyticsName: "New template",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -376,13 +585,14 @@ export const createTemplate = createAction({
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createDocument
|
||||
),
|
||||
perform: ({ activeCollectionId, event }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
history.push(newTemplatePath(activeCollectionId));
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = newTemplatePath(activeCollectionId).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { DoneIcon, SmileyIcon, TrashIcon } from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import stores from "~/stores";
|
||||
import Comment from "~/models/Comment";
|
||||
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
|
||||
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
|
||||
import history from "~/utils/history";
|
||||
import { createAction } from "..";
|
||||
import { DocumentSection } from "../sections";
|
||||
import { createActionV2 } from "..";
|
||||
import { ActiveDocumentSection } from "../sections";
|
||||
|
||||
export const deleteCommentFactory = ({
|
||||
comment,
|
||||
@@ -15,15 +13,15 @@ export const deleteCommentFactory = ({
|
||||
comment: Comment;
|
||||
onDelete: () => void;
|
||||
}) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete comment",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <TrashIcon />,
|
||||
keywords: "trash",
|
||||
dangerous: true,
|
||||
visible: () => stores.policies.abilities(comment.id).delete,
|
||||
perform: ({ t, event }) => {
|
||||
visible: ({ stores }) => stores.policies.abilities(comment.id).delete,
|
||||
perform: ({ t, stores, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
@@ -41,26 +39,16 @@ export const resolveCommentFactory = ({
|
||||
comment: Comment;
|
||||
onResolve: () => void;
|
||||
}) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) => t("Mark as resolved"),
|
||||
analyticsName: "Resolve thread",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <DoneIcon outline />,
|
||||
visible: () =>
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(comment.id).resolve &&
|
||||
stores.policies.abilities(comment.documentId).update,
|
||||
perform: async ({ t }) => {
|
||||
await comment.resolve();
|
||||
|
||||
const locationState = history.location.state as Record<string, unknown>;
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: {
|
||||
sidebarContext: locationState["sidebarContext"],
|
||||
commentId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
onResolve();
|
||||
toast.success(t("Thread resolved"));
|
||||
},
|
||||
@@ -73,26 +61,16 @@ export const unresolveCommentFactory = ({
|
||||
comment: Comment;
|
||||
onUnresolve: () => void;
|
||||
}) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) => t("Mark as unresolved"),
|
||||
analyticsName: "Unresolve thread",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <DoneIcon outline />,
|
||||
visible: () =>
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(comment.id).unresolve &&
|
||||
stores.policies.abilities(comment.documentId).update,
|
||||
perform: async () => {
|
||||
await comment.unresolve();
|
||||
|
||||
const locationState = history.location.state as Record<string, unknown>;
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: {
|
||||
sidebarContext: locationState["sidebarContext"],
|
||||
commentId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
onUnresolve();
|
||||
},
|
||||
});
|
||||
@@ -102,15 +80,15 @@ export const viewCommentReactionsFactory = ({
|
||||
}: {
|
||||
comment: Comment;
|
||||
}) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) => `${t("View reactions")}`,
|
||||
analyticsName: "View comment reactions",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <SmileyIcon />,
|
||||
visible: () =>
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(comment.id).read &&
|
||||
comment.reactions.length > 0,
|
||||
perform: ({ t, event }) => {
|
||||
perform: ({ t, stores, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import copy from "copy-to-clipboard";
|
||||
import {
|
||||
BeakerIcon,
|
||||
@@ -127,6 +128,17 @@ export const clearIndexedDB = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const clearStorage = createAction({
|
||||
name: ({ t }) => t("Clear local storage"),
|
||||
icon: <TrashIcon />,
|
||||
keywords: "cache clear localstorage",
|
||||
section: DeveloperSection,
|
||||
perform: ({ t }) => {
|
||||
Storage.clear();
|
||||
toast.success(t("Local storage cleared"));
|
||||
},
|
||||
});
|
||||
|
||||
export const createTestUsers = createAction({
|
||||
name: "Create 10 test users",
|
||||
icon: <UserIcon />,
|
||||
@@ -201,6 +213,7 @@ export const developer = createAction({
|
||||
createToast,
|
||||
createTestUsers,
|
||||
clearIndexedDB,
|
||||
clearStorage,
|
||||
startTyping,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -26,11 +26,12 @@ import {
|
||||
PublishIcon,
|
||||
CommentIcon,
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
PadlockIcon,
|
||||
GlobeIcon,
|
||||
LogoutIcon,
|
||||
CaseSensitiveIcon,
|
||||
RestoreIcon,
|
||||
EditIcon,
|
||||
} from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import Icon from "@shared/components/Icon";
|
||||
@@ -49,10 +50,15 @@ import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInT
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DocumentCopy from "~/components/DocumentCopy";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import SharePopover from "~/components/Sharing/Document";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
|
||||
import { createAction } from "~/actions";
|
||||
import {
|
||||
createAction,
|
||||
createActionV2,
|
||||
createActionV2Group,
|
||||
createActionV2WithChildren,
|
||||
createInternalLinkActionV2,
|
||||
} from "~/actions";
|
||||
import {
|
||||
ActiveDocumentSection,
|
||||
DocumentSection,
|
||||
@@ -62,7 +68,6 @@ import env from "~/env";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
documentInsightsPath,
|
||||
documentHistoryPath,
|
||||
homePath,
|
||||
newDocumentPath,
|
||||
@@ -71,7 +76,19 @@ import {
|
||||
documentPath,
|
||||
urlify,
|
||||
trashPath,
|
||||
documentEditPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { ActionV2, ActionV2Group, ActionV2Separator } from "~/types";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const Insights = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Insights")
|
||||
);
|
||||
const SharePopover = lazyWithRetry(
|
||||
() => import("~/components/Sharing/Document/SharePopover")
|
||||
);
|
||||
|
||||
export const openDocument = createAction({
|
||||
name: ({ t }) => t("Open document"),
|
||||
@@ -103,6 +120,38 @@ export const openDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const editDocument = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Edit"),
|
||||
analyticsName: "Edit document",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "edit",
|
||||
icon: <EditIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const { auth, documents, policies } = stores;
|
||||
|
||||
const document = activeDocumentId
|
||||
? documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
const can = activeDocumentId
|
||||
? policies.abilities(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
!!can?.update && !!auth.user?.separateEditMode && !document?.template
|
||||
);
|
||||
},
|
||||
to: ({ activeDocumentId, stores }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return documentEditPath(document);
|
||||
},
|
||||
});
|
||||
|
||||
export const createDocument = createAction({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document",
|
||||
@@ -141,7 +190,7 @@ export const createDraftDocument = createAction({
|
||||
}),
|
||||
});
|
||||
|
||||
export const createDocumentFromTemplate = createAction({
|
||||
export const createDocumentFromTemplate = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("New from template"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
@@ -171,16 +220,24 @@ export const createDocumentFromTemplate = createAction({
|
||||
}
|
||||
return stores.policies.abilities(currentTeamId).createDocument;
|
||||
},
|
||||
perform: ({ activeCollectionId, activeDocumentId, sidebarContext }) =>
|
||||
history.push(
|
||||
newDocumentPath(activeCollectionId, { templateId: activeDocumentId }),
|
||||
{
|
||||
sidebarContext,
|
||||
}
|
||||
),
|
||||
to: ({ activeDocumentId, activeCollectionId, sidebarContext }) => {
|
||||
if (!activeDocumentId || !activeCollectionId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const [pathname, search] = newDocumentPath(activeCollectionId, {
|
||||
templateId: activeDocumentId,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const createNestedDocument = createAction({
|
||||
export const createNestedDocument = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("New nested document"),
|
||||
analyticsName: "New document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -191,13 +248,19 @@ export const createNestedDocument = createAction({
|
||||
!!activeDocumentId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument &&
|
||||
stores.policies.abilities(activeDocumentId).createChildDocument,
|
||||
perform: ({ activeDocumentId, sidebarContext }) =>
|
||||
history.push(newNestedDocumentPath(activeDocumentId), {
|
||||
sidebarContext,
|
||||
}),
|
||||
to: ({ activeDocumentId, sidebarContext }) => {
|
||||
const [pathname, search] =
|
||||
newNestedDocumentPath(activeDocumentId).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const starDocument = createAction({
|
||||
export const starDocument = createActionV2({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -223,7 +286,7 @@ export const starDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const unstarDocument = createAction({
|
||||
export const unstarDocument = createActionV2({
|
||||
name: ({ t }) => t("Unstar"),
|
||||
analyticsName: "Unstar document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -249,7 +312,7 @@ export const unstarDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const publishDocument = createAction({
|
||||
export const publishDocument = createActionV2({
|
||||
name: ({ t }) => t("Publish"),
|
||||
analyticsName: "Publish document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -291,7 +354,7 @@ export const publishDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const unpublishDocument = createAction({
|
||||
export const unpublishDocument = createActionV2({
|
||||
name: ({ t }) => t("Unpublish"),
|
||||
analyticsName: "Unpublish document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -322,11 +385,27 @@ export const unpublishDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const subscribeDocument = createAction({
|
||||
export const subscribeDocument = createActionV2({
|
||||
name: ({ t }) => t("Subscribe"),
|
||||
analyticsName: "Subscribe to document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <SubscribeIcon />,
|
||||
tooltip: ({ activeCollectionId, isMenu, stores, t }) => {
|
||||
if (!isMenu || !activeCollectionId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return stores.collections.get(activeCollectionId)?.isSubscribed
|
||||
? t("Subscription inherited from collection")
|
||||
: undefined;
|
||||
},
|
||||
disabled: ({ activeCollectionId, isMenu, stores }) => {
|
||||
if (!isMenu || !activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!stores.collections.get(activeCollectionId)?.isSubscribed;
|
||||
},
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
@@ -335,6 +414,7 @@ export const subscribeDocument = createAction({
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
return (
|
||||
!!document?.isActive &&
|
||||
!document?.collection?.isSubscribed &&
|
||||
!document?.isSubscribed &&
|
||||
stores.policies.abilities(activeDocumentId).subscribe
|
||||
@@ -351,11 +431,27 @@ export const subscribeDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const unsubscribeDocument = createAction({
|
||||
export const unsubscribeDocument = createActionV2({
|
||||
name: ({ t }) => t("Unsubscribe"),
|
||||
analyticsName: "Unsubscribe from document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <UnsubscribeIcon />,
|
||||
tooltip: ({ activeCollectionId, isMenu, stores, t }) => {
|
||||
if (!isMenu || !activeCollectionId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return stores.collections.get(activeCollectionId)?.isSubscribed
|
||||
? t("Subscription inherited from collection")
|
||||
: undefined;
|
||||
},
|
||||
disabled: ({ activeCollectionId, isMenu, stores }) => {
|
||||
if (!isMenu || !activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!stores.collections.get(activeCollectionId)?.isSubscribed;
|
||||
},
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
@@ -364,9 +460,10 @@ export const unsubscribeDocument = createAction({
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
return (
|
||||
!!document?.collection?.isSubscribed ||
|
||||
(!!document?.isSubscribed &&
|
||||
stores.policies.abilities(activeDocumentId).unsubscribe)
|
||||
!!document?.isActive &&
|
||||
(!!document?.collection?.isSubscribed ||
|
||||
(!!document?.isSubscribed &&
|
||||
stores.policies.abilities(activeDocumentId).unsubscribe))
|
||||
);
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
|
||||
@@ -382,7 +479,7 @@ export const unsubscribeDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const shareDocument = createAction({
|
||||
export const shareDocument = createActionV2({
|
||||
name: ({ t }) => `${t("Permissions")}…`,
|
||||
analyticsName: "Share document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -415,7 +512,7 @@ export const shareDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsHTML = createAction({
|
||||
export const downloadDocumentAsHTML = createActionV2({
|
||||
name: ({ t }) => t("HTML"),
|
||||
analyticsName: "Download document as HTML",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -434,7 +531,7 @@ export const downloadDocumentAsHTML = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsPDF = createAction({
|
||||
export const downloadDocumentAsPDF = createActionV2({
|
||||
name: ({ t }) => t("PDF"),
|
||||
analyticsName: "Download document as PDF",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -442,9 +539,11 @@ export const downloadDocumentAsPDF = createAction({
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId &&
|
||||
stores.policies.abilities(activeDocumentId).download &&
|
||||
env.PDF_EXPORT_ENABLED,
|
||||
!!(
|
||||
activeDocumentId &&
|
||||
stores.policies.abilities(activeDocumentId).download &&
|
||||
env.PDF_EXPORT_ENABLED
|
||||
),
|
||||
perform: ({ activeDocumentId, t, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
@@ -458,7 +557,7 @@ export const downloadDocumentAsPDF = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsMarkdown = createAction({
|
||||
export const downloadDocumentAsMarkdown = createActionV2({
|
||||
name: ({ t }) => t("Markdown"),
|
||||
analyticsName: "Download document as Markdown",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -477,9 +576,8 @@ export const downloadDocumentAsMarkdown = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Download") : t("Download document"),
|
||||
export const downloadDocument = createActionV2WithChildren({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download document")),
|
||||
analyticsName: "Download document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <DownloadIcon />,
|
||||
@@ -493,7 +591,7 @@ export const downloadDocument = createAction({
|
||||
],
|
||||
});
|
||||
|
||||
export const copyDocumentAsMarkdown = createAction({
|
||||
export const copyDocumentAsMarkdown = createActionV2({
|
||||
name: ({ t }) => t("Copy as Markdown"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard",
|
||||
@@ -501,18 +599,21 @@ export const copyDocumentAsMarkdown = createAction({
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ stores, activeDocumentId, t }) => {
|
||||
perform: async ({ stores, activeDocumentId, t }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document) {
|
||||
copy(document.toMarkdown());
|
||||
const { ProsemirrorHelper } = await import(
|
||||
"~/models/helpers/ProsemirrorHelper"
|
||||
);
|
||||
copy(ProsemirrorHelper.toMarkdown(document));
|
||||
toast.success(t("Markdown copied to clipboard"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentAsPlainText = createAction({
|
||||
export const copyDocumentAsPlainText = createActionV2({
|
||||
name: ({ t }) => t("Copy as text"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard",
|
||||
@@ -520,18 +621,21 @@ export const copyDocumentAsPlainText = createAction({
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ stores, activeDocumentId, t }) => {
|
||||
perform: async ({ stores, activeDocumentId, t }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document) {
|
||||
copy(document.toPlainText());
|
||||
const { ProsemirrorHelper } = await import(
|
||||
"~/models/helpers/ProsemirrorHelper"
|
||||
);
|
||||
copy(ProsemirrorHelper.toPlainText(document));
|
||||
toast.success(t("Text copied to clipboard"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentShareLink = createAction({
|
||||
export const copyDocumentShareLink = createActionV2({
|
||||
name: ({ t }) => t("Copy public link"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard share",
|
||||
@@ -552,7 +656,7 @@ export const copyDocumentShareLink = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentLink = createAction({
|
||||
export const copyDocumentLink = createActionV2({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard",
|
||||
@@ -570,7 +674,7 @@ export const copyDocumentLink = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocument = createAction({
|
||||
export const copyDocument = createActionV2WithChildren({
|
||||
name: ({ t }) => t("Copy"),
|
||||
analyticsName: "Copy document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -584,9 +688,8 @@ export const copyDocument = createAction({
|
||||
],
|
||||
});
|
||||
|
||||
export const duplicateDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Duplicate") : t("Duplicate document"),
|
||||
export const duplicateDocument = createActionV2({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Duplicate") : t("Duplicate document")),
|
||||
analyticsName: "Duplicate document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <DuplicateIcon />,
|
||||
@@ -620,7 +723,7 @@ export const duplicateDocument = createAction({
|
||||
* Pin a document to a collection. Pinned documents will be displayed at the top
|
||||
* of the collection for all collection members to see.
|
||||
*/
|
||||
export const pinDocumentToCollection = createAction({
|
||||
export const pinDocumentToCollection = createActionV2({
|
||||
name: ({ activeDocumentId = "", t, stores }) => {
|
||||
const selectedDocument = stores.documents.get(activeDocumentId);
|
||||
const collectionName = selectedDocument
|
||||
@@ -665,7 +768,7 @@ export const pinDocumentToCollection = createAction({
|
||||
* Pin a document to team home. Pinned documents will be displayed at the top
|
||||
* of the home screen for all team members to see.
|
||||
*/
|
||||
export const pinDocumentToHome = createAction({
|
||||
export const pinDocumentToHome = createActionV2({
|
||||
name: ({ t }) => t("Pin to home"),
|
||||
analyticsName: "Pin document to home",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -697,7 +800,7 @@ export const pinDocumentToHome = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const pinDocument = createAction({
|
||||
export const pinDocument = createActionV2WithChildren({
|
||||
name: ({ t }) => t("Pin"),
|
||||
analyticsName: "Pin document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -705,7 +808,7 @@ export const pinDocument = createAction({
|
||||
children: [pinDocumentToCollection, pinDocumentToHome],
|
||||
});
|
||||
|
||||
export const searchInDocument = createAction({
|
||||
export const searchInDocument = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Search in document"),
|
||||
analyticsName: "Search document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -718,14 +821,25 @@ export const searchInDocument = createAction({
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return !!document?.isActive;
|
||||
},
|
||||
perform: ({ activeDocumentId }) => {
|
||||
history.push(searchPath({ documentId: activeDocumentId }));
|
||||
to: ({ activeDocumentId, sidebarContext }) => {
|
||||
if (!activeDocumentId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const [pathname, search] = searchPath({
|
||||
documentId: activeDocumentId,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const printDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Print") : t("Print document"),
|
||||
export const printDocument = createActionV2({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print document")),
|
||||
analyticsName: "Print document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <PrintIcon />,
|
||||
@@ -735,7 +849,7 @@ export const printDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const importDocument = createAction({
|
||||
export const importDocument = createActionV2({
|
||||
name: ({ t }) => t("Import document"),
|
||||
analyticsName: "Import document",
|
||||
section: DocumentSection,
|
||||
@@ -747,7 +861,7 @@ export const importDocument = createAction({
|
||||
}
|
||||
|
||||
if (activeCollectionId) {
|
||||
return !!stores.policies.abilities(activeCollectionId).update;
|
||||
return !!stores.policies.abilities(activeCollectionId).createDocument;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -760,7 +874,6 @@ export const importDocument = createAction({
|
||||
|
||||
input.onchange = async (ev) => {
|
||||
const files = getEventFiles(ev);
|
||||
|
||||
const file = files[0];
|
||||
|
||||
try {
|
||||
@@ -782,7 +895,7 @@ export const importDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const createTemplateFromDocument = createAction({
|
||||
export const createTemplateFromDocument = createActionV2({
|
||||
name: ({ t }) => t("Templatize"),
|
||||
analyticsName: "Templatize document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -844,7 +957,7 @@ export const searchDocumentsForQuery = (query: string) =>
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
export const moveTemplateToWorkspace = createAction({
|
||||
export const moveTemplateToWorkspace = createActionV2({
|
||||
name: ({ t }) => t("Move to workspace"),
|
||||
analyticsName: "Move template to workspace",
|
||||
section: DocumentSection,
|
||||
@@ -874,7 +987,7 @@ export const moveTemplateToWorkspace = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const moveDocumentToCollection = createAction({
|
||||
export const moveDocumentToCollection = createActionV2({
|
||||
name: ({ activeDocumentId, stores, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return t("Move");
|
||||
@@ -911,7 +1024,7 @@ export const moveDocumentToCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const moveDocument = createAction({
|
||||
export const moveDocument = createActionV2({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -930,7 +1043,7 @@ export const moveDocument = createAction({
|
||||
perform: moveDocumentToCollection.perform,
|
||||
});
|
||||
|
||||
export const moveTemplate = createAction({
|
||||
export const moveTemplate = createActionV2WithChildren({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -949,7 +1062,7 @@ export const moveTemplate = createAction({
|
||||
children: [moveTemplateToWorkspace, moveDocumentToCollection],
|
||||
});
|
||||
|
||||
export const archiveDocument = createAction({
|
||||
export const archiveDocument = createActionV2({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -989,7 +1102,102 @@ export const archiveDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteDocument = createAction({
|
||||
export const restoreDocument = createActionV2({
|
||||
name: ({ t }) => `${t("Restore")}`,
|
||||
analyticsName: "Restore document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <RestoreIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = document.collectionId
|
||||
? stores.collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const can = stores.policies.abilities(document.id);
|
||||
|
||||
return (
|
||||
!!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||||
!!(can.restore || can.unarchive)
|
||||
);
|
||||
},
|
||||
perform: async ({ t, stores, activeDocumentId }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
await document.restore();
|
||||
toast.success(
|
||||
t("{{ documentName }} restored", {
|
||||
documentName: capitalize(document.noun),
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const restoreDocumentToCollection = createActionV2WithChildren({
|
||||
name: ({ t }) => `${t("Restore")}…`,
|
||||
analyticsName: "Restore document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <RestoreIcon />,
|
||||
visible: ({ stores, activeDocumentId }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const can = stores.policies.abilities(document.id);
|
||||
const collection = document.collectionId
|
||||
? stores.collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||||
!!(can.restore || can.unarchive)
|
||||
);
|
||||
},
|
||||
children: ({ t, activeDocumentId, stores }) => {
|
||||
const { collections, documents, policies } = stores;
|
||||
|
||||
const document = activeDocumentId
|
||||
? documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const actions = collections.orderedData.map((collection) => {
|
||||
const can = policies.abilities(collection.id);
|
||||
return createActionV2({
|
||||
name: collection.name,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <CollectionIcon collection={collection} />,
|
||||
visible: can.createDocument,
|
||||
perform: async () => {
|
||||
await document.restore({ collectionId: collection.id });
|
||||
toast.success(
|
||||
t("{{ documentName }} restored", {
|
||||
documentName: capitalize(document.noun),
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return [createActionV2Group({ name: t("Choose a collection"), actions })];
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteDocument = createActionV2({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1023,7 +1231,7 @@ export const deleteDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const permanentlyDeleteDocument = createAction({
|
||||
export const permanentlyDeleteDocument = createActionV2({
|
||||
name: ({ t }) => t("Permanently delete"),
|
||||
analyticsName: "Permanently delete document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1078,7 +1286,7 @@ export const permanentlyDeleteDocumentsInTrash = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentComments = createAction({
|
||||
export const openDocumentComments = createActionV2({
|
||||
name: ({ t }) => t("Comments"),
|
||||
analyticsName: "Open comments",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1101,7 +1309,7 @@ export const openDocumentComments = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentHistory = createAction({
|
||||
export const openDocumentHistory = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("History"),
|
||||
analyticsName: "Open document history",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1110,19 +1318,25 @@ export const openDocumentHistory = createAction({
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
return !!activeDocumentId && can.listRevisions;
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
to: ({ activeDocumentId, stores, sidebarContext }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return;
|
||||
return "";
|
||||
}
|
||||
history.push(documentHistoryPath(document));
|
||||
|
||||
const [pathname, search] = documentHistoryPath(document).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentInsights = createAction({
|
||||
export const openDocumentInsights = createActionV2({
|
||||
name: ({ t }) => t("Insights"),
|
||||
analyticsName: "Open document insights",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1140,50 +1354,22 @@ export const openDocumentInsights = createAction({
|
||||
!document?.isDeleted
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
history.push(documentInsightsPath(document));
|
||||
},
|
||||
});
|
||||
|
||||
export const toggleViewerInsights = createAction({
|
||||
name: ({ t, stores, activeDocumentId }) => {
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
return document?.insightsEnabled
|
||||
? t("Disable viewer insights")
|
||||
: t("Enable viewer insights");
|
||||
},
|
||||
analyticsName: "Toggle viewer insights",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <EyeIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
return can.updateInsights;
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
await document.save({
|
||||
insightsEnabled: !document.insightsEnabled,
|
||||
stores.dialogs.openModal({
|
||||
title: t("Insights"),
|
||||
content: <Insights document={document} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const leaveDocument = createAction({
|
||||
export const leaveDocument = createActionV2({
|
||||
name: ({ t }) => t("Leave document"),
|
||||
analyticsName: "Leave document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1219,6 +1405,27 @@ export const leaveDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const applyTemplateFactory = ({
|
||||
actions,
|
||||
}: {
|
||||
actions: (ActionV2 | ActionV2Group | ActionV2Separator)[];
|
||||
}) =>
|
||||
createActionV2WithChildren({
|
||||
name: ({ t }) => t("Apply template"),
|
||||
analyticsName: "Apply template",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const { policies } = stores;
|
||||
const can = activeDocumentId
|
||||
? policies.abilities(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
return !!can?.update;
|
||||
},
|
||||
children: actions,
|
||||
});
|
||||
|
||||
export const rootDocumentActions = [
|
||||
openDocument,
|
||||
archiveDocument,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { TrashIcon } from "outline-icons";
|
||||
import stores from "~/stores";
|
||||
import { createAction } from "..";
|
||||
import { SettingsSection } from "../sections";
|
||||
import Integration from "~/models/Integration";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import { DisconnectAnalyticsDialog } from "~/components/DisconnectAnalyticsDialog";
|
||||
|
||||
export const disconnectAnalyticsIntegrationFactory = (
|
||||
integration?: Integration<IntegrationType.Analytics>
|
||||
) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Disconnect analytics"),
|
||||
analyticsName: "Disconnect analytics",
|
||||
section: SettingsSection,
|
||||
icon: <TrashIcon />,
|
||||
keywords: "disconnect",
|
||||
visible: () => !!integration,
|
||||
perform: ({ t, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Disconnect analytics"),
|
||||
content: <DisconnectAnalyticsDialog integration={integration!} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -12,13 +12,19 @@ import {
|
||||
BrowserIcon,
|
||||
ShapesIcon,
|
||||
DraftsIcon,
|
||||
BugIcon,
|
||||
} from "outline-icons";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
import { isMac } from "@shared/utils/browser";
|
||||
import stores from "~/stores";
|
||||
import SearchQuery from "~/models/SearchQuery";
|
||||
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||
import { createAction } from "~/actions";
|
||||
import {
|
||||
createAction,
|
||||
createActionV2,
|
||||
createExternalLinkActionV2,
|
||||
createInternalLinkActionV2,
|
||||
} from "~/actions";
|
||||
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
@@ -97,7 +103,7 @@ export const navigateToSettings = createAction({
|
||||
to: settingsPath(),
|
||||
});
|
||||
|
||||
export const navigateToWorkspaceSettings = createAction({
|
||||
export const navigateToWorkspaceSettings = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Settings"),
|
||||
analyticsName: "Navigate to workspace settings",
|
||||
section: NavigationSection,
|
||||
@@ -106,7 +112,7 @@ export const navigateToWorkspaceSettings = createAction({
|
||||
to: settingsPath("details"),
|
||||
});
|
||||
|
||||
export const navigateToProfileSettings = createAction({
|
||||
export const navigateToProfileSettings = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Profile"),
|
||||
analyticsName: "Navigate to profile settings",
|
||||
section: NavigationSection,
|
||||
@@ -124,8 +130,9 @@ export const navigateToTemplateSettings = createAction({
|
||||
to: settingsPath("templates"),
|
||||
});
|
||||
|
||||
export const navigateToNotificationSettings = createAction({
|
||||
name: ({ t }) => t("Notifications"),
|
||||
export const navigateToNotificationSettings = createInternalLinkActionV2({
|
||||
name: ({ t, isMenu }) =>
|
||||
isMenu ? t("Notification settings") : t("Notifications"),
|
||||
analyticsName: "Navigate to notification settings",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
@@ -133,7 +140,7 @@ export const navigateToNotificationSettings = createAction({
|
||||
to: settingsPath("notifications"),
|
||||
});
|
||||
|
||||
export const navigateToAccountPreferences = createAction({
|
||||
export const navigateToAccountPreferences = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Preferences"),
|
||||
analyticsName: "Navigate to account preferences",
|
||||
section: NavigationSection,
|
||||
@@ -142,28 +149,24 @@ export const navigateToAccountPreferences = createAction({
|
||||
to: settingsPath("preferences"),
|
||||
});
|
||||
|
||||
export const openDocumentation = createAction({
|
||||
export const openDocumentation = createExternalLinkActionV2({
|
||||
name: ({ t }) => t("Documentation"),
|
||||
analyticsName: "Open documentation",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <OpenIcon />,
|
||||
to: {
|
||||
url: UrlHelper.guide,
|
||||
target: "_blank",
|
||||
},
|
||||
url: UrlHelper.guide,
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const openAPIDocumentation = createAction({
|
||||
export const openAPIDocumentation = createExternalLinkActionV2({
|
||||
name: ({ t }) => t("API documentation"),
|
||||
analyticsName: "Open API documentation",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <OpenIcon />,
|
||||
to: {
|
||||
url: UrlHelper.developers,
|
||||
target: "_blank",
|
||||
},
|
||||
url: UrlHelper.developers,
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const toggleSidebar = createAction({
|
||||
@@ -174,41 +177,37 @@ export const toggleSidebar = createAction({
|
||||
perform: () => stores.ui.toggleCollapsedSidebar(),
|
||||
});
|
||||
|
||||
export const openFeedbackUrl = createAction({
|
||||
export const openFeedbackUrl = createExternalLinkActionV2({
|
||||
name: ({ t }) => t("Send us feedback"),
|
||||
analyticsName: "Open feedback",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <EmailIcon />,
|
||||
to: {
|
||||
url: UrlHelper.contact,
|
||||
target: "_blank",
|
||||
},
|
||||
url: UrlHelper.contact,
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const openBugReportUrl = createAction({
|
||||
export const openBugReportUrl = createExternalLinkActionV2({
|
||||
name: ({ t }) => t("Report a bug"),
|
||||
analyticsName: "Open bug report",
|
||||
section: NavigationSection,
|
||||
to: {
|
||||
url: UrlHelper.github,
|
||||
target: "_blank",
|
||||
},
|
||||
iconInContextMenu: false,
|
||||
icon: <BugIcon />,
|
||||
url: UrlHelper.github,
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const openChangelog = createAction({
|
||||
export const openChangelog = createExternalLinkActionV2({
|
||||
name: ({ t }) => t("Changelog"),
|
||||
analyticsName: "Open changelog",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <OpenIcon />,
|
||||
to: {
|
||||
url: UrlHelper.changelog,
|
||||
target: "_blank",
|
||||
},
|
||||
url: UrlHelper.changelog,
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const openKeyboardShortcuts = createAction({
|
||||
export const openKeyboardShortcuts = createActionV2({
|
||||
name: ({ t }) => t("Keyboard shortcuts"),
|
||||
analyticsName: "Open keyboard shortcuts",
|
||||
section: NavigationSection,
|
||||
@@ -239,7 +238,7 @@ export const downloadApp = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const logout = createAction({
|
||||
export const logout = createActionV2({
|
||||
name: ({ t }) => t("Log out"),
|
||||
analyticsName: "Log out",
|
||||
section: NavigationSection,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons";
|
||||
import { createAction } from "..";
|
||||
import { createAction, createActionV2 } from "..";
|
||||
import { NotificationSection } from "../sections";
|
||||
|
||||
export const markNotificationsAsRead = createAction({
|
||||
@@ -12,7 +12,7 @@ export const markNotificationsAsRead = createAction({
|
||||
visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0,
|
||||
});
|
||||
|
||||
export const markNotificationsAsArchived = createAction({
|
||||
export const markNotificationsAsArchived = createActionV2({
|
||||
name: ({ t }) => t("Archive all notifications"),
|
||||
analyticsName: "Mark notifications as archived",
|
||||
section: NotificationSection,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { LinkIcon, RestoreIcon, TrashIcon } from "outline-icons";
|
||||
import { matchPath } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import stores from "~/stores";
|
||||
import { createAction } from "~/actions";
|
||||
import { createAction, createActionV2 } from "~/actions";
|
||||
import { RevisionSection } from "~/actions/sections";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
matchDocumentHistory,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
export const restoreRevision = createAction({
|
||||
export const restoreRevision = createActionV2({
|
||||
name: ({ t }) => t("Restore"),
|
||||
analyticsName: "Restore revision",
|
||||
icon: <RestoreIcon />,
|
||||
@@ -73,7 +73,7 @@ export const deleteRevision = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyLinkToRevision = createAction({
|
||||
export const copyLinkToRevision = createActionV2({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
analyticsName: "Copy link to revision",
|
||||
icon: <LinkIcon />,
|
||||
|
||||
@@ -1,50 +1,47 @@
|
||||
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
|
||||
import stores from "~/stores";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
import { createAction } from "~/actions";
|
||||
import { createActionV2, createActionV2WithChildren } from "~/actions";
|
||||
import { SettingsSection } from "~/actions/sections";
|
||||
|
||||
export const changeToDarkTheme = createAction({
|
||||
export const changeToDarkTheme = createActionV2({
|
||||
name: ({ t }) => t("Dark"),
|
||||
analyticsName: "Change to dark theme",
|
||||
icon: <MoonIcon />,
|
||||
iconInContextMenu: false,
|
||||
keywords: "theme dark night",
|
||||
section: SettingsSection,
|
||||
selected: () => stores.ui.theme === "dark",
|
||||
perform: () => stores.ui.setTheme(Theme.Dark),
|
||||
selected: ({ stores }) => stores.ui.theme === "dark",
|
||||
perform: ({ stores }) => stores.ui.setTheme(Theme.Dark),
|
||||
});
|
||||
|
||||
export const changeToLightTheme = createAction({
|
||||
export const changeToLightTheme = createActionV2({
|
||||
name: ({ t }) => t("Light"),
|
||||
analyticsName: "Change to light theme",
|
||||
icon: <SunIcon />,
|
||||
iconInContextMenu: false,
|
||||
keywords: "theme light day",
|
||||
section: SettingsSection,
|
||||
selected: () => stores.ui.theme === "light",
|
||||
perform: () => stores.ui.setTheme(Theme.Light),
|
||||
selected: ({ stores }) => stores.ui.theme === "light",
|
||||
perform: ({ stores }) => stores.ui.setTheme(Theme.Light),
|
||||
});
|
||||
|
||||
export const changeToSystemTheme = createAction({
|
||||
export const changeToSystemTheme = createActionV2({
|
||||
name: ({ t }) => t("System"),
|
||||
analyticsName: "Change to system theme",
|
||||
icon: <BrowserIcon />,
|
||||
iconInContextMenu: false,
|
||||
keywords: "theme system default",
|
||||
section: SettingsSection,
|
||||
selected: () => stores.ui.theme === "system",
|
||||
perform: () => stores.ui.setTheme(Theme.System),
|
||||
selected: ({ stores }) => stores.ui.theme === "system",
|
||||
perform: ({ stores }) => stores.ui.setTheme(Theme.System),
|
||||
});
|
||||
|
||||
export const changeTheme = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Appearance") : t("Change theme"),
|
||||
export const changeTheme = createActionV2WithChildren({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Appearance") : t("Change theme")),
|
||||
analyticsName: "Change theme",
|
||||
placeholder: ({ t }) => t("Change theme to"),
|
||||
icon: function _Icon() {
|
||||
return stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />;
|
||||
},
|
||||
icon: ({ stores }) =>
|
||||
stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
|
||||
keywords: "appearance display",
|
||||
section: SettingsSection,
|
||||
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import Share from "~/models/Share";
|
||||
import { createActionV2, createInternalLinkActionV2 } from "..";
|
||||
import { ArrowIcon, CopyIcon, TrashIcon } from "outline-icons";
|
||||
import { ShareSection } from "../sections";
|
||||
import env from "~/env";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const copyShareUrlFactory = ({ share }: { share: Share }) =>
|
||||
createActionV2({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
analyticsName: "Copy share link",
|
||||
section: ShareSection,
|
||||
icon: <CopyIcon />,
|
||||
perform: ({ t }) => {
|
||||
copy(share.url, {
|
||||
debug: env.ENVIRONMENT !== "production",
|
||||
format: "text/plain",
|
||||
});
|
||||
toast.success(t("Share link copied"));
|
||||
},
|
||||
});
|
||||
|
||||
export const goToShareSourceFactory = ({ share }: { share: Share }) =>
|
||||
createInternalLinkActionV2({
|
||||
name: ({ t }) =>
|
||||
share.collectionId ? t("Go to collection") : t("Go to document"),
|
||||
analyticsName: "Go to share source",
|
||||
section: ShareSection,
|
||||
icon: <ArrowIcon />,
|
||||
to: {
|
||||
pathname: share.sourcePathWithFallback,
|
||||
state: { sidebarContext: "collections" }, // optimistic preference of "collections"
|
||||
},
|
||||
});
|
||||
|
||||
export const revokeShareFactory = ({
|
||||
share,
|
||||
can,
|
||||
}: {
|
||||
share: Share;
|
||||
can: Record<string, boolean>;
|
||||
}) =>
|
||||
createActionV2({
|
||||
name: ({ t }) => t("Revoke link"),
|
||||
analyticsName: "Revoke share",
|
||||
section: ShareSection,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
visible: !!can.revoke,
|
||||
perform: async ({ t, stores }) => {
|
||||
try {
|
||||
await stores.shares.revoke(share);
|
||||
toast.message(t("Share link revoked"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -5,20 +5,24 @@ import RootStore from "~/stores/RootStore";
|
||||
import { LoginDialog } from "~/scenes/Login/components/LoginDialog";
|
||||
import TeamNew from "~/scenes/TeamNew";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
import { createAction } from "~/actions";
|
||||
import { ActionContext } from "~/types";
|
||||
import {
|
||||
createActionV2,
|
||||
createActionV2WithChildren,
|
||||
createExternalLinkActionV2,
|
||||
} from "~/actions";
|
||||
import { ActionContext, ExternalLinkActionV2 } from "~/types";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { TeamSection } from "../sections";
|
||||
|
||||
export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
stores.auth.availableTeams?.map((session) => ({
|
||||
id: `switch-${session.id}`,
|
||||
name: session.name,
|
||||
analyticsName: "Switch workspace",
|
||||
section: TeamSection,
|
||||
keywords: "change switch workspace organization team",
|
||||
icon: function _Icon() {
|
||||
return (
|
||||
stores.auth.availableTeams?.map<ExternalLinkActionV2>((session) =>
|
||||
createExternalLinkActionV2({
|
||||
id: `switch-${session.id}`,
|
||||
name: session.name,
|
||||
analyticsName: "Switch workspace",
|
||||
section: TeamSection,
|
||||
keywords: "change switch workspace organization team",
|
||||
icon: (
|
||||
<StyledTeamLogo
|
||||
alt={session.name}
|
||||
model={{
|
||||
@@ -29,16 +33,15 @@ export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
}}
|
||||
size={24}
|
||||
/>
|
||||
);
|
||||
},
|
||||
visible: ({ currentTeamId }: ActionContext) => currentTeamId !== session.id,
|
||||
to: {
|
||||
),
|
||||
visible: ({ currentTeamId }: ActionContext) =>
|
||||
currentTeamId !== session.id,
|
||||
url: session.url,
|
||||
target: "_self",
|
||||
},
|
||||
})) ?? [];
|
||||
})
|
||||
) ?? [];
|
||||
|
||||
export const switchTeam = createAction({
|
||||
export const switchTeam = createActionV2WithChildren({
|
||||
name: ({ t }) => t("Switch workspace"),
|
||||
placeholder: ({ t }) => t("Select a workspace"),
|
||||
analyticsName: "Switch workspace",
|
||||
@@ -49,7 +52,7 @@ export const switchTeam = createAction({
|
||||
children: switchTeamsList,
|
||||
});
|
||||
|
||||
export const createTeam = createAction({
|
||||
export const createTeam = createActionV2({
|
||||
name: ({ t }) => `${t("New workspace")}…`,
|
||||
analyticsName: "New workspace",
|
||||
keywords: "create change switch workspace organization team",
|
||||
@@ -71,7 +74,7 @@ export const createTeam = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const desktopLoginTeam = createAction({
|
||||
export const desktopLoginTeam = createActionV2({
|
||||
name: ({ t }) => t("Login to workspace"),
|
||||
analyticsName: "Login to workspace",
|
||||
keywords: "change switch workspace organization team",
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
UserChangeRoleDialog,
|
||||
UserDeleteDialog,
|
||||
} from "~/components/UserDialogs";
|
||||
import { createAction } from "~/actions";
|
||||
import { createAction, createActionV2 } from "~/actions";
|
||||
import { UserSection } from "~/actions/sections";
|
||||
|
||||
export const inviteUser = createAction({
|
||||
@@ -28,7 +28,7 @@ export const inviteUser = createAction({
|
||||
});
|
||||
|
||||
export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) =>
|
||||
UserRoleHelper.isRoleHigher(role, user!.role)
|
||||
? `${t("Promote to {{ role }}", {
|
||||
@@ -63,7 +63,7 @@ export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
|
||||
});
|
||||
|
||||
export const deleteUserActionFactory = (userId: string) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) => `${t("Delete user")}…`,
|
||||
analyticsName: "Delete user",
|
||||
keywords: "leave",
|
||||
|
||||
+102
-16
@@ -1,7 +1,7 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import flattenDeep from "lodash/flattenDeep";
|
||||
import { toast } from "sonner";
|
||||
import { Optional } from "utility-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
Action,
|
||||
ActionContext,
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
ActionV2Separator as TActionV2Separator,
|
||||
ActionV2Variant,
|
||||
ActionV2WithChildren,
|
||||
CommandBarAction,
|
||||
ExternalLinkActionV2,
|
||||
InternalLinkActionV2,
|
||||
MenuExternalLink,
|
||||
@@ -21,8 +20,9 @@ import {
|
||||
} from "~/types";
|
||||
import Analytics from "~/utils/Analytics";
|
||||
import history from "~/utils/history";
|
||||
import { Action as KbarAction } from "kbar";
|
||||
|
||||
function resolve<T>(value: any, context: ActionContext): T {
|
||||
export function resolve<T>(value: any, context: ActionContext): T {
|
||||
return typeof value === "function" ? value(context) : value;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function createAction(definition: Optional<Action, "id">): Action {
|
||||
return definition.perform?.(context);
|
||||
}
|
||||
: undefined,
|
||||
id: definition.id ?? uuidv4(),
|
||||
id: definition.id ?? crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ export function actionToMenuItem(
|
||||
export function actionToKBar(
|
||||
action: Action,
|
||||
context: ActionContext
|
||||
): CommandBarAction[] {
|
||||
): KbarAction[] {
|
||||
if (typeof action.visible === "function" && !action.visible(context)) {
|
||||
return [];
|
||||
}
|
||||
@@ -201,7 +201,7 @@ export function createActionV2(
|
||||
return definition.perform(context);
|
||||
}
|
||||
: () => {},
|
||||
id: definition.id ?? uuidv4(),
|
||||
id: definition.id ?? crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ export function createInternalLinkActionV2(
|
||||
...definition,
|
||||
type: "action",
|
||||
variant: "internal_link",
|
||||
id: definition.id ?? uuidv4(),
|
||||
id: definition.id ?? crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ export function createExternalLinkActionV2(
|
||||
...definition,
|
||||
type: "action",
|
||||
variant: "external_link",
|
||||
id: definition.id ?? uuidv4(),
|
||||
id: definition.id ?? crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ export function createActionV2WithChildren(
|
||||
...definition,
|
||||
type: "action",
|
||||
variant: "action_with_children",
|
||||
id: definition.id ?? uuidv4(),
|
||||
id: definition.id ?? crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@ export function createRootMenuAction(
|
||||
actions: (ActionV2Variant | ActionV2Group | TActionV2Separator)[]
|
||||
): ActionV2WithChildren {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
id: crypto.randomUUID(),
|
||||
type: "action",
|
||||
variant: "action_with_children",
|
||||
name: "root_action",
|
||||
@@ -267,10 +267,11 @@ export function actionV2ToMenuItem(
|
||||
switch (action.type) {
|
||||
case "action": {
|
||||
const title = resolve<string>(action.name, context);
|
||||
const visible = resolve<boolean>(action.visible, context);
|
||||
const visible = resolve<boolean>(action.visible, context) ?? true;
|
||||
const disabled = resolve<boolean>(action.disabled, context);
|
||||
const icon =
|
||||
!!action.icon && action.iconInContextMenu !== false
|
||||
? action.icon
|
||||
? resolve<React.ReactNode>(action.icon, context)
|
||||
: undefined;
|
||||
|
||||
switch (action.variant) {
|
||||
@@ -280,18 +281,24 @@ export function actionV2ToMenuItem(
|
||||
title,
|
||||
icon,
|
||||
visible,
|
||||
disabled,
|
||||
tooltip: resolve<React.ReactChild>(action.tooltip, context),
|
||||
selected: resolve<boolean>(action.selected, context),
|
||||
dangerous: action.dangerous,
|
||||
onClick: () => performActionV2(action, context),
|
||||
};
|
||||
|
||||
case "internal_link":
|
||||
case "internal_link": {
|
||||
const to = resolve<LocationDescriptor>(action.to, context);
|
||||
return {
|
||||
type: "route",
|
||||
title,
|
||||
icon,
|
||||
visible,
|
||||
to: action.to,
|
||||
disabled,
|
||||
to,
|
||||
};
|
||||
}
|
||||
|
||||
case "external_link":
|
||||
return {
|
||||
@@ -299,6 +306,7 @@ export function actionV2ToMenuItem(
|
||||
title,
|
||||
icon,
|
||||
visible,
|
||||
disabled,
|
||||
href: action.target
|
||||
? { url: action.url, target: action.target }
|
||||
: action.url,
|
||||
@@ -316,6 +324,7 @@ export function actionV2ToMenuItem(
|
||||
title,
|
||||
icon,
|
||||
items: subMenuItems,
|
||||
disabled,
|
||||
visible: visible && hasVisibleItems(subMenuItems),
|
||||
};
|
||||
}
|
||||
@@ -342,11 +351,88 @@ export function actionV2ToMenuItem(
|
||||
}
|
||||
}
|
||||
|
||||
export function actionV2ToKBar(
|
||||
action: ActionV2Variant,
|
||||
context: ActionContext
|
||||
): KbarAction[] {
|
||||
const visible = resolve<boolean>(action.visible, context);
|
||||
if (visible === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const name = resolve<string>(action.name, context);
|
||||
const icon = resolve<React.ReactElement>(action.icon, context);
|
||||
const section = resolve<string>(action.section, context);
|
||||
|
||||
const sectionPriority =
|
||||
typeof action.section !== "string" && "priority" in action.section
|
||||
? ((action.section.priority as number) ?? 0)
|
||||
: 0;
|
||||
|
||||
const priority = (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0));
|
||||
|
||||
switch (action.variant) {
|
||||
case "action":
|
||||
case "internal_link":
|
||||
case "external_link": {
|
||||
return [
|
||||
{
|
||||
id: action.id,
|
||||
name,
|
||||
section,
|
||||
keywords: action.keywords,
|
||||
shortcut: action.shortcut,
|
||||
icon,
|
||||
priority,
|
||||
perform: () => performActionV2(action, context),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case "action_with_children": {
|
||||
const resolvedChildren = resolve<ActionV2Variant[]>(
|
||||
action.children,
|
||||
context
|
||||
);
|
||||
const children = resolvedChildren
|
||||
.map((a) => actionV2ToKBar(a, context))
|
||||
.flat()
|
||||
.filter(Boolean);
|
||||
|
||||
return [
|
||||
{
|
||||
id: action.id,
|
||||
name,
|
||||
section,
|
||||
keywords: action.keywords,
|
||||
shortcut: action.shortcut,
|
||||
icon,
|
||||
priority,
|
||||
},
|
||||
...children.map((child) => ({
|
||||
...child,
|
||||
parent: child.parent ?? action.id,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
default:
|
||||
throw Error("invalid action variant");
|
||||
}
|
||||
}
|
||||
|
||||
export async function performActionV2(
|
||||
action: ActionV2,
|
||||
action: Exclude<ActionV2Variant, ActionV2WithChildren>,
|
||||
context: ActionContext
|
||||
) {
|
||||
const result = action.perform(context);
|
||||
const perform =
|
||||
action.variant === "action"
|
||||
? () => action.perform(context)
|
||||
: action.variant === "internal_link"
|
||||
? () => history.push(resolve<LocationDescriptor>(action.to, context))
|
||||
: () => window.open(action.url, action.target);
|
||||
|
||||
const result = perform();
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result.catch((err: Error) => {
|
||||
|
||||
@@ -36,10 +36,14 @@ export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
|
||||
|
||||
export const NotificationSection = ({ t }: ActionContext) => t("Notification");
|
||||
|
||||
export const GroupSection = ({ t }: ActionContext) => t("Groups");
|
||||
|
||||
export const UserSection = ({ t }: ActionContext) => t("People");
|
||||
|
||||
UserSection.priority = 0.5;
|
||||
|
||||
export const ShareSection = ({ t }: ActionContext) => t("Share");
|
||||
|
||||
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
|
||||
|
||||
export const RecentSearchesSection = ({ t }: ActionContext) =>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
/* oxlint-disable react/prop-types */
|
||||
import * as React from "react";
|
||||
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
|
||||
import { performAction } from "~/actions";
|
||||
import { performAction, performActionV2, resolve } from "~/actions";
|
||||
import useIsMounted from "~/hooks/useIsMounted";
|
||||
import { Action, ActionContext } from "~/types";
|
||||
import { Action, ActionV2Variant, ActionV2WithChildren } from "~/types";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
|
||||
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
/** Show the button in a disabled state */
|
||||
@@ -11,9 +12,7 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
/** Hide the button entirely if action is not applicable */
|
||||
hideOnActionDisabled?: boolean;
|
||||
/** Action to use on button */
|
||||
action?: Action;
|
||||
/** Context of action, must be provided with action */
|
||||
context?: ActionContext;
|
||||
action?: Action | Exclude<ActionV2Variant, ActionV2WithChildren>;
|
||||
/** If tooltip props are provided the button will be wrapped in a tooltip */
|
||||
tooltip?: Omit<TooltipProps, "children">;
|
||||
};
|
||||
@@ -23,34 +22,33 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
*/
|
||||
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
function _ActionButton(
|
||||
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
|
||||
{ action, tooltip, hideOnActionDisabled, ...rest }: Props,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) {
|
||||
const actionContext = useActionContext({
|
||||
isButton: true,
|
||||
});
|
||||
const isMounted = useIsMounted();
|
||||
const [executing, setExecuting] = React.useState(false);
|
||||
const disabled = rest.disabled;
|
||||
|
||||
if (action && !context) {
|
||||
throw new Error("Context must be provided with action");
|
||||
}
|
||||
if (!context || !action) {
|
||||
if (!actionContext || !action) {
|
||||
return <button {...rest} ref={ref} />;
|
||||
}
|
||||
|
||||
const actionContext = { ...context, isButton: true };
|
||||
|
||||
if (
|
||||
action?.visible &&
|
||||
!action.visible(actionContext) &&
|
||||
action.visible &&
|
||||
!resolve<boolean>(action.visible, actionContext) &&
|
||||
hideOnActionDisabled
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label =
|
||||
typeof action.name === "function"
|
||||
rest["aria-label"] ??
|
||||
(typeof action.name === "function"
|
||||
? action.name(actionContext)
|
||||
: action.name;
|
||||
: action.name);
|
||||
|
||||
const button = (
|
||||
<button
|
||||
@@ -63,7 +61,10 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
? (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const response = performAction(action, actionContext);
|
||||
const response =
|
||||
"variant" in action
|
||||
? performActionV2(action, actionContext)
|
||||
: performAction(action, actionContext);
|
||||
if (response?.finally) {
|
||||
setExecuting(true);
|
||||
void response.finally(
|
||||
|
||||
@@ -6,7 +6,6 @@ import Flex from "~/components/Flex";
|
||||
export const Action = styled(Flex)`
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 0 0 12px;
|
||||
height: 32px;
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
@@ -18,7 +17,6 @@ export const Action = styled(Flex)`
|
||||
|
||||
export const Separator = styled.div`
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: ${s("divider")};
|
||||
@@ -33,6 +31,7 @@ const Actions = styled(Flex)`
|
||||
background: ${s("background")};
|
||||
padding: 12px;
|
||||
backdrop-filter: blur(20px);
|
||||
gap: 12px;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable prefer-rest-params */
|
||||
/* oxlint-disable prefer-rest-params */
|
||||
/* global ga */
|
||||
import escape from "lodash/escape";
|
||||
import * as React from "react";
|
||||
|
||||
@@ -13,8 +13,6 @@ import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
import SidebarRight from "~/components/Sidebar/Right";
|
||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -27,11 +25,11 @@ import {
|
||||
settingsPath,
|
||||
matchDocumentHistory,
|
||||
matchDocumentSlug as slug,
|
||||
matchDocumentInsights,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { DocumentContextProvider } from "./DocumentContext";
|
||||
import Fade from "./Fade";
|
||||
import { PortalContext } from "./Portal";
|
||||
import CommandBar from "./CommandBar";
|
||||
|
||||
const DocumentComments = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Comments")
|
||||
@@ -39,10 +37,9 @@ const DocumentComments = lazyWithRetry(
|
||||
const DocumentHistory = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/History")
|
||||
);
|
||||
const DocumentInsights = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Insights")
|
||||
const SettingsSidebar = lazyWithRetry(
|
||||
() => import("~/components/Sidebar/Settings")
|
||||
);
|
||||
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -98,12 +95,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
!!matchPath(location.pathname, {
|
||||
path: matchDocumentHistory,
|
||||
}) && can.listRevisions;
|
||||
const showInsights =
|
||||
!!matchPath(location.pathname, {
|
||||
path: matchDocumentInsights,
|
||||
}) && can.listViews;
|
||||
const showComments =
|
||||
!showInsights &&
|
||||
!showHistory &&
|
||||
can.comment &&
|
||||
ui.activeDocumentId &&
|
||||
@@ -115,15 +107,12 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
initial={false}
|
||||
key={ui.activeDocumentId ? "active" : "inactive"}
|
||||
>
|
||||
{(showHistory || showInsights || showComments) && (
|
||||
{(showHistory || showComments) && (
|
||||
<Route path={`/doc/${slug}`}>
|
||||
<SidebarRight>
|
||||
<React.Suspense fallback={null}>
|
||||
{showHistory && <DocumentHistory />}
|
||||
{showInsights && <DocumentInsights />}
|
||||
{showComments && <DocumentComments />}
|
||||
</React.Suspense>
|
||||
</SidebarRight>
|
||||
<React.Suspense fallback={null}>
|
||||
{showHistory && <DocumentHistory />}
|
||||
{showComments && <DocumentComments />}
|
||||
</React.Suspense>
|
||||
</Route>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -142,9 +131,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<React.Suspense fallback={null}>
|
||||
<CommandBar />
|
||||
</React.Suspense>
|
||||
<CommandBar />
|
||||
</Layout>
|
||||
</PortalContext.Provider>
|
||||
</DocumentContextProvider>
|
||||
|
||||
@@ -25,6 +25,8 @@ type Props = {
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
/** Size of the avatar, defaults to AvatarSize.Large */
|
||||
size?: AvatarSize;
|
||||
/** Optional alt text for the avatar image */
|
||||
alt?: string;
|
||||
/** Optional inline styles to apply to the avatar wrapper */
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
@@ -53,6 +55,7 @@ function AvatarWithPresence({
|
||||
isCurrentUser,
|
||||
size = AvatarSize.Large,
|
||||
style,
|
||||
alt,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const status = isPresent
|
||||
@@ -83,7 +86,7 @@ function AvatarWithPresence({
|
||||
$color={user.color}
|
||||
style={style}
|
||||
>
|
||||
<Avatar model={user} onClick={onClick} size={size} />
|
||||
<Avatar model={user} onClick={onClick} size={size} alt={alt} />
|
||||
</AvatarPresence>
|
||||
</Tooltip>
|
||||
</>
|
||||
|
||||
+12
-14
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import env from "~/env";
|
||||
import OutlineIcon from "./Icons/OutlineIcon";
|
||||
@@ -10,7 +10,7 @@ type Props = {
|
||||
|
||||
function Branding({ href = env.URL }: Props) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<Link href={href} target="_blank">
|
||||
<OutlineIcon size={20} />
|
||||
{env.APP_NAME}
|
||||
</Link>
|
||||
@@ -33,18 +33,16 @@ const Link = styled.a`
|
||||
fill: ${s("text")};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
z-index: ${depths.sidebar + 1};
|
||||
background: ${s("sidebarBackground")};
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
padding: 16px;
|
||||
z-index: ${depths.sidebar + 1};
|
||||
background: ${s("sidebarBackground")};
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
padding: 16px;
|
||||
|
||||
&:hover {
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
`};
|
||||
&:hover {
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
`;
|
||||
|
||||
export default Branding;
|
||||
export default React.memo(Branding);
|
||||
|
||||
@@ -6,55 +6,89 @@ import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import { InternalLinkActionV2, MenuInternalLink } from "~/types";
|
||||
import { actionV2ToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
|
||||
type TopLevelAction =
|
||||
| InternalLinkActionV2
|
||||
| { type: "menu"; actions: InternalLinkActionV2[] };
|
||||
|
||||
type Props = React.PropsWithChildren<{
|
||||
items: MenuInternalLink[];
|
||||
actions: InternalLinkActionV2[];
|
||||
max?: number;
|
||||
highlightFirstItem?: boolean;
|
||||
}>;
|
||||
|
||||
function Breadcrumb(
|
||||
{ items, highlightFirstItem, children, max = 2 }: Props,
|
||||
{ actions, highlightFirstItem, children, max = 2 }: Props,
|
||||
ref: React.RefObject<HTMLDivElement> | null
|
||||
) {
|
||||
const totalItems = items.length;
|
||||
const topLevelItems: MenuInternalLink[] = [...items];
|
||||
let overflowItems;
|
||||
const actionContext = useActionContext({ isMenu: true });
|
||||
|
||||
const visibleActions = useComputed(
|
||||
() =>
|
||||
actions.filter((action) =>
|
||||
typeof action.visible === "function"
|
||||
? action.visible(actionContext)
|
||||
: (action.visible ?? true)
|
||||
),
|
||||
[actions, actionContext]
|
||||
);
|
||||
const totalVisibleActions = visibleActions.length;
|
||||
|
||||
const topLevelActions: TopLevelAction[] = [...visibleActions];
|
||||
|
||||
// chop middle breadcrumbs and present a "..." menu instead
|
||||
if (totalItems > max) {
|
||||
if (totalVisibleActions > max) {
|
||||
const halfMax = Math.floor(max / 2);
|
||||
overflowItems = topLevelItems.splice(halfMax, totalItems - max);
|
||||
const menuActions = topLevelActions.splice(
|
||||
halfMax,
|
||||
totalVisibleActions - max
|
||||
) as InternalLinkActionV2[];
|
||||
|
||||
topLevelItems.splice(halfMax, 0, {
|
||||
to: "",
|
||||
type: "route",
|
||||
title: <BreadcrumbMenu items={overflowItems as MenuInternalLink[]} />,
|
||||
topLevelActions.splice(halfMax, 0, {
|
||||
type: "menu",
|
||||
actions: menuActions,
|
||||
});
|
||||
}
|
||||
|
||||
const toBreadcrumb = React.useCallback(
|
||||
(action: TopLevelAction, index: number) => {
|
||||
if (action.type === "menu") {
|
||||
return <BreadcrumbMenu key="menu" actions={action.actions} />;
|
||||
}
|
||||
|
||||
const item = actionV2ToMenuItem(
|
||||
action,
|
||||
actionContext
|
||||
) as MenuInternalLink;
|
||||
|
||||
return (
|
||||
<>
|
||||
{item.icon}
|
||||
<Item
|
||||
to={item.to}
|
||||
$withIcon={!!item.icon}
|
||||
$highlight={!!highlightFirstItem && index === 0}
|
||||
>
|
||||
{item.title}
|
||||
</Item>
|
||||
</>
|
||||
);
|
||||
},
|
||||
[actionContext, highlightFirstItem]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex justify="flex-start" align="center" ref={ref}>
|
||||
{topLevelItems.map((item, index) => (
|
||||
<React.Fragment
|
||||
key={
|
||||
(typeof item.to === "string" ? item.to : item.to.pathname) || index
|
||||
}
|
||||
>
|
||||
{item.icon}
|
||||
{item.to ? (
|
||||
<Item
|
||||
to={item.to}
|
||||
$withIcon={!!item.icon}
|
||||
$highlight={!!highlightFirstItem && index === 0}
|
||||
>
|
||||
{item.title}
|
||||
</Item>
|
||||
) : (
|
||||
item.title
|
||||
)}
|
||||
{index !== topLevelItems.length - 1 || !!children ? <Slash /> : null}
|
||||
{topLevelActions.map((action, index) => (
|
||||
<React.Fragment key={action.type === "menu" ? "menu" : `item-${index}`}>
|
||||
{toBreadcrumb(action, index)}
|
||||
{index !== topLevelActions.length - 1 || !!children ? (
|
||||
<Slash />
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{children}
|
||||
|
||||
@@ -132,6 +132,7 @@ function Collaborators(props: Props) {
|
||||
isEditing={isEditing}
|
||||
isObserving={isObserving}
|
||||
isCurrentUser={currentUserId === collaborator.id}
|
||||
alt={t("Avatar of {{ name }}", { name: collaborator.name })}
|
||||
onClick={
|
||||
isObservable
|
||||
? handleAvatarClick(
|
||||
@@ -148,6 +149,10 @@ function Collaborators(props: Props) {
|
||||
[presentIds, editingIds, observingUserId, currentUserId, handleAvatarClick]
|
||||
);
|
||||
|
||||
if (!document.insightsEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
|
||||
@@ -3,9 +3,10 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Collection from "~/models/Collection";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import { archivePath, collectionPath } from "~/utils/routeHelpers";
|
||||
import Breadcrumb from "./Breadcrumb";
|
||||
import { createInternalLinkActionV2 } from "~/actions";
|
||||
import { ActiveCollectionSection } from "~/actions/sections";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -14,32 +15,24 @@ type Props = {
|
||||
export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const collectionNode: MenuInternalLink = {
|
||||
type: "route",
|
||||
title: collection.name,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: collectionPath(collection.path),
|
||||
};
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
createInternalLinkActionV2({
|
||||
name: t("Archive"),
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: collection.isArchived,
|
||||
to: archivePath(),
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
name: collection.name,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: collectionPath(collection.path),
|
||||
}),
|
||||
],
|
||||
[collection, t]
|
||||
);
|
||||
|
||||
const category: MenuInternalLink | undefined = collection.isArchived
|
||||
? {
|
||||
type: "route",
|
||||
icon: <ArchiveIcon />,
|
||||
title: t("Archive"),
|
||||
to: archivePath(),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const output = [];
|
||||
if (category) {
|
||||
output.push(category);
|
||||
}
|
||||
|
||||
output.push(collectionNode);
|
||||
|
||||
return output;
|
||||
}, [collection, t]);
|
||||
|
||||
return <Breadcrumb items={items} highlightFirstItem />;
|
||||
return <Breadcrumb actions={actions} highlightFirstItem />;
|
||||
};
|
||||
|
||||
@@ -143,13 +143,14 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
},
|
||||
[]
|
||||
);
|
||||
const contentEditable = !disabled && !readOnly;
|
||||
|
||||
return (
|
||||
<div className={className} dir={dir} onClick={onClick} tabIndex={-1}>
|
||||
{children}
|
||||
<Content
|
||||
ref={contentRef}
|
||||
contentEditable={!disabled && !readOnly}
|
||||
contentEditable={contentEditable}
|
||||
onInput={wrappedEvent(onInput)}
|
||||
onFocus={wrappedEvent(onFocus)}
|
||||
onBlur={wrappedEvent(onBlur)}
|
||||
@@ -157,7 +158,7 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
onPaste={handlePaste}
|
||||
data-placeholder={placeholder}
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
role={contentEditable ? "textbox" : undefined}
|
||||
{...rest}
|
||||
>
|
||||
{innerValue}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
const Header = styled.h3`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: ${s("sidebarText")};
|
||||
letter-spacing: 0.04em;
|
||||
margin: 1em 12px 0.5em;
|
||||
`;
|
||||
|
||||
export default Header;
|
||||
@@ -1,13 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
const MenuIconWrapper = styled.span`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 6px;
|
||||
margin-left: -4px;
|
||||
color: ${s("textSecondary")};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default MenuIconWrapper;
|
||||
@@ -1,217 +0,0 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import { ellipsis, transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
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.MouseEvent) => void | Promise<void>;
|
||||
onPointerMove?: (event: React.MouseEvent) => void | Promise<void>;
|
||||
active?: boolean;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
to?: LocationDescriptor;
|
||||
href?: string;
|
||||
target?: string;
|
||||
as?: string | React.ComponentType<any>;
|
||||
hide?: () => void;
|
||||
level?: number;
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
|
||||
};
|
||||
|
||||
const MenuItem = (
|
||||
{
|
||||
onClick,
|
||||
onPointerMove,
|
||||
children,
|
||||
active,
|
||||
selected,
|
||||
disabled,
|
||||
as,
|
||||
hide,
|
||||
icon,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.Ref<HTMLAnchorElement>
|
||||
) => {
|
||||
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) {
|
||||
preventDefault(ev);
|
||||
await onClick(ev);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MenuAnchor
|
||||
{...props}
|
||||
$active={active}
|
||||
as={onClick ? "button" : as}
|
||||
onClick={handleClick}
|
||||
onPointerDown={preventDefault}
|
||||
onMouseDown={preventDefault}
|
||||
ref={mergeRefs([
|
||||
ref,
|
||||
props.ref as React.RefObject<HTMLAnchorElement>,
|
||||
])}
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<SelectedWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon /> : <Spacer />}
|
||||
</SelectedWrapper>
|
||||
)}
|
||||
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
|
||||
<Title>{children}</Title>
|
||||
</MenuAnchor>
|
||||
);
|
||||
},
|
||||
[active, as, hide, icon, onClick, ref, children, selected]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseMenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
onPointerMove={disabled ? undefined : onPointerMove}
|
||||
disabled={disabled}
|
||||
hide={hide}
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
</BaseMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const Spacer = styled.svg`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Title = styled.div`
|
||||
${ellipsis()}
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
type MenuAnchorProps = {
|
||||
level?: number;
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
disclosure?: boolean;
|
||||
$active?: boolean;
|
||||
};
|
||||
|
||||
export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
background: none;
|
||||
color: ${(props) =>
|
||||
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
|
||||
justify-content: left;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
opacity: ${(props) => (props.disabled ? ".5" : 1)};
|
||||
}
|
||||
|
||||
${(props) => props.disabled && "pointer-events: none;"}
|
||||
|
||||
${(props) =>
|
||||
props.$active === undefined &&
|
||||
!props.disabled &&
|
||||
`
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
|
||||
outline-color: ${
|
||||
props.dangerous ? props.theme.danger : props.theme.accent
|
||||
};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg {
|
||||
color: ${props.theme.accentText};
|
||||
fill: ${props.theme.accentText};
|
||||
}
|
||||
|
||||
${Text} {
|
||||
color: ${transparentize(0.5, props.theme.accentText)};
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.$active &&
|
||||
!props.disabled &&
|
||||
`
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.accentText};
|
||||
}
|
||||
`}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px 12px;
|
||||
padding-right: ${(props: MenuAnchorProps) =>
|
||||
props.disclosure ? 32 : 12}px;
|
||||
font-size: 14px;
|
||||
`}
|
||||
`;
|
||||
|
||||
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);
|
||||
@@ -1,70 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useMousePosition } from "~/hooks/useMousePosition";
|
||||
|
||||
type Positions = {
|
||||
/** Sub-menu x */
|
||||
x: number;
|
||||
/** Sub-menu y */
|
||||
y: number;
|
||||
/** Sub-menu height */
|
||||
h: number;
|
||||
/** Sub-menu width */
|
||||
w: number;
|
||||
/** Mouse x */
|
||||
mouseX: number;
|
||||
/** Mouse y */
|
||||
mouseY: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Component to cover the area between the mouse cursor and the sub-menu, to
|
||||
* allow moving cursor to lower parts of sub-menu without the sub-menu
|
||||
* disappearing.
|
||||
*/
|
||||
export default function MouseSafeArea(props: {
|
||||
parentRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const {
|
||||
x = 0,
|
||||
y = 0,
|
||||
height: h = 0,
|
||||
width: w = 0,
|
||||
} = props.parentRef.current?.getBoundingClientRect() || {};
|
||||
const [mouseX, mouseY] = useMousePosition();
|
||||
const positions = { x, y, h, w, mouseX, mouseY };
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
// backgroundColor: "rgba(255,0,0,0.1)", // Uncomment to debug
|
||||
right: getRight(positions),
|
||||
left: getLeft(positions),
|
||||
height: h,
|
||||
width: getWidth(positions),
|
||||
clipPath: getClipPath(positions),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const getLeft = ({ x, mouseX }: Positions) =>
|
||||
mouseX > x ? undefined : -Math.max(x - mouseX, 10) + "px";
|
||||
|
||||
const getRight = ({ x, w, mouseX }: Positions) =>
|
||||
mouseX > x ? -Math.max(mouseX - (x + w), 10) + "px" : undefined;
|
||||
|
||||
const getWidth = ({ x, w, mouseX }: Positions) =>
|
||||
mouseX > x
|
||||
? Math.max(mouseX - (x + w), 10) + "px"
|
||||
: Math.max(x - mouseX, 10) + "px";
|
||||
|
||||
const getClipPath = ({ x, y, h, mouseX, mouseY }: Positions) =>
|
||||
mouseX > x
|
||||
? `polygon(0% 0%, 0% 100%, 100% ${(100 * (mouseY - y)) / h - 10}%, 100% ${
|
||||
(100 * (mouseY - y)) / h + 5
|
||||
}%)`
|
||||
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - 10}%, 0% ${
|
||||
(100 * (mouseY - y)) / h + 5
|
||||
}%, 100% 100%)`;
|
||||
@@ -1,20 +0,0 @@
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
|
||||
type Props = React.ComponentProps<typeof MenuButton> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function OverflowMenuButton({ className, ...rest }: Props) {
|
||||
return (
|
||||
<MenuButton {...rest}>
|
||||
{(props) => (
|
||||
<NudeButton className={className} {...props}>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { MenuSeparator } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
|
||||
export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
|
||||
return (
|
||||
<MenuSeparator {...rest}>
|
||||
{(props) => <HorizontalRule {...props} />}
|
||||
</MenuSeparator>
|
||||
);
|
||||
}
|
||||
|
||||
const HorizontalRule = styled.hr`
|
||||
margin: 6px 0;
|
||||
`;
|
||||
@@ -1,264 +0,0 @@
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
MenuButton,
|
||||
MenuItem as BaseMenuItem,
|
||||
MenuStateReturn,
|
||||
} from "reakit/Menu";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import MenuIconWrapper from "~/components/ContextMenu/MenuIconWrapper";
|
||||
import Flex from "~/components/Flex";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import {
|
||||
Action,
|
||||
ActionContext,
|
||||
MenuSeparator,
|
||||
MenuHeading,
|
||||
MenuItem as TMenuItem,
|
||||
} from "~/types";
|
||||
import Tooltip from "../Tooltip";
|
||||
import Header from "./Header";
|
||||
import MenuItem, { MenuAnchor } from "./MenuItem";
|
||||
import MouseSafeArea from "./MouseSafeArea";
|
||||
import Separator from "./Separator";
|
||||
import ContextMenu from ".";
|
||||
|
||||
type Props = Omit<MenuStateReturn, "items"> & {
|
||||
actions?: (Action | MenuSeparator | MenuHeading)[];
|
||||
context?: Partial<ActionContext>;
|
||||
items?: TMenuItem[];
|
||||
showIcons?: boolean;
|
||||
};
|
||||
|
||||
const Disclosure = styled(ExpandedIcon)`
|
||||
transform: rotate(270deg);
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
`;
|
||||
|
||||
type SubMenuProps = MenuStateReturn & {
|
||||
templateItems: TMenuItem[];
|
||||
parentMenuState: Omit<MenuStateReturn, "items">;
|
||||
title: React.ReactNode;
|
||||
};
|
||||
|
||||
const SubMenu = React.forwardRef(function _Template(
|
||||
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
|
||||
ref: React.LegacyRef<HTMLButtonElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const menu = useMenuState({
|
||||
parentId: parentMenuState.baseId,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton ref={ref} {...menu} {...rest}>
|
||||
{(props) => (
|
||||
<MenuAnchor disclosure {...props}>
|
||||
{title} <Disclosure color={theme.textTertiary} />
|
||||
</MenuAnchor>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
aria-label={t("Submenu")}
|
||||
onClick={parentMenuState.hide}
|
||||
parentMenuState={parentMenuState}
|
||||
>
|
||||
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
|
||||
<Template {...menu} items={templateItems} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
return items
|
||||
.filter((item) => item.visible !== false)
|
||||
.reduce((acc, item) => {
|
||||
// trim separator if the previous item was a separator
|
||||
if (
|
||||
item.type === "separator" &&
|
||||
acc[acc.length - 1]?.type === "separator"
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
return [...acc, item];
|
||||
}, [] as TMenuItem[])
|
||||
.filter((item, index, arr) => {
|
||||
if (
|
||||
item.type === "separator" &&
|
||||
(index === 0 || index === arr.length - 1)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
const ctx = useActionContext({
|
||||
isContextMenu: true,
|
||||
});
|
||||
|
||||
const templateItems = actions
|
||||
? actions.map((item) =>
|
||||
item.type === "separator" || item.type === "heading"
|
||||
? item
|
||||
: actionToMenuItem(item, ctx)
|
||||
)
|
||||
: items || [];
|
||||
|
||||
const filteredTemplates = filterTemplateItems(templateItems);
|
||||
|
||||
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
|
||||
(item) =>
|
||||
item.type !== "separator" && item.type !== "heading" && !!item.icon
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{filteredTemplates.map((item, index) => {
|
||||
if (
|
||||
iconIsPresentInAnyMenuItem &&
|
||||
item.type !== "separator" &&
|
||||
item.type !== "heading" &&
|
||||
showIcons !== false
|
||||
) {
|
||||
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
|
||||
}
|
||||
|
||||
if (item.type === "route") {
|
||||
return (
|
||||
<MenuItem
|
||||
as={Link}
|
||||
id={`${item.title}-${index}`}
|
||||
to={item.to}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "link") {
|
||||
return (
|
||||
<MenuItem
|
||||
id={`${item.title}-${index}`}
|
||||
href={typeof item.href === "string" ? item.href : item.href.url}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
level={item.level}
|
||||
target={
|
||||
typeof item.href === "string" ? undefined : item.href.target
|
||||
}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "button") {
|
||||
const menuItem = (
|
||||
<MenuItem
|
||||
as="button"
|
||||
id={`${item.title}-${index}`}
|
||||
onClick={item.onClick}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
dangerous={item.dangerous}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
|
||||
return item.tooltip ? (
|
||||
<Tooltip
|
||||
content={item.tooltip}
|
||||
placement={"bottom"}
|
||||
key={`tooltip-${item.title}-${index}`}
|
||||
>
|
||||
<div>{menuItem}</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<React.Fragment key={`${item.type}-${item.title}-${index}`}>
|
||||
{menuItem}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "submenu") {
|
||||
// Skip rendering empty submenus
|
||||
return item.items.length > 0 ? (
|
||||
<BaseMenuItem
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
as={SubMenu}
|
||||
id={`${item.title}-${index}`}
|
||||
templateItems={item.items}
|
||||
parentMenuState={menu}
|
||||
title={
|
||||
<Title
|
||||
title={item.title}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
/>
|
||||
}
|
||||
{...menu}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
if (item.type === "separator") {
|
||||
return <Separator key={`separator-${index}`} />;
|
||||
}
|
||||
|
||||
if (item.type === "heading") {
|
||||
return (
|
||||
<Header key={`heading-${item.title}-${index}`}>{item.title}</Header>
|
||||
);
|
||||
}
|
||||
|
||||
// This should never be reached for Reakit dropdown menu.
|
||||
// Added for exhaustiveness check.
|
||||
if (item.type === "group") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const _exhaustiveCheck: never = item;
|
||||
return _exhaustiveCheck;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Title({
|
||||
title,
|
||||
icon,
|
||||
}: {
|
||||
title: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Flex align="center">
|
||||
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
|
||||
{title}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo<Props>(Template);
|
||||
@@ -1,317 +0,0 @@
|
||||
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu, MenuStateReturn } from "reakit/Menu";
|
||||
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";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useUnmount from "~/hooks/useUnmount";
|
||||
import {
|
||||
fadeIn,
|
||||
fadeAndSlideUp,
|
||||
fadeAndSlideDown,
|
||||
mobileContextMenu,
|
||||
} from "~/styles/animations";
|
||||
|
||||
export type Placement =
|
||||
| "auto-start"
|
||||
| "auto"
|
||||
| "auto-end"
|
||||
| "top-start"
|
||||
| "top"
|
||||
| "top-end"
|
||||
| "right-start"
|
||||
| "right"
|
||||
| "right-end"
|
||||
| "bottom-end"
|
||||
| "bottom"
|
||||
| "bottom-start"
|
||||
| "left-end"
|
||||
| "left"
|
||||
| "left-start";
|
||||
|
||||
type Props = MenuStateReturn & {
|
||||
"aria-label"?: string;
|
||||
/** Reference to the rendered menu div element */
|
||||
menuRef?: React.RefObject<HTMLDivElement>;
|
||||
/** The parent menu state if this is a submenu. */
|
||||
parentMenuState?: Omit<MenuStateReturn, "items">;
|
||||
/** Called when the context menu is opened. */
|
||||
onOpen?: () => void;
|
||||
/** Called when the context menu is closed. */
|
||||
onClose?: () => void;
|
||||
/** Called when the context menu is clicked. */
|
||||
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;
|
||||
};
|
||||
|
||||
const ContextMenu: React.FC<Props> = ({
|
||||
menuRef,
|
||||
children,
|
||||
onOpen,
|
||||
onClose,
|
||||
parentMenuState,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const previousVisible = usePrevious(rest.visible);
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { setIsMenuOpen } = useMenuContext();
|
||||
const isMobile = useMobile();
|
||||
const isSubMenu = !!parentMenuState;
|
||||
|
||||
useUnmount(() => {
|
||||
setIsMenuOpen(false);
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (rest.visible && !previousVisible) {
|
||||
onOpen?.();
|
||||
|
||||
if (!isSubMenu) {
|
||||
setIsMenuOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!rest.visible && previousVisible) {
|
||||
onClose?.();
|
||||
|
||||
if (!isSubMenu) {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
onOpen,
|
||||
onClose,
|
||||
previousVisible,
|
||||
rest.visible,
|
||||
ui.sidebarCollapsed,
|
||||
setIsMenuOpen,
|
||||
isSubMenu,
|
||||
t,
|
||||
]);
|
||||
|
||||
// Perf win – don't render anything until the menu has been opened
|
||||
if (!rest.visible && !previousVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// sets the menu height based on the available space between the disclosure/
|
||||
// trigger and the bottom of the window
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
ref={menuRef}
|
||||
hideOnClickOutside={!isMobile}
|
||||
preventBodyScroll={false}
|
||||
{...rest}
|
||||
>
|
||||
{(props) => (
|
||||
<InnerContextMenu
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
menuProps={props as any}
|
||||
{...rest}
|
||||
isSubMenu={isSubMenu}
|
||||
>
|
||||
{children}
|
||||
</InnerContextMenu>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type InnerContextMenuProps = MenuStateReturn & {
|
||||
isSubMenu: boolean;
|
||||
menuProps: { style?: React.CSSProperties; placement: string };
|
||||
children: React.ReactNode;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inner context menu allows deferring expensive window measurement hooks etc
|
||||
* until the menu is actually opened.
|
||||
*/
|
||||
const InnerContextMenu = (props: InnerContextMenuProps) => {
|
||||
const { menuProps } = props;
|
||||
// kind of hacky, but this is an effective way of telling which way
|
||||
// the menu will _actually_ be placed when taking into account screen
|
||||
// positioning.
|
||||
const topAnchor =
|
||||
menuProps.style?.top === "0" || menuProps.style?.position === "fixed";
|
||||
const rightAnchor = menuProps.placement === "bottom-end";
|
||||
const backgroundRef = React.useRef<HTMLDivElement>(null);
|
||||
const isMobile = useMobile();
|
||||
|
||||
const maxHeight = useMenuHeight({
|
||||
visible: props.visible,
|
||||
elementRef: props.unstable_disclosureRef,
|
||||
});
|
||||
|
||||
// We must manually manage scroll lock for iOS support so that the scrollable
|
||||
// element can be passed into body-scroll-lock. See:
|
||||
// https://github.com/ariakit/ariakit/issues/469
|
||||
React.useEffect(() => {
|
||||
const scrollElement = backgroundRef.current;
|
||||
if (props.visible && scrollElement && !props.isSubMenu) {
|
||||
disableBodyScroll(scrollElement, {
|
||||
reserveScrollBarGap: true,
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
if (scrollElement && !props.isSubMenu) {
|
||||
enableBodyScroll(scrollElement);
|
||||
}
|
||||
};
|
||||
}, [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
|
||||
? {
|
||||
maxHeight,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMobile && (
|
||||
<Backdrop
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
props.hide?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Position {...menuProps}>
|
||||
<Background
|
||||
dir="auto"
|
||||
maxWidth={props.maxWidth}
|
||||
minHeight={props.minHeight}
|
||||
topAnchor={topAnchor}
|
||||
rightAnchor={rightAnchor}
|
||||
ref={backgroundRef}
|
||||
hiddenScrollbars
|
||||
style={style}
|
||||
>
|
||||
{props.visible || props.animating ? props.children : null}
|
||||
</Background>
|
||||
</Position>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContextMenu;
|
||||
|
||||
export const Backdrop = styled.div`
|
||||
animation: ${fadeIn} 200ms ease-in-out;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: ${s("backdrop")};
|
||||
z-index: ${depths.menu - 1};
|
||||
`;
|
||||
|
||||
export const Position = styled.div`
|
||||
position: absolute;
|
||||
z-index: ${depths.menu};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/*
|
||||
* overrides make mobile-first coding style challenging
|
||||
* so we explicitly define mobile breakpoint here
|
||||
*/
|
||||
${breakpoint("mobile", "tablet")`
|
||||
position: fixed !important;
|
||||
transform: none !important;
|
||||
top: auto !important;
|
||||
right: 8px !important;
|
||||
bottom: 16px !important;
|
||||
left: 8px !important;
|
||||
`};
|
||||
`;
|
||||
|
||||
type BackgroundProps = {
|
||||
topAnchor?: boolean;
|
||||
rightAnchor?: boolean;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
theme: DefaultTheme;
|
||||
};
|
||||
|
||||
export const Background = styled(Scrollable)<BackgroundProps>`
|
||||
animation: ${mobileContextMenu} 200ms ease;
|
||||
transform-origin: 50% 100%;
|
||||
max-width: 100%;
|
||||
background: ${s("menuBackground")};
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
min-width: 180px;
|
||||
min-height: ${(props) => props.minHeight || 44}px;
|
||||
max-height: 75vh;
|
||||
font-weight: normal;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
animation: ${(props: BackgroundProps) =>
|
||||
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
|
||||
transform-origin: ${(props: BackgroundProps) =>
|
||||
props.rightAnchor ? "75%" : "25%"} 0;
|
||||
max-width: ${(props: BackgroundProps) => props.maxWidth ?? 276}px;
|
||||
max-height: 100vh;
|
||||
background: ${(props: BackgroundProps) => props.theme.menuBackground};
|
||||
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
|
||||
`};
|
||||
`;
|
||||
@@ -1,13 +1,18 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Guide from "~/components/Guide";
|
||||
import Modal from "~/components/Modal";
|
||||
import { Suspense } from "react";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const Guide = lazyWithRetry(() => import("~/components/Guide"));
|
||||
const Modal = lazyWithRetry(() => import("~/components/Modal"));
|
||||
|
||||
function Dialogs() {
|
||||
const { dialogs } = useStores();
|
||||
const { guide, modalStack } = dialogs;
|
||||
const modals = [...modalStack];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Suspense fallback={null}>
|
||||
{guide ? (
|
||||
<Guide
|
||||
isOpen={guide.isOpen}
|
||||
@@ -17,7 +22,7 @@ function Dialogs() {
|
||||
{guide.content}
|
||||
</Guide>
|
||||
) : undefined}
|
||||
{[...modalStack].map(([id, modal]) => (
|
||||
{modals.map(([id, modal]) => (
|
||||
<Modal
|
||||
key={id}
|
||||
isOpen={modal.isOpen}
|
||||
@@ -31,7 +36,7 @@ function Dialogs() {
|
||||
{modal.content}
|
||||
</Modal>
|
||||
))}
|
||||
</>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Text from "~/components/Text";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import { observer } from "mobx-react";
|
||||
import capitalize from "lodash/capitalize";
|
||||
|
||||
type Props = {
|
||||
integration: Integration<IntegrationType.Analytics>;
|
||||
};
|
||||
|
||||
export const DisconnectAnalyticsDialog = observer(({ integration }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const history = useHistory();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await integration.delete();
|
||||
history.push(settingsPath("integrations"));
|
||||
dialogs.closeAllModals();
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("Disconnect")}
|
||||
savingText={`${t("Disconnecting")}…`}
|
||||
danger
|
||||
>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Are you sure you want to disconnect the <em>{{ service }}</em> integration?"
|
||||
values={{
|
||||
service: capitalize(integration.service),
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans defaults="This will stop sending analytics events to the configured instance." />
|
||||
</Text>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
});
|
||||
@@ -11,8 +11,9 @@ import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
|
||||
import { createInternalLinkActionV2 } from "~/actions";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -27,46 +28,12 @@ type Props = {
|
||||
maxDepth?: number;
|
||||
};
|
||||
|
||||
function useCategory(document: Document): MenuInternalLink | null {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (document.isDeleted) {
|
||||
return {
|
||||
type: "route",
|
||||
icon: <TrashIcon />,
|
||||
title: t("Trash"),
|
||||
to: trashPath(),
|
||||
};
|
||||
}
|
||||
|
||||
if (document.isArchived) {
|
||||
return {
|
||||
type: "route",
|
||||
icon: <ArchiveIcon />,
|
||||
title: t("Archive"),
|
||||
to: archivePath(),
|
||||
};
|
||||
}
|
||||
|
||||
if (document.template) {
|
||||
return {
|
||||
type: "route",
|
||||
icon: <ShapesIcon />,
|
||||
title: t("Templates"),
|
||||
to: settingsPath("templates"),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function DocumentBreadcrumb(
|
||||
{ document, children, onlyText, reverse = false, maxDepth }: Props,
|
||||
ref: React.RefObject<HTMLDivElement> | null
|
||||
) {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const category = useCategory(document);
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
@@ -78,69 +45,91 @@ function DocumentBreadcrumb(
|
||||
void document.loadRelations({ withoutPolicies: true });
|
||||
}, [document]);
|
||||
|
||||
let collectionNode: MenuInternalLink | undefined;
|
||||
|
||||
if (collection && can.readDocument) {
|
||||
collectionNode = {
|
||||
type: "route",
|
||||
title: collection.name,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: {
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
},
|
||||
};
|
||||
} else if (document.isCollectionDeleted) {
|
||||
collectionNode = {
|
||||
type: "route",
|
||||
title: t("Deleted Collection"),
|
||||
icon: undefined,
|
||||
to: "",
|
||||
};
|
||||
}
|
||||
|
||||
const path = document.pathTo.slice(0, -1);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const output: MenuInternalLink[] = [];
|
||||
|
||||
const actions = React.useMemo(() => {
|
||||
if (depth === 0) {
|
||||
return output;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (category) {
|
||||
output.push(category);
|
||||
}
|
||||
if (collectionNode) {
|
||||
output.push(collectionNode);
|
||||
}
|
||||
|
||||
path.forEach((node: NavigationNode) => {
|
||||
const title = node.title || t("Untitled");
|
||||
output.push({
|
||||
type: "route",
|
||||
title: node.icon ? (
|
||||
<>
|
||||
<StyledIcon value={node.icon} color={node.color} /> {title}
|
||||
</>
|
||||
) : (
|
||||
title
|
||||
),
|
||||
to: {
|
||||
pathname: node.url,
|
||||
state: { sidebarContext },
|
||||
},
|
||||
});
|
||||
});
|
||||
const outputActions = [
|
||||
createInternalLinkActionV2({
|
||||
name: t("Trash"),
|
||||
section: ActiveDocumentSection,
|
||||
icon: <TrashIcon />,
|
||||
visible: document.isDeleted,
|
||||
to: trashPath(),
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
name: t("Archive"),
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: document.isArchived,
|
||||
to: archivePath(),
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
name: t("Templates"),
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
visible: document.template,
|
||||
to: settingsPath("templates"),
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
name: collection?.name,
|
||||
section: ActiveDocumentSection,
|
||||
icon: collection ? (
|
||||
<CollectionIcon collection={collection} expanded />
|
||||
) : undefined,
|
||||
visible: !!(collection && can.readDocument),
|
||||
to: collection
|
||||
? {
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
}
|
||||
: "",
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
name: t("Deleted Collection"),
|
||||
section: ActiveDocumentSection,
|
||||
visible: document.isCollectionDeleted,
|
||||
to: "",
|
||||
}),
|
||||
...path.map((node) => {
|
||||
const title = node.title || t("Untitled");
|
||||
return createInternalLinkActionV2({
|
||||
name: node.icon ? (
|
||||
<>
|
||||
<StyledIcon value={node.icon} color={node.color} /> {title}
|
||||
</>
|
||||
) : (
|
||||
title
|
||||
),
|
||||
section: ActiveDocumentSection,
|
||||
to: {
|
||||
pathname: node.url,
|
||||
state: { sidebarContext },
|
||||
},
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
return reverse
|
||||
? depth !== undefined
|
||||
? output.slice(-depth)
|
||||
: output
|
||||
? outputActions.slice(-depth)
|
||||
: outputActions
|
||||
: depth !== undefined
|
||||
? output.slice(0, depth)
|
||||
: output;
|
||||
}, [t, path, category, sidebarContext, collectionNode, reverse, depth]);
|
||||
? outputActions.slice(0, depth)
|
||||
: outputActions;
|
||||
}, [
|
||||
t,
|
||||
document,
|
||||
collection,
|
||||
can.readDocument,
|
||||
sidebarContext,
|
||||
path,
|
||||
reverse,
|
||||
depth,
|
||||
]);
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return null;
|
||||
@@ -176,7 +165,7 @@ function DocumentBreadcrumb(
|
||||
}
|
||||
|
||||
return (
|
||||
<Breadcrumb items={items} ref={ref} highlightFirstItem>
|
||||
<Breadcrumb actions={actions} ref={ref} highlightFirstItem>
|
||||
{children}
|
||||
</Breadcrumb>
|
||||
);
|
||||
|
||||
@@ -3,8 +3,8 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import { subDays } from "date-fns";
|
||||
import { m } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
|
||||
import { useRef, useCallback, useMemo } from "react";
|
||||
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
|
||||
import { useRef, useCallback, Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
@@ -19,10 +19,12 @@ import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useTextStats } from "~/hooks/useTextStats";
|
||||
import CollectionIcon from "./Icons/CollectionIcon";
|
||||
import Text from "./Text";
|
||||
import Tooltip from "./Tooltip";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const ReadingTime = lazyWithRetry(() => import("./ReadingTime"));
|
||||
|
||||
type Props = {
|
||||
/** The pin record */
|
||||
@@ -76,6 +78,13 @@ function DocumentCard(props: Props) {
|
||||
const isRecentlyUpdated =
|
||||
new Date(document.updatedAt) > subDays(new Date(), 7);
|
||||
|
||||
const updatedAt = (
|
||||
<>
|
||||
<Clock size={18} />
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Reorderable
|
||||
ref={setNodeRef}
|
||||
@@ -150,12 +159,11 @@ function DocumentCard(props: Props) {
|
||||
</Heading>
|
||||
<DocumentMeta size="xsmall">
|
||||
{isRecentlyUpdated ? (
|
||||
<>
|
||||
<Clock size={18} />
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
</>
|
||||
updatedAt
|
||||
) : (
|
||||
<ReadingTime document={document} />
|
||||
<Suspense fallback={updatedAt}>
|
||||
<ReadingTime document={document} />
|
||||
</Suspense>
|
||||
)}
|
||||
</DocumentMeta>
|
||||
</div>
|
||||
@@ -177,21 +185,6 @@ function DocumentCard(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const ReadingTime = ({ document }: { document: Document }) => {
|
||||
const { t } = useTranslation();
|
||||
const markdown = useMemo(() => document.toMarkdown(), [document]);
|
||||
const stats = useTextStats(markdown);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EyeIcon size={18} />
|
||||
{t(`{{ minutes }}m read`, {
|
||||
minutes: stats.total.readingTime,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentSquircle = ({
|
||||
icon,
|
||||
color,
|
||||
|
||||
@@ -11,9 +11,15 @@ class DocumentContext {
|
||||
/** The editor instance for this document */
|
||||
editor?: Editor;
|
||||
|
||||
/** The ID of the currently focused comment, or null if no comment is focused */
|
||||
@observable
|
||||
focusedCommentId: string | null = null;
|
||||
|
||||
/** Whether the editor has been initialized */
|
||||
@observable
|
||||
isEditorInitialized: boolean = false;
|
||||
|
||||
/** The headings in the document */
|
||||
@observable
|
||||
headings: Heading[] = [];
|
||||
|
||||
@@ -39,6 +45,11 @@ class DocumentContext {
|
||||
this.isEditorInitialized = initialized;
|
||||
};
|
||||
|
||||
@action
|
||||
setFocusedCommentId = (commentId: string | null) => {
|
||||
this.focusedCommentId = commentId;
|
||||
};
|
||||
|
||||
@action
|
||||
updateState = () => {
|
||||
this.updateHeadings();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import flatten from "lodash/flatten";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
@@ -11,7 +10,6 @@ import Button from "~/components/Button";
|
||||
import DocumentExplorer from "~/components/DocumentExplorer";
|
||||
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { flattenTree } from "~/utils/tree";
|
||||
import Switch from "./Switch";
|
||||
import Text from "./Text";
|
||||
|
||||
@@ -32,7 +30,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const nodes = flatten(collectionTrees.map(flattenTree)).filter((node) =>
|
||||
const nodes = collectionTrees.filter((node) =>
|
||||
node.collectionId
|
||||
? policies.get(node.collectionId)?.abilities.createDocument
|
||||
: true
|
||||
@@ -78,34 +76,32 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
onSelect={selectPath}
|
||||
defaultValue={document.parentDocumentId || document.collectionId || ""}
|
||||
/>
|
||||
<OptionsContainer>
|
||||
{!document.isTemplate && (
|
||||
<>
|
||||
{document.collectionId && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="publish"
|
||||
label={t("Publish")}
|
||||
labelPosition="right"
|
||||
checked={publish}
|
||||
onChange={setPublish}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{document.publishedAt && document.childDocuments.length > 0 && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="recursive"
|
||||
label={t("Include nested documents")}
|
||||
labelPosition="right"
|
||||
checked={recursive}
|
||||
onChange={setRecursive}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</OptionsContainer>
|
||||
{!document.isTemplate && (
|
||||
<OptionsContainer>
|
||||
{document.collectionId && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="publish"
|
||||
label={t("Publish")}
|
||||
labelPosition="right"
|
||||
checked={publish}
|
||||
onChange={setPublish}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{document.publishedAt && document.childDocuments.length > 0 && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="recursive"
|
||||
label={t("Include nested documents")}
|
||||
labelPosition="right"
|
||||
checked={recursive}
|
||||
onChange={setRecursive}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</OptionsContainer>
|
||||
)}
|
||||
<Footer justify="space-between" align="center" gap={8}>
|
||||
<StyledText type="secondary">
|
||||
{selectedPath ? (
|
||||
@@ -127,9 +123,11 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
}
|
||||
|
||||
const OptionsContainer = styled.div`
|
||||
margin: 16px 0 8px 0;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
border-top: 1px solid ${(props) => props.theme.horizontalRule};
|
||||
padding: 16px 24px 0;
|
||||
margin-bottom: -1px;
|
||||
background: ${(props) => props.theme.modalBackground};
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
export default observer(DocumentCopy);
|
||||
|
||||
@@ -3,6 +3,7 @@ import concat from "lodash/concat";
|
||||
import difference from "lodash/difference";
|
||||
import fill from "lodash/fill";
|
||||
import filter from "lodash/filter";
|
||||
import flatten from "lodash/flatten";
|
||||
import includes from "lodash/includes";
|
||||
import map from "lodash/map";
|
||||
import { observer } from "mobx-react";
|
||||
@@ -15,7 +16,7 @@ import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode, NavigationNodeType } from "@shared/types";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
@@ -26,7 +27,7 @@ import InputSearch from "~/components/InputSearch";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ancestors, descendants } from "~/utils/tree";
|
||||
import { ancestors, descendants, flattenTree } from "~/utils/tree";
|
||||
|
||||
type Props = {
|
||||
/** Action taken upon submission of selected item, could be publish, move etc. */
|
||||
@@ -48,8 +49,13 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
const [searchTerm, setSearchTerm] = React.useState<string>();
|
||||
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
|
||||
() => {
|
||||
const node =
|
||||
defaultValue && items.find((item) => item.id === defaultValue);
|
||||
if (!defaultValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Search through all nodes in the tree, not just top-level items
|
||||
const allNodes = flatten(items.map(flattenTree));
|
||||
const node = allNodes.find((item) => item.id === defaultValue);
|
||||
return node || null;
|
||||
}
|
||||
);
|
||||
@@ -58,7 +64,9 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
const [activeNode, setActiveNode] = React.useState<number>(0);
|
||||
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
|
||||
if (defaultValue) {
|
||||
const node = items.find((item) => item.id === defaultValue);
|
||||
// Search through all nodes in the tree, not just top-level items
|
||||
const allNodes = flatten(items.map(flattenTree));
|
||||
const node = allNodes.find((item) => item.id === defaultValue);
|
||||
if (node) {
|
||||
return ancestors(node).map((ancestorNode) => ancestorNode.id);
|
||||
}
|
||||
@@ -80,7 +88,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
|
||||
const searchIndex = React.useMemo(
|
||||
() =>
|
||||
new FuzzySearch(items, ["title"], {
|
||||
new FuzzySearch(flatten(items.map(flattenTree)), ["title"], {
|
||||
caseSensitive: false,
|
||||
}),
|
||||
[items]
|
||||
@@ -103,6 +111,20 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
);
|
||||
}, [items.length]);
|
||||
|
||||
function getNodes() {
|
||||
function includeDescendants(item: NavigationNode): NavigationNode[] {
|
||||
return expandedNodes.includes(item.id)
|
||||
? [item, ...descendants(item, 1).flatMap(includeDescendants)]
|
||||
: [item];
|
||||
}
|
||||
|
||||
return searchTerm
|
||||
? searchIndex.search(searchTerm)
|
||||
: items.flatMap(includeDescendants);
|
||||
}
|
||||
|
||||
const nodes = getNodes();
|
||||
|
||||
React.useEffect(() => {
|
||||
onSelect(selectedNode);
|
||||
}, [selectedNode, onSelect]);
|
||||
@@ -114,29 +136,12 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
function getNodes() {
|
||||
function includeDescendants(item: NavigationNode): NavigationNode[] {
|
||||
return expandedNodes.includes(item.id)
|
||||
? [item, ...descendants(item, 1).flatMap(includeDescendants)]
|
||||
: [item];
|
||||
}
|
||||
|
||||
return searchTerm
|
||||
? searchIndex.search(searchTerm)
|
||||
: items
|
||||
.concat(
|
||||
items.filter((item) => item.type === NavigationNodeType.Collection)
|
||||
)
|
||||
.flatMap(includeDescendants);
|
||||
}
|
||||
|
||||
const nodes = getNodes();
|
||||
}, [defaultValue, selectedNode, nodes]);
|
||||
const baseDepth = nodes.reduce(
|
||||
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
|
||||
Infinity
|
||||
);
|
||||
const normalizedBaseDepth = baseDepth === Infinity ? 0 : baseDepth;
|
||||
|
||||
const scrollNodeIntoView = React.useCallback(
|
||||
(node: number) => {
|
||||
@@ -310,7 +315,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
expanded={isExpanded(index)}
|
||||
icon={renderedIcon}
|
||||
title={title}
|
||||
depth={(node.depth ?? 0) - baseDepth}
|
||||
depth={(node.depth ?? 0) - normalizedBaseDepth}
|
||||
hasChildren={hasChildren(index)}
|
||||
ref={itemRefs[index]}
|
||||
/>
|
||||
|
||||
@@ -25,6 +25,10 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
import { ContextMenu } from "./Menu/ContextMenu";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -33,7 +37,6 @@ type Props = {
|
||||
showParentDocuments?: boolean;
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
showPin?: boolean;
|
||||
showDraft?: boolean;
|
||||
showTemplate?: boolean;
|
||||
};
|
||||
@@ -51,6 +54,7 @@ function DocumentListItem(
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
|
||||
@@ -68,7 +72,6 @@ function DocumentListItem(
|
||||
showParentDocuments,
|
||||
showCollection,
|
||||
showPublished,
|
||||
showPin,
|
||||
showDraft = true,
|
||||
showTemplate,
|
||||
highlight,
|
||||
@@ -80,89 +83,110 @@ function DocumentListItem(
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar = !document.isArchived && !document.isTemplate;
|
||||
|
||||
const isShared = !!(
|
||||
userMemberships.getByDocumentId(document.id) ||
|
||||
groupMemberships.getByDocumentId(document.id)
|
||||
);
|
||||
|
||||
const sidebarContext = determineSidebarContext({
|
||||
document,
|
||||
user,
|
||||
currentContext: locationSidebarContext,
|
||||
});
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
dir={document.dir}
|
||||
role="menuitem"
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
sidebarContext,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
{...rovingTabIndex}
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
{document.icon && (
|
||||
<>
|
||||
<Icon
|
||||
value={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
initial={document.initial}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
{document.isBadgedNew && document.createdBy?.id !== user.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip content={t("Only visible to you")} placement="top">
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
</Heading>
|
||||
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showLastViewed
|
||||
/>
|
||||
</Content>
|
||||
<Actions>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
showPin={showPin}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
modal={false}
|
||||
/>
|
||||
</Actions>
|
||||
</DocumentLink>
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId:
|
||||
!isShared && document.collectionId
|
||||
? document.collectionId
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<ContextMenu
|
||||
action={contextMenuAction}
|
||||
ariaLabel={t("Document options")}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
dir={document.dir}
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
sidebarContext,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
{...rovingTabIndex}
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
{document.icon && (
|
||||
<>
|
||||
<Icon
|
||||
value={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
initial={document.initial}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
{document.isBadgedNew && document.createdBy?.id !== user.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip content={t("Only visible to you")} placement="top">
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showLastViewed
|
||||
/>
|
||||
</Content>
|
||||
<Actions>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Actions>
|
||||
</DocumentLink>
|
||||
</ContextMenu>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -256,7 +280,7 @@ const DocumentLink = styled(Link)<{
|
||||
`}
|
||||
`;
|
||||
|
||||
const Heading = styled.h3<{ rtl?: boolean }>`
|
||||
const Heading = styled.span<{ rtl?: boolean }>`
|
||||
display: flex;
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
align-items: center;
|
||||
@@ -266,6 +290,8 @@ const Heading = styled.h3<{ rtl?: boolean }>`
|
||||
color: ${s("text")};
|
||||
font-family: ${s("fontFamily")};
|
||||
font-weight: 500;
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
`;
|
||||
|
||||
const StarPositioner = styled(Flex)`
|
||||
|
||||
@@ -155,26 +155,22 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
}
|
||||
return (
|
||||
<Viewed>
|
||||
• <Modified highlight>{t("Never viewed")}</Modified>
|
||||
<Separator />
|
||||
<Modified highlight>{t("Never viewed")}</Modified>
|
||||
</Viewed>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Viewed>
|
||||
• {t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
|
||||
<Separator />
|
||||
{t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
|
||||
</Viewed>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container
|
||||
align="center"
|
||||
rtl={document.dir === "rtl"}
|
||||
{...rest}
|
||||
dir="ltr"
|
||||
lang=""
|
||||
>
|
||||
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
|
||||
{to ? (
|
||||
<Link to={to} replace={replace}>
|
||||
{content}
|
||||
@@ -192,16 +188,17 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
)}
|
||||
{showParentDocuments && nestedDocumentsCount > 0 && (
|
||||
<span>
|
||||
• {nestedDocumentsCount}{" "}
|
||||
<Separator />
|
||||
{nestedDocumentsCount}{" "}
|
||||
{t("nested document", {
|
||||
count: nestedDocumentsCount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{timeSinceNow()}
|
||||
{timeSinceNow()}
|
||||
{canShowProgressBar && (
|
||||
<>
|
||||
•
|
||||
<Separator />
|
||||
<DocumentTasks document={document} />
|
||||
</>
|
||||
)}
|
||||
@@ -210,6 +207,14 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const Separator = styled.span`
|
||||
padding: 0 0.4em;
|
||||
|
||||
&::after {
|
||||
content: "•";
|
||||
}
|
||||
`;
|
||||
|
||||
const Strong = styled.strong`
|
||||
font-weight: 550;
|
||||
`;
|
||||
|
||||
@@ -39,6 +39,7 @@ function DocumentTasks({ document }: Props) {
|
||||
const done = completed === total;
|
||||
const previousDone = usePrevious(done);
|
||||
const message = getMessage(t, total, completed);
|
||||
|
||||
return (
|
||||
<>
|
||||
{completed === total ? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, truncateMultiline } from "@shared/styles";
|
||||
|
||||
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
|
||||
/** A callback when the title is submitted. */
|
||||
@@ -32,6 +32,7 @@ function EditableTitle(
|
||||
const [isEditing, setIsEditing] = React.useState(rest.isEditing || false);
|
||||
const [originalValue, setOriginalValue] = React.useState(title);
|
||||
const [value, setValue] = React.useState(title);
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
setIsEditing,
|
||||
@@ -65,6 +66,10 @@ function EditableTitle(
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (trimmedValue === originalValue || trimmedValue.length === 0) {
|
||||
@@ -74,6 +79,7 @@ function EditableTitle(
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(trimmedValue);
|
||||
setOriginalValue(trimmedValue);
|
||||
@@ -82,10 +88,11 @@ function EditableTitle(
|
||||
toast.error(error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setIsEditing(false);
|
||||
}
|
||||
},
|
||||
[originalValue, value, onCancel, onSubmit]
|
||||
[originalValue, value, onCancel, onSubmit, isSubmitting]
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
@@ -128,17 +135,21 @@ function EditableTitle(
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<span
|
||||
<Text
|
||||
onDoubleClick={canUpdate ? handleDoubleClick : undefined}
|
||||
className={rest.className}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Text = styled.span`
|
||||
${truncateMultiline(3)}
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
color: ${s("text")};
|
||||
background: ${s("background")};
|
||||
|
||||
@@ -13,6 +13,9 @@ import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import Logger from "~/utils/Logger";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import { deleteAllDatabases } from "~/utils/developer";
|
||||
import Flex from "./Flex";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
/** Whether to reload the page if a chunk fails to load. */
|
||||
@@ -23,6 +26,9 @@ type Props = WithTranslation & {
|
||||
component?: React.ComponentType | string;
|
||||
};
|
||||
|
||||
const ERROR_TRACKING_KEY = "error-boundary-tracking";
|
||||
const ERROR_TRACKING_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
@observer
|
||||
class ErrorBoundary extends React.Component<Props> {
|
||||
@observable
|
||||
@@ -31,6 +37,13 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
@observable
|
||||
showDetails = false;
|
||||
|
||||
@observable
|
||||
isRepeatedError = false;
|
||||
|
||||
componentDidMount() {
|
||||
this.checkForPreviousErrors();
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
this.error = error;
|
||||
|
||||
@@ -46,9 +59,47 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
return;
|
||||
}
|
||||
|
||||
this.trackError();
|
||||
Logger.error("ErrorBoundary", error);
|
||||
}
|
||||
|
||||
private checkForPreviousErrors = () => {
|
||||
try {
|
||||
const stored = Storage.get(ERROR_TRACKING_KEY);
|
||||
if (!stored) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errors: number[] = JSON.parse(stored);
|
||||
const cutoff = Date.now() - ERROR_TRACKING_WINDOW_MS;
|
||||
const recentErrors = errors.filter((timestamp) => timestamp > cutoff);
|
||||
|
||||
this.isRepeatedError = recentErrors.length > 0;
|
||||
} catch (err) {
|
||||
Logger.warn("Failed to parse stored errors for error boundary", { err });
|
||||
}
|
||||
};
|
||||
|
||||
private trackError = () => {
|
||||
try {
|
||||
const stored = Storage.get(ERROR_TRACKING_KEY);
|
||||
const errors: number[] = stored ? JSON.parse(stored) : [];
|
||||
const cutoff = Date.now() - ERROR_TRACKING_WINDOW_MS;
|
||||
|
||||
// Filter out old errors and add current one
|
||||
const updatedErrors = [
|
||||
...errors.filter((timestamp) => timestamp > cutoff),
|
||||
Date.now(),
|
||||
];
|
||||
|
||||
Storage.set(ERROR_TRACKING_KEY, JSON.stringify(updatedErrors));
|
||||
|
||||
this.isRepeatedError = updatedErrors.length > 1;
|
||||
} catch (err) {
|
||||
Logger.warn("Failed to track error in error boundary", { err });
|
||||
}
|
||||
};
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
@@ -61,6 +112,12 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
window.open(isCloudHosted ? UrlHelper.contact : UrlHelper.github);
|
||||
};
|
||||
|
||||
handleClearCache = async () => {
|
||||
await deleteAllDatabases();
|
||||
Storage.clear();
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t, component: Component = CenteredContent, showTitle } = this.props;
|
||||
|
||||
@@ -107,29 +164,46 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
</Heading>
|
||||
</>
|
||||
)}
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
|
||||
values={{
|
||||
notified: isReported
|
||||
? ` – ${t("our engineers have been notified")}`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
{this.showDetails && <Pre>{error.toString()}</Pre>}
|
||||
<p>
|
||||
<Button onClick={this.handleReload}>{t("Reload")}</Button>{" "}
|
||||
|
||||
{this.isRepeatedError ? (
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>
|
||||
An error has occurred multiple times recently. If it continues
|
||||
please try clearing the cache or using a different browser.
|
||||
</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
|
||||
values={{
|
||||
notified: isReported
|
||||
? ` – ${t("our engineers have been notified")}`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{this.showDetails && <Pre>{error.stack}</Pre>}
|
||||
<Flex gap={8} wrap>
|
||||
{this.isRepeatedError && (
|
||||
<Button onClick={this.handleClearCache}>
|
||||
<Trans>Clear cache + reload</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={this.handleReload} neutral={this.isRepeatedError}>
|
||||
{t("Reload")}
|
||||
</Button>
|
||||
{this.showDetails ? (
|
||||
<Button onClick={this.handleReportBug} neutral>
|
||||
<Trans>Report a bug</Trans>…
|
||||
<Trans>Report a bug</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={this.handleShowDetails} neutral>
|
||||
<Trans>Show detail</Trans>…
|
||||
</Button>
|
||||
)}
|
||||
</p>
|
||||
</Flex>
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import deburr from "lodash/deburr";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { 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 { useMenuState } from "~/hooks/useMenuState";
|
||||
import Input, { NativeInput, Outline } from "./Input";
|
||||
import PaginatedList, { PaginatedItem } from "./PaginatedList";
|
||||
import { MenuProvider } from "./primitives/Menu/MenuContext";
|
||||
import { Menu, MenuContent, MenuTrigger, MenuButton } from "./primitives/Menu";
|
||||
|
||||
interface TFilterOption extends PaginatedItem {
|
||||
key: string;
|
||||
label: string;
|
||||
note?: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -34,19 +31,17 @@ type Props = {
|
||||
const FilterOptions = ({
|
||||
options,
|
||||
selectedKeys = [],
|
||||
defaultLabel = "Filter options",
|
||||
className,
|
||||
onSelect,
|
||||
showFilter,
|
||||
fetchQuery,
|
||||
fetchQueryOptions,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const listRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const menu = useMenuState({
|
||||
modal: false,
|
||||
});
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const selectedItems = options.filter((option) =>
|
||||
selectedKeys.includes(option.key)
|
||||
);
|
||||
@@ -58,32 +53,26 @@ const FilterOptions = ({
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
(option) => (
|
||||
<MenuItem
|
||||
<MenuButton
|
||||
key={option.key}
|
||||
icon={option.icon}
|
||||
label={option.label}
|
||||
onClick={() => {
|
||||
onSelect(option.key);
|
||||
menu.hide();
|
||||
setOpen(false);
|
||||
}}
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
{...menu}
|
||||
>
|
||||
{option.icon}
|
||||
{option.note ? (
|
||||
<LabelWithNote>
|
||||
{option.label}
|
||||
<Note>{option.note}</Note>
|
||||
</LabelWithNote>
|
||||
) : (
|
||||
option.label
|
||||
)}
|
||||
</MenuItem>
|
||||
/>
|
||||
),
|
||||
[menu, onSelect, selectedKeys]
|
||||
[onSelect, selectedKeys]
|
||||
);
|
||||
|
||||
const handleFilter = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(ev.target.value);
|
||||
};
|
||||
const handleFilter = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(ev.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
const normalizedQuery = deburr(query.toLowerCase());
|
||||
@@ -121,13 +110,13 @@ const FilterOptions = ({
|
||||
|
||||
switch (ev.key) {
|
||||
case "Escape":
|
||||
menu.hide();
|
||||
setOpen(false);
|
||||
break;
|
||||
case "Enter":
|
||||
if (filteredOptions.length === 1) {
|
||||
ev.preventDefault();
|
||||
onSelect(filteredOptions[0].key);
|
||||
menu.hide();
|
||||
setOpen(false);
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
@@ -138,7 +127,7 @@ const FilterOptions = ({
|
||||
break;
|
||||
}
|
||||
},
|
||||
[filteredOptions, menu, onSelect]
|
||||
[filteredOptions, onSelect]
|
||||
);
|
||||
|
||||
const handleEscapeFromList = React.useCallback((ev: React.KeyboardEvent) => {
|
||||
@@ -150,21 +139,21 @@ const FilterOptions = ({
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (menu.visible) {
|
||||
if (open) {
|
||||
searchInputRef.current?.focus();
|
||||
} else {
|
||||
setQuery("");
|
||||
}
|
||||
}, [menu.visible]);
|
||||
}, [open]);
|
||||
|
||||
const showFilterInput = showFilter || options.length > 10;
|
||||
const defaultLabel = rest.defaultLabel || t("Filter options");
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<MenuProvider variant="dropdown">
|
||||
<Menu open={open} onOpenChange={setOpen}>
|
||||
<MenuTrigger>
|
||||
<StyledButton
|
||||
{...props}
|
||||
className={className}
|
||||
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
|
||||
neutral
|
||||
@@ -172,31 +161,31 @@ const FilterOptions = ({
|
||||
>
|
||||
{selectedItems.length ? selectedLabel : defaultLabel}
|
||||
</StyledButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu aria-label={defaultLabel} minHeight={66} {...menu}>
|
||||
<PaginatedList<TFilterOption>
|
||||
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
|
||||
</MenuTrigger>
|
||||
<MenuContent aria-label={defaultLabel} align="start">
|
||||
<PaginatedList<TFilterOption>
|
||||
listRef={listRef}
|
||||
options={{ query, ...fetchQueryOptions }}
|
||||
items={filteredOptions}
|
||||
fetch={fetchQuery}
|
||||
renderItem={renderItem}
|
||||
onEscape={handleEscapeFromList}
|
||||
heading={showFilterInput ? <Spacer /> : undefined}
|
||||
empty={<Empty />}
|
||||
/>
|
||||
)}
|
||||
</ContextMenu>
|
||||
</>
|
||||
{showFilterInput && (
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
value={query}
|
||||
onChange={handleFilter}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</MenuProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -242,24 +231,6 @@ const SearchInput = styled(Input)`
|
||||
}
|
||||
`;
|
||||
|
||||
const Note = styled(Text)`
|
||||
display: block;
|
||||
margin: 2px 0;
|
||||
line-height: 1.2em;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${s("textTertiary")};
|
||||
`;
|
||||
|
||||
const LabelWithNote = styled.div`
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
|
||||
&:hover ${Note} {
|
||||
color: ${(props) => props.theme.white50};
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledButton = styled(Button)`
|
||||
box-shadow: none;
|
||||
text-transform: none;
|
||||
|
||||
+42
-59
@@ -1,9 +1,14 @@
|
||||
import * as React from "react";
|
||||
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import {
|
||||
fadeIn,
|
||||
fadeOut,
|
||||
fadeInAndSlideLeft,
|
||||
fadeOutAndSlideRight,
|
||||
} from "~/styles/animations";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -18,55 +23,33 @@ const Guide: React.FC<Props> = ({
|
||||
title = "Untitled",
|
||||
onRequestClose,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const dialog = useDialogState({
|
||||
animated: 250,
|
||||
});
|
||||
const wasOpen = usePrevious(isOpen);
|
||||
}: Props) => (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onRequestClose()}>
|
||||
<Dialog.Portal>
|
||||
<StyledOverlay>
|
||||
<Scene
|
||||
onEscapeKeyDown={onRequestClose}
|
||||
onPointerDownOutside={onRequestClose}
|
||||
aria-describedby={undefined}
|
||||
{...rest}
|
||||
>
|
||||
<Content>
|
||||
{title && <Header>{title}</Header>}
|
||||
{children}
|
||||
</Content>
|
||||
</Scene>
|
||||
</StyledOverlay>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!wasOpen && isOpen) {
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
if (wasOpen && !isOpen) {
|
||||
dialog.hide();
|
||||
}
|
||||
}, [dialog, wasOpen, isOpen]);
|
||||
|
||||
return (
|
||||
<DialogBackdrop {...dialog}>
|
||||
{(backdropProps) => (
|
||||
<Backdrop {...backdropProps}>
|
||||
<Dialog
|
||||
{...dialog}
|
||||
aria-label={title}
|
||||
preventBodyScroll
|
||||
hideOnEsc
|
||||
hide={onRequestClose}
|
||||
>
|
||||
{(dialogProps) => (
|
||||
<Scene {...dialogProps} {...rest}>
|
||||
<Content>
|
||||
{title && <Header>{title}</Header>}
|
||||
{children}
|
||||
</Content>
|
||||
</Scene>
|
||||
)}
|
||||
</Dialog>
|
||||
</Backdrop>
|
||||
)}
|
||||
</DialogBackdrop>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = styled.h1`
|
||||
const Header = styled(Dialog.Title)`
|
||||
font-size: 18px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1em;
|
||||
`;
|
||||
|
||||
const Backdrop = styled.div`
|
||||
const StyledOverlay = styled(Dialog.Overlay)`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -74,37 +57,37 @@ const Backdrop = styled.div`
|
||||
bottom: 0;
|
||||
background-color: ${s("backdrop")} !important;
|
||||
z-index: ${depths.overlay};
|
||||
transition: opacity 200ms ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
&[data-enter] {
|
||||
opacity: 1;
|
||||
&[data-state="open"] {
|
||||
animation: ${fadeIn} 200ms ease;
|
||||
}
|
||||
|
||||
&[data-state="closed"] {
|
||||
animation: ${fadeOut} 200ms ease;
|
||||
}
|
||||
`;
|
||||
|
||||
const Scene = styled.div`
|
||||
const Scene = styled(Dialog.Content)`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: 12px;
|
||||
z-index: ${depths.modal};
|
||||
display: flex;
|
||||
z-index: ${depths.modal};
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
width: 350px;
|
||||
background: ${s("background")};
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
opacity: 0;
|
||||
transform: translateX(16px);
|
||||
transition:
|
||||
transform 250ms ease,
|
||||
opacity 250ms ease;
|
||||
|
||||
&[data-enter] {
|
||||
opacity: 1;
|
||||
transform: translateX(0px);
|
||||
&[data-state="open"] {
|
||||
animation: ${fadeInAndSlideLeft} 200ms ease;
|
||||
}
|
||||
|
||||
&[data-state="closed"] {
|
||||
animation: ${fadeOutAndSlideRight} 200ms ease;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { useComponentSize } from "@shared/hooks/useComponentSize";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { supportsPassiveListener } from "@shared/utils/browser";
|
||||
import Button from "~/components/Button";
|
||||
@@ -38,8 +38,8 @@ function Header(
|
||||
const { ui } = useStores();
|
||||
const isMobile = useMobile();
|
||||
const hasMobileSidebar = hasSidebar && isMobile;
|
||||
const internalRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const breadcrumbsRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [internalMeasureRef, size] = useMeasure();
|
||||
const [breadcrumbsMeasureRef, breadcrumbsSize] = useMeasure();
|
||||
const passThrough = !actions && !left && !title;
|
||||
|
||||
const [isScrolled, setScrolled] = React.useState(false);
|
||||
@@ -62,19 +62,22 @@ function Header(
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setBreadcrumbRef = React.useCallback((node: HTMLDivElement | null) => {
|
||||
breadcrumbsRef.current = node?.firstElementChild as HTMLDivElement;
|
||||
}, []);
|
||||
const setBreadcrumbRef = React.useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (node?.firstElementChild) {
|
||||
breadcrumbsMeasureRef(node.firstElementChild as HTMLDivElement);
|
||||
}
|
||||
},
|
||||
[breadcrumbsMeasureRef]
|
||||
);
|
||||
|
||||
const size = useComponentSize(internalRef);
|
||||
const breadcrumbsSize = useComponentSize(breadcrumbsRef);
|
||||
const breadcrumbMakesCompact = breadcrumbsSize.width > size.width / 3;
|
||||
const isCompact = size.width < 1000 || breadcrumbMakesCompact;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Wrapper
|
||||
ref={mergeRefs([ref, internalRef])}
|
||||
ref={mergeRefs([ref, internalMeasureRef])}
|
||||
align="center"
|
||||
shrink={false}
|
||||
className={className}
|
||||
@@ -122,6 +125,7 @@ const Actions = styled(Flex)`
|
||||
flex-basis: 0;
|
||||
min-width: auto;
|
||||
padding-left: 8px;
|
||||
gap: 12px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
position: unset;
|
||||
|
||||
@@ -136,6 +136,7 @@ const CustomColor = ({
|
||||
maxLength={7}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
autoFocus
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -136,11 +136,12 @@ const EmojiPanel = ({
|
||||
freqEmojis,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (scrollableRef.current) {
|
||||
scrollableRef.current.scrollTop = 0;
|
||||
React.useLayoutEffect(() => {
|
||||
if (!panelActive) {
|
||||
return;
|
||||
}
|
||||
searchRef.current?.focus();
|
||||
scrollableRef.current?.scroll({ top: 0 });
|
||||
requestAnimationFrame(() => searchRef.current?.focus());
|
||||
}, [panelActive]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -155,11 +155,12 @@ const IconPanel = ({
|
||||
baseIcons,
|
||||
];
|
||||
|
||||
React.useEffect(() => {
|
||||
if (scrollableRef.current) {
|
||||
scrollableRef.current.scrollTop = 0;
|
||||
React.useLayoutEffect(() => {
|
||||
if (!panelActive) {
|
||||
return;
|
||||
}
|
||||
searchRef.current?.focus();
|
||||
scrollableRef.current?.scroll({ top: 0 });
|
||||
requestAnimationFrame(() => searchRef.current?.focus());
|
||||
}, [panelActive]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useMemo, useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu, MenuButton, MenuItem } from "reakit";
|
||||
import styled from "styled-components";
|
||||
import { depths, s, hover } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { EmojiSkinTone } from "@shared/types";
|
||||
import { getEmojiVariants } from "@shared/utils/emoji";
|
||||
import { Emoji } from "~/components/Emoji";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/primitives/Popover";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
const SkinTonePicker = ({
|
||||
@@ -19,62 +22,61 @@ const SkinTonePicker = ({
|
||||
onChange: (skin: EmojiSkinTone) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handEmojiVariants = useMemo(() => getEmojiVariants({ id: "hand" }), []);
|
||||
|
||||
const menu = useMenuState({
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
const handleSkinClick = useCallback(
|
||||
(emojiSkin) => {
|
||||
menu.hide();
|
||||
(emojiSkin: EmojiSkinTone) => {
|
||||
setOpen(false);
|
||||
onChange(emojiSkin);
|
||||
},
|
||||
[menu, onChange]
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const menuItems = useMemo(
|
||||
() =>
|
||||
Object.entries(handEmojiVariants).map(([eskin, emoji]) => (
|
||||
<MenuItem {...menu} key={emoji.value}>
|
||||
{(menuprops) => (
|
||||
<IconButton {...menuprops} onClick={() => handleSkinClick(eskin)}>
|
||||
Object.values(EmojiSkinTone)
|
||||
.map((skinTone) => {
|
||||
const emoji = handEmojiVariants[skinTone];
|
||||
return emoji ? (
|
||||
<IconButton
|
||||
key={emoji.value}
|
||||
onClick={() => handleSkinClick(skinTone)}
|
||||
>
|
||||
<Emoji width={24} height={24}>
|
||||
{emoji.value}
|
||||
</Emoji>
|
||||
</IconButton>
|
||||
)}
|
||||
</MenuItem>
|
||||
)),
|
||||
[menu, handEmojiVariants, handleSkinClick]
|
||||
) : null;
|
||||
})
|
||||
.filter(Boolean),
|
||||
[handEmojiVariants, handleSkinClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<StyledMenuButton
|
||||
{...props}
|
||||
aria-label={t("Choose default skin tone")}
|
||||
>
|
||||
{handEmojiVariants[skinTone]!.value}
|
||||
</StyledMenuButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
<Menu {...menu} aria-label={t("Choose default skin tone")}>
|
||||
{(props) => <MenuContainer {...props}>{menuItems}</MenuContainer>}
|
||||
</Menu>
|
||||
</>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger>
|
||||
<StyledMenuButton aria-label={t("Choose default skin tone")}>
|
||||
{handEmojiVariants[skinTone]?.value}
|
||||
</StyledMenuButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
aria-label={t("Choose default skin tone")}
|
||||
width={208}
|
||||
scrollable={false}
|
||||
shrink
|
||||
>
|
||||
<Emojis>{menuItems}</Emojis>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuContainer = styled(Flex)`
|
||||
z-index: ${depths.menu};
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
background: ${s("menuBackground")};
|
||||
box-shadow: ${s("menuShadow")};
|
||||
const Emojis = styled(Flex)`
|
||||
padding: 0 8px;
|
||||
`;
|
||||
|
||||
const StyledMenuButton = styled(NudeButton)`
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ArrowIcon as ArrowRightIcon } from "outline-icons";
|
||||
import styled from "styled-components";
|
||||
|
||||
export { ArrowIcon as ArrowRightIcon } from "outline-icons";
|
||||
|
||||
export const ArrowUpIcon = styled(ArrowRightIcon)`
|
||||
transform: rotate(-90deg);
|
||||
`;
|
||||
|
||||
export const ArrowDownIcon = styled(ArrowRightIcon)`
|
||||
transform: rotate(90deg);
|
||||
`;
|
||||
|
||||
export const ArrowLeftIcon = styled(ArrowRightIcon)`
|
||||
transform: rotate(180deg);
|
||||
`;
|
||||
@@ -1,16 +1,14 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import ContextMenu from "./ContextMenu";
|
||||
import DelayedMount from "./DelayedMount";
|
||||
import Input, { Props as InputProps } from "./Input";
|
||||
import NudeButton from "./NudeButton";
|
||||
import Relative from "./Sidebar/components/Relative";
|
||||
import Text from "./Text";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./primitives/Popover";
|
||||
|
||||
type Props = Omit<InputProps, "onChange"> & {
|
||||
value: string | undefined;
|
||||
@@ -19,10 +17,6 @@ type Props = Omit<InputProps, "onChange"> & {
|
||||
|
||||
const InputColor: React.FC<Props> = ({ value, onChange, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
return (
|
||||
<Relative>
|
||||
@@ -33,30 +27,26 @@ const InputColor: React.FC<Props> = ({ value, onChange, ...rest }: Props) => {
|
||||
maxLength={7}
|
||||
{...rest}
|
||||
/>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<SwatchButton
|
||||
aria-label={t("Show menu")}
|
||||
{...props}
|
||||
$background={value}
|
||||
/>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Select a color")}>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<DelayedMount>
|
||||
<Text>{t("Loading")}…</Text>
|
||||
</DelayedMount>
|
||||
}
|
||||
>
|
||||
<StyledColorPicker
|
||||
disableAlpha
|
||||
color={value}
|
||||
onChange={(color) => onChange(color.hex)}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</ContextMenu>
|
||||
<Popover modal={true}>
|
||||
<PopoverTrigger>
|
||||
<SwatchButton aria-label={t("Show menu")} $background={value} />
|
||||
</PopoverTrigger>
|
||||
<StyledContent aria-label={t("Select a color")} align="end">
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<DelayedMount>
|
||||
<Text>{t("Loading")}…</Text>
|
||||
</DelayedMount>
|
||||
}
|
||||
>
|
||||
<StyledColorPicker
|
||||
disableAlpha
|
||||
color={value}
|
||||
onChange={(color) => onChange(color.hex)}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</StyledContent>
|
||||
</Popover>
|
||||
</Relative>
|
||||
);
|
||||
};
|
||||
@@ -70,6 +60,11 @@ const SwatchButton = styled(NudeButton)<{ $background: string | undefined }>`
|
||||
right: 6px;
|
||||
`;
|
||||
|
||||
const StyledContent = styled(PopoverContent)`
|
||||
width: auto;
|
||||
padding: 8px;
|
||||
`;
|
||||
|
||||
const ColorPicker = lazyWithRetry(
|
||||
() => import("react-color/lib/components/chrome/Chrome")
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import Separator from "./ContextMenu/Separator";
|
||||
import Flex from "./Flex";
|
||||
import { LabelText } from "./Input";
|
||||
import NudeButton from "./NudeButton";
|
||||
@@ -98,9 +97,9 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
|
||||
);
|
||||
|
||||
const renderOption = React.useCallback(
|
||||
(option: Option) => {
|
||||
(option: Option, idx: number) => {
|
||||
if (option.type === "separator") {
|
||||
return <InputSelectSeparator />;
|
||||
return <InputSelectSeparator key={`separator-${idx}`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -219,9 +218,9 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
|
||||
);
|
||||
|
||||
const renderOption = React.useCallback(
|
||||
(option: Option) => {
|
||||
(option: Option, idx: number) => {
|
||||
if (option.type === "separator") {
|
||||
return <Separator />;
|
||||
return <InputSelectSeparator key={`separator-${idx}`} />;
|
||||
}
|
||||
|
||||
const isSelected = option === selectedOption;
|
||||
|
||||
+27
-30
@@ -12,7 +12,6 @@ import SkipNavLink from "~/components/SkipNavLink";
|
||||
import env from "~/env";
|
||||
import useAutoRefresh from "~/hooks/useAutoRefresh";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { MenuProvider } from "~/hooks/useMenuContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
@@ -38,41 +37,39 @@ const Layout = React.forwardRef(function Layout_(
|
||||
});
|
||||
|
||||
return (
|
||||
<MenuProvider>
|
||||
<Container column auto ref={ref}>
|
||||
<Helmet>
|
||||
<title>{title ? title : env.APP_NAME}</title>
|
||||
</Helmet>
|
||||
<Container column auto ref={ref}>
|
||||
<Helmet>
|
||||
<title>{title ? title : env.APP_NAME}</title>
|
||||
</Helmet>
|
||||
|
||||
<SkipNavLink />
|
||||
<SkipNavLink />
|
||||
|
||||
{ui.progressBarVisible && <LoadingIndicatorBar />}
|
||||
{ui.progressBarVisible && <LoadingIndicatorBar />}
|
||||
|
||||
<Container auto>
|
||||
<MenuProvider>{sidebar}</MenuProvider>
|
||||
<Container auto>
|
||||
{sidebar}
|
||||
|
||||
<SkipNavContent />
|
||||
<Content
|
||||
auto
|
||||
justify="center"
|
||||
$isResizing={ui.sidebarIsResizing}
|
||||
$sidebarCollapsed={sidebarCollapsed}
|
||||
$hasSidebar={!!sidebar}
|
||||
style={
|
||||
sidebarCollapsed
|
||||
? undefined
|
||||
: {
|
||||
marginLeft: `${ui.sidebarWidth}px`,
|
||||
}
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Content>
|
||||
<SkipNavContent />
|
||||
<Content
|
||||
auto
|
||||
justify="center"
|
||||
$isResizing={ui.sidebarIsResizing}
|
||||
$sidebarCollapsed={sidebarCollapsed}
|
||||
$hasSidebar={!!sidebar}
|
||||
style={
|
||||
sidebarCollapsed
|
||||
? undefined
|
||||
: {
|
||||
marginLeft: `${ui.sidebarWidth}px`,
|
||||
}
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Content>
|
||||
|
||||
{sidebarRight}
|
||||
</Container>
|
||||
{sidebarRight}
|
||||
</Container>
|
||||
</MenuProvider>
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -231,6 +231,7 @@ const Subtitle = styled.p<{ $small?: boolean; $selected?: boolean }>`
|
||||
font-size: ${(props) => (props.$small ? 13 : 14)}px;
|
||||
color: ${s("textTertiary")};
|
||||
margin-top: -2px;
|
||||
overflow-wrap: break-word;
|
||||
`;
|
||||
|
||||
export const Actions = styled(Flex)<{ $selected?: boolean }>`
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import * as React from "react";
|
||||
import { actionV2ToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { ActionV2Variant, ActionV2WithChildren } from "~/types";
|
||||
import { toMenuItems } from "./transformer";
|
||||
import { observer } from "mobx-react";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
import { Menu, MenuContent, MenuTrigger } from "~/components/primitives/Menu";
|
||||
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
|
||||
type Props = {
|
||||
/** Root action with children representing the menu items */
|
||||
action?: ActionV2WithChildren;
|
||||
/** Trigger for the menu */
|
||||
children: React.ReactNode;
|
||||
/** ARIA label for the menu */
|
||||
ariaLabel: string;
|
||||
/** Callback when menu is opened */
|
||||
onOpen?: () => void;
|
||||
/** Callback when menu is closed */
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export const ContextMenu = observer(
|
||||
({ action, children, ariaLabel, onOpen, onClose }: Props) => {
|
||||
const isMobile = useMobile();
|
||||
const contentRef = React.useRef<React.ElementRef<typeof MenuContent>>(null);
|
||||
const actionContext = useActionContext({
|
||||
isMenu: true,
|
||||
});
|
||||
|
||||
const menuItems = useComputed(() => {
|
||||
if (!open) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ((action?.children as ActionV2Variant[]) ?? []).map(
|
||||
(childAction) => actionV2ToMenuItem(childAction, actionContext)
|
||||
);
|
||||
}, [open, action?.children, actionContext]);
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(open: boolean) => {
|
||||
if (open) {
|
||||
onOpen?.();
|
||||
} else {
|
||||
onClose?.();
|
||||
}
|
||||
},
|
||||
[onOpen, onClose]
|
||||
);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "auto";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "none";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseAutoFocus = React.useCallback(
|
||||
(e: Event) => e.preventDefault(),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile || !action || menuItems.length === 0) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const content = toMenuItems(menuItems);
|
||||
|
||||
return (
|
||||
<MenuProvider variant="context">
|
||||
<Menu onOpenChange={handleOpenChange}>
|
||||
<MenuTrigger aria-label={ariaLabel}>{children}</MenuTrigger>
|
||||
<MenuContent
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
{content}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</MenuProvider>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import styled from "styled-components";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import {
|
||||
@@ -7,100 +8,155 @@ import {
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/primitives/Drawer";
|
||||
import {
|
||||
DropdownMenu as DropdownMenuRoot,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
} from "~/components/primitives/DropdownMenu";
|
||||
import { Menu, MenuContent, MenuTrigger } from "~/components/primitives/Menu";
|
||||
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
import { actionV2ToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { ActionV2Variant, ActionV2WithChildren, MenuItem } from "~/types";
|
||||
import { toDropdownMenuItems, toMobileMenuItems } from "./transformer";
|
||||
import {
|
||||
ActionV2Variant,
|
||||
ActionV2WithChildren,
|
||||
MenuItem,
|
||||
MenuItemWithChildren,
|
||||
} from "~/types";
|
||||
import { toMenuItems, toMobileMenuItems } from "./transformer";
|
||||
import { observer } from "mobx-react";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
|
||||
type Props = {
|
||||
/** Root action with children representing the menu items */
|
||||
action: ActionV2WithChildren;
|
||||
/** Trigger for the menu */
|
||||
children: React.ReactNode;
|
||||
/** Alignment w.r.t trigger - defaults to start */
|
||||
align?: "start" | "end";
|
||||
/** ARIA label for the menu */
|
||||
ariaLabel: string;
|
||||
contentAriaLabel?: string;
|
||||
};
|
||||
/** Additional component to display at the bottom of the top-level menu */
|
||||
append?: React.ReactNode;
|
||||
/** Callback when menu is opened */
|
||||
onOpen?: () => void;
|
||||
/** Callback when menu is closed */
|
||||
onClose?: () => void;
|
||||
// TODO: Invert the dependency chain by forwarding dropdown ref and props to Tooltip component
|
||||
} & React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>;
|
||||
|
||||
export function DropdownMenu({
|
||||
action,
|
||||
children,
|
||||
align = "start",
|
||||
ariaLabel,
|
||||
contentAriaLabel,
|
||||
}: Props) {
|
||||
const isMobile = useMobile();
|
||||
const contentRef =
|
||||
React.useRef<React.ElementRef<typeof DropdownMenuContent>>(null);
|
||||
const context = useActionContext({
|
||||
isContextMenu: true,
|
||||
});
|
||||
const menuItems = (action.children as ActionV2Variant[]).map((childAction) =>
|
||||
actionV2ToMenuItem(childAction, context)
|
||||
);
|
||||
export const DropdownMenu = observer(
|
||||
React.forwardRef<React.ElementRef<typeof TooltipPrimitive.Trigger>, Props>(
|
||||
(
|
||||
{
|
||||
action,
|
||||
children,
|
||||
align = "start",
|
||||
ariaLabel,
|
||||
append,
|
||||
onOpen,
|
||||
onClose,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const isMobile = useMobile();
|
||||
const contentRef =
|
||||
React.useRef<React.ElementRef<typeof MenuContent>>(null);
|
||||
const actionContext = useActionContext({
|
||||
isMenu: true,
|
||||
});
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "auto";
|
||||
const menuItems = useComputed(() => {
|
||||
if (!open) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (action.children as ActionV2Variant[]).map((childAction) =>
|
||||
actionV2ToMenuItem(childAction, actionContext)
|
||||
);
|
||||
}, [open, action.children, actionContext]);
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(open: boolean) => {
|
||||
setOpen(open);
|
||||
if (open) {
|
||||
onOpen?.();
|
||||
} else {
|
||||
onClose?.();
|
||||
}
|
||||
},
|
||||
[onOpen, onClose]
|
||||
);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "auto";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "none";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseAutoFocus = React.useCallback(
|
||||
(e: Event) => e.preventDefault(),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileDropdown
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
items={menuItems}
|
||||
trigger={children}
|
||||
ariaLabel={ariaLabel}
|
||||
append={append}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const content = toMenuItems(menuItems);
|
||||
|
||||
return (
|
||||
<MenuProvider variant="dropdown">
|
||||
<Menu open={open} onOpenChange={handleOpenChange}>
|
||||
<MenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
|
||||
{children}
|
||||
</MenuTrigger>
|
||||
<MenuContent
|
||||
align={align}
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
{content}
|
||||
{append}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</MenuProvider>
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "none";
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileDropdown
|
||||
items={menuItems}
|
||||
trigger={children}
|
||||
ariaLabel={ariaLabel}
|
||||
contentAriaLabel={contentAriaLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const content = toDropdownMenuItems(menuItems);
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger aria-label={ariaLabel}>
|
||||
{children}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
aria-label={contentAriaLabel ?? ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
>
|
||||
{content}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuRoot>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
type MobileDropdownProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
items: MenuItem[];
|
||||
trigger: React.ReactNode;
|
||||
} & Pick<Props, "ariaLabel" | "contentAriaLabel">;
|
||||
} & Pick<Props, "ariaLabel" | "append">;
|
||||
|
||||
function MobileDropdown({
|
||||
open,
|
||||
onOpenChange,
|
||||
items,
|
||||
trigger,
|
||||
ariaLabel,
|
||||
contentAriaLabel,
|
||||
append,
|
||||
}: MobileDropdownProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [submenuName, setSubmenuName] = React.useState<string>();
|
||||
const contentRef = React.useRef<React.ElementRef<typeof DrawerContent>>(null);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
@@ -116,29 +172,52 @@ function MobileDropdown({
|
||||
}, []);
|
||||
|
||||
const closeDrawer = React.useCallback(() => {
|
||||
setOpen(false);
|
||||
onOpenChange(false);
|
||||
setTimeout(() => setSubmenuName(undefined), 500); // needed for a Vaul bug where 'onAnimationEnd' is not called for controlled state.
|
||||
}, [onOpenChange]);
|
||||
|
||||
const resetSubmenu = React.useCallback((isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setSubmenuName(undefined);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const content = toMobileMenuItems(items, closeDrawer);
|
||||
const menuItems = React.useMemo(() => {
|
||||
if (!items.length || !submenuName) {
|
||||
return items;
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
const submenu = items.find(
|
||||
(item) =>
|
||||
item.type === "submenu" && (item.title as string) === submenuName
|
||||
)! as MenuItemWithChildren;
|
||||
|
||||
return submenu.items;
|
||||
}, [items, submenuName]);
|
||||
|
||||
const content = toMobileMenuItems(menuItems, closeDrawer, setSubmenuName);
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onAnimationEnd={resetSubmenu}
|
||||
>
|
||||
<DrawerTrigger aria-label={ariaLabel} asChild>
|
||||
{trigger}
|
||||
</DrawerTrigger>
|
||||
<DrawerContent
|
||||
ref={contentRef}
|
||||
aria-label={contentAriaLabel ?? ariaLabel}
|
||||
aria-label={ariaLabel}
|
||||
aria-describedby={undefined}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
>
|
||||
<DrawerTitle>{ariaLabel}</DrawerTitle>
|
||||
<StyledScrollable hiddenScrollbars>{content}</StyledScrollable>
|
||||
<StyledScrollable hiddenScrollbars>
|
||||
{content}
|
||||
{!submenuName ? append : null}
|
||||
</StyledScrollable>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Button from "~/components/Button";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
|
||||
type Props = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Trigger
|
||||
> & {
|
||||
neutral?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const OverflowMenuButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
({ className, ...rest }, ref) => (
|
||||
<NudeButton ref={ref} className={className} {...rest}>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
)
|
||||
({ neutral, className, ...rest }, ref) =>
|
||||
neutral ? (
|
||||
<Button ref={ref} icon={<MoreIcon />} neutral borderOnHover {...rest} />
|
||||
) : (
|
||||
<NudeButton ref={ref} className={className} {...rest}>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
)
|
||||
);
|
||||
OverflowMenuButton.displayName = "OverflowMenuButton";
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import {
|
||||
DropdownMenuButton,
|
||||
DropdownMenuExternalLink,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuInternalLink,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from "~/components/primitives/DropdownMenu";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import {
|
||||
MenuButton,
|
||||
MenuIconWrapper,
|
||||
MenuInternalLink,
|
||||
MenuExternalLink,
|
||||
MenuLabel,
|
||||
MenuSeparator,
|
||||
} from "~/components/primitives/components/Menu";
|
||||
SubMenu,
|
||||
SubMenuTrigger,
|
||||
SubMenuContent,
|
||||
MenuGroup,
|
||||
} from "~/components/primitives/Menu";
|
||||
import * as Components from "~/components/primitives/components/Menu";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
export function toMenuItems(items: MenuItem[]) {
|
||||
const filteredItems = filterMenuItems(items);
|
||||
|
||||
if (!filteredItems.length) {
|
||||
@@ -33,19 +29,21 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
|
||||
return filteredItems.map((item, index) => {
|
||||
const icon = showIcon ? (
|
||||
<MenuIconWrapper aria-hidden>
|
||||
<Components.MenuIconWrapper aria-hidden>
|
||||
{"icon" in item ? item.icon : null}
|
||||
</MenuIconWrapper>
|
||||
</Components.MenuIconWrapper>
|
||||
) : undefined;
|
||||
|
||||
switch (item.type) {
|
||||
case "button":
|
||||
return (
|
||||
<DropdownMenuButton
|
||||
<MenuButton
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
tooltip={item.tooltip}
|
||||
selected={item.selected}
|
||||
dangerous={item.dangerous}
|
||||
onClick={item.onClick}
|
||||
/>
|
||||
@@ -53,7 +51,7 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
|
||||
case "route":
|
||||
return (
|
||||
<DropdownMenuInternalLink
|
||||
<MenuInternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
@@ -64,7 +62,7 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
|
||||
case "link":
|
||||
return (
|
||||
<DropdownMenuExternalLink
|
||||
<MenuExternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
@@ -76,15 +74,34 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
/>
|
||||
);
|
||||
|
||||
case "submenu": {
|
||||
const submenuItems = toMenuItems(item.items);
|
||||
|
||||
if (!submenuItems?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SubMenu key={`${item.type}-${item.title}-${index}`}>
|
||||
<SubMenuTrigger
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
/>
|
||||
<SubMenuContent>{submenuItems}</SubMenuContent>
|
||||
</SubMenu>
|
||||
);
|
||||
}
|
||||
|
||||
case "group": {
|
||||
const groupItems = toDropdownMenuItems(item.items);
|
||||
const groupItems = toMenuItems(item.items);
|
||||
|
||||
if (!groupItems?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuGroup
|
||||
<MenuGroup
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
items={groupItems}
|
||||
@@ -93,7 +110,7 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
}
|
||||
|
||||
case "separator":
|
||||
return <DropdownMenuSeparator key={`${item.type}-${index}`} />;
|
||||
return <MenuSeparator key={`${item.type}-${index}`} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
@@ -101,7 +118,11 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
});
|
||||
}
|
||||
|
||||
export function toMobileMenuItems(items: MenuItem[], closeMenu: () => void) {
|
||||
export function toMobileMenuItems(
|
||||
items: MenuItem[],
|
||||
closeMenu: () => void,
|
||||
openSubmenu: (submenuName: string) => void
|
||||
) {
|
||||
const filteredItems = filterMenuItems(items);
|
||||
|
||||
if (!filteredItems.length) {
|
||||
@@ -118,15 +139,15 @@ export function toMobileMenuItems(items: MenuItem[], closeMenu: () => void) {
|
||||
|
||||
return filteredItems.map((item, index) => {
|
||||
const icon = showIcon ? (
|
||||
<MenuIconWrapper aria-hidden>
|
||||
<Components.MenuIconWrapper aria-hidden>
|
||||
{"icon" in item ? item.icon : null}
|
||||
</MenuIconWrapper>
|
||||
</Components.MenuIconWrapper>
|
||||
) : undefined;
|
||||
|
||||
switch (item.type) {
|
||||
case "button":
|
||||
return (
|
||||
<MenuButton
|
||||
<Components.MenuButton
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
$dangerous={item.dangerous}
|
||||
@@ -136,26 +157,31 @@ export function toMobileMenuItems(items: MenuItem[], closeMenu: () => void) {
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<MenuLabel>{item.title}</MenuLabel>
|
||||
</MenuButton>
|
||||
<Components.MenuLabel>{item.title}</Components.MenuLabel>
|
||||
{item.selected !== undefined && (
|
||||
<Components.SelectedIconWrapper aria-hidden>
|
||||
{item.selected ? <CheckmarkIcon /> : null}
|
||||
</Components.SelectedIconWrapper>
|
||||
)}
|
||||
</Components.MenuButton>
|
||||
);
|
||||
|
||||
case "route":
|
||||
return (
|
||||
<MenuInternalLink
|
||||
<Components.MenuInternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
to={item.to}
|
||||
disabled={item.disabled}
|
||||
onClick={closeMenu}
|
||||
>
|
||||
{icon}
|
||||
<MenuLabel>{item.title}</MenuLabel>
|
||||
</MenuInternalLink>
|
||||
<Components.MenuLabel>{item.title}</Components.MenuLabel>
|
||||
</Components.MenuInternalLink>
|
||||
);
|
||||
|
||||
case "link":
|
||||
return (
|
||||
<MenuExternalLink
|
||||
<Components.MenuExternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
href={typeof item.href === "string" ? item.href : item.href.url}
|
||||
target={
|
||||
@@ -165,12 +191,42 @@ export function toMobileMenuItems(items: MenuItem[], closeMenu: () => void) {
|
||||
onClick={closeMenu}
|
||||
>
|
||||
{icon}
|
||||
<MenuLabel>{item.title}</MenuLabel>
|
||||
</MenuExternalLink>
|
||||
<Components.MenuLabel>{item.title}</Components.MenuLabel>
|
||||
</Components.MenuExternalLink>
|
||||
);
|
||||
|
||||
case "submenu": {
|
||||
const submenuItems = toMobileMenuItems(
|
||||
item.items,
|
||||
closeMenu,
|
||||
openSubmenu
|
||||
);
|
||||
|
||||
if (!submenuItems?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Components.MenuButton
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
openSubmenu(item.title as string);
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<Components.MenuLabel>{item.title}</Components.MenuLabel>
|
||||
<Components.MenuDisclosure />
|
||||
</Components.MenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
case "group": {
|
||||
const groupItems = toMobileMenuItems(item.items, closeMenu);
|
||||
const groupItems = toMobileMenuItems(
|
||||
item.items,
|
||||
closeMenu,
|
||||
openSubmenu
|
||||
);
|
||||
|
||||
if (!groupItems?.length) {
|
||||
return null;
|
||||
@@ -178,14 +234,14 @@ export function toMobileMenuItems(items: MenuItem[], closeMenu: () => void) {
|
||||
|
||||
return (
|
||||
<div key={`${item.type}-${item.title}-${index}`}>
|
||||
<DropdownMenuLabel>{item.title}</DropdownMenuLabel>
|
||||
<Components.MenuHeader>{item.title}</Components.MenuHeader>
|
||||
{groupItems}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "separator":
|
||||
return <MenuSeparator key={`${item.type}-${index}`} />;
|
||||
return <Components.MenuSeparator key={`${item.type}-${index}`} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
|
||||
+53
-55
@@ -12,9 +12,10 @@ import Scrollable from "~/components/Scrollable";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
import { fadeAndScaleIn, fadeIn } from "~/styles/animations";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -45,55 +46,57 @@ const Modal: React.FC<Props> = ({
|
||||
onOpenChange={(open) => !open && onRequestClose()}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<StyledOverlay>
|
||||
<StyledContent
|
||||
onEscapeKeyDown={onRequestClose}
|
||||
onPointerDownOutside={onRequestClose}
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{isMobile ? (
|
||||
<Mobile>
|
||||
<Content>
|
||||
<Centered onClick={(ev) => ev.stopPropagation()} column>
|
||||
{title && (
|
||||
<Text size="xlarge" weight="bold">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</Centered>
|
||||
</Content>
|
||||
<Close onClick={onRequestClose}>
|
||||
<CloseIcon size={32} />
|
||||
</Close>
|
||||
<Back onClick={onRequestClose}>
|
||||
<BackIcon size={32} />
|
||||
<Text>{t("Back")} </Text>
|
||||
</Back>
|
||||
</Mobile>
|
||||
) : (
|
||||
<Small>
|
||||
<Centered
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
// maxHeight needed for proper overflow behavior in Safari
|
||||
style={{ maxHeight: "65vh" }}
|
||||
column
|
||||
reverse
|
||||
>
|
||||
<SmallContent style={style} shadow>
|
||||
<ErrorBoundary component="div">{children}</ErrorBoundary>
|
||||
</SmallContent>
|
||||
<Header>
|
||||
{title && <Text size="large">{title}</Text>}
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
</Header>
|
||||
<StyledOverlay />
|
||||
<Dialog.Title asChild>
|
||||
<VisuallyHidden.Root>{title}</VisuallyHidden.Root>
|
||||
</Dialog.Title>
|
||||
<StyledContent
|
||||
onEscapeKeyDown={onRequestClose}
|
||||
onPointerDownOutside={onRequestClose}
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{isMobile ? (
|
||||
<Mobile>
|
||||
<Content>
|
||||
<Centered onClick={(ev) => ev.stopPropagation()} column>
|
||||
{title && (
|
||||
<Text size="xlarge" weight="bold">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</Centered>
|
||||
</Small>
|
||||
)}
|
||||
</StyledContent>
|
||||
</StyledOverlay>
|
||||
</Content>
|
||||
<Close onClick={onRequestClose}>
|
||||
<CloseIcon size={32} />
|
||||
</Close>
|
||||
<Back onClick={onRequestClose}>
|
||||
<BackIcon size={32} />
|
||||
<Text>{t("Back")} </Text>
|
||||
</Back>
|
||||
</Mobile>
|
||||
) : (
|
||||
<Small>
|
||||
<Centered
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
// maxHeight needed for proper overflow behavior in Safari
|
||||
style={{ maxHeight: "65vh" }}
|
||||
column
|
||||
reverse
|
||||
>
|
||||
<SmallContent style={style} shadow>
|
||||
<ErrorBoundary component="div">{children}</ErrorBoundary>
|
||||
</SmallContent>
|
||||
<Header>
|
||||
{title && <Text size="large">{title}</Text>}
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
</Header>
|
||||
</Centered>
|
||||
</Small>
|
||||
)}
|
||||
</StyledContent>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
@@ -107,12 +110,7 @@ const StyledOverlay = styled(Dialog.Overlay)`
|
||||
bottom: 0;
|
||||
background-color: ${(props) => props.theme.modalBackdrop} !important;
|
||||
z-index: ${depths.overlay};
|
||||
transition: opacity 50ms ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
&[data-state="open"] {
|
||||
opacity: 1;
|
||||
}
|
||||
animation: ${fadeIn} 200ms ease;
|
||||
`;
|
||||
|
||||
const StyledContent = styled(Dialog.Content)`
|
||||
|
||||
@@ -6,13 +6,17 @@ import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s, hover, truncateMultiline } from "@shared/styles";
|
||||
import Notification from "~/models/Notification";
|
||||
import CommentEditor from "~/scenes/Document/components/CommentEditor";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Avatar, AvatarSize, AvatarVariant } from "../Avatar";
|
||||
import Flex from "../Flex";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
import { UnreadBadge } from "../UnreadBadge";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const CommentEditor = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/CommentEditor")
|
||||
);
|
||||
|
||||
type Props = {
|
||||
notification: Notification;
|
||||
|
||||
@@ -6,7 +6,6 @@ import styled from "styled-components";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import Notification from "~/models/Notification";
|
||||
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import NotificationMenu from "~/menus/NotificationMenu";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
@@ -32,10 +31,9 @@ function Notifications(
|
||||
{ onRequestClose }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const context = useActionContext();
|
||||
const { notifications } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const isEmpty = notifications.orderedData.length === 0;
|
||||
const isEmpty = notifications.active.length === 0;
|
||||
|
||||
// Update the notification count in the dock icon, if possible.
|
||||
React.useEffect(() => {
|
||||
@@ -67,7 +65,10 @@ function Notifications(
|
||||
<Flex gap={8}>
|
||||
{notifications.approximateUnreadCount > 0 && (
|
||||
<Tooltip content={t("Mark all as read")}>
|
||||
<Button action={markNotificationsAsRead} context={context}>
|
||||
<Button
|
||||
action={markNotificationsAsRead}
|
||||
aria-label={t("Mark all as read")}
|
||||
>
|
||||
<MarkAsReadIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -80,7 +81,7 @@ function Notifications(
|
||||
<PaginatedList<Notification>
|
||||
fetch={notifications.fetchPage}
|
||||
options={{ archived: false }}
|
||||
items={notifications.orderedData}
|
||||
items={notifications.active}
|
||||
renderItem={(item) => (
|
||||
<NotificationListItem
|
||||
key={item.id}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Popover,
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
PopoverContent,
|
||||
} from "~/components/primitives/Popover";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Notifications from "./Notifications";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const Notifications = lazyWithRetry(() => import("./Notifications"));
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -16,18 +18,18 @@ type Props = {
|
||||
const NotificationsPopover: React.FC = ({ children }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { notifications } = useStores();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const scrollableRef = React.useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
void notifications.fetchPage({});
|
||||
useEffect(() => {
|
||||
void notifications.fetchPage({ archived: false });
|
||||
}, [notifications]);
|
||||
|
||||
const handleRequestClose = React.useCallback(() => {
|
||||
const handleRequestClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleAutoFocus = React.useCallback((event: Event) => {
|
||||
const handleAutoFocus = useCallback((event: Event) => {
|
||||
// Prevent focus from moving to the popover content
|
||||
event.preventDefault();
|
||||
|
||||
@@ -48,10 +50,12 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
|
||||
onOpenAutoFocus={handleAutoFocus}
|
||||
shrink
|
||||
>
|
||||
<Notifications
|
||||
onRequestClose={handleRequestClose}
|
||||
ref={scrollableRef}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<Notifications
|
||||
onRequestClose={handleRequestClose}
|
||||
ref={scrollableRef}
|
||||
/>
|
||||
</Suspense>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -56,13 +56,14 @@ export const OAuthClientForm = observer(function OAuthClientForm_({
|
||||
return (
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
<>
|
||||
<label style={{ marginBottom: "1em" }}>
|
||||
<label style={{ marginBottom: "1em", display: "block" }}>
|
||||
<LabelText>{t("Icon")}</LabelText>
|
||||
<Controller
|
||||
control={control}
|
||||
name="avatarUrl"
|
||||
render={({ field }) => (
|
||||
<ImageInput
|
||||
alt={t("OAuth client icon")}
|
||||
onSuccess={(url) => field.onChange(url)}
|
||||
onError={(err) => setError("avatarUrl", { message: err })}
|
||||
model={{
|
||||
|
||||
@@ -46,7 +46,6 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
<DocumentListItem
|
||||
key={item.id}
|
||||
document={item}
|
||||
showPin={!!options?.collectionId}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import "../stores";
|
||||
import { render } from "@testing-library/react";
|
||||
import { TFunction } from "i18next";
|
||||
import { Provider } from "mobx-react";
|
||||
import { getI18n } from "react-i18next";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import PaginatedList from "./PaginatedList";
|
||||
|
||||
describe("PaginatedList", () => {
|
||||
const i18n = getI18n();
|
||||
const authStore = {};
|
||||
|
||||
const props = {
|
||||
i18n,
|
||||
tReady: true,
|
||||
t: ((key: string) => key) as TFunction,
|
||||
} as any;
|
||||
|
||||
it("with no items renders nothing", () => {
|
||||
const result = render(
|
||||
<Provider auth={authStore}>
|
||||
<PaginatedList items={[]} renderItem={render} {...props} />
|
||||
</Provider>
|
||||
);
|
||||
expect(result.container.innerHTML).toEqual("");
|
||||
});
|
||||
|
||||
it("with no items renders empty prop", async () => {
|
||||
const result = render(
|
||||
<Provider auth={authStore}>
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
empty={<p>Sorry, no results</p>}
|
||||
renderItem={render}
|
||||
{...props}
|
||||
/>{" "}
|
||||
</Provider>
|
||||
);
|
||||
await expect(
|
||||
result.findAllByText("Sorry, no results")
|
||||
).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("calls fetch with options + pagination on mount", () => {
|
||||
const fetch = jest.fn();
|
||||
const options = {
|
||||
id: "one",
|
||||
};
|
||||
render(
|
||||
<Provider auth={authStore}>
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={render}
|
||||
{...props}
|
||||
/>{" "}
|
||||
</Provider>
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith({
|
||||
...options,
|
||||
limit: Pagination.defaultLimit,
|
||||
offset: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -255,6 +255,7 @@ const PaginatedList = <T extends PaginatedItem>({
|
||||
<React.Fragment>
|
||||
{heading}
|
||||
<ArrowKeyNavigation
|
||||
role={rest.role}
|
||||
aria-label={rest["aria-label"]}
|
||||
onEscape={onEscape}
|
||||
className={className}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { Dialog } from "reakit/Dialog";
|
||||
import { Popover as ReakitPopover, PopoverProps } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
|
||||
type Props = PopoverProps & {
|
||||
children: React.ReactNode;
|
||||
/** The width of the popover, defaults to 380px. */
|
||||
width?: number;
|
||||
/** The minimum width of the popover, use instead of width if contents adjusts size. */
|
||||
minWidth?: number;
|
||||
/** Shrink the padding of the popover */
|
||||
shrink?: boolean;
|
||||
/** Make the popover flex */
|
||||
flex?: boolean;
|
||||
/** The tab index of the popover */
|
||||
tabIndex?: number;
|
||||
/** Whether the popover should be scrollable, defaults to true. */
|
||||
scrollable?: boolean;
|
||||
/** The position of the popover on mobile, defaults to "top". */
|
||||
mobilePosition?: "top" | "bottom";
|
||||
/** Function to show the popover */
|
||||
show: () => void;
|
||||
/** Function to hide the popover */
|
||||
hide: () => void;
|
||||
};
|
||||
|
||||
const Popover = (
|
||||
{
|
||||
children,
|
||||
shrink,
|
||||
width = 380,
|
||||
minWidth,
|
||||
scrollable = true,
|
||||
flex,
|
||||
mobilePosition,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) => {
|
||||
const isMobile = useMobile();
|
||||
|
||||
// Custom Escape handler rather than using hideOnEsc from reakit so we can
|
||||
// prevent default behavior of exiting fullscreen.
|
||||
useKeyDown(
|
||||
"Escape",
|
||||
(event) => {
|
||||
if (rest.visible && rest.hideOnEsc !== false) {
|
||||
event.preventDefault();
|
||||
rest.hide();
|
||||
}
|
||||
},
|
||||
{
|
||||
allowInInput: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Dialog {...rest} modal>
|
||||
<Contents
|
||||
ref={ref}
|
||||
$shrink={shrink}
|
||||
$scrollable={scrollable}
|
||||
$flex={flex}
|
||||
$mobilePosition={mobilePosition}
|
||||
>
|
||||
{children}
|
||||
</Contents>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledPopover {...rest} hideOnEsc={false} hideOnClickOutside>
|
||||
<Contents
|
||||
ref={ref}
|
||||
$shrink={shrink}
|
||||
$width={width}
|
||||
$minWidth={minWidth}
|
||||
$scrollable={scrollable}
|
||||
$flex={flex}
|
||||
>
|
||||
{children}
|
||||
</Contents>
|
||||
</StyledPopover>
|
||||
);
|
||||
};
|
||||
|
||||
type ContentsProps = {
|
||||
$shrink?: boolean;
|
||||
$width?: number;
|
||||
$minWidth?: number;
|
||||
$flex?: boolean;
|
||||
$scrollable: boolean;
|
||||
$mobilePosition?: "top" | "bottom";
|
||||
};
|
||||
|
||||
const StyledPopover = styled(ReakitPopover)`
|
||||
z-index: ${depths.modal};
|
||||
`;
|
||||
|
||||
const Contents = styled.div<ContentsProps>`
|
||||
display: ${(props) => (props.$flex ? "flex" : "block")};
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: 75% 0;
|
||||
background: ${s("menuBackground")};
|
||||
border-radius: 6px;
|
||||
padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
|
||||
max-height: 75vh;
|
||||
box-shadow: ${s("menuShadow")};
|
||||
${(props) => props.$width && `width: ${props.$width}px`};
|
||||
${(props) => props.$minWidth && `min-width: ${props.$minWidth}px`};
|
||||
|
||||
${(props) =>
|
||||
props.$scrollable
|
||||
? `
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
`
|
||||
: `
|
||||
overflow: hidden;
|
||||
`}
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
position: fixed;
|
||||
z-index: ${depths.menu};
|
||||
|
||||
// 50 is a magic number that positions us nicely under the top bar
|
||||
top: ${(props: ContentsProps) =>
|
||||
props.$mobilePosition === "bottom" ? "auto" : "50px"};
|
||||
bottom: ${(props: ContentsProps) =>
|
||||
props.$mobilePosition === "bottom" ? "0" : "auto"};
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
width: auto;
|
||||
`};
|
||||
`;
|
||||
|
||||
export default React.forwardRef(Popover);
|
||||
@@ -2,7 +2,7 @@ import compact from "lodash/compact";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tab, TabPanel, useTabState } from "reakit";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { toast } from "sonner";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s, hover } from "@shared/styles";
|
||||
@@ -22,7 +22,9 @@ type Props = {
|
||||
const ViewReactionsDialog: React.FC<Props> = ({ model }) => {
|
||||
const { t } = useTranslation();
|
||||
const { users } = useStores();
|
||||
const tab = useTabState();
|
||||
const [selectedTab, setSelectedTab] = React.useState<string>(
|
||||
model.reactions[0]?.emoji || ""
|
||||
);
|
||||
const { reactedUsersLoaded } = model;
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -37,24 +39,32 @@ const ViewReactionsDialog: React.FC<Props> = ({ model }) => {
|
||||
void loadReactedUsersData();
|
||||
}, [t, model]);
|
||||
|
||||
// Set initial tab when reactions are loaded
|
||||
React.useEffect(() => {
|
||||
if (model.reactions.length > 0 && !selectedTab) {
|
||||
setSelectedTab(model.reactions[0].emoji);
|
||||
}
|
||||
}, [model.reactions, selectedTab]);
|
||||
|
||||
if (!reactedUsersLoaded) {
|
||||
return <PlaceHolder />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs.Root value={selectedTab} onValueChange={setSelectedTab}>
|
||||
<TabActionsWrapper>
|
||||
{model.reactions.map((reaction) => (
|
||||
<StyledTab
|
||||
{...tab}
|
||||
key={reaction.emoji}
|
||||
id={reaction.emoji}
|
||||
aria-label={t("Reaction")}
|
||||
$active={tab.selectedId === reaction.emoji}
|
||||
>
|
||||
<Emoji size={16}>{reaction.emoji}</Emoji>
|
||||
</StyledTab>
|
||||
))}
|
||||
<Tabs.List>
|
||||
{model.reactions.map((reaction) => (
|
||||
<StyledTab
|
||||
key={reaction.emoji}
|
||||
value={reaction.emoji}
|
||||
aria-label={t("Reaction")}
|
||||
$active={selectedTab === reaction.emoji}
|
||||
>
|
||||
<Emoji size={16}>{reaction.emoji}</Emoji>
|
||||
</StyledTab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</TabActionsWrapper>
|
||||
{model.reactions.map((reaction) => {
|
||||
const reactedUsers = compact(
|
||||
@@ -62,7 +72,7 @@ const ViewReactionsDialog: React.FC<Props> = ({ model }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledTabPanel {...tab} key={reaction.emoji}>
|
||||
<StyledTabPanel key={reaction.emoji} value={reaction.emoji}>
|
||||
{reactedUsers.map((user) => (
|
||||
<UserInfo key={user.name} align="center" gap={8}>
|
||||
<Avatar model={user} size={AvatarSize.Medium} />
|
||||
@@ -72,7 +82,7 @@ const ViewReactionsDialog: React.FC<Props> = ({ model }) => {
|
||||
</StyledTabPanel>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</Tabs.Root>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -101,7 +111,7 @@ const TabActionsWrapper = styled(Flex)`
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
`;
|
||||
|
||||
const StyledTab = styled(Tab)<{ $active: boolean }>`
|
||||
const StyledTab = styled(Tabs.Trigger)<{ $active: boolean }>`
|
||||
position: relative;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
@@ -132,7 +142,7 @@ const StyledTab = styled(Tab)<{ $active: boolean }>`
|
||||
`}
|
||||
`;
|
||||
|
||||
const StyledTabPanel = styled(TabPanel)`
|
||||
const StyledTabPanel = styled(Tabs.Content)`
|
||||
height: 300px;
|
||||
padding: 5px 0;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { EyeIcon } from "outline-icons";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTextStats } from "~/hooks/useTextStats";
|
||||
import type Document from "~/models/Document";
|
||||
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
|
||||
|
||||
const ReadingTime = ({ document }: { document: Document }) => {
|
||||
const { t } = useTranslation();
|
||||
const markdown = useMemo(
|
||||
() => ProsemirrorHelper.toMarkdown(document),
|
||||
[document]
|
||||
);
|
||||
const stats = useTextStats(markdown);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EyeIcon size={18} />
|
||||
{t(`{{ minutes }}m read`, {
|
||||
minutes: stats.total.readingTime,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReadingTime;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { m, TargetAndTransition } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { useComponentSize } from "@shared/hooks/useComponentSize";
|
||||
import useMeasure from "react-use-measure";
|
||||
|
||||
type Props = {
|
||||
/** The children to render */
|
||||
@@ -33,8 +33,7 @@ export const ResizingHeightContainer = React.forwardRef<HTMLDivElement, Props>(
|
||||
style,
|
||||
} = props;
|
||||
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const { height } = useComponentSize(ref);
|
||||
const [measureRef, { height }] = useMeasure();
|
||||
|
||||
return (
|
||||
<m.div
|
||||
@@ -48,7 +47,7 @@ export const ResizingHeightContainer = React.forwardRef<HTMLDivElement, Props>(
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div ref={mergeRefs([ref, forwardedRef])}>{children}</div>
|
||||
<div ref={mergeRefs([measureRef, forwardedRef])}>{children}</div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import { s, hover, ellipsis } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Highlight, { Mark } from "~/components/Highlight";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { sharedModelPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -51,7 +51,7 @@ function DocumentListItem(
|
||||
dir={document.dir}
|
||||
to={{
|
||||
pathname: shareId
|
||||
? sharedDocumentPath(shareId, document.url)
|
||||
? sharedModelPath(shareId, document.url)
|
||||
: document.url,
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
|
||||
@@ -2,15 +2,17 @@ import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import { depths } from "@shared/styles";
|
||||
import Empty from "~/components/Empty";
|
||||
import { Outline } from "~/components/Input";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Placeholder from "~/components/List/Placeholder";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import Popover from "~/components/Popover";
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
} from "~/components/primitives/Popover";
|
||||
import { id as bodyContentId } from "~/components/SkipNavContent";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -27,14 +29,8 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
const { documents } = useStores();
|
||||
const focusRef = React.useRef<HTMLElement | null>(null);
|
||||
|
||||
const popover = usePopoverState({
|
||||
placement: "bottom-start",
|
||||
unstable_offset: [-24, 0],
|
||||
modal: true,
|
||||
});
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const { show, hide } = popover;
|
||||
|
||||
const [searchResults, setSearchResults] = React.useState<
|
||||
SearchResult[] | undefined
|
||||
@@ -48,9 +44,9 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
if (searchResults) {
|
||||
setCachedQuery(query);
|
||||
setCachedSearchResults(searchResults);
|
||||
show();
|
||||
setOpen(true);
|
||||
}
|
||||
}, [searchResults, query, show]);
|
||||
}, [searchResults, query]);
|
||||
|
||||
const performSearch = React.useCallback(
|
||||
async ({ query: searchQuery, ...options }) => {
|
||||
@@ -76,25 +72,14 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
() =>
|
||||
debounce(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
setQuery(value.trim());
|
||||
|
||||
// covers edge case: user manually dismisses popover then
|
||||
// quickly edits input resulting in no change in query
|
||||
// the useEffect that normally shows the popover will miss it
|
||||
if (value === cachedQuery) {
|
||||
popover.show();
|
||||
}
|
||||
|
||||
if (!value.length) {
|
||||
popover.hide();
|
||||
}
|
||||
const trimmedValue = value.trim();
|
||||
setQuery(trimmedValue);
|
||||
setOpen(!!trimmedValue);
|
||||
}, 300),
|
||||
[popover, cachedQuery]
|
||||
[cachedQuery]
|
||||
);
|
||||
|
||||
const searchInputRef =
|
||||
popover.unstable_referenceRef as React.RefObject<HTMLInputElement>;
|
||||
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const handleEscapeList = React.useCallback(
|
||||
@@ -114,24 +99,29 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
|
||||
if (ev.key === "Enter") {
|
||||
if (searchResults) {
|
||||
popover.show();
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowDown" && !ev.shiftKey) {
|
||||
if (ev.currentTarget.value.length) {
|
||||
if (
|
||||
ev.currentTarget.value.length === ev.currentTarget.selectionStart
|
||||
) {
|
||||
popover.show();
|
||||
const atEnd =
|
||||
ev.currentTarget.value.length === ev.currentTarget.selectionStart;
|
||||
|
||||
if (atEnd) {
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
if (open || atEnd) {
|
||||
ev.preventDefault();
|
||||
firstSearchItem.current?.focus();
|
||||
}
|
||||
firstSearchItem.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowUp") {
|
||||
if (popover.visible) {
|
||||
popover.hide();
|
||||
if (open) {
|
||||
setOpen(false);
|
||||
if (!ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
@@ -147,22 +137,22 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
}
|
||||
|
||||
if (ev.key === "Escape") {
|
||||
if (popover.visible) {
|
||||
popover.hide();
|
||||
if (open) {
|
||||
setOpen(false);
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
},
|
||||
[popover, searchResults]
|
||||
[open, searchResults]
|
||||
);
|
||||
|
||||
const handleSearchItemClick = React.useCallback(() => {
|
||||
hide();
|
||||
setOpen(false);
|
||||
if (searchInputRef.current) {
|
||||
searchInputRef.current.value = "";
|
||||
focusRef.current = document.getElementById(bodyContentId);
|
||||
}
|
||||
}, [searchInputRef, hide]);
|
||||
}, [searchInputRef]);
|
||||
|
||||
useKeyDown("/", (ev) => {
|
||||
if (
|
||||
@@ -175,32 +165,39 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
// props assumes the disclosure is a button, but we want a type-ahead
|
||||
// so we take the aria props, and ref and ignore the event handlers
|
||||
<StyledInputSearch
|
||||
aria-controls={props["aria-controls"]}
|
||||
aria-expanded={props["aria-expanded"]}
|
||||
aria-haspopup={props["aria-haspopup"]}
|
||||
ref={props.ref}
|
||||
onChange={handleSearchInputChange}
|
||||
onFocus={handleSearchInputFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={className}
|
||||
/>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
<Popover
|
||||
{...popover}
|
||||
<Popover open={open} onOpenChange={setOpen} modal={true}>
|
||||
<PopoverAnchor>
|
||||
<StyledInputSearch
|
||||
role="combobox"
|
||||
aria-controls="search-results"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
ref={searchInputRef}
|
||||
onChange={handleSearchInputChange}
|
||||
onFocus={handleSearchInputFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={className}
|
||||
label={t("Search")}
|
||||
labelHidden
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
id="search-results"
|
||||
aria-label={t("Results")}
|
||||
unstable_autoFocusOnShow={false}
|
||||
unstable_finalFocusRef={focusRef}
|
||||
style={{ zIndex: depths.sidebar + 1 }}
|
||||
side="bottom"
|
||||
align="start"
|
||||
shrink
|
||||
onEscapeKeyDown={handleEscapeList}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onInteractOutside={(event) => {
|
||||
const target = event.target as Element | null;
|
||||
if (target === searchInputRef.current) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PaginatedList<SearchResult>
|
||||
role="listbox"
|
||||
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
|
||||
items={cachedSearchResults}
|
||||
fetch={performSearch}
|
||||
@@ -221,8 +218,8 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Popover>
|
||||
</>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,32 +5,42 @@ import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { s } from "@shared/styles";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Share from "~/models/Share";
|
||||
import { Avatar, GroupAvatar, AvatarSize } from "~/components/Avatar";
|
||||
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
|
||||
import { InputSelectPermission } from "~/components/InputSelectPermission";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
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 { Separator } from "../components";
|
||||
import { ListItem } from "../components/ListItem";
|
||||
import { Placeholder } from "../components/Placeholder";
|
||||
import { PublicAccess } from "./PublicAccess";
|
||||
|
||||
type Props = {
|
||||
/** Collection to which team members are supposed to be invited */
|
||||
collection: Collection;
|
||||
/** The existing share model, if any. */
|
||||
share: Share | null | undefined;
|
||||
/** 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[];
|
||||
/** Whether the popover is visible. */
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
export const AccessControlList = observer(
|
||||
({ collection, invitedInSession }: Props) => {
|
||||
({ collection, share, invitedInSession, visible }: Props) => {
|
||||
const { memberships, groupMemberships } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(collection);
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
@@ -246,6 +256,12 @@ export const AccessControlList = observer(
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{team.sharing && can.share && collection.sharing && visible && (
|
||||
<Sticky>
|
||||
{collection.members.length ? <Separator /> : null}
|
||||
<PublicAccess collection={collection} share={share} />
|
||||
</Sticky>
|
||||
)}
|
||||
</ScrollableContainer>
|
||||
);
|
||||
}
|
||||
@@ -255,3 +271,9 @@ const ScrollableContainer = styled(Scrollable)`
|
||||
padding: 12px 24px;
|
||||
margin: -12px -24px;
|
||||
`;
|
||||
|
||||
const Sticky = styled.div`
|
||||
background: ${s("menuBackground")};
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { observer } from "mobx-react";
|
||||
import { CopyIcon, GlobeIcon, InfoIcon, QuestionMarkIcon } from "outline-icons";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { s } from "@shared/styles";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
import Collection from "~/models/Collection";
|
||||
import Share from "~/models/Share";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import CopyToClipboard from "~/components/CopyToClipboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input, { NativeInput } from "~/components/Input";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import env from "~/env";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { ListItem } from "../components/ListItem";
|
||||
|
||||
type Props = {
|
||||
/** The collection to share. */
|
||||
collection: Collection;
|
||||
/** The existing share model, if any. */
|
||||
share: Share | null | undefined;
|
||||
};
|
||||
|
||||
function InnerPublicAccess({ collection, share }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [validationError, setValidationError] = useState("");
|
||||
const [urlId, setUrlId] = useState(share?.urlId);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const can = usePolicy(share);
|
||||
const collectionAbilities = usePolicy(collection);
|
||||
const canPublish = can.update && collectionAbilities.share;
|
||||
|
||||
useEffect(() => {
|
||||
setUrlId(share?.urlId);
|
||||
}, [share?.urlId]);
|
||||
|
||||
const handleIndexingChanged = useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
allowIndexing: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleShowLastModifiedChanged = useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
showLastUpdated: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleShowTOCChanged = useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
showTOC: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handlePublishedChange = useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
published: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleUrlChange = useMemo(
|
||||
() =>
|
||||
debounce(async (ev) => {
|
||||
if (!share) {
|
||||
return;
|
||||
}
|
||||
|
||||
const val = ev.target.value;
|
||||
setUrlId(val);
|
||||
if (val && !UrlHelper.SHARE_URL_SLUG_REGEX.test(val)) {
|
||||
setValidationError(
|
||||
t("Only lowercase letters, digits and dashes allowed")
|
||||
);
|
||||
} else {
|
||||
setValidationError("");
|
||||
if (share.urlId !== val) {
|
||||
try {
|
||||
await share.save({
|
||||
urlId: isEmpty(val) ? null : val,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.message.includes("must be unique")) {
|
||||
setValidationError(t("Sorry, this link has already been used"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 500),
|
||||
[t, share]
|
||||
);
|
||||
|
||||
const handleCopied = useCallback(() => {
|
||||
toast.success(t("Public link copied to clipboard"));
|
||||
}, [t]);
|
||||
|
||||
const copyButton = (
|
||||
<Tooltip content={t("Copy public link")} placement="top">
|
||||
<CopyToClipboard text={share?.url ?? ""} onCopy={handleCopied}>
|
||||
<NudeButton type="button" disabled={!share} style={{ marginRight: 3 }}>
|
||||
<CopyIcon color={theme.placeholder} size={18} />
|
||||
</NudeButton>
|
||||
</CopyToClipboard>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<ListItem
|
||||
title={t("Web")}
|
||||
subtitle={<>{t("Allow anyone with the link to access")}</>}
|
||||
image={
|
||||
<Squircle color={theme.text} size={AvatarSize.Medium}>
|
||||
<GlobeIcon color={theme.background} size={18} />
|
||||
</Squircle>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Publish to internet")}
|
||||
checked={share?.published ?? false}
|
||||
onChange={handlePublishedChange}
|
||||
disabled={!canPublish}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<ResizingHeightContainer>
|
||||
{!!share?.published && (
|
||||
<>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Search engine indexing")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Disable this setting to discourage search engines from indexing the page"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Search engine indexing")}
|
||||
checked={share?.allowIndexing ?? false}
|
||||
onChange={handleIndexingChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Show last modified")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Display the last modified timestamp on the shared page"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Show last modified")}
|
||||
checked={share?.showLastUpdated ?? false}
|
||||
onChange={handleShowLastModifiedChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Show table of contents")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Display the table of contents on documents by default"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Show table of contents")}
|
||||
checked={share?.showTOC ?? false}
|
||||
onChange={handleShowTOCChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ShareLinkInput
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
placeholder={share?.id}
|
||||
onChange={handleUrlChange}
|
||||
error={validationError}
|
||||
defaultValue={urlId}
|
||||
prefix={
|
||||
<DomainPrefix onClick={() => inputRef.current?.focus()}>
|
||||
{env.URL.replace(/https?:\/\//, "") + "/s/"}
|
||||
</DomainPrefix>
|
||||
}
|
||||
>
|
||||
{copyButton}
|
||||
</ShareLinkInput>
|
||||
<Flex align="flex-start" gap={4}>
|
||||
<StyledInfoIcon size={18} color={theme.textTertiary} />
|
||||
<Text type="tertiary" size="xsmall">
|
||||
{t(
|
||||
"All documents in this collection will be shared on the web, including any new documents added later"
|
||||
)}
|
||||
.
|
||||
</Text>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</ResizingHeightContainer>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
padding-bottom: 8px;
|
||||
`;
|
||||
|
||||
const DomainPrefix = styled.span`
|
||||
padding: 0 2px 0 8px;
|
||||
flex: 0 1 auto;
|
||||
cursor: text;
|
||||
color: ${s("placeholder")};
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const ShareLinkInput = styled(Input)`
|
||||
margin-top: 12px;
|
||||
min-width: 100px;
|
||||
flex: 1;
|
||||
|
||||
${NativeInput}:not(:first-child) {
|
||||
padding: 4px 8px 4px 0;
|
||||
flex: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledInfoIcon = styled(InfoIcon)`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export const PublicAccess = observer(InnerPublicAccess);
|
||||
@@ -39,7 +39,7 @@ type Props = {
|
||||
|
||||
function SharePopover({ collection, visible, onRequestClose }: Props) {
|
||||
const team = useCurrentTeam();
|
||||
const { groupMemberships, users, groups, memberships } = useStores();
|
||||
const { groupMemberships, users, groups, memberships, shares } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(collection);
|
||||
const [query, setQuery] = React.useState("");
|
||||
@@ -51,6 +51,7 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
|
||||
CollectionPermission.Read
|
||||
);
|
||||
|
||||
const share = shares.getByCollectionId(collection.id);
|
||||
const prevPendingIds = usePrevious(pendingIds);
|
||||
|
||||
const suggestionsRef = React.useRef<HTMLDivElement | null>(null);
|
||||
@@ -93,9 +94,10 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
void collection.share();
|
||||
setHasRendered(true);
|
||||
}
|
||||
}, [visible]);
|
||||
}, [collection, visible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (prevPendingIds && pendingIds.length > prevPendingIds.length) {
|
||||
@@ -363,7 +365,9 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
|
||||
<div style={{ display: picker ? "none" : "block" }}>
|
||||
<AccessControlList
|
||||
collection={collection}
|
||||
share={share}
|
||||
invitedInSession={invitedInSession}
|
||||
visible={visible}
|
||||
/>
|
||||
</div>
|
||||
</Wrapper>
|
||||
|
||||
@@ -77,6 +77,19 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleShowTOCChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
showTOC: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handlePublishedChange = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
@@ -125,8 +138,6 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
toast.success(t("Public link copied to clipboard"));
|
||||
}, [t]);
|
||||
|
||||
const documentTitle = sharedParent?.documentTitle;
|
||||
|
||||
const shareUrl = sharedParent?.url
|
||||
? `${sharedParent.url}${document.url}`
|
||||
: (share?.url ?? "");
|
||||
@@ -148,13 +159,24 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
subtitle={
|
||||
<>
|
||||
{sharedParent && !document.isDraft ? (
|
||||
<Trans>
|
||||
Anyone with the link can access because the parent document,{" "}
|
||||
<StyledLink to={`/doc/${sharedParent.documentId}`}>
|
||||
{{ documentTitle }}
|
||||
</StyledLink>
|
||||
, is shared
|
||||
</Trans>
|
||||
sharedParent.collectionId ? (
|
||||
<Trans>
|
||||
Anyone with the link can access because the containing
|
||||
collection,{" "}
|
||||
<StyledLink to={`/collection/${sharedParent.collectionId}`}>
|
||||
{sharedParent.sourceTitle}
|
||||
</StyledLink>
|
||||
, is shared
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Anyone with the link can access because the parent document,{" "}
|
||||
<StyledLink to={`/doc/${sharedParent.documentId}`}>
|
||||
{sharedParent.sourceTitle}
|
||||
</StyledLink>
|
||||
, is shared
|
||||
</Trans>
|
||||
)
|
||||
) : (
|
||||
t("Allow anyone with the link to access")
|
||||
)}
|
||||
@@ -180,60 +202,84 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
/>
|
||||
|
||||
<ResizingHeightContainer>
|
||||
{share?.published && (
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Search engine indexing")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Disable this setting to discourage search engines from indexing the page"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Search engine indexing")}
|
||||
checked={share?.allowIndexing ?? false}
|
||||
onChange={handleIndexingChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{share?.published && (
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Show last modified")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Display the last modified timestamp on the shared page"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Show last modified")}
|
||||
checked={share?.showLastUpdated ?? false}
|
||||
onChange={handleShowLastModifiedChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{share?.published && !sharedParent?.published && (
|
||||
<>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Search engine indexing")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Disable this setting to discourage search engines from indexing the page"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Search engine indexing")}
|
||||
checked={share?.allowIndexing ?? false}
|
||||
onChange={handleIndexingChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Show last modified")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Display the last modified timestamp on the shared page"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Show last modified")}
|
||||
checked={share?.showLastUpdated ?? false}
|
||||
onChange={handleShowLastModifiedChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Show table of contents")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Display the table of contents on documents by default"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Show table of contents")}
|
||||
checked={share?.showTOC ?? false}
|
||||
onChange={handleShowTOCChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{sharedParent?.published ? (
|
||||
|
||||
@@ -43,7 +43,7 @@ function SharePopover({ document, onRequestClose, visible }: Props) {
|
||||
const can = usePolicy(document);
|
||||
const { shares } = useStores();
|
||||
const share = shares.getByDocumentId(document.id);
|
||||
const sharedParent = shares.getByDocumentParents(document.id);
|
||||
const sharedParent = shares.getByDocumentParents(document);
|
||||
const [hasRendered, setHasRendered] = React.useState(visible);
|
||||
const { users, userMemberships, groups, groupMemberships } = useStores();
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { MoonIcon, SunIcon } from "outline-icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
|
||||
export const AppearanceAction = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
const { resolvedTheme } = ui;
|
||||
|
||||
return (
|
||||
<Action>
|
||||
<Tooltip
|
||||
content={
|
||||
resolvedTheme === "light" ? t("Switch to dark") : t("Switch to light")
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
icon={resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />}
|
||||
onClick={() =>
|
||||
ui.setTheme(resolvedTheme === "light" ? Theme.Dark : Theme.Light)
|
||||
}
|
||||
aria-label={
|
||||
resolvedTheme === "light"
|
||||
? t("Switch to dark")
|
||||
: t("Switch to light")
|
||||
}
|
||||
neutral
|
||||
borderOnHover
|
||||
/>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
);
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import { Inner } from "~/components/Button";
|
||||
import ButtonSmall from "~/components/ButtonSmall";
|
||||
import Fade from "~/components/Fade";
|
||||
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { Action, Permission } from "~/types";
|
||||
|
||||
export function PermissionAction({
|
||||
@@ -21,7 +20,6 @@ export function PermissionAction({
|
||||
onChange: (permission: CollectionPermission | DocumentPermission) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const context = useActionContext();
|
||||
|
||||
return (
|
||||
<Fade timing="150ms" key="invite">
|
||||
@@ -31,9 +29,7 @@ export function PermissionAction({
|
||||
onChange={onChange}
|
||||
value={permission}
|
||||
/>
|
||||
<ButtonSmall action={action} context={context}>
|
||||
{t("Add")}
|
||||
</ButtonSmall>
|
||||
<ButtonSmall action={action}>{t("Add")}</ButtonSmall>
|
||||
</Flex>
|
||||
</Fade>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ import HistoryNavigation from "./components/HistoryNavigation";
|
||||
import Section from "./components/Section";
|
||||
import SharedWithMe from "./components/SharedWithMe";
|
||||
import SidebarAction from "./components/SidebarAction";
|
||||
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
import SidebarLink from "./components/SidebarLink";
|
||||
import Starred from "./components/Starred";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
@@ -63,34 +63,36 @@ function AppSidebar() {
|
||||
<DragPlaceholder />
|
||||
|
||||
<TeamMenu>
|
||||
{(props: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{...props}
|
||||
title={team.name}
|
||||
image={
|
||||
<TeamLogo
|
||||
model={team}
|
||||
size={24}
|
||||
alt={t("Logo")}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
}
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
image={
|
||||
<TeamLogo
|
||||
model={team}
|
||||
size={24}
|
||||
alt={t("Logo")}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
content={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
>
|
||||
<Tooltip
|
||||
content={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
>
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</SidebarButton>
|
||||
)}
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
aria-label={
|
||||
ui.sidebarCollapsed
|
||||
? t("Expand sidebar")
|
||||
: t("Collapse sidebar")
|
||||
}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</SidebarButton>
|
||||
</TeamMenu>
|
||||
<Overflow>
|
||||
<Section>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { depths, s } from "@shared/styles";
|
||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
import ResizeBorder from "~/components/Sidebar/components/ResizeBorder";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { sidebarAppearDuration } from "~/styles/animations";
|
||||
|
||||
@@ -20,7 +19,6 @@ function Right({ children, border, className }: Props) {
|
||||
const theme = useTheme();
|
||||
const { ui } = useStores();
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
const isMobile = useMobile();
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
|
||||
@@ -100,13 +98,11 @@ function Right({ children, border, className }: Props) {
|
||||
<Sidebar {...animationProps} $border={border} className={className}>
|
||||
<Position style={style} column>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
{!isMobile && (
|
||||
<ResizeBorder
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={handleReset}
|
||||
dir="right"
|
||||
/>
|
||||
)}
|
||||
<ResizeBorder
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={handleReset}
|
||||
dir="right"
|
||||
/>
|
||||
</Position>
|
||||
</Sidebar>
|
||||
);
|
||||
|
||||
@@ -52,6 +52,9 @@ function SettingsSidebar() {
|
||||
>
|
||||
<Tooltip content={t("Toggle sidebar")} shortcut={`${metaDisplay}+.`}>
|
||||
<ToggleButton
|
||||
aria-label={
|
||||
ui.sidebarCollapsed ? t("Expand sidebar") : t("Collapse sidebar")
|
||||
}
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
onClick={() => {
|
||||
|
||||
@@ -3,8 +3,8 @@ import { SidebarIcon } from "outline-icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { hover } from "@shared/styles";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import Share from "~/models/Share";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import SearchPopover from "~/components/SearchPopover";
|
||||
@@ -12,28 +12,39 @@ import Tooltip from "~/components/Tooltip";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { homePath, sharedModelPath } from "~/utils/routeHelpers";
|
||||
import { AvatarSize } from "../Avatar";
|
||||
import { useTeamContext } from "../TeamContext";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Sidebar from "./Sidebar";
|
||||
import Section from "./components/Section";
|
||||
import DocumentLink from "./components/SharedDocumentLink";
|
||||
import { SharedCollectionLink } from "./components/SharedCollectionLink";
|
||||
import { SharedDocumentLink } from "./components/SharedDocumentLink";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type Props = {
|
||||
rootNode: NavigationNode;
|
||||
shareId: string;
|
||||
share: Share;
|
||||
};
|
||||
|
||||
function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
function SharedSidebar({ share }: Props) {
|
||||
const team = useTeamContext();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const { ui, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const teamAvailable = !!team?.name;
|
||||
const rootNode = share.tree;
|
||||
const shareId = share.urlId || share.id;
|
||||
|
||||
useEffect(() => {
|
||||
ui.tocVisible = share.showTOC;
|
||||
}, []);
|
||||
|
||||
if (!rootNode?.children.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledSidebar $hoverTransition={!teamAvailable}>
|
||||
@@ -44,9 +55,7 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
<TeamLogo model={team} size={AvatarSize.XLarge} alt={t("Logo")} />
|
||||
}
|
||||
onClick={() =>
|
||||
history.push(
|
||||
user ? homePath() : sharedDocumentPath(shareId, rootNode.url)
|
||||
)
|
||||
history.push(user ? homePath() : sharedModelPath(shareId))
|
||||
}
|
||||
>
|
||||
<ToggleSidebar />
|
||||
@@ -64,15 +73,19 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
)}
|
||||
</TopSection>
|
||||
<Section>
|
||||
<DocumentLink
|
||||
index={0}
|
||||
depth={0}
|
||||
shareId={shareId}
|
||||
node={rootNode}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
activeDocumentId={ui.activeDocumentId}
|
||||
activeDocument={documents.active}
|
||||
/>
|
||||
{share.collectionId ? (
|
||||
<SharedCollectionLink node={rootNode} shareId={shareId} />
|
||||
) : (
|
||||
<SharedDocumentLink
|
||||
index={0}
|
||||
depth={0}
|
||||
shareId={shareId}
|
||||
node={rootNode}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
activeDocumentId={ui.activeDocumentId}
|
||||
activeDocument={documents.active}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</ScrollContainer>
|
||||
</StyledSidebar>
|
||||
@@ -88,6 +101,9 @@ const ToggleSidebar = () => {
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
aria-label={
|
||||
ui.sidebarCollapsed ? t("Expand sidebar") : t("Collapse sidebar")
|
||||
}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
@@ -130,7 +146,8 @@ const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
|
||||
${({ $hoverTransition }) =>
|
||||
$hoverTransition &&
|
||||
`
|
||||
&: ${hover} {
|
||||
@media (hover: hover) {
|
||||
&:${hover} {
|
||||
${StyledSearchPopover} {
|
||||
width: 85%;
|
||||
}
|
||||
@@ -138,6 +155,7 @@ const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
|
||||
${ToggleWrapper} {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -7,7 +7,6 @@ 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";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -19,8 +18,9 @@ import NotificationIcon from "../Notifications/NotificationIcon";
|
||||
import NotificationsPopover from "../Notifications/NotificationsPopover";
|
||||
import { TooltipProvider } from "../TooltipContext";
|
||||
import ResizeBorder from "./components/ResizeBorder";
|
||||
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const ANIMATION_MS = 250;
|
||||
|
||||
@@ -35,15 +35,15 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const [isCollapsing, setCollapsing] = React.useState(false);
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const { ui } = useStores();
|
||||
const location = useLocation();
|
||||
const previousLocation = usePrevious(location);
|
||||
const { isMenuOpen } = useMenuContext();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const isMobile = useMobile();
|
||||
const width = ui.sidebarWidth;
|
||||
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
|
||||
const collapsed = ui.sidebarIsClosed;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
|
||||
@@ -231,29 +231,27 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
|
||||
{user && (
|
||||
<AccountMenu>
|
||||
{(props: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{...props}
|
||||
showMoreMenu
|
||||
title={user.name}
|
||||
position="bottom"
|
||||
image={
|
||||
<Avatar
|
||||
alt={user.name}
|
||||
model={user}
|
||||
size={24}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NotificationsPopover>
|
||||
<SidebarButton
|
||||
position="bottom"
|
||||
image={<NotificationIcon />}
|
||||
/>
|
||||
</NotificationsPopover>
|
||||
</SidebarButton>
|
||||
)}
|
||||
<SidebarButton
|
||||
showMoreMenu
|
||||
title={user.name}
|
||||
position="bottom"
|
||||
image={
|
||||
<Avatar
|
||||
alt={t("Avatar of {{ name }}", { name: user.name })}
|
||||
model={user}
|
||||
size={24}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NotificationsPopover>
|
||||
<SidebarButton
|
||||
position="bottom"
|
||||
image={<NotificationIcon />}
|
||||
aria-label={t("Notifications")}
|
||||
/>
|
||||
</NotificationsPopover>
|
||||
</SidebarButton>
|
||||
</AccountMenu>
|
||||
)}
|
||||
<ResizeBorder
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user