mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
328 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fd429baa9 | |||
| 4d3655bc6c | |||
| 7b98ce3514 | |||
| e6196ae79e | |||
| 89f3d47327 | |||
| 846fb122cd | |||
| 884f3c5896 | |||
| f2df25d115 | |||
| 92ba095124 | |||
| 1e847dc1cf | |||
| 3cd90f3e74 | |||
| 964d2b6bb3 | |||
| 56f9755cd9 | |||
| 057d8a7f3b | |||
| 7380f6d5ae | |||
| 08d89fb57a | |||
| b53c595e1b | |||
| f23a7bd685 | |||
| 60941dc285 | |||
| 33576b794a | |||
| a6f8872baa | |||
| fb56b00e81 | |||
| d6fcf44bf4 | |||
| afb5ccbf74 | |||
| d7dacd0cd3 | |||
| 5402731ec3 | |||
| bf6bd3f8d0 | |||
| 35fd1227e7 | |||
| 2fcf9149b5 | |||
| f063bef968 | |||
| 3e1287064b | |||
| d4b598570d | |||
| 04ac417bef | |||
| 1b11b9e31b | |||
| 6adf80d4e7 | |||
| 7bc8f1fc72 | |||
| 5fc68db5da | |||
| 1abe4964e8 | |||
| b0095e6fe1 | |||
| e7f3e500cd | |||
| ef76405bd6 | |||
| 389297a337 | |||
| 1a3b2dc307 | |||
| fb74494108 | |||
| 764dc84da9 | |||
| 3bf35affb1 | |||
| 9f6c90c86a | |||
| 0518cdc6d9 | |||
| 5df48b3204 | |||
| c32aee8372 | |||
| 3589980864 | |||
| d536fa9939 | |||
| 89694a561f | |||
| ac7668b5f7 | |||
| 76b12cbad5 | |||
| bb8fd93628 | |||
| 12f7e3d1da | |||
| ba612a557f | |||
| 608e1eeaa0 | |||
| 297536bfe5 | |||
| caafdb2fe7 | |||
| ea97963feb | |||
| 03869784be | |||
| 43ee487e91 | |||
| 955705dd64 | |||
| d8b7d14419 | |||
| d89ce1ea4d | |||
| 5bc5759f42 | |||
| 03c739032d | |||
| 0bec781695 | |||
| a357cbaf8d | |||
| 0b7253bb0c | |||
| 31cb9c865f | |||
| 787b893cd2 | |||
| faf97401e6 | |||
| 1ce0d3470e | |||
| bedad9d802 | |||
| e2c5daefac | |||
| 3feb104288 | |||
| 9e3b2c043c | |||
| d5bac6cbca | |||
| 00ee8729ec | |||
| 1305e3746b | |||
| 02731e73c5 | |||
| b3f9707ffb | |||
| c32ac1a265 | |||
| 6a74fdf6cf | |||
| a84008085f | |||
| 3212d37ca5 | |||
| 47837e315a | |||
| a652386329 | |||
| c5c323690b | |||
| 8bfd17c8d4 | |||
| e8646acd21 | |||
| ffbe4c1b80 | |||
| b63cd67c24 | |||
| 0d319d50b8 | |||
| a579ecd512 | |||
| 547b6c0ac9 | |||
| bf53ac4f4b | |||
| 09938c2649 | |||
| 8354a5bc37 | |||
| fec1a72780 | |||
| 4181aa0f3c | |||
| 5305c142a2 | |||
| 594898affc | |||
| 6402f0bfcf | |||
| 2f3247b500 | |||
| b6706efe6f | |||
| 63263eee82 | |||
| 9924fa6621 | |||
| ac319de1df | |||
| 2108ca29df | |||
| ea4de0dfb5 | |||
| 773c35ebc3 | |||
| 0ae4c7d6bd | |||
| 50faefbc45 | |||
| eb71a8f933 | |||
| a2f037531a | |||
| e70d4e60fd | |||
| 5e0b812083 | |||
| 1359f44814 | |||
| e1c90d3938 | |||
| e967641bb6 | |||
| 4d2a5ae748 | |||
| 56cae8a545 | |||
| e5e049a671 | |||
| 48438eea2d | |||
| 8e7dfdb6a0 | |||
| c8acf96790 | |||
| e7b7032284 | |||
| 16cd82a732 | |||
| 41a6f77998 | |||
| e2a6d828a9 | |||
| 2868ab2d00 | |||
| aa79bc85f1 | |||
| 5397907599 | |||
| f4fd9dae5f | |||
| bd06e03b1e | |||
| 5b2bb41ead | |||
| 2e759e4e81 | |||
| 5a89edbcb2 | |||
| 6eab716779 | |||
| 6de96b1d9d | |||
| 318a1120d4 | |||
| 86cb861ca7 | |||
| 2261514138 | |||
| 402695c2e3 | |||
| 9e810387c0 | |||
| b1ddf417be | |||
| 0014bcf22d | |||
| 606a4e0772 | |||
| 4807c60042 | |||
| dd02bd9c03 | |||
| 1639c657c8 | |||
| 25b961b3b8 | |||
| 144ba0ced9 | |||
| d340f8977d | |||
| 7145f7ef51 | |||
| 43bdb97639 | |||
| 136ee0ad1d | |||
| 517f2634e3 | |||
| 42cc991317 | |||
| e50e0bba53 | |||
| d0bb6c6a41 | |||
| 6aec085942 | |||
| 65d3c8309e | |||
| 5c7c9ceeb1 | |||
| 3f11b014c5 | |||
| 76862b626b | |||
| 8833e578f1 | |||
| 8c661345f0 | |||
| 89537aabc3 | |||
| 6672536cde | |||
| 34d4209dd5 | |||
| 27befbf3f7 | |||
| 5aa7b42f8b | |||
| 67b1fe5514 | |||
| fea50feb0d | |||
| 1b1b95d673 | |||
| 1137d45f92 | |||
| 091ef340f4 | |||
| 432fa970e5 | |||
| 59734f2bf7 | |||
| 4fa3270f4e | |||
| 3582a6a0a2 | |||
| 8c2a47db9d | |||
| 266a2f4485 | |||
| c20eac0b03 | |||
| 6b4feb51e0 | |||
| b79f86d347 | |||
| 411ab6b785 | |||
| 924ab156f3 | |||
| 7e17e82ac8 | |||
| ef22a5dc52 | |||
| 56a526e930 | |||
| a32857c715 | |||
| 882408bc0e | |||
| b80ee89588 | |||
| d81db7e4f6 | |||
| 401d1ba871 | |||
| 99e3a305d3 | |||
| 5e9151f02a | |||
| 9e218bd4f3 | |||
| d43f1b529d | |||
| 0856f5f6ae | |||
| ac068c0c07 | |||
| 9602d09964 | |||
| c22ed0c82e | |||
| 6159973df9 | |||
| 5c839998c1 | |||
| 80ef0a38d6 | |||
| 7270e65f0c | |||
| 76845a3308 | |||
| 5c8bcc11b4 | |||
| d8bfb0fe5d | |||
| bb555de1ba | |||
| 127115272a | |||
| d1de5871de | |||
| ec0564eb32 | |||
| 3eb947e9a5 | |||
| a724a21c21 | |||
| c4aad4d4bf | |||
| 795fe37bd6 | |||
| 262590e507 | |||
| 5f788012db | |||
| 2358c3d13d | |||
| a03b95221a | |||
| 3223341062 | |||
| ce645b158b | |||
| 74860ed961 | |||
| c376dc1011 | |||
| a956f54b5a | |||
| 1c99e8519a | |||
| 6079b71d3c | |||
| 749c8dc335 | |||
| 57d1643d77 | |||
| 1df7a42868 | |||
| 02cced078f | |||
| d7c331532d | |||
| 0261e0712c | |||
| f7111991dc | |||
| 10a190cd80 | |||
| 3721ea2333 | |||
| 1048ea8771 | |||
| a3cfef09f3 | |||
| ef71a54120 | |||
| 1c7bb65c7a | |||
| 093ee74a90 | |||
| 0054b7152e | |||
| 8b4b2ca741 | |||
| 911bb1f492 | |||
| c9f0c86719 | |||
| d0fe6ad93f | |||
| 4e53029377 | |||
| 7abb4f9ad6 | |||
| dec03b9d84 | |||
| d591158c4d | |||
| fa03f9c08d | |||
| b7055ef853 | |||
| 864ddbd438 | |||
| 30a4303a8e | |||
| 7725f29dc7 | |||
| 08825c7d97 | |||
| 448258746c | |||
| b002d51ace | |||
| 3e6a22e369 | |||
| 412f3ed9a4 | |||
| 78ad1b867a | |||
| c643f62d96 | |||
| 79ff9309fd | |||
| 9256c59e60 | |||
| 1d90f98a29 | |||
| 10ec8a59b4 | |||
| dfbd89ad53 | |||
| da9a8af543 | |||
| aada5c20cd | |||
| 8f86eadc5d | |||
| 53c6c5599a | |||
| e3ba87dcb0 | |||
| 3c5753621c | |||
| 3366fb46cd | |||
| 89bf5373aa | |||
| e6b0e434ea | |||
| 225f0dbf11 | |||
| 418d3305b2 | |||
| 5c07694f6b | |||
| 74722b80f2 | |||
| 4354e1055e | |||
| c3a8858c6b | |||
| 546022e5d6 | |||
| 33e532847e | |||
| c9d62420c8 | |||
| cc2a1865c5 | |||
| 1ec87da8a9 | |||
| d820b2a617 | |||
| 5e7ea165b4 | |||
| c68d55f49b | |||
| 7e349c9db1 | |||
| 13b067fb3f | |||
| 41c346d105 | |||
| 4788ab3bd6 | |||
| 5f00b4f744 | |||
| fd600ced09 | |||
| 0047384d70 | |||
| 8bff566c30 | |||
| fce90df3aa | |||
| 28ae1af2a3 | |||
| 9f0534d544 | |||
| 4edfab20fe | |||
| c38e045df2 | |||
| b7bfc4bb1a | |||
| a71ad43c31 | |||
| 199fa5844e | |||
| b466f1c8bb | |||
| 503e4e1f71 | |||
| 2bc52be2cf | |||
| 3ba730943c | |||
| 6828718cf0 | |||
| 9749a53558 | |||
| f4e4992508 | |||
| cf2f0b1b5c | |||
| 4a4ea0e531 | |||
| 8830773acb | |||
| f5d2c7890a | |||
| 434812dbe3 | |||
| ed5671209a | |||
| c32cec7bff |
+12
-5
@@ -82,6 +82,7 @@ jobs:
|
||||
command: yarn test:shared
|
||||
test-server:
|
||||
<<: *defaults
|
||||
parallelism: 3
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
@@ -91,7 +92,9 @@ jobs:
|
||||
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test:server --forceExit
|
||||
command: |
|
||||
TESTFILES=$(circleci tests glob "server/**/*.test.ts" | circleci tests split)
|
||||
yarn test --maxWorkers=2 $TESTFILES
|
||||
bundle-size:
|
||||
<<: *defaults
|
||||
environment:
|
||||
@@ -142,7 +145,12 @@ jobs:
|
||||
command: docker push $BASE_IMAGE_NAME:latest
|
||||
- run:
|
||||
name: Build and push Docker image
|
||||
command: docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
|
||||
command: |
|
||||
if [[ "$CIRCLE_TAG" == *"-"* ]]; then
|
||||
docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
|
||||
else
|
||||
docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
|
||||
fi
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
@@ -166,9 +174,8 @@ workflows:
|
||||
- build
|
||||
- bundle-size:
|
||||
requires:
|
||||
- test-app
|
||||
- test-shared
|
||||
- test-server
|
||||
- build
|
||||
- types
|
||||
|
||||
build-docker:
|
||||
jobs:
|
||||
|
||||
@@ -13,5 +13,4 @@ app.json
|
||||
crowdin.yml
|
||||
build
|
||||
docker-compose.yml
|
||||
fakes3
|
||||
node_modules
|
||||
|
||||
+14
-4
@@ -30,7 +30,7 @@ REDIS_URL=redis://localhost:6379
|
||||
|
||||
# URL should point to the fully qualified, publicly accessible URL. If using a
|
||||
# proxy the port in URL and PORT may be different.
|
||||
URL=http://localhost:3000
|
||||
URL=https://app.outline.dev:3000
|
||||
PORT=3000
|
||||
|
||||
# See [documentation](docs/SERVICES.md) on running a separate collaboration
|
||||
@@ -51,10 +51,20 @@ AWS_REGION=xx-xxxx-x
|
||||
AWS_S3_ACCELERATE_URL=
|
||||
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
|
||||
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
|
||||
AWS_S3_UPLOAD_MAX_SIZE=26214400
|
||||
AWS_S3_FORCE_PATH_STYLE=true
|
||||
AWS_S3_ACL=private
|
||||
|
||||
# Specify what storage system to use. Possible value is one of "s3" or "local".
|
||||
# For "local", the avatar images and document attachments will be saved on local disk.
|
||||
FILE_STORAGE=local
|
||||
|
||||
# If "local" is configured for FILE_STORAGE above, then this sets the parent directory under
|
||||
# which all attachments/images go. Make sure that the process has permissions to create
|
||||
# this path and also to write files to it.
|
||||
FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
|
||||
|
||||
# Maximum allowed size for the uploaded attachment.
|
||||
FILE_STORAGE_UPLOAD_MAX_SIZE=26214400
|
||||
|
||||
# –––––––––––––– AUTHENTICATION ––––––––––––––
|
||||
|
||||
@@ -183,5 +193,5 @@ RATE_LIMITER_REQUESTS=1000
|
||||
RATE_LIMITER_DURATION_WINDOW=60
|
||||
|
||||
# Iframely API config
|
||||
IFRAMELY_URL=
|
||||
IFRAMELY_API_KEY=
|
||||
# IFRAMELY_URL=
|
||||
# IFRAMELY_API_KEY=
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"eslint-plugin-import",
|
||||
"eslint-plugin-node",
|
||||
"eslint-plugin-react",
|
||||
"import"
|
||||
"eslint-plugin-lodash"
|
||||
],
|
||||
"rules": {
|
||||
"eqeqeq": 2,
|
||||
@@ -55,6 +55,7 @@
|
||||
],
|
||||
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
|
||||
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
|
||||
"lodash/import-scope": ["warn", "method"],
|
||||
"import/no-named-as-default": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
"import/newline-after-import": 2,
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ node_modules/*
|
||||
npm-debug.log
|
||||
stats.json
|
||||
.DS_Store
|
||||
fakes3/*
|
||||
data/*
|
||||
.idea
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
+5
-6
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"workerIdleMemoryLimit": "0.75",
|
||||
"maxWorkers": "50%",
|
||||
"projects": [
|
||||
{
|
||||
"displayName": "server",
|
||||
@@ -8,13 +9,11 @@
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
},
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/console.js",
|
||||
"<rootDir>/server/test/env.ts"
|
||||
],
|
||||
"setupFiles": ["<rootDir>/__mocks__/console.js", "<rootDir>/server/test/env.ts"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
|
||||
"testEnvironment": "node",
|
||||
"runner": "@getoutline/jest-runner-serial"
|
||||
"globalSetup": "<rootDir>/server/test/globalSetup.js",
|
||||
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
{
|
||||
"displayName": "app",
|
||||
|
||||
+10
-1
@@ -24,7 +24,16 @@ COPY --from=base $APP_PATH/package.json ./package.json
|
||||
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodejs -u 1001 && \
|
||||
chown -R nodejs:nodejs $APP_PATH/build
|
||||
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" && \
|
||||
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
|
||||
|
||||
VOLUME /var/lib/outline/data
|
||||
|
||||
USER nodejs
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.64.0
|
||||
Licensed Work: Outline 0.71.0
|
||||
The Licensed Work is (c) 2020 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: 2026-05-23
|
||||
Change Date: 2027-08-18
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
up:
|
||||
docker-compose up -d redis postgres s3
|
||||
docker-compose up -d redis postgres
|
||||
yarn install-local-ssl
|
||||
yarn install --pure-lockfile
|
||||
yarn dev:watch
|
||||
|
||||
@@ -7,17 +8,17 @@ build:
|
||||
docker-compose build --pull outline
|
||||
|
||||
test:
|
||||
docker-compose up -d redis postgres s3
|
||||
docker-compose up -d redis postgres
|
||||
yarn sequelize db:drop --env=test
|
||||
yarn sequelize db:create --env=test
|
||||
yarn sequelize db:migrate --env=test
|
||||
NODE_ENV=test yarn sequelize db:migrate --env=test
|
||||
yarn test
|
||||
|
||||
watch:
|
||||
docker-compose up -d redis postgres s3
|
||||
docker-compose up -d redis postgres
|
||||
yarn sequelize db:drop --env=test
|
||||
yarn sequelize db:create --env=test
|
||||
yarn sequelize db:migrate --env=test
|
||||
NODE_ENV=test yarn sequelize db:migrate --env=test
|
||||
yarn test:watch
|
||||
|
||||
destroy:
|
||||
|
||||
@@ -96,6 +96,10 @@ Or to run migrations on test database:
|
||||
yarn sequelize db:migrate --env test
|
||||
```
|
||||
|
||||
## License
|
||||
# Activity
|
||||
|
||||

|
||||
|
||||
# License
|
||||
|
||||
Outline is [BSL 1.1 licensed](LICENSE).
|
||||
|
||||
@@ -128,11 +128,6 @@
|
||||
"description": "Live web link to your bucket. For CNAMEs, https://yourbucket.example.com",
|
||||
"required": false
|
||||
},
|
||||
"AWS_S3_UPLOAD_MAX_SIZE": {
|
||||
"description": "Maximum file upload size in bytes",
|
||||
"value": "26214400",
|
||||
"required": false
|
||||
},
|
||||
"AWS_S3_FORCE_PATH_STYLE": {
|
||||
"description": "Use path-style URL's for connecting to S3 instead of subdomain. This is useful for S3-compatible storage.",
|
||||
"value": "true",
|
||||
@@ -148,6 +143,11 @@
|
||||
"description": "S3 canned ACL for document attachments",
|
||||
"required": false
|
||||
},
|
||||
"FILE_STORAGE_UPLOAD_MAX_SIZE": {
|
||||
"description": "Maximum file upload size in bytes",
|
||||
"value": "26214400",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_HOST": {
|
||||
"description": "smtp.example.com (optional)",
|
||||
"required": false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ToolsIcon, TrashIcon, UserIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import { toast } from "sonner";
|
||||
import { createAction } from "~/actions";
|
||||
import { DeveloperSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
@@ -15,7 +15,7 @@ export const clearIndexedDB = createAction({
|
||||
section: DeveloperSection,
|
||||
perform: async ({ t }) => {
|
||||
await deleteAllDatabases();
|
||||
stores.toasts.showToast(t("IndexedDB cache deleted"));
|
||||
toast.message(t("IndexedDB cache deleted"));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -29,20 +29,31 @@ export const createTestUsers = createAction({
|
||||
|
||||
try {
|
||||
await client.post("/developer.create_test_users", { count });
|
||||
stores.toasts.showToast(`${count} test users created`);
|
||||
toast.message(`${count} test users created`);
|
||||
} catch (err) {
|
||||
stores.toasts.showToast(err.message, { type: "error" });
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const createToast = createAction({
|
||||
name: "Create toast",
|
||||
section: DeveloperSection,
|
||||
visible: () => env.ENVIRONMENT === "development",
|
||||
perform: async () => {
|
||||
toast.message("Hello world", {
|
||||
duration: 30000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const toggleDebugLogging = createAction({
|
||||
name: ({ t }) => t("Toggle debug logging"),
|
||||
icon: <ToolsIcon />,
|
||||
section: DeveloperSection,
|
||||
perform: async ({ t }) => {
|
||||
Logger.debugLoggingEnabled = !Logger.debugLoggingEnabled;
|
||||
stores.toasts.showToast(
|
||||
toast.message(
|
||||
Logger.debugLoggingEnabled
|
||||
? t("Debug logging enabled")
|
||||
: t("Debug logging disabled")
|
||||
@@ -56,7 +67,7 @@ export const developer = createAction({
|
||||
icon: <ToolsIcon />,
|
||||
iconInContextMenu: false,
|
||||
section: DeveloperSection,
|
||||
children: [clearIndexedDB, toggleDebugLogging, createTestUsers],
|
||||
children: [clearIndexedDB, toggleDebugLogging, createToast, createTestUsers],
|
||||
});
|
||||
|
||||
export const rootDeveloperActions = [developer];
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
CommentIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ExportContentType, TeamPreference } from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
@@ -32,6 +33,7 @@ import DocumentMove from "~/scenes/DocumentMove";
|
||||
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
|
||||
import DocumentPublish from "~/scenes/DocumentPublish";
|
||||
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
|
||||
import DuplicateDialog from "~/components/DuplicateDialog";
|
||||
import { createAction } from "~/actions";
|
||||
import { DocumentSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
homePath,
|
||||
newDocumentPath,
|
||||
searchPath,
|
||||
documentPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
export const openDocument = createAction({
|
||||
@@ -86,6 +89,48 @@ export const createDocument = createAction({
|
||||
}),
|
||||
});
|
||||
|
||||
export const createDocumentFromTemplate = createAction({
|
||||
name: ({ t }) => t("New from template"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) =>
|
||||
!!currentTeamId &&
|
||||
!!activeDocumentId &&
|
||||
!!stores.documents.get(activeDocumentId)?.template &&
|
||||
stores.policies.abilities(currentTeamId).createDocument,
|
||||
perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) =>
|
||||
history.push(
|
||||
newDocumentPath(activeCollectionId, { templateId: activeDocumentId }),
|
||||
{
|
||||
starred: inStarredSection,
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
export const createNestedDocument = createAction({
|
||||
name: ({ t }) => t("New nested document"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) =>
|
||||
!!currentTeamId &&
|
||||
!!activeDocumentId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument &&
|
||||
stores.policies.abilities(activeDocumentId).createChildDocument,
|
||||
perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) =>
|
||||
history.push(
|
||||
newDocumentPath(activeCollectionId, {
|
||||
parentDocumentId: activeDocumentId,
|
||||
}),
|
||||
{
|
||||
starred: inStarredSection,
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
export const starDocument = createAction({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star document",
|
||||
@@ -165,9 +210,11 @@ export const publishDocument = createAction({
|
||||
await document.save(undefined, {
|
||||
publish: true,
|
||||
});
|
||||
stores.toasts.showToast(t("Document published"), {
|
||||
type: "success",
|
||||
});
|
||||
toast.success(
|
||||
t("Published {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})
|
||||
);
|
||||
} else if (document) {
|
||||
stores.dialogs.openModal({
|
||||
title: t("Publish document"),
|
||||
@@ -195,12 +242,21 @@ export const unpublishDocument = createAction({
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
await document?.unpublish();
|
||||
try {
|
||||
await document.unpublish();
|
||||
|
||||
stores.toasts.showToast(t("Document unpublished"), {
|
||||
type: "success",
|
||||
});
|
||||
toast.success(
|
||||
t("Unpublished {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -230,9 +286,7 @@ export const subscribeDocument = createAction({
|
||||
|
||||
await document?.subscribe();
|
||||
|
||||
stores.toasts.showToast(t("Subscribed to document notifications"), {
|
||||
type: "success",
|
||||
});
|
||||
toast.success(t("Subscribed to document notifications"));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -262,9 +316,7 @@ export const unsubscribeDocument = createAction({
|
||||
|
||||
await document?.unsubscribe(currentUserId);
|
||||
|
||||
stores.toasts.showToast(t("Unsubscribed from document notifications"), {
|
||||
type: "success",
|
||||
});
|
||||
toast.success(t("Unsubscribed from document notifications"));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -303,15 +355,11 @@ export const downloadDocumentAsPDF = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
const id = stores.toasts.showToast(`${t("Exporting")}…`, {
|
||||
type: "loading",
|
||||
timeout: 30 * 1000,
|
||||
});
|
||||
|
||||
const id = toast.loading(`${t("Exporting")}…`);
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document
|
||||
?.download(ExportContentType.Pdf)
|
||||
.finally(() => id && stores.toasts.hideToast(id));
|
||||
.finally(() => id && toast.dismiss(id));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -364,11 +412,19 @@ export const duplicateDocument = createAction({
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
invariant(document, "Document must exist");
|
||||
const duped = await document.duplicate();
|
||||
// when duplicating, go straight to the duplicated document content
|
||||
history.push(duped.url);
|
||||
stores.toasts.showToast(t("Document duplicated"), {
|
||||
type: "success",
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Copy document"),
|
||||
isCentered: true,
|
||||
content: (
|
||||
<DuplicateDialog
|
||||
document={document}
|
||||
onSubmit={(response) => {
|
||||
stores.dialogs.closeAllModals();
|
||||
history.push(documentPath(response[0]));
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -414,12 +470,10 @@ export const pinDocumentToCollection = createAction({
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
if (!collection || !location.pathname.startsWith(collection?.url)) {
|
||||
stores.toasts.showToast(t("Pinned to collection"));
|
||||
toast.success(t("Pinned to collection"));
|
||||
}
|
||||
} catch (err) {
|
||||
stores.toasts.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -456,12 +510,10 @@ export const pinDocumentToHome = createAction({
|
||||
await document?.pin();
|
||||
|
||||
if (location.pathname !== homePath()) {
|
||||
stores.toasts.showToast(t("Pinned to team home"));
|
||||
toast.success(t("Pinned to home"));
|
||||
}
|
||||
} catch (err) {
|
||||
stores.toasts.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -504,7 +556,7 @@ export const importDocument = createAction({
|
||||
return false;
|
||||
},
|
||||
perform: ({ activeCollectionId, activeDocumentId, stores }) => {
|
||||
const { documents, toasts } = stores;
|
||||
const { documents } = stores;
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = documents.importFileTypes.join(", ");
|
||||
@@ -524,9 +576,7 @@ export const importDocument = createAction({
|
||||
);
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toasts.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
toast.error(err.message);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -647,9 +697,7 @@ export const archiveDocument = createAction({
|
||||
}
|
||||
|
||||
await document.archive();
|
||||
stores.toasts.showToast(t("Document archived"), {
|
||||
type: "success",
|
||||
});
|
||||
toast.success(t("Document archived"));
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -775,7 +823,16 @@ export const openDocumentInsights = createAction({
|
||||
icon: <LightBulbIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
return !!activeDocumentId && can.read;
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
!!activeDocumentId &&
|
||||
can.read &&
|
||||
!document?.isTemplate &&
|
||||
!document?.isDeleted
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
|
||||
@@ -6,14 +6,15 @@ import {
|
||||
EditIcon,
|
||||
OpenIcon,
|
||||
SettingsIcon,
|
||||
ShapesIcon,
|
||||
KeyboardIcon,
|
||||
EmailIcon,
|
||||
LogoutIcon,
|
||||
ProfileIcon,
|
||||
BrowserIcon,
|
||||
ShapesIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { isMac } from "@shared/utils/browser";
|
||||
import {
|
||||
developersUrl,
|
||||
changelogUrl,
|
||||
@@ -26,14 +27,12 @@ import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||
import { createAction } from "~/actions";
|
||||
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { isMac } from "~/utils/browser";
|
||||
import history from "~/utils/history";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import {
|
||||
homePath,
|
||||
searchPath,
|
||||
draftsPath,
|
||||
templatesPath,
|
||||
archivePath,
|
||||
trashPath,
|
||||
settingsPath,
|
||||
@@ -67,15 +66,6 @@ export const navigateToDrafts = createAction({
|
||||
visible: ({ location }) => location.pathname !== draftsPath(),
|
||||
});
|
||||
|
||||
export const navigateToTemplates = createAction({
|
||||
name: ({ t }) => t("Templates"),
|
||||
analyticsName: "Navigate to templates",
|
||||
section: NavigationSection,
|
||||
icon: <ShapesIcon />,
|
||||
perform: () => history.push(templatesPath()),
|
||||
visible: ({ location }) => location.pathname !== templatesPath(),
|
||||
});
|
||||
|
||||
export const navigateToArchive = createAction({
|
||||
name: ({ t }) => t("Archive"),
|
||||
analyticsName: "Navigate to archive",
|
||||
@@ -103,7 +93,7 @@ export const navigateToSettings = createAction({
|
||||
icon: <SettingsIcon />,
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").update,
|
||||
perform: () => history.push(settingsPath("details")),
|
||||
perform: () => history.push(settingsPath()),
|
||||
});
|
||||
|
||||
export const navigateToProfileSettings = createAction({
|
||||
@@ -115,6 +105,15 @@ export const navigateToProfileSettings = createAction({
|
||||
perform: () => history.push(settingsPath()),
|
||||
});
|
||||
|
||||
export const navigateToTemplateSettings = createAction({
|
||||
name: ({ t }) => t("Templates"),
|
||||
analyticsName: "Navigate to template settings",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <ShapesIcon />,
|
||||
perform: () => history.push(settingsPath("templates")),
|
||||
});
|
||||
|
||||
export const navigateToNotificationSettings = createAction({
|
||||
name: ({ t }) => t("Notifications"),
|
||||
analyticsName: "Navigate to notification settings",
|
||||
@@ -216,7 +215,6 @@ export const logout = createAction({
|
||||
export const rootNavigationActions = [
|
||||
navigateToHome,
|
||||
navigateToDrafts,
|
||||
navigateToTemplates,
|
||||
navigateToArchive,
|
||||
navigateToTrash,
|
||||
downloadApp,
|
||||
|
||||
@@ -2,6 +2,7 @@ import copy from "copy-to-clipboard";
|
||||
import { LinkIcon, RestoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { matchPath } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import stores from "~/stores";
|
||||
import { createAction } from "~/actions";
|
||||
import { RevisionSection } from "~/actions/sections";
|
||||
@@ -68,9 +69,7 @@ export const copyLinkToRevision = createAction({
|
||||
copy(url, {
|
||||
format: "text/plain",
|
||||
onCopy: () => {
|
||||
stores.toasts.showToast(t("Link copied"), {
|
||||
type: "info",
|
||||
});
|
||||
toast.message(t("Link copied"));
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { flattenDeep } from "lodash";
|
||||
import flattenDeep from "lodash/flattenDeep";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Optional } from "utility-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
@@ -77,9 +78,7 @@ export function actionToMenuItem(
|
||||
try {
|
||||
action.perform?.(context);
|
||||
} catch (err) {
|
||||
context.stores.toasts.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
selected: action.selected?.(context),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable prefer-rest-params */
|
||||
/* global ga */
|
||||
import { escape } from "lodash";
|
||||
import escape from "lodash/escape";
|
||||
import * as React from "react";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import env from "~/env";
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
fill?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function SlackLogo({ size = 34, fill = "#FFF", className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 34 34"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<g stroke="none" strokeWidth="1" fillRule="evenodd">
|
||||
<g transform="translate(0.000000, 17.822581)">
|
||||
<path d="M7.23870968,3.61935484 C7.23870968,5.56612903 5.6483871,7.15645161 3.7016129,7.15645161 C1.75483871,7.15645161 0.164516129,5.56612903 0.164516129,3.61935484 C0.164516129,1.67258065 1.75483871,0.0822580645 3.7016129,0.0822580645 L7.23870968,0.0822580645 L7.23870968,3.61935484 Z" />
|
||||
<path d="M9.02096774,3.61935484 C9.02096774,1.67258065 10.6112903,0.0822580645 12.5580645,0.0822580645 C14.5048387,0.0822580645 16.0951613,1.67258065 16.0951613,3.61935484 L16.0951613,12.4758065 C16.0951613,14.4225806 14.5048387,16.0129032 12.5580645,16.0129032 C10.6112903,16.0129032 9.02096774,14.4225806 9.02096774,12.4758065 C9.02096774,12.4758065 9.02096774,3.61935484 9.02096774,3.61935484 Z" />
|
||||
</g>
|
||||
<g>
|
||||
<path d="M12.5580645,7.23870968 C10.6112903,7.23870968 9.02096774,5.6483871 9.02096774,3.7016129 C9.02096774,1.75483871 10.6112903,0.164516129 12.5580645,0.164516129 C14.5048387,0.164516129 16.0951613,1.75483871 16.0951613,3.7016129 L16.0951613,7.23870968 L12.5580645,7.23870968 Z" />
|
||||
<path d="M12.5580645,9.02096774 C14.5048387,9.02096774 16.0951613,10.6112903 16.0951613,12.5580645 C16.0951613,14.5048387 14.5048387,16.0951613 12.5580645,16.0951613 L3.7016129,16.0951613 C1.75483871,16.0951613 0.164516129,14.5048387 0.164516129,12.5580645 C0.164516129,10.6112903 1.75483871,9.02096774 3.7016129,9.02096774 C3.7016129,9.02096774 12.5580645,9.02096774 12.5580645,9.02096774 Z" />
|
||||
</g>
|
||||
<g transform="translate(17.822581, 0.000000)">
|
||||
<path d="M8.93870968,12.5580645 C8.93870968,10.6112903 10.5290323,9.02096774 12.4758065,9.02096774 C14.4225806,9.02096774 16.0129032,10.6112903 16.0129032,12.5580645 C16.0129032,14.5048387 14.4225806,16.0951613 12.4758065,16.0951613 L8.93870968,16.0951613 L8.93870968,12.5580645 Z" />
|
||||
<path d="M7.15645161,12.5580645 C7.15645161,14.5048387 5.56612903,16.0951613 3.61935484,16.0951613 C1.67258065,16.0951613 0.0822580645,14.5048387 0.0822580645,12.5580645 L0.0822580645,3.7016129 C0.0822580645,1.75483871 1.67258065,0.164516129 3.61935484,0.164516129 C5.56612903,0.164516129 7.15645161,1.75483871 7.15645161,3.7016129 L7.15645161,12.5580645 Z" />
|
||||
</g>
|
||||
<g transform="translate(17.822581, 17.822581)">
|
||||
<path d="M3.61935484,8.93870968 C5.56612903,8.93870968 7.15645161,10.5290323 7.15645161,12.4758065 C7.15645161,14.4225806 5.56612903,16.0129032 3.61935484,16.0129032 C1.67258065,16.0129032 0.0822580645,14.4225806 0.0822580645,12.4758065 L0.0822580645,8.93870968 L3.61935484,8.93870968 Z" />
|
||||
<path d="M3.61935484,7.15645161 C1.67258065,7.15645161 0.0822580645,5.56612903 0.0822580645,3.61935484 C0.0822580645,1.67258065 1.67258065,0.0822580645 3.61935484,0.0822580645 L12.4758065,0.0822580645 C14.4225806,0.0822580645 16.0129032,1.67258065 16.0129032,3.61935484 C16.0129032,5.56612903 14.4225806,7.15645161 12.4758065,7.15645161 L3.61935484,7.15645161 Z" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default SlackLogo;
|
||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { changeLanguage } from "~/utils/language";
|
||||
import LoadingIndicator from "./LoadingIndicator";
|
||||
@@ -13,10 +14,11 @@ type Props = {
|
||||
const Authenticated = ({ children }: Props) => {
|
||||
const { auth } = useStores();
|
||||
const { i18n } = useTranslation();
|
||||
const language = auth.user?.language;
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const language = user?.language;
|
||||
|
||||
// Watching for language changes here as this is the earliest point we have
|
||||
// the user available and means we can start loading translations faster
|
||||
// Watching for language changes here as this is the earliest point we might have the user
|
||||
// available and means we can start loading translations faster
|
||||
React.useEffect(() => {
|
||||
void changeLanguage(language, i18n);
|
||||
}, [i18n, language]);
|
||||
|
||||
@@ -12,6 +12,7 @@ import Sidebar from "~/components/Sidebar";
|
||||
import SidebarRight from "~/components/Sidebar/Right";
|
||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||
import type { Editor as TEditor } from "~/editor";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
@@ -45,7 +46,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
const { ui, auth } = useStores();
|
||||
const location = useLocation();
|
||||
const can = usePolicy(ui.activeCollectionId);
|
||||
const { user, team } = auth;
|
||||
const team = useCurrentTeam();
|
||||
const documentContext = useLocalStore<DocumentContextValue>(() => ({
|
||||
editor: null,
|
||||
setEditor: (editor: TEditor) => {
|
||||
@@ -76,16 +77,14 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
return <ErrorSuspended />;
|
||||
}
|
||||
|
||||
const showSidebar = auth.authenticated && user && team;
|
||||
|
||||
const sidebar = showSidebar ? (
|
||||
const sidebar = (
|
||||
<Fade>
|
||||
<Switch>
|
||||
<Route path={settingsPath()} component={SettingsSidebar} />
|
||||
<Route component={Sidebar} />
|
||||
</Switch>
|
||||
</Fade>
|
||||
) : undefined;
|
||||
);
|
||||
|
||||
const showHistory = !!matchPath(location.pathname, {
|
||||
path: matchDocumentHistory,
|
||||
@@ -98,7 +97,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
!showHistory &&
|
||||
ui.activeDocumentId &&
|
||||
ui.commentsExpanded.includes(ui.activeDocumentId) &&
|
||||
team?.getPreference(TeamPreference.Commenting);
|
||||
team.getPreference(TeamPreference.Commenting);
|
||||
|
||||
const sidebarRight = (
|
||||
<AnimatePresence
|
||||
@@ -121,7 +120,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
|
||||
return (
|
||||
<DocumentContext.Provider value={documentContext}>
|
||||
<Layout title={team?.name} sidebar={sidebar} sidebarRight={sidebarRight}>
|
||||
<Layout title={team.name} sidebar={sidebar} sidebarRight={sidebarRight}>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import Initials from "./Initials";
|
||||
|
||||
export enum AvatarSize {
|
||||
Small = 16,
|
||||
Toast = 18,
|
||||
Medium = 24,
|
||||
Large = 32,
|
||||
XLarge = 48,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { changeLanguage } from "~/utils/language";
|
||||
|
||||
type Props = {
|
||||
locale: string;
|
||||
};
|
||||
|
||||
export default function ChangeLanguage({ locale }: Props) {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
void changeLanguage(locale, i18n);
|
||||
}, [locale, i18n]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { sortBy, filter, uniq, isEqual } from "lodash";
|
||||
import filter from "lodash/filter";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import uniq from "lodash/uniq";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import Collection from "~/models/Collection";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Text from "~/components/Text";
|
||||
@@ -22,11 +23,14 @@ function CollectionDeleteDialog({ collection, onSubmit }: Props) {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const redirect = collection.id === ui.activeCollectionId;
|
||||
await collection.delete();
|
||||
onSubmit();
|
||||
|
||||
if (redirect) {
|
||||
history.push(homePath());
|
||||
}
|
||||
|
||||
await collection.delete();
|
||||
onSubmit();
|
||||
toast.success(t("Collection deleted"));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { observer } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Collection from "~/models/Collection";
|
||||
@@ -13,7 +14,6 @@ import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -21,7 +21,6 @@ type Props = {
|
||||
|
||||
function CollectionDescription({ collection }: Props) {
|
||||
const { collections } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const [isExpanded, setExpanded] = React.useState(false);
|
||||
const [isEditing, setEditing] = React.useState(false);
|
||||
@@ -59,15 +58,11 @@ function CollectionDescription({ collection }: Props) {
|
||||
});
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
showToast(
|
||||
t("Sorry, an error occurred saving the collection", {
|
||||
type: "error",
|
||||
})
|
||||
);
|
||||
toast.error(t("Sorry, an error occurred saving the collection"));
|
||||
throw err;
|
||||
}
|
||||
}, 1000),
|
||||
[collection, showToast, t]
|
||||
[collection, t]
|
||||
);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
|
||||
@@ -11,39 +11,26 @@ import SearchActions from "~/components/SearchActions";
|
||||
import rootActions from "~/actions/root";
|
||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||
import useSettingsActions from "~/hooks/useSettingsActions";
|
||||
import { CommandBarAction } from "~/types";
|
||||
import useTemplateActions from "~/hooks/useTemplateActions";
|
||||
|
||||
function CommandBar() {
|
||||
const { t } = useTranslation();
|
||||
const settingsActions = useSettingsActions();
|
||||
const templateActions = useTemplateActions();
|
||||
const commandBarActions = React.useMemo(
|
||||
() => [...rootActions, settingsActions],
|
||||
[settingsActions]
|
||||
() => [...rootActions, templateActions, settingsActions],
|
||||
[settingsActions, templateActions]
|
||||
);
|
||||
|
||||
useCommandBarActions(commandBarActions);
|
||||
|
||||
const { rootAction } = useKBar((state) => ({
|
||||
rootAction: state.currentRootActionId
|
||||
? (state.actions[
|
||||
state.currentRootActionId
|
||||
] as unknown as CommandBarAction)
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<KBarPortal>
|
||||
<Positioner>
|
||||
<Animator>
|
||||
<SearchActions />
|
||||
<SearchInput
|
||||
placeholder={`${
|
||||
rootAction?.placeholder ||
|
||||
rootAction?.name ||
|
||||
t("Type a command or search")
|
||||
}…`}
|
||||
/>
|
||||
<SearchInput defaultPlaceholder={t("Type a command or search")} />
|
||||
<CommandBarResults />
|
||||
</Animator>
|
||||
</Positioner>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import Comment from "~/models/Comment";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
comment: Comment;
|
||||
@@ -14,7 +14,6 @@ type Props = {
|
||||
|
||||
function CommentDeleteDialog({ comment, onSubmit }: Props) {
|
||||
const { comments } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const hasChildComments = comments.inThread(comment.id).length > 1;
|
||||
|
||||
@@ -23,7 +22,7 @@ function CommentDeleteDialog({ comment, onSubmit }: Props) {
|
||||
await comment.delete();
|
||||
onSubmit?.();
|
||||
} catch (err) {
|
||||
showToast(err.message, { type: "error" });
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
/** Callback when the dialog is submitted */
|
||||
@@ -30,7 +30,6 @@ const ConfirmationDialog: React.FC<Props> = ({
|
||||
}: Props) => {
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const { dialogs } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
@@ -40,14 +39,12 @@ const ConfirmationDialog: React.FC<Props> = ({
|
||||
await onSubmit();
|
||||
dialogs.closeAllModals();
|
||||
} catch (err) {
|
||||
showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[onSubmit, dialogs, showToast]
|
||||
[onSubmit, dialogs]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,15 +14,48 @@ function ConnectionStatus() {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const codeToMessage = {
|
||||
1009: {
|
||||
title: t("Document is too large"),
|
||||
body: t(
|
||||
"This document has reached the maximum size and can no longer be edited"
|
||||
),
|
||||
},
|
||||
4401: {
|
||||
title: t("Authentication failed"),
|
||||
body: t("Please try logging out and back in again"),
|
||||
},
|
||||
4403: {
|
||||
title: t("Authorization failed"),
|
||||
body: t("You may have lost access to this document, try reloading"),
|
||||
},
|
||||
4503: {
|
||||
title: t("Too many users connected to document"),
|
||||
body: t("Your edits will sync once other users leave the document"),
|
||||
},
|
||||
};
|
||||
|
||||
const message = ui.multiplayerErrorCode
|
||||
? codeToMessage[ui.multiplayerErrorCode]
|
||||
: undefined;
|
||||
|
||||
return ui.multiplayerStatus === "connecting" ||
|
||||
ui.multiplayerStatus === "disconnected" ? (
|
||||
<Tooltip
|
||||
tooltip={
|
||||
<Centered>
|
||||
<strong>{t("Server connection lost")}</strong>
|
||||
<br />
|
||||
{t("Edits you make will sync once you’re online")}
|
||||
</Centered>
|
||||
message ? (
|
||||
<Centered>
|
||||
<strong>{message.title}</strong>
|
||||
<br />
|
||||
{message.body}
|
||||
</Centered>
|
||||
) : (
|
||||
<Centered>
|
||||
<strong>{t("Server connection lost")}</strong>
|
||||
<br />
|
||||
{t("Edits you make will sync once you’re online")}
|
||||
</Centered>
|
||||
)
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
|
||||
@@ -9,6 +9,7 @@ type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
|
||||
readOnly?: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
||||
onChange?: (text: string) => void;
|
||||
onFocus?: React.FocusEventHandler<HTMLSpanElement> | undefined;
|
||||
onBlur?: React.FocusEventHandler<HTMLSpanElement> | undefined;
|
||||
onInput?: React.FormEventHandler<HTMLSpanElement> | undefined;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLSpanElement> | undefined;
|
||||
@@ -35,6 +36,7 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
disabled,
|
||||
onChange,
|
||||
onInput,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
value,
|
||||
@@ -143,11 +145,13 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className} dir={dir} onClick={onClick}>
|
||||
<div className={className} dir={dir} onClick={onClick} tabIndex={-1}>
|
||||
{children}
|
||||
<Content
|
||||
ref={contentRef}
|
||||
contentEditable={!disabled && !readOnly}
|
||||
onInput={wrappedEvent(onInput)}
|
||||
onFocus={wrappedEvent(onFocus)}
|
||||
onBlur={wrappedEvent(onBlur)}
|
||||
onKeyDown={wrappedEvent(onKeyDown)}
|
||||
onPaste={handlePaste}
|
||||
@@ -158,7 +162,6 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
>
|
||||
{innerValue}
|
||||
</Content>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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: ${({ theme }) => theme.textSecondary};
|
||||
color: ${s("textSecondary")};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default MenuIconWrapper;
|
||||
@@ -5,7 +5,7 @@ import { mergeRefs } from "react-merge-refs";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import MenuIconWrapper from "../MenuIconWrapper";
|
||||
import MenuIconWrapper from "./MenuIconWrapper";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
|
||||
@@ -2,17 +2,17 @@ import * as React from "react";
|
||||
import { useMousePosition } from "~/hooks/useMousePosition";
|
||||
|
||||
type Positions = {
|
||||
/* Sub-menu x */
|
||||
/** Sub-menu x */
|
||||
x: number;
|
||||
/* Sub-menu y */
|
||||
/** Sub-menu y */
|
||||
y: number;
|
||||
/* Sub-menu height */
|
||||
/** Sub-menu height */
|
||||
h: number;
|
||||
/* Sub-menu width */
|
||||
/** Sub-menu width */
|
||||
w: number;
|
||||
/* Mouse x */
|
||||
/** Mouse x */
|
||||
mouseX: number;
|
||||
/* Mouse y */
|
||||
/** Mouse y */
|
||||
mouseY: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
MenuStateReturn,
|
||||
} from "reakit/Menu";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import MenuIconWrapper from "~/components/ContextMenu/MenuIconWrapper";
|
||||
import Flex from "~/components/Flex";
|
||||
import MenuIconWrapper from "~/components/MenuIconWrapper";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import {
|
||||
|
||||
@@ -46,6 +46,8 @@ type Props = MenuStateReturn & {
|
||||
onClose?: () => void;
|
||||
/** Called when the context menu is clicked. */
|
||||
onClick?: (ev: React.MouseEvent) => void;
|
||||
/** The maximum width of the context menu. */
|
||||
maxWidth?: number;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -57,11 +59,6 @@ const ContextMenu: React.FC<Props> = ({
|
||||
...rest
|
||||
}: Props) => {
|
||||
const previousVisible = usePrevious(rest.visible);
|
||||
const maxHeight = useMenuHeight({
|
||||
visible: rest.visible,
|
||||
elementRef: rest.unstable_disclosureRef,
|
||||
});
|
||||
const backgroundRef = React.useRef<HTMLDivElement>(null);
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { setIsMenuOpen } = useMenuContext();
|
||||
@@ -99,21 +96,6 @@ const ContextMenu: React.FC<Props> = ({
|
||||
t,
|
||||
]);
|
||||
|
||||
// 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 (rest.visible && scrollElement && !isSubMenu) {
|
||||
disableBodyScroll(scrollElement, {
|
||||
reserveScrollBarGap: true,
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
scrollElement && !isSubMenu && enableBodyScroll(scrollElement);
|
||||
};
|
||||
}, [isSubMenu, rest.visible]);
|
||||
|
||||
// Perf win – don't render anything until the menu has been opened
|
||||
if (!rest.visible && !previousVisible) {
|
||||
return null;
|
||||
@@ -124,51 +106,98 @@ const ContextMenu: React.FC<Props> = ({
|
||||
return (
|
||||
<>
|
||||
<Menu hideOnClickOutside={!isMobile} preventBodyScroll={false} {...rest}>
|
||||
{(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 = props.style?.top === "0";
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'placement' does not exist on type 'Extra... Remove this comment to see the full error message
|
||||
const rightAnchor = props.placement === "bottom-end";
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMobile && (
|
||||
<Backdrop
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
rest.hide?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Position {...props}>
|
||||
<Background
|
||||
dir="auto"
|
||||
topAnchor={topAnchor}
|
||||
rightAnchor={rightAnchor}
|
||||
ref={backgroundRef}
|
||||
hiddenScrollbars
|
||||
style={
|
||||
topAnchor
|
||||
? {
|
||||
maxHeight,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{rest.visible || rest.animating ? children : null}
|
||||
</Background>
|
||||
</Position>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
{(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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 () => {
|
||||
scrollElement && !props.isSubMenu && enableBodyScroll(scrollElement);
|
||||
};
|
||||
}, [props.isSubMenu, props.visible]);
|
||||
|
||||
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}
|
||||
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`
|
||||
@@ -203,6 +232,7 @@ export const Position = styled.div`
|
||||
type BackgroundProps = {
|
||||
topAnchor?: boolean;
|
||||
rightAnchor?: boolean;
|
||||
maxWidth?: number;
|
||||
theme: DefaultTheme;
|
||||
};
|
||||
|
||||
@@ -228,7 +258,8 @@ export const Background = styled(Scrollable)<BackgroundProps>`
|
||||
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
|
||||
transform-origin: ${(props: BackgroundProps) =>
|
||||
props.rightAnchor ? "75%" : "25%"} 0;
|
||||
max-width: 276px;
|
||||
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,13 @@
|
||||
import { HomeIcon } from "outline-icons";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Optional } from "utility-types";
|
||||
import Flex from "~/components/Flex";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import InputSelect from "~/components/InputSelect";
|
||||
import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type DefaultCollectionInputSelectProps = Optional<
|
||||
React.ComponentProps<typeof InputSelect>
|
||||
@@ -25,7 +25,6 @@ const DefaultCollectionInputSelect = ({
|
||||
const { collections } = useStores();
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [fetchError, setFetchError] = useState();
|
||||
const { showToast } = useToasts();
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchData() {
|
||||
@@ -36,11 +35,8 @@ const DefaultCollectionInputSelect = ({
|
||||
limit: 100,
|
||||
});
|
||||
} catch (error) {
|
||||
showToast(
|
||||
t("Collections could not be loaded, please reload the app"),
|
||||
{
|
||||
type: "error",
|
||||
}
|
||||
toast.error(
|
||||
t("Collections could not be loaded, please reload the app")
|
||||
);
|
||||
setFetchError(error);
|
||||
} finally {
|
||||
@@ -49,7 +45,7 @@ const DefaultCollectionInputSelect = ({
|
||||
}
|
||||
}
|
||||
void fetchData();
|
||||
}, [showToast, fetchError, t, fetching, collections]);
|
||||
}, [fetchError, t, fetching, collections]);
|
||||
|
||||
const options = React.useMemo(
|
||||
() =>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||
import { useDesktopTitlebar } from "~/hooks/useDesktopTitlebar";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
|
||||
export default function DesktopEventHandler() {
|
||||
@@ -12,7 +12,6 @@ export default function DesktopEventHandler() {
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const { dialogs } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
|
||||
React.useEffect(() => {
|
||||
Desktop.bridge?.redirect((path: string, replace = false) => {
|
||||
@@ -24,11 +23,11 @@ export default function DesktopEventHandler() {
|
||||
});
|
||||
|
||||
Desktop.bridge?.updateDownloaded(() => {
|
||||
showToast("An update is ready to install.", {
|
||||
type: "info",
|
||||
timeout: Infinity,
|
||||
toast.message("An update is ready to install.", {
|
||||
duration: Infinity,
|
||||
dismissible: true,
|
||||
action: {
|
||||
text: "Install now",
|
||||
label: t("Install now"),
|
||||
onClick: () => {
|
||||
void Desktop.bridge?.restartAndInstall();
|
||||
},
|
||||
@@ -50,7 +49,7 @@ export default function DesktopEventHandler() {
|
||||
content: <KeyboardShortcuts />,
|
||||
});
|
||||
});
|
||||
}, [t, history, dialogs, showToast]);
|
||||
}, [t, history, dialogs]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -12,9 +12,10 @@ import { MenuInternalLink } from "~/types";
|
||||
import {
|
||||
archivePath,
|
||||
collectionPath,
|
||||
templatesPath,
|
||||
settingsPath,
|
||||
trashPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import EmojiIcon from "./Icons/EmojiIcon";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -43,12 +44,12 @@ function useCategory(document: Document): MenuInternalLink | null {
|
||||
};
|
||||
}
|
||||
|
||||
if (document.isTemplate) {
|
||||
if (document.template) {
|
||||
return {
|
||||
type: "route",
|
||||
icon: <ShapesIcon />,
|
||||
title: t("Templates"),
|
||||
to: templatesPath(),
|
||||
to: settingsPath("templates"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,7 +106,13 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
path.forEach((node: NavigationNode) => {
|
||||
output.push({
|
||||
type: "route",
|
||||
title: node.title,
|
||||
title: node.emoji ? (
|
||||
<>
|
||||
<EmojiIcon emoji={node.emoji} /> {node.title}
|
||||
</>
|
||||
) : (
|
||||
node.title
|
||||
),
|
||||
to: node.url,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,11 +111,12 @@ function DocumentCard(props: Props) {
|
||||
|
||||
{document.emoji ? (
|
||||
<Squircle color={theme.slateLight}>
|
||||
<EmojiIcon emoji={document.emoji} size={26} />
|
||||
<EmojiIcon emoji={document.emoji} size={24} />
|
||||
</Squircle>
|
||||
) : (
|
||||
<Squircle color={collection?.color}>
|
||||
{collection?.icon &&
|
||||
collection?.icon !== "letter" &&
|
||||
collection?.icon !== "collection" &&
|
||||
!pin?.collectionId ? (
|
||||
<CollectionIcon collection={collection} color="white" />
|
||||
@@ -279,8 +280,8 @@ const Heading = styled.h3`
|
||||
overflow: hidden;
|
||||
|
||||
color: ${s("text")};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
font-family: ${s("fontFamily")};
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
export default observer(DocumentCard);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { Editor } from "~/editor";
|
||||
import useIdle from "~/hooks/useIdle";
|
||||
|
||||
export type DocumentContextValue = {
|
||||
/** The current editor instance for this document. */
|
||||
@@ -16,4 +17,21 @@ const DocumentContext = React.createContext<DocumentContextValue>({
|
||||
|
||||
export const useDocumentContext = () => React.useContext(DocumentContext);
|
||||
|
||||
const activityEvents = [
|
||||
"click",
|
||||
"mousemove",
|
||||
"DOMMouseScroll",
|
||||
"mousewheel",
|
||||
"mousedown",
|
||||
"touchstart",
|
||||
"touchmove",
|
||||
"focus",
|
||||
];
|
||||
|
||||
export const useEditingFocus = () => {
|
||||
const { editor } = useDocumentContext();
|
||||
const isIdle = useIdle(3000, activityEvents);
|
||||
return isIdle && !!editor?.view.hasFocus();
|
||||
};
|
||||
|
||||
export default DocumentContext;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import FuzzySearch from "fuzzy-search";
|
||||
import { includes, difference, concat, filter, map, fill } from "lodash";
|
||||
import concat from "lodash/concat";
|
||||
import difference from "lodash/difference";
|
||||
import fill from "lodash/fill";
|
||||
import filter from "lodash/filter";
|
||||
import includes from "lodash/includes";
|
||||
import map from "lodash/map";
|
||||
import { observer } from "mobx-react";
|
||||
import { StarredIcon, DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
@@ -10,7 +15,6 @@ import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import parseTitle from "@shared/utils/parseTitle";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -200,84 +204,86 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const ListItem = ({
|
||||
index,
|
||||
data,
|
||||
style,
|
||||
}: {
|
||||
index: number;
|
||||
data: NavigationNode[];
|
||||
style: React.CSSProperties;
|
||||
}) => {
|
||||
const node = data[index];
|
||||
const isCollection = node.type === "collection";
|
||||
let icon, title, path;
|
||||
const ListItem = observer(
|
||||
({
|
||||
index,
|
||||
data,
|
||||
style,
|
||||
}: {
|
||||
index: number;
|
||||
data: NavigationNode[];
|
||||
style: React.CSSProperties;
|
||||
}) => {
|
||||
const node = data[index];
|
||||
const isCollection = node.type === "collection";
|
||||
let icon, title: string, emoji: string | undefined, path;
|
||||
|
||||
if (isCollection) {
|
||||
const col = collections.get(node.collectionId as string);
|
||||
icon = col && (
|
||||
<CollectionIcon collection={col} expanded={isExpanded(index)} />
|
||||
);
|
||||
title = node.title;
|
||||
} else {
|
||||
const doc = documents.get(node.id);
|
||||
const { strippedTitle, emoji } = parseTitle(node.title);
|
||||
title = strippedTitle;
|
||||
|
||||
if (emoji) {
|
||||
icon = <EmojiIcon emoji={emoji} />;
|
||||
} else if (doc?.isStarred) {
|
||||
icon = <StarredIcon color={theme.yellow} />;
|
||||
if (isCollection) {
|
||||
const col = collections.get(node.collectionId as string);
|
||||
icon = col && (
|
||||
<CollectionIcon collection={col} expanded={isExpanded(index)} />
|
||||
);
|
||||
title = node.title;
|
||||
} else {
|
||||
icon = <DocumentIcon color={theme.textSecondary} />;
|
||||
const doc = documents.get(node.id);
|
||||
emoji = doc?.emoji ?? node.emoji;
|
||||
title = doc?.title ?? node.title;
|
||||
|
||||
if (emoji) {
|
||||
icon = <EmojiIcon emoji={emoji} />;
|
||||
} else if (doc?.isStarred) {
|
||||
icon = <StarredIcon color={theme.yellow} />;
|
||||
} else {
|
||||
icon = <DocumentIcon color={theme.textSecondary} />;
|
||||
}
|
||||
|
||||
path = ancestors(node)
|
||||
.map((a) => a.title)
|
||||
.join(" / ");
|
||||
}
|
||||
|
||||
path = ancestors(node)
|
||||
.map((a) => parseTitle(a.title).strippedTitle)
|
||||
.join(" / ");
|
||||
return searchTerm ? (
|
||||
<DocumentExplorerSearchResult
|
||||
selected={isSelected(index)}
|
||||
active={activeNode === index}
|
||||
style={{
|
||||
...style,
|
||||
top: (style.top as number) + VERTICAL_PADDING,
|
||||
left: (style.left as number) + HORIZONTAL_PADDING,
|
||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||
}}
|
||||
onPointerMove={() => setActiveNode(index)}
|
||||
onClick={() => toggleSelect(index)}
|
||||
icon={icon}
|
||||
title={title}
|
||||
path={path}
|
||||
/>
|
||||
) : (
|
||||
<DocumentExplorerNode
|
||||
style={{
|
||||
...style,
|
||||
top: (style.top as number) + VERTICAL_PADDING,
|
||||
left: (style.left as number) + HORIZONTAL_PADDING,
|
||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||
}}
|
||||
onPointerMove={() => setActiveNode(index)}
|
||||
onClick={() => toggleSelect(index)}
|
||||
onDisclosureClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
toggleCollapse(index);
|
||||
}}
|
||||
selected={isSelected(index)}
|
||||
active={activeNode === index}
|
||||
expanded={isExpanded(index)}
|
||||
icon={icon}
|
||||
title={title}
|
||||
depth={node.depth as number}
|
||||
hasChildren={hasChildren(index)}
|
||||
ref={itemRefs[index]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return searchTerm ? (
|
||||
<DocumentExplorerSearchResult
|
||||
selected={isSelected(index)}
|
||||
active={activeNode === index}
|
||||
style={{
|
||||
...style,
|
||||
top: (style.top as number) + VERTICAL_PADDING,
|
||||
left: (style.left as number) + HORIZONTAL_PADDING,
|
||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||
}}
|
||||
onPointerMove={() => setActiveNode(index)}
|
||||
onClick={() => toggleSelect(index)}
|
||||
icon={icon}
|
||||
title={title}
|
||||
path={path}
|
||||
/>
|
||||
) : (
|
||||
<DocumentExplorerNode
|
||||
style={{
|
||||
...style,
|
||||
top: (style.top as number) + VERTICAL_PADDING,
|
||||
left: (style.left as number) + HORIZONTAL_PADDING,
|
||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||
}}
|
||||
onPointerMove={() => setActiveNode(index)}
|
||||
onClick={() => toggleSelect(index)}
|
||||
onDisclosureClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
toggleCollapse(index);
|
||||
}}
|
||||
selected={isSelected(index)}
|
||||
active={activeNode === index}
|
||||
expanded={isExpanded(index)}
|
||||
icon={icon}
|
||||
title={title}
|
||||
depth={node.depth as number}
|
||||
hasChildren={hasChildren(index)}
|
||||
ref={itemRefs[index]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
||||
const focusSearchInput = () => {
|
||||
inputSearchRef.current?.focus();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -9,7 +8,6 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Badge from "~/components/Badge";
|
||||
import Button from "~/components/Button";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import EventBoundary from "~/components/EventBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -18,12 +16,11 @@ import NudeButton from "~/components/NudeButton";
|
||||
import StarButton, { AnimatedStar } from "~/components/Star";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { hover } from "~/styles";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import EmojiIcon from "./Icons/EmojiIcon";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -51,7 +48,6 @@ function DocumentListItem(
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const team = useCurrentTeam();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
|
||||
const {
|
||||
@@ -71,8 +67,6 @@ function DocumentListItem(
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar =
|
||||
!document.isDraft && !document.isArchived && !document.isTemplate;
|
||||
const can = usePolicy(team);
|
||||
const canCollection = usePolicy(document.collectionId);
|
||||
|
||||
return (
|
||||
<CompositeItem
|
||||
@@ -83,7 +77,7 @@ function DocumentListItem(
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: document.url,
|
||||
pathname: documentPath(document),
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
},
|
||||
@@ -92,6 +86,12 @@ function DocumentListItem(
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
{document.emoji && (
|
||||
<>
|
||||
<EmojiIcon emoji={document.emoji} size={24} />
|
||||
|
||||
</>
|
||||
)}
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
@@ -135,25 +135,6 @@ function DocumentListItem(
|
||||
/>
|
||||
</Content>
|
||||
<Actions>
|
||||
{document.isTemplate &&
|
||||
!document.isArchived &&
|
||||
!document.isDeleted &&
|
||||
can.createDocument &&
|
||||
canCollection.update && (
|
||||
<>
|
||||
<Button
|
||||
as={Link}
|
||||
to={newDocumentPath(document.collectionId, {
|
||||
templateId: document.id,
|
||||
})}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
|
||||
</>
|
||||
)}
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
showPin={showPin}
|
||||
@@ -262,8 +243,8 @@ const Heading = styled.h3<{ rtl?: boolean }>`
|
||||
margin-bottom: 0.25em;
|
||||
white-space: nowrap;
|
||||
color: ${s("text")};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
font-family: ${s("fontFamily")};
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const StarPositioner = styled(Flex)`
|
||||
|
||||
@@ -3,9 +3,9 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
@@ -14,7 +14,6 @@ type Props = {
|
||||
|
||||
function DocumentTemplatizeDialog({ documentId }: Props) {
|
||||
const history = useHistory();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const document = documents.get(documentId);
|
||||
@@ -24,11 +23,9 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
|
||||
const template = await document?.templatize();
|
||||
if (template) {
|
||||
history.push(documentPath(template));
|
||||
showToast(t("Template created, go ahead and customize it"), {
|
||||
type: "info",
|
||||
});
|
||||
toast.success(t("Template created, go ahead and customize it"));
|
||||
}
|
||||
}, [document, showToast, history, t]);
|
||||
}, [document, history, t]);
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { sortBy } from "lodash";
|
||||
import compact from "lodash/compact";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { dateToRelative } from "@shared/utils/date";
|
||||
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
@@ -18,6 +20,9 @@ type Props = {
|
||||
function DocumentViews({ document, isOpen }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { views, presence } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const locale = dateLocale(user.language);
|
||||
|
||||
const documentPresence = presence.get(document.id);
|
||||
const documentPresenceArray = documentPresence
|
||||
? Array.from(documentPresence.values())
|
||||
@@ -31,10 +36,10 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
const documentViews = views.inDocument(document.id);
|
||||
const sortedViews = sortBy(
|
||||
documentViews,
|
||||
(view) => !presentIds.includes(view.user.id)
|
||||
(view) => !presentIds.includes(view.userId)
|
||||
);
|
||||
const users = React.useMemo(
|
||||
() => sortedViews.map((v) => v.user),
|
||||
() => compact(sortedViews.map((v) => v.user)),
|
||||
[sortedViews]
|
||||
);
|
||||
|
||||
@@ -45,16 +50,20 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
aria-label={t("Viewers")}
|
||||
items={users}
|
||||
renderItem={(model: User) => {
|
||||
const view = documentViews.find((v) => v.user.id === model.id);
|
||||
const view = documentViews.find((v) => v.userId === model.id);
|
||||
const isPresent = presentIds.includes(model.id);
|
||||
const isEditing = editingIds.includes(model.id);
|
||||
const subtitle = isPresent
|
||||
? isEditing
|
||||
? t("Currently editing")
|
||||
: t("Currently viewing")
|
||||
: t("Viewed {{ timeAgo }} ago", {
|
||||
: t("Viewed {{ timeAgo }}", {
|
||||
timeAgo: dateToRelative(
|
||||
view ? Date.parse(view.lastViewedAt) : new Date()
|
||||
view ? Date.parse(view.lastViewedAt) : new Date(),
|
||||
{
|
||||
addSuffix: true,
|
||||
locale,
|
||||
}
|
||||
),
|
||||
});
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import Document from "~/models/Document";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Input from "./Input";
|
||||
import Text from "./Text";
|
||||
|
||||
type Props = {
|
||||
/** The original document to duplicate */
|
||||
document: Document;
|
||||
onSubmit: (documents: Document[]) => void;
|
||||
};
|
||||
|
||||
function DuplicateDialog({ document, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const defaultTitle = t(`Copy of {{ documentName }}`, {
|
||||
documentName: document.title,
|
||||
});
|
||||
const [recursive, setRecursive] = React.useState<boolean>(true);
|
||||
const [title, setTitle] = React.useState<string>(defaultTitle);
|
||||
|
||||
const handleRecursiveChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRecursive(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleTitleChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTitle(ev.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const result = await document.duplicate({
|
||||
recursive,
|
||||
title,
|
||||
});
|
||||
onSubmit(result);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog onSubmit={handleSubmit} submitText={t("Duplicate")}>
|
||||
<Input
|
||||
autoFocus
|
||||
autoSelect
|
||||
name="title"
|
||||
label={t("Title")}
|
||||
onChange={handleTitleChange}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
defaultValue={defaultTitle}
|
||||
/>
|
||||
{document.publishedAt && !document.isTemplate && (
|
||||
<label>
|
||||
<Text size="small">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="recursive"
|
||||
checked={recursive}
|
||||
onChange={handleRecursiveChange}
|
||||
/>{" "}
|
||||
{t("Include nested documents")}
|
||||
</Text>
|
||||
</label>
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(DuplicateDialog);
|
||||
@@ -1,4 +1,6 @@
|
||||
import { deburr, difference, sortBy } from "lodash";
|
||||
import deburr from "lodash/deburr";
|
||||
import difference from "lodash/difference";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { observer } from "mobx-react";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
@@ -19,10 +21,10 @@ import ClickablePadding from "~/components/ClickablePadding";
|
||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
import HoverPreview from "~/components/HoverPreview";
|
||||
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useEmbeds from "~/hooks/useEmbeds";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { NotFoundError } from "~/utils/errors";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
@@ -41,11 +43,11 @@ export type Props = Optional<
|
||||
| "onClickLink"
|
||||
| "embeds"
|
||||
| "dictionary"
|
||||
| "onShowToast"
|
||||
| "extensions"
|
||||
> & {
|
||||
shareId?: string | undefined;
|
||||
embedsDisabled?: boolean;
|
||||
previewsDisabled?: boolean;
|
||||
onHeadingsChange?: (headings: Heading[]) => void;
|
||||
onSynced?: () => Promise<void>;
|
||||
onPublish?: (event: React.MouseEvent) => any;
|
||||
@@ -60,16 +62,16 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
onHeadingsChange,
|
||||
onCreateCommentMark,
|
||||
onDeleteCommentMark,
|
||||
previewsDisabled,
|
||||
} = props;
|
||||
const userLocale = useUserLocale();
|
||||
const locale = dateLocale(userLocale);
|
||||
const { auth, comments, documents } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const { comments, documents } = useStores();
|
||||
const dictionary = useDictionary();
|
||||
const embeds = useEmbeds(!shareId);
|
||||
const history = useHistory();
|
||||
const localRef = React.useRef<SharedEditor>();
|
||||
const preferences = auth.user?.preferences;
|
||||
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
|
||||
const previousHeadings = React.useRef<Heading[] | null>(null);
|
||||
const [activeLinkElement, setActiveLink] =
|
||||
React.useState<HTMLAnchorElement | null>(null);
|
||||
@@ -237,7 +239,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
uploadFile: handleUploadFile,
|
||||
onFileUploadStart: props.onFileUploadStart,
|
||||
onFileUploadStop: props.onFileUploadStop,
|
||||
onShowToast: showToast,
|
||||
dictionary,
|
||||
isAttachment,
|
||||
});
|
||||
@@ -248,7 +249,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
props.onFileUploadStop,
|
||||
dictionary,
|
||||
handleUploadFile,
|
||||
showToast,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -332,12 +332,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
<LazyLoadedEditor
|
||||
ref={mergeRefs([ref, localRef, handleRefChanged])}
|
||||
uploadFile={handleUploadFile}
|
||||
onShowToast={showToast}
|
||||
embeds={embeds}
|
||||
userPreferences={preferences}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onHoverLink={handleLinkActive}
|
||||
onHoverLink={previewsDisabled ? undefined : handleLinkActive}
|
||||
onClickLink={handleClickLink}
|
||||
onSearchLink={handleSearchLink}
|
||||
onChange={handleChange}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import styled from "styled-components";
|
||||
import Button from "~/components/Button";
|
||||
import { hover } from "~/styles";
|
||||
import Flex from "../Flex";
|
||||
|
||||
export const EmojiButton = styled(Button)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
&: ${hover},
|
||||
&:active,
|
||||
&[aria-expanded= "true"] {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Emoji = styled(Flex)<{ size?: number }>`
|
||||
line-height: 1.6;
|
||||
${(props) => (props.size ? `font-size: ${props.size}px` : "")}
|
||||
`;
|
||||
@@ -0,0 +1,269 @@
|
||||
import data from "@emoji-mart/data";
|
||||
import Picker from "@emoji-mart/react";
|
||||
import { SmileyIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { toRGB } from "@shared/utils/color";
|
||||
import Button from "~/components/Button";
|
||||
import Popover from "~/components/Popover";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { Emoji, EmojiButton } from "./components";
|
||||
|
||||
/* Locales supported by emoji-mart */
|
||||
const supportedLocales = [
|
||||
"en",
|
||||
"ar",
|
||||
"be",
|
||||
"cs",
|
||||
"de",
|
||||
"es",
|
||||
"fa",
|
||||
"fi",
|
||||
"fr",
|
||||
"hi",
|
||||
"it",
|
||||
"ja",
|
||||
"kr",
|
||||
"nl",
|
||||
"pl",
|
||||
"pt",
|
||||
"ru",
|
||||
"sa",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
"zh",
|
||||
];
|
||||
|
||||
/**
|
||||
* React hook to derive emoji picker's theme from UI theme
|
||||
*
|
||||
* @returns {string} Theme to use for emoji picker
|
||||
*/
|
||||
function usePickerTheme(): string {
|
||||
const { ui } = useStores();
|
||||
const { theme } = ui;
|
||||
|
||||
if (theme === "system") {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
/** The selected emoji, if any */
|
||||
value?: string | null;
|
||||
/** Callback when an emoji is selected */
|
||||
onChange: (emoji: string | null) => void | Promise<void>;
|
||||
/** Callback when the picker is opened */
|
||||
onOpen?: () => void;
|
||||
/** Callback when the picker is closed */
|
||||
onClose?: () => void;
|
||||
/** Callback when the picker is clicked outside of */
|
||||
onClickOutside: () => void;
|
||||
/** Whether to auto focus the search input on open */
|
||||
autoFocus?: boolean;
|
||||
/** Class name to apply to the trigger button */
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function EmojiPicker({
|
||||
value,
|
||||
onOpen,
|
||||
onClose,
|
||||
onChange,
|
||||
onClickOutside,
|
||||
autoFocus,
|
||||
className,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const pickerTheme = usePickerTheme();
|
||||
const theme = useTheme();
|
||||
const locale = useUserLocale(true) ?? "en";
|
||||
|
||||
const popover = usePopoverState({
|
||||
placement: "bottom-start",
|
||||
modal: true,
|
||||
unstable_offset: [0, 0],
|
||||
});
|
||||
|
||||
const [emojisPerLine, setEmojisPerLine] = React.useState(9);
|
||||
|
||||
const pickerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (popover.visible) {
|
||||
onOpen?.();
|
||||
} else {
|
||||
onClose?.();
|
||||
}
|
||||
}, [popover.visible, onOpen, onClose]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (popover.visible && pickerRef.current) {
|
||||
// 28 is picker's observed width when perLine is set to 0
|
||||
// and 36 is the default emojiButtonSize
|
||||
// Ref: https://github.com/missive/emoji-mart#options--props
|
||||
setEmojisPerLine(Math.floor((pickerRef.current.clientWidth - 28) / 36));
|
||||
}
|
||||
}, [popover.visible]);
|
||||
|
||||
const handleEmojiChange = React.useCallback(
|
||||
async (emoji) => {
|
||||
popover.hide();
|
||||
await onChange(emoji ? emoji.native : null);
|
||||
},
|
||||
[popover, onChange]
|
||||
);
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
(ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
if (popover.visible) {
|
||||
popover.hide();
|
||||
} else {
|
||||
popover.show();
|
||||
}
|
||||
},
|
||||
[popover]
|
||||
);
|
||||
|
||||
const handleClickOutside = React.useCallback(() => {
|
||||
// It was observed that onClickOutside got triggered
|
||||
// even when the picker wasn't open or opened at all.
|
||||
// Hence, this guard here...
|
||||
if (popover.visible) {
|
||||
onClickOutside();
|
||||
}
|
||||
}, [popover.visible, onClickOutside]);
|
||||
|
||||
// Auto focus search input when picker is opened
|
||||
React.useLayoutEffect(() => {
|
||||
if (autoFocus && popover.visible) {
|
||||
requestAnimationFrame(() => {
|
||||
const searchInput = pickerRef.current
|
||||
?.querySelector("em-emoji-picker")
|
||||
?.shadowRoot?.querySelector(
|
||||
"input[type=search]"
|
||||
) as HTMLInputElement | null;
|
||||
searchInput?.focus();
|
||||
});
|
||||
}
|
||||
}, [autoFocus, popover.visible]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<EmojiButton
|
||||
{...props}
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
icon={
|
||||
value ? (
|
||||
<Emoji size={32} align="center" justify="center">
|
||||
{value}
|
||||
</Emoji>
|
||||
) : (
|
||||
<StyledSmileyIcon size={32} color={theme.textTertiary} />
|
||||
)
|
||||
}
|
||||
neutral
|
||||
borderOnHover
|
||||
/>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
<PickerPopover
|
||||
{...popover}
|
||||
tabIndex={0}
|
||||
// This prevents picker from closing when any of its
|
||||
// children are focused, e.g, clicking on search bar or
|
||||
// a click on skin tone button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
width={352}
|
||||
aria-label={t("Emoji Picker")}
|
||||
>
|
||||
{popover.visible && (
|
||||
<>
|
||||
{value && (
|
||||
<RemoveButton neutral onClick={() => handleEmojiChange(null)}>
|
||||
{t("Remove")}
|
||||
</RemoveButton>
|
||||
)}
|
||||
<PickerStyles ref={pickerRef}>
|
||||
<Picker
|
||||
// https://github.com/missive/emoji-mart/issues/800
|
||||
locale={
|
||||
locale === "ko"
|
||||
? "kr"
|
||||
: supportedLocales.includes(locale)
|
||||
? locale
|
||||
: "en"
|
||||
}
|
||||
data={data}
|
||||
onEmojiSelect={handleEmojiChange}
|
||||
theme={pickerTheme}
|
||||
previewPosition="none"
|
||||
perLine={emojisPerLine}
|
||||
onClickOutside={handleClickOutside}
|
||||
/>
|
||||
</PickerStyles>
|
||||
</>
|
||||
)}
|
||||
</PickerPopover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledSmileyIcon = styled(SmileyIcon)`
|
||||
flex-shrink: 0;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const RemoveButton = styled(Button)`
|
||||
margin-left: -12px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 6px;
|
||||
height: 24px;
|
||||
font-size: 13px;
|
||||
|
||||
> :first-child {
|
||||
min-height: unset;
|
||||
line-height: unset;
|
||||
}
|
||||
`;
|
||||
|
||||
const PickerPopover = styled(Popover)`
|
||||
z-index: ${depths.popover};
|
||||
> :first-child {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 0;
|
||||
max-height: 488px;
|
||||
overflow: unset;
|
||||
}
|
||||
`;
|
||||
|
||||
const PickerStyles = styled.div`
|
||||
margin-left: -24px;
|
||||
margin-right: -24px;
|
||||
em-emoji-picker {
|
||||
--shadow: none;
|
||||
--font-family: ${s("fontFamily")};
|
||||
--rgb-background: ${(props) => toRGB(props.theme.menuBackground)};
|
||||
--rgb-accent: ${(props) => toRGB(props.theme.accent)};
|
||||
--border-radius: 6px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
min-height: 443px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default EmojiPicker;
|
||||
@@ -121,11 +121,11 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
<Button onClick={this.handleReload}>{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>…
|
||||
<Trans>Show detail</Trans>…
|
||||
</Button>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { FileOperationFormat, NotificationEventType } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
@@ -10,7 +11,8 @@ import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import history from "~/utils/history";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
collection?: Collection;
|
||||
@@ -24,7 +26,6 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
const [includeAttachments, setIncludeAttachments] =
|
||||
React.useState<boolean>(true);
|
||||
const user = useCurrentUser();
|
||||
const { showToast } = useToasts();
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const appName = env.APP_NAME;
|
||||
@@ -46,11 +47,22 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
const handleSubmit = async () => {
|
||||
if (collection) {
|
||||
await collection.export(format, includeAttachments);
|
||||
toast.success(t("Export started"), {
|
||||
description: t(`Your file will be available in {{ location }} soon`, {
|
||||
location: `"${t("Settings")} > ${t("Export")}"`,
|
||||
}),
|
||||
action: {
|
||||
label: t("View"),
|
||||
onClick: () => {
|
||||
history.push(settingsPath("export"));
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await collections.export(format, includeAttachments);
|
||||
toast.success(t("Export started"));
|
||||
}
|
||||
onSubmit();
|
||||
showToast(t("Export started"), { type: "success" });
|
||||
};
|
||||
|
||||
const items = [
|
||||
|
||||
@@ -32,9 +32,12 @@ function Facepile({
|
||||
</span>
|
||||
</More>
|
||||
)}
|
||||
{users.slice(0, limit).map((user) => (
|
||||
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
|
||||
))}
|
||||
{users
|
||||
.filter(Boolean)
|
||||
.slice(0, limit)
|
||||
.map((user) => (
|
||||
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
|
||||
))}
|
||||
</Avatars>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ const Note = styled(Text)`
|
||||
margin-bottom: 0;
|
||||
line-height: 1.2em;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
font-weight: 500;
|
||||
color: ${s("textTertiary")};
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { throttle } from "lodash";
|
||||
import throttle from "lodash/throttle";
|
||||
import { observer } from "mobx-react";
|
||||
import { MenuIcon } from "outline-icons";
|
||||
import { transparentize } from "polished";
|
||||
@@ -6,6 +6,7 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { supportsPassiveListener } from "@shared/utils/browser";
|
||||
import Button from "~/components/Button";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -14,16 +15,16 @@ import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { supportsPassiveListener } from "~/utils/browser";
|
||||
|
||||
type Props = {
|
||||
left?: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
hasSidebar?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Header({ left, title, actions, hasSidebar }: Props) {
|
||||
function Header({ left, title, actions, hasSidebar, className }: Props) {
|
||||
const { ui } = useStores();
|
||||
const isMobile = useMobile();
|
||||
const hasMobileSidebar = hasSidebar && isMobile;
|
||||
@@ -54,6 +55,7 @@ function Header({ left, title, actions, hasSidebar }: Props) {
|
||||
<Wrapper
|
||||
align="center"
|
||||
shrink={false}
|
||||
className={className}
|
||||
$passThrough={passThrough}
|
||||
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { escapeRegExp } from "lodash";
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import * as React from "react";
|
||||
import replace from "string-replace-to-array";
|
||||
import styled from "styled-components";
|
||||
|
||||
@@ -4,7 +4,7 @@ import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
export const CARD_MARGIN = 16;
|
||||
export const CARD_MARGIN = 10;
|
||||
|
||||
const NUMBER_OF_LINES = 10;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { m } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { depths } from "@shared/styles";
|
||||
import { UnfurlType } from "@shared/types";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
@@ -17,16 +17,24 @@ import HoverPreviewDocument from "./HoverPreviewDocument";
|
||||
import HoverPreviewLink from "./HoverPreviewLink";
|
||||
import HoverPreviewMention from "./HoverPreviewMention";
|
||||
|
||||
const DELAY_OPEN = 300;
|
||||
const DELAY_OPEN = 500;
|
||||
const DELAY_CLOSE = 600;
|
||||
|
||||
type Props = {
|
||||
/* The HTML element that is being hovered over */
|
||||
/** The HTML element that is being hovered over */
|
||||
element: HTMLAnchorElement;
|
||||
/* A callback on close of the hover preview */
|
||||
/** A callback on close of the hover preview */
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
enum Direction {
|
||||
UP,
|
||||
DOWN,
|
||||
}
|
||||
|
||||
const POINTER_HEIGHT = 22;
|
||||
const POINTER_WIDTH = 22;
|
||||
|
||||
function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
const url = element.href || element.dataset.url;
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
@@ -36,31 +44,46 @@ function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
const stores = useStores();
|
||||
const [cardLeft, setCardLeft] = React.useState(0);
|
||||
const [cardTop, setCardTop] = React.useState(0);
|
||||
const [pointerOffset, setPointerOffset] = React.useState(0);
|
||||
const [pointerLeft, setPointerLeft] = React.useState(0);
|
||||
const [pointerTop, setPointerTop] = React.useState(0);
|
||||
const [pointerDir, setPointerDir] = React.useState(Direction.UP);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (isVisible && cardRef.current) {
|
||||
const elem = element.getBoundingClientRect();
|
||||
const card = cardRef.current.getBoundingClientRect();
|
||||
|
||||
const top = elem.bottom + window.scrollY;
|
||||
setCardTop(top);
|
||||
let cTop = elem.bottom + window.scrollY + CARD_MARGIN;
|
||||
let pTop = -POINTER_HEIGHT;
|
||||
let pDir = Direction.UP;
|
||||
if (cTop + card.height > window.innerHeight + window.scrollY) {
|
||||
// shift card upwards if it goes out of screen
|
||||
const bottom = elem.top + window.scrollY;
|
||||
cTop = bottom - card.height;
|
||||
// shift a little further to leave some margin between card and element boundary
|
||||
cTop -= CARD_MARGIN;
|
||||
// pointer should be shifted downwards to align with card's bottom
|
||||
pTop = card.height;
|
||||
pDir = Direction.DOWN;
|
||||
}
|
||||
setCardTop(cTop);
|
||||
setPointerTop(pTop);
|
||||
setPointerDir(pDir);
|
||||
|
||||
let left = elem.left;
|
||||
let pointerOffset = elem.width / 2;
|
||||
if (left + card.width > window.innerWidth) {
|
||||
let cLeft = elem.left;
|
||||
let pLeft = elem.width / 2;
|
||||
if (cLeft + card.width > window.innerWidth) {
|
||||
// shift card leftwards by the amount it went out of screen
|
||||
let shiftBy = left + card.width - window.innerWidth;
|
||||
// shift a littler further to leave some margin between card and window boundary
|
||||
let shiftBy = cLeft + card.width - window.innerWidth;
|
||||
// shift a little further to leave some margin between card and window boundary
|
||||
shiftBy += CARD_MARGIN;
|
||||
left -= shiftBy;
|
||||
cLeft -= shiftBy;
|
||||
|
||||
// shift pointer rightwards by same amount so as to position it back correctly
|
||||
pointerOffset += shiftBy;
|
||||
pLeft += shiftBy;
|
||||
}
|
||||
setCardLeft(left);
|
||||
|
||||
setPointerOffset(pointerOffset);
|
||||
setCardLeft(cLeft);
|
||||
setPointerLeft(pLeft);
|
||||
}
|
||||
}, [isVisible, element]);
|
||||
|
||||
@@ -103,18 +126,18 @@ function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
useKeyDown("Escape", closePreview);
|
||||
useEventListener("scroll", closePreview, window, { capture: true });
|
||||
|
||||
const stopCloseTimer = () => {
|
||||
const stopCloseTimer = React.useCallback(() => {
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
timerClose.current = undefined;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startOpenTimer = () => {
|
||||
const startOpenTimer = React.useCallback(() => {
|
||||
if (!timerOpen.current) {
|
||||
timerOpen.current = setTimeout(() => setVisible(true), DELAY_OPEN);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startCloseTimer = React.useCallback(() => {
|
||||
stopOpenTimer();
|
||||
@@ -149,7 +172,7 @@ function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
|
||||
stopCloseTimer();
|
||||
};
|
||||
}, [element, startCloseTimer, data]);
|
||||
}, [element, startCloseTimer, data, startOpenTimer, stopCloseTimer]);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingIndicator />;
|
||||
@@ -193,7 +216,11 @@ function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
description={data.description}
|
||||
/>
|
||||
)}
|
||||
<Pointer offset={pointerOffset} />
|
||||
<Pointer
|
||||
top={pointerTop}
|
||||
left={pointerLeft}
|
||||
direction={pointerDir}
|
||||
/>
|
||||
</Animate>
|
||||
) : null}
|
||||
</Position>
|
||||
@@ -217,7 +244,6 @@ const Animate = styled(m.div)`
|
||||
`;
|
||||
|
||||
const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
|
||||
margin-top: 10px;
|
||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
||||
z-index: ${depths.hoverPreview};
|
||||
display: flex;
|
||||
@@ -227,11 +253,11 @@ const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
|
||||
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
|
||||
`;
|
||||
|
||||
const Pointer = styled.div<{ offset: number }>`
|
||||
top: -22px;
|
||||
left: ${(props) => props.offset}px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
const Pointer = styled.div<{ top: number; left: number; direction: Direction }>`
|
||||
top: ${(props) => props.top}px;
|
||||
left: ${(props) => props.left}px;
|
||||
width: ${POINTER_WIDTH}px;
|
||||
height: ${POINTER_HEIGHT}px;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
@@ -241,20 +267,26 @@ const Pointer = styled.div<{ offset: number }>`
|
||||
content: "";
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
${({ direction }) => (direction === Direction.UP ? "bottom: 0" : "top: 0")};
|
||||
${({ direction }) => (direction === Direction.UP ? "right: 0" : "left: 0")};
|
||||
}
|
||||
|
||||
&:before {
|
||||
border: 8px solid transparent;
|
||||
border-bottom-color: ${(props) =>
|
||||
props.theme.menuBorder || "rgba(0, 0, 0, 0.1)"};
|
||||
right: -1px;
|
||||
${({ direction, theme }) =>
|
||||
direction === Direction.UP
|
||||
? `border-bottom-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`
|
||||
: `border-top-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`};
|
||||
${({ direction }) =>
|
||||
direction === Direction.UP ? "right: -1px" : "left: -1px"};
|
||||
}
|
||||
|
||||
&:after {
|
||||
border: 7px solid transparent;
|
||||
border-bottom-color: ${s("menuBackground")};
|
||||
${({ direction, theme }) =>
|
||||
direction === Direction.UP
|
||||
? `border-bottom-color: ${theme.menuBackground}`
|
||||
: `border-top-color: ${theme.menuBackground}`};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
|
||||
) {
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
<Flex column>
|
||||
<Flex column ref={ref}>
|
||||
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt={""} /> : null}
|
||||
<Card ref={ref}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Flex column>
|
||||
<Title>{title}</Title>
|
||||
|
||||
@@ -49,11 +49,7 @@ import NudeButton from "~/components/NudeButton";
|
||||
import Text from "~/components/Text";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import DelayedMount from "./DelayedMount";
|
||||
|
||||
const style = {
|
||||
width: 30,
|
||||
height: 30,
|
||||
};
|
||||
import LetterIcon from "./Icons/LetterIcon";
|
||||
|
||||
const TwitterPicker = lazyWithRetry(
|
||||
() => import("react-color/lib/components/twitter/Twitter")
|
||||
@@ -136,6 +132,10 @@ export const icons = {
|
||||
component: LightningIcon,
|
||||
keywords: "lightning fast zap",
|
||||
},
|
||||
letter: {
|
||||
component: LetterIcon,
|
||||
keywords: "letter",
|
||||
},
|
||||
math: {
|
||||
component: MathIcon,
|
||||
keywords: "math formula",
|
||||
@@ -206,11 +206,19 @@ type Props = {
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
onChange: (color: string, icon: string) => void;
|
||||
initial: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
function IconPicker({
|
||||
onOpen,
|
||||
onClose,
|
||||
icon,
|
||||
initial,
|
||||
color,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const menu = useMenuState({
|
||||
@@ -230,7 +238,9 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
as={icons[icon || "collection"].component}
|
||||
color={color}
|
||||
size={30}
|
||||
/>
|
||||
>
|
||||
{initial}
|
||||
</Icon>
|
||||
</Button>
|
||||
)}
|
||||
</MenuButton>
|
||||
@@ -238,6 +248,7 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
{...menu}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
maxWidth={308}
|
||||
aria-label={t("Choose icon")}
|
||||
>
|
||||
<Icons>
|
||||
@@ -251,13 +262,14 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
<IconButton
|
||||
style={
|
||||
{
|
||||
...style,
|
||||
"--delay": `${index * 8}ms`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<Icon as={icons[name].component} color={color} size={30} />
|
||||
<Icon as={icons[name].component} color={color} size={30}>
|
||||
{initial}
|
||||
</Icon>
|
||||
</IconButton>
|
||||
)}
|
||||
</MenuItem>
|
||||
@@ -318,7 +330,7 @@ const Icons = styled.div`
|
||||
padding: 8px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: 276px;
|
||||
width: 304px;
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -329,6 +341,7 @@ const Button = styled(NudeButton)`
|
||||
`;
|
||||
|
||||
const IconButton = styled(NudeButton)`
|
||||
vertical-align: top;
|
||||
border-radius: 4px;
|
||||
margin: 0px 6px 6px 0px;
|
||||
width: 30px;
|
||||
|
||||
@@ -39,7 +39,11 @@ function ResolvedCollectionIcon({
|
||||
if (collection.icon && collection.icon !== "collection") {
|
||||
try {
|
||||
const Component = icons[collection.icon].component;
|
||||
return <Component color={color} size={size} />;
|
||||
return (
|
||||
<Component color={color} size={size}>
|
||||
{collection.initial}
|
||||
</Component>
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.warn("Failed to render custom icon", {
|
||||
icon: collection.icon,
|
||||
|
||||
@@ -2,9 +2,9 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
/* The emoji to render */
|
||||
/** The emoji to render */
|
||||
emoji: string;
|
||||
/* The size of the emoji, 24px is default to match standard icons */
|
||||
/** The size of the emoji, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
};
|
||||
|
||||
@@ -29,5 +29,5 @@ const Span = styled.span<{ $size: number }>`
|
||||
width: ${(props) => props.$size}px;
|
||||
height: ${(props) => props.$size}px;
|
||||
text-indent: -0.15em;
|
||||
font-size: 14px;
|
||||
font-size: ${(props) => props.$size - 10}px;
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Squircle from "../Squircle";
|
||||
|
||||
type Props = {
|
||||
/** The width and height of the icon, including standard padding. */
|
||||
size?: number;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* A squircle shaped icon with a letter inside, used for collections.
|
||||
*/
|
||||
const LetterIcon = ({ children, size = 24, ...rest }: Props) => (
|
||||
<LetterIconWrapper $size={size}>
|
||||
<Squircle size={Math.round(size * 0.66)} {...rest}>
|
||||
{children}
|
||||
</Squircle>
|
||||
</LetterIconWrapper>
|
||||
);
|
||||
|
||||
const LetterIconWrapper = styled.div<{ $size: number }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: ${({ $size }) => $size}px;
|
||||
height: ${({ $size }) => $size}px;
|
||||
|
||||
font-weight: 700;
|
||||
font-size: ${({ $size }) => $size / 2}px;
|
||||
color: ${s("background")};
|
||||
`;
|
||||
|
||||
export default LetterIcon;
|
||||
@@ -5,7 +5,7 @@ type Props = {
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the current text color */
|
||||
color?: string;
|
||||
/* Whether the safe area should be removed and have graphic across full size */
|
||||
/** Whether the safe area should be removed and have graphic across full size */
|
||||
cover?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
@@ -121,7 +122,9 @@ export type Props = React.InputHTMLAttributes<
|
||||
margin?: string | number;
|
||||
error?: string;
|
||||
icon?: React.ReactNode;
|
||||
/* Callback is triggered with the CMD+Enter keyboard combo */
|
||||
/** Like autoFocus, but also select any text in the input */
|
||||
autoSelect?: boolean;
|
||||
/** Callback is triggered with the CMD+Enter keyboard combo */
|
||||
onRequestSubmit?: (
|
||||
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => unknown;
|
||||
@@ -133,6 +136,7 @@ function Input(
|
||||
props: Props,
|
||||
ref: React.RefObject<HTMLInputElement | HTMLTextAreaElement>
|
||||
) {
|
||||
const internalRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>();
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
|
||||
const handleBlur = (ev: React.SyntheticEvent) => {
|
||||
@@ -165,6 +169,12 @@ function Input(
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (props.autoSelect && internalRef.current) {
|
||||
internalRef.current.select();
|
||||
}
|
||||
}, [props.autoSelect, internalRef]);
|
||||
|
||||
const {
|
||||
type = "text",
|
||||
icon,
|
||||
@@ -197,7 +207,10 @@ function Input(
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
{type === "textarea" ? (
|
||||
<RealTextarea
|
||||
ref={ref as React.RefObject<HTMLTextAreaElement>}
|
||||
ref={mergeRefs([
|
||||
internalRef,
|
||||
ref as React.RefObject<HTMLTextAreaElement>,
|
||||
])}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -206,7 +219,10 @@ function Input(
|
||||
/>
|
||||
) : (
|
||||
<RealInput
|
||||
ref={ref as React.RefObject<HTMLInputElement>}
|
||||
ref={mergeRefs([
|
||||
internalRef,
|
||||
ref as React.RefObject<HTMLInputElement>,
|
||||
])}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function InputSelectPermission(
|
||||
const handleChange = React.useCallback(
|
||||
(value) => {
|
||||
if (value === "no_access") {
|
||||
value = "";
|
||||
value = null;
|
||||
}
|
||||
|
||||
onChange?.(value);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
/* Set to true if displaying a single symbol character to disable monospace */
|
||||
/** Set to true if displaying a single symbol character to disable monospace */
|
||||
symbol?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { find } from "lodash";
|
||||
import find from "lodash/find";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
@@ -42,7 +42,7 @@ function Icon({ className }: { className?: string }) {
|
||||
}
|
||||
|
||||
export default function LanguagePrompt() {
|
||||
const { auth, ui } = useStores();
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const language = detectLanguage();
|
||||
@@ -75,9 +75,7 @@ export default function LanguagePrompt() {
|
||||
<Link
|
||||
onClick={async () => {
|
||||
ui.setLanguagePromptDismissed();
|
||||
await auth.updateUser({
|
||||
language,
|
||||
});
|
||||
await user.save({ language });
|
||||
}}
|
||||
>
|
||||
{t("Change Language")}
|
||||
|
||||
@@ -126,6 +126,7 @@ const Subtitle = styled.p<{ $small?: boolean; $selected?: boolean }>`
|
||||
export const Actions = styled(Flex)<{ $selected?: boolean }>`
|
||||
align-self: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: ${(props) =>
|
||||
props.$selected ? props.theme.white : props.theme.textSecondary};
|
||||
`;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { times } from "lodash";
|
||||
import times from "lodash/times";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Fade from "~/components/Fade";
|
||||
|
||||
@@ -94,11 +94,9 @@ const Modal: React.FC<Props> = ({
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<Text as="span" size="large">
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
</Text>
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
</Header>
|
||||
</Centered>
|
||||
</Small>
|
||||
@@ -259,7 +257,7 @@ const Small = styled.div`
|
||||
margin: auto auto;
|
||||
width: 30vw;
|
||||
min-width: 350px;
|
||||
max-width: 500px;
|
||||
max-width: 450px;
|
||||
z-index: ${depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { LocationDescriptor, LocationDescriptorObject } from "history";
|
||||
import * as React from "react";
|
||||
import { match, NavLink, Route } from "react-router-dom";
|
||||
|
||||
@@ -9,10 +9,20 @@ type Props = React.ComponentProps<typeof NavLink> & {
|
||||
[x: string]: string | undefined;
|
||||
}>
|
||||
| boolean
|
||||
| null
|
||||
| null,
|
||||
location: LocationDescriptorObject
|
||||
) => React.ReactNode;
|
||||
/**
|
||||
* If true, the tab will only be active if the path matches exactly.
|
||||
*/
|
||||
exact?: boolean;
|
||||
/**
|
||||
* CSS properties to apply to the link when it is active.
|
||||
*/
|
||||
activeStyle?: React.CSSProperties;
|
||||
/**
|
||||
* The path to match against the current location.
|
||||
*/
|
||||
to: LocationDescriptor;
|
||||
};
|
||||
|
||||
@@ -25,7 +35,10 @@ function NavLinkWithChildrenFunc(
|
||||
{({ match, location }) => (
|
||||
<NavLink {...rest} to={to} exact={exact} ref={ref}>
|
||||
{children
|
||||
? children(rest.isActive ? rest.isActive(match, location) : match)
|
||||
? children(
|
||||
rest.isActive ? rest.isActive(match, location) : match,
|
||||
location
|
||||
)
|
||||
: null}
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { SubscribeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Relative from "../Sidebar/components/Relative";
|
||||
|
||||
const NotificationIcon = () => {
|
||||
const { notifications } = useStores();
|
||||
const theme = useTheme();
|
||||
const count = notifications.approximateUnreadCount;
|
||||
|
||||
return (
|
||||
<Relative style={{ height: 24 }}>
|
||||
<SubscribeIcon color={theme.textTertiary} />
|
||||
<SubscribeIcon />
|
||||
{count > 0 && <Badge />}
|
||||
</Relative>
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ function NotificationListItem({ notification, onNavigate }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Link to={notification.path} onClick={handleClick}>
|
||||
<Link to={notification.path ?? ""} onClick={handleClick}>
|
||||
<Container gap={8} $unread={!notification.viewedAt}>
|
||||
<StyledAvatar model={notification.actor} size={AvatarSize.Large} />
|
||||
<Flex column>
|
||||
@@ -64,6 +64,7 @@ function NotificationListItem({ notification, onNavigate }: Props) {
|
||||
{notification.comment && (
|
||||
<StyledCommentEditor
|
||||
defaultValue={toJS(notification.comment.data)}
|
||||
previewsDisabled
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
@@ -87,6 +88,7 @@ const StyledAvatar = styled(Avatar)`
|
||||
const Container = styled(Flex)<{ $unread: boolean }>`
|
||||
position: relative;
|
||||
padding: 8px 12px;
|
||||
padding-right: 40px;
|
||||
margin: 0 8px;
|
||||
border-radius: 4px;
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import Tooltip from "../Tooltip";
|
||||
import NotificationListItem from "./NotificationListItem";
|
||||
|
||||
type Props = {
|
||||
/* Callback when the notification panel wants to close. */
|
||||
/** Callback when the notification panel wants to close. */
|
||||
onRequestClose: () => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import { shallow } from "enzyme";
|
||||
import { TFunction } from "i18next";
|
||||
import * as React from "react";
|
||||
import { getI18n } from "react-i18next";
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "~/stores/BaseStore";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "~/stores/base/Store";
|
||||
import { runAllPromises } from "~/test/support";
|
||||
import { Component as PaginatedList } from "./PaginatedList";
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { isEqual } from "lodash";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { observable, action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, WithTranslation } from "react-i18next";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import { CompositeStateReturn } from "reakit/Composite";
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "~/stores/BaseStore";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "~/stores/base/Store";
|
||||
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import PlaceholderList from "~/components/List/Placeholder";
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import * as React from "react";
|
||||
import { Dialog } from "reakit/Dialog";
|
||||
import { Popover as ReakitPopover, PopoverProps } from "reakit/Popover";
|
||||
import styled, { css } from "styled-components";
|
||||
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";
|
||||
|
||||
@@ -15,6 +16,8 @@ type Props = PopoverProps & {
|
||||
tabIndex?: number;
|
||||
scrollable?: boolean;
|
||||
mobilePosition?: "top" | "bottom";
|
||||
show: () => void;
|
||||
hide: () => void;
|
||||
};
|
||||
|
||||
const Popover: React.FC<Props> = ({
|
||||
@@ -28,6 +31,21 @@ const Popover: React.FC<Props> = ({
|
||||
}: Props) => {
|
||||
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>
|
||||
@@ -44,7 +62,7 @@ const Popover: React.FC<Props> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<ReakitPopover {...rest}>
|
||||
<ReakitPopover {...rest} hideOnEsc={false}>
|
||||
<Contents
|
||||
$shrink={shrink}
|
||||
$width={width}
|
||||
@@ -77,10 +95,13 @@ const Contents = styled.div<ContentsProps>`
|
||||
width: ${(props) => props.$width}px;
|
||||
|
||||
${(props) =>
|
||||
props.$scrollable &&
|
||||
css`
|
||||
props.$scrollable
|
||||
? `
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
`
|
||||
: `
|
||||
overflow: hidden;
|
||||
`}
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { debounce } from "lodash";
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon, SearchIcon, ShapesIcon, HomeIcon } from "outline-icons";
|
||||
import { EditIcon, SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
@@ -14,29 +14,26 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import OrganizationMenu from "~/menus/OrganizationMenu";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import {
|
||||
homePath,
|
||||
draftsPath,
|
||||
templatesPath,
|
||||
searchPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import { homePath, draftsPath, searchPath } from "~/utils/routeHelpers";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Tooltip from "../Tooltip";
|
||||
import Sidebar from "./Sidebar";
|
||||
import ArchiveLink from "./components/ArchiveLink";
|
||||
import Collections from "./components/Collections";
|
||||
import DragPlaceholder from "./components/DragPlaceholder";
|
||||
import HeaderButton, { HeaderButtonProps } from "./components/HeaderButton";
|
||||
import HistoryNavigation from "./components/HistoryNavigation";
|
||||
import Section from "./components/Section";
|
||||
import SidebarAction from "./components/SidebarAction";
|
||||
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
|
||||
import SidebarLink from "./components/SidebarLink";
|
||||
import Starred from "./components/Starred";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
import TrashLink from "./components/TrashLink";
|
||||
|
||||
function AppSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const { documents, ui } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const can = usePolicy(team);
|
||||
@@ -44,7 +41,6 @@ function AppSidebar() {
|
||||
React.useEffect(() => {
|
||||
if (!user.isViewer) {
|
||||
void documents.fetchDrafts();
|
||||
void documents.fetchTemplates();
|
||||
}
|
||||
}, [documents, user.isViewer]);
|
||||
|
||||
@@ -65,23 +61,34 @@ function AppSidebar() {
|
||||
<DragPlaceholder />
|
||||
|
||||
<OrganizationMenu>
|
||||
{(props: HeaderButtonProps) => (
|
||||
<HeaderButton
|
||||
{(props: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{...props}
|
||||
title={team.name}
|
||||
image={
|
||||
<TeamLogo
|
||||
model={team}
|
||||
size={Desktop.hasInsetTitlebar() ? 24 : 32}
|
||||
size={24}
|
||||
alt={t("Logo")}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
}
|
||||
style={
|
||||
// Move the logo over to align with smaller size
|
||||
Desktop.hasInsetTitlebar() ? { paddingLeft: 8 } : undefined
|
||||
}
|
||||
showDisclosure
|
||||
/>
|
||||
>
|
||||
<Tooltip
|
||||
tooltip={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
delay={500}
|
||||
>
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</SidebarButton>
|
||||
)}
|
||||
</OrganizationMenu>
|
||||
<Scrollable flex shadow>
|
||||
@@ -105,9 +112,11 @@ function AppSidebar() {
|
||||
label={
|
||||
<Flex align="center" justify="space-between">
|
||||
{t("Drafts")}
|
||||
<Drafts size="xsmall" type="tertiary">
|
||||
{documents.totalDrafts}
|
||||
</Drafts>
|
||||
{documents.totalDrafts > 0 ? (
|
||||
<Drafts size="xsmall" type="tertiary">
|
||||
{documents.totalDrafts}
|
||||
</Drafts>
|
||||
) : null}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
@@ -122,19 +131,6 @@ function AppSidebar() {
|
||||
<Section>
|
||||
{can.createDocument && (
|
||||
<>
|
||||
<SidebarLink
|
||||
to={templatesPath()}
|
||||
icon={<ShapesIcon />}
|
||||
exact={false}
|
||||
label={t("Templates")}
|
||||
active={
|
||||
documents.active
|
||||
? documents.active.isTemplate &&
|
||||
!documents.active.isDeleted &&
|
||||
!documents.active.isArchived
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ArchiveLink />
|
||||
<TrashLink />
|
||||
</>
|
||||
|
||||
@@ -120,7 +120,7 @@ const Position = styled(Flex)`
|
||||
const Sidebar = styled(m.div)<{
|
||||
$border?: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
background: ${s("background")};
|
||||
max-width: 80%;
|
||||
@@ -129,6 +129,7 @@ const Sidebar = styled(m.div)<{
|
||||
z-index: 1;
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
import { groupBy } from "lodash";
|
||||
import groupBy from "lodash/groupBy";
|
||||
import { observer } from "mobx-react";
|
||||
import { BackIcon } from "outline-icons";
|
||||
import { BackIcon, SidebarIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import Tooltip from "../Tooltip";
|
||||
import Sidebar from "./Sidebar";
|
||||
import Header from "./components/Header";
|
||||
import HeaderButton from "./components/HeaderButton";
|
||||
import HistoryNavigation from "./components/HistoryNavigation";
|
||||
import Section from "./components/Section";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
import SidebarLink from "./components/SidebarLink";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
import Version from "./components/Version";
|
||||
|
||||
function SettingsSidebar() {
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const configs = useSettingsConfig();
|
||||
const groupedConfig = groupBy(configs, "group");
|
||||
|
||||
@@ -31,12 +37,26 @@ function SettingsSidebar() {
|
||||
return (
|
||||
<Sidebar>
|
||||
<HistoryNavigation />
|
||||
<HeaderButton
|
||||
<SidebarButton
|
||||
title={t("Return to App")}
|
||||
image={<StyledBackIcon />}
|
||||
onClick={returnToApp}
|
||||
minHeight={Desktop.hasInsetTitlebar() ? undefined : 48}
|
||||
/>
|
||||
>
|
||||
<Tooltip
|
||||
tooltip={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
delay={500}
|
||||
>
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</SidebarButton>
|
||||
|
||||
<Flex auto column>
|
||||
<Scrollable shadow>
|
||||
@@ -47,6 +67,11 @@ function SettingsSidebar() {
|
||||
<SidebarLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
active={
|
||||
item.path !== settingsPath()
|
||||
? location.pathname.startsWith(item.path)
|
||||
: undefined
|
||||
}
|
||||
icon={<item.icon />}
|
||||
label={item.name}
|
||||
/>
|
||||
|
||||
@@ -5,15 +5,16 @@ import styled from "styled-components";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import SearchPopover from "~/components/SearchPopover";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { useTeamContext } from "../TeamContext";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Sidebar from "./Sidebar";
|
||||
import HeaderButton from "./components/HeaderButton";
|
||||
import Section from "./components/Section";
|
||||
import DocumentLink from "./components/SharedDocumentLink";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
|
||||
type Props = {
|
||||
rootNode: NavigationNode;
|
||||
@@ -22,18 +23,19 @@ type Props = {
|
||||
|
||||
function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
const team = useTeamContext();
|
||||
const { ui, documents, auth } = useStores();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const { ui, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
{team && (
|
||||
<HeaderButton
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
image={<TeamLogo model={team} size={32} alt={t("Logo")} />}
|
||||
onClick={() =>
|
||||
history.push(
|
||||
auth.user ? homePath() : sharedDocumentPath(shareId, rootNode.url)
|
||||
user ? homePath() : sharedDocumentPath(shareId, rootNode.url)
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Portal } from "react-portal";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMenuContext from "~/hooks/useMenuContext";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import AccountMenu from "~/menus/AccountMenu";
|
||||
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
|
||||
import { fadeOnDesktopBackgrounded } from "~/styles";
|
||||
import { fadeIn } from "~/styles/animations";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import Avatar from "../Avatar";
|
||||
import NotificationIcon from "../Notifications/NotificationIcon";
|
||||
import NotificationsPopover from "../Notifications/NotificationsPopover";
|
||||
import HeaderButton, { HeaderButtonProps } from "./components/HeaderButton";
|
||||
import ResizeBorder from "./components/ResizeBorder";
|
||||
import Toggle, { ToggleButton, Positioner } from "./components/Toggle";
|
||||
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
|
||||
const ANIMATION_MS = 250;
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
{ children }: Props,
|
||||
{ children, className }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const [isCollapsing, setCollapsing] = React.useState(false);
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { ui, auth } = useStores();
|
||||
const { ui } = useStores();
|
||||
const location = useLocation();
|
||||
const previousLocation = usePrevious(location);
|
||||
const { isMenuOpen } = useMenuContext();
|
||||
const { user } = auth;
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const width = ui.sidebarWidth;
|
||||
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
@@ -46,8 +46,10 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
|
||||
const setWidth = ui.setSidebarWidth;
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [isHovering, setHovering] = React.useState(false);
|
||||
const [isAnimating, setAnimating] = React.useState(false);
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
const [hasPointerMoved, setPointerMoved] = React.useState(false);
|
||||
const isSmallerThanMinimum = width < minWidth;
|
||||
|
||||
const handleDrag = React.useCallback(
|
||||
@@ -99,6 +101,34 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
[width]
|
||||
);
|
||||
|
||||
const handlePointerMove = React.useCallback(() => {
|
||||
if (ui.sidebarIsClosed) {
|
||||
setHovering(true);
|
||||
setPointerMoved(true);
|
||||
}
|
||||
}, [ui.sidebarIsClosed]);
|
||||
|
||||
const handlePointerLeave = React.useCallback(
|
||||
(ev) => {
|
||||
if (hasPointerMoved) {
|
||||
setHovering(
|
||||
ev.pageX < width &&
|
||||
ev.pageX > 0 &&
|
||||
ev.pageY < window.innerHeight &&
|
||||
ev.pageY > 0
|
||||
);
|
||||
}
|
||||
},
|
||||
[width, hasPointerMoved]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (ui.sidebarIsClosed) {
|
||||
setHovering(false);
|
||||
setPointerMoved(false);
|
||||
}
|
||||
}, [ui.sidebarIsClosed]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isAnimating) {
|
||||
setTimeout(() => setAnimating(false), ANIMATION_MS);
|
||||
@@ -147,23 +177,19 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
[width]
|
||||
);
|
||||
|
||||
const toggleStyle = React.useMemo(
|
||||
() => ({
|
||||
right: "auto",
|
||||
marginLeft: `${collapsed ? theme.sidebarCollapsedWidth : width}px`,
|
||||
}),
|
||||
[width, theme.sidebarCollapsedWidth, collapsed]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container
|
||||
ref={ref}
|
||||
style={style}
|
||||
$isHovering={isHovering}
|
||||
$isAnimating={isAnimating}
|
||||
$isSmallerThanMinimum={isSmallerThanMinimum}
|
||||
$mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
$collapsed={collapsed}
|
||||
className={className}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
column
|
||||
>
|
||||
{ui.mobileSidebarVisible && (
|
||||
@@ -175,26 +201,32 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
|
||||
{user && (
|
||||
<AccountMenu>
|
||||
{(props: HeaderButtonProps) => (
|
||||
<HeaderButton
|
||||
{(props: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{...props}
|
||||
showMoreMenu
|
||||
title={user.name}
|
||||
position="bottom"
|
||||
image={
|
||||
<StyledAvatar
|
||||
<Avatar
|
||||
alt={user.name}
|
||||
model={user}
|
||||
size={24}
|
||||
showBorder={false}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NotificationsPopover>
|
||||
{(rest: HeaderButtonProps) => (
|
||||
<HeaderButton {...rest} image={<NotificationIcon />} />
|
||||
{(rest: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{...rest}
|
||||
position="bottom"
|
||||
image={<NotificationIcon />}
|
||||
/>
|
||||
)}
|
||||
</NotificationsPopover>
|
||||
</HeaderButton>
|
||||
</SidebarButton>
|
||||
)}
|
||||
</AccountMenu>
|
||||
)}
|
||||
@@ -202,28 +234,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
|
||||
/>
|
||||
{ui.sidebarIsClosed && (
|
||||
<Toggle
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
direction={"right"}
|
||||
aria-label={t("Expand")}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
<Toggle
|
||||
style={toggleStyle}
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
direction={ui.sidebarIsClosed ? "right" : "left"}
|
||||
aria-label={ui.sidebarIsClosed ? t("Expand") : t("Collapse")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const StyledAvatar = styled(Avatar)`
|
||||
margin-left: 4px;
|
||||
`;
|
||||
|
||||
const Backdrop = styled.a`
|
||||
animation: ${fadeIn} 250ms ease-in-out;
|
||||
position: fixed;
|
||||
@@ -240,16 +255,33 @@ type ContainerProps = {
|
||||
$mobileSidebarVisible: boolean;
|
||||
$isAnimating: boolean;
|
||||
$isSmallerThanMinimum: boolean;
|
||||
$isHovering: boolean;
|
||||
$collapsed: boolean;
|
||||
};
|
||||
|
||||
const hoverStyles = (props: ContainerProps) => `
|
||||
transform: none;
|
||||
box-shadow: ${
|
||||
props.$collapsed
|
||||
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
|
||||
: props.$isSmallerThanMinimum
|
||||
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
|
||||
: "none"
|
||||
};
|
||||
|
||||
${ToggleButton} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)<ContainerProps>`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background: ${s("sidebarBackground")};
|
||||
transition: box-shadow 100ms ease-in-out, transform 100ms ease-out,
|
||||
transition: box-shadow 100ms ease-in-out, opacity 100ms ease-in-out,
|
||||
transform 100ms ease-out,
|
||||
${s("backgroundTransition")}
|
||||
${(props: ContainerProps) =>
|
||||
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
|
||||
@@ -259,19 +291,17 @@ const Container = styled(Flex)<ContainerProps>`
|
||||
z-index: ${depths.sidebar};
|
||||
max-width: 80%;
|
||||
min-width: 280px;
|
||||
padding-top: ${Desktop.hasInsetTitlebar() ? 36 : 0}px;
|
||||
${draggableOnDesktop()}
|
||||
${fadeOnDesktopBackgrounded()}
|
||||
|
||||
${Positioner} {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
& > div {
|
||||
opacity: ${(props) => (props.$collapsed && !props.$isHovering ? "0" : "1")};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
@@ -280,28 +310,20 @@ const Container = styled(Flex)<ContainerProps>`
|
||||
? `calc(-100% + ${Desktop.hasInsetTitlebar() ? 8 : 16}px)`
|
||||
: 0});
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
transform: none;
|
||||
box-shadow: ${(props: ContainerProps) =>
|
||||
props.$collapsed
|
||||
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
|
||||
: props.$isSmallerThanMinimum
|
||||
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
|
||||
: "none"};
|
||||
|
||||
${Positioner} {
|
||||
display: block;
|
||||
}
|
||||
${(props: ContainerProps) => props.$isHovering && css(hoverStyles)}
|
||||
|
||||
&:hover {
|
||||
${ToggleButton} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:hover):not(:focus-within) > div {
|
||||
opacity: ${(props: ContainerProps) => (props.$collapsed ? "0" : "1")};
|
||||
transition: opacity 100ms ease-in-out;
|
||||
&:focus-within {
|
||||
${hoverStyles}
|
||||
|
||||
& > div {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -3,24 +3,21 @@ import { ArchiveIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { archivePath } from "~/utils/routeHelpers";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
|
||||
function ArchiveLink() {
|
||||
const { policies, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToasts();
|
||||
|
||||
const [{ isDocumentDropping }, dropToArchiveDocument] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item: DragObject) => {
|
||||
const document = documents.get(item.id);
|
||||
await document?.archive();
|
||||
showToast(t("Document archived"), {
|
||||
type: "success",
|
||||
});
|
||||
toast.success(t("Document archived"));
|
||||
},
|
||||
canDrop: (item) => policies.abilities(item.id).archive,
|
||||
collect: (monitor) => ({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
@@ -9,7 +10,6 @@ import DocumentsLoader from "~/components/DocumentsLoader";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import EmptyCollectionPlaceholder from "./EmptyCollectionPlaceholder";
|
||||
@@ -30,7 +30,6 @@ function CollectionLinkChildren({
|
||||
prefetchDocument,
|
||||
}: Props) {
|
||||
const can = usePolicy(collection);
|
||||
const { showToast } = useToasts();
|
||||
const manualSort = collection.sort.field === "index";
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
@@ -42,14 +41,10 @@ function CollectionLinkChildren({
|
||||
accept: "document",
|
||||
drop: (item: DragObject) => {
|
||||
if (!manualSort && item.collectionId === collection?.id) {
|
||||
showToast(
|
||||
toast.message(
|
||||
t(
|
||||
"You can't reorder documents in an alphabetically sorted collection"
|
||||
),
|
||||
{
|
||||
type: "info",
|
||||
timeout: 5000,
|
||||
}
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useDrag, useDrop } from "react-dnd";
|
||||
import { getEmptyImage } from "react-dnd-html5-backend";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
@@ -18,12 +19,11 @@ import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
import DropCursor from "./DropCursor";
|
||||
import DropToImport from "./DropToImport";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
import EditableTitle, { RefHandle } from "./EditableTitle";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
@@ -53,7 +53,6 @@ function InnerDocumentLink(
|
||||
}: Props,
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
) {
|
||||
const { showToast } = useToasts();
|
||||
const { documents, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const canUpdate = usePolicy(node.id).update;
|
||||
@@ -63,6 +62,7 @@ function InnerDocumentLink(
|
||||
const document = documents.get(node.id);
|
||||
const { fetchChildDocuments } = documents;
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const editableTitleRef = React.useRef<RefHandle>(null);
|
||||
const inStarredSection = useStarredContext();
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -221,14 +221,10 @@ function InnerDocumentLink(
|
||||
accept: "document",
|
||||
drop: (item: DragObject) => {
|
||||
if (!manualSort) {
|
||||
showToast(
|
||||
toast.message(
|
||||
t(
|
||||
"You can't reorder documents in an alphabetically sorted collection"
|
||||
),
|
||||
{
|
||||
type: "info",
|
||||
timeout: 5000,
|
||||
}
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -275,10 +271,6 @@ function InnerDocumentLink(
|
||||
node,
|
||||
]);
|
||||
|
||||
const handleTitleEditing = React.useCallback((isEditing: boolean) => {
|
||||
setIsEditing(isEditing);
|
||||
}, []);
|
||||
|
||||
const title =
|
||||
(activeDocument?.id === node.id ? activeDocument.title : node.title) ||
|
||||
t("Untitled");
|
||||
@@ -324,13 +316,15 @@ function InnerDocumentLink(
|
||||
starred: inStarredSection,
|
||||
},
|
||||
}}
|
||||
emoji={document?.emoji || node.emoji}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={title}
|
||||
onSubmit={handleTitleChange}
|
||||
onEditing={handleTitleEditing}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={canUpdate}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
ref={editableTitleRef}
|
||||
/>
|
||||
}
|
||||
isActive={(match, location: Location<{ starred?: boolean }>) =>
|
||||
@@ -367,6 +361,9 @@ function InnerDocumentLink(
|
||||
)}
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onRename={() =>
|
||||
editableTitleRef.current?.setIsEditing(true)
|
||||
}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
|
||||
@@ -3,12 +3,12 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled, { css } from "styled-components";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import useImportDocument from "~/hooks/useImportDocument";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
children: JSX.Element;
|
||||
@@ -21,7 +21,6 @@ type Props = {
|
||||
function DropToImport({ disabled, children, collectionId, documentId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const { handleFiles, isImporting } = useImportDocument(
|
||||
collectionId,
|
||||
documentId
|
||||
@@ -35,13 +34,10 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
|
||||
const canDocument = usePolicy(documentId);
|
||||
|
||||
const handleRejection = React.useCallback(() => {
|
||||
showToast(
|
||||
t("Document not supported – try Markdown, Plain text, HTML, or Word"),
|
||||
{
|
||||
type: "error",
|
||||
}
|
||||
toast.error(
|
||||
t("Document not supported – try Markdown, Plain text, HTML, or Word")
|
||||
);
|
||||
}, [t, showToast]);
|
||||
}, [t]);
|
||||
|
||||
if (
|
||||
disabled ||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
onSubmit: (title: string) => Promise<void>;
|
||||
@@ -11,17 +11,21 @@ type Props = {
|
||||
maxLength?: number;
|
||||
};
|
||||
|
||||
function EditableTitle({
|
||||
title,
|
||||
onSubmit,
|
||||
canUpdate,
|
||||
onEditing,
|
||||
...rest
|
||||
}: Props) {
|
||||
export type RefHandle = {
|
||||
setIsEditing: (isEditing: boolean) => void;
|
||||
};
|
||||
|
||||
function EditableTitle(
|
||||
{ title, onSubmit, canUpdate, onEditing, ...rest }: Props,
|
||||
ref: React.RefObject<RefHandle>
|
||||
) {
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [originalValue, setOriginalValue] = React.useState(title);
|
||||
const [value, setValue] = React.useState(title);
|
||||
const { showToast } = useToasts();
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
setIsEditing,
|
||||
}));
|
||||
|
||||
React.useEffect(() => {
|
||||
setValue(title);
|
||||
@@ -73,14 +77,12 @@ function EditableTitle({
|
||||
setOriginalValue(trimmedValue);
|
||||
} catch (error) {
|
||||
setValue(originalValue);
|
||||
showToast(error.message, {
|
||||
type: "error",
|
||||
});
|
||||
toast.error(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
[originalValue, showToast, value, onSubmit]
|
||||
[originalValue, value, onSubmit]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -128,4 +130,4 @@ const Input = styled.input`
|
||||
}
|
||||
`;
|
||||
|
||||
export default EditableTitle;
|
||||
export default React.forwardRef(EditableTitle);
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { ExpandedIcon, MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
export type HeaderButtonProps = React.ComponentProps<typeof Button> & {
|
||||
title: React.ReactNode;
|
||||
image: React.ReactNode;
|
||||
minHeight?: number;
|
||||
rounded?: boolean;
|
||||
showDisclosure?: boolean;
|
||||
showMoreMenu?: boolean;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const HeaderButton = React.forwardRef<HTMLButtonElement, HeaderButtonProps>(
|
||||
function _HeaderButton(
|
||||
{
|
||||
showDisclosure,
|
||||
showMoreMenu,
|
||||
image,
|
||||
title,
|
||||
minHeight = 0,
|
||||
children,
|
||||
...rest
|
||||
}: HeaderButtonProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<Flex justify="space-between" align="center" shrink={false}>
|
||||
<Button
|
||||
{...rest}
|
||||
minHeight={minHeight}
|
||||
as="button"
|
||||
ref={ref}
|
||||
role="button"
|
||||
>
|
||||
<Title gap={8} align="center">
|
||||
{image}
|
||||
{title}
|
||||
</Title>
|
||||
{showDisclosure && <ExpandedIcon />}
|
||||
{showMoreMenu && <MoreIcon />}
|
||||
</Button>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const Title = styled(Flex)`
|
||||
color: ${s("text")};
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Button = styled(Flex)<{ minHeight: number }>`
|
||||
flex: 1;
|
||||
color: ${s("textTertiary")};
|
||||
align-items: center;
|
||||
padding: 8px 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
margin: 8px 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
flex-shrink: 0;
|
||||
min-height: ${(props) => props.minHeight}px;
|
||||
|
||||
-webkit-appearance: none;
|
||||
text-decoration: none;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
cursor: var(--pointer);
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
&:active,
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
color: ${s("sidebarText")};
|
||||
transition: background 100ms ease-in-out;
|
||||
background: ${s("sidebarActiveBackground")};
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-left: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default HeaderButton;
|
||||
@@ -3,12 +3,12 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { isMac } from "@shared/utils/browser";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { isMac } from "~/utils/browser";
|
||||
|
||||
function HistoryNavigation(props: React.ComponentProps<typeof Flex>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import includes from "lodash/includes";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -6,14 +7,14 @@ import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import Disclosure from "./Disclosure";
|
||||
import { descendants } from "~/utils/tree";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
node: NavigationNode;
|
||||
collection?: Collection;
|
||||
activeDocumentId: string | undefined;
|
||||
activeDocument: Document | undefined;
|
||||
activeDocumentId?: string;
|
||||
activeDocument?: Document;
|
||||
isDraft?: boolean;
|
||||
depth: number;
|
||||
index: number;
|
||||
@@ -41,10 +42,19 @@ function DocumentLink(
|
||||
const hasChildDocuments =
|
||||
!!node.children.length || activeDocument?.parentDocumentId === node.id;
|
||||
const document = documents.get(node.id);
|
||||
|
||||
const showChildren = React.useMemo(
|
||||
() => !!hasChildDocuments,
|
||||
[hasChildDocuments]
|
||||
() =>
|
||||
!!(
|
||||
hasChildDocuments &&
|
||||
((activeDocumentId &&
|
||||
includes(
|
||||
descendants(node).map((n) => n.id),
|
||||
activeDocumentId
|
||||
)) ||
|
||||
isActiveDocument ||
|
||||
depth <= 1)
|
||||
),
|
||||
[hasChildDocuments, activeDocumentId, isActiveDocument, depth, node]
|
||||
);
|
||||
|
||||
const [expanded, setExpanded] = React.useState(showChildren);
|
||||
@@ -55,12 +65,6 @@ function DocumentLink(
|
||||
}
|
||||
}, [showChildren]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isActiveDocument) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [isActiveDocument]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
@@ -105,14 +109,10 @@ function DocumentLink(
|
||||
title: node.title,
|
||||
},
|
||||
}}
|
||||
label={
|
||||
<>
|
||||
{hasChildDocuments && depth !== 0 && (
|
||||
<Disclosure expanded={expanded} onClick={handleDisclosureClick} />
|
||||
)}
|
||||
{title}
|
||||
</>
|
||||
}
|
||||
expanded={hasChildDocuments && depth !== 0 ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
emoji={node.emoji}
|
||||
label={title}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
scrollIntoViewIfNeeded={!document?.isStarred}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { extraArea, s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import { draggableOnDesktop, undraggableOnDesktop } from "~/styles";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
|
||||
export type SidebarButtonProps = React.ComponentProps<typeof Button> & {
|
||||
position: "top" | "bottom";
|
||||
title: React.ReactNode;
|
||||
image: React.ReactNode;
|
||||
showMoreMenu?: boolean;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
|
||||
function _SidebarButton(
|
||||
{
|
||||
position = "top",
|
||||
showMoreMenu,
|
||||
image,
|
||||
title,
|
||||
children,
|
||||
...rest
|
||||
}: SidebarButtonProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<Container
|
||||
justify="space-between"
|
||||
align="center"
|
||||
shrink={false}
|
||||
$position={position}
|
||||
>
|
||||
<Button
|
||||
{...rest}
|
||||
$position={position}
|
||||
as="button"
|
||||
ref={ref}
|
||||
role="button"
|
||||
>
|
||||
<Content gap={8} align="center">
|
||||
{image}
|
||||
{title && <Title as="span">{title}</Title>}
|
||||
</Content>
|
||||
{showMoreMenu && <StyledMoreIcon />}
|
||||
</Button>
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const StyledMoreIcon = styled(MoreIcon)`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)<{ $position: "top" | "bottom" }>`
|
||||
padding-top: ${(props) =>
|
||||
props.$position === "top" && Desktop.hasInsetTitlebar() ? 36 : 0}px;
|
||||
${draggableOnDesktop()}
|
||||
`;
|
||||
|
||||
const Title = styled(Text)`
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const Content = styled(Flex)`
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const Button = styled(Flex)<{
|
||||
$position: "top" | "bottom";
|
||||
}>`
|
||||
flex: 1;
|
||||
color: ${s("textTertiary")};
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
border: 0;
|
||||
margin: ${(props) => (props.$position === "top" ? 16 : 8)}px 0;
|
||||
background: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
-webkit-appearance: none;
|
||||
text-decoration: none;
|
||||
text-align: left;
|
||||
user-select: none;
|
||||
cursor: var(--pointer);
|
||||
position: relative;
|
||||
|
||||
${undraggableOnDesktop()}
|
||||
${extraArea(4)}
|
||||
|
||||
&:active,
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
color: ${s("sidebarText")};
|
||||
transition: background 100ms ease-in-out;
|
||||
background: ${s("sidebarActiveBackground")};
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-left: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default SidebarButton;
|
||||
@@ -5,6 +5,7 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import EventBoundary from "~/components/EventBoundary";
|
||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import useUnmount from "~/hooks/useUnmount";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
@@ -21,16 +22,17 @@ type Props = Omit<NavLinkProps, "to"> & {
|
||||
to?: LocationDescriptor;
|
||||
innerRef?: (ref: HTMLElement | null | undefined) => void;
|
||||
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
|
||||
/* Callback when we expect the user to click on the link. Used for prefetching data. */
|
||||
/** Callback when we expect the user to click on the link. Used for prefetching data. */
|
||||
onClickIntent?: () => void;
|
||||
onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
icon?: React.ReactNode;
|
||||
emoji?: string | null;
|
||||
label?: React.ReactNode;
|
||||
menu?: React.ReactNode;
|
||||
showActions?: boolean;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
/* If set, a disclosure will be rendered to the left of any icon */
|
||||
/** If set, a disclosure will be rendered to the left of any icon */
|
||||
expanded?: boolean;
|
||||
isActiveDrop?: boolean;
|
||||
isDraft?: boolean;
|
||||
@@ -48,6 +50,7 @@ function SidebarLink(
|
||||
onClick,
|
||||
onClickIntent,
|
||||
to,
|
||||
emoji,
|
||||
label,
|
||||
active,
|
||||
isActiveDrop,
|
||||
@@ -136,6 +139,7 @@ function SidebarLink(
|
||||
/>
|
||||
)}
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
{emoji && <EmojiIcon emoji={emoji} />}
|
||||
<Label>{label}</Label>
|
||||
</Content>
|
||||
</Link>
|
||||
@@ -152,6 +156,7 @@ const Content = styled.span`
|
||||
|
||||
${Disclosure} {
|
||||
margin-top: 2px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -292,7 +297,7 @@ const Label = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: 4.8em;
|
||||
line-height: 1.6;
|
||||
line-height: 24px;
|
||||
|
||||
* {
|
||||
unicode-bidi: plaintext;
|
||||
|
||||
@@ -3,9 +3,11 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import Star from "~/models/Star";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import Flex from "~/components/Flex";
|
||||
import usePaginatedRequest from "~/hooks/usePaginatedRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Header from "./Header";
|
||||
@@ -18,36 +20,16 @@ import StarredLink from "./StarredLink";
|
||||
const STARRED_PAGINATION_LIMIT = 10;
|
||||
|
||||
function Starred() {
|
||||
const [fetchError, setFetchError] = React.useState();
|
||||
const [displayedStarsCount, setDisplayedStarsCount] = React.useState(
|
||||
STARRED_PAGINATION_LIMIT
|
||||
);
|
||||
const { stars } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const fetchResults = React.useCallback(
|
||||
async (offset = 0) => {
|
||||
try {
|
||||
await stars.fetchPage({
|
||||
limit: STARRED_PAGINATION_LIMIT + 1,
|
||||
offset,
|
||||
});
|
||||
} catch (error) {
|
||||
setFetchError(error);
|
||||
}
|
||||
},
|
||||
[stars]
|
||||
const { loading, next, end, error, page } = usePaginatedRequest<Star>(
|
||||
stars.fetchPage,
|
||||
{
|
||||
limit: STARRED_PAGINATION_LIMIT,
|
||||
}
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
void fetchResults();
|
||||
}, []);
|
||||
|
||||
const handleShowMore = async () => {
|
||||
await fetchResults(displayedStarsCount);
|
||||
setDisplayedStarsCount((prev) => prev + STARRED_PAGINATION_LIMIT);
|
||||
};
|
||||
|
||||
// Drop to reorder document
|
||||
const [{ isOverReorder, isDraggingAnyStar }, dropToReorder] = useDrop({
|
||||
accept: "star",
|
||||
@@ -62,6 +44,10 @@ function Starred() {
|
||||
}),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(t("Could not load starred documents"));
|
||||
}
|
||||
|
||||
if (!stars.orderedData.length) {
|
||||
return null;
|
||||
}
|
||||
@@ -78,18 +64,20 @@ function Starred() {
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{stars.orderedData.slice(0, displayedStarsCount).map((star) => (
|
||||
<StarredLink key={star.id} star={star} />
|
||||
))}
|
||||
{stars.orderedData.length > displayedStarsCount && (
|
||||
{stars.orderedData
|
||||
.slice(0, page * STARRED_PAGINATION_LIMIT)
|
||||
.map((star) => (
|
||||
<StarredLink key={star.id} star={star} />
|
||||
))}
|
||||
{!end && (
|
||||
<SidebarLink
|
||||
onClick={handleShowMore}
|
||||
onClick={next}
|
||||
label={`${t("Show more")}…`}
|
||||
disabled={stars.isFetching}
|
||||
depth={0}
|
||||
/>
|
||||
)}
|
||||
{(stars.isFetching || fetchError) && !stars.orderedData.length && (
|
||||
{loading && (
|
||||
<Flex column>
|
||||
<DelayedMount>
|
||||
<PlaceholderCollections />
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useDrag, useDrop } from "react-dnd";
|
||||
import { getEmptyImage } from "react-dnd-html5-backend";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import parseTitle from "@shared/utils/parseTitle";
|
||||
import Star from "~/models/Star";
|
||||
import Fade from "~/components/Fade";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
@@ -42,14 +41,10 @@ function useLabelAndIcon({ documentId, collectionId }: Star) {
|
||||
if (documentId) {
|
||||
const document = documents.get(documentId);
|
||||
if (document) {
|
||||
const { emoji } = parseTitle(document?.title);
|
||||
|
||||
return {
|
||||
label: emoji
|
||||
? document.title.replace(emoji, "")
|
||||
: document.titleWithDefault,
|
||||
icon: emoji ? (
|
||||
<EmojiIcon emoji={emoji} />
|
||||
label: document.titleWithDefault,
|
||||
icon: document.emoji ? (
|
||||
<EmojiIcon emoji={document.emoji} />
|
||||
) : (
|
||||
<StarredIcon color={theme.yellow} />
|
||||
),
|
||||
@@ -148,6 +143,10 @@ function StarredLink({ star }: Props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { emoji } = document;
|
||||
const label = emoji
|
||||
? document.title.replace(emoji, "")
|
||||
: document.titleWithDefault;
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Arrow from "~/components/Arrow";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
|
||||
type Props = {
|
||||
direction: "left" | "right";
|
||||
style?: React.CSSProperties;
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
const Toggle = React.forwardRef<HTMLButtonElement, Props>(function Toggle_(
|
||||
{ direction = "left", onClick, style }: Props,
|
||||
ref
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const [hovering, setHovering] = React.useState(false);
|
||||
const positionRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Not using CSS hover here so that we can disable pointer events on this
|
||||
// div and allow click through to the editor elements behind.
|
||||
useEventListener("mousemove", (event: MouseEvent) => {
|
||||
if (!positionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bound = positionRef.current.getBoundingClientRect();
|
||||
const withinBounds =
|
||||
event.clientX >= bound.left && event.clientX <= bound.right;
|
||||
if (withinBounds !== hovering) {
|
||||
setHovering(withinBounds);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Positioner style={style} ref={positionRef} $hovering={hovering}>
|
||||
<ToggleButton
|
||||
ref={ref}
|
||||
$direction={direction}
|
||||
onClick={onClick}
|
||||
aria-label={t("Toggle sidebar")}
|
||||
>
|
||||
<Arrow />
|
||||
</ToggleButton>
|
||||
</Positioner>
|
||||
);
|
||||
});
|
||||
|
||||
export const ToggleButton = styled.button<{ $direction?: "left" | "right" }>`
|
||||
opacity: 0;
|
||||
background: none;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
transform: translateY(-50%)
|
||||
scaleX(${(props) => (props.$direction === "left" ? 1 : -1)});
|
||||
position: fixed;
|
||||
top: 50vh;
|
||||
padding: 8px;
|
||||
border: 0;
|
||||
pointer-events: none;
|
||||
color: ${s("divider")};
|
||||
|
||||
&:active {
|
||||
color: ${s("sidebarText")};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
pointer-events: all;
|
||||
cursor: var(--pointer);
|
||||
`}
|
||||
|
||||
@media (hover: none) {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Positioner = styled.div<{ $hovering: boolean }>`
|
||||
display: none;
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: -30px;
|
||||
width: 30px;
|
||||
pointer-events: none;
|
||||
|
||||
&:focus-within ${ToggleButton} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$hovering &&
|
||||
css`
|
||||
${ToggleButton} {
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: block;
|
||||
`}
|
||||
`;
|
||||
|
||||
export default Toggle;
|
||||
@@ -0,0 +1,15 @@
|
||||
import styled from "styled-components";
|
||||
import { hover } from "~/styles";
|
||||
import SidebarButton from "./SidebarButton";
|
||||
|
||||
const ToggleButton = styled(SidebarButton)`
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
|
||||
&:${hover},
|
||||
&:active {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
export default ToggleButton;
|
||||
+20
-12
@@ -3,29 +3,37 @@ import styled from "styled-components";
|
||||
import Flex from "./Flex";
|
||||
|
||||
type Props = {
|
||||
/** The width and height of the squircle */
|
||||
size?: number;
|
||||
/** The color of the squircle */
|
||||
color?: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Squircle: React.FC<Props> = ({ color, size = 28, children }: Props) => (
|
||||
<Wrapper
|
||||
style={{ width: size, height: size }}
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<svg width={size} height={size} viewBox="0 0 28 28">
|
||||
<path
|
||||
fill={color}
|
||||
d="M0 11.1776C0 1.97285 1.97285 0 11.1776 0H16.8224C26.0272 0 28 1.97285 28 11.1776V16.8224C28 26.0272 26.0272 28 16.8224 28H11.1776C1.97285 28 0 26.0272 0 16.8224V11.1776Z"
|
||||
/>
|
||||
const Squircle: React.FC<Props> = ({
|
||||
color,
|
||||
size = 28,
|
||||
children,
|
||||
className,
|
||||
}: Props) => (
|
||||
<Wrapper size={size} align="center" justify="center" className={className}>
|
||||
<svg width={size} height={size} fill={color} viewBox="0 0 28 28">
|
||||
<path d="M0 11.1776C0 1.97285 1.97285 0 11.1776 0H16.8224C26.0272 0 28 1.97285 28 11.1776V16.8224C28 26.0272 26.0272 28 16.8224 28H11.1776C1.97285 28 0 26.0272 0 16.8224V11.1776Z" />
|
||||
</svg>
|
||||
<Content>{children}</Content>
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
const Wrapper = styled(Flex)<{ size: number }>`
|
||||
position: relative;
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
|
||||
svg {
|
||||
transition: fill 150ms ease-in-out;
|
||||
transition-delay: var(--delay);
|
||||
}
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
|
||||
+13
-2
@@ -1,6 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { StarredIcon, UnstarredIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
@@ -14,12 +15,18 @@ import { hover } from "~/styles";
|
||||
import NudeButton from "./NudeButton";
|
||||
|
||||
type Props = {
|
||||
/** Target collection */
|
||||
collection?: Collection;
|
||||
/** Target document */
|
||||
document?: Document;
|
||||
/** Size of the star */
|
||||
size?: number;
|
||||
/** Color override for the star */
|
||||
color?: string;
|
||||
};
|
||||
|
||||
function Star({ size, document, collection, ...rest }: Props) {
|
||||
function Star({ size, document, collection, color, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const context = useActionContext({
|
||||
activeDocumentId: document?.id,
|
||||
@@ -36,6 +43,10 @@ function Star({ size, document, collection, ...rest }: Props) {
|
||||
<NudeButton
|
||||
context={context}
|
||||
hideOnActionDisabled
|
||||
tooltip={{
|
||||
tooltip: target.isStarred ? t("Unstar document") : t("Star document"),
|
||||
delay: 500,
|
||||
}}
|
||||
action={
|
||||
collection
|
||||
? collection.isStarred
|
||||
@@ -55,7 +66,7 @@ function Star({ size, document, collection, ...rest }: Props) {
|
||||
) : (
|
||||
<AnimatedStar
|
||||
size={size}
|
||||
color={theme.textTertiary}
|
||||
color={color ?? theme.textTertiary}
|
||||
as={UnstarredIcon}
|
||||
/>
|
||||
)}
|
||||
|
||||
+39
-11
@@ -1,4 +1,7 @@
|
||||
import { m } from "framer-motion";
|
||||
import { LocationDescriptor } from "history";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import queryString from "query-string";
|
||||
import * as React from "react";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
@@ -6,8 +9,19 @@ import NavLink from "~/components/NavLink";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
type Props = Omit<React.ComponentProps<typeof NavLink>, "children"> & {
|
||||
to: string;
|
||||
/**
|
||||
* The path to match against the current location.
|
||||
*/
|
||||
to: LocationDescriptor;
|
||||
/**
|
||||
* If true, the tab will only be active if the path matches exactly.
|
||||
*/
|
||||
exact?: boolean;
|
||||
/**
|
||||
* If true, the tab will only be active if the query string matches exactly.
|
||||
* By default query string parameters are ignored for location mathing.
|
||||
*/
|
||||
exactQueryString?: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -45,24 +59,38 @@ const transition = {
|
||||
damping: 30,
|
||||
};
|
||||
|
||||
const Tab: React.FC<Props> = ({ children, ...rest }: Props) => {
|
||||
const Tab: React.FC<Props> = ({
|
||||
children,
|
||||
exact,
|
||||
exactQueryString,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const theme = useTheme();
|
||||
const activeStyle = {
|
||||
color: theme.textSecondary,
|
||||
};
|
||||
|
||||
return (
|
||||
<TabLink {...rest} activeStyle={activeStyle}>
|
||||
{(match) => (
|
||||
<TabLink
|
||||
{...rest}
|
||||
exact={exact || exactQueryString}
|
||||
activeStyle={activeStyle}
|
||||
>
|
||||
{(match, location) => (
|
||||
<>
|
||||
{children}
|
||||
{match && (
|
||||
<Active
|
||||
layoutId="underline"
|
||||
initial={false}
|
||||
transition={transition}
|
||||
/>
|
||||
)}
|
||||
{match &&
|
||||
(!exactQueryString ||
|
||||
isEqual(
|
||||
queryString.parse(location.search ?? ""),
|
||||
queryString.parse(rest.to.search as string)
|
||||
)) && (
|
||||
<Active
|
||||
layoutId="underline"
|
||||
initial={false}
|
||||
transition={transition}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabLink>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isEqual } from "lodash";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user