mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
554 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e9beac59f | |||
| a0f7c76405 | |||
| 454a4e9a8d | |||
| ef9c410d97 | |||
| 7c2f779f68 | |||
| c45de6904b | |||
| 4758778fc7 | |||
| 401ae73a04 | |||
| 0ddbd9c608 | |||
| 6c4e2a9d11 | |||
| d8f1f55a80 | |||
| 9b811c999d | |||
| 5a60329021 | |||
| 042ea7b61f | |||
| 80acc16791 | |||
| 3c25b2b047 | |||
| 16f1328a83 | |||
| d1a7a30c00 | |||
| 5b67273d8f | |||
| fdd8ecc79d | |||
| 7c15d03b50 | |||
| b691311f88 | |||
| eda023c908 | |||
| 2331bbbd36 | |||
| 228d1faa9f | |||
| ff6d30581a | |||
| 027545a768 | |||
| 91585ee09d | |||
| a13f2c7311 | |||
| d4a51b420f | |||
| faa02623b3 | |||
| 2baf4d7d8b | |||
| 2b21ac1b97 | |||
| d2fcd1dee6 | |||
| 3b43460a0a | |||
| 1864ed605f | |||
| 20932a08d0 | |||
| 7e1ea69939 | |||
| a3983c36c9 | |||
| ccdcda372f | |||
| 07ad5032b4 | |||
| 286aea2701 | |||
| 30e63e022c | |||
| b88670b58d | |||
| ddc883bfcd | |||
| 67691477a9 | |||
| e3807a1c75 | |||
| f95ce018e1 | |||
| 2201fd7bd6 | |||
| fbb793ab8e | |||
| 31f8a3fb44 | |||
| 03ebca2f0c | |||
| 2a17e0cbf6 | |||
| 9ac1e13227 | |||
| bd0240b7a5 | |||
| 81bd68380e | |||
| b3d8bd1cc8 | |||
| a30487c2d7 | |||
| 8b3c58a162 | |||
| 43a91626b2 | |||
| 15c8a4867f | |||
| d94caf2783 | |||
| aaeb6f7dc6 | |||
| e0289aed40 | |||
| 8865d394c6 | |||
| 1d893a06f9 | |||
| 0c291ee806 | |||
| 8732155dbb | |||
| 56e01b784d | |||
| e47d493d13 | |||
| 2677c964a5 | |||
| f8927ff819 | |||
| 72adcd10ef | |||
| 7bc37cb700 | |||
| 217e53d8b6 | |||
| 404f5ff871 | |||
| 0db6f39f43 | |||
| 479b805613 | |||
| 48f1047016 | |||
| caf7333682 | |||
| cd59af4a9b | |||
| 8d549abaa9 | |||
| 5e705f3dc7 | |||
| 5d71398ea6 | |||
| dbd85d62cb | |||
| d180ecbe96 | |||
| 9046abb682 | |||
| 5810ddb589 | |||
| 0d30220017 | |||
| b60e46a961 | |||
| 3c6e2aaac6 | |||
| eae6204d55 | |||
| 1e78079ade | |||
| d3fc6fc0fd | |||
| 0f10fe4052 | |||
| d928d456de | |||
| 5206beaf19 | |||
| 70113be9af | |||
| 04ea3431e7 | |||
| d3ce70016e | |||
| 46d6664307 | |||
| 2427f4747a | |||
| 60b456f35a | |||
| 64b2718673 | |||
| 4b14fa5dd7 | |||
| abb38ea447 | |||
| e81f97b2de | |||
| e653b185a4 | |||
| 39e12cef65 | |||
| b1230d0c81 | |||
| 6e9e1c15a5 | |||
| 66331d3d4f | |||
| ea07b72c7a | |||
| 5dcd7a74ca | |||
| 5c83070941 | |||
| a9ab196a18 | |||
| b9fc301589 | |||
| c56add74c6 | |||
| 5ae4834333 | |||
| 437865e7aa | |||
| 042f2ff737 | |||
| 5656384cc4 | |||
| 098d91808b | |||
| 21d446881e | |||
| cf32d227e6 | |||
| e59e121179 | |||
| 98a182c892 | |||
| 6bc1b789ee | |||
| a8674c7dda | |||
| c952dfa065 | |||
| 9a95fa47a0 | |||
| 5bfb2c89c8 | |||
| 9b6a645928 | |||
| d550fb79d3 | |||
| ed9cf4cee3 | |||
| 8cc2853102 | |||
| 814bacbead | |||
| 9431df45c2 | |||
| a75d6b298e | |||
| ff1bc5db2a | |||
| e6e9512979 | |||
| 29db1ef1bf | |||
| b54a370e01 | |||
| cce22bcdee | |||
| da62c2c044 | |||
| 201690e342 | |||
| 4fddc0fd87 | |||
| 72c7b0373b | |||
| 7c5d834f39 | |||
| f1f3159b12 | |||
| a62739be8f | |||
| 3124423eeb | |||
| 40dbbe10c5 | |||
| a4ba1f18bc | |||
| 0aabcb8d22 | |||
| 3c2e7b5b63 | |||
| 9d4a1965b0 | |||
| ea527bf147 | |||
| 02744411f3 | |||
| f843a20a54 | |||
| 7aec0e24ef | |||
| 843092e5f0 | |||
| 1fd7f75929 | |||
| 768fcbf6c4 | |||
| f214db0ab7 | |||
| aec190245b | |||
| 88775cd287 | |||
| 64b2bfe704 | |||
| 8957e86c12 | |||
| 4bec08ee05 | |||
| a3d70622af | |||
| 92c8eff61d | |||
| 73c2a67fa5 | |||
| c2af5db0f8 | |||
| 1e4b59ac6c | |||
| 9933f30c25 | |||
| bbee4b5791 | |||
| 89d5527d39 | |||
| f3d8129a13 | |||
| 87a675d02b | |||
| a2fae1f1eb | |||
| e001b8d161 | |||
| 5000500f96 | |||
| 8a33bdcb8a | |||
| 9987c15daf | |||
| 06f2d7a993 | |||
| a234a92f80 | |||
| 7314a71bf1 | |||
| edb9099c9d | |||
| 94882d4d3a | |||
| 453bbb3b25 | |||
| e58163ef5f | |||
| 7940cef517 | |||
| 86d6117a31 | |||
| 4e69ae1ffe | |||
| 06033ac781 | |||
| 25e8c32b84 | |||
| 08601a9f84 | |||
| 831318d941 | |||
| d96bf5106d | |||
| a094087342 | |||
| 8d69de1be0 | |||
| 6d556c7a55 | |||
| eb62b961a4 | |||
| 0e5a576439 | |||
| e7861d0bb9 | |||
| 25ae923130 | |||
| d7ae829d92 | |||
| cef048572a | |||
| 51ed458ab2 | |||
| 38e8a649ef | |||
| 127728db29 | |||
| 8b87ab0fd7 | |||
| ba65c52e97 | |||
| f76f2e61d8 | |||
| b3c08fdb48 | |||
| 9cfe5da70b | |||
| 5fa8ebc587 | |||
| 9ef375d83c | |||
| 9d04d5ebd9 | |||
| 4494a30441 | |||
| 7bce4c807d | |||
| 80a9bae761 | |||
| f40e1f09fd | |||
| 2c3e842b3c | |||
| f1d1035550 | |||
| c23e415237 | |||
| 137c33c323 | |||
| e00c30bd1f | |||
| 1ddb9aba3b | |||
| 746c27e718 | |||
| d319bb7d9a | |||
| 3f7e66980b | |||
| d5c1336580 | |||
| a810d9176f | |||
| ffbf138748 | |||
| b9844472b9 | |||
| 4363d4e1e5 | |||
| 46c4185078 | |||
| 424dea8197 | |||
| 0d0b53f292 | |||
| d18994b14e | |||
| a8a506af3e | |||
| ba3a47d138 | |||
| c70bef0fdd | |||
| 5f4e942d31 | |||
| 874bdb1e11 | |||
| 7f8a177b01 | |||
| 5e76d72ae6 | |||
| 45641103ba | |||
| 555691c79b | |||
| 20ea422f77 | |||
| 045dbb932e | |||
| 582c4ba99d | |||
| d519e93300 | |||
| 5dab811c5f | |||
| ef7ae719a6 | |||
| aa80f5667f | |||
| d57ec39113 | |||
| be3bcebf6b | |||
| e9ec31e5b8 | |||
| 33b0354cfe | |||
| d5341a486c | |||
| e340e568e2 | |||
| e8b9a1b650 | |||
| bb0cd891bd | |||
| fbd16d4b9a | |||
| 3317bf2396 | |||
| 4e75b4029a | |||
| 73fe70817f | |||
| 3c5dc10446 | |||
| d587360f4b | |||
| 458f24185a | |||
| 502d8b9e8d | |||
| 7e6831c803 | |||
| 965d85fb6f | |||
| 31b9c2b8a4 | |||
| dbad4a9b84 | |||
| f3caaed7fe | |||
| ac6047bbb7 | |||
| ea885133ac | |||
| b1e2ff0713 | |||
| dca64fe84b | |||
| 1e50facd5d | |||
| ce87624289 | |||
| a19c19985e | |||
| 9b257e9593 | |||
| aff9413b0f | |||
| de46178871 | |||
| fd700e6fd6 | |||
| b2ab3b010d | |||
| ff72d381fb | |||
| a24f09afe3 | |||
| a6844c7c8a | |||
| fadf51907d | |||
| 42dea7859c | |||
| 164263f13a | |||
| 35dbeae80b | |||
| e2bc2f2067 | |||
| 7ce97f4d50 | |||
| 0ce9931910 | |||
| 693dfecab7 | |||
| 86a7d1c548 | |||
| 44ed374636 | |||
| 8c5c445f0d | |||
| 2a0780cfff | |||
| 8d157ff962 | |||
| 26a8c5e4ab | |||
| d9d15bffa5 | |||
| bb2ee9dd40 | |||
| 90b13d5f27 | |||
| 679a86fe6f | |||
| ab30bfcf24 | |||
| 9dfdafa116 | |||
| 06be19090c | |||
| 07ae67924f | |||
| 1cf597aca7 | |||
| a0df79ea5a | |||
| 738fa55e12 | |||
| c8ee501377 | |||
| 3421f24896 | |||
| 33e67a11ed | |||
| cfe7bebd95 | |||
| ac8946f0c5 | |||
| 0504e91aa6 | |||
| aebd626954 | |||
| 9942bbee3e | |||
| d096ba486f | |||
| be5cddc14f | |||
| 6e12e8be3a | |||
| 9918b9bf13 | |||
| 3d6a875631 | |||
| cda8acddbb | |||
| 2ceba5039b | |||
| 7d7781d795 | |||
| 5ee6cdb2ca | |||
| 454a338d24 | |||
| 85299c6104 | |||
| 001a083e15 | |||
| 7ec4e20546 | |||
| 94202920f8 | |||
| f2ea8daf65 | |||
| df1c360b2a | |||
| 60b15b7b46 | |||
| 9280904476 | |||
| 20f3c55914 | |||
| d8b4fef554 | |||
| 2942e9c78e | |||
| 4b810bcdb7 | |||
| 12bfa6c58d | |||
| ba2bfc7c89 | |||
| 0f8c444af0 | |||
| 4dade03c33 | |||
| ef075c0fa2 | |||
| 4f019b7a99 | |||
| 217d41332f | |||
| f1ce23dce9 | |||
| 01707d733a | |||
| 106b335602 | |||
| b0da3b7cab | |||
| 6b978fc780 | |||
| 473eb93377 | |||
| c418829810 | |||
| d4da028527 | |||
| e8355171e9 | |||
| 86062f396d | |||
| 7250c0ed64 | |||
| 71b2cd1c46 | |||
| 7620d37009 | |||
| 076b854b49 | |||
| 4134eced2c | |||
| 20d85e3d3a | |||
| f79cba9b55 | |||
| e2c5fda610 | |||
| 4b5680a16e | |||
| 43c2e6880a | |||
| 53c25a5689 | |||
| 3e5cd9eb3c | |||
| 7740ee2046 | |||
| 4cde29541d | |||
| af6eb4897e | |||
| 54cf3fb8b1 | |||
| fb8d8f8159 | |||
| dadba3f3af | |||
| f4206f888c | |||
| 961afe7dc5 | |||
| e4fb151a71 | |||
| d04b15a04b | |||
| 1642eb610d | |||
| bcffd81c9c | |||
| f91cdc3296 | |||
| c52909fa17 | |||
| 94b4496ae8 | |||
| f97b87407e | |||
| a069350ffa | |||
| c521c2dcb5 | |||
| 746f4e4150 | |||
| f2b3524d87 | |||
| aa04a5e6f4 | |||
| 2b38368fcd | |||
| 9c063c9f65 | |||
| 138c3f1ebe | |||
| 169a99f21e | |||
| 515f5e8e73 | |||
| facf7cb19a | |||
| 7cd3bf8859 | |||
| 2d354f95fa | |||
| 094c4486ce | |||
| 7c44e116fc | |||
| 821c9368f6 | |||
| 511e790cb1 | |||
| 8bd797aed7 | |||
| 0eef79e6e6 | |||
| d6b51f3053 | |||
| 49d903d6d4 | |||
| a9800165c1 | |||
| 3e20c437fa | |||
| 21d6fbed87 | |||
| 9da99f6955 | |||
| a6f1d99b56 | |||
| 4be35d1983 | |||
| bcbf2e7fc5 | |||
| 1b2ecb2798 | |||
| 60dbad765a | |||
| 70f3a998a4 | |||
| 75aea90972 | |||
| 2f9a56aa6f | |||
| 8324b03938 | |||
| 64ed25c1a2 | |||
| 3115bbd5ef | |||
| aab3a49f2c | |||
| 489cfcd0b0 | |||
| c82b05a044 | |||
| dcb15bae13 | |||
| 9c9ceef8ee | |||
| c97110e72b | |||
| db73879918 | |||
| 422bdc32d9 | |||
| c202198d61 | |||
| a5c44ee961 | |||
| d0483be133 | |||
| 195df69e59 | |||
| 1f3d7506d7 | |||
| 24729fa0d4 | |||
| a585a7d66b | |||
| 6c16ffb99a | |||
| 99e4b458df | |||
| 9a7ecd7403 | |||
| 9573026fdd | |||
| 2458085eed | |||
| 3ca86bcc0c | |||
| 1b11cb5aca | |||
| c71cbf39f5 | |||
| 4a99f9f386 | |||
| 5421f92a9f | |||
| de58d0fb86 | |||
| 2e28a631b6 | |||
| c6068d0fee | |||
| cf3689014b | |||
| 40103c9d8f | |||
| 16a5be1aa6 | |||
| 1be1371171 | |||
| 046fe522c1 | |||
| ec3ebb91c0 | |||
| 381d640719 | |||
| f8a6a4b840 | |||
| ace18ce336 | |||
| 794df52080 | |||
| 8a2831ef80 | |||
| 980e613a7b | |||
| f86ae64a69 | |||
| a2f1f059c7 | |||
| 7ba6a9379b | |||
| 3108a26793 | |||
| 1b1cd1c8d4 | |||
| fcc89be622 | |||
| 6040015b8d | |||
| 05a8e45f01 | |||
| ce294bd1e7 | |||
| 8cc4cff0d7 | |||
| e182dafeac | |||
| b5e7b7e3ef | |||
| eab7d17c83 | |||
| e2e0b01143 | |||
| 45a603f76f | |||
| c7e95df5ce | |||
| d11e17c68b | |||
| 8281e4a094 | |||
| 114019c4d8 | |||
| e86f17a6f0 | |||
| aec8f14836 | |||
| 7321970504 | |||
| 827c912887 | |||
| 39eac5c6a6 | |||
| 68640860fb | |||
| dafc4fb609 | |||
| c17fc8421e | |||
| 45831e9469 | |||
| 41f97b0563 | |||
| 6dd4afccaf | |||
| b68eba4b63 | |||
| 17f6d68707 | |||
| 316520dbb0 | |||
| e69935be99 | |||
| 497ab4b89f | |||
| d2e9910908 | |||
| 21a44428f4 | |||
| 6a29104d09 | |||
| d663b92f2a | |||
| 4182cbd5d0 | |||
| f6ac73a741 | |||
| 1e2eb00ace | |||
| 00549b21a8 | |||
| e2dff9afca | |||
| e9ece9125a | |||
| 2c84036a3a | |||
| 3eabb30949 | |||
| af0485fa12 | |||
| e786888dfb | |||
| 8fc4cb846a | |||
| a3d93c12e0 | |||
| 96c90dbb29 | |||
| e2429f6d85 | |||
| c039501035 | |||
| 6ad76903b9 | |||
| f21f890cb7 | |||
| f5d326e237 | |||
| f48889d77d | |||
| 4fd6e450ab | |||
| 6e23f34133 | |||
| 58f2b9aa2b | |||
| d3b099819d | |||
| 28c4854985 | |||
| 234651448e | |||
| ba13a25a78 | |||
| 5c12f52c8d | |||
| aaf6a0cb41 | |||
| 477d28e37e | |||
| 4e2864a3f9 | |||
| 4ba5d0d8e0 | |||
| de031b365c | |||
| 09435ed798 | |||
| 591a87b728 | |||
| f9709897fe | |||
| b795c992fe | |||
| 69c7bf6100 | |||
| ac3284986c | |||
| 646afec491 | |||
| 760355302c | |||
| 4ff0fdfb4f | |||
| e4fadd01d9 | |||
| bef9673530 | |||
| 9a96230976 | |||
| e90e111139 |
+8
-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
|
||||
@@ -99,7 +99,7 @@ OIDC_USERINFO_URI=
|
||||
OIDC_USERNAME_CLAIM=preferred_username
|
||||
|
||||
# Display name for OIDC authentication
|
||||
OIDC_DISPLAY_NAME=OpenID
|
||||
OIDC_DISPLAY_NAME=OpenID Connect
|
||||
|
||||
# Space separated auth scopes.
|
||||
OIDC_SCOPES=openid profile email
|
||||
@@ -166,8 +166,8 @@ SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=
|
||||
SMTP_REPLY_EMAIL=
|
||||
SMTP_FROM_EMAIL=hello@example.com
|
||||
SMTP_REPLY_EMAIL=hello@example.com
|
||||
SMTP_TLS_CIPHERS=
|
||||
SMTP_SECURE=true
|
||||
|
||||
@@ -181,3 +181,7 @@ RATE_LIMITER_ENABLED=true
|
||||
# Configure default throttling parameters for rate limiter
|
||||
RATE_LIMITER_REQUESTS=1000
|
||||
RATE_LIMITER_DURATION_WINDOW=60
|
||||
|
||||
# Iframely API config
|
||||
IFRAMELY_URL=
|
||||
IFRAMELY_API_KEY=
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"extraFileExtensions": [".json"],
|
||||
"project": "./tsconfig.json",
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
}
|
||||
@@ -25,10 +26,25 @@
|
||||
"rules": {
|
||||
"eqeqeq": 2,
|
||||
"curly": 2,
|
||||
"no-console": "error",
|
||||
"arrow-body-style": ["error", "as-needed"],
|
||||
"spaced-comment": "error",
|
||||
"object-shorthand": "error",
|
||||
"no-mixed-operators": "off",
|
||||
"no-useless-escape": "off",
|
||||
"es/no-regexp-lookbehind-assertions": "error",
|
||||
"react/self-closing-comp": ["error", {
|
||||
"component": true,
|
||||
"html": true
|
||||
}],
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/await-thenable": "error",
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
{
|
||||
"checksVoidReturn": false
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Feature request
|
||||
url: https://github.com/outline/outline/discussions/new
|
||||
url: https://github.com/outline/outline/discussions/new?category=ideas
|
||||
about: Request a feature to be added to the project
|
||||
- name: Self hosting questions
|
||||
url: https://github.com/outline/outline/discussions/new
|
||||
url: https://github.com/outline/outline/discussions/new?category=self-hosting
|
||||
about: Ask questions and discuss running Outline with community members
|
||||
|
||||
@@ -7,5 +7,9 @@ version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
open-pull-requests-limit: 5
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"projects": [
|
||||
{
|
||||
"displayName": "server",
|
||||
"roots": ["<rootDir>/server"],
|
||||
"roots": ["<rootDir>/server", "<rootDir>/plugins"],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
|
||||
@@ -7,6 +7,10 @@ WORKDIR $APP_PATH
|
||||
# ---
|
||||
FROM node:18-alpine AS runner
|
||||
|
||||
RUN apk update && apk add --no-cache curl && apk add --no-cache ca-certificates
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
ENV NODE_ENV production
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:16.14.2-alpine3.15 AS deps
|
||||
FROM node:18-alpine AS deps
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
up:
|
||||
docker-compose up -d redis postgres s3
|
||||
yarn install-local-ssl
|
||||
yarn install --pure-lockfile
|
||||
yarn sequelize db:migrate
|
||||
yarn dev:watch
|
||||
|
||||
build:
|
||||
|
||||
@@ -7,26 +7,26 @@
|
||||
<img width="1640" alt="screenshot" src="https://user-images.githubusercontent.com/380914/110356468-26374600-7fef-11eb-9f6a-f2cc2c8c6590.png">
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield&circle-token=c0c4c2f39990e277385d5c1ae96169c409eb887a"></a>
|
||||
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield"></a>
|
||||
<a href="http://www.typescriptlang.org" rel="nofollow"><img src="https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg" alt="TypeScript"></a>
|
||||
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat" alt="Prettier"></a>
|
||||
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg" alt="Styled Components"></a>
|
||||
<a href="https://translate.getoutline.com/project/outline" alt="Localized"><img src="https://badges.crowdin.net/outline/localized.svg"></a>
|
||||
</p>
|
||||
|
||||
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com).
|
||||
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
|
||||
|
||||
If you'd like to run your own copy of Outline or contribute to development then this is the place for you.
|
||||
|
||||
# Installation
|
||||
|
||||
Please see the [documentation](https://app.getoutline.com/share/770a97da-13e5-401e-9f8a-37949c19f97e/) for running your own copy of Outline in a production configuration.
|
||||
Please see the [documentation](https://docs.getoutline.com/s/hosting/) for running your own copy of Outline in a production configuration.
|
||||
|
||||
If you have questions or improvements for the docs please create a thread in [GitHub discussions](https://github.com/outline/outline/discussions).
|
||||
|
||||
# Development
|
||||
|
||||
There is a short guide for [setting up a development environment](https://app.getoutline.com/share/770a97da-13e5-401e-9f8a-37949c19f97e/doc/local-development-5hEhFRXow7) if you wish to contribute changes, fixes, and improvements to Outline.
|
||||
There is a short guide for [setting up a development environment](https://docs.getoutline.com/s/hosting/doc/local-development-5hEhFRXow7) if you wish to contribute changes, fixes, and improvements to Outline.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -3,13 +3,7 @@
|
||||
"description": "Open source wiki and knowledge base for growing teams",
|
||||
"website": "https://www.getoutline.com/",
|
||||
"repository": "https://github.com/outline/outline",
|
||||
"keywords": [
|
||||
"wiki",
|
||||
"team",
|
||||
"node",
|
||||
"markdown",
|
||||
"slack"
|
||||
],
|
||||
"keywords": ["wiki", "team", "node", "markdown", "slack"],
|
||||
"success_url": "/",
|
||||
"formation": {
|
||||
"web": {
|
||||
@@ -94,7 +88,7 @@
|
||||
},
|
||||
"OIDC_DISPLAY_NAME": {
|
||||
"description": "Display name for OIDC authentication",
|
||||
"value": "OpenID",
|
||||
"value": "OpenID Connect",
|
||||
"required": false
|
||||
},
|
||||
"OIDC_SCOPES": {
|
||||
@@ -188,7 +182,7 @@
|
||||
"required": false
|
||||
},
|
||||
"GOOGLE_ANALYTICS_ID": {
|
||||
"description": "UA-xxxx (optional)",
|
||||
"description": "G-xxxx (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SENTRY_DSN": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": [
|
||||
"../.eslintrc",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
],
|
||||
"plugins": [
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
StarredIcon,
|
||||
TrashIcon,
|
||||
UnstarredIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
@@ -12,14 +13,15 @@ import Collection from "~/models/Collection";
|
||||
import CollectionEdit from "~/scenes/CollectionEdit";
|
||||
import CollectionNew from "~/scenes/CollectionNew";
|
||||
import CollectionPermissions from "~/scenes/CollectionPermissions";
|
||||
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
|
||||
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { createAction } from "~/actions";
|
||||
import { CollectionSection } from "~/actions/sections";
|
||||
import history from "~/utils/history";
|
||||
|
||||
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => {
|
||||
return <DynamicCollectionIcon collection={collection} />;
|
||||
};
|
||||
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
|
||||
<DynamicCollectionIcon collection={collection} />
|
||||
);
|
||||
|
||||
export const openCollection = createAction({
|
||||
name: ({ t }) => t("Open collection"),
|
||||
@@ -122,13 +124,13 @@ export const starCollection = createAction({
|
||||
stores.policies.abilities(activeCollectionId).star
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
collection?.star();
|
||||
await collection?.star();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -148,13 +150,47 @@ export const unstarCollection = createAction({
|
||||
stores.policies.abilities(activeCollectionId).unstar
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
collection?.unstar();
|
||||
await collection?.unstar();
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteCollection = createAction({
|
||||
name: ({ t }) => t("Delete"),
|
||||
analyticsName: "Delete collection",
|
||||
section: CollectionSection,
|
||||
icon: <TrashIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return stores.policies.abilities(activeCollectionId).delete;
|
||||
},
|
||||
perform: ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
isCentered: true,
|
||||
title: t("Delete collection"),
|
||||
content: (
|
||||
<CollectionDeleteDialog
|
||||
collection={collection}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -163,4 +199,5 @@ export const rootCollectionActions = [
|
||||
createCollection,
|
||||
starCollection,
|
||||
unstarCollection,
|
||||
deleteCollection,
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createAction } from "~/actions";
|
||||
import { DeveloperSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { deleteAllDatabases } from "~/utils/developer";
|
||||
|
||||
export const clearIndexedDB = createAction({
|
||||
@@ -35,16 +36,27 @@ export const createTestUsers = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const toggleDebugLogging = createAction({
|
||||
name: ({ t }) => t("Toggle debug logging"),
|
||||
icon: <ToolsIcon />,
|
||||
section: DeveloperSection,
|
||||
perform: async ({ t }) => {
|
||||
Logger.debugLoggingEnabled = !Logger.debugLoggingEnabled;
|
||||
stores.toasts.showToast(
|
||||
Logger.debugLoggingEnabled
|
||||
? t("Debug logging enabled")
|
||||
: t("Debug logging disabled")
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const developer = createAction({
|
||||
name: ({ t }) => t("Developer"),
|
||||
name: ({ t }) => t("Development"),
|
||||
keywords: "debug",
|
||||
icon: <ToolsIcon />,
|
||||
iconInContextMenu: false,
|
||||
section: DeveloperSection,
|
||||
visible: ({ event }) =>
|
||||
env.ENVIRONMENT === "development" ||
|
||||
(event instanceof KeyboardEvent && event.altKey),
|
||||
children: [clearIndexedDB, createTestUsers],
|
||||
children: [clearIndexedDB, toggleDebugLogging, createTestUsers],
|
||||
});
|
||||
|
||||
export const rootDeveloperActions = [developer];
|
||||
|
||||
@@ -37,8 +37,8 @@ import { DocumentSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
documentInsightsUrl,
|
||||
documentHistoryUrl,
|
||||
documentInsightsPath,
|
||||
documentHistoryPath,
|
||||
homePath,
|
||||
newDocumentPath,
|
||||
searchPath,
|
||||
@@ -61,8 +61,11 @@ export const openDocument = createAction({
|
||||
// cache if the document is renamed
|
||||
id: path.url,
|
||||
name: path.title,
|
||||
icon: () =>
|
||||
stores.documents.get(path.id)?.isStarred ? <StarredIcon /> : null,
|
||||
icon: function _Icon() {
|
||||
return stores.documents.get(path.id)?.isStarred ? (
|
||||
<StarredIcon />
|
||||
) : null;
|
||||
},
|
||||
section: DocumentSection,
|
||||
perform: () => history.push(path.url),
|
||||
}));
|
||||
@@ -98,13 +101,13 @@ export const starDocument = createAction({
|
||||
!document?.isStarred && stores.policies.abilities(activeDocumentId).star
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.star();
|
||||
await document?.star();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -124,13 +127,13 @@ export const unstarDocument = createAction({
|
||||
stores.policies.abilities(activeDocumentId).unstar
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.unstar();
|
||||
await document?.unstar();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -159,7 +162,7 @@ export const publishDocument = createAction({
|
||||
}
|
||||
|
||||
if (document?.collectionId) {
|
||||
await document.save({
|
||||
await document.save(undefined, {
|
||||
publish: true,
|
||||
});
|
||||
stores.toasts.showToast(t("Document published"), {
|
||||
@@ -186,14 +189,14 @@ export const unpublishDocument = createAction({
|
||||
}
|
||||
return stores.policies.abilities(activeDocumentId).unpublish;
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
perform: async ({ activeDocumentId, stores, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
document?.unpublish();
|
||||
await document?.unpublish();
|
||||
|
||||
stores.toasts.showToast(t("Document unpublished"), {
|
||||
type: "success",
|
||||
@@ -218,14 +221,14 @@ export const subscribeDocument = createAction({
|
||||
stores.policies.abilities(activeDocumentId).subscribe
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
perform: async ({ activeDocumentId, stores, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
document?.subscribe();
|
||||
await document?.subscribe();
|
||||
|
||||
stores.toasts.showToast(t("Subscribed to document notifications"), {
|
||||
type: "success",
|
||||
@@ -250,14 +253,14 @@ export const unsubscribeDocument = createAction({
|
||||
stores.policies.abilities(activeDocumentId).unsubscribe
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, currentUserId, t }) => {
|
||||
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
|
||||
if (!activeDocumentId || !currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
document?.unsubscribe(currentUserId);
|
||||
await document?.unsubscribe(currentUserId);
|
||||
|
||||
stores.toasts.showToast(t("Unsubscribed from document notifications"), {
|
||||
type: "success",
|
||||
@@ -274,13 +277,13 @@ export const downloadDocumentAsHTML = createAction({
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.download(ExportContentType.Html);
|
||||
await document?.download(ExportContentType.Html);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -321,13 +324,13 @@ export const downloadDocumentAsMarkdown = createAction({
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.download(ExportContentType.Markdown);
|
||||
await document?.download(ExportContentType.Markdown);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -404,13 +407,19 @@ export const pinDocumentToCollection = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.pin(document.collectionId);
|
||||
try {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.pin(document.collectionId);
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
if (!collection || !location.pathname.startsWith(collection?.url)) {
|
||||
stores.toasts.showToast(t("Pinned to collection"));
|
||||
if (!collection || !location.pathname.startsWith(collection?.url)) {
|
||||
stores.toasts.showToast(t("Pinned to collection"));
|
||||
}
|
||||
} catch (err) {
|
||||
stores.toasts.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -443,10 +452,16 @@ export const pinDocumentToHome = createAction({
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
await document?.pin();
|
||||
try {
|
||||
await document?.pin();
|
||||
|
||||
if (location.pathname !== homePath()) {
|
||||
stores.toasts.showToast(t("Pinned to team home"));
|
||||
if (location.pathname !== homePath()) {
|
||||
stores.toasts.showToast(t("Pinned to team home"));
|
||||
}
|
||||
} catch (err) {
|
||||
stores.toasts.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -728,7 +743,7 @@ export const openDocumentComments = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
stores.ui.toggleComments();
|
||||
stores.ui.toggleComments(activeDocumentId);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -749,7 +764,7 @@ export const openDocumentHistory = createAction({
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
history.push(documentHistoryUrl(document));
|
||||
history.push(documentHistoryPath(document));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -770,7 +785,7 @@ export const openDocumentInsights = createAction({
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
history.push(documentInsightsUrl(document));
|
||||
history.push(documentInsightsPath(document));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -30,15 +30,13 @@ import { isMac } from "~/utils/browser";
|
||||
import history from "~/utils/history";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import {
|
||||
organizationSettingsPath,
|
||||
profileSettingsPath,
|
||||
accountPreferencesPath,
|
||||
homePath,
|
||||
searchPath,
|
||||
draftsPath,
|
||||
templatesPath,
|
||||
archivePath,
|
||||
trashPath,
|
||||
settingsPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
export const navigateToHome = createAction({
|
||||
@@ -105,7 +103,7 @@ export const navigateToSettings = createAction({
|
||||
icon: <SettingsIcon />,
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").update,
|
||||
perform: () => history.push(organizationSettingsPath()),
|
||||
perform: () => history.push(settingsPath("details")),
|
||||
});
|
||||
|
||||
export const navigateToProfileSettings = createAction({
|
||||
@@ -114,7 +112,16 @@ export const navigateToProfileSettings = createAction({
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <ProfileIcon />,
|
||||
perform: () => history.push(profileSettingsPath()),
|
||||
perform: () => history.push(settingsPath()),
|
||||
});
|
||||
|
||||
export const navigateToNotificationSettings = createAction({
|
||||
name: ({ t }) => t("Notifications"),
|
||||
analyticsName: "Navigate to notification settings",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <EmailIcon />,
|
||||
perform: () => history.push(settingsPath("notifications")),
|
||||
});
|
||||
|
||||
export const navigateToAccountPreferences = createAction({
|
||||
@@ -123,7 +130,7 @@ export const navigateToAccountPreferences = createAction({
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <SettingsIcon />,
|
||||
perform: () => history.push(accountPreferencesPath()),
|
||||
perform: () => history.push(settingsPath("preferences")),
|
||||
});
|
||||
|
||||
export const openAPIDocumentation = createAction({
|
||||
@@ -135,6 +142,14 @@ export const openAPIDocumentation = createAction({
|
||||
perform: () => window.open(developersUrl()),
|
||||
});
|
||||
|
||||
export const toggleSidebar = createAction({
|
||||
name: ({ t }) => t("Toggle sidebar"),
|
||||
analyticsName: "Toggle sidebar",
|
||||
keywords: "hide show navigation",
|
||||
section: NavigationSection,
|
||||
perform: ({ stores }) => stores.ui.toggleCollapsedSidebar(),
|
||||
});
|
||||
|
||||
export const openFeedbackUrl = createAction({
|
||||
name: ({ t }) => t("Send us feedback"),
|
||||
analyticsName: "Open feedback",
|
||||
@@ -210,5 +225,6 @@ export const rootNavigationActions = [
|
||||
openBugReportUrl,
|
||||
openChangelog,
|
||||
openKeyboardShortcuts,
|
||||
toggleSidebar,
|
||||
logout,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { MarkAsReadIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { createAction } from "..";
|
||||
import { NotificationSection } from "../sections";
|
||||
|
||||
export const markNotificationsAsRead = createAction({
|
||||
name: ({ t }) => t("Mark notifications as read"),
|
||||
analyticsName: "Mark notifications as read",
|
||||
section: NotificationSection,
|
||||
icon: <MarkAsReadIcon />,
|
||||
shortcut: ["Shift+Escape"],
|
||||
perform: ({ stores }) => stores.notifications.markAllAsRead(),
|
||||
visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0,
|
||||
});
|
||||
|
||||
export const rootNotificationActions = [markNotificationsAsRead];
|
||||
@@ -6,7 +6,10 @@ import stores from "~/stores";
|
||||
import { createAction } from "~/actions";
|
||||
import { RevisionSection } from "~/actions/sections";
|
||||
import history from "~/utils/history";
|
||||
import { documentHistoryUrl, matchDocumentHistory } from "~/utils/routeHelpers";
|
||||
import {
|
||||
documentHistoryPath,
|
||||
matchDocumentHistory,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
export const restoreRevision = createAction({
|
||||
name: ({ t }) => t("Restore revision"),
|
||||
@@ -15,7 +18,7 @@ export const restoreRevision = createAction({
|
||||
section: RevisionSection,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
|
||||
perform: async ({ t, event, location, activeDocumentId }) => {
|
||||
perform: async ({ event, location, activeDocumentId }) => {
|
||||
event?.preventDefault();
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
@@ -26,26 +29,15 @@ export const restoreRevision = createAction({
|
||||
});
|
||||
const revisionId = match?.params.revisionId;
|
||||
|
||||
const { team } = stores.auth;
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (team?.collaborativeEditing) {
|
||||
history.push(document.url, {
|
||||
restore: true,
|
||||
revisionId,
|
||||
});
|
||||
} else {
|
||||
await document.restore({
|
||||
revisionId,
|
||||
});
|
||||
stores.toasts.showToast(t("Document restored"), {
|
||||
type: "success",
|
||||
});
|
||||
history.push(document.url);
|
||||
}
|
||||
history.push(document.url, {
|
||||
restore: true,
|
||||
revisionId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -68,7 +60,7 @@ export const copyLinkToRevision = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `${window.location.origin}${documentHistoryUrl(
|
||||
const url = `${window.location.origin}${documentHistoryPath(
|
||||
document,
|
||||
revisionId
|
||||
)}`;
|
||||
|
||||
@@ -43,8 +43,9 @@ export const changeTheme = createAction({
|
||||
isContextMenu ? t("Appearance") : t("Change theme"),
|
||||
analyticsName: "Change theme",
|
||||
placeholder: ({ t }) => t("Change theme to"),
|
||||
icon: () =>
|
||||
stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
|
||||
icon: function _Icon() {
|
||||
return stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />;
|
||||
},
|
||||
keywords: "appearance display",
|
||||
section: SettingsSection,
|
||||
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
|
||||
|
||||
@@ -9,15 +9,15 @@ import { createAction } from "~/actions";
|
||||
import { ActionContext } from "~/types";
|
||||
import { TeamSection } from "../sections";
|
||||
|
||||
export const createTeamsList = ({ stores }: { stores: RootStore }) => {
|
||||
return (
|
||||
stores.auth.availableTeams?.map((session) => ({
|
||||
id: `switch-${session.id}`,
|
||||
name: session.name,
|
||||
analyticsName: "Switch workspace",
|
||||
section: TeamSection,
|
||||
keywords: "change switch workspace organization team",
|
||||
icon: () => (
|
||||
export const createTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
stores.auth.availableTeams?.map((session) => ({
|
||||
id: `switch-${session.id}`,
|
||||
name: session.name,
|
||||
analyticsName: "Switch workspace",
|
||||
section: TeamSection,
|
||||
keywords: "change switch workspace organization team",
|
||||
icon: function _Icon() {
|
||||
return (
|
||||
<StyledTeamLogo
|
||||
alt={session.name}
|
||||
model={{
|
||||
@@ -28,13 +28,11 @@ export const createTeamsList = ({ stores }: { stores: RootStore }) => {
|
||||
}}
|
||||
size={24}
|
||||
/>
|
||||
),
|
||||
visible: ({ currentTeamId }: ActionContext) =>
|
||||
currentTeamId !== session.id,
|
||||
perform: () => (window.location.href = session.url),
|
||||
})) ?? []
|
||||
);
|
||||
};
|
||||
);
|
||||
},
|
||||
visible: ({ currentTeamId }: ActionContext) => currentTeamId !== session.id,
|
||||
perform: () => (window.location.href = session.url),
|
||||
})) ?? [];
|
||||
|
||||
export const switchTeam = createAction({
|
||||
name: ({ t }) => t("Switch workspace"),
|
||||
@@ -53,9 +51,8 @@ export const createTeam = createAction({
|
||||
keywords: "create change switch workspace organization team",
|
||||
section: TeamSection,
|
||||
icon: <PlusIcon />,
|
||||
visible: ({ stores, currentTeamId }) => {
|
||||
return stores.policies.abilities(currentTeamId ?? "").createTeam;
|
||||
},
|
||||
visible: ({ stores, currentTeamId }) =>
|
||||
stores.policies.abilities(currentTeamId ?? "").createTeam,
|
||||
perform: ({ t, event, stores }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import Invite from "~/scenes/Invite";
|
||||
import { UserDeleteDialog } from "~/components/UserDialogs";
|
||||
import { createAction } from "~/actions";
|
||||
import { UserSection } from "~/actions/sections";
|
||||
|
||||
@@ -21,4 +22,31 @@ export const inviteUser = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteUserActionFactory = (userId: string) =>
|
||||
createAction({
|
||||
name: ({ t }) => `${t("Delete user")}…`,
|
||||
analyticsName: "Delete user",
|
||||
keywords: "leave",
|
||||
dangerous: true,
|
||||
section: UserSection,
|
||||
visible: ({ stores }) => stores.policies.abilities(userId).delete,
|
||||
perform: ({ t }) => {
|
||||
const user = stores.users.get(userId);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Delete user"),
|
||||
isCentered: true,
|
||||
content: (
|
||||
<UserDeleteDialog
|
||||
user={user}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const rootUserActions = [inviteUser];
|
||||
|
||||
@@ -35,7 +35,7 @@ export function createAction(definition: Optional<Action, "id">): Action {
|
||||
return definition.perform?.(context);
|
||||
}
|
||||
: undefined,
|
||||
id: uuidv4(),
|
||||
id: definition.id ?? uuidv4(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,9 +49,7 @@ export function actionToMenuItem(
|
||||
const title = resolve<string>(action.name, context);
|
||||
const icon =
|
||||
resolvedIcon && action.iconInContextMenu !== false
|
||||
? React.cloneElement(resolvedIcon, {
|
||||
color: "currentColor",
|
||||
})
|
||||
? resolvedIcon
|
||||
: undefined;
|
||||
|
||||
if (resolvedChildren) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { rootCollectionActions } from "./definitions/collections";
|
||||
import { rootDeveloperActions } from "./definitions/developer";
|
||||
import { rootDocumentActions } from "./definitions/documents";
|
||||
import { rootNavigationActions } from "./definitions/navigation";
|
||||
import { rootNotificationActions } from "./definitions/notifications";
|
||||
import { rootRevisionActions } from "./definitions/revisions";
|
||||
import { rootSettingsActions } from "./definitions/settings";
|
||||
import { rootTeamActions } from "./definitions/teams";
|
||||
@@ -12,6 +13,7 @@ export default [
|
||||
...rootDocumentActions,
|
||||
...rootUserActions,
|
||||
...rootNavigationActions,
|
||||
...rootNotificationActions,
|
||||
...rootRevisionActions,
|
||||
...rootSettingsActions,
|
||||
...rootDeveloperActions,
|
||||
|
||||
@@ -12,6 +12,8 @@ export const SettingsSection = ({ t }: ActionContext) => t("Settings");
|
||||
|
||||
export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
|
||||
|
||||
export const NotificationSection = ({ t }: ActionContext) => t("Notification");
|
||||
|
||||
export const UserSection = ({ t }: ActionContext) => t("People");
|
||||
|
||||
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import * as React from "react";
|
||||
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
|
||||
import { Action, ActionContext } from "~/types";
|
||||
|
||||
export type Props = React.ComponentPropsWithoutRef<"button"> & {
|
||||
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
/** Show the button in a disabled state */
|
||||
disabled?: boolean;
|
||||
/** Hide the button entirely if action is not applicable */
|
||||
@@ -18,14 +19,17 @@ export type Props = React.ComponentPropsWithoutRef<"button"> & {
|
||||
/**
|
||||
* Button that can be used to trigger an action definition.
|
||||
*/
|
||||
const ActionButton = React.forwardRef(
|
||||
(
|
||||
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
function _ActionButton(
|
||||
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) => {
|
||||
) {
|
||||
const [executing, setExecuting] = React.useState(false);
|
||||
const disabled = rest.disabled;
|
||||
|
||||
if (action && !context) {
|
||||
throw new Error("Context must be provided with action");
|
||||
}
|
||||
if (!context || !action) {
|
||||
return <button {...rest} ref={ref} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
export const Action = styled(Flex)`
|
||||
@@ -20,7 +21,7 @@ export const Separator = styled.div`
|
||||
margin-left: 12px;
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: ${(props) => props.theme.divider};
|
||||
background: ${s("divider")};
|
||||
`;
|
||||
|
||||
const Actions = styled(Flex)`
|
||||
@@ -29,8 +30,8 @@ const Actions = styled(Flex)`
|
||||
right: 0;
|
||||
left: 0;
|
||||
border-radius: 3px;
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
padding: 12px;
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
|
||||
@@ -5,10 +5,14 @@ import * as React from "react";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import env from "~/env";
|
||||
|
||||
const Analytics: React.FC = ({ children }) => {
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Analytics: React.FC = ({ children }: Props) => {
|
||||
// Google Analytics 3
|
||||
React.useEffect(() => {
|
||||
if (!env.GOOGLE_ANALYTICS_ID) {
|
||||
if (!env.GOOGLE_ANALYTICS_ID?.startsWith("UA-")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -37,25 +41,36 @@ const Analytics: React.FC = ({ children }) => {
|
||||
|
||||
// Google Analytics 4
|
||||
React.useEffect(() => {
|
||||
if (env.analytics.service !== IntegrationService.GoogleAnalytics) {
|
||||
const measurementIds = [];
|
||||
|
||||
if (env.analytics.service === IntegrationService.GoogleAnalytics) {
|
||||
measurementIds.push(escape(env.analytics.settings?.measurementId));
|
||||
}
|
||||
if (env.GOOGLE_ANALYTICS_ID?.startsWith("G-")) {
|
||||
measurementIds.push(env.GOOGLE_ANALYTICS_ID);
|
||||
}
|
||||
if (measurementIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const measurementId = escape(env.analytics.settings?.measurementId);
|
||||
const params = {
|
||||
allow_google_signals: false,
|
||||
restricted_data_processing: true,
|
||||
};
|
||||
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
window.gtag = function () {
|
||||
window.dataLayer.push(arguments);
|
||||
};
|
||||
window.gtag("js", new Date());
|
||||
window.gtag("config", measurementId, {
|
||||
allow_google_signals: false,
|
||||
restricted_data_processing: true,
|
||||
});
|
||||
|
||||
for (const measurementId of measurementIds) {
|
||||
window.gtag("config", measurementId, params);
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.type = "text/javascript";
|
||||
script.src = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`;
|
||||
script.src = `https://www.googletagmanager.com/gtag/js?id=${measurementIds[0]}`;
|
||||
script.async = true;
|
||||
document.getElementsByTagName("head")[0]?.appendChild(script);
|
||||
}, []);
|
||||
|
||||
@@ -6,17 +6,11 @@ export default function Arrow() {
|
||||
width="13"
|
||||
height="30"
|
||||
viewBox="0 0 13 30"
|
||||
fill="none"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z"
|
||||
/>
|
||||
<path d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z" />
|
||||
<path d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ function ArrowKeyNavigation(
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev) => {
|
||||
if (onEscape) {
|
||||
if (ev.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Escape") {
|
||||
onEscape(ev);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { changeLanguage } from "~/utils/language";
|
||||
import LoadingIndicator from "./LoadingIndicator";
|
||||
|
||||
type Props = {
|
||||
children: JSX.Element;
|
||||
@@ -18,20 +18,18 @@ const Authenticated = ({ children }: Props) => {
|
||||
// Watching for language changes here as this is the earliest point we have
|
||||
// the user available and means we can start loading translations faster
|
||||
React.useEffect(() => {
|
||||
changeLanguage(language, i18n);
|
||||
void changeLanguage(language, i18n);
|
||||
}, [i18n, language]);
|
||||
|
||||
if (auth.authenticated) {
|
||||
const { user, team } = auth;
|
||||
|
||||
if (!team || !user) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
auth.logout(true);
|
||||
if (auth.isFetching) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
void auth.logout(true);
|
||||
return <Redirect to="/" />;
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { Editor as TEditor } from "~/editor";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import {
|
||||
searchPath,
|
||||
newDocumentPath,
|
||||
@@ -25,18 +26,22 @@ import {
|
||||
} from "~/utils/routeHelpers";
|
||||
import Fade from "./Fade";
|
||||
|
||||
const DocumentComments = React.lazy(
|
||||
const DocumentComments = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Comments")
|
||||
);
|
||||
const DocumentHistory = React.lazy(
|
||||
const DocumentHistory = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/History")
|
||||
);
|
||||
const DocumentInsights = React.lazy(
|
||||
const DocumentInsights = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Insights")
|
||||
);
|
||||
const CommandBar = React.lazy(() => import("~/components/CommandBar"));
|
||||
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
|
||||
|
||||
const AuthenticatedLayout: React.FC = ({ children }) => {
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
const { ui, auth } = useStores();
|
||||
const location = useLocation();
|
||||
const can = usePolicy(ui.activeCollectionId);
|
||||
@@ -61,7 +66,7 @@ const AuthenticatedLayout: React.FC = ({ children }) => {
|
||||
return;
|
||||
}
|
||||
const { activeCollectionId } = ui;
|
||||
if (!activeCollectionId || !can.update) {
|
||||
if (!activeCollectionId || !can.createDocument) {
|
||||
return;
|
||||
}
|
||||
history.push(newDocumentPath(activeCollectionId));
|
||||
@@ -91,11 +96,15 @@ const AuthenticatedLayout: React.FC = ({ children }) => {
|
||||
const showComments =
|
||||
!showInsights &&
|
||||
!showHistory &&
|
||||
!ui.commentsCollapsed &&
|
||||
ui.activeDocumentId &&
|
||||
ui.commentsExpanded.includes(ui.activeDocumentId) &&
|
||||
team?.getPreference(TeamPreference.Commenting);
|
||||
|
||||
const sidebarRight = (
|
||||
<AnimatePresence>
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
key={ui.activeDocumentId ? "active" : "inactive"}
|
||||
>
|
||||
{(showHistory || showInsights || showComments) && (
|
||||
<Route path={`/doc/${slug}`}>
|
||||
<SidebarRight>
|
||||
@@ -117,7 +126,9 @@ const AuthenticatedLayout: React.FC = ({ children }) => {
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
<React.Suspense fallback={null}>
|
||||
<CommandBar />
|
||||
</React.Suspense>
|
||||
</Layout>
|
||||
</DocumentContext.Provider>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,14 @@ import styled from "styled-components";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import Initials from "./Initials";
|
||||
|
||||
export enum AvatarSize {
|
||||
Small = 16,
|
||||
Medium = 24,
|
||||
Large = 32,
|
||||
XLarge = 48,
|
||||
XXLarge = 64,
|
||||
}
|
||||
|
||||
export interface IAvatar {
|
||||
avatarUrl: string | null;
|
||||
color?: string;
|
||||
@@ -11,9 +19,8 @@ export interface IAvatar {
|
||||
}
|
||||
|
||||
type Props = {
|
||||
size: number;
|
||||
size: AvatarSize;
|
||||
src?: string;
|
||||
icon?: React.ReactNode;
|
||||
model?: IAvatar;
|
||||
alt?: string;
|
||||
showBorder?: boolean;
|
||||
@@ -23,7 +30,7 @@ type Props = {
|
||||
};
|
||||
|
||||
function Avatar(props: Props) {
|
||||
const { icon, showBorder, model, style, ...rest } = props;
|
||||
const { showBorder, model, style, ...rest } = props;
|
||||
const src = props.src || model?.avatarUrl;
|
||||
const [error, handleError] = useBoolean(false);
|
||||
|
||||
@@ -43,13 +50,12 @@ function Avatar(props: Props) {
|
||||
) : (
|
||||
<Initials $showBorder={showBorder} {...rest} />
|
||||
)}
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
</Relative>
|
||||
);
|
||||
}
|
||||
|
||||
Avatar.defaultProps = {
|
||||
size: 24,
|
||||
size: AvatarSize.Medium,
|
||||
};
|
||||
|
||||
const Relative = styled.div`
|
||||
@@ -58,18 +64,6 @@ const Relative = styled.div`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.div`
|
||||
display: flex;
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
background: ${(props) => props.theme.accent};
|
||||
border: 2px solid ${(props) => props.theme.background};
|
||||
border-radius: 100%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
`;
|
||||
|
||||
const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
|
||||
display: block;
|
||||
width: ${(props) => props.size}px;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
@@ -106,7 +107,7 @@ const AvatarWrapper = styled.div<AvatarWrapperProps>`
|
||||
|
||||
&:hover:after {
|
||||
border: 2px solid ${(props) => props.$color};
|
||||
box-shadow: inset 0 0 0 2px ${(props) => props.theme.background};
|
||||
box-shadow: inset 0 0 0 2px ${s("background")};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -7,7 +7,11 @@ const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
|
||||
background-color: ${({ yellow, primary, theme }) =>
|
||||
yellow ? theme.yellow : primary ? theme.accent : "transparent"};
|
||||
color: ${({ primary, yellow, theme }) =>
|
||||
primary ? theme.white : yellow ? theme.almostBlack : theme.textTertiary};
|
||||
primary
|
||||
? theme.accentText
|
||||
: yellow
|
||||
? theme.almostBlack
|
||||
: theme.textTertiary};
|
||||
border: 1px solid
|
||||
${({ primary, yellow, theme }) =>
|
||||
primary || yellow
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths } from "@shared/styles";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import env from "~/env";
|
||||
import OutlineIcon from "./Icons/OutlineIcon";
|
||||
|
||||
@@ -26,16 +26,16 @@ const Link = styled.a`
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
border-top-right-radius: 2px;
|
||||
color: ${(props) => props.theme.text};
|
||||
color: ${s("text")};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
svg {
|
||||
fill: ${(props) => props.theme.text};
|
||||
fill: ${s("text")};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
background: ${s("sidebarBackground")};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
|
||||
@@ -2,6 +2,7 @@ import { GoToIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
@@ -60,20 +61,18 @@ function Breadcrumb({
|
||||
|
||||
const Slash = styled(GoToIcon)`
|
||||
flex-shrink: 0;
|
||||
fill: ${(props) => props.theme.divider};
|
||||
fill: ${s("divider")};
|
||||
`;
|
||||
|
||||
const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
||||
${ellipsis()}
|
||||
display: flex;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
cursor: var(--pointer);
|
||||
color: ${(props) => props.theme.text};
|
||||
color: ${s("text")};
|
||||
font-size: 15px;
|
||||
height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
|
||||
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ExpandedIcon } from "outline-icons";
|
||||
import { darken, lighten, transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import ActionButton, {
|
||||
Props as ActionButtonProps,
|
||||
} from "~/components/ActionButton";
|
||||
@@ -13,7 +14,6 @@ type RealProps = {
|
||||
$borderOnHover?: boolean;
|
||||
$neutral?: boolean;
|
||||
$danger?: boolean;
|
||||
$iconColor?: string;
|
||||
};
|
||||
|
||||
const RealButton = styled(ActionButton)<RealProps>`
|
||||
@@ -22,8 +22,8 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: ${(props) => props.theme.accent};
|
||||
color: ${(props) => props.theme.accentText};
|
||||
background: ${s("accent")};
|
||||
color: ${s("accentText")};
|
||||
box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 2px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
@@ -36,14 +36,6 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
appearance: none !important;
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
${(props) =>
|
||||
!props.$borderOnHover &&
|
||||
`
|
||||
svg {
|
||||
fill: ${props.$iconColor || "currentColor"};
|
||||
}
|
||||
`}
|
||||
|
||||
&::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
@@ -68,7 +60,7 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
${(props) =>
|
||||
props.$neutral &&
|
||||
`
|
||||
background: ${props.theme.buttonNeutralBackground};
|
||||
background: inherit;
|
||||
color: ${props.theme.buttonNeutralText};
|
||||
box-shadow: ${
|
||||
props.$borderOnHover
|
||||
@@ -76,15 +68,6 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
: `rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.buttonNeutralBorder} 0 0 0 1px inset`
|
||||
};
|
||||
|
||||
${
|
||||
props.$borderOnHover
|
||||
? ""
|
||||
: `svg {
|
||||
fill: ${props.$iconColor || "currentColor"};
|
||||
}`
|
||||
}
|
||||
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&[aria-expanded="true"] {
|
||||
background: ${
|
||||
@@ -155,7 +138,6 @@ export const Inner = styled.span<{
|
||||
|
||||
export type Props<T> = ActionButtonProps & {
|
||||
icon?: React.ReactNode;
|
||||
iconColor?: string;
|
||||
children?: React.ReactNode;
|
||||
disclosure?: boolean;
|
||||
neutral?: boolean;
|
||||
@@ -183,7 +165,6 @@ const Button = <T extends React.ElementType = "button">(
|
||||
neutral,
|
||||
action,
|
||||
icon,
|
||||
iconColor,
|
||||
borderOnHover,
|
||||
hideIcon,
|
||||
fullwidth,
|
||||
@@ -203,13 +184,12 @@ const Button = <T extends React.ElementType = "button">(
|
||||
$danger={danger}
|
||||
$fullwidth={fullwidth}
|
||||
$borderOnHover={borderOnHover}
|
||||
$iconColor={iconColor}
|
||||
{...rest}
|
||||
>
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
{hasIcon && ic}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
{disclosure && <ExpandedIcon color="currentColor" />}
|
||||
{disclosure && <ExpandedIcon />}
|
||||
</Inner>
|
||||
</RealButton>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
withStickyHeader?: boolean;
|
||||
};
|
||||
|
||||
@@ -26,12 +27,10 @@ const Content = styled.div`
|
||||
`};
|
||||
`;
|
||||
|
||||
const CenteredContent: React.FC<Props> = ({ children, ...rest }) => {
|
||||
return (
|
||||
<Container {...rest}>
|
||||
<Content>{children}</Content>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
const CenteredContent: React.FC<Props> = ({ children, ...rest }: Props) => (
|
||||
<Container {...rest}>
|
||||
<Content>{children}</Content>
|
||||
</Container>
|
||||
);
|
||||
|
||||
export default CenteredContent;
|
||||
|
||||
@@ -42,7 +42,7 @@ const Circle = ({
|
||||
style={{
|
||||
transition: "stroke-dashoffset 0.6s ease 0s",
|
||||
}}
|
||||
></circle>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
const ClickablePadding = styled.div<{ grow?: boolean; minHeight?: string }>`
|
||||
const ClickablePadding = styled.div<{
|
||||
grow?: boolean;
|
||||
minHeight?: React.CSSProperties["paddingBottom"];
|
||||
}>`
|
||||
min-height: ${(props) => props.minHeight || "50vh"};
|
||||
flex-grow: 100;
|
||||
cursor: text;
|
||||
|
||||
@@ -9,7 +9,6 @@ import DocumentViews from "~/components/DocumentViews";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Popover from "~/components/Popover";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -20,7 +19,6 @@ type Props = {
|
||||
function Collaborators(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const team = useCurrentTeam();
|
||||
const currentUserId = user?.id;
|
||||
const [requestedUserIds, setRequestedUserIds] = React.useState<string[]>([]);
|
||||
const { users, presence, ui } = useStores();
|
||||
@@ -59,7 +57,7 @@ function Collaborators(props: Props) {
|
||||
|
||||
if (!isEqual(requestedUserIds, ids) && ids.length > 0) {
|
||||
setRequestedUserIds(ids);
|
||||
users.fetchPage({ ids, limit: 100 });
|
||||
void users.fetchPage({ ids, limit: 100 });
|
||||
}
|
||||
}, [document, users, presentIds, document.collaboratorIds, requestedUserIds]);
|
||||
|
||||
@@ -79,8 +77,7 @@ function Collaborators(props: Props) {
|
||||
const isPresent = presentIds.includes(collaborator.id);
|
||||
const isEditing = editingIds.includes(collaborator.id);
|
||||
const isObserving = ui.observingUserId === collaborator.id;
|
||||
const isObservable =
|
||||
team.collaborativeEditing && collaborator.id !== user.id;
|
||||
const isObservable = collaborator.id !== user.id;
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
|
||||
@@ -39,7 +39,7 @@ function CollectionDeleteDialog({ collection, onSubmit }: Props) {
|
||||
<>
|
||||
<Text type="secondary">
|
||||
<Trans
|
||||
defaults="Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash."
|
||||
defaults="Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however all published documents within will be moved to the trash."
|
||||
values={{
|
||||
collectionName: collection.name,
|
||||
}}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Collection from "~/models/Collection";
|
||||
import Arrow from "~/components/Arrow";
|
||||
import ButtonLink from "~/components/ButtonLink";
|
||||
@@ -70,9 +71,9 @@ function CollectionDescription({ collection }: Props) {
|
||||
);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(getValue) => {
|
||||
async (getValue) => {
|
||||
setDirty(true);
|
||||
handleSave(getValue);
|
||||
await handleSave(getValue);
|
||||
},
|
||||
[handleSave]
|
||||
);
|
||||
@@ -110,7 +111,7 @@ function CollectionDescription({ collection }: Props) {
|
||||
onBlur={handleStopEditing}
|
||||
maxLength={1000}
|
||||
embedsDisabled
|
||||
readOnlyWriteCheckboxes
|
||||
canUpdate
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
@@ -141,7 +142,7 @@ function CollectionDescription({ collection }: Props) {
|
||||
|
||||
const Disclosure = styled(NudeButton)`
|
||||
opacity: 0;
|
||||
color: ${(props) => props.theme.divider};
|
||||
color: ${s("divider")};
|
||||
position: absolute;
|
||||
top: calc(25vh - 50px);
|
||||
left: 50%;
|
||||
@@ -155,12 +156,12 @@ const Disclosure = styled(NudeButton)`
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
color: ${s("sidebarText")};
|
||||
}
|
||||
`;
|
||||
|
||||
const Placeholder = styled(ButtonLink)`
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
color: ${s("placeholder")};
|
||||
cursor: text;
|
||||
min-height: 27px;
|
||||
`;
|
||||
@@ -193,7 +194,7 @@ const Input = styled.div`
|
||||
margin: -8px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
transition: ${s("backgroundTransition")};
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
@@ -206,7 +207,7 @@ const Input = styled.div`
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
${(props) => transparentize(1, props.theme.background)} 0%,
|
||||
${(props) => props.theme.background} 100%
|
||||
${s("background")} 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -218,7 +219,7 @@ const Input = styled.div`
|
||||
}
|
||||
|
||||
&[data-editing="true"] {
|
||||
background: ${(props) => props.theme.secondaryBackground};
|
||||
background: ${s("secondaryBackground")};
|
||||
}
|
||||
|
||||
.block-menu-trigger,
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar";
|
||||
import { observer } from "mobx-react";
|
||||
import { QuestionMarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths } from "@shared/styles";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import CommandBarResults from "~/components/CommandBarResults";
|
||||
import SearchActions from "~/components/SearchActions";
|
||||
import rootActions from "~/actions/root";
|
||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||
import useSettingsActions from "~/hooks/useSettingsActions";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { CommandBarAction } from "~/types";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import Text from "./Text";
|
||||
|
||||
function CommandBar() {
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
const settingsActions = useSettingsActions();
|
||||
const commandBarActions = React.useMemo(
|
||||
() => [...rootActions, settingsActions],
|
||||
@@ -30,9 +25,9 @@ function CommandBar() {
|
||||
|
||||
const { rootAction } = useKBar((state) => ({
|
||||
rootAction: state.currentRootActionId
|
||||
? ((state.actions[
|
||||
? (state.actions[
|
||||
state.currentRootActionId
|
||||
] as unknown) as CommandBarAction)
|
||||
] as unknown as CommandBarAction)
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
@@ -50,17 +45,6 @@ function CommandBar() {
|
||||
}…`}
|
||||
/>
|
||||
<CommandBarResults />
|
||||
{ui.commandBarOpenedFromSidebar && (
|
||||
<Hint size="small" type="tertiary">
|
||||
<QuestionMarkIcon size={18} color="currentColor" />
|
||||
{t(
|
||||
"Open search from anywhere with the {{ shortcut }} shortcut",
|
||||
{
|
||||
shortcut: `${metaDisplay} + k`,
|
||||
}
|
||||
)}
|
||||
</Hint>
|
||||
)}
|
||||
</Animator>
|
||||
</Positioner>
|
||||
</KBarPortal>
|
||||
@@ -68,7 +52,11 @@ function CommandBar() {
|
||||
);
|
||||
}
|
||||
|
||||
const KBarPortal: React.FC = ({ children }) => {
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const KBarPortal: React.FC = ({ children }: Props) => {
|
||||
const { showing } = useKBar((state) => ({
|
||||
showing: state.visualState !== "hidden",
|
||||
}));
|
||||
@@ -80,16 +68,6 @@ const KBarPortal: React.FC = ({ children }) => {
|
||||
return <Portal>{children}</Portal>;
|
||||
};
|
||||
|
||||
const Hint = styled(Text)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-top: 1px solid ${(props) => props.theme.background};
|
||||
margin: 1px 0 0;
|
||||
padding: 6px 16px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Positioner = styled(KBarPositioner)`
|
||||
z-index: ${depths.commandBar};
|
||||
`;
|
||||
@@ -99,12 +77,12 @@ const SearchInput = styled(KBarSearch)`
|
||||
width: 100%;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${s("menuBackground")};
|
||||
color: ${s("text")};
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
color: ${s("placeholder")};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -112,8 +90,8 @@ const Animator = styled(KBarAnimator)`
|
||||
max-width: 600px;
|
||||
max-height: 75vh;
|
||||
width: 90vw;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${s("menuBackground")};
|
||||
color: ${s("text")};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: rgb(0 0 0 / 40%) 0px 16px 60px;
|
||||
|
||||
@@ -2,8 +2,10 @@ import { ActionImpl } from "kbar";
|
||||
import { ArrowIcon, BackIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import Key from "~/components/Key";
|
||||
import Text from "./Text";
|
||||
|
||||
type Props = {
|
||||
action: ActionImpl;
|
||||
@@ -38,10 +40,9 @@ function CommandBarItem(
|
||||
// @ts-expect-error no icon on ActionImpl
|
||||
React.cloneElement(action.icon, {
|
||||
size: 22,
|
||||
color: "currentColor",
|
||||
})
|
||||
) : (
|
||||
<ArrowIcon color="currentColor" />
|
||||
<ArrowIcon />
|
||||
)}
|
||||
</Icon>
|
||||
|
||||
@@ -55,43 +56,56 @@ function CommandBarItem(
|
||||
{action.children?.length ? "…" : ""}
|
||||
</Content>
|
||||
{action.shortcut?.length ? (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridAutoFlow: "column",
|
||||
gap: "4px",
|
||||
}}
|
||||
>
|
||||
{action.shortcut.map((sc: string) => (
|
||||
<Key key={sc}>{sc}</Key>
|
||||
<Shortcut>
|
||||
{action.shortcut.map((sc: string, index) => (
|
||||
<React.Fragment key={sc}>
|
||||
{index > 0 ? (
|
||||
<>
|
||||
{" "}
|
||||
<Text size="xsmall" as="span" type="secondary">
|
||||
then
|
||||
</Text>{" "}
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{sc.split("+").map((s) => (
|
||||
<Key key={s}>{s}</Key>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</Shortcut>
|
||||
) : null}
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
|
||||
const Shortcut = styled.div`
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const Icon = styled(Flex)`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
color: ${s("textSecondary")};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Ancestor = styled.span`
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
const Content = styled(Flex)`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
${ellipsis()}
|
||||
flex-shrink: 1;
|
||||
`;
|
||||
|
||||
const Item = styled.div<{ active?: boolean }>`
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
padding: 9px 12px;
|
||||
margin: 0 8px;
|
||||
border-radius: 4px;
|
||||
@@ -102,9 +116,7 @@ const Item = styled.div<{ active?: boolean }>`
|
||||
justify-content: space-between;
|
||||
cursor: var(--pointer);
|
||||
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
${ellipsis()}
|
||||
user-select: none;
|
||||
min-width: 0;
|
||||
|
||||
|
||||
@@ -1,35 +1,45 @@
|
||||
import { useMatches, KBarResults } from "kbar";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import CommandBarItem from "~/components/CommandBarItem";
|
||||
|
||||
export default function CommandBarResults() {
|
||||
const { results, rootActionId } = useMatches();
|
||||
|
||||
return (
|
||||
<KBarResults
|
||||
items={results}
|
||||
maxHeight={400}
|
||||
onRender={({ item, active }) =>
|
||||
typeof item === "string" ? (
|
||||
<Header>{item}</Header>
|
||||
) : (
|
||||
<CommandBarItem
|
||||
action={item}
|
||||
active={active}
|
||||
currentRootActionId={rootActionId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Container>
|
||||
<KBarResults
|
||||
items={results}
|
||||
maxHeight={400}
|
||||
onRender={({ item, active }) =>
|
||||
typeof item === "string" ? (
|
||||
<Header>{item}</Header>
|
||||
) : (
|
||||
<CommandBarItem
|
||||
action={item}
|
||||
active={active}
|
||||
currentRootActionId={rootActionId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// Cannot style KBarResults unfortunately, so we must wrap and target the inner
|
||||
const Container = styled.div`
|
||||
> div {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Header = styled.h3`
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0;
|
||||
padding: 16px 0 4px 20px;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
color: ${s("textTertiary")};
|
||||
height: 36px;
|
||||
`;
|
||||
|
||||
@@ -15,6 +15,9 @@ type Props = {
|
||||
savingText?: string;
|
||||
/** If true, the submit button will be a dangerous red */
|
||||
danger?: boolean;
|
||||
/** Keep the submit button disabled */
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const ConfirmationDialog: React.FC<Props> = ({
|
||||
@@ -23,7 +26,8 @@ const ConfirmationDialog: React.FC<Props> = ({
|
||||
submitText,
|
||||
savingText,
|
||||
danger,
|
||||
}) => {
|
||||
disabled = false,
|
||||
}: Props) => {
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const { dialogs } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
@@ -50,7 +54,12 @@ const ConfirmationDialog: React.FC<Props> = ({
|
||||
<Flex column>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text type="secondary">{children}</Text>
|
||||
<Button type="submit" disabled={isSaving} danger={danger} autoFocus>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSaving || disabled}
|
||||
danger={danger}
|
||||
autoFocus
|
||||
>
|
||||
{isSaving && savingText ? savingText : submitText}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
+114
-101
@@ -1,6 +1,7 @@
|
||||
import isPrintableKeyEvent from "is-printable-key-event";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import useOnScreen from "~/hooks/useOnScreen";
|
||||
|
||||
type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
|
||||
@@ -29,63 +30,74 @@ export type RefHandle = {
|
||||
* Defines a content editable component with the same interface as a native
|
||||
* HTMLInputElement (or, as close as we can get).
|
||||
*/
|
||||
const ContentEditable = React.forwardRef(
|
||||
(
|
||||
{
|
||||
disabled,
|
||||
onChange,
|
||||
onInput,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
maxLength,
|
||||
autoFocus,
|
||||
placeholder,
|
||||
readOnly,
|
||||
dir,
|
||||
onClick,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.RefObject<RefHandle>
|
||||
) => {
|
||||
const contentRef = React.useRef<HTMLSpanElement>(null);
|
||||
const [innerValue, setInnerValue] = React.useState<string>(value);
|
||||
const lastValue = React.useRef("");
|
||||
const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
{
|
||||
disabled,
|
||||
onChange,
|
||||
onInput,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
maxLength,
|
||||
autoFocus,
|
||||
placeholder,
|
||||
readOnly,
|
||||
dir,
|
||||
onClick,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.RefObject<RefHandle>
|
||||
) {
|
||||
const contentRef = React.useRef<HTMLSpanElement>(null);
|
||||
const [innerValue, setInnerValue] = React.useState<string>(value);
|
||||
const lastValue = React.useRef(value);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
contentRef.current?.focus();
|
||||
},
|
||||
focusAtStart: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
// looks unnecessary but required because of https://github.com/outline/outline/issues/5198
|
||||
if (!contentRef.current.innerText) {
|
||||
placeCaret(contentRef.current, true);
|
||||
}
|
||||
},
|
||||
focusAtEnd: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
placeCaret(contentRef.current, false);
|
||||
}
|
||||
},
|
||||
getComputedDirection: () => {
|
||||
if (contentRef.current) {
|
||||
return window.getComputedStyle(contentRef.current).direction;
|
||||
}
|
||||
return "ltr";
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
focusAtStart: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
placeCaret(contentRef.current, true);
|
||||
}
|
||||
},
|
||||
focusAtEnd: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
placeCaret(contentRef.current, false);
|
||||
}
|
||||
},
|
||||
getComputedDirection: () => {
|
||||
if (contentRef.current) {
|
||||
return window.getComputedStyle(contentRef.current).direction;
|
||||
}
|
||||
return "ltr";
|
||||
},
|
||||
}));
|
||||
|
||||
const wrappedEvent = (
|
||||
const wrappedEvent =
|
||||
(
|
||||
callback:
|
||||
| React.FocusEventHandler<HTMLSpanElement>
|
||||
| React.FormEventHandler<HTMLSpanElement>
|
||||
| React.KeyboardEventHandler<HTMLSpanElement>
|
||||
| undefined
|
||||
) => (event: any) => {
|
||||
const text = contentRef.current?.innerText || "";
|
||||
) =>
|
||||
(event: any) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = event.currentTarget.textContent || "";
|
||||
|
||||
if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) {
|
||||
event?.preventDefault();
|
||||
@@ -94,62 +106,62 @@ const ContentEditable = React.forwardRef(
|
||||
|
||||
if (text !== lastValue.current) {
|
||||
lastValue.current = text;
|
||||
onChange && onChange(text);
|
||||
onChange?.(text);
|
||||
}
|
||||
|
||||
callback?.(event);
|
||||
};
|
||||
|
||||
// This is to account for being within a React.Suspense boundary, in this
|
||||
// case the component may be rendered with display: none. React 18 may solve
|
||||
// this in the future by delaying useEffect hooks:
|
||||
// https://github.com/facebook/react/issues/14536#issuecomment-861980492
|
||||
const isVisible = useOnScreen(contentRef);
|
||||
// This is to account for being within a React.Suspense boundary, in this
|
||||
// case the component may be rendered with display: none. React 18 may solve
|
||||
// this in the future by delaying useEffect hooks:
|
||||
// https://github.com/facebook/react/issues/14536#issuecomment-861980492
|
||||
const isVisible = useOnScreen(contentRef);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoFocus && isVisible && !disabled && !readOnly) {
|
||||
contentRef.current?.focus();
|
||||
}
|
||||
}, [autoFocus, disabled, isVisible, readOnly, contentRef]);
|
||||
React.useEffect(() => {
|
||||
if (autoFocus && isVisible && !disabled && !readOnly) {
|
||||
contentRef.current?.focus();
|
||||
}
|
||||
}, [autoFocus, disabled, isVisible, readOnly, contentRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value !== contentRef.current?.innerText) {
|
||||
setInnerValue(value);
|
||||
}
|
||||
}, [value, contentRef]);
|
||||
React.useEffect(() => {
|
||||
if (contentRef.current && value !== contentRef.current.textContent) {
|
||||
setInnerValue(value);
|
||||
}
|
||||
}, [value, contentRef]);
|
||||
|
||||
// Ensure only plain text can be pasted into input when pasting from another
|
||||
// rich text source
|
||||
const handlePaste = React.useCallback(
|
||||
(event: React.ClipboardEvent<HTMLSpanElement>) => {
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
window.document.execCommand("insertText", false, text);
|
||||
},
|
||||
[]
|
||||
);
|
||||
// Ensure only plain text can be pasted into input when pasting from another
|
||||
// rich text source. Note: If `onPaste` prop is passed then it takes
|
||||
// priority over this behavior.
|
||||
const handlePaste = React.useCallback(
|
||||
(event: React.ClipboardEvent<HTMLSpanElement>) => {
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
window.document.execCommand("insertText", false, text);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className} dir={dir} onClick={onClick}>
|
||||
<Content
|
||||
ref={contentRef}
|
||||
contentEditable={!disabled && !readOnly}
|
||||
onInput={wrappedEvent(onInput)}
|
||||
onBlur={wrappedEvent(onBlur)}
|
||||
onKeyDown={wrappedEvent(onKeyDown)}
|
||||
onPaste={handlePaste}
|
||||
data-placeholder={placeholder}
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
{...rest}
|
||||
>
|
||||
{innerValue}
|
||||
</Content>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
return (
|
||||
<div className={className} dir={dir} onClick={onClick}>
|
||||
<Content
|
||||
ref={contentRef}
|
||||
contentEditable={!disabled && !readOnly}
|
||||
onInput={wrappedEvent(onInput)}
|
||||
onBlur={wrappedEvent(onBlur)}
|
||||
onKeyDown={wrappedEvent(onKeyDown)}
|
||||
onPaste={handlePaste}
|
||||
data-placeholder={placeholder}
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
{...rest}
|
||||
>
|
||||
{innerValue}
|
||||
</Content>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function placeCaret(element: HTMLElement, atStart: boolean) {
|
||||
if (
|
||||
@@ -166,13 +178,14 @@ function placeCaret(element: HTMLElement, atStart: boolean) {
|
||||
}
|
||||
|
||||
const Content = styled.span`
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
color: ${(props) => props.theme.text};
|
||||
-webkit-text-fill-color: ${(props) => props.theme.text};
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
color: ${s("text")};
|
||||
-webkit-text-fill-color: ${s("text")};
|
||||
outline: none;
|
||||
resize: none;
|
||||
cursor: text;
|
||||
word-break: anywhere;
|
||||
|
||||
&:empty {
|
||||
display: inline-block;
|
||||
@@ -180,8 +193,8 @@ const Content = styled.span`
|
||||
|
||||
&:empty::before {
|
||||
display: inline-block;
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
-webkit-text-fill-color: ${(props) => props.theme.placeholder};
|
||||
color: ${s("placeholder")};
|
||||
-webkit-text-fill-color: ${s("placeholder")};
|
||||
content: attr(data-placeholder);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
const Header = styled.h3`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
color: ${s("sidebarText")};
|
||||
letter-spacing: 0.04em;
|
||||
margin: 1em 12px 0.5em;
|
||||
`;
|
||||
|
||||
@@ -8,6 +8,7 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import MenuIconWrapper from "../MenuIconWrapper";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
onClick?: (event: React.SyntheticEvent) => void | Promise<void>;
|
||||
active?: boolean;
|
||||
selected?: boolean;
|
||||
@@ -21,6 +22,7 @@ type Props = {
|
||||
level?: number;
|
||||
icon?: React.ReactElement;
|
||||
children?: React.ReactNode;
|
||||
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
|
||||
};
|
||||
|
||||
const MenuItem = (
|
||||
@@ -37,34 +39,26 @@ const MenuItem = (
|
||||
}: Props,
|
||||
ref: React.Ref<HTMLAnchorElement>
|
||||
) => {
|
||||
const handleClick = React.useCallback(
|
||||
(ev) => {
|
||||
if (onClick) {
|
||||
const content = React.useCallback(
|
||||
(props) => {
|
||||
const handleClick = async (ev: React.MouseEvent) => {
|
||||
hide?.();
|
||||
|
||||
if (onClick) {
|
||||
ev.preventDefault();
|
||||
await onClick(ev);
|
||||
}
|
||||
};
|
||||
|
||||
// Preventing default mousedown otherwise menu items do not work in Firefox,
|
||||
// which triggers the hideOnClickOutside handler first via mousedown – hiding
|
||||
// and un-rendering the menu contents.
|
||||
const handleMouseDown = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
onClick(ev);
|
||||
}
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
hide?.();
|
||||
},
|
||||
[onClick, hide]
|
||||
);
|
||||
|
||||
// Preventing default mousedown otherwise menu items do not work in Firefox,
|
||||
// which triggers the hideOnClickOutside handler first via mousedown – hiding
|
||||
// and un-rendering the menu contents.
|
||||
const handleMouseDown = React.useCallback((ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BaseMenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
hide={hide}
|
||||
{...rest}
|
||||
>
|
||||
{(props) => (
|
||||
return (
|
||||
<MenuAnchor
|
||||
{...props}
|
||||
$active={active}
|
||||
@@ -78,18 +72,26 @@ const MenuItem = (
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<>
|
||||
{selected ? <CheckmarkIcon color="currentColor" /> : <Spacer />}
|
||||
{selected ? <CheckmarkIcon /> : <Spacer />}
|
||||
|
||||
</>
|
||||
)}
|
||||
{icon && (
|
||||
<MenuIconWrapper>
|
||||
{React.cloneElement(icon, { color: "currentColor" })}
|
||||
</MenuIconWrapper>
|
||||
)}
|
||||
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
|
||||
{children}
|
||||
</MenuAnchor>
|
||||
)}
|
||||
);
|
||||
},
|
||||
[active, as, hide, icon, onClick, ref, children, selected]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseMenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
hide={hide}
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
</BaseMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,8 +24,12 @@ type Positions = {
|
||||
export default function MouseSafeArea(props: {
|
||||
parentRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const { x = 0, y = 0, height: h = 0, width: w = 0 } =
|
||||
props.parentRef.current?.getBoundingClientRect() || {};
|
||||
const {
|
||||
x = 0,
|
||||
y = 0,
|
||||
height: h = 0,
|
||||
width: w = 0,
|
||||
} = props.parentRef.current?.getBoundingClientRect() || {};
|
||||
const [mouseX, mouseY] = useMousePosition();
|
||||
const positions = { x, y, h, w, mouseX, mouseY };
|
||||
|
||||
|
||||
@@ -5,19 +5,14 @@ import NudeButton from "~/components/NudeButton";
|
||||
|
||||
type Props = React.ComponentProps<typeof MenuButton> & {
|
||||
className?: string;
|
||||
iconColor?: string;
|
||||
};
|
||||
|
||||
export default function OverflowMenuButton({
|
||||
iconColor,
|
||||
className,
|
||||
...rest
|
||||
}: Props) {
|
||||
export default function OverflowMenuButton({ className, ...rest }: Props) {
|
||||
return (
|
||||
<MenuButton {...rest}>
|
||||
{(props) => (
|
||||
<NudeButton className={className} {...props}>
|
||||
<MoreIcon color={iconColor} />
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
|
||||
@@ -44,36 +44,35 @@ type SubMenuProps = MenuStateReturn & {
|
||||
title: React.ReactNode;
|
||||
};
|
||||
|
||||
const SubMenu = React.forwardRef(
|
||||
(
|
||||
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
|
||||
ref: React.LegacyRef<HTMLButtonElement>
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const menu = useMenuState();
|
||||
const SubMenu = React.forwardRef(function _Template(
|
||||
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
|
||||
ref: React.LegacyRef<HTMLButtonElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const menu = useMenuState();
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton ref={ref} {...menu} {...rest}>
|
||||
{(props) => (
|
||||
<MenuAnchor disclosure {...props}>
|
||||
{title} <Disclosure color={theme.textTertiary} />
|
||||
</MenuAnchor>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
aria-label={t("Submenu")}
|
||||
onClick={parentMenuState.hide}
|
||||
>
|
||||
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
|
||||
<Template {...menu} items={templateItems} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<MenuButton ref={ref} {...menu} {...rest}>
|
||||
{(props) => (
|
||||
<MenuAnchor disclosure {...props}>
|
||||
{title} <Disclosure color={theme.textTertiary} />
|
||||
</MenuAnchor>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
aria-label={t("Submenu")}
|
||||
onClick={parentMenuState.hide}
|
||||
parentMenuState={parentMenuState}
|
||||
>
|
||||
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
|
||||
<Template {...menu} items={templateItems} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
return items
|
||||
@@ -134,6 +133,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
return (
|
||||
<MenuItem
|
||||
as={Link}
|
||||
id={`${item.title}-${index}`}
|
||||
to={item.to}
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
@@ -149,6 +149,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
if (item.type === "link") {
|
||||
return (
|
||||
<MenuItem
|
||||
id={`${item.title}-${index}`}
|
||||
href={item.href}
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
@@ -167,6 +168,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
return (
|
||||
<MenuItem
|
||||
as="button"
|
||||
id={`${item.title}-${index}`}
|
||||
onClick={item.onClick}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
@@ -185,6 +187,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
<BaseMenuItem
|
||||
key={index}
|
||||
as={SubMenu}
|
||||
id={`${item.title}-${index}`}
|
||||
templateItems={item.items}
|
||||
parentMenuState={menu}
|
||||
title={<Title title={item.title} icon={item.icon} />}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Menu, MenuStateReturn } from "reakit/Menu";
|
||||
import styled, { DefaultTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths } from "@shared/styles";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useMenuContext from "~/hooks/useMenuContext";
|
||||
import useMenuHeight from "~/hooks/useMenuHeight";
|
||||
@@ -37,15 +37,16 @@ export type Placement =
|
||||
| "left-start";
|
||||
|
||||
type Props = MenuStateReturn & {
|
||||
"aria-label": string;
|
||||
"aria-label"?: string;
|
||||
/** The parent menu state if this is a submenu. */
|
||||
parentMenuState?: MenuStateReturn;
|
||||
parentMenuState?: Omit<MenuStateReturn, "items">;
|
||||
/** Called when the context menu is opened. */
|
||||
onOpen?: () => void;
|
||||
/** Called when the context menu is closed. */
|
||||
onClose?: () => void;
|
||||
/** Called when the context menu is clicked. */
|
||||
onClick?: (ev: React.MouseEvent) => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const ContextMenu: React.FC<Props> = ({
|
||||
@@ -54,14 +55,18 @@ const ContextMenu: React.FC<Props> = ({
|
||||
onClose,
|
||||
parentMenuState,
|
||||
...rest
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const previousVisible = usePrevious(rest.visible);
|
||||
const maxHeight = useMenuHeight(rest.visible, rest.unstable_disclosureRef);
|
||||
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();
|
||||
const isMobile = useMobile();
|
||||
const isSubMenu = !!parentMenuState;
|
||||
|
||||
useUnmount(() => {
|
||||
setIsMenuOpen(false);
|
||||
@@ -71,7 +76,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
if (rest.visible && !previousVisible) {
|
||||
onOpen?.();
|
||||
|
||||
if (!parentMenuState) {
|
||||
if (!isSubMenu) {
|
||||
setIsMenuOpen(true);
|
||||
}
|
||||
}
|
||||
@@ -79,7 +84,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
if (!rest.visible && previousVisible) {
|
||||
onClose?.();
|
||||
|
||||
if (!parentMenuState) {
|
||||
if (!isSubMenu) {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
}
|
||||
@@ -90,7 +95,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
rest.visible,
|
||||
ui.sidebarCollapsed,
|
||||
setIsMenuOpen,
|
||||
parentMenuState,
|
||||
isSubMenu,
|
||||
t,
|
||||
]);
|
||||
|
||||
@@ -99,13 +104,15 @@ const ContextMenu: React.FC<Props> = ({
|
||||
// https://github.com/ariakit/ariakit/issues/469
|
||||
React.useEffect(() => {
|
||||
const scrollElement = backgroundRef.current;
|
||||
if (rest.visible && scrollElement) {
|
||||
disableBodyScroll(scrollElement);
|
||||
if (rest.visible && scrollElement && !isSubMenu) {
|
||||
disableBodyScroll(scrollElement, {
|
||||
reserveScrollBarGap: true,
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
scrollElement && enableBodyScroll(scrollElement);
|
||||
scrollElement && !isSubMenu && enableBodyScroll(scrollElement);
|
||||
};
|
||||
}, [rest.visible]);
|
||||
}, [isSubMenu, rest.visible]);
|
||||
|
||||
// Perf win – don't render anything until the menu has been opened
|
||||
if (!rest.visible && !previousVisible) {
|
||||
@@ -144,7 +151,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
ref={backgroundRef}
|
||||
hiddenScrollbars
|
||||
style={
|
||||
maxHeight && topAnchor
|
||||
topAnchor
|
||||
? {
|
||||
maxHeight,
|
||||
}
|
||||
@@ -171,7 +178,7 @@ export const Backdrop = styled.div`
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: ${(props) => props.theme.backdrop};
|
||||
background: ${s("backdrop")};
|
||||
z-index: ${depths.menu - 1};
|
||||
`;
|
||||
|
||||
@@ -203,7 +210,7 @@ export const Background = styled(Scrollable)<BackgroundProps>`
|
||||
animation: ${mobileContextMenu} 200ms ease;
|
||||
transform-origin: 50% 100%;
|
||||
max-width: 100%;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
background: ${s("menuBackground")};
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
min-width: 180px;
|
||||
|
||||
@@ -28,7 +28,7 @@ const DefaultCollectionInputSelect = ({
|
||||
const { showToast } = useToasts();
|
||||
|
||||
React.useEffect(() => {
|
||||
async function load() {
|
||||
async function fetchData() {
|
||||
if (!collections.isLoaded && !fetching && !fetchError) {
|
||||
try {
|
||||
setFetching(true);
|
||||
@@ -48,7 +48,7 @@ const DefaultCollectionInputSelect = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
load();
|
||||
void fetchData();
|
||||
}, [showToast, fetchError, t, fetching, collections]);
|
||||
|
||||
const options = React.useMemo(
|
||||
@@ -73,7 +73,7 @@ const DefaultCollectionInputSelect = ({
|
||||
label: (
|
||||
<Flex align="center">
|
||||
<IconWrapper>
|
||||
<HomeIcon color="currentColor" />
|
||||
<HomeIcon />
|
||||
</IconWrapper>
|
||||
{t("Home")}
|
||||
</Flex>
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function DesktopEventHandler() {
|
||||
action: {
|
||||
text: "Install now",
|
||||
onClick: () => {
|
||||
Desktop.bridge?.restartAndInstall();
|
||||
void Desktop.bridge?.restartAndInstall();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
const Divider = styled.hr`
|
||||
border: 0;
|
||||
border-bottom: 1px solid ${(props) => props.theme.divider};
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
@@ -9,9 +9,15 @@ import Breadcrumb from "~/components/Breadcrumb";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import { collectionUrl } from "~/utils/routeHelpers";
|
||||
import {
|
||||
archivePath,
|
||||
collectionPath,
|
||||
templatesPath,
|
||||
trashPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
document: Document;
|
||||
onlyText?: boolean;
|
||||
};
|
||||
@@ -22,27 +28,27 @@ function useCategory(document: Document): MenuInternalLink | null {
|
||||
if (document.isDeleted) {
|
||||
return {
|
||||
type: "route",
|
||||
icon: <TrashIcon color="currentColor" />,
|
||||
icon: <TrashIcon />,
|
||||
title: t("Trash"),
|
||||
to: "/trash",
|
||||
to: trashPath(),
|
||||
};
|
||||
}
|
||||
|
||||
if (document.isArchived) {
|
||||
return {
|
||||
type: "route",
|
||||
icon: <ArchiveIcon color="currentColor" />,
|
||||
icon: <ArchiveIcon />,
|
||||
title: t("Archive"),
|
||||
to: "/archive",
|
||||
to: archivePath(),
|
||||
};
|
||||
}
|
||||
|
||||
if (document.isTemplate) {
|
||||
return {
|
||||
type: "route",
|
||||
icon: <ShapesIcon color="currentColor" />,
|
||||
icon: <ShapesIcon />,
|
||||
title: t("Templates"),
|
||||
to: "/templates",
|
||||
to: templatesPath(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,11 +59,13 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
document,
|
||||
children,
|
||||
onlyText,
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const category = useCategory(document);
|
||||
const collection = collections.get(document.collectionId);
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
let collectionNode: MenuInternalLink | undefined;
|
||||
|
||||
@@ -66,14 +74,14 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
type: "route",
|
||||
title: collection.name,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: collectionUrl(collection.url),
|
||||
to: collectionPath(collection.url),
|
||||
};
|
||||
} else if (document.collectionId && !collection) {
|
||||
collectionNode = {
|
||||
type: "route",
|
||||
title: t("Deleted Collection"),
|
||||
icon: undefined,
|
||||
to: collectionUrl("deleted-collection"),
|
||||
to: collectionPath("deleted-collection"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,7 +130,11 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
);
|
||||
}
|
||||
|
||||
return <Breadcrumb items={items} children={children} highlightFirstItem />;
|
||||
return (
|
||||
<Breadcrumb items={items} highlightFirstItem>
|
||||
{children}
|
||||
</Breadcrumb>
|
||||
);
|
||||
};
|
||||
|
||||
const SmallSlash = styled(GoToIcon)`
|
||||
@@ -131,7 +143,7 @@ const SmallSlash = styled(GoToIcon)`
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
|
||||
fill: ${(props) => props.theme.slate};
|
||||
fill: ${(props) => props.theme.textTertiary};
|
||||
opacity: 0.5;
|
||||
`;
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Pin from "~/models/Pin";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
import CollectionIcon from "./Icons/CollectionIcon";
|
||||
import EmojiIcon from "./Icons/EmojiIcon";
|
||||
import Squircle from "./Squircle";
|
||||
@@ -35,7 +37,9 @@ function DocumentCard(props: Props) {
|
||||
const { collections } = useStores();
|
||||
const theme = useTheme();
|
||||
const { document, pin, canUpdatePin, isDraggable } = props;
|
||||
const collection = collections.get(document.collectionId);
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -54,10 +58,10 @@ function DocumentCard(props: Props) {
|
||||
};
|
||||
|
||||
const handleUnpin = React.useCallback(
|
||||
(ev) => {
|
||||
async (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
pin?.delete();
|
||||
await pin?.delete();
|
||||
},
|
||||
[pin]
|
||||
);
|
||||
@@ -127,7 +131,7 @@ function DocumentCard(props: Props) {
|
||||
: document.titleWithDefault}
|
||||
</Heading>
|
||||
<DocumentMeta size="xsmall">
|
||||
<Clock color="currentColor" size={18} />
|
||||
<Clock size={18} />
|
||||
<Time
|
||||
dateTime={document.updatedAt}
|
||||
tooltipDelay={500}
|
||||
@@ -142,7 +146,7 @@ function DocumentCard(props: Props) {
|
||||
{!isDragging && pin && (
|
||||
<Tooltip tooltip={t("Unpin")}>
|
||||
<PinButton onClick={handleUnpin} aria-label={t("Unpin")}>
|
||||
<CloseIcon color="currentColor" />
|
||||
<CloseIcon />
|
||||
</PinButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -164,9 +168,9 @@ const AnimatePresence = styled(m.div)`
|
||||
`;
|
||||
|
||||
const Fold = styled.svg`
|
||||
fill: ${(props) => props.theme.background};
|
||||
stroke: ${(props) => props.theme.inputBorder};
|
||||
background: ${(props) => props.theme.background};
|
||||
fill: ${s("background")};
|
||||
stroke: ${s("inputBorder")};
|
||||
background: ${s("background")};
|
||||
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
@@ -174,11 +178,11 @@ const Fold = styled.svg`
|
||||
`;
|
||||
|
||||
const PinButton = styled(NudeButton)`
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
color: ${s("textTertiary")};
|
||||
|
||||
&:hover,
|
||||
&:${hover},
|
||||
&:active {
|
||||
color: ${(props) => props.theme.text};
|
||||
color: ${s("text")};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -188,7 +192,7 @@ const Actions = styled(Flex)`
|
||||
right: ${(props) => (props.dir === "rtl" ? "auto" : "4px")};
|
||||
left: ${(props) => (props.dir === "rtl" ? "4px" : "auto")};
|
||||
opacity: 0;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
color: ${s("textTertiary")};
|
||||
|
||||
// move actions above content
|
||||
z-index: 2;
|
||||
@@ -206,7 +210,7 @@ const Reorderable = styled.div<{ $isDragging: boolean }>`
|
||||
z-index: ${(props) => (props.$isDragging ? 1 : "inherit")};
|
||||
pointer-events: ${(props) => (props.$isDragging ? "none" : "inherit")};
|
||||
|
||||
&:hover ${Actions} {
|
||||
&: ${hover} ${Actions} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
@@ -217,14 +221,12 @@ const Content = styled(Flex)`
|
||||
`;
|
||||
|
||||
const DocumentMeta = styled(Text)`
|
||||
${ellipsis()}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
color: ${s("textTertiary")};
|
||||
margin: 0 0 0 -2px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const DocumentLink = styled(Link)<{
|
||||
@@ -237,9 +239,9 @@ const DocumentLink = styled(Link)<{
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
cursor: var(--pointer);
|
||||
background: ${(props) => props.theme.background};
|
||||
background: ${s("background")};
|
||||
transition: transform 50ms ease-in-out;
|
||||
border: 1px solid ${(props) => props.theme.inputBorder};
|
||||
border: 1px solid ${s("inputBorder")};
|
||||
border-bottom-width: 2px;
|
||||
border-right-width: 2px;
|
||||
|
||||
@@ -247,7 +249,7 @@ const DocumentLink = styled(Link)<{
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:${hover},
|
||||
&:active,
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
@@ -276,7 +278,7 @@ const Heading = styled.h3`
|
||||
max-height: 66px; // 3*line-height
|
||||
overflow: hidden;
|
||||
|
||||
color: ${(props) => props.theme.text};
|
||||
color: ${s("text")};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
`;
|
||||
|
||||
@@ -45,10 +45,8 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
|
||||
null
|
||||
);
|
||||
const [initialScrollOffset, setInitialScrollOffset] = React.useState<number>(
|
||||
0
|
||||
);
|
||||
const [nodes, setNodes] = React.useState<NavigationNode[]>([]);
|
||||
const [initialScrollOffset, setInitialScrollOffset] =
|
||||
React.useState<number>(0);
|
||||
const [activeNode, setActiveNode] = React.useState<number>(0);
|
||||
const [expandedNodes, setExpandedNodes] = React.useState<string[]>([]);
|
||||
const [itemRefs, setItemRefs] = React.useState<
|
||||
@@ -63,11 +61,13 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
const VERTICAL_PADDING = 6;
|
||||
const HORIZONTAL_PADDING = 24;
|
||||
|
||||
const searchIndex = React.useMemo(() => {
|
||||
return new FuzzySearch(items, ["title"], {
|
||||
caseSensitive: false,
|
||||
});
|
||||
}, [items]);
|
||||
const searchIndex = React.useMemo(
|
||||
() =>
|
||||
new FuzzySearch(items, ["title"], {
|
||||
caseSensitive: false,
|
||||
}),
|
||||
[items]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (searchTerm) {
|
||||
@@ -77,19 +77,6 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
setActiveNode(0);
|
||||
}, [searchTerm]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let results;
|
||||
|
||||
if (searchTerm) {
|
||||
results = searchIndex.search(searchTerm);
|
||||
} else {
|
||||
results = items.filter((item) => item.type === "collection");
|
||||
}
|
||||
|
||||
setInitialScrollOffset(0);
|
||||
setNodes(results);
|
||||
}, [searchTerm, items, searchIndex]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setItemRefs((itemRefs) =>
|
||||
map(
|
||||
@@ -103,6 +90,22 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
onSelect(selectedNode);
|
||||
}, [selectedNode, onSelect]);
|
||||
|
||||
function getNodes() {
|
||||
function includeDescendants(item: NavigationNode): NavigationNode[] {
|
||||
return expandedNodes.includes(item.id)
|
||||
? [item, ...descendants(item, 1).flatMap(includeDescendants)]
|
||||
: [item];
|
||||
}
|
||||
|
||||
return searchTerm
|
||||
? searchIndex.search(searchTerm)
|
||||
: items
|
||||
.filter((item) => item.type === "collection")
|
||||
.flatMap(includeDescendants);
|
||||
}
|
||||
|
||||
const nodes = getNodes();
|
||||
|
||||
const scrollNodeIntoView = React.useCallback(
|
||||
(node: number) => {
|
||||
if (itemRefs[node] && itemRefs[node].current) {
|
||||
@@ -119,9 +122,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
setSearchTerm(ev.target.value);
|
||||
};
|
||||
|
||||
const isExpanded = (node: number) => {
|
||||
return includes(expandedNodes, nodes[node].id);
|
||||
};
|
||||
const isExpanded = (node: number) => includes(expandedNodes, nodes[node].id);
|
||||
|
||||
const calculateInitialScrollOffset = (itemCount: number) => {
|
||||
if (listRef.current) {
|
||||
@@ -130,7 +131,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
scrollOffset: number;
|
||||
};
|
||||
const itemsHeight = itemCount * itemSize;
|
||||
return itemsHeight < height ? 0 : scrollOffset;
|
||||
return itemsHeight < Number(height) ? 0 : scrollOffset;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
@@ -145,7 +146,6 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
const newNodes = filter(nodes, (node) => !includes(descendantIds, node.id));
|
||||
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
|
||||
setInitialScrollOffset(scrollOffset);
|
||||
setNodes(newNodes);
|
||||
};
|
||||
|
||||
const expand = (node: number) => {
|
||||
@@ -156,9 +156,18 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1));
|
||||
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
|
||||
setInitialScrollOffset(scrollOffset);
|
||||
setNodes(newNodes);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
collections.orderedData
|
||||
.filter(
|
||||
(collection) => expandedNodes.includes(collection.id) || searchTerm
|
||||
)
|
||||
.forEach((collection) => {
|
||||
void collection.fetchDocuments();
|
||||
});
|
||||
}, [collections, expandedNodes, searchTerm]);
|
||||
|
||||
const isSelected = (node: number) => {
|
||||
if (!selectedNode) {
|
||||
return false;
|
||||
@@ -169,9 +178,8 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
return selectedNodeId === nodeId;
|
||||
};
|
||||
|
||||
const hasChildren = (node: number) => {
|
||||
return nodes[node].children.length > 0;
|
||||
};
|
||||
const hasChildren = (node: number) =>
|
||||
nodes[node].children.length > 0 || nodes[node].type === "collection";
|
||||
|
||||
const toggleCollapse = (node: number) => {
|
||||
if (!hasChildren(node)) {
|
||||
@@ -221,7 +229,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
} else if (doc?.isStarred) {
|
||||
icon = <StarredIcon color={theme.yellow} />;
|
||||
} else {
|
||||
icon = <DocumentIcon />;
|
||||
icon = <DocumentIcon color={theme.textSecondary} />;
|
||||
}
|
||||
|
||||
path = ancestors(node)
|
||||
@@ -275,24 +283,22 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
inputSearchRef.current?.focus();
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
return Math.min(activeNode + 1, nodes.length - 1);
|
||||
};
|
||||
const next = () => Math.min(activeNode + 1, nodes.length - 1);
|
||||
|
||||
const prev = () => {
|
||||
return Math.max(activeNode - 1, 0);
|
||||
};
|
||||
const prev = () => Math.max(activeNode - 1, 0);
|
||||
|
||||
const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
switch (ev.key) {
|
||||
case "ArrowDown": {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setActiveNode(next());
|
||||
scrollNodeIntoView(next());
|
||||
break;
|
||||
}
|
||||
case "ArrowUp": {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (activeNode === 0) {
|
||||
focusSearchInput();
|
||||
} else {
|
||||
@@ -329,16 +335,21 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
const innerElementType = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ style, ...rest }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
));
|
||||
>(function innerElementType(
|
||||
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Container tabIndex={-1} onKeyDown={handleKeyDown}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import Disclosure from "~/components/Sidebar/components/Disclosure";
|
||||
import Text from "~/components/Text";
|
||||
@@ -70,9 +71,7 @@ function DocumentExplorerNode(
|
||||
}
|
||||
|
||||
const Title = styled(Text)`
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
${ellipsis()}
|
||||
margin: 0 4px 0 4px;
|
||||
color: inherit;
|
||||
`;
|
||||
@@ -99,7 +98,7 @@ export const Node = styled.span<{
|
||||
overflow: hidden;
|
||||
font-size: 16px;
|
||||
width: ${(props) => props.style.width};
|
||||
color: ${(props) => props.theme.text};
|
||||
color: ${s("text")};
|
||||
cursor: var(--pointer);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import styled from "styled-components";
|
||||
import { ellipsis } from "@shared/styles";
|
||||
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
@@ -73,10 +74,8 @@ const Title = styled(Text)`
|
||||
`;
|
||||
|
||||
const Path = styled(Text)<{ $selected: boolean }>`
|
||||
${ellipsis()}
|
||||
padding-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0 4px 0 8px;
|
||||
color: ${(props) =>
|
||||
props.$selected ? props.theme.white50 : props.theme.textTertiary};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Link } from "react-router-dom";
|
||||
import { CompositeStateReturn, CompositeItem } from "reakit/Composite";
|
||||
import styled, { css } from "styled-components";
|
||||
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";
|
||||
@@ -177,11 +178,11 @@ const Actions = styled(EventBoundary)`
|
||||
margin: 8px;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
${NudeButton} {
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
background: ${(props) => props.theme.sidebarControlHoverBackground};
|
||||
&: ${hover}, &[aria-expanded= "true"] {
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +224,7 @@ const DocumentLink = styled(Link)<{
|
||||
&:active,
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
background: ${s("listItemHoverBackground")};
|
||||
|
||||
${Actions} {
|
||||
opacity: 1;
|
||||
@@ -232,7 +233,7 @@ const DocumentLink = styled(Link)<{
|
||||
${AnimatedStar} {
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
&:${hover} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -241,7 +242,7 @@ const DocumentLink = styled(Link)<{
|
||||
${(props) =>
|
||||
props.$menuOpen &&
|
||||
css`
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
background: ${s("listItemHoverBackground")};
|
||||
|
||||
${Actions} {
|
||||
opacity: 1;
|
||||
@@ -257,12 +258,10 @@ const Heading = styled.h3<{ rtl?: boolean }>`
|
||||
display: flex;
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.25em;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
color: ${(props) => props.theme.text};
|
||||
color: ${s("text")};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
`;
|
||||
@@ -280,7 +279,7 @@ const Title = styled(Highlight)`
|
||||
|
||||
const ResultContext = styled(Highlight)`
|
||||
display: block;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
color: ${s("textTertiary")};
|
||||
font-size: 14px;
|
||||
margin-top: -0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
|
||||
@@ -4,7 +4,9 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import DocumentTasks from "~/components/DocumentTasks";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -13,11 +15,13 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
showLastViewed?: boolean;
|
||||
showParentDocuments?: boolean;
|
||||
document: Document;
|
||||
revision?: Revision;
|
||||
replace?: boolean;
|
||||
to?: LocationDescriptor;
|
||||
};
|
||||
@@ -28,11 +32,12 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
showLastViewed,
|
||||
showParentDocuments,
|
||||
document,
|
||||
revision,
|
||||
children,
|
||||
replace,
|
||||
to,
|
||||
...rest
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useStores();
|
||||
const user = useCurrentUser();
|
||||
@@ -56,12 +61,23 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const lastUpdatedByCurrentUser = user.id === updatedBy.id;
|
||||
const userName = updatedBy.name;
|
||||
let content;
|
||||
|
||||
if (deletedAt) {
|
||||
if (revision) {
|
||||
content = (
|
||||
<span>
|
||||
{revision.createdBy?.id === user.id
|
||||
? t("You updated")
|
||||
: t("{{ userName }} updated", { userName })}{" "}
|
||||
<Time dateTime={revision.createdAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (deletedAt) {
|
||||
content = (
|
||||
<span>
|
||||
{lastUpdatedByCurrentUser
|
||||
@@ -184,7 +200,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
|
||||
const Container = styled(Flex)<{ rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
color: ${s("textTertiary")};
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -192,8 +208,7 @@ const Container = styled(Flex)<{ rtl?: boolean }>`
|
||||
`;
|
||||
|
||||
const Viewed = styled.span`
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
${ellipsis()}
|
||||
`;
|
||||
|
||||
const Modified = styled.span<{ highlight?: boolean }>`
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useHistory } from "react-router-dom";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { documentUrl } from "~/utils/routeHelpers";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
@@ -23,7 +23,7 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
|
||||
const handleSubmit = React.useCallback(async () => {
|
||||
const template = await document?.templatize();
|
||||
if (template) {
|
||||
history.push(documentUrl(template));
|
||||
history.push(documentPath(template));
|
||||
showToast(t("Template created, go ahead and customize it"), {
|
||||
type: "info",
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { sortBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { dateToRelative } from "@shared/utils/date";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
@@ -33,9 +33,10 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
documentViews,
|
||||
(view) => !presentIds.includes(view.user.id)
|
||||
);
|
||||
const users = React.useMemo(() => sortedViews.map((v) => v.user), [
|
||||
sortedViews,
|
||||
]);
|
||||
const users = React.useMemo(
|
||||
() => sortedViews.map((v) => v.user),
|
||||
[sortedViews]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -52,7 +53,7 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
? t("Currently editing")
|
||||
: t("Currently viewing")
|
||||
: t("Viewed {{ timeAgo }} ago", {
|
||||
timeAgo: formatDistanceToNow(
|
||||
timeAgo: dateToRelative(
|
||||
view ? Date.parse(view.lastViewedAt) : new Date()
|
||||
),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import Collection from "~/models/Collection";
|
||||
|
||||
type Props = {
|
||||
enabled: boolean;
|
||||
collection: Collection;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function DocumentsLoader({ collection, enabled, children }: Props) {
|
||||
React.useEffect(() => {
|
||||
if (enabled) {
|
||||
void collection.fetchDocuments();
|
||||
}
|
||||
}, [collection, enabled]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default observer(DocumentsLoader);
|
||||
+20
-18
@@ -1,4 +1,3 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { deburr, difference, sortBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
@@ -10,6 +9,7 @@ import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { Heading } from "@shared/utils/ProsemirrorHelper";
|
||||
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
@@ -23,14 +23,16 @@ 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";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { isHash } from "~/utils/urls";
|
||||
import DocumentBreadcrumb from "./DocumentBreadcrumb";
|
||||
|
||||
const LazyLoadedEditor = React.lazy(() => import("~/editor"));
|
||||
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
|
||||
|
||||
export type Props = Optional<
|
||||
EditorProps,
|
||||
@@ -47,7 +49,7 @@ export type Props = Optional<
|
||||
onHeadingsChange?: (headings: Heading[]) => void;
|
||||
onSynced?: () => Promise<void>;
|
||||
onPublish?: (event: React.MouseEvent) => any;
|
||||
bottomPadding?: string;
|
||||
editorStyle?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
@@ -59,6 +61,8 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
onCreateCommentMark,
|
||||
onDeleteCommentMark,
|
||||
} = props;
|
||||
const userLocale = useUserLocale();
|
||||
const locale = dateLocale(userLocale);
|
||||
const { auth, comments, documents } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const dictionary = useDictionary();
|
||||
@@ -67,10 +71,8 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const localRef = React.useRef<SharedEditor>();
|
||||
const preferences = auth.user?.preferences;
|
||||
const previousHeadings = React.useRef<Heading[] | null>(null);
|
||||
const [
|
||||
activeLinkElement,
|
||||
setActiveLink,
|
||||
] = React.useState<HTMLAnchorElement | null>(null);
|
||||
const [activeLinkElement, setActiveLink] =
|
||||
React.useState<HTMLAnchorElement | null>(null);
|
||||
const previousCommentIds = React.useRef<string[]>();
|
||||
|
||||
const handleLinkActive = React.useCallback((element: HTMLAnchorElement) => {
|
||||
@@ -93,8 +95,10 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
|
||||
try {
|
||||
const document = await documents.fetch(slug);
|
||||
const time = formatDistanceToNow(Date.parse(document.updatedAt), {
|
||||
const time = dateToRelative(Date.parse(document.updatedAt), {
|
||||
addSuffix: true,
|
||||
shorten: true,
|
||||
locale,
|
||||
});
|
||||
|
||||
return [
|
||||
@@ -116,13 +120,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const results = await documents.searchTitles(term);
|
||||
|
||||
return sortBy(
|
||||
results.map((document: Document) => {
|
||||
return {
|
||||
title: document.title,
|
||||
subtitle: <DocumentBreadcrumb document={document} onlyText />,
|
||||
url: document.url,
|
||||
};
|
||||
}),
|
||||
results.map((document: Document) => ({
|
||||
title: document.title,
|
||||
subtitle: <DocumentBreadcrumb document={document} onlyText />,
|
||||
url: document.url,
|
||||
})),
|
||||
(document) =>
|
||||
deburr(document.title)
|
||||
.toLowerCase()
|
||||
@@ -325,7 +327,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
);
|
||||
|
||||
return (
|
||||
<ErrorBoundary reloadOnChunkMissing>
|
||||
<ErrorBoundary component="div" reloadOnChunkMissing>
|
||||
<>
|
||||
<LazyLoadedEditor
|
||||
ref={mergeRefs([ref, localRef, handleRefChanged])}
|
||||
@@ -342,12 +344,12 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
{props.bottomPadding && !props.readOnly && (
|
||||
{props.editorStyle?.paddingBottom && !props.readOnly && (
|
||||
<ClickablePadding
|
||||
onClick={focusAtEnd}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
minHeight={props.bottomPadding}
|
||||
minHeight={props.editorStyle.paddingBottom}
|
||||
/>
|
||||
)}
|
||||
{activeLinkElement && !shareId && (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
const Empty = styled.p`
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
color: ${s("textTertiary")};
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, Trans, WithTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { githubIssuesUrl } from "@shared/utils/urlHelpers";
|
||||
import { s } from "@shared/styles";
|
||||
import { githubIssuesUrl, feedbackUrl } from "@shared/utils/urlHelpers";
|
||||
import Button from "~/components/Button";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import PageTitle from "~/components/PageTitle";
|
||||
@@ -13,7 +14,12 @@ import Logger from "~/utils/Logger";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
/** Whether to reload the page if a chunk fails to load. */
|
||||
reloadOnChunkMissing?: boolean;
|
||||
/** Whether to show a title heading. */
|
||||
showTitle?: boolean;
|
||||
/** The wrapping component to use. */
|
||||
component?: React.ComponentType | string;
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -51,26 +57,31 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
};
|
||||
|
||||
handleReportBug = () => {
|
||||
window.open(githubIssuesUrl());
|
||||
window.open(isCloudHosted ? feedbackUrl() : githubIssuesUrl());
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
const { t, component: Component = CenteredContent, showTitle } = this.props;
|
||||
|
||||
if (this.error) {
|
||||
const error = this.error;
|
||||
const isReported = !!env.SENTRY_DSN && isCloudHosted;
|
||||
const isChunkError = this.error.message.match(
|
||||
/dynamically imported module/
|
||||
);
|
||||
const isChunkError = [
|
||||
"module script failed",
|
||||
"dynamically imported module",
|
||||
].some((msg) => this.error?.message?.includes(msg));
|
||||
|
||||
if (isChunkError) {
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title={t("Module failed to load")} />
|
||||
<h1>
|
||||
<Trans>Loading Failed</Trans>
|
||||
</h1>
|
||||
<Component>
|
||||
{showTitle && (
|
||||
<>
|
||||
<PageTitle title={t("Module failed to load")} />
|
||||
<h1>
|
||||
<Trans>Loading Failed</Trans>
|
||||
</h1>
|
||||
</>
|
||||
)}
|
||||
<Text type="secondary">
|
||||
<Trans>
|
||||
Sorry, part of the application failed to load. This may be
|
||||
@@ -81,16 +92,20 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
<p>
|
||||
<Button onClick={this.handleReload}>{t("Reload")}</Button>
|
||||
</p>
|
||||
</CenteredContent>
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title={t("Something Unexpected Happened")} />
|
||||
<h1>
|
||||
<Trans>Something Unexpected Happened</Trans>
|
||||
</h1>
|
||||
<Component>
|
||||
{showTitle && (
|
||||
<>
|
||||
<PageTitle title={t("Something Unexpected Happened")} />
|
||||
<h1>
|
||||
<Trans>Something Unexpected Happened</Trans>
|
||||
</h1>
|
||||
</>
|
||||
)}
|
||||
<Text type="secondary">
|
||||
<Trans
|
||||
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
|
||||
@@ -114,7 +129,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
</Button>
|
||||
)}
|
||||
</p>
|
||||
</CenteredContent>
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,7 +138,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
}
|
||||
|
||||
const Pre = styled.pre`
|
||||
background: ${(props) => props.theme.secondaryBackground};
|
||||
background: ${s("secondaryBackground")};
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const EventBoundary: React.FC<Props> = ({ children, className }) => {
|
||||
const EventBoundary: React.FC<Props> = ({ children, className }: Props) => {
|
||||
const handleClick = React.useCallback((event: React.SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
PublishIcon,
|
||||
MoveIcon,
|
||||
UnpublishIcon,
|
||||
LightningIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { CompositeStateReturn } from "reakit/Composite";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Event from "~/models/Event";
|
||||
import Avatar from "~/components/Avatar";
|
||||
@@ -24,7 +24,9 @@ import Item, { Actions } from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import RevisionMenu from "~/menus/RevisionMenu";
|
||||
import { documentHistoryUrl } from "~/utils/routeHelpers";
|
||||
import { hover } from "~/styles";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { documentHistoryPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -49,33 +51,30 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
ref.current?.focus();
|
||||
};
|
||||
|
||||
const prefetchRevision = () => {
|
||||
const prefetchRevision = async () => {
|
||||
if (event.name === "revisions.create" && event.modelId) {
|
||||
revisions.fetch(event.modelId);
|
||||
await revisions.fetch(event.modelId);
|
||||
}
|
||||
};
|
||||
|
||||
switch (event.name) {
|
||||
case "revisions.create":
|
||||
icon = <EditIcon color="currentColor" size={16} />;
|
||||
meta = t("{{userName}} edited", opts);
|
||||
icon = <EditIcon size={16} />;
|
||||
meta = latest ? (
|
||||
<>
|
||||
{t("Current version")} · {event.actor.name}
|
||||
</>
|
||||
) : (
|
||||
t("{{userName}} edited", opts)
|
||||
);
|
||||
to = {
|
||||
pathname: documentHistoryUrl(document, event.modelId || ""),
|
||||
state: { retainScrollPosition: true },
|
||||
};
|
||||
break;
|
||||
|
||||
case "documents.live_editing":
|
||||
icon = <LightningIcon color="currentColor" size={16} />;
|
||||
meta = t("Latest");
|
||||
to = {
|
||||
pathname: documentHistoryUrl(document),
|
||||
pathname: documentHistoryPath(document, event.modelId || "latest"),
|
||||
state: { retainScrollPosition: true },
|
||||
};
|
||||
break;
|
||||
|
||||
case "documents.archive":
|
||||
icon = <ArchiveIcon color="currentColor" size={16} />;
|
||||
icon = <ArchiveIcon size={16} />;
|
||||
meta = t("{{userName}} archived", opts);
|
||||
break;
|
||||
|
||||
@@ -84,7 +83,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
break;
|
||||
|
||||
case "documents.delete":
|
||||
icon = <TrashIcon color="currentColor" size={16} />;
|
||||
icon = <TrashIcon size={16} />;
|
||||
meta = t("{{userName}} deleted", opts);
|
||||
break;
|
||||
|
||||
@@ -93,22 +92,22 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
break;
|
||||
|
||||
case "documents.publish":
|
||||
icon = <PublishIcon color="currentColor" size={16} />;
|
||||
icon = <PublishIcon size={16} />;
|
||||
meta = t("{{userName}} published", opts);
|
||||
break;
|
||||
|
||||
case "documents.unpublish":
|
||||
icon = <UnpublishIcon color="currentColor" size={16} />;
|
||||
icon = <UnpublishIcon size={16} />;
|
||||
meta = t("{{userName}} unpublished", opts);
|
||||
break;
|
||||
|
||||
case "documents.move":
|
||||
icon = <MoveIcon color="currentColor" size={16} />;
|
||||
icon = <MoveIcon size={16} />;
|
||||
meta = t("{{userName}} moved", opts);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn("Unhandled event: ", event.name);
|
||||
Logger.warn("Unhandled event", { event });
|
||||
}
|
||||
|
||||
if (!meta) {
|
||||
@@ -150,7 +149,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
</Subtitle>
|
||||
}
|
||||
actions={
|
||||
isRevision && isActive && event.modelId ? (
|
||||
isRevision && isActive && event.modelId && !latest ? (
|
||||
<RevisionMenu document={document} revisionId={event.modelId} />
|
||||
) : undefined
|
||||
}
|
||||
@@ -161,15 +160,16 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const BaseItem = React.forwardRef(
|
||||
({ to, ...rest }: ItemProps, ref?: React.Ref<HTMLAnchorElement>) => {
|
||||
if (to) {
|
||||
return <CompositeListItem to={to} ref={ref} {...rest} />;
|
||||
}
|
||||
|
||||
return <ListItem ref={ref} {...rest} />;
|
||||
const BaseItem = React.forwardRef(function _BaseItem(
|
||||
{ to, ...rest }: ItemProps,
|
||||
ref?: React.Ref<HTMLAnchorElement>
|
||||
) {
|
||||
if (to) {
|
||||
return <CompositeListItem to={to} ref={ref} {...rest} />;
|
||||
}
|
||||
);
|
||||
|
||||
return <ListItem ref={ref} {...rest} />;
|
||||
});
|
||||
|
||||
const Subtitle = styled.span`
|
||||
svg {
|
||||
@@ -197,7 +197,7 @@ const ItemStyle = css`
|
||||
left: 23px;
|
||||
width: 2px;
|
||||
height: calc(100% + 8px);
|
||||
background: ${(props) => props.theme.textSecondary};
|
||||
background: ${s("textSecondary")};
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ const ItemStyle = css`
|
||||
${Actions} {
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
&: ${hover} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { FileOperationFormat } from "@shared/types";
|
||||
import { FileOperationFormat, NotificationEventType } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
@@ -20,15 +21,14 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
const [format, setFormat] = React.useState<FileOperationFormat>(
|
||||
FileOperationFormat.MarkdownZip
|
||||
);
|
||||
const [includeAttachments, setIncludeAttachments] =
|
||||
React.useState<boolean>(true);
|
||||
const user = useCurrentUser();
|
||||
const { showToast } = useToasts();
|
||||
const { collections, notificationSettings } = useStores();
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
React.useEffect(() => {
|
||||
notificationSettings.fetchPage({});
|
||||
}, [notificationSettings]);
|
||||
|
||||
const handleFormatChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormat(ev.target.value as FileOperationFormat);
|
||||
@@ -36,11 +36,18 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleIncludeAttachmentsChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIncludeAttachments(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (collection) {
|
||||
await collection.export(format);
|
||||
await collection.export(format, includeAttachments);
|
||||
} else {
|
||||
await collections.export(format);
|
||||
await collections.export(format, includeAttachments);
|
||||
}
|
||||
onSubmit();
|
||||
showToast(t("Export started"), { type: "success" });
|
||||
@@ -86,13 +93,13 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
em: <strong />,
|
||||
}}
|
||||
/>{" "}
|
||||
{notificationSettings.getByEvent("emails.export_completed") &&
|
||||
{user.subscribedToEventType(NotificationEventType.ExportCompleted) &&
|
||||
t("You will receive an email when it's complete.")}
|
||||
</Text>
|
||||
)}
|
||||
<Flex gap={12} column>
|
||||
{items.map((item) => (
|
||||
<Option>
|
||||
<Option key={item.value}>
|
||||
<input
|
||||
type="radio"
|
||||
name="format"
|
||||
@@ -109,6 +116,23 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
</Option>
|
||||
))}
|
||||
</Flex>
|
||||
<hr />
|
||||
<Option>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="includeAttachments"
|
||||
checked={includeAttachments}
|
||||
onChange={handleIncludeAttachmentsChange}
|
||||
/>
|
||||
<div>
|
||||
<Text size="small" weight="bold">
|
||||
{t("Include attachments")}
|
||||
</Text>
|
||||
<Text size="small">
|
||||
{t("Including uploaded images and files in the exported data")}.
|
||||
</Text>{" "}
|
||||
</div>
|
||||
</Option>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -59,8 +60,8 @@ const More = styled.div<{ size: number }>`
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 100%;
|
||||
background: ${(props) => props.theme.slate};
|
||||
color: ${(props) => props.theme.text};
|
||||
border: 2px solid ${(props) => props.theme.background};
|
||||
color: ${s("text")};
|
||||
border: 2px solid ${s("background")};
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Button, { Inner } from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
@@ -80,7 +81,7 @@ const Note = styled(Text)`
|
||||
line-height: 1.2em;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
color: ${s("textTertiary")};
|
||||
`;
|
||||
|
||||
const LabelWithNote = styled.div`
|
||||
|
||||
@@ -10,6 +10,7 @@ const Flex = styled.div<{
|
||||
column?: boolean;
|
||||
align?: AlignValues;
|
||||
justify?: JustifyValues;
|
||||
wrap?: boolean;
|
||||
shrink?: boolean;
|
||||
reverse?: boolean;
|
||||
gap?: number;
|
||||
@@ -26,7 +27,9 @@ const Flex = styled.div<{
|
||||
: "row"};
|
||||
align-items: ${({ align }) => align};
|
||||
justify-content: ${({ justify }) => justify};
|
||||
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
|
||||
flex-wrap: ${({ wrap }) => (wrap ? "wrap" : "initial")};
|
||||
flex-shrink: ${({ shrink }) =>
|
||||
shrink === true ? 1 : shrink === false ? 0 : "initial"};
|
||||
gap: ${({ gap }) => (gap ? `${gap}px` : "initial")};
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
|
||||
import { s } from "@shared/styles";
|
||||
import CollectionGroupMembership from "~/models/CollectionGroupMembership";
|
||||
import Group from "~/models/Group";
|
||||
import GroupMembers from "~/scenes/GroupMembers";
|
||||
@@ -13,6 +14,7 @@ import ListItem from "~/components/List/Item";
|
||||
import Modal from "~/components/Modal";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
import NudeButton from "./NudeButton";
|
||||
|
||||
type Props = {
|
||||
@@ -26,11 +28,8 @@ type Props = {
|
||||
function GroupListItem({ group, showFacepile, renderActions }: Props) {
|
||||
const { groupMemberships } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [
|
||||
membersModalOpen,
|
||||
setMembersModalOpen,
|
||||
setMembersModalClosed,
|
||||
] = useBoolean();
|
||||
const [membersModalOpen, setMembersModalOpen, setMembersModalClosed] =
|
||||
useBoolean();
|
||||
const memberCount = group.memberCount;
|
||||
const membershipsInGroup = groupMemberships.inGroup(group.id);
|
||||
const users = membershipsInGroup
|
||||
@@ -81,12 +80,12 @@ const Image = styled(Flex)`
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: ${(props) => props.theme.secondaryBackground};
|
||||
background: ${s("secondaryBackground")};
|
||||
border-radius: 32px;
|
||||
`;
|
||||
|
||||
const Title = styled.span`
|
||||
&:hover {
|
||||
&: ${hover} {
|
||||
text-decoration: underline;
|
||||
cursor: var(--pointer);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import * as React from "react";
|
||||
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
|
||||
import styled from "styled-components";
|
||||
import { depths } from "@shared/styles";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
onRequestClose: () => void;
|
||||
@@ -17,7 +18,7 @@ const Guide: React.FC<Props> = ({
|
||||
title = "Untitled",
|
||||
onRequestClose,
|
||||
...rest
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const dialog = useDialogState({
|
||||
animated: 250,
|
||||
});
|
||||
@@ -71,7 +72,7 @@ const Backdrop = styled.div`
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: ${(props) => props.theme.backdrop} !important;
|
||||
background-color: ${s("backdrop")} !important;
|
||||
z-index: ${depths.modalOverlay};
|
||||
transition: opacity 200ms ease-in-out;
|
||||
opacity: 0;
|
||||
@@ -92,8 +93,8 @@ const Scene = styled.div`
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
width: 350px;
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
opacity: 0;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths } from "@shared/styles";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Button from "~/components/Button";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -63,7 +63,6 @@ function Header({ left, title, actions, hasSidebar }: Props) {
|
||||
<MobileMenuButton
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
icon={<MenuIcon />}
|
||||
iconColor="currentColor"
|
||||
neutral
|
||||
/>
|
||||
)}
|
||||
@@ -113,7 +112,7 @@ const Wrapper = styled(Flex)<WrapperProps>`
|
||||
top: 0;
|
||||
z-index: ${depths.header};
|
||||
position: sticky;
|
||||
background: ${(props) => props.theme.background};
|
||||
background: ${s("background")};
|
||||
|
||||
${(props) =>
|
||||
props.$passThrough
|
||||
@@ -132,7 +131,11 @@ const Wrapper = styled(Flex)<WrapperProps>`
|
||||
min-height: 64px;
|
||||
justify-content: flex-start;
|
||||
${draggableOnDesktop()}
|
||||
${fadeOnDesktopBackgrounded()}
|
||||
|
||||
button,
|
||||
[role="button"] {
|
||||
${fadeOnDesktopBackgrounded()}
|
||||
}
|
||||
|
||||
@supports (backdrop-filter: blur(20px)) {
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { escapeRegExp } from "lodash";
|
||||
import * as React from "react";
|
||||
import replace from "string-replace-to-array";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
type Props = React.HTMLAttributes<HTMLSpanElement> & {
|
||||
highlight: (string | null | undefined) | RegExp;
|
||||
@@ -43,7 +44,7 @@ function Highlight({
|
||||
}
|
||||
|
||||
export const Mark = styled.mark`
|
||||
background: ${(props) => props.theme.searchHighlight};
|
||||
background: ${s("searchHighlight")};
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
`;
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import { depths } from "@shared/styles";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isExternalUrl } from "@shared/utils/urls";
|
||||
import HoverPreviewDocument from "~/components/HoverPreviewDocument";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { fadeAndSlideDown } from "~/styles/animations";
|
||||
|
||||
const DELAY_OPEN = 300;
|
||||
const DELAY_CLOSE = 300;
|
||||
|
||||
type Props = {
|
||||
element: HTMLAnchorElement;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
const { documents } = useStores();
|
||||
const slug = parseDocumentSlug(element.href);
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const timerOpen = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const cardRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const startCloseTimer = () => {
|
||||
stopOpenTimer();
|
||||
timerClose.current = setTimeout(() => {
|
||||
if (isVisible) {
|
||||
setVisible(false);
|
||||
}
|
||||
onClose();
|
||||
}, DELAY_CLOSE);
|
||||
};
|
||||
|
||||
const stopCloseTimer = () => {
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
}
|
||||
};
|
||||
|
||||
const startOpenTimer = () => {
|
||||
timerOpen.current = setTimeout(() => setVisible(true), DELAY_OPEN);
|
||||
};
|
||||
|
||||
const stopOpenTimer = () => {
|
||||
if (timerOpen.current) {
|
||||
clearTimeout(timerOpen.current);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (slug) {
|
||||
documents.prefetchDocument(slug);
|
||||
}
|
||||
|
||||
startOpenTimer();
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.addEventListener("mouseenter", stopCloseTimer);
|
||||
}
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.addEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
element.addEventListener("mouseout", startCloseTimer);
|
||||
element.addEventListener("mouseover", stopCloseTimer);
|
||||
element.addEventListener("mouseover", startOpenTimer);
|
||||
return () => {
|
||||
element.removeEventListener("mouseout", startCloseTimer);
|
||||
element.removeEventListener("mouseover", stopCloseTimer);
|
||||
element.removeEventListener("mouseover", startOpenTimer);
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.removeEventListener("mouseenter", stopCloseTimer);
|
||||
}
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.removeEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
}
|
||||
};
|
||||
}, [element, slug]);
|
||||
|
||||
const anchorBounds = element.getBoundingClientRect();
|
||||
const cardBounds = cardRef.current?.getBoundingClientRect();
|
||||
const left = cardBounds
|
||||
? Math.min(anchorBounds.left, window.innerWidth - 16 - 350)
|
||||
: anchorBounds.left;
|
||||
const leftOffset = anchorBounds.left - left;
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Position
|
||||
top={anchorBounds.bottom + window.scrollY}
|
||||
left={left}
|
||||
aria-hidden
|
||||
>
|
||||
<div ref={cardRef}>
|
||||
<HoverPreviewDocument url={element.href}>
|
||||
{(content: React.ReactNode) =>
|
||||
isVisible ? (
|
||||
<Animate>
|
||||
<Card>
|
||||
<Margin />
|
||||
<CardContent>{content}</CardContent>
|
||||
</Card>
|
||||
<Pointer offset={leftOffset + anchorBounds.width / 2} />
|
||||
</Animate>
|
||||
) : null
|
||||
}
|
||||
</HoverPreviewDocument>
|
||||
</div>
|
||||
</Position>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function HoverPreview({ element, ...rest }: Props) {
|
||||
const isMobile = useMobile();
|
||||
if (isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// previews only work for internal doc links for now
|
||||
if (isExternalUrl(element.href)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HoverPreviewInternal {...rest} element={element} />;
|
||||
}
|
||||
|
||||
const Animate = styled.div`
|
||||
animation: ${fadeAndSlideDown} 150ms ease;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
// fills the gap between the card and pointer to avoid a dead zone
|
||||
const Margin = styled.div`
|
||||
position: absolute;
|
||||
top: -11px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 11px;
|
||||
`;
|
||||
|
||||
const CardContent = styled.div`
|
||||
overflow: hidden;
|
||||
max-height: 20em;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
// &:after — gradient mask for overflow text
|
||||
const Card = styled.div`
|
||||
backdrop-filter: blur(10px);
|
||||
background: ${(props) => props.theme.background};
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
|
||||
0 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
padding: 16px;
|
||||
width: 350px;
|
||||
font-size: 0.9em;
|
||||
position: relative;
|
||||
|
||||
.placeholder,
|
||||
.heading-anchor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
${(props) => transparentize(1, props.theme.background)} 0%,
|
||||
${(props) => transparentize(1, props.theme.background)} 75%,
|
||||
${(props) => props.theme.background} 90%
|
||||
);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1.7em;
|
||||
border-bottom: 16px solid ${(props) => props.theme.background};
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
|
||||
margin-top: 10px;
|
||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
||||
z-index: ${depths.hoverPreview};
|
||||
display: flex;
|
||||
max-height: 75%;
|
||||
|
||||
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
|
||||
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
|
||||
`;
|
||||
|
||||
const Pointer = styled.div<{ offset: number }>`
|
||||
top: -22px;
|
||||
left: ${(props) => props.offset}px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&:before {
|
||||
border: 8px solid transparent;
|
||||
border-bottom-color: ${(props) =>
|
||||
props.theme.menuBorder || "rgba(0, 0, 0, 0.1)"};
|
||||
right: -1px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
border: 7px solid transparent;
|
||||
border-bottom-color: ${(props) => props.theme.background};
|
||||
}
|
||||
`;
|
||||
|
||||
export default HoverPreview;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { transparentize } from "polished";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
export const CARD_MARGIN = 16;
|
||||
|
||||
const NUMBER_OF_LINES = 10;
|
||||
|
||||
const sharedVars = css`
|
||||
--line-height: 1.6em;
|
||||
`;
|
||||
|
||||
const StyledText = styled(Text)`
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
export const Preview = styled(Link)`
|
||||
cursor: ${(props: any) =>
|
||||
props.as === "div" ? "default" : "var(--pointer)"};
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
|
||||
0 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
min-width: 350px;
|
||||
max-width: 375px;
|
||||
`;
|
||||
|
||||
export const Title = styled.h2`
|
||||
font-size: 1.25em;
|
||||
margin: 0;
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
export const Info = styled(StyledText).attrs(() => ({
|
||||
type: "tertiary",
|
||||
size: "xsmall",
|
||||
}))`
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const Description = styled(StyledText)`
|
||||
${sharedVars}
|
||||
margin-top: 0.5em;
|
||||
line-height: var(--line-height);
|
||||
max-height: calc(var(--line-height) * ${NUMBER_OF_LINES});
|
||||
`;
|
||||
|
||||
export const Thumbnail = styled.img`
|
||||
object-fit: cover;
|
||||
height: 200px;
|
||||
background: ${s("menuBackground")};
|
||||
`;
|
||||
|
||||
export const CardContent = styled.div`
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
// &:after — gradient mask for overflow text
|
||||
export const Card = styled.div<{ fadeOut?: boolean; $borderRadius?: string }>`
|
||||
backdrop-filter: blur(10px);
|
||||
background: ${s("menuBackground")};
|
||||
padding: 16px;
|
||||
font-size: 0.9em;
|
||||
position: relative;
|
||||
|
||||
.placeholder,
|
||||
.heading-anchor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// fills the gap between the card and pointer to avoid a dead zone
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.fadeOut !== false
|
||||
? `&:after {
|
||||
${sharedVars}
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
${transparentize(1, props.theme.menuBackground)} 0%,
|
||||
${transparentize(1, props.theme.menuBackground)} 75%,
|
||||
${props.theme.menuBackground} 90%
|
||||
);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--line-height);
|
||||
border-bottom: 16px solid ${props.theme.menuBackground};
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}`
|
||||
: ""}
|
||||
`;
|
||||
@@ -0,0 +1,261 @@
|
||||
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 { UnfurlType } from "@shared/types";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { CARD_MARGIN } from "./Components";
|
||||
import HoverPreviewDocument from "./HoverPreviewDocument";
|
||||
import HoverPreviewLink from "./HoverPreviewLink";
|
||||
import HoverPreviewMention from "./HoverPreviewMention";
|
||||
|
||||
const DELAY_OPEN = 300;
|
||||
const DELAY_CLOSE = 600;
|
||||
|
||||
type Props = {
|
||||
/* The HTML element that is being hovered over */
|
||||
element: HTMLAnchorElement;
|
||||
/* A callback on close of the hover preview */
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
const url = element.href || element.dataset.url;
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const timerOpen = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const cardRef = React.useRef<HTMLDivElement>(null);
|
||||
const stores = useStores();
|
||||
const [cardLeft, setCardLeft] = React.useState(0);
|
||||
const [cardTop, setCardTop] = React.useState(0);
|
||||
const [pointerOffset, setPointerOffset] = React.useState(0);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (isVisible && cardRef.current) {
|
||||
const elem = element.getBoundingClientRect();
|
||||
const card = cardRef.current.getBoundingClientRect();
|
||||
|
||||
const top = elem.bottom + window.scrollY;
|
||||
setCardTop(top);
|
||||
|
||||
let left = elem.left;
|
||||
let pointerOffset = elem.width / 2;
|
||||
if (left + 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
|
||||
shiftBy += CARD_MARGIN;
|
||||
left -= shiftBy;
|
||||
|
||||
// shift pointer rightwards by same amount so as to position it back correctly
|
||||
pointerOffset += shiftBy;
|
||||
}
|
||||
setCardLeft(left);
|
||||
|
||||
setPointerOffset(pointerOffset);
|
||||
}
|
||||
}, [isVisible, element]);
|
||||
|
||||
const { data, request, loading } = useRequest(
|
||||
React.useCallback(
|
||||
() =>
|
||||
client.post("/urls.unfurl", {
|
||||
url,
|
||||
documentId: stores.ui.activeDocumentId,
|
||||
}),
|
||||
[url, stores.ui.activeDocumentId]
|
||||
)
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (url) {
|
||||
stopOpenTimer();
|
||||
setVisible(false);
|
||||
|
||||
void request();
|
||||
}
|
||||
}, [url, request]);
|
||||
|
||||
const stopOpenTimer = () => {
|
||||
if (timerOpen.current) {
|
||||
clearTimeout(timerOpen.current);
|
||||
timerOpen.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const closePreview = React.useCallback(() => {
|
||||
if (isVisible) {
|
||||
stopOpenTimer();
|
||||
setVisible(false);
|
||||
onClose();
|
||||
}
|
||||
}, [isVisible, onClose]);
|
||||
|
||||
useOnClickOutside(cardRef, closePreview);
|
||||
useKeyDown("Escape", closePreview);
|
||||
useEventListener("scroll", closePreview, window, { capture: true });
|
||||
|
||||
const stopCloseTimer = () => {
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
timerClose.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const startOpenTimer = () => {
|
||||
if (!timerOpen.current) {
|
||||
timerOpen.current = setTimeout(() => setVisible(true), DELAY_OPEN);
|
||||
}
|
||||
};
|
||||
|
||||
const startCloseTimer = React.useCallback(() => {
|
||||
stopOpenTimer();
|
||||
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
|
||||
}, [closePreview]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const card = cardRef.current;
|
||||
|
||||
if (data) {
|
||||
startOpenTimer();
|
||||
|
||||
if (card) {
|
||||
card.addEventListener("mouseenter", stopCloseTimer);
|
||||
card.addEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
element.addEventListener("mouseout", startCloseTimer);
|
||||
element.addEventListener("mouseover", stopCloseTimer);
|
||||
element.addEventListener("mouseover", startOpenTimer);
|
||||
}
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("mouseout", startCloseTimer);
|
||||
element.removeEventListener("mouseover", stopCloseTimer);
|
||||
element.removeEventListener("mouseover", startOpenTimer);
|
||||
|
||||
if (card) {
|
||||
card.removeEventListener("mouseenter", stopCloseTimer);
|
||||
card.removeEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
stopCloseTimer();
|
||||
};
|
||||
}, [element, startCloseTimer, data]);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Position top={cardTop} left={cardLeft} aria-hidden>
|
||||
{isVisible ? (
|
||||
<Animate
|
||||
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
|
||||
animate={{ opacity: 1, y: 0, pointerEvents: "auto" }}
|
||||
>
|
||||
{data.type === UnfurlType.Mention ? (
|
||||
<HoverPreviewMention
|
||||
ref={cardRef}
|
||||
url={data.thumbnailUrl}
|
||||
title={data.title}
|
||||
info={data.meta.info}
|
||||
color={data.meta.color}
|
||||
/>
|
||||
) : data.type === UnfurlType.Document ? (
|
||||
<HoverPreviewDocument
|
||||
ref={cardRef}
|
||||
id={data.meta.id}
|
||||
url={data.url}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
info={data.meta.info}
|
||||
/>
|
||||
) : (
|
||||
<HoverPreviewLink
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
thumbnailUrl={data.thumbnailUrl}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
/>
|
||||
)}
|
||||
<Pointer offset={pointerOffset} />
|
||||
</Animate>
|
||||
) : null}
|
||||
</Position>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function HoverPreview({ element, ...rest }: Props) {
|
||||
const isMobile = useMobile();
|
||||
if (isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HoverPreviewInternal {...rest} element={element} />;
|
||||
}
|
||||
|
||||
const Animate = styled(m.div)`
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
|
||||
margin-top: 10px;
|
||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
||||
z-index: ${depths.hoverPreview};
|
||||
display: flex;
|
||||
max-height: 75%;
|
||||
|
||||
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
|
||||
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
|
||||
`;
|
||||
|
||||
const Pointer = styled.div<{ offset: number }>`
|
||||
top: -22px;
|
||||
left: ${(props) => props.offset}px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&:before {
|
||||
border: 8px solid transparent;
|
||||
border-bottom-color: ${(props) =>
|
||||
props.theme.menuBorder || "rgba(0, 0, 0, 0.1)"};
|
||||
right: -1px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
border: 7px solid transparent;
|
||||
border-bottom-color: ${s("menuBackground")};
|
||||
}
|
||||
`;
|
||||
|
||||
export default HoverPreview;
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from "react";
|
||||
import Editor from "~/components/Editor";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
Preview,
|
||||
Title,
|
||||
Info,
|
||||
Card,
|
||||
CardContent,
|
||||
Description,
|
||||
} from "./Components";
|
||||
|
||||
type Props = {
|
||||
/** Document id associated with the editor, if any */
|
||||
id?: string;
|
||||
/** Document url */
|
||||
url: string;
|
||||
/** Title for the preview card */
|
||||
title: string;
|
||||
/** Info about last activity on the document */
|
||||
info: string;
|
||||
/** Text preview of document content */
|
||||
description: string;
|
||||
};
|
||||
|
||||
const HoverPreviewDocument = React.forwardRef(function _HoverPreviewDocument(
|
||||
{ id, url, title, info, description }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
<Preview to={url}>
|
||||
<Card ref={ref}>
|
||||
<CardContent>
|
||||
<Flex column gap={2}>
|
||||
<Title>{title}</Title>
|
||||
<Info>{info}</Info>
|
||||
<Description as="div">
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
key={id}
|
||||
defaultValue={description}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Description>
|
||||
</Flex>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Preview>
|
||||
);
|
||||
});
|
||||
|
||||
export default HoverPreviewDocument;
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as React from "react";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
Preview,
|
||||
Title,
|
||||
Description,
|
||||
Card,
|
||||
CardContent,
|
||||
Thumbnail,
|
||||
} from "./Components";
|
||||
|
||||
type Props = {
|
||||
/** Link url */
|
||||
url: string;
|
||||
/** Title for the preview card */
|
||||
title: string;
|
||||
/** Url for thumbnail served by the link provider */
|
||||
thumbnailUrl: string;
|
||||
/** Some description about the link provider */
|
||||
description: string;
|
||||
};
|
||||
|
||||
const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
|
||||
{ url, thumbnailUrl, title, description }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
<Flex column>
|
||||
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt={""} /> : null}
|
||||
<Card ref={ref}>
|
||||
<CardContent>
|
||||
<Flex column>
|
||||
<Title>{title}</Title>
|
||||
<Description>{description}</Description>
|
||||
</Flex>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Flex>
|
||||
</Preview>
|
||||
);
|
||||
});
|
||||
|
||||
export default HoverPreviewLink;
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { AvatarSize } from "~/components/Avatar/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { Preview, Title, Info, Card, CardContent } from "./Components";
|
||||
|
||||
type Props = {
|
||||
/** Resource url, avatar url in case of user mention */
|
||||
url: string;
|
||||
/** Title for the preview card*/
|
||||
title: string;
|
||||
/** Info about mentioned user's recent activity */
|
||||
info: string;
|
||||
/** Used for avatar's background color in absence of avatar url */
|
||||
color: string;
|
||||
};
|
||||
|
||||
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
|
||||
{ url, title, info, color }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
<Preview as="div">
|
||||
<Card fadeOut={false} ref={ref}>
|
||||
<CardContent>
|
||||
<Flex gap={12}>
|
||||
<Avatar
|
||||
model={{
|
||||
avatarUrl: url,
|
||||
initial: title ? title[0] : "?",
|
||||
color,
|
||||
}}
|
||||
size={AvatarSize.XLarge}
|
||||
/>
|
||||
<Flex column gap={2} justify="center">
|
||||
<Title>{title}</Title>
|
||||
<Info>{info}</Info>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Preview>
|
||||
);
|
||||
});
|
||||
|
||||
export default HoverPreviewMention;
|
||||
@@ -0,0 +1,3 @@
|
||||
import HoverPreview from "./HoverPreview";
|
||||
|
||||
export default HoverPreview;
|
||||
@@ -1,58 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import Editor from "~/components/Editor";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
url: string;
|
||||
children: (content: React.ReactNode) => React.ReactNode;
|
||||
};
|
||||
|
||||
function HoverPreviewDocument({ url, children }: Props) {
|
||||
const { documents } = useStores();
|
||||
const slug = parseDocumentSlug(url);
|
||||
|
||||
if (slug) {
|
||||
documents.prefetchDocument(slug);
|
||||
}
|
||||
|
||||
const document = slug ? documents.getByUrl(slug) : undefined;
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{children(
|
||||
<Content to={document.url}>
|
||||
<Heading>{document.titleWithDefault}</Heading>
|
||||
<DocumentMeta document={document} />
|
||||
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
key={document.id}
|
||||
defaultValue={document.getSummary()}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Content>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Content = styled(Link)`
|
||||
cursor: var(--pointer);
|
||||
`;
|
||||
|
||||
const Heading = styled.h2`
|
||||
margin: 0 0 0.75em;
|
||||
color: ${(props) => props.theme.text};
|
||||
`;
|
||||
|
||||
export default observer(HoverPreviewDocument);
|
||||
@@ -40,12 +40,14 @@ import { useTranslation } from "react-i18next";
|
||||
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Flex from "~/components/Flex";
|
||||
import { LabelText } from "~/components/Input";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Text from "~/components/Text";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import DelayedMount from "./DelayedMount";
|
||||
|
||||
const style = {
|
||||
@@ -53,7 +55,7 @@ const style = {
|
||||
height: 30,
|
||||
};
|
||||
|
||||
const TwitterPicker = React.lazy(
|
||||
const TwitterPicker = lazyWithRetry(
|
||||
() => import("react-color/lib/components/twitter/Twitter")
|
||||
);
|
||||
|
||||
@@ -239,29 +241,27 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
aria-label={t("Choose icon")}
|
||||
>
|
||||
<Icons>
|
||||
{Object.keys(icons).map((name, index) => {
|
||||
return (
|
||||
<MenuItem
|
||||
key={name}
|
||||
onClick={() => onChange(color, name)}
|
||||
{...menu}
|
||||
>
|
||||
{(props) => (
|
||||
<IconButton
|
||||
style={
|
||||
{
|
||||
...style,
|
||||
"--delay": `${index * 8}ms`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<Icon as={icons[name].component} color={color} size={30} />
|
||||
</IconButton>
|
||||
)}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
{Object.keys(icons).map((name, index) => (
|
||||
<MenuItem
|
||||
key={name}
|
||||
onClick={() => onChange(color, name)}
|
||||
{...menu}
|
||||
>
|
||||
{(props) => (
|
||||
<IconButton
|
||||
style={
|
||||
{
|
||||
...style,
|
||||
"--delay": `${index * 8}ms`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<Icon as={icons[name].component} color={color} size={30} />
|
||||
</IconButton>
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Icons>
|
||||
<Colors>
|
||||
<React.Suspense
|
||||
@@ -323,7 +323,7 @@ const Icons = styled.div`
|
||||
`;
|
||||
|
||||
const Button = styled(NudeButton)`
|
||||
border: 1px solid ${(props) => props.theme.inputBorder};
|
||||
border: 1px solid ${s("inputBorder")};
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
`;
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function MarkdownIcon({
|
||||
<path
|
||||
d="M19.2692 7H3.86538C3.38745 7 3 7.38476 3 7.85938V16.2812C3 16.7559 3.38745 17.1406 3.86538 17.1406H19.2692C19.7472 17.1406 20.1346 16.7559 20.1346 16.2812V7.85938C20.1346 7.38476 19.7472 7 19.2692 7Z"
|
||||
stroke={color}
|
||||
stroke-width="2"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M5.16345 14.9922V9.14844H6.89422L8.62499 11.2969L10.3558 9.14844H12.0865V14.9922H10.3558V11.6406L8.62499 13.7891L6.89422 11.6406V14.9922H5.16345ZM15.9808 14.9922L13.3846 12.1562H15.1154V9.14844H16.8461V12.1562H18.5769L15.9808 14.9922Z"
|
||||
|
||||
+13
-10
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
@@ -12,11 +13,11 @@ const RealTextarea = styled.textarea<{ hasIcon?: boolean }>`
|
||||
padding: 8px 12px 8px ${(props) => (props.hasIcon ? "8px" : "12px")};
|
||||
outline: none;
|
||||
background: none;
|
||||
color: ${(props) => props.theme.text};
|
||||
color: ${s("text")};
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
color: ${s("placeholder")};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -26,24 +27,23 @@ const RealInput = styled.input<{ hasIcon?: boolean }>`
|
||||
padding: 8px 12px 8px ${(props) => (props.hasIcon ? "8px" : "12px")};
|
||||
outline: none;
|
||||
background: none;
|
||||
color: ${(props) => props.theme.text};
|
||||
color: ${s("text")};
|
||||
height: 30px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 15px;
|
||||
|
||||
${ellipsis()}
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
color: ${s("placeholder")};
|
||||
}
|
||||
|
||||
&:-webkit-autofill,
|
||||
&:-webkit-autofill:hover,
|
||||
&:-webkit-autofill:focus {
|
||||
-webkit-box-shadow: 0 0 0px 1000px ${(props) => props.theme.background}
|
||||
inset;
|
||||
-webkit-box-shadow: 0 0 0px 1000px ${s("background")} inset;
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
@@ -98,7 +98,7 @@ export const Outline = styled(Flex)<{
|
||||
font-weight: normal;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
background: ${(props) => props.theme.background};
|
||||
background: ${s("background")};
|
||||
|
||||
/* Prevents an issue where input placeholder appears in a selected style when double clicking title bar */
|
||||
user-select: none;
|
||||
@@ -177,6 +177,8 @@ function Input(
|
||||
labelHidden,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onRequestSubmit,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
@@ -213,6 +215,7 @@ function Input(
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</Outline>
|
||||
</label>
|
||||
{error && (
|
||||
|
||||
@@ -2,6 +2,8 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import ContextMenu from "./ContextMenu";
|
||||
import DelayedMount from "./DelayedMount";
|
||||
import Input, { Props as InputProps } from "./Input";
|
||||
@@ -14,7 +16,7 @@ type Props = Omit<InputProps, "onChange"> & {
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const InputColor: React.FC<Props> = ({ value, onChange, ...rest }) => {
|
||||
const InputColor: React.FC<Props> = ({ value, onChange, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
@@ -25,7 +27,7 @@ const InputColor: React.FC<Props> = ({ value, onChange, ...rest }) => {
|
||||
<Relative>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onChange={(event) => onChange(event.target.value.replace(/^#?/, "#"))}
|
||||
placeholder="#"
|
||||
maxLength={7}
|
||||
{...rest}
|
||||
@@ -60,14 +62,14 @@ const InputColor: React.FC<Props> = ({ value, onChange, ...rest }) => {
|
||||
|
||||
const SwatchButton = styled(NudeButton)<{ $background: string | undefined }>`
|
||||
background: ${(props) => props.$background};
|
||||
border: 1px solid ${(props) => props.theme.inputBorder};
|
||||
border: 1px solid ${s("inputBorder")};
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 6px;
|
||||
`;
|
||||
|
||||
const ColorPicker = React.lazy(
|
||||
const ColorPicker = lazyWithRetry(
|
||||
() => import("react-color/lib/components/chrome/Chrome")
|
||||
);
|
||||
|
||||
@@ -80,7 +82,7 @@ const StyledColorPicker = styled(ColorPicker)`
|
||||
|
||||
input {
|
||||
user-select: text;
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
color: ${s("text")} !important;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ function InputSearchPage({
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (ev.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
history.push(
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Button, { Inner } from "~/components/Button";
|
||||
import Text from "~/components/Text";
|
||||
import useMenuHeight from "~/hooks/useMenuHeight";
|
||||
@@ -45,9 +46,8 @@ export type Props = {
|
||||
onChange?: (value: string | null) => void;
|
||||
};
|
||||
|
||||
const getOptionFromValue = (options: Option[], value: string | null) => {
|
||||
return options.find((option) => option.value === value);
|
||||
};
|
||||
const getOptionFromValue = (options: Option[], value: string | null) =>
|
||||
options.find((option) => option.value === value);
|
||||
|
||||
const InputSelect = (props: Props) => {
|
||||
const {
|
||||
@@ -85,11 +85,11 @@ const InputSelect = (props: Props) => {
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const minWidth = buttonRef.current?.offsetWidth || 0;
|
||||
const margin = 8;
|
||||
const menuMaxHeight = useMenuHeight(
|
||||
select.visible,
|
||||
select.unstable_disclosureRef,
|
||||
margin
|
||||
);
|
||||
const menuMaxHeight = useMenuHeight({
|
||||
visible: select.visible,
|
||||
elementRef: select.unstable_disclosureRef,
|
||||
margin,
|
||||
});
|
||||
const maxHeight = Math.min(
|
||||
menuMaxHeight ?? 0,
|
||||
window.innerHeight -
|
||||
@@ -108,11 +108,7 @@ const InputSelect = (props: Props) => {
|
||||
}
|
||||
previousValue.current = select.selectedValue;
|
||||
|
||||
async function load() {
|
||||
await onChange?.(select.selectedValue);
|
||||
}
|
||||
|
||||
load();
|
||||
onChange?.(select.selectedValue);
|
||||
}, [onChange, select.selectedValue]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
@@ -218,7 +214,7 @@ const Background = styled(ContextMenuBackground)`
|
||||
`;
|
||||
|
||||
const Placeholder = styled.span`
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
color: ${s("placeholder")};
|
||||
`;
|
||||
|
||||
const Spacer = styled.div`
|
||||
@@ -236,7 +232,7 @@ const StyledButton = styled(Button)<{ nude?: boolean }>`
|
||||
cursor: default;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${(props) => props.theme.buttonNeutralBackground};
|
||||
background: ${s("buttonNeutralBackground")};
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
@@ -276,7 +272,7 @@ const Positioner = styled(Position)`
|
||||
${StyledSelectOption} {
|
||||
&[aria-selected="true"] {
|
||||
color: ${(props) => props.theme.white};
|
||||
background: ${(props) => props.theme.accent};
|
||||
background: ${s("accent")};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
label: React.ReactNode | string;
|
||||
};
|
||||
|
||||
const Labeled: React.FC<Props> = ({ label, children, ...props }) => (
|
||||
const Labeled: React.FC<Props> = ({ label, children, ...props }: Props) => (
|
||||
<Flex column {...props}>
|
||||
<Label>{label}</Label>
|
||||
{children}
|
||||
@@ -18,7 +20,7 @@ export const Label = styled(Flex)`
|
||||
font-weight: 500;
|
||||
padding-bottom: 4px;
|
||||
display: inline-block;
|
||||
color: ${(props) => props.theme.text};
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
export default observer(Labeled);
|
||||
|
||||
@@ -5,7 +5,6 @@ import styled from "styled-components";
|
||||
import { languages, languageOptions } from "@shared/i18n";
|
||||
import ButtonLink from "~/components/ButtonLink";
|
||||
import Flex from "~/components/Flex";
|
||||
import NoticeTip from "~/components/NoticeTip";
|
||||
import env from "~/env";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -22,14 +21,14 @@ function Icon({ className }: { className?: string }) {
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M21 18H16L14 16V6C14 4.89543 14.8954 4 16 4H28C29.1046 4 30 4.89543 30 6V16C30 17.1046 29.1046 18 28 18H27L25.4142 19.5858C24.6332 20.3668 23.3668 20.3668 22.5858 19.5858L21 18ZM16 15.1716V6H28V16H27H26.1716L25.5858 16.5858L24 18.1716L22.4142 16.5858L21.8284 16H21H16.8284L16 15.1716Z"
|
||||
fill="#2B2F35"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M16 13H4C2.89543 13 2 13.8954 2 15V25C2 26.1046 2.89543 27 4 27H5L6.58579 28.5858C7.36684 29.3668 8.63316 29.3668 9.41421 28.5858L11 27H16C17.1046 27 18 26.1046 18 25V15C18 13.8954 17.1046 13 16 13ZM9 17L6 16.9681C6 16.9681 5 17.016 5 18C5 18.984 6 19 6 19H8.5H10C10 19 9.57627 20.1885 8.38983 21.0831C7.20339 21.9777 5.7197 23 5.7197 23C5.7197 23 4.99153 23.6054 5.5 24.5C6.00847 25.3946 7 24.8403 7 24.8403L9.74576 22.8722L11.9492 24.6614C11.9492 24.6614 12.6271 25.3771 13.3051 24.4825C13.9831 23.5879 13.3051 23.0512 13.3051 23.0512L11.1017 21.262C11.1017 21.262 11.5 21 12 20L12.5 19H14C14 19 15 19.0319 15 18C15 16.9681 14 16.9681 14 16.9681L11 17V16C11 16 11.0169 15 10 15C8.98305 15 9 16 9 16V17Z"
|
||||
fill="#2B2F35"
|
||||
/>
|
||||
@@ -61,7 +60,7 @@ export default function LanguagePrompt() {
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
return (
|
||||
<NoticeTip>
|
||||
<Wrapper>
|
||||
<Flex align="center">
|
||||
<LanguageIcon />
|
||||
<span>
|
||||
@@ -74,11 +73,11 @@ export default function LanguagePrompt() {
|
||||
</Trans>
|
||||
<br />
|
||||
<Link
|
||||
onClick={() => {
|
||||
auth.updateUser({
|
||||
onClick={async () => {
|
||||
ui.setLanguagePromptDismissed();
|
||||
await auth.updateUser({
|
||||
language,
|
||||
});
|
||||
ui.setLanguagePromptDismissed();
|
||||
}}
|
||||
>
|
||||
{t("Change Language")}
|
||||
@@ -87,10 +86,28 @@ export default function LanguagePrompt() {
|
||||
<Link onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</Link>
|
||||
</span>
|
||||
</Flex>
|
||||
</NoticeTip>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.p`
|
||||
background: ${(props) => props.theme.brand.marine};
|
||||
color: ${(props) => props.theme.almostBlack};
|
||||
padding: 10px 12px;
|
||||
margin-top: 24px;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
|
||||
a {
|
||||
color: ${(props) => props.theme.almostBlack};
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const Link = styled(ButtonLink)`
|
||||
color: ${(props) => props.theme.almostBlack};
|
||||
font-weight: 500;
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import styled, { DefaultTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
|
||||
import SkipNavContent from "~/components/SkipNavContent";
|
||||
import SkipNavLink from "~/components/SkipNavLink";
|
||||
import env from "~/env";
|
||||
import useAutoRefresh from "~/hooks/useAutoRefresh";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { MenuProvider } from "~/hooks/useMenuContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
title?: string;
|
||||
sidebar?: React.ReactNode;
|
||||
sidebarRight?: React.ReactNode;
|
||||
@@ -24,10 +27,12 @@ const Layout: React.FC<Props> = ({
|
||||
children,
|
||||
sidebar,
|
||||
sidebarRight,
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const { ui } = useStores();
|
||||
const sidebarCollapsed = !sidebar || ui.sidebarIsClosed;
|
||||
|
||||
useAutoRefresh();
|
||||
|
||||
useKeyDown(".", (event) => {
|
||||
if (isModKey(event)) {
|
||||
ui.toggleCollapsedSidebar();
|
||||
@@ -73,8 +78,8 @@ const Layout: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
const Container = styled(Flex)`
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import * as React from "react";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { loadPolyfills } from "~/utils/polyfills";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Asyncronously load required polyfills. Should wrap the React tree.
|
||||
*/
|
||||
export const LazyPolyfill: React.FC = ({ children }) => {
|
||||
export const LazyPolyfill: React.FC = ({ children }: Props) => {
|
||||
const [isLoaded, setIsLoaded] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadPolyfills().then(() => {
|
||||
setIsLoaded(true);
|
||||
});
|
||||
loadPolyfills()
|
||||
.then(() => {
|
||||
setIsLoaded(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.error("Polyfills failed to load", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!isLoaded) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DisconnectedIcon, WarningIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Empty from "~/components/Empty";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import { OfflineError } from "~/utils/errors";
|
||||
@@ -20,17 +21,17 @@ export default function LoadingError({ error, retry, ...rest }: Props) {
|
||||
const message =
|
||||
error instanceof OfflineError ? (
|
||||
<>
|
||||
<DisconnectedIcon color="currentColor" /> {t("You’re offline.")}
|
||||
<DisconnectedIcon /> {t("You’re offline.")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WarningIcon color="currentColor" /> {t("Sorry, an error occurred.")}
|
||||
<WarningIcon /> {t("Sorry, an error occurred.")}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Content {...rest}>
|
||||
<Flex align="center" gap={4}>
|
||||
<Flex align="center" gap={4} wrap>
|
||||
{message}{" "}
|
||||
<ButtonLink onClick={() => retry()}>{t("Click to retry")}…</ButtonLink>
|
||||
</Flex>
|
||||
@@ -43,10 +44,10 @@ const Content = styled(Empty)`
|
||||
white-space: nowrap;
|
||||
|
||||
${ButtonLink} {
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
color: ${s("textTertiary")};
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
color: ${s("textSecondary")};
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import NavLink from "~/components/NavLink";
|
||||
|
||||
@@ -97,15 +98,13 @@ const Image = styled(Flex)`
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
color: ${(props) => props.theme.text};
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
const Heading = styled.p<{ $small?: boolean }>`
|
||||
font-size: ${(props) => (props.$small ? 14 : 16)}px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
${ellipsis()}
|
||||
line-height: ${(props) => (props.$small ? 1.3 : 1.2)};
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
@@ -14,18 +14,16 @@ type Props = {
|
||||
body?: PlaceholderTextProps;
|
||||
};
|
||||
|
||||
const Placeholder = ({ count, className, header, body }: Props) => {
|
||||
return (
|
||||
<Fade>
|
||||
{times(count || 2, (index) => (
|
||||
<Item key={index} className={className} column auto>
|
||||
<PlaceholderText {...header} header delay={0.2 * index} />
|
||||
<PlaceholderText {...body} delay={0.2 * index} />
|
||||
</Item>
|
||||
))}
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
const Placeholder = ({ count, className, header, body }: Props) => (
|
||||
<Fade>
|
||||
{times(count || 2, (index) => (
|
||||
<Item key={index} className={className} column auto>
|
||||
<PlaceholderText {...header} header delay={0.2 * index} />
|
||||
<PlaceholderText {...body} delay={0.2 * index} />
|
||||
</Item>
|
||||
))}
|
||||
</Fade>
|
||||
);
|
||||
|
||||
const Item = styled(Flex)`
|
||||
padding: 10px 0;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user