mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
423 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2aaad03270 | |||
| 9252683260 | |||
| f5748eb5e7 | |||
| 0555fd2caa | |||
| d885252fb0 | |||
| df9b0bcf91 | |||
| 31910f1628 | |||
| 14cb3a36c1 | |||
| d3350c20b6 | |||
| 174acfac32 | |||
| 9ef4e2b437 | |||
| 8088da8cf3 | |||
| 221ee48429 | |||
| ffe8c046ef | |||
| dbe8a10702 | |||
| 11f7e3a060 | |||
| 0f41a04e49 | |||
| d055021ad4 | |||
| 810dc5a061 | |||
| 7abe375b3e | |||
| 63371d8f5b | |||
| 6e61df0729 | |||
| 5ddc4000d0 | |||
| 48b61559cc | |||
| 0cac5cfe51 | |||
| e9ce80a3aa | |||
| 07d488c826 | |||
| e2bd03494d | |||
| ead55442e0 | |||
| 449dc55aaa | |||
| e312b264a6 | |||
| 68dcb4de5f | |||
| d2b9a5c03f | |||
| 1b023fb6d7 | |||
| afe4553a7e | |||
| 139e2e29d7 | |||
| 638418432a | |||
| c6d2467fae | |||
| e9387db895 | |||
| 065d04ec98 | |||
| 869fc086d6 | |||
| 59c24aba7c | |||
| bc4806ac30 | |||
| 169ad5b025 | |||
| 0b33b5bc05 | |||
| 109efcaa27 | |||
| 2cc6d7add8 | |||
| 003d82fe8a | |||
| f75a07cb0d | |||
| 0d6720e499 | |||
| a97a1df5f1 | |||
| 822395c265 | |||
| 96d6e9b85e | |||
| 70e1194f90 | |||
| 710fcc697c | |||
| 58f9e95d2f | |||
| bc128359ab | |||
| af09713c8c | |||
| 35052ef38f | |||
| ec3adc6d1c | |||
| 67981a351e | |||
| 24448c7504 | |||
| d5b5d4fc27 | |||
| d8603cc961 | |||
| 943a290b83 | |||
| f4a4f034cf | |||
| a53934a5c9 | |||
| e859e3b9e0 | |||
| b51d818db3 | |||
| bfea742650 | |||
| 9951cbf194 | |||
| 5cb04d7ac1 | |||
| 75561079eb | |||
| d2c7f3f166 | |||
| a077766bff | |||
| a25e03d7cd | |||
| 2953d09ee1 | |||
| 28d15b1573 | |||
| 18108cb359 | |||
| 5dfa6a71a4 | |||
| 35270cd104 | |||
| 19d01ed575 | |||
| 2b7639c903 | |||
| c8269ca134 | |||
| da9fc5bfdf | |||
| efcfda8398 | |||
| ce2f69342c | |||
| 9be5597f4b | |||
| 26f6961c82 | |||
| bf5f83e97d | |||
| 8db0260a1a | |||
| edbae275ae | |||
| f43deb7940 | |||
| 2a3b9e2104 | |||
| 64c3ff8d6b | |||
| 4f7e7ec853 | |||
| d864e228e7 | |||
| f3ea02fdd0 | |||
| ed096013e4 | |||
| 2e376b32ec | |||
| f352b35e13 | |||
| 0f8d503df8 | |||
| 5010b08e83 | |||
| 933bbdfb84 | |||
| d25a9d56dc | |||
| 1d8c3f3faf | |||
| d7766280a9 | |||
| ae5eff2914 | |||
| b444874944 | |||
| 20efa82ad9 | |||
| bd9d4b3d0d | |||
| 4b4f4fd188 | |||
| 33815639f2 | |||
| 05e24df226 | |||
| d2d9164fa1 | |||
| f2eb395e8d | |||
| 133cb56cca | |||
| e752dba566 | |||
| c1a141d99f | |||
| ccedea55d6 | |||
| c929f83813 | |||
| 1b25d12e2e | |||
| 0c254285a1 | |||
| 8b274c3713 | |||
| 5a20a35c8b | |||
| 72635e98e6 | |||
| 7d55b7f69b | |||
| ca1ba277ad | |||
| 32562886aa | |||
| 7f07cb57a2 | |||
| 62dd1e41f9 | |||
| 446a9ade8c | |||
| a281b1e5be | |||
| 1b5600b025 | |||
| f0e513542d | |||
| 4797a5fc77 | |||
| f05035c0b7 | |||
| 75cfbd8970 | |||
| c4837f1943 | |||
| 0b3166dab5 | |||
| 603f7962a2 | |||
| 69d16dd1d4 | |||
| e2ffe06221 | |||
| 6e2ea3ac4b | |||
| f4c4a11277 | |||
| c28dc08f6a | |||
| 4ef82d5476 | |||
| 3487eb7857 | |||
| 87020348ce | |||
| 672ffacc5b | |||
| 218b0ea76a | |||
| 47ff6feaee | |||
| 8ac5a574f3 | |||
| aed76c7bcb | |||
| 7f9cd51f37 | |||
| 092b29ee63 | |||
| ee7e1959c9 | |||
| 092d9dce18 | |||
| 9274005cbb | |||
| 400a1c87bb | |||
| 93abbab44a | |||
| 18cf148bd1 | |||
| e0b33ee576 | |||
| 82749ffbd8 | |||
| 231aab6ef8 | |||
| f8077e2125 | |||
| 9d9435cce5 | |||
| 5deec264bc | |||
| 48c87a1902 | |||
| 06a3258b99 | |||
| bd2837250b | |||
| e1adb16c43 | |||
| d914ecb603 | |||
| 187be4737e | |||
| 870b91f17a | |||
| 6db92c9f49 | |||
| c41e6e0423 | |||
| 9f8e7be755 | |||
| cead37051e | |||
| fd99da96af | |||
| c526adf292 | |||
| ee5ae140c3 | |||
| 083ac0d840 | |||
| fbaaa08ec7 | |||
| b536c682a2 | |||
| c94823dd59 | |||
| 1a60f51460 | |||
| abf91a3a51 | |||
| 383806d155 | |||
| 7413e8bf7a | |||
| 7c1aa7622a | |||
| f94efaada8 | |||
| 283a762a9c | |||
| cef687464a | |||
| 8287355261 | |||
| 02d33267cc | |||
| b964bdbe90 | |||
| c832265e8a | |||
| 8819a0836e | |||
| 9338a54fe0 | |||
| a0e73bf4c2 | |||
| 8a0263093b | |||
| 9d8e99400f | |||
| 98b5350c65 | |||
| 3bd1c1f047 | |||
| 9712b8d205 | |||
| 597c09d2bc | |||
| d0606a72c3 | |||
| 0deecfac44 | |||
| f534203cbd | |||
| f521543b0a | |||
| 7da0d7589e | |||
| 09dea295a2 | |||
| d3773dc943 | |||
| 5db43f2607 | |||
| 4b3ddf8769 | |||
| 7a6ed86c95 | |||
| f0be9beeb4 | |||
| 4851f51d8b | |||
| e611979a8d | |||
| 05af318a1d | |||
| 142303b3de | |||
| 6c451a34d4 | |||
| fa4f1846ec | |||
| 4baf5ce99a | |||
| 533ec3bd9c | |||
| 572127b830 | |||
| f0afa67012 | |||
| dac2d43f55 | |||
| d06ec5ce0c | |||
| 148affb52e | |||
| afba1edae4 | |||
| f8c53f8a88 | |||
| 0b86714984 | |||
| 3e7acc377e | |||
| 4cb48e7310 | |||
| 8daef8ebce | |||
| 760e2b2ce9 | |||
| 902e7a4772 | |||
| 3b0b37628e | |||
| c973436870 | |||
| 41fb2826b3 | |||
| c15cbd06a4 | |||
| 8fbd4a7463 | |||
| e18d0fdc77 | |||
| 5a20f6322f | |||
| 0556e2a32c | |||
| 908b457dec | |||
| 71ed0844b9 | |||
| 394be7ba74 | |||
| 8225a924c1 | |||
| 8e2b19dc7a | |||
| 064d8cea44 | |||
| 241d557c90 | |||
| 8e5a5a57a9 | |||
| 22230c25e5 | |||
| 5b78cb8963 | |||
| f231c664e6 | |||
| 6d14dd5028 | |||
| f776741e77 | |||
| aa61c37442 | |||
| e38f4996ae | |||
| cd3035a692 | |||
| 0ccbc6126b | |||
| 9214be5645 | |||
| c1c316b379 | |||
| 7bbddcaebf | |||
| 9c66a14fec | |||
| 321e5c9cbd | |||
| 146e4da73b | |||
| 541e4ebe37 | |||
| c7aa261863 | |||
| c484d15de6 | |||
| 93e4ad8c5e | |||
| 85e70e579a | |||
| 10e038be5b | |||
| 76365e8560 | |||
| 595cb9cda5 | |||
| b6fd9f4211 | |||
| 98dda567c2 | |||
| c20282de06 | |||
| d995f27736 | |||
| 6bf2069fa7 | |||
| adf323713e | |||
| 293c3b7b9c | |||
| 949dd296b4 | |||
| ca0de9fb5a | |||
| 89b87c5268 | |||
| 4511fb7259 | |||
| 6b378bf78f | |||
| 671fa9cc84 | |||
| c360d7cd8c | |||
| 6d8216c54e | |||
| 5731ff34a4 | |||
| cbd9ff2dd9 | |||
| 504aa9f7bb | |||
| a2a7f5ef5c | |||
| 9427f06031 | |||
| dd11bb9079 | |||
| 828ce086cc | |||
| 682151554b | |||
| 3ea79dd31a | |||
| e404955394 | |||
| 14f6e6abad | |||
| f06097d9e8 | |||
| dce0c4ac73 | |||
| ae6d24c343 | |||
| 3c794ec5b6 | |||
| f7a9152ee3 | |||
| 77153a2529 | |||
| 2e61bb7d88 | |||
| 9ef9c75c6b | |||
| 98cd93c99c | |||
| 3a8cd2bb35 | |||
| e72608938a | |||
| e024b1b042 | |||
| 3099eaaf12 | |||
| b4d6c70b29 | |||
| 4080216fe3 | |||
| 1b415ac19a | |||
| a9f851897d | |||
| db94a95001 | |||
| 2d2ad83469 | |||
| b95e1cdef3 | |||
| 66197a967a | |||
| 5263d8a315 | |||
| 97c8bfc27f | |||
| b093acd94f | |||
| 21af0bd8be | |||
| 766a52f10e | |||
| 84c1cfea14 | |||
| 01f672fac9 | |||
| 5eb384b2c8 | |||
| 8ea1323a7c | |||
| 00d5b58850 | |||
| 65b8fb40f3 | |||
| ec4d4fb20f | |||
| fc201663c6 | |||
| d4347b6f4b | |||
| 2d913e3766 | |||
| e33aaec469 | |||
| 24231053af | |||
| 927e0b3fb8 | |||
| b0ceae5af0 | |||
| 6fde4e2ec5 | |||
| b42e9737b6 | |||
| 4164fc178c | |||
| b1a1d24f9c | |||
| d46530a4a0 | |||
| 3f7d4f7873 | |||
| b20d41a047 | |||
| 1797a0e90c | |||
| dd5e30f414 | |||
| 690299ac6b | |||
| 7ff0a1d820 | |||
| 3292d95d8b | |||
| 18658e354a | |||
| 02f2868d06 | |||
| 4ea4bd41cd | |||
| 593aa80abf | |||
| f43643f43b | |||
| e2453b5b2a | |||
| 439ae1e832 | |||
| 228c0c45e7 | |||
| 6520a501e3 | |||
| 140f009b4d | |||
| 579eaf325b | |||
| b56f8e7870 | |||
| 79f8a41b5b | |||
| abf5b79de6 | |||
| c60295fcca | |||
| 5bd2409e39 | |||
| 780c5c1129 | |||
| b98c908568 | |||
| f1e8633623 | |||
| 39de7c0aee | |||
| 8b942cf202 | |||
| ccfad1d800 | |||
| 347015cf86 | |||
| d96afad6c0 | |||
| d066468bc0 | |||
| b6dd55fbea | |||
| 8f2d31876d | |||
| 8e76c4e8f1 | |||
| 468fd792ed | |||
| c1bef2db59 | |||
| 53cc69a413 | |||
| 7e62b3b9aa | |||
| 54565fff74 | |||
| e2b28dfeb7 | |||
| cf18b952a4 | |||
| 929722e1c1 | |||
| c0ec349ad2 | |||
| 4d2eda6750 | |||
| db08263e5c | |||
| 33320ba13d | |||
| 4dad3f3287 | |||
| f87b561685 | |||
| d024d31f66 | |||
| 7b2eea0009 | |||
| 2ce11365ab | |||
| e936aa82c9 | |||
| a26ae119fe | |||
| 789a1acea1 | |||
| dd95c9cba9 | |||
| ae1cf2d00c | |||
| 978eda3ad2 | |||
| 0f028812e1 | |||
| c18e4cd43e | |||
| 7f10fe728f | |||
| 5c99116898 | |||
| 38a67b1f9e | |||
| fb4f6822a4 | |||
| 75b03fdba2 | |||
| 60a31992d0 | |||
| be09b6a3bd | |||
| 92a18159b5 | |||
| 7c01904cec | |||
| c9b86ec2e7 | |||
| 466ba6ec1f | |||
| 9cbc9aaad6 | |||
| 5ec2c3c7a0 | |||
| a8d8fecd15 |
@@ -1,18 +1,29 @@
|
||||
{
|
||||
"presets": ["react", "env"],
|
||||
"presets": [
|
||||
"@babel/preset-react",
|
||||
"@babel/preset-flow",
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"corejs": {
|
||||
"version": "2",
|
||||
"proposals": true
|
||||
},
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": [
|
||||
"lodash",
|
||||
"styled-components",
|
||||
"transform-decorators-legacy",
|
||||
"transform-es2015-destructuring",
|
||||
"transform-object-rest-spread",
|
||||
"transform-regenerator",
|
||||
"transform-class-properties",
|
||||
"syntax-dynamic-import"
|
||||
],
|
||||
"env": {
|
||||
"development": {
|
||||
"presets": ["react-hmre"]
|
||||
}
|
||||
}
|
||||
}
|
||||
[
|
||||
"@babel/plugin-proposal-decorators",
|
||||
{
|
||||
"legacy": true
|
||||
}
|
||||
],
|
||||
"@babel/plugin-transform-destructuring",
|
||||
"@babel/plugin-transform-regenerator",
|
||||
"transform-class-properties"
|
||||
]
|
||||
}
|
||||
@@ -3,7 +3,7 @@ jobs:
|
||||
build:
|
||||
working_directory: ~/outline
|
||||
docker:
|
||||
- image: circleci/node:8.11
|
||||
- image: circleci/node:12
|
||||
- image: circleci/redis:latest
|
||||
- image: circleci/postgres:9.6.5-alpine-ram
|
||||
environment:
|
||||
@@ -29,12 +29,15 @@ jobs:
|
||||
- run:
|
||||
name: migrate
|
||||
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test
|
||||
- run:
|
||||
name: lint
|
||||
command: yarn lint
|
||||
- run:
|
||||
name: flow
|
||||
command: yarn flow
|
||||
command: yarn flow check --max-workers 4
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test
|
||||
- run:
|
||||
name: build
|
||||
command: yarn build
|
||||
+17
-9
@@ -4,18 +4,20 @@
|
||||
#
|
||||
# Please use `openssl rand -hex 32` to create SECRET_KEY
|
||||
SECRET_KEY=generate_a_new_key
|
||||
UTILS_SECRET=generate_a_new_key
|
||||
|
||||
DATABASE_URL=postgres://user:pass@postgres:5432/outline
|
||||
DATABASE_URL_TEST=postgres://user:pass@postgres:5432/outline-test
|
||||
REDIS_URL=redis://redis:6379
|
||||
DATABASE_URL=postgres://user:pass@localhost:5532/outline
|
||||
DATABASE_URL_TEST=postgres://user:pass@localhost:5532/outline-test
|
||||
REDIS_URL=redis://localhost:6479
|
||||
|
||||
URL=http://localhost:3000
|
||||
PORT=3000
|
||||
|
||||
DEPLOYMENT=self
|
||||
# enforce (auto redirect to) https in production, (optional) default is true.
|
||||
# set to false if your SSL is terminated at a loadbalancer, for example
|
||||
FORCE_HTTPS=true
|
||||
|
||||
ENABLE_UPDATES=true
|
||||
SUBDOMAINS_ENABLED=false
|
||||
WEBSOCKETS_ENABLED=true
|
||||
DEBUG=cache,presenters,events
|
||||
|
||||
# Third party signin credentials (at least one is required)
|
||||
@@ -24,6 +26,7 @@ SLACK_SECRET=get_the_secret_of_above_key
|
||||
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Comma separated list of domains to be allowed (optional)
|
||||
# If not set, all Google apps domains are allowed by default
|
||||
GOOGLE_ALLOWED_DOMAINS=
|
||||
@@ -33,16 +36,18 @@ SLACK_VERIFICATION_TOKEN=PLxk6OlXXXXXVj3YYYY
|
||||
SLACK_APP_ID=A0XXXXXXX
|
||||
SLACK_MESSAGE_ACTIONS=true
|
||||
GOOGLE_ANALYTICS_ID=
|
||||
BUGSNAG_KEY=
|
||||
GITHUB_ACCESS_TOKEN=
|
||||
SENTRY_DSN=
|
||||
|
||||
# AWS credentials (optional in dev)
|
||||
# AWS credentials (optional in development)
|
||||
AWS_ACCESS_KEY_ID=get_a_key_from_aws
|
||||
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
|
||||
AWS_REGION=xx-xxxx-x
|
||||
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
|
||||
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
|
||||
AWS_S3_UPLOAD_MAX_SIZE=26214400
|
||||
# uploaded s3 objects permission level, default is private
|
||||
# set to "public-read" to allow public access
|
||||
AWS_S3_ACL=private
|
||||
|
||||
# Emails configuration (optional)
|
||||
SMTP_HOST=
|
||||
@@ -51,3 +56,6 @@ SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=
|
||||
SMTP_REPLY_EMAIL=
|
||||
|
||||
# Custom logo that displays on the authentication screen, scaled to height: 60px
|
||||
# TEAM_LOGO=https://example.com/images/logo.png
|
||||
|
||||
@@ -6,11 +6,54 @@
|
||||
"plugin:import/warnings",
|
||||
"plugin:flowtype/recommended"
|
||||
],
|
||||
"plugins": ["prettier", "flowtype"],
|
||||
"plugins": [
|
||||
"prettier",
|
||||
"flowtype"
|
||||
],
|
||||
"rules": {
|
||||
"eqeqeq": 2,
|
||||
"no-unused-vars": 2,
|
||||
"no-mixed-operators": "off",
|
||||
"import/order": [
|
||||
"error",
|
||||
{
|
||||
"alphabetize": {
|
||||
"order": "asc"
|
||||
},
|
||||
"pathGroups": [
|
||||
{
|
||||
"pattern": "shared/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "stores",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "stores/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "models/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "scenes/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "components/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"flowtype/require-valid-file-annotation": [
|
||||
2,
|
||||
"always",
|
||||
@@ -18,14 +61,19 @@
|
||||
"annotationStyle": "line"
|
||||
}
|
||||
],
|
||||
"flowtype/space-after-type-colon": [2, "always"],
|
||||
"flowtype/space-before-type-colon": [2, "never"],
|
||||
"flowtype/space-after-type-colon": [
|
||||
2,
|
||||
"always"
|
||||
],
|
||||
"flowtype/space-before-type-colon": [
|
||||
2,
|
||||
"never"
|
||||
],
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
"printWidth": 80,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -33,12 +81,14 @@
|
||||
"react": {
|
||||
"createClass": "createReactClass",
|
||||
"pragma": "React",
|
||||
"version": "detect",
|
||||
"flowVersion": "0.86"
|
||||
"version": "detect"
|
||||
},
|
||||
"import/resolver": {
|
||||
"node": {
|
||||
"paths": ["app", "."]
|
||||
"paths": [
|
||||
"app",
|
||||
"."
|
||||
]
|
||||
}
|
||||
},
|
||||
"flowtype": {
|
||||
@@ -49,12 +99,6 @@
|
||||
"jest": true
|
||||
},
|
||||
"globals": {
|
||||
"__DEV__": true,
|
||||
"SLACK_KEY": true,
|
||||
"DEPLOYMENT": true,
|
||||
"BASE_URL": true,
|
||||
"BUGSNAG_KEY": true,
|
||||
"afterAll": true,
|
||||
"Bugsnag": true
|
||||
"EDITOR_VERSION": true
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -7,13 +7,11 @@
|
||||
.*/node_modules/tiny-cookie/flow/.*
|
||||
.*/node_modules/styled-components/.*
|
||||
.*/node_modules/polished/.*
|
||||
.*/node_modules/mobx/.*.flow
|
||||
.*/node_modules/react-side-effect/.*
|
||||
.*/node_modules/fbjs/.*
|
||||
.*/node_modules/slate-edit-code/example/.*
|
||||
.*/node_modules/slate-edit-code/lib/.*
|
||||
.*/node_modules/slate-edit-list/.*
|
||||
.*/node_modules/slate-prism/.*
|
||||
.*/node_modules/config-chain/.*
|
||||
.*/server/scripts/.*
|
||||
*.test.js
|
||||
|
||||
[libs]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
yarn flow
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots or videos to help explain your problem.
|
||||
|
||||
**Outline (please complete the following information):**
|
||||
- Install: [getoutline.com or self hosted]
|
||||
- Version: [commit sha if self hosted]
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Mobile (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Feature request
|
||||
url: https://github.com/outline/outline/discussions/new
|
||||
about: Request a feature to be added to the project
|
||||
- name: Self hosting questions
|
||||
url: https://github.com/outline/outline/discussions/new
|
||||
about: Ask questions and discuss running Outline with community members
|
||||
@@ -1,5 +1,6 @@
|
||||
dist
|
||||
node_modules/*
|
||||
server/scripts
|
||||
.env
|
||||
.log
|
||||
npm-debug.log
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"javascript.validate.enable": false,
|
||||
"typescript.validate.enable": false,
|
||||
"editor.formatOnSave": true,
|
||||
"typescript.format.enable": false
|
||||
}
|
||||
+5
-2
@@ -1,4 +1,4 @@
|
||||
FROM node:8.11
|
||||
FROM node:12-alpine
|
||||
|
||||
ENV PATH /opt/outline/node_modules/.bin:/opt/node_modules/.bin:$PATH
|
||||
ENV NODE_PATH /opt/outline/node_modules:/opt/node_modules
|
||||
@@ -9,6 +9,9 @@ WORKDIR $APP_PATH
|
||||
COPY . $APP_PATH
|
||||
|
||||
RUN yarn install --pure-lockfile
|
||||
RUN yarn build
|
||||
RUN cp -r /opt/outline/node_modules /opt/node_modules
|
||||
|
||||
CMD yarn build && yarn start
|
||||
CMD yarn start
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -1,19 +1,103 @@
|
||||
Copyright (c) 2017 Outline (https://www.getoutline.com/) and individual contributors.
|
||||
All rights reserved.
|
||||
Business Source License 1.1
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
Parameters
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.46.0
|
||||
The Licensed Work is (c) 2020 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
Service.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
A “Document Service” is a commercial offering that
|
||||
allows third parties (other than your employees and
|
||||
contractors) to access the functionality of the
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
3. Neither the name of the Outline nor the names of its contributors may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
Change Date: 2023-08-12
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
|
||||
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
|
||||
ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
For information about alternative licensing arrangements for the Software,
|
||||
please visit: https://www.getoutline.com
|
||||
|
||||
Notice
|
||||
|
||||
The Business Source License (this document, or the “License”) is not an Open
|
||||
Source license. However, the Licensed Work will eventually be made available
|
||||
under an Open Source License, as stated in this License.
|
||||
|
||||
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
|
||||
“Business Source License” is a trademark of MariaDB Corporation Ab.
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
|
||||
Business Source License 1.1
|
||||
|
||||
Terms
|
||||
|
||||
The Licensor hereby grants you the right to copy, modify, create derivative
|
||||
works, redistribute, and make non-production use of the Licensed Work. The
|
||||
Licensor may make an Additional Use Grant, above, permitting limited
|
||||
production use.
|
||||
|
||||
Effective on the Change Date, or the fourth anniversary of the first publicly
|
||||
available distribution of a specific version of the Licensed Work under this
|
||||
License, whichever comes first, the Licensor hereby grants you rights under
|
||||
the terms of the Change License, and the rights granted in the paragraph
|
||||
above terminate.
|
||||
|
||||
If your use of the Licensed Work does not comply with the requirements
|
||||
currently in effect as described in this License, you must purchase a
|
||||
commercial license from the Licensor, its affiliated entities, or authorized
|
||||
resellers, or you must refrain from using the Licensed Work.
|
||||
|
||||
All copies of the original and modified Licensed Work, and derivative works
|
||||
of the Licensed Work, are subject to this License. This License applies
|
||||
separately for each version of the Licensed Work and the Change Date may vary
|
||||
for each version of the Licensed Work released by Licensor.
|
||||
|
||||
You must conspicuously display this License on each original or modified copy
|
||||
of the Licensed Work. If you receive the Licensed Work in original or
|
||||
modified form from a third party, the terms and conditions set forth in this
|
||||
License apply to your use of that work.
|
||||
|
||||
Any use of the Licensed Work in violation of this License will automatically
|
||||
terminate your rights under this License for the current and all other
|
||||
versions of the Licensed Work.
|
||||
|
||||
This License does not grant you any right in any trademark or logo of
|
||||
Licensor or its affiliates (provided that you may use a trademark or logo of
|
||||
Licensor as expressly required by this License).
|
||||
|
||||
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
||||
AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
||||
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
|
||||
TITLE.
|
||||
|
||||
MariaDB hereby grants you permission to use this License’s text to license
|
||||
your works, and to refer to it using the trademark “Business Source License”,
|
||||
as long as you comply with the Covenants of Licensor below.
|
||||
|
||||
Covenants of Licensor
|
||||
|
||||
In consideration of the right to use this License’s text and the “Business
|
||||
Source License” name and trademark, Licensor covenants to MariaDB, and to all
|
||||
other recipients of the licensed work to be provided by Licensor:
|
||||
|
||||
1. To specify as the Change License the GPL Version 2.0 or any later version,
|
||||
or a license that is compatible with GPL Version 2.0 or a later version,
|
||||
where “compatible” means that software provided under the Change License can
|
||||
be included in a program with software provided under GPL Version 2.0 or a
|
||||
later version. Licensor may specify additional Change Licenses without
|
||||
limitation.
|
||||
|
||||
2. To either: (a) specify an additional grant of rights to use that does not
|
||||
impose any additional restriction on the right granted in this License, as
|
||||
the Additional Use Grant; or (b) insert the text “None”.
|
||||
|
||||
3. To specify a Change Date.
|
||||
|
||||
4. Not to modify this License in any other way.
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
up:
|
||||
docker-compose up -d redis postgres s3
|
||||
docker-compose run --rm outline bash -c "yarn && yarn sequelize db:migrate"
|
||||
docker-compose up outline
|
||||
yarn install --pure-lockfile
|
||||
yarn sequelize db:migrate
|
||||
yarn dev
|
||||
|
||||
build:
|
||||
docker-compose build --pull outline
|
||||
|
||||
test:
|
||||
docker-compose run --rm outline yarn test
|
||||
docker-compose up -d redis postgres s3
|
||||
yarn test
|
||||
|
||||
watch:
|
||||
docker-compose run --rm outline yarn test:watch
|
||||
docker-compose up -d redis postgres s3
|
||||
yarn test:watch
|
||||
|
||||
destroy:
|
||||
docker-compose stop
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img src="https://user-images.githubusercontent.com/31465/34380645-bd67f474-eb0b-11e7-8d03-0151c1730654.png" height="29" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<i>An open, extensible, wiki for your team built using React and Node.js.<br/>Try out Outline using our hosted version at <a href="https://www.getoutline.com">www.getoutline.com</a>.</i>
|
||||
<br/>
|
||||
<img src="https://user-images.githubusercontent.com/31465/34456332-51e41eb0-ed9c-11e7-9fa9-20e7fa946494.jpg" alt="Outline" width="800" />
|
||||
<img src="https://user-images.githubusercontent.com/380914/78513257-153ae080-775f-11ea-9b49-1e1939451a3e.png" alt="Outline" width="800" />
|
||||
</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://spectrum.chat/outline" rel="nofollow"><img src="https://withspectrum.github.io/badge/badge.svg" alt="Join the community on Spectrum"/></a>
|
||||
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat"></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"></a>
|
||||
</p>
|
||||
@@ -21,9 +22,9 @@ If you'd like to run your own copy of Outline or contribute to development then
|
||||
|
||||
Outline requires the following dependencies:
|
||||
|
||||
- Node.js >= 8.11
|
||||
- Node.js >= 12
|
||||
- Postgres >=9.5
|
||||
- Redis
|
||||
- Redis >= 4
|
||||
- AWS S3 storage bucket for media and other attachments
|
||||
- Slack or Google developer application for authentication
|
||||
|
||||
@@ -32,15 +33,20 @@ Outline requires the following dependencies:
|
||||
|
||||
In development you can quickly get an environment running using Docker by following these steps:
|
||||
|
||||
1. Install these dependencies if you don't already have them
|
||||
1. [Docker for Desktop](https://www.docker.com)
|
||||
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
|
||||
1. [Yarn](https://yarnpkg.com)
|
||||
1. Clone this repo
|
||||
1. Install [Docker for Desktop](https://www.docker.com) if you don't already have it
|
||||
1. Register a Slack app at https://api.slack.com/apps
|
||||
1. Copy the file `.env.sample` to `.env`
|
||||
1. Fill out the following fields:
|
||||
1. `SECRET_KEY` (follow instructions in the comments of `.env`)
|
||||
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
|
||||
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
|
||||
1. `SLACK_SECRET`
|
||||
1. Add `http://localhost:3000/auth/slack.callback` as an Oauth callback URL in Slack App settings
|
||||
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
|
||||
1. Configure your Slack app's Oauth & Permissions settings
|
||||
1. Add `http://localhost:3000/auth/slack.callback` as an Oauth redirect URL
|
||||
1. Ensure that the bot token scope contains at least `users:read`
|
||||
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
|
||||
|
||||
|
||||
@@ -50,13 +56,13 @@ For a self-hosted production installation there is more flexibility, but these a
|
||||
|
||||
1. Clone this repo and install dependencies with `yarn` or `npm install`
|
||||
|
||||
> Requires [Node.js, npm](https://nodejs.org/) and [yarn](https://yarnpkg.com) installed
|
||||
> Requires [Node.js](https://nodejs.org/) and [yarn](https://yarnpkg.com) installed
|
||||
|
||||
1. Build the web app with `yarn build:webpack` or `npm run build:webpack`
|
||||
1. Copy the file `.env.sample` to `.env` and fill out at least the essential fields:
|
||||
1. `SECRET_KEY` (follow instructions in the comments of `.env`)
|
||||
1. Using the `.env.sample` as a reference, set the required variables in your production environment. The following are required as a minimum:
|
||||
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
|
||||
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
|
||||
1. `SLACK_SECRET`
|
||||
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
|
||||
1. `DATABASE_URL` (run your own local copy of Postgres, or use a cloud service)
|
||||
1. `REDIS_URL` (run your own local copy of Redis, or use a cloud service)
|
||||
1. `URL` (the public facing URL of your installation)
|
||||
@@ -103,7 +109,7 @@ Outline is composed of separate backend and frontend application which are both
|
||||
|
||||
Outline's frontend is a React application compiled with [Webpack](https://webpack.js.org/). It uses [Mobx](https://mobx.js.org/) for state management and [Styled Components](https://www.styled-components.com/) for component styles. Unless global, state logic and styles are always co-located with React components together with their subcomponents to make the component tree easier to manage.
|
||||
|
||||
The editor itself is built ontop of [Slate](https://github.com/ianstormtaylor/slate) and hosted in a separate repository to encourage reuse: [rich-markdown-editor](https://github.com/outline/rich-markdown-editor)
|
||||
The editor itself is built on [Prosemirror](https://github.com/prosemirror) and hosted in a separate repository to encourage reuse: [rich-markdown-editor](https://github.com/outline/rich-markdown-editor)
|
||||
|
||||
- `app/` - Frontend React application
|
||||
- `app/scenes` - Full page views
|
||||
@@ -120,7 +126,6 @@ Backend is driven by [Koa](http://koajs.com/) (API, web server), [Sequelize](htt
|
||||
- `server/commands` - Domain logic, currently being refactored from /models
|
||||
- `server/emails` - React rendered email templates
|
||||
- `server/models` - Database models
|
||||
- `server/pages` - Server-side rendered public pages
|
||||
- `server/policies` - Authorization logic
|
||||
- `server/presenters` - API responses for database models
|
||||
- `server/test` - Test helps and support
|
||||
@@ -148,7 +153,7 @@ yarn test:app
|
||||
|
||||
Outline is built and maintained by a small team – we'd love your help to fix bugs and add features!
|
||||
|
||||
However, before working on a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and we'd also love to hear from you in our [Spectrum community](https://spectrum.chat/outline). This way we can ensure that an approach is agreed on before code is written and will hopefully help to get your contributions integrated faster! Take a look at our [roadmap](https://www.getoutline.com/share/3e6cb2b5-d68b-4ad8-8900-062476820311).
|
||||
However, before working on a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and we'd also love to hear from you in the [Discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and will hopefully help to get your contributions integrated faster!
|
||||
|
||||
If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
|
||||
|
||||
@@ -156,8 +161,7 @@ If you’re looking for ways to get started, here's a list of ways to help us im
|
||||
* Performance improvements, both on server and frontend
|
||||
* Developer happiness and documentation
|
||||
* Bugs and other issues listed on GitHub
|
||||
* Helping others on Spectrum
|
||||
|
||||
## License
|
||||
|
||||
Outline is [BSD licensed](https://github.com/outline/outline/blob/master/LICENSE).
|
||||
Outline is [BSL 1.1 licensed](https://github.com/outline/outline/blob/master/LICENSE).
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
The Outline team takes security bugs seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
|
||||
|
||||
To report a security issue, email [hello@getoutline.com](mailto:hello@getoutline.com) and include the word "SECURITY" in the subject line.
|
||||
|
||||
The Outline team will send a response indicating the next steps in handling your report. After the initial reply to your report you will be kept informed of the progress towards a fix and full announcement.
|
||||
|
||||
Report security bugs in third-party dependencies to the person or team maintaining the module. You can also report a vulnerability through the [Node Security Project](https://nodesecurity.io/report).
|
||||
@@ -35,25 +35,10 @@
|
||||
"generator": "secret",
|
||||
"required": true
|
||||
},
|
||||
"DEPLOYMENT": {
|
||||
"description": "Should be 'self' for self hosted installations, turns off things like pricing pages",
|
||||
"value": "self",
|
||||
"required": true
|
||||
},
|
||||
"ENABLE_UPDATES": {
|
||||
"value": "true",
|
||||
"required": true
|
||||
},
|
||||
"SUBDOMAINS_ENABLED": {
|
||||
"value": "false",
|
||||
"required": true,
|
||||
"description": "Allows each team to have a different subdomain. Not recommend when self hosting"
|
||||
},
|
||||
"WEBSOCKETS_ENABLED": {
|
||||
"value": "true",
|
||||
"required": true,
|
||||
"description": "Allow realtime data to be pushed to clients over websockets"
|
||||
},
|
||||
"URL": {
|
||||
"description": "https://{your app name}.herokuapp.com",
|
||||
"required": true
|
||||
@@ -87,7 +72,7 @@
|
||||
"required": false
|
||||
},
|
||||
"AWS_ACCESS_KEY_ID": {
|
||||
"description": "Needed to save file uploads. Optional for dev / testing.",
|
||||
"description": "Needed to save file uploads. Optional for development / testing",
|
||||
"required": false
|
||||
},
|
||||
"AWS_SECRET_ACCESS_KEY": {
|
||||
@@ -107,6 +92,16 @@
|
||||
"value": "26214400",
|
||||
"required": false
|
||||
},
|
||||
"AWS_REGION": {
|
||||
"value": "us-east-1",
|
||||
"description": "Region in which the above S3 bucket exists",
|
||||
"required": false
|
||||
},
|
||||
"AWS_S3_ACL": {
|
||||
"value": "private",
|
||||
"description": "S3 canned ACL for document attachments",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_HOST": {
|
||||
"description": "smtp.example.com (optional)",
|
||||
"required": false
|
||||
@@ -135,13 +130,13 @@
|
||||
"description": "UA-xxxx (optional)",
|
||||
"required": false
|
||||
},
|
||||
"BUGSNAG_KEY": {
|
||||
"description": "An API key for bugsnag if you wish to collect error reporting (optional)",
|
||||
"SENTRY_DSN": {
|
||||
"description": "An API key for Sentry if you wish to collect error reporting (optional)",
|
||||
"required": false
|
||||
},
|
||||
"GITHUB_ACCESS_TOKEN": {
|
||||
"description": "An API token for GitHub, optional for self hosted (optional)",
|
||||
"TEAM_LOGO": {
|
||||
"description": "A logo that will be displayed on the signed out home page",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,32 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
import breakpoint from 'styled-components-breakpoint';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
export const Action = styled(Flex)`
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 0 0 12px;
|
||||
height: 32px;
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
|
||||
a {
|
||||
color: ${props => props.theme.text};
|
||||
color: ${(props) => props.theme.text};
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Separator = styled.div`
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: ${props => props.theme.divider};
|
||||
background: ${(props) => props.theme.divider};
|
||||
`;
|
||||
|
||||
const Actions = styled(Flex)`
|
||||
@@ -29,8 +35,8 @@ const Actions = styled(Flex)`
|
||||
right: 0;
|
||||
left: 0;
|
||||
border-radius: 3px;
|
||||
background: ${props => props.theme.background};
|
||||
transition: ${props => props.theme.backgroundTransition};
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
padding: 12px;
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
|
||||
@@ -38,7 +44,7 @@ const Actions = styled(Flex)`
|
||||
display: none;
|
||||
}
|
||||
|
||||
${breakpoint('tablet')`
|
||||
${breakpoint("tablet")`
|
||||
left: auto;
|
||||
padding: 24px;
|
||||
`};
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import styled from 'styled-components';
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
type?: 'info' | 'success' | 'warning' | 'danger' | 'offline',
|
||||
};
|
||||
|
||||
@observer
|
||||
class Alert extends React.Component<Props> {
|
||||
defaultProps = {
|
||||
type: 'info',
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Container align="center" justify="center" type={this.props.type}>
|
||||
{this.props.children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Container = styled(Flex)`
|
||||
height: $headerHeight;
|
||||
color: ${props => props.theme.white};
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
|
||||
background-color: ${({ theme, type }) => theme.color[type]};
|
||||
`;
|
||||
|
||||
export default Alert;
|
||||
@@ -1,27 +1,32 @@
|
||||
// @flow
|
||||
/* global ga */
|
||||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
import env from "env";
|
||||
|
||||
export default class Analytics extends React.Component<*> {
|
||||
type Props = {
|
||||
children?: React.Node,
|
||||
};
|
||||
|
||||
export default class Analytics extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
if (!process.env.GOOGLE_ANALYTICS_ID) return;
|
||||
if (!env.GOOGLE_ANALYTICS_ID) return;
|
||||
|
||||
// standard Google Analytics script
|
||||
window.ga =
|
||||
window.ga ||
|
||||
function() {
|
||||
function () {
|
||||
// $FlowIssue
|
||||
(ga.q = ga.q || []).push(arguments);
|
||||
};
|
||||
|
||||
// $FlowIssue
|
||||
ga.l = +new Date();
|
||||
ga('create', process.env.GOOGLE_ANALYTICS_ID, 'auto');
|
||||
ga('set', { dimension1: 'true' });
|
||||
ga('send', 'pageview');
|
||||
ga("create", env.GOOGLE_ANALYTICS_ID, "auto");
|
||||
ga("set", { dimension1: "true" });
|
||||
ga("send", "pageview");
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://www.google-analytics.com/analytics.js';
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://www.google-analytics.com/analytics.js";
|
||||
script.async = true;
|
||||
|
||||
if (document.body) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import LoadingIndicator from 'components/LoadingIndicator';
|
||||
import { isCustomSubdomain } from 'shared/utils/domains';
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import { isCustomSubdomain } from "shared/utils/domains";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
import env from "env";
|
||||
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
@@ -22,7 +24,7 @@ const Authenticated = observer(({ auth, children }: Props) => {
|
||||
// If we're authenticated but viewing a subdomain that doesn't match the
|
||||
// currently authenticated team then kick the user to the teams subdomain.
|
||||
if (
|
||||
process.env.SUBDOMAINS_ENABLED &&
|
||||
env.SUBDOMAINS_ENABLED &&
|
||||
team.subdomain &&
|
||||
isCustomSubdomain(hostname) &&
|
||||
!hostname.startsWith(`${team.subdomain}.`)
|
||||
@@ -34,8 +36,8 @@ const Authenticated = observer(({ auth, children }: Props) => {
|
||||
return children;
|
||||
}
|
||||
|
||||
auth.logout();
|
||||
return null;
|
||||
auth.logout(true);
|
||||
return <Redirect to="/" />;
|
||||
});
|
||||
|
||||
export default inject('auth')(Authenticated);
|
||||
export default inject("auth")(Authenticated);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import placeholder from './placeholder.png';
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import placeholder from "./placeholder.png";
|
||||
|
||||
type Props = {
|
||||
src: string,
|
||||
size: number,
|
||||
icon?: React.Node,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -23,23 +24,43 @@ class Avatar extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { src, ...rest } = this.props;
|
||||
const { src, icon, ...rest } = this.props;
|
||||
|
||||
return (
|
||||
<CircleImg
|
||||
onError={this.handleError}
|
||||
src={this.error ? placeholder : src}
|
||||
{...rest}
|
||||
/>
|
||||
<AvatarWrapper>
|
||||
<CircleImg
|
||||
onError={this.handleError}
|
||||
src={this.error ? placeholder : src}
|
||||
{...rest}
|
||||
/>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
</AvatarWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const AvatarWrapper = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.div`
|
||||
display: flex;
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
background: ${(props) => props.theme.primary};
|
||||
border: 2px solid ${(props) => props.theme.background};
|
||||
border-radius: 100%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
`;
|
||||
|
||||
const CircleImg = styled.img`
|
||||
width: ${props => props.size}px;
|
||||
height: ${props => props.size}px;
|
||||
display: block;
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid ${props => props.theme.background};
|
||||
border: 2px solid ${(props) => props.theme.background};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import User from "models/User";
|
||||
import UserProfile from "scenes/UserProfile";
|
||||
import Avatar from "components/Avatar";
|
||||
import Tooltip from "components/Tooltip";
|
||||
|
||||
type Props = {
|
||||
user: User,
|
||||
isPresent: boolean,
|
||||
isEditing: boolean,
|
||||
isCurrentUser: boolean,
|
||||
lastViewedAt: string,
|
||||
};
|
||||
|
||||
@observer
|
||||
class AvatarWithPresence extends React.Component<Props> {
|
||||
@observable isOpen: boolean = false;
|
||||
|
||||
handleOpenProfile = () => {
|
||||
this.isOpen = true;
|
||||
};
|
||||
|
||||
handleCloseProfile = () => {
|
||||
this.isOpen = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
user,
|
||||
lastViewedAt,
|
||||
isPresent,
|
||||
isEditing,
|
||||
isCurrentUser,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
tooltip={
|
||||
<Centered>
|
||||
<strong>{user.name}</strong> {isCurrentUser && "(You)"}
|
||||
<br />
|
||||
{isPresent
|
||||
? isEditing
|
||||
? "currently editing"
|
||||
: "currently viewing"
|
||||
: `viewed ${distanceInWordsToNow(new Date(lastViewedAt))} ago`}
|
||||
</Centered>
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
<AvatarWrapper isPresent={isPresent}>
|
||||
<Avatar
|
||||
src={user.avatarUrl}
|
||||
onClick={this.handleOpenProfile}
|
||||
size={32}
|
||||
icon={isEditing ? <EditIcon size={16} color="#FFF" /> : undefined}
|
||||
/>
|
||||
</AvatarWrapper>
|
||||
</Tooltip>
|
||||
<UserProfile
|
||||
user={user}
|
||||
isOpen={this.isOpen}
|
||||
onRequestClose={this.handleCloseProfile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Centered = styled.div`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const AvatarWrapper = styled.div`
|
||||
opacity: ${(props) => (props.isPresent ? 1 : 0.5)};
|
||||
transition: opacity 250ms ease-in-out;
|
||||
`;
|
||||
|
||||
export default AvatarWithPresence;
|
||||
@@ -1,3 +1,6 @@
|
||||
// @flow
|
||||
import Avatar from './Avatar';
|
||||
import Avatar from "./Avatar";
|
||||
import AvatarWithPresence from "./AvatarWithPresence";
|
||||
|
||||
export { AvatarWithPresence };
|
||||
export default Avatar;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
import styled from "styled-components";
|
||||
|
||||
const Badge = styled.span`
|
||||
margin-left: 10px;
|
||||
padding: 2px 6px 3px;
|
||||
background-color: ${({ admin, theme }) =>
|
||||
admin ? theme.primary : theme.smokeDark};
|
||||
color: ${({ admin, theme }) => (admin ? theme.white : theme.text)};
|
||||
border-radius: 2px;
|
||||
background-color: ${({ primary, theme }) =>
|
||||
primary ? theme.primary : theme.textTertiary};
|
||||
color: ${({ primary, theme }) => (primary ? theme.white : theme.background)};
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import OutlineLogo from "./OutlineLogo";
|
||||
import env from "env";
|
||||
|
||||
type Props = {
|
||||
href?: string,
|
||||
};
|
||||
|
||||
function Branding({ href = env.URL }: Props) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<OutlineLogo size={16} />
|
||||
Outline
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const Link = styled.a`
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
border-top-right-radius: 2px;
|
||||
color: ${(props) => props.theme.text};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
|
||||
svg {
|
||||
fill: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
}
|
||||
`;
|
||||
|
||||
export default Branding;
|
||||
@@ -1,21 +1,23 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import breakpoint from 'styled-components-breakpoint';
|
||||
import styled from 'styled-components';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { observer, inject } from "mobx-react";
|
||||
import {
|
||||
CollectionIcon,
|
||||
PrivateCollectionIcon,
|
||||
PadlockIcon,
|
||||
GoToIcon,
|
||||
MoreIcon,
|
||||
} from 'outline-icons';
|
||||
ShapesIcon,
|
||||
EditIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
import Document from 'models/Document';
|
||||
import CollectionsStore from 'stores/CollectionsStore';
|
||||
import { collectionUrl } from 'utils/routeHelpers';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import BreadcrumbMenu from './BreadcrumbMenu';
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import Document from "models/Document";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import Flex from "components/Flex";
|
||||
import BreadcrumbMenu from "./BreadcrumbMenu";
|
||||
import { collectionUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document,
|
||||
@@ -25,64 +27,92 @@ type Props = {
|
||||
|
||||
const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
|
||||
const collection = collections.get(document.collectionId);
|
||||
if (!collection) return null;
|
||||
if (!collection) return <div />;
|
||||
|
||||
const path = collection.pathToDocument(document).slice(0, -1);
|
||||
|
||||
if (onlyText === true) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<>
|
||||
{collection.private && (
|
||||
<>
|
||||
<SmallPadlockIcon color="currentColor" size={16} />{" "}
|
||||
</>
|
||||
)}
|
||||
{collection.name}
|
||||
{path.map(n => (
|
||||
{path.map((n) => (
|
||||
<React.Fragment key={n.id}>
|
||||
<SmallSlash />
|
||||
{n.title}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const isTemplate = document.isTemplate;
|
||||
const isDraft = !document.publishedAt && !isTemplate;
|
||||
const isNestedDocument = path.length > 1;
|
||||
const lastPath = path.length ? path[path.length - 1] : undefined;
|
||||
const menuPath = isNestedDocument ? path.slice(0, -1) : [];
|
||||
|
||||
return (
|
||||
<Wrapper justify="flex-start" align="center">
|
||||
{isTemplate && (
|
||||
<>
|
||||
<CollectionName to="/templates">
|
||||
<ShapesIcon color="currentColor" />
|
||||
|
||||
<span>Templates</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
)}
|
||||
{isDraft && (
|
||||
<>
|
||||
<CollectionName to="/drafts">
|
||||
<EditIcon color="currentColor" />
|
||||
|
||||
<span>Drafts</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
)}
|
||||
<CollectionName to={collectionUrl(collection.id)}>
|
||||
{collection.private ? (
|
||||
<PrivateCollectionIcon color={collection.color} expanded />
|
||||
) : (
|
||||
<CollectionIcon color={collection.color} expanded />
|
||||
)}{' '}
|
||||
<CollectionIcon collection={collection} expanded />
|
||||
|
||||
<span>{collection.name}</span>
|
||||
</CollectionName>
|
||||
{isNestedDocument && (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<Slash /> <BreadcrumbMenu label={<Overflow />} path={menuPath} />
|
||||
</React.Fragment>
|
||||
</>
|
||||
)}
|
||||
{lastPath && (
|
||||
<React.Fragment>
|
||||
<Slash />{' '}
|
||||
<>
|
||||
<Slash />{" "}
|
||||
<Crumb to={lastPath.url} title={lastPath.title}>
|
||||
{lastPath.title}
|
||||
</Crumb>
|
||||
</React.Fragment>
|
||||
</>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
});
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
width: 33.3%;
|
||||
display: none;
|
||||
|
||||
${breakpoint('tablet')`
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
`;
|
||||
|
||||
const SmallPadlockIcon = styled(PadlockIcon)`
|
||||
display: inline-block;
|
||||
vertical-align: sub;
|
||||
`;
|
||||
|
||||
const SmallSlash = styled(GoToIcon)`
|
||||
width: 15px;
|
||||
height: 10px;
|
||||
@@ -90,9 +120,9 @@ const SmallSlash = styled(GoToIcon)`
|
||||
opacity: 0.25;
|
||||
`;
|
||||
|
||||
const Slash = styled(GoToIcon)`
|
||||
export const Slash = styled(GoToIcon)`
|
||||
flex-shrink: 0;
|
||||
opacity: 0.25;
|
||||
fill: ${(props) => props.theme.divider};
|
||||
`;
|
||||
|
||||
const Overflow = styled(MoreIcon)`
|
||||
@@ -107,7 +137,7 @@ const Overflow = styled(MoreIcon)`
|
||||
`;
|
||||
|
||||
const Crumb = styled(Link)`
|
||||
color: ${props => props.theme.text};
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 15px;
|
||||
height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
@@ -122,11 +152,11 @@ const Crumb = styled(Link)`
|
||||
const CollectionName = styled(Link)`
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
color: ${props => props.theme.text};
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export default inject('collections')(Breadcrumb);
|
||||
export default inject("collections")(Breadcrumb);
|
||||
@@ -1,7 +1,7 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
|
||||
|
||||
type Props = {
|
||||
label: React.Node,
|
||||
@@ -14,7 +14,7 @@ export default class BreadcrumbMenu extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<DropdownMenu label={this.props.label} position="center">
|
||||
{path.map(item => (
|
||||
{path.map((item) => (
|
||||
<DropdownMenuItem as={Link} to={item.url} key={item.id}>
|
||||
{item.title}
|
||||
</DropdownMenuItem>
|
||||
+63
-27
@@ -1,16 +1,17 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { darken } from 'polished';
|
||||
import { ExpandedIcon } from 'outline-icons';
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import { darken, lighten } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const RealButton = styled.button`
|
||||
display: inline-block;
|
||||
display: ${(props) => (props.fullwidth ? "block" : "inline-block")};
|
||||
width: ${(props) => (props.fullwidth ? "100%" : "auto")};
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: ${props => props.theme.buttonBackground};
|
||||
color: ${props => props.theme.buttonText};
|
||||
background: ${(props) => props.theme.buttonBackground};
|
||||
color: ${(props) => props.theme.buttonText};
|
||||
box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 2px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
@@ -23,7 +24,7 @@ const RealButton = styled.button`
|
||||
user-select: none;
|
||||
|
||||
svg {
|
||||
fill: ${props => props.theme.buttonText};
|
||||
fill: ${(props) => props.iconColor || props.theme.buttonText};
|
||||
}
|
||||
|
||||
&::-moz-focus-inner {
|
||||
@@ -32,44 +33,68 @@ const RealButton = styled.button`
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${props => darken(0.05, props.theme.buttonBackground)};
|
||||
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
transition-duration: 0.05s;
|
||||
box-shadow: ${(props) => lighten(0.4, props.theme.buttonBackground)} 0px 0px
|
||||
0px 3px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
color: ${props => props.theme.white50};
|
||||
color: ${(props) => props.theme.white50};
|
||||
}
|
||||
|
||||
${props =>
|
||||
${(props) =>
|
||||
props.neutral &&
|
||||
`
|
||||
background: ${props.theme.buttonNeutralBackground};
|
||||
color: ${props.theme.buttonNeutralText};
|
||||
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px;
|
||||
border: 1px solid ${darken(0.1, props.theme.buttonNeutralBackground)};
|
||||
box-shadow: ${
|
||||
props.borderOnHover ? "none" : "rgba(0, 0, 0, 0.07) 0px 1px 2px"
|
||||
};
|
||||
border: 1px solid ${
|
||||
props.borderOnHover ? "transparent" : props.theme.buttonNeutralBorder
|
||||
};
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.buttonNeutralText};
|
||||
fill: ${props.iconColor || props.theme.buttonNeutralText};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
|
||||
border: 1px solid ${darken(0.15, props.theme.buttonNeutralBackground)};
|
||||
border: 1px solid ${props.theme.buttonNeutralBorder};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
transition-duration: 0.05s;
|
||||
border: 1px solid ${lighten(0.4, props.theme.buttonBackground)};
|
||||
box-shadow: ${lighten(0.4, props.theme.buttonBackground)} 0px 0px
|
||||
0px 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: ${props.theme.textTertiary};
|
||||
}
|
||||
`} ${props =>
|
||||
props.danger &&
|
||||
`
|
||||
`} ${(props) =>
|
||||
props.danger &&
|
||||
`
|
||||
background: ${props.theme.danger};
|
||||
color: ${props.theme.white};
|
||||
|
||||
&:hover {
|
||||
background: ${darken(0.05, props.theme.danger)};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
transition-duration: 0.05s;
|
||||
box-shadow: ${lighten(0.4, props.theme.danger)} 0px 0px
|
||||
0px 3px;
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -78,43 +103,50 @@ const Label = styled.span`
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
${props => props.hasIcon && 'padding-left: 4px;'};
|
||||
${(props) => props.hasIcon && "padding-left: 4px;"};
|
||||
`;
|
||||
|
||||
const Inner = styled.span`
|
||||
export const Inner = styled.span`
|
||||
display: flex;
|
||||
padding: 0 8px;
|
||||
padding-right: ${props => (props.disclosure ? 2 : 8)}px;
|
||||
line-height: ${props => (props.hasIcon ? 24 : 32)}px;
|
||||
padding-right: ${(props) => (props.disclosure ? 2 : 8)}px;
|
||||
line-height: ${(props) => (props.hasIcon ? 24 : 32)}px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
|
||||
${props => props.hasIcon && 'padding-left: 4px;'};
|
||||
${(props) => props.hasIcon && props.hasText && "padding-left: 4px;"};
|
||||
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
|
||||
`;
|
||||
|
||||
export type Props = {
|
||||
type?: string,
|
||||
value?: string,
|
||||
icon?: React.Node,
|
||||
iconColor?: string,
|
||||
className?: string,
|
||||
children?: React.Node,
|
||||
innerRef?: React.ElementRef<any>,
|
||||
disclosure?: boolean,
|
||||
fullwidth?: boolean,
|
||||
borderOnHover?: boolean,
|
||||
};
|
||||
|
||||
export default function Button({
|
||||
type = 'text',
|
||||
function Button({
|
||||
type = "text",
|
||||
icon,
|
||||
children,
|
||||
value,
|
||||
disclosure,
|
||||
innerRef,
|
||||
...rest
|
||||
}: Props) {
|
||||
const hasText = children !== undefined || value !== undefined;
|
||||
const hasIcon = icon !== undefined;
|
||||
|
||||
return (
|
||||
<RealButton type={type} {...rest}>
|
||||
<Inner hasIcon={hasIcon} disclosure={disclosure}>
|
||||
<RealButton type={type} ref={innerRef} {...rest}>
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
{hasIcon && icon}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
{disclosure && <ExpandedIcon />}
|
||||
@@ -122,3 +154,7 @@ export default function Button({
|
||||
</RealButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.forwardRef<Props, typeof Button>((props, ref) => (
|
||||
<Button {...props} innerRef={ref} />
|
||||
));
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
import Button, { Inner } from "./Button";
|
||||
|
||||
const ButtonLarge = styled(Button)`
|
||||
height: 40px;
|
||||
|
||||
${Inner} {
|
||||
padding: 4px 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default ButtonLarge;
|
||||
@@ -1,7 +1,7 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import breakpoint from 'styled-components-breakpoint';
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
type Props = {
|
||||
children?: React.Node,
|
||||
@@ -9,9 +9,10 @@ type Props = {
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
padding: 60px 20px;
|
||||
|
||||
${breakpoint('tablet')`
|
||||
${breakpoint("tablet")`
|
||||
padding: 60px;
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import HelpText from 'components/HelpText';
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import HelpText from "components/HelpText";
|
||||
import VisuallyHidden from "components/VisuallyHidden";
|
||||
|
||||
export type Props = {
|
||||
checked?: boolean,
|
||||
label?: string,
|
||||
labelHidden?: boolean,
|
||||
className?: string,
|
||||
note?: string,
|
||||
short?: boolean,
|
||||
small?: boolean,
|
||||
};
|
||||
|
||||
const LabelText = styled.span`
|
||||
font-weight: 500;
|
||||
margin-left: ${props => (props.small ? '6px' : '10px')};
|
||||
${props => (props.small ? `color: ${props.theme.textSecondary}` : '')};
|
||||
margin-left: ${(props) => (props.small ? "6px" : "10px")};
|
||||
${(props) => (props.small ? `color: ${props.theme.textSecondary}` : "")};
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
padding-bottom: 8px;
|
||||
${props => (props.small ? 'font-size: 14px' : '')};
|
||||
${(props) => (props.small ? "font-size: 14px" : "")};
|
||||
`;
|
||||
|
||||
const Label = styled.label`
|
||||
@@ -30,21 +33,29 @@ const Label = styled.label`
|
||||
|
||||
export default function Checkbox({
|
||||
label,
|
||||
labelHidden,
|
||||
note,
|
||||
className,
|
||||
small,
|
||||
short,
|
||||
...rest
|
||||
}: Props) {
|
||||
const wrappedLabel = <LabelText small={small}>{label}</LabelText>;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<Wrapper small={small}>
|
||||
<Label>
|
||||
<input type="checkbox" {...rest} />
|
||||
{label && <LabelText small={small}>{label}</LabelText>}
|
||||
{label &&
|
||||
(labelHidden ? (
|
||||
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
</Label>
|
||||
{note && <HelpText small>{note}</HelpText>}
|
||||
</Wrapper>
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
import styled from "styled-components";
|
||||
|
||||
const ClickablePadding = styled.div`
|
||||
min-height: 10em;
|
||||
cursor: ${({ onClick }) => (onClick ? 'text' : 'default')};
|
||||
cursor: ${({ onClick }) => (onClick ? "text" : "default")};
|
||||
${({ grow }) => grow && `flex-grow: 100;`};
|
||||
`;
|
||||
|
||||
|
||||
+49
-138
@@ -1,165 +1,76 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import { filter } from 'lodash';
|
||||
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
|
||||
import styled from 'styled-components';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import Avatar from 'components/Avatar';
|
||||
import Tooltip from 'components/Tooltip';
|
||||
import Document from 'models/Document';
|
||||
import UserProfile from 'scenes/UserProfile';
|
||||
import ViewsStore from 'stores/ViewsStore';
|
||||
import { sortBy, keyBy } from "lodash";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { MAX_AVATAR_DISPLAY } from "shared/constants";
|
||||
|
||||
const MAX_DISPLAY = 6;
|
||||
import DocumentPresenceStore from "stores/DocumentPresenceStore";
|
||||
import ViewsStore from "stores/ViewsStore";
|
||||
import Document from "models/Document";
|
||||
import { AvatarWithPresence } from "components/Avatar";
|
||||
import Facepile from "components/Facepile";
|
||||
|
||||
type Props = {
|
||||
views: ViewsStore,
|
||||
presence: DocumentPresenceStore,
|
||||
document: Document,
|
||||
currentUserId: string,
|
||||
};
|
||||
|
||||
@observer
|
||||
class Collaborators extends React.Component<Props> {
|
||||
@observable openProfileId: ?string;
|
||||
|
||||
componentDidMount() {
|
||||
this.props.views.fetchPage({ documentId: this.props.document.id });
|
||||
}
|
||||
|
||||
handleOpenProfile = (userId: string) => {
|
||||
this.openProfileId = userId;
|
||||
};
|
||||
|
||||
handleCloseProfile = () => {
|
||||
this.openProfileId = undefined;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { document, views } = this.props;
|
||||
const { document, presence, views, currentUserId } = this.props;
|
||||
let documentPresence = presence.get(document.id);
|
||||
documentPresence = documentPresence
|
||||
? Array.from(documentPresence.values())
|
||||
: [];
|
||||
|
||||
const documentViews = views.inDocument(document.id);
|
||||
const { createdAt, updatedAt, updatedBy, collaborators } = document;
|
||||
|
||||
// filter to only show views that haven't collaborated
|
||||
const collaboratorIds = collaborators.map(user => user.id);
|
||||
const viewersNotCollaborators = filter(
|
||||
documentViews,
|
||||
view => !collaboratorIds.includes(view.user.id)
|
||||
const presentIds = documentPresence.map((p) => p.userId);
|
||||
const editingIds = documentPresence
|
||||
.filter((p) => p.isEditing)
|
||||
.map((p) => p.userId);
|
||||
|
||||
// ensure currently present via websocket are always ordered first
|
||||
const mostRecentViewers = sortBy(
|
||||
documentViews.slice(0, MAX_AVATAR_DISPLAY),
|
||||
(view) => {
|
||||
return presentIds.includes(view.user.id);
|
||||
}
|
||||
);
|
||||
|
||||
// only show the most recent viewers, the rest can overflow
|
||||
const mostRecentViewers = viewersNotCollaborators.slice(
|
||||
0,
|
||||
MAX_DISPLAY - collaborators.length
|
||||
);
|
||||
|
||||
// if there are too many to display then add a (+X) to the UI
|
||||
const overflow = viewersNotCollaborators.length - mostRecentViewers.length;
|
||||
const viewersKeyedByUserId = keyBy(mostRecentViewers, (v) => v.user.id);
|
||||
const overflow = documentViews.length - mostRecentViewers.length;
|
||||
|
||||
return (
|
||||
<Avatars>
|
||||
{overflow > 0 && <More>+{overflow}</More>}
|
||||
{mostRecentViewers.map(({ lastViewedAt, user }) => (
|
||||
<React.Fragment key={user.id}>
|
||||
<AvatarPile
|
||||
tooltip={
|
||||
<TooltipCentered>
|
||||
<strong>{user.name}</strong>
|
||||
<br />
|
||||
viewed {distanceInWordsToNow(new Date(lastViewedAt))} ago
|
||||
</TooltipCentered>
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
<Viewer>
|
||||
<Avatar
|
||||
src={user.avatarUrl}
|
||||
onClick={() => this.handleOpenProfile(user.id)}
|
||||
size={32}
|
||||
/>
|
||||
</Viewer>
|
||||
</AvatarPile>
|
||||
<UserProfile
|
||||
<Facepile
|
||||
users={mostRecentViewers.map((v) => v.user)}
|
||||
overflow={overflow}
|
||||
renderAvatar={(user) => {
|
||||
const isPresent = presentIds.includes(user.id);
|
||||
const isEditing = editingIds.includes(user.id);
|
||||
const { lastViewedAt } = viewersKeyedByUserId[user.id];
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
key={user.id}
|
||||
user={user}
|
||||
isOpen={this.openProfileId === user.id}
|
||||
onRequestClose={this.handleCloseProfile}
|
||||
lastViewedAt={lastViewedAt}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
isCurrentUser={currentUserId === user.id}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{collaborators.map(user => (
|
||||
<React.Fragment key={user.id}>
|
||||
<AvatarPile
|
||||
tooltip={
|
||||
<TooltipCentered>
|
||||
<strong>{user.name}</strong>
|
||||
<br />
|
||||
{createdAt === updatedAt ? 'published' : 'updated'}{' '}
|
||||
{updatedBy.id === user.id &&
|
||||
`${distanceInWordsToNow(new Date(updatedAt))} ago`}
|
||||
</TooltipCentered>
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
<Collaborator>
|
||||
<Avatar
|
||||
src={user.avatarUrl}
|
||||
onClick={() => this.handleOpenProfile(user.id)}
|
||||
size={32}
|
||||
/>
|
||||
</Collaborator>
|
||||
</AvatarPile>
|
||||
<UserProfile
|
||||
user={user}
|
||||
isOpen={this.openProfileId === user.id}
|
||||
onRequestClose={this.handleCloseProfile}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Avatars>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const TooltipCentered = styled.div`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const AvatarPile = styled(Tooltip)`
|
||||
margin-right: -8px;
|
||||
|
||||
&:first-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const Viewer = styled.div`
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
opacity: 0.75;
|
||||
`;
|
||||
|
||||
const Collaborator = styled.div`
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
`;
|
||||
|
||||
const More = styled.div`
|
||||
min-width: 30px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
background: ${props => props.theme.slate};
|
||||
color: ${props => props.theme.text};
|
||||
border: 2px solid ${props => props.theme.background};
|
||||
text-align: center;
|
||||
line-height: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const Avatars = styled(Flex)`
|
||||
align-items: center;
|
||||
flex-direction: row-reverse;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export default inject('views')(Collaborators);
|
||||
export default inject("views", "presence")(Collaborators);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// @flow
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { PrivateCollectionIcon, CollectionIcon } from "outline-icons";
|
||||
import { getLuminance } from "polished";
|
||||
import * as React from "react";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Collection from "models/Collection";
|
||||
import { icons } from "components/IconPicker";
|
||||
|
||||
type Props = {
|
||||
collection: Collection,
|
||||
expanded?: boolean,
|
||||
size?: number,
|
||||
ui: UiStore,
|
||||
};
|
||||
|
||||
function ResolvedCollectionIcon({ collection, expanded, size, ui }: Props) {
|
||||
// If the chosen icon color is very dark then we invert it in dark mode
|
||||
// otherwise it will be impossible to see against the dark background.
|
||||
const color =
|
||||
ui.resolvedTheme === "dark"
|
||||
? getLuminance(collection.color) > 0.12
|
||||
? collection.color
|
||||
: "currentColor"
|
||||
: collection.color;
|
||||
|
||||
if (collection.icon && collection.icon !== "collection") {
|
||||
try {
|
||||
const Component = icons[collection.icon].component;
|
||||
return <Component color={color} size={size} />;
|
||||
} catch (error) {
|
||||
console.warn("Failed to render custom icon " + collection.icon);
|
||||
}
|
||||
}
|
||||
|
||||
if (collection.private) {
|
||||
return (
|
||||
<PrivateCollectionIcon color={color} expanded={expanded} size={size} />
|
||||
);
|
||||
}
|
||||
|
||||
return <CollectionIcon color={color} expanded={expanded} size={size} />;
|
||||
}
|
||||
|
||||
export default inject("ui")(observer(ResolvedCollectionIcon));
|
||||
@@ -1,189 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observable, computed, action } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import styled from 'styled-components';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { LabelText, Outline } from 'components/Input';
|
||||
import { validateColorHex } from 'shared/utils/color';
|
||||
|
||||
const colors = [
|
||||
'#4E5C6E',
|
||||
'#19B7FF',
|
||||
'#7F6BFF',
|
||||
'#FC7419',
|
||||
'#FC2D2D',
|
||||
'#FFE100',
|
||||
'#14CF9F',
|
||||
'#EE84F0',
|
||||
'#2F362F',
|
||||
];
|
||||
|
||||
type Props = {
|
||||
onSelect: (color: string) => void,
|
||||
value?: string,
|
||||
};
|
||||
|
||||
@observer
|
||||
class ColorPicker extends React.Component<Props> {
|
||||
@observable selectedColor: string = colors[0];
|
||||
@observable customColorValue: string = '';
|
||||
@observable customColorSelected: boolean;
|
||||
|
||||
componentWillMount() {
|
||||
const { value } = this.props;
|
||||
if (value && colors.includes(value)) {
|
||||
this.selectedColor = value;
|
||||
} else if (value) {
|
||||
this.customColorSelected = true;
|
||||
this.customColorValue = value.replace('#', '');
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fireCallback();
|
||||
}
|
||||
|
||||
fireCallback = () => {
|
||||
this.props.onSelect(
|
||||
this.customColorSelected ? this.customColor : this.selectedColor
|
||||
);
|
||||
};
|
||||
|
||||
@computed
|
||||
get customColor(): string {
|
||||
return this.customColorValue &&
|
||||
validateColorHex(`#${this.customColorValue}`)
|
||||
? `#${this.customColorValue}`
|
||||
: colors[0];
|
||||
}
|
||||
|
||||
@action
|
||||
setColor = (color: string) => {
|
||||
this.selectedColor = color;
|
||||
this.customColorSelected = false;
|
||||
this.fireCallback();
|
||||
};
|
||||
|
||||
@action
|
||||
focusOnCustomColor = (event: SyntheticEvent<*>) => {
|
||||
this.selectedColor = '';
|
||||
this.customColorSelected = true;
|
||||
this.fireCallback();
|
||||
};
|
||||
|
||||
@action
|
||||
setCustomColor = (event: SyntheticEvent<*>) => {
|
||||
let target = event.target;
|
||||
if (target instanceof HTMLInputElement) {
|
||||
const color = target.value;
|
||||
this.customColorValue = color.replace('#', '');
|
||||
this.fireCallback();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Flex column>
|
||||
<LabelText>Color</LabelText>
|
||||
<StyledOutline justify="space-between">
|
||||
<Flex>
|
||||
{colors.map(color => (
|
||||
<Swatch
|
||||
key={color}
|
||||
color={color}
|
||||
active={
|
||||
color === this.selectedColor && !this.customColorSelected
|
||||
}
|
||||
onClick={() => this.setColor(color)}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
<Flex justify="flex-end">
|
||||
<strong>Custom color:</strong>
|
||||
<HexHash>#</HexHash>
|
||||
<CustomColorInput
|
||||
placeholder="FFFFFF"
|
||||
onFocus={this.focusOnCustomColor}
|
||||
onChange={this.setCustomColor}
|
||||
value={this.customColorValue}
|
||||
maxLength={6}
|
||||
/>
|
||||
<Swatch
|
||||
color={this.customColor}
|
||||
active={this.customColorSelected}
|
||||
/>
|
||||
</Flex>
|
||||
</StyledOutline>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type SwatchProps = {
|
||||
onClick?: () => void,
|
||||
color?: string,
|
||||
active?: boolean,
|
||||
};
|
||||
|
||||
const Swatch = ({ onClick, ...props }: SwatchProps) => (
|
||||
<SwatchOutset onClick={onClick} {...props}>
|
||||
<SwatchInset {...props} />
|
||||
</SwatchOutset>
|
||||
);
|
||||
|
||||
const SwatchOutset = styled(Flex)`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 5px;
|
||||
border: 2px solid ${({ active, color }) => (active ? color : 'transparent')};
|
||||
border-radius: 2px;
|
||||
background: ${({ color }) => color};
|
||||
${({ onClick }) => onClick && `cursor: pointer;`} &:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const SwatchInset = styled(Flex)`
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid ${({ active, color }) => (active ? 'white' : 'transparent')};
|
||||
border-radius: 2px;
|
||||
background: ${({ color }) => color};
|
||||
`;
|
||||
|
||||
const StyledOutline = styled(Outline)`
|
||||
padding: 5px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
`;
|
||||
|
||||
const HexHash = styled.div`
|
||||
margin-left: 12px;
|
||||
padding-bottom: 0;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const CustomColorInput = styled.input`
|
||||
border: 0;
|
||||
flex: 1;
|
||||
width: 65px;
|
||||
margin-right: 12px;
|
||||
padding-bottom: 0;
|
||||
outline: none;
|
||||
background: none;
|
||||
font-family: ${props => props.theme.monospaceFontFamily};
|
||||
font-weight: 500;
|
||||
|
||||
&::placeholder {
|
||||
color: ${props => props.theme.slate};
|
||||
font-family: ${props => props.theme.monospaceFontFamily};
|
||||
font-weight: 500;
|
||||
}
|
||||
`;
|
||||
|
||||
export default ColorPicker;
|
||||
@@ -1,25 +1,25 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import copy from "copy-to-clipboard";
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
text: string,
|
||||
children?: React.Node,
|
||||
onClick?: () => *,
|
||||
onCopy: () => *,
|
||||
onClick?: () => void,
|
||||
onCopy: () => void,
|
||||
};
|
||||
|
||||
class CopyToClipboard extends React.PureComponent<Props> {
|
||||
onClick = (ev: SyntheticEvent<*>) => {
|
||||
onClick = (ev: SyntheticEvent<>) => {
|
||||
const { text, onCopy, children } = this.props;
|
||||
const elem = React.Children.only(children);
|
||||
copy(text, {
|
||||
debug: !!__DEV__,
|
||||
debug: process.env.NODE_ENV !== "production",
|
||||
});
|
||||
|
||||
if (onCopy) onCopy();
|
||||
|
||||
if (elem && elem.props && typeof elem.props.onClick === 'function') {
|
||||
if (elem && elem.props && typeof elem.props.onClick === "function") {
|
||||
elem.props.onClick(ev);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
delay?: number,
|
||||
children: React.Node,
|
||||
};
|
||||
|
||||
export default function DelayedMount({ delay = 250, children }: Props) {
|
||||
const [isShowing, setShowing] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeout = setTimeout(() => setShowing(true), delay);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isShowing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -1,26 +1,26 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observable, action } from 'mobx';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import styled from 'styled-components';
|
||||
import Waypoint from 'react-waypoint';
|
||||
import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
|
||||
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
|
||||
import { observable, action } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { type RouterHistory, type Match } from "react-router-dom";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { DEFAULT_PAGINATION_LIMIT } from 'stores/BaseStore';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
import RevisionsStore from 'stores/RevisionsStore';
|
||||
import Document from 'models/Document';
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import RevisionsStore from "stores/RevisionsStore";
|
||||
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { ListPlaceholder } from 'components/LoadingPlaceholder';
|
||||
import Revision from './components/Revision';
|
||||
import { documentHistoryUrl } from 'utils/routeHelpers';
|
||||
import Flex from "components/Flex";
|
||||
import { ListPlaceholder } from "components/LoadingPlaceholder";
|
||||
import Revision from "./components/Revision";
|
||||
import { documentHistoryUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
match: Object,
|
||||
match: Match,
|
||||
documents: DocumentsStore,
|
||||
revisions: RevisionsStore,
|
||||
history: Object,
|
||||
history: RouterHistory,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -29,29 +29,12 @@ class DocumentHistory extends React.Component<Props> {
|
||||
@observable isFetching: boolean = false;
|
||||
@observable offset: number = 0;
|
||||
@observable allowLoadMore: boolean = true;
|
||||
@observable document: Document;
|
||||
|
||||
constructor(props) {
|
||||
super();
|
||||
this.document = props.documents.getByUrl(props.match.params.documentSlug);
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
await this.loadMoreResults();
|
||||
this.selectFirstRevision();
|
||||
}
|
||||
|
||||
async componentWillReceiveProps(nextProps) {
|
||||
const document = nextProps.documents.getByUrl(
|
||||
nextProps.match.params.documentSlug
|
||||
);
|
||||
if (!this.document && document) {
|
||||
this.document = document;
|
||||
await this.loadMoreResults();
|
||||
this.selectFirstRevision();
|
||||
}
|
||||
}
|
||||
|
||||
fetchResults = async () => {
|
||||
this.isFetching = true;
|
||||
|
||||
@@ -59,7 +42,7 @@ class DocumentHistory extends React.Component<Props> {
|
||||
const results = await this.props.revisions.fetchPage({
|
||||
limit,
|
||||
offset: this.offset,
|
||||
id: this.document.id,
|
||||
documentId: this.props.match.params.documentSlug,
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -77,8 +60,13 @@ class DocumentHistory extends React.Component<Props> {
|
||||
|
||||
selectFirstRevision = () => {
|
||||
if (this.revisions.length) {
|
||||
const document = this.props.documents.getByUrl(
|
||||
this.props.match.params.documentSlug
|
||||
);
|
||||
if (!document) return;
|
||||
|
||||
this.props.history.replace(
|
||||
documentHistoryUrl(this.document, this.revisions[0].id)
|
||||
documentHistoryUrl(document, this.revisions[0].id)
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -86,43 +74,52 @@ class DocumentHistory extends React.Component<Props> {
|
||||
@action
|
||||
loadMoreResults = async () => {
|
||||
// Don't paginate if there aren't more results or we’re in the middle of fetching
|
||||
if (!this.allowLoadMore || this.isFetching || !this.document) return;
|
||||
if (!this.allowLoadMore || this.isFetching) return;
|
||||
await this.fetchResults();
|
||||
};
|
||||
|
||||
get revisions() {
|
||||
if (!this.document) return [];
|
||||
return this.props.revisions.getDocumentRevisions(this.document.id);
|
||||
const document = this.props.documents.getByUrl(
|
||||
this.props.match.params.documentSlug
|
||||
);
|
||||
if (!document) return [];
|
||||
return this.props.revisions.getDocumentRevisions(document.id);
|
||||
}
|
||||
|
||||
render() {
|
||||
const showLoading = !this.isLoaded && this.isFetching;
|
||||
const document = this.props.documents.getByUrl(
|
||||
this.props.match.params.documentSlug
|
||||
);
|
||||
const showLoading = (!this.isLoaded && this.isFetching) || !document;
|
||||
|
||||
return (
|
||||
<Wrapper column>
|
||||
{showLoading ? (
|
||||
<Loading>
|
||||
<ListPlaceholder count={5} />
|
||||
</Loading>
|
||||
) : (
|
||||
<ArrowKeyNavigation
|
||||
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||
defaultActiveChildIndex={0}
|
||||
>
|
||||
{this.revisions.map((revision, index) => (
|
||||
<Revision
|
||||
key={revision.id}
|
||||
revision={revision}
|
||||
document={this.document}
|
||||
showMenu={index !== 0}
|
||||
/>
|
||||
))}
|
||||
</ArrowKeyNavigation>
|
||||
)}
|
||||
{this.allowLoadMore && (
|
||||
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
|
||||
)}
|
||||
</Wrapper>
|
||||
<Sidebar>
|
||||
<Wrapper column>
|
||||
{showLoading ? (
|
||||
<Loading>
|
||||
<ListPlaceholder count={5} />
|
||||
</Loading>
|
||||
) : (
|
||||
<ArrowKeyNavigation
|
||||
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||
defaultActiveChildIndex={0}
|
||||
>
|
||||
{this.revisions.map((revision, index) => (
|
||||
<Revision
|
||||
key={revision.id}
|
||||
revision={revision}
|
||||
document={document}
|
||||
showMenu={index !== 0}
|
||||
selected={this.props.match.params.revisionId === revision.id}
|
||||
/>
|
||||
))}
|
||||
</ArrowKeyNavigation>
|
||||
)}
|
||||
{this.allowLoadMore && (
|
||||
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
|
||||
)}
|
||||
</Wrapper>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -132,10 +129,21 @@ const Loading = styled.div`
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
min-width: ${props => props.theme.sidebarWidth};
|
||||
border-left: 1px solid ${props => props.theme.divider};
|
||||
overflow: scroll;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
min-width: ${(props) => props.theme.sidebarWidth};
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: none;
|
||||
`;
|
||||
|
||||
export default inject('documents', 'revisions')(DocumentHistory);
|
||||
const Sidebar = styled(Flex)`
|
||||
background: ${(props) => props.theme.background};
|
||||
min-width: ${(props) => props.theme.sidebarWidth};
|
||||
border-left: 1px solid ${(props) => props.theme.divider};
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
export default inject("documents", "revisions")(DocumentHistory);
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import styled, { withTheme } from 'styled-components';
|
||||
import format from 'date-fns/format';
|
||||
import { MoreIcon } from 'outline-icons';
|
||||
import format from "date-fns/format";
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
|
||||
import Flex from 'shared/components/Flex';
|
||||
import Time from 'shared/components/Time';
|
||||
import Avatar from 'components/Avatar';
|
||||
import RevisionMenu from 'menus/RevisionMenu';
|
||||
import Document from "models/Document";
|
||||
import Revision from "models/Revision";
|
||||
import Avatar from "components/Avatar";
|
||||
import Flex from "components/Flex";
|
||||
import Time from "components/Time";
|
||||
import RevisionMenu from "menus/RevisionMenu";
|
||||
|
||||
import { documentHistoryUrl } from 'utils/routeHelpers';
|
||||
import { documentHistoryUrl } from "utils/routeHelpers";
|
||||
|
||||
class Revision extends React.Component<*> {
|
||||
type Props = {
|
||||
theme: Object,
|
||||
showMenu: boolean,
|
||||
selected: boolean,
|
||||
document: Document,
|
||||
revision: Revision,
|
||||
};
|
||||
|
||||
class RevisionListItem extends React.Component<Props> {
|
||||
render() {
|
||||
const { revision, document, showMenu, theme } = this.props;
|
||||
const { revision, document, showMenu, selected, theme } = this.props;
|
||||
|
||||
return (
|
||||
<StyledNavLink
|
||||
@@ -22,19 +32,21 @@ class Revision extends React.Component<*> {
|
||||
activeStyle={{ background: theme.primary, color: theme.white }}
|
||||
>
|
||||
<Author>
|
||||
<StyledAvatar src={revision.createdBy.avatarUrl} />{' '}
|
||||
<StyledAvatar src={revision.createdBy.avatarUrl} />{" "}
|
||||
{revision.createdBy.name}
|
||||
</Author>
|
||||
<Meta>
|
||||
<Time dateTime={revision.createdAt}>
|
||||
{format(revision.createdAt, 'MMMM Do, YYYY h:mm a')}
|
||||
{format(revision.createdAt, "MMMM Do, YYYY h:mm a")}
|
||||
</Time>
|
||||
</Meta>
|
||||
{showMenu && (
|
||||
<StyledRevisionMenu
|
||||
document={document}
|
||||
revision={revision}
|
||||
label={<MoreIcon color={theme.white} />}
|
||||
label={
|
||||
<MoreIcon color={selected ? theme.white : theme.textTertiary} />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</StyledNavLink>
|
||||
@@ -50,11 +62,11 @@ const StyledAvatar = styled(Avatar)`
|
||||
const StyledRevisionMenu = styled(RevisionMenu)`
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 16px;
|
||||
top: 20px;
|
||||
`;
|
||||
|
||||
const StyledNavLink = styled(NavLink)`
|
||||
color: ${props => props.theme.text};
|
||||
color: ${(props) => props.theme.text};
|
||||
display: block;
|
||||
padding: 8px 16px;
|
||||
font-size: 15px;
|
||||
@@ -74,4 +86,4 @@ const Meta = styled.p`
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
export default withTheme(Revision);
|
||||
export default withTheme(RevisionListItem);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// @flow
|
||||
import DocumentHistory from './DocumentHistory';
|
||||
import DocumentHistory from "./DocumentHistory";
|
||||
export default DocumentHistory;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import Document from 'models/Document';
|
||||
import DocumentPreview from 'components/DocumentPreview';
|
||||
import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
|
||||
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
|
||||
import * as React from "react";
|
||||
import Document from "models/Document";
|
||||
import DocumentPreview from "components/DocumentPreview";
|
||||
|
||||
type Props = {
|
||||
documents: Document[],
|
||||
@@ -17,7 +17,7 @@ export default function DocumentList({ limit, documents, ...rest }: Props) {
|
||||
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||
defaultActiveChildIndex={0}
|
||||
>
|
||||
{items.map(document => (
|
||||
{items.map((document) => (
|
||||
<DocumentPreview key={document.id} document={document} {...rest} />
|
||||
))}
|
||||
</ArrowKeyNavigation>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// @flow
|
||||
import { inject, observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import Document from "models/Document";
|
||||
import Breadcrumb from "components/Breadcrumb";
|
||||
import Flex from "components/Flex";
|
||||
import Time from "components/Time";
|
||||
|
||||
const Container = styled(Flex)`
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Modified = styled.span`
|
||||
color: ${(props) =>
|
||||
props.highlight ? props.theme.text : props.theme.textTertiary};
|
||||
font-weight: ${(props) => (props.highlight ? "600" : "400")};
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
collections: CollectionsStore,
|
||||
auth: AuthStore,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
document: Document,
|
||||
children: React.Node,
|
||||
to?: string,
|
||||
};
|
||||
|
||||
function DocumentMeta({
|
||||
auth,
|
||||
collections,
|
||||
showPublished,
|
||||
showCollection,
|
||||
document,
|
||||
children,
|
||||
to,
|
||||
...rest
|
||||
}: Props) {
|
||||
const {
|
||||
modifiedSinceViewed,
|
||||
updatedAt,
|
||||
updatedBy,
|
||||
createdAt,
|
||||
publishedAt,
|
||||
archivedAt,
|
||||
deletedAt,
|
||||
isDraft,
|
||||
} = document;
|
||||
|
||||
// Prevent meta information from displaying if updatedBy is not available.
|
||||
// Currently the situation where this is true is rendering share links.
|
||||
if (!updatedBy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let content;
|
||||
|
||||
if (deletedAt) {
|
||||
content = (
|
||||
<span>
|
||||
deleted <Time dateTime={deletedAt} /> ago
|
||||
</span>
|
||||
);
|
||||
} else if (archivedAt) {
|
||||
content = (
|
||||
<span>
|
||||
archived <Time dateTime={archivedAt} /> ago
|
||||
</span>
|
||||
);
|
||||
} else if (createdAt === updatedAt) {
|
||||
content = (
|
||||
<span>
|
||||
created <Time dateTime={updatedAt} /> ago
|
||||
</span>
|
||||
);
|
||||
} else if (publishedAt && (publishedAt === updatedAt || showPublished)) {
|
||||
content = (
|
||||
<span>
|
||||
published <Time dateTime={publishedAt} /> ago
|
||||
</span>
|
||||
);
|
||||
} else if (isDraft) {
|
||||
content = (
|
||||
<span>
|
||||
saved <Time dateTime={updatedAt} /> ago
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<Modified highlight={modifiedSinceViewed}>
|
||||
updated <Time dateTime={updatedAt} /> ago
|
||||
</Modified>
|
||||
);
|
||||
}
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
const updatedByMe = auth.user && auth.user.id === updatedBy.id;
|
||||
|
||||
return (
|
||||
<Container align="center" {...rest}>
|
||||
{updatedByMe ? "You" : updatedBy.name}
|
||||
{to ? <Link to={to}>{content}</Link> : content}
|
||||
{showCollection && collection && (
|
||||
<span>
|
||||
in
|
||||
<strong>
|
||||
<Breadcrumb document={document} onlyText />
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default inject("collections", "auth")(observer(DocumentMeta));
|
||||
@@ -0,0 +1,48 @@
|
||||
// @flow
|
||||
import { inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import ViewsStore from "stores/ViewsStore";
|
||||
import Document from "models/Document";
|
||||
import DocumentMeta from "components/DocumentMeta";
|
||||
|
||||
type Props = {|
|
||||
views: ViewsStore,
|
||||
document: Document,
|
||||
isDraft: boolean,
|
||||
to?: string,
|
||||
|};
|
||||
|
||||
function DocumentMetaWithViews({ views, to, isDraft, document }: Props) {
|
||||
const totalViews = views.countForDocument(document.id);
|
||||
|
||||
return (
|
||||
<Meta document={document} to={to}>
|
||||
{totalViews && !isDraft ? (
|
||||
<>
|
||||
· Viewed{" "}
|
||||
{totalViews === 1 ? "once" : `${totalViews} times`}
|
||||
</>
|
||||
) : null}
|
||||
</Meta>
|
||||
);
|
||||
}
|
||||
|
||||
const Meta = styled(DocumentMeta)`
|
||||
margin: -12px 0 2em 0;
|
||||
font-size: 14px;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default inject("views")(DocumentMetaWithViews);
|
||||
@@ -1,30 +1,149 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { StarredIcon } from 'outline-icons';
|
||||
import styled, { withTheme } from 'styled-components';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import Highlight from 'components/Highlight';
|
||||
import PublishingInfo from 'components/PublishingInfo';
|
||||
import DocumentMenu from 'menus/DocumentMenu';
|
||||
import Document from 'models/Document';
|
||||
import { observer } from "mobx-react";
|
||||
import { StarredIcon, PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Link, withRouter, type RouterHistory } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import Badge from "components/Badge";
|
||||
import Button from "components/Button";
|
||||
import DocumentMeta from "components/DocumentMeta";
|
||||
import Flex from "components/Flex";
|
||||
import Highlight from "components/Highlight";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
document: Document,
|
||||
highlight?: ?string,
|
||||
context?: ?string,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
ref?: *,
|
||||
showDraft?: boolean,
|
||||
showTemplate?: boolean,
|
||||
};
|
||||
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
|
||||
@observer
|
||||
class DocumentPreview extends React.Component<Props> {
|
||||
handleStar = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.document.star();
|
||||
};
|
||||
|
||||
handleUnstar = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.document.unstar();
|
||||
};
|
||||
|
||||
replaceResultMarks = (tag: string) => {
|
||||
// don't use SEARCH_RESULT_REGEX here as it causes
|
||||
// an infinite loop to trigger a regex inside it's own callback
|
||||
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
|
||||
};
|
||||
|
||||
handleNewFromTemplate = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const { document } = this.props;
|
||||
|
||||
this.props.history.push(
|
||||
newDocumentUrl(document.collectionId, {
|
||||
templateId: document.id,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
document,
|
||||
showCollection,
|
||||
showPublished,
|
||||
showPin,
|
||||
showDraft = true,
|
||||
showTemplate,
|
||||
highlight,
|
||||
context,
|
||||
} = this.props;
|
||||
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
to={{
|
||||
pathname: document.url,
|
||||
state: { title: document.titleWithDefault },
|
||||
}}
|
||||
>
|
||||
<Heading>
|
||||
<Title text={document.titleWithDefault} highlight={highlight} />
|
||||
{!document.isDraft &&
|
||||
!document.isArchived &&
|
||||
!document.isTemplate && (
|
||||
<Actions>
|
||||
{document.isStarred ? (
|
||||
<StyledStar onClick={this.handleUnstar} solid />
|
||||
) : (
|
||||
<StyledStar onClick={this.handleStar} />
|
||||
)}
|
||||
</Actions>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip tooltip="Only visible to you" delay={500} placement="top">
|
||||
<Badge>Draft</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>Template</Badge>
|
||||
)}
|
||||
<SecondaryActions>
|
||||
{document.isTemplate &&
|
||||
!document.isArchived &&
|
||||
!document.isDeleted && (
|
||||
<Button
|
||||
onClick={this.handleNewFromTemplate}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
New doc
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DocumentMenu document={document} showPin={showPin} />
|
||||
</SecondaryActions>
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={this.replaceResultMarks}
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
/>
|
||||
</DocumentLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const StyledStar = withTheme(styled(({ solid, theme, ...props }) => (
|
||||
<StarredIcon color={theme.text} {...props} />
|
||||
))`
|
||||
flex-shrink: 0;
|
||||
opacity: ${props => (props.solid ? '1 !important' : 0)};
|
||||
opacity: ${(props) => (props.solid ? "1 !important" : 0)};
|
||||
transition: all 100ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
@@ -35,7 +154,8 @@ const StyledStar = withTheme(styled(({ solid, theme, ...props }) => (
|
||||
}
|
||||
`);
|
||||
|
||||
const StyledDocumentMenu = styled(DocumentMenu)`
|
||||
const SecondaryActions = styled(Flex)`
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
@@ -49,20 +169,25 @@ const DocumentLink = styled(Link)`
|
||||
border-radius: 8px;
|
||||
max-height: 50vh;
|
||||
min-width: 100%;
|
||||
max-width: calc(100vw - 40px);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
${StyledDocumentMenu} {
|
||||
${SecondaryActions} {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background: ${props => props.theme.listItemHoverBackground};
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
outline: none;
|
||||
|
||||
${StyledStar}, ${StyledDocumentMenu} {
|
||||
${SecondaryActions} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${StyledStar} {
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
@@ -80,8 +205,9 @@ const Heading = styled.h3`
|
||||
margin-bottom: 0.25em;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
`;
|
||||
|
||||
const Actions = styled(Flex)`
|
||||
@@ -97,86 +223,10 @@ const Title = styled(Highlight)`
|
||||
|
||||
const ResultContext = styled(Highlight)`
|
||||
display: block;
|
||||
color: ${props => props.theme.textTertiary};
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-size: 14px;
|
||||
margin-top: -0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
`;
|
||||
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
|
||||
@observer
|
||||
class DocumentPreview extends React.Component<Props> {
|
||||
star = (ev: SyntheticEvent<*>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.document.star();
|
||||
};
|
||||
|
||||
unstar = (ev: SyntheticEvent<*>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.document.unstar();
|
||||
};
|
||||
|
||||
replaceResultMarks = (tag: string) => {
|
||||
// don't use SEARCH_RESULT_REGEX here as it causes
|
||||
// an infinite loop to trigger a regex inside it's own callback
|
||||
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, '$1');
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
document,
|
||||
showCollection,
|
||||
showPublished,
|
||||
showPin,
|
||||
highlight,
|
||||
context,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().match(highlight.toLowerCase());
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
to={{
|
||||
pathname: document.url,
|
||||
state: { title: document.title },
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<Heading>
|
||||
<Title text={document.title} highlight={highlight} />
|
||||
{!document.isDraft &&
|
||||
!document.isArchived && (
|
||||
<Actions>
|
||||
{document.isStarred ? (
|
||||
<StyledStar onClick={this.unstar} solid />
|
||||
) : (
|
||||
<StyledStar onClick={this.star} />
|
||||
)}
|
||||
</Actions>
|
||||
)}
|
||||
<StyledDocumentMenu document={document} showPin={showPin} />
|
||||
</Heading>
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={this.replaceResultMarks}
|
||||
/>
|
||||
)}
|
||||
<PublishingInfo
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
/>
|
||||
</DocumentLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DocumentPreview;
|
||||
export default withRouter(DocumentPreview);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// @flow
|
||||
import DocumentPreview from './DocumentPreview';
|
||||
import DocumentPreview from "./DocumentPreview";
|
||||
export default DocumentPreview;
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
import invariant from 'invariant';
|
||||
import importFile from 'utils/importFile';
|
||||
import Dropzone from 'react-dropzone';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
import LoadingIndicator from 'components/LoadingIndicator';
|
||||
import invariant from "invariant";
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
|
||||
import { createGlobalStyle } from "styled-components";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
import importFile from "utils/importFile";
|
||||
|
||||
const EMPTY_OBJECT = {};
|
||||
let importingLock = false;
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
@@ -21,20 +22,20 @@ type Props = {
|
||||
documents: DocumentsStore,
|
||||
disabled: boolean,
|
||||
location: Object,
|
||||
match: Object,
|
||||
history: Object,
|
||||
match: Match,
|
||||
history: RouterHistory,
|
||||
staticContext: Object,
|
||||
};
|
||||
|
||||
export const GlobalStyles = createGlobalStyle`
|
||||
.activeDropZone {
|
||||
border-radius: 4px;
|
||||
background: ${props => props.theme.slateDark};
|
||||
svg { fill: ${props => props.theme.white}; }
|
||||
background: ${(props) => props.theme.slateDark};
|
||||
svg { fill: ${(props) => props.theme.white}; }
|
||||
}
|
||||
|
||||
.activeDropZone a {
|
||||
color: ${props => props.theme.white} !important;
|
||||
color: ${(props) => props.theme.white} !important;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -43,7 +44,10 @@ class DropToImport extends React.Component<Props> {
|
||||
@observable isImporting: boolean = false;
|
||||
|
||||
onDropAccepted = async (files = []) => {
|
||||
if (importingLock) return;
|
||||
|
||||
this.isImporting = true;
|
||||
importingLock = true;
|
||||
|
||||
try {
|
||||
let collectionId = this.props.collectionId;
|
||||
@@ -52,7 +56,7 @@ class DropToImport extends React.Component<Props> {
|
||||
|
||||
if (documentId && !collectionId) {
|
||||
const document = await this.props.documents.fetch(documentId);
|
||||
invariant(document, 'Document not available');
|
||||
invariant(document, "Document not available");
|
||||
collectionId = document.collectionId;
|
||||
}
|
||||
|
||||
@@ -70,6 +74,7 @@ class DropToImport extends React.Component<Props> {
|
||||
}
|
||||
} finally {
|
||||
this.isImporting = false;
|
||||
importingLock = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -105,4 +110,4 @@ class DropToImport extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default inject('documents')(withRouter(DropToImport));
|
||||
export default inject("documents")(withRouter(DropToImport));
|
||||
|
||||
@@ -1,59 +1,81 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import invariant from 'invariant';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import { PortalWithState } from 'react-portal';
|
||||
import styled from 'styled-components';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { fadeAndScaleIn } from 'shared/styles/animations';
|
||||
import invariant from "invariant";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import { rgba } from "polished";
|
||||
import * as React from "react";
|
||||
import { PortalWithState } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||
import Flex from "components/Flex";
|
||||
import NudeButton from "components/NudeButton";
|
||||
|
||||
let previousClosePortal;
|
||||
let counter = 0;
|
||||
|
||||
type Children =
|
||||
| React.Node
|
||||
| ((options: { closePortal: () => void }) => React.Node);
|
||||
|
||||
type Props = {
|
||||
label: React.Node,
|
||||
label?: React.Node,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
children?: Children,
|
||||
className?: string,
|
||||
hover?: boolean,
|
||||
style?: Object,
|
||||
position?: 'left' | 'right' | 'center',
|
||||
position?: "left" | "right" | "center",
|
||||
};
|
||||
|
||||
@observer
|
||||
class DropdownMenu extends React.Component<Props> {
|
||||
@observable top: number;
|
||||
@observable right: number;
|
||||
@observable left: number;
|
||||
id: string = `menu${counter++}`;
|
||||
closeTimeout: TimeoutID;
|
||||
|
||||
@observable top: ?number;
|
||||
@observable bottom: ?number;
|
||||
@observable right: ?number;
|
||||
@observable left: ?number;
|
||||
@observable position: "left" | "right" | "center";
|
||||
@observable fixed: ?boolean;
|
||||
@observable bodyRect: ClientRect;
|
||||
@observable labelRect: ClientRect;
|
||||
@observable dropdownRef: { current: null | HTMLElement } = React.createRef();
|
||||
@observable menuRef: { current: null | HTMLElement } = React.createRef();
|
||||
|
||||
handleOpen = (
|
||||
openPortal: (SyntheticEvent<*>) => void,
|
||||
openPortal: (SyntheticEvent<>) => void,
|
||||
closePortal: () => void
|
||||
) => {
|
||||
return (ev: SyntheticMouseEvent<*>) => {
|
||||
return (ev: SyntheticMouseEvent<HTMLElement>) => {
|
||||
ev.preventDefault();
|
||||
const currentTarget = ev.currentTarget;
|
||||
invariant(document.body, 'why you not here');
|
||||
invariant(document.body, "why you not here");
|
||||
|
||||
if (currentTarget instanceof HTMLDivElement) {
|
||||
const bodyRect = document.body.getBoundingClientRect();
|
||||
const targetRect = currentTarget.getBoundingClientRect();
|
||||
this.top = targetRect.bottom - bodyRect.top;
|
||||
this.bodyRect = document.body.getBoundingClientRect();
|
||||
this.labelRect = currentTarget.getBoundingClientRect();
|
||||
this.top = this.labelRect.bottom - this.bodyRect.top;
|
||||
this.bottom = undefined;
|
||||
this.position = this.props.position || "left";
|
||||
|
||||
if (this.props.position === 'left') {
|
||||
this.left = targetRect.left;
|
||||
} else if (this.props.position === 'center') {
|
||||
this.left = targetRect.left + targetRect.width / 2;
|
||||
} else {
|
||||
this.right = bodyRect.width - targetRect.left - targetRect.width;
|
||||
if (currentTarget.parentElement) {
|
||||
const triggerParentStyle = getComputedStyle(
|
||||
currentTarget.parentElement
|
||||
);
|
||||
|
||||
if (triggerParentStyle.position === "static") {
|
||||
this.fixed = true;
|
||||
this.top = this.labelRect.bottom;
|
||||
}
|
||||
}
|
||||
|
||||
this.initPosition();
|
||||
|
||||
// attempt to keep only one flyout menu open at once
|
||||
if (previousClosePortal) {
|
||||
if (previousClosePortal && !this.props.hover) {
|
||||
previousClosePortal();
|
||||
}
|
||||
previousClosePortal = closePortal;
|
||||
@@ -62,47 +84,143 @@ class DropdownMenu extends React.Component<Props> {
|
||||
};
|
||||
};
|
||||
|
||||
initPosition() {
|
||||
if (this.position === "left") {
|
||||
this.right =
|
||||
this.bodyRect.width - this.labelRect.left - this.labelRect.width;
|
||||
} else if (this.position === "center") {
|
||||
this.left = this.labelRect.left + this.labelRect.width / 2;
|
||||
} else {
|
||||
this.left = this.labelRect.left;
|
||||
}
|
||||
}
|
||||
|
||||
onOpen = () => {
|
||||
if (typeof this.props.onOpen === "function") {
|
||||
this.props.onOpen();
|
||||
}
|
||||
this.fitOnTheScreen();
|
||||
};
|
||||
|
||||
fitOnTheScreen() {
|
||||
if (!this.dropdownRef || !this.dropdownRef.current) return;
|
||||
const el = this.dropdownRef.current;
|
||||
|
||||
const sticksOutPastBottomEdge =
|
||||
el.clientHeight + this.top > window.innerHeight;
|
||||
if (sticksOutPastBottomEdge) {
|
||||
this.top = undefined;
|
||||
this.bottom = this.fixed ? 0 : -1 * window.pageYOffset;
|
||||
} else {
|
||||
this.bottom = undefined;
|
||||
}
|
||||
|
||||
if (this.position === "left" || this.position === "right") {
|
||||
const totalWidth =
|
||||
Math.sign(this.position === "left" ? -1 : 1) * el.offsetLeft +
|
||||
el.scrollWidth;
|
||||
const isVisible = totalWidth < window.innerWidth;
|
||||
|
||||
if (!isVisible) {
|
||||
if (this.position === "right") {
|
||||
this.position = "left";
|
||||
this.left = undefined;
|
||||
} else if (this.position === "left") {
|
||||
this.position = "right";
|
||||
this.right = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.initPosition();
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
closeAfterTimeout = (closePortal: () => void) => () => {
|
||||
if (this.closeTimeout) {
|
||||
clearTimeout(this.closeTimeout);
|
||||
}
|
||||
this.closeTimeout = setTimeout(closePortal, 500);
|
||||
};
|
||||
|
||||
clearCloseTimeout = () => {
|
||||
if (this.closeTimeout) {
|
||||
clearTimeout(this.closeTimeout);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { className, label, position, children } = this.props;
|
||||
const { className, hover, label, children } = this.props;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<PortalWithState
|
||||
onOpen={this.props.onOpen}
|
||||
onOpen={this.onOpen}
|
||||
onClose={this.props.onClose}
|
||||
closeOnOutsideClick
|
||||
closeOnEsc
|
||||
>
|
||||
{({ closePortal, openPortal, portal }) => (
|
||||
<React.Fragment>
|
||||
<Label onClick={this.handleOpen(openPortal, closePortal)}>
|
||||
{label}
|
||||
{({ closePortal, openPortal, isOpen, portal }) => (
|
||||
<>
|
||||
<Label
|
||||
onMouseMove={hover ? this.clearCloseTimeout : undefined}
|
||||
onMouseOut={
|
||||
hover ? this.closeAfterTimeout(closePortal) : undefined
|
||||
}
|
||||
onMouseEnter={
|
||||
hover ? this.handleOpen(openPortal, closePortal) : undefined
|
||||
}
|
||||
onClick={
|
||||
hover ? undefined : this.handleOpen(openPortal, closePortal)
|
||||
}
|
||||
>
|
||||
{label || (
|
||||
<NudeButton
|
||||
id={`${this.id}button`}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isOpen ? "true" : "false"}
|
||||
aria-controls={this.id}
|
||||
>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
)}
|
||||
</Label>
|
||||
{portal(
|
||||
<Position
|
||||
position={position}
|
||||
ref={this.dropdownRef}
|
||||
position={this.position}
|
||||
fixed={this.fixed}
|
||||
top={this.top}
|
||||
bottom={this.bottom}
|
||||
left={this.left}
|
||||
right={this.right}
|
||||
>
|
||||
<Menu
|
||||
ref={this.menuRef}
|
||||
onMouseMove={hover ? this.clearCloseTimeout : undefined}
|
||||
onMouseOut={
|
||||
hover ? this.closeAfterTimeout(closePortal) : undefined
|
||||
}
|
||||
onClick={
|
||||
typeof children === 'function'
|
||||
typeof children === "function"
|
||||
? undefined
|
||||
: ev => {
|
||||
: (ev) => {
|
||||
ev.stopPropagation();
|
||||
closePortal();
|
||||
}
|
||||
}
|
||||
style={this.props.style}
|
||||
id={this.id}
|
||||
aria-labelledby={`${this.id}button`}
|
||||
role="menu"
|
||||
>
|
||||
{typeof children === 'function'
|
||||
{typeof children === "function"
|
||||
? children({ closePortal })
|
||||
: children}
|
||||
</Menu>
|
||||
</Position>
|
||||
)}
|
||||
</React.Fragment>
|
||||
</>
|
||||
)}
|
||||
</PortalWithState>
|
||||
</div>
|
||||
@@ -111,36 +229,58 @@ class DropdownMenu extends React.Component<Props> {
|
||||
}
|
||||
|
||||
const Label = styled(Flex).attrs({
|
||||
justify: 'center',
|
||||
align: 'center',
|
||||
justify: "center",
|
||||
align: "center",
|
||||
})`
|
||||
z-index: 1000;
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const Position = styled.div`
|
||||
position: fixed;
|
||||
${({ left }) => (left !== undefined ? `left: ${left}px` : '')};
|
||||
${({ right }) => (right !== undefined ? `right: ${right}px` : '')};
|
||||
top: ${({ top }) => top}px;
|
||||
z-index: 1000;
|
||||
transform: ${props =>
|
||||
props.position === 'center' ? 'translateX(-50%)' : 'initial'};
|
||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
||||
display: flex;
|
||||
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
|
||||
${({ right }) => (right !== undefined ? `right: ${right}px` : "")};
|
||||
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
|
||||
${({ bottom }) => (bottom !== undefined ? `bottom: ${bottom}px` : "")};
|
||||
max-height: 75%;
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
transform: ${(props) =>
|
||||
props.position === "center" ? "translateX(-50%)" : "initial"};
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
const Menu = styled.div`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: ${props => (props.left !== undefined ? '25%' : '75%')} 0;
|
||||
background: ${props => props.theme.menuBackground};
|
||||
transform-origin: ${(props) => (props.left !== undefined ? "25%" : "75%")} 0;
|
||||
backdrop-filter: blur(10px);
|
||||
background: ${(props) => rgba(props.theme.menuBackground, 0.8)};
|
||||
border: ${(props) =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
border-radius: 2px;
|
||||
padding: 0.5em 0;
|
||||
min-width: 180px;
|
||||
overflow: hidden;
|
||||
box-shadow: ${props => props.theme.menuShadow};
|
||||
overflow-y: auto;
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
pointer-events: all;
|
||||
|
||||
hr {
|
||||
margin: 0.5em 12px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Header = styled.h3`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
letter-spacing: 0.04em;
|
||||
margin: 1em 12px 0.5em;
|
||||
`;
|
||||
|
||||
export default DropdownMenu;
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
onClick?: (SyntheticEvent<*>) => *,
|
||||
onClick?: (SyntheticEvent<>) => void | Promise<void>,
|
||||
children?: React.Node,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
};
|
||||
|
||||
const DropdownMenuItem = ({ onClick, children, ...rest }: Props) => {
|
||||
const DropdownMenuItem = ({
|
||||
onClick,
|
||||
children,
|
||||
selected,
|
||||
disabled,
|
||||
...rest
|
||||
}: Props) => {
|
||||
return (
|
||||
<MenuItem onClick={onClick} {...rest}>
|
||||
<MenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
role="menuitem"
|
||||
tabIndex="-1"
|
||||
{...rest}
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<>
|
||||
<CheckmarkIcon
|
||||
color={selected === false ? "transparent" : undefined}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</MenuItem>
|
||||
);
|
||||
@@ -20,33 +42,46 @@ const MenuItem = styled.a`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 6px 12px;
|
||||
height: 32px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
|
||||
color: ${props =>
|
||||
color: ${(props) =>
|
||||
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
|
||||
justify-content: left;
|
||||
align-items: center;
|
||||
font-size: 15px;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
|
||||
svg:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
${props =>
|
||||
svg {
|
||||
opacity: ${(props) => (props.disabled ? ".5" : 1)};
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.disabled
|
||||
? ''
|
||||
? "pointer-events: none;"
|
||||
: `
|
||||
|
||||
&:hover {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.theme.primary};
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.theme.primary};
|
||||
outline: none;
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// @flow
|
||||
export { default as DropdownMenu } from './DropdownMenu';
|
||||
export { default as DropdownMenuItem } from './DropdownMenuItem';
|
||||
export { default as DropdownMenu, Header } from "./DropdownMenu";
|
||||
export { default as DropdownMenuItem } from "./DropdownMenuItem";
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// @flow
|
||||
import { lighten } from "polished";
|
||||
import * as React from "react";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import UiStore from "stores/UiStore";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import embeds from "../embeds";
|
||||
import isInternalUrl from "utils/isInternalUrl";
|
||||
import { uploadFile } from "utils/uploadFile";
|
||||
|
||||
const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));
|
||||
|
||||
const EMPTY_ARRAY = [];
|
||||
|
||||
type Props = {
|
||||
id?: string,
|
||||
defaultValue?: string,
|
||||
readOnly?: boolean,
|
||||
grow?: boolean,
|
||||
disableEmbeds?: boolean,
|
||||
ui?: UiStore,
|
||||
};
|
||||
|
||||
type PropsWithRef = Props & {
|
||||
forwardedRef: React.Ref<any>,
|
||||
history: RouterHistory,
|
||||
};
|
||||
|
||||
class Editor extends React.Component<PropsWithRef> {
|
||||
onUploadImage = async (file: File) => {
|
||||
const result = await uploadFile(file, { documentId: this.props.id });
|
||||
return result.url;
|
||||
};
|
||||
|
||||
onClickLink = (href: string) => {
|
||||
// on page hash
|
||||
if (href[0] === "#") {
|
||||
window.location.href = href;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInternalUrl(href)) {
|
||||
// relative
|
||||
let navigateTo = href;
|
||||
|
||||
// probably absolute
|
||||
if (href[0] !== "/") {
|
||||
try {
|
||||
const url = new URL(href);
|
||||
navigateTo = url.pathname + url.hash;
|
||||
} catch (err) {
|
||||
navigateTo = href;
|
||||
}
|
||||
}
|
||||
|
||||
this.props.history.push(navigateTo);
|
||||
} else {
|
||||
window.open(href, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
onShowToast = (message: string) => {
|
||||
if (this.props.ui) {
|
||||
this.props.ui.showToast(message);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ErrorBoundary reloadOnChunkMissing>
|
||||
<StyledEditor
|
||||
ref={this.props.forwardedRef}
|
||||
uploadImage={this.onUploadImage}
|
||||
onClickLink={this.onClickLink}
|
||||
onShowToast={this.onShowToast}
|
||||
embeds={this.props.disableEmbeds ? EMPTY_ARRAY : embeds}
|
||||
tooltip={EditorTooltip}
|
||||
{...this.props}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const StyledEditor = styled(RichMarkdownEditor)`
|
||||
flex-grow: ${(props) => (props.grow ? 1 : 0)};
|
||||
justify-content: start;
|
||||
|
||||
> div {
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
}
|
||||
|
||||
.notice-block.tip,
|
||||
.notice-block.warning {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
p {
|
||||
a {
|
||||
color: ${(props) => props.theme.text};
|
||||
border-bottom: 1px solid ${(props) => lighten(0.5, props.theme.text)};
|
||||
text-decoration: none !important;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
border-bottom: 1px solid ${(props) => props.theme.text};
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const EditorTooltip = ({ children, ...props }) => (
|
||||
<Tooltip offset="0, 16" delay={150} {...props}>
|
||||
<Span>{children}</Span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const Span = styled.span`
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
const EditorWithRouterAndTheme = withRouter(withTheme(Editor));
|
||||
|
||||
export default React.forwardRef<Props, typeof Editor>((props, ref) => (
|
||||
<EditorWithRouterAndTheme {...props} forwardedRef={ref} />
|
||||
));
|
||||
@@ -1,281 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import { lighten } from 'polished';
|
||||
import styled, { withTheme, createGlobalStyle } from 'styled-components';
|
||||
import RichMarkdownEditor from 'rich-markdown-editor';
|
||||
import Placeholder from 'rich-markdown-editor/lib/components/Placeholder';
|
||||
import { uploadFile } from 'utils/uploadFile';
|
||||
import isInternalUrl from 'utils/isInternalUrl';
|
||||
import Tooltip from 'components/Tooltip';
|
||||
import Embed from './Embed';
|
||||
import embeds from '../../embeds';
|
||||
|
||||
type Props = {
|
||||
defaultValue?: string,
|
||||
readOnly?: boolean,
|
||||
disableEmbeds?: boolean,
|
||||
forwardedRef: *,
|
||||
ui: *,
|
||||
};
|
||||
|
||||
@observer
|
||||
class Editor extends React.Component<Props> {
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
onUploadImage = async (file: File) => {
|
||||
const result = await uploadFile(file);
|
||||
return result.url;
|
||||
};
|
||||
|
||||
onClickLink = (href: string) => {
|
||||
// on page hash
|
||||
if (href[0] === '#') {
|
||||
window.location.href = href;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInternalUrl(href)) {
|
||||
// relative
|
||||
let navigateTo = href;
|
||||
|
||||
// probably absolute
|
||||
if (href[0] !== '/') {
|
||||
try {
|
||||
const url = new URL(href);
|
||||
navigateTo = url.pathname + url.hash;
|
||||
} catch (err) {
|
||||
navigateTo = href;
|
||||
}
|
||||
}
|
||||
|
||||
this.redirectTo = navigateTo;
|
||||
} else {
|
||||
window.open(href, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
onShowToast = (message: string) => {
|
||||
this.props.ui.showToast(message);
|
||||
};
|
||||
|
||||
getLinkComponent = node => {
|
||||
if (this.props.disableEmbeds) return;
|
||||
|
||||
const url = node.data.get('href');
|
||||
const keys = Object.keys(embeds);
|
||||
|
||||
for (const key of keys) {
|
||||
const component = embeds[key];
|
||||
|
||||
for (const host of component.ENABLED) {
|
||||
const matches = url.match(host);
|
||||
if (matches) return Embed;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<PrismStyles />
|
||||
<StyledEditor
|
||||
ref={this.props.forwardedRef}
|
||||
uploadImage={this.onUploadImage}
|
||||
onClickLink={this.onClickLink}
|
||||
onShowToast={this.onShowToast}
|
||||
getLinkComponent={this.getLinkComponent}
|
||||
tooltip={EditorTooltip}
|
||||
{...this.props}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const StyledEditor = styled(RichMarkdownEditor)`
|
||||
justify-content: start;
|
||||
|
||||
> div {
|
||||
transition: ${props => props.theme.backgroundTransition};
|
||||
}
|
||||
|
||||
p {
|
||||
${Placeholder} {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
p:nth-child(2):last-child {
|
||||
${Placeholder} {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
a {
|
||||
color: ${props => props.theme.link};
|
||||
border-bottom: 1px solid ${props => lighten(0.5, props.theme.link)};
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
border-bottom: 1px solid ${props => props.theme.link};
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/*
|
||||
Based on Prism template by Bram de Haan (http://atelierbram.github.io/syntax-highlighting/prism/)
|
||||
Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16)
|
||||
*/
|
||||
const PrismStyles = createGlobalStyle`
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
-webkit-font-smoothing: initial;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.375;
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
-webkit-hyphens: none;
|
||||
-moz-hyphens: none;
|
||||
-ms-hyphens: none;
|
||||
hyphens: none;
|
||||
color: #24292e;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre[class*="language-"] {
|
||||
padding: 1em;
|
||||
margin: .5em 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
:not(pre) > code[class*="language-"] {
|
||||
padding: .1em;
|
||||
border-radius: .3em;
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: #6a737d;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: #5e6687;
|
||||
}
|
||||
|
||||
.token.namespace {
|
||||
opacity: .7;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.boolean,
|
||||
.token.number {
|
||||
color: #d73a49;
|
||||
}
|
||||
|
||||
.token.property {
|
||||
color: #c08b30;
|
||||
}
|
||||
|
||||
.token.tag {
|
||||
color: #3d8fd1;
|
||||
}
|
||||
|
||||
.token.string {
|
||||
color: #032f62;
|
||||
}
|
||||
|
||||
.token.selector {
|
||||
color: #6679cc;
|
||||
}
|
||||
|
||||
.token.attr-name {
|
||||
color: #c76b29;
|
||||
}
|
||||
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: #22a2c9;
|
||||
}
|
||||
|
||||
.token.attr-value,
|
||||
.token.keyword,
|
||||
.token.control,
|
||||
.token.directive,
|
||||
.token.unit {
|
||||
color: #d73a49;
|
||||
}
|
||||
|
||||
.token.function {
|
||||
color: #6f42c1;
|
||||
}
|
||||
|
||||
.token.statement,
|
||||
.token.regex,
|
||||
.token.atrule {
|
||||
color: #22a2c9;
|
||||
}
|
||||
|
||||
.token.placeholder,
|
||||
.token.variable {
|
||||
color: #3d8fd1;
|
||||
}
|
||||
|
||||
.token.deleted {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.token.inserted {
|
||||
border-bottom: 1px dotted #202746;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.important,
|
||||
.token.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.token.important {
|
||||
color: #c94922;
|
||||
}
|
||||
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
pre > code.highlight {
|
||||
outline: 0.4em solid #c94922;
|
||||
outline-offset: .4em;
|
||||
}
|
||||
`;
|
||||
|
||||
const EditorTooltip = props => (
|
||||
<Tooltip offset="0, 16" delay={150} {...props} />
|
||||
);
|
||||
|
||||
export default withTheme(
|
||||
// $FlowIssue - https://github.com/facebook/flow/issues/6103
|
||||
React.forwardRef((props, ref) => <Editor {...props} forwardedRef={ref} />)
|
||||
);
|
||||
@@ -1,53 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { fadeIn } from 'shared/styles/animations';
|
||||
import embeds from '../../embeds';
|
||||
|
||||
export default class Embed extends React.Component<*> {
|
||||
get url(): string {
|
||||
return this.props.node.data.get('href');
|
||||
}
|
||||
|
||||
getMatchResults(): ?{ component: *, matches: string[] } {
|
||||
const keys = Object.keys(embeds);
|
||||
|
||||
for (const key of keys) {
|
||||
const component = embeds[key];
|
||||
|
||||
for (const host of component.ENABLED) {
|
||||
const matches = this.url.match(host);
|
||||
if (matches) return { component, matches };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const result = this.getMatchResults();
|
||||
if (!result) return null;
|
||||
|
||||
const { attributes, isSelected, children } = this.props;
|
||||
const { component, matches } = result;
|
||||
const EmbedComponent = component;
|
||||
|
||||
return (
|
||||
<Container
|
||||
contentEditable={false}
|
||||
isSelected={isSelected}
|
||||
{...attributes}
|
||||
>
|
||||
<EmbedComponent matches={matches} url={this.url} />
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
animation: ${fadeIn} 500ms ease-in-out;
|
||||
line-height: 0;
|
||||
|
||||
border-radius: 3px;
|
||||
box-shadow: ${props =>
|
||||
props.isSelected ? `0 0 0 2px ${props.theme.selected}` : 'none'};
|
||||
`;
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import Editor from './Editor';
|
||||
export default Editor;
|
||||
+3
-15
@@ -1,20 +1,8 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
};
|
||||
|
||||
const Empty = (props: Props) => {
|
||||
const { children, ...rest } = props;
|
||||
return <Container {...rest}>{children}</Container>;
|
||||
};
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
color: ${props => props.theme.slate};
|
||||
text-align: center;
|
||||
const Empty = styled.p`
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
`;
|
||||
|
||||
export default Empty;
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { observer } from 'mobx-react';
|
||||
import { observable } from 'mobx';
|
||||
import HelpText from 'components/HelpText';
|
||||
import Button from 'components/Button';
|
||||
import CenteredContent from 'components/CenteredContent';
|
||||
import PageTitle from 'components/PageTitle';
|
||||
import { githubIssuesUrl } from '../../shared/utils/routeHelpers';
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Button from "components/Button";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import HelpText from "components/HelpText";
|
||||
import PageTitle from "components/PageTitle";
|
||||
import { githubIssuesUrl } from "../../shared/utils/routeHelpers";
|
||||
import env from "env";
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
reloadOnChunkMissing?: boolean,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -20,15 +22,27 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
|
||||
componentDidCatch(error: Error, info: Object) {
|
||||
this.error = error;
|
||||
console.error(error);
|
||||
|
||||
// Error handler is often blocked by the browser
|
||||
if (window.Bugsnag) {
|
||||
Bugsnag.notifyException(error, { react: info });
|
||||
if (
|
||||
this.props.reloadOnChunkMissing &&
|
||||
error.message &&
|
||||
error.message.match(/chunk/)
|
||||
) {
|
||||
// If the editor bundle fails to load then reload the entire window. This
|
||||
// can happen if a deploy happens between the user loading the initial JS
|
||||
// bundle and the async-loaded editor JS bundle as the hash will change.
|
||||
window.location.reload(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.Sentry) {
|
||||
window.Sentry.captureException(error);
|
||||
}
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
window.location.reload(true);
|
||||
};
|
||||
|
||||
handleShowDetails = () => {
|
||||
@@ -41,20 +55,20 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
|
||||
render() {
|
||||
if (this.error) {
|
||||
const isReported = !!window.Bugsnag;
|
||||
const isReported = !!window.Sentry && env.DEPLOYMENT === "hosted";
|
||||
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title="Something Unexpected Happened" />
|
||||
<h1>Something Unexpected Happened</h1>
|
||||
<HelpText>
|
||||
Sorry, an unrecoverable error occurred{isReported &&
|
||||
' – our engineers have been notified'}. Please try reloading the
|
||||
page, it may have been a temporary glitch.
|
||||
Sorry, an unrecoverable error occurred
|
||||
{isReported && " – our engineers have been notified"}. Please try
|
||||
reloading the page, it may have been a temporary glitch.
|
||||
</HelpText>
|
||||
{this.showDetails && <Pre>{this.error.toString()}</Pre>}
|
||||
<p>
|
||||
<Button onClick={this.handleReload}>Reload</Button>{' '}
|
||||
<Button onClick={this.handleReload}>Reload</Button>{" "}
|
||||
{this.showDetails ? (
|
||||
<Button onClick={this.handleReportBug} neutral>
|
||||
Report a Bug…
|
||||
@@ -73,7 +87,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
}
|
||||
|
||||
const Pre = styled.pre`
|
||||
background: ${props => props.theme.smoke};
|
||||
background: ${(props) => props.theme.smoke};
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import User from "models/User";
|
||||
import Avatar from "components/Avatar";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
type Props = {
|
||||
users: User[],
|
||||
size?: number,
|
||||
overflow: number,
|
||||
renderAvatar: (user: User) => React.Node,
|
||||
};
|
||||
|
||||
@observer
|
||||
class Facepile extends React.Component<Props> {
|
||||
render() {
|
||||
const {
|
||||
users,
|
||||
overflow,
|
||||
size = 32,
|
||||
renderAvatar = renderDefaultAvatar,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Avatars {...rest}>
|
||||
{overflow > 0 && (
|
||||
<More size={size}>
|
||||
<span>+{overflow}</span>
|
||||
</More>
|
||||
)}
|
||||
{users.map((user) => (
|
||||
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
|
||||
))}
|
||||
</Avatars>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDefaultAvatar(user: User) {
|
||||
return <Avatar user={user} src={user.avatarUrl} size={32} />;
|
||||
}
|
||||
|
||||
const AvatarWrapper = styled.div`
|
||||
margin-right: -8px;
|
||||
|
||||
&:first-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const More = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: ${(props) => props.size}px;
|
||||
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};
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const Avatars = styled(Flex)`
|
||||
align-items: center;
|
||||
flex-direction: row-reverse;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export default inject("views", "presence")(withTheme(Facepile));
|
||||
@@ -1,9 +1,9 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
import { fadeIn } from 'shared/styles/animations';
|
||||
import styled from "styled-components";
|
||||
import { fadeIn } from "shared/styles/animations";
|
||||
|
||||
const Fade = styled.span`
|
||||
animation: ${fadeIn} ${props => props.timing || '250ms'} ease-in-out;
|
||||
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
|
||||
`;
|
||||
|
||||
export default Fade;
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type JustifyValues =
|
||||
| 'center'
|
||||
| 'space-around'
|
||||
| 'space-between'
|
||||
| 'flex-start'
|
||||
| 'flex-end';
|
||||
| "center"
|
||||
| "space-around"
|
||||
| "space-between"
|
||||
| "flex-start"
|
||||
| "flex-end";
|
||||
|
||||
type AlignValues =
|
||||
| 'stretch'
|
||||
| 'center'
|
||||
| 'baseline'
|
||||
| 'flex-start'
|
||||
| 'flex-end';
|
||||
| "stretch"
|
||||
| "center"
|
||||
| "baseline"
|
||||
| "flex-start"
|
||||
| "flex-end";
|
||||
|
||||
type Props = {
|
||||
column?: ?boolean,
|
||||
@@ -33,11 +33,11 @@ const Flex = (props: Props) => {
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex: ${({ auto }) => (auto ? '1 1 auto' : 'initial')};
|
||||
flex-direction: ${({ column }) => (column ? 'column' : 'row')};
|
||||
flex: ${({ auto }) => (auto ? "1 1 auto" : "initial")};
|
||||
flex-direction: ${({ column }) => (column ? "column" : "row")};
|
||||
align-items: ${({ align }) => align};
|
||||
justify-content: ${({ justify }) => justify};
|
||||
flex-shrink: ${({ shrink }) => (shrink ? 1 : 'initial')};
|
||||
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
`;
|
||||
@@ -0,0 +1,24 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Empty from "components/Empty";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
export default function FullscreenLoading() {
|
||||
return (
|
||||
<Fade timing={500}>
|
||||
<Centered>
|
||||
<Empty>Loading…</Empty>
|
||||
</Centered>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
|
||||
const Centered = styled(Flex)`
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
size?: number,
|
||||
@@ -7,7 +7,7 @@ type Props = {
|
||||
className?: string,
|
||||
};
|
||||
|
||||
function GithubLogo({ size = 34, fill = '#FFF', className }: Props) {
|
||||
function GithubLogo({ size = 34, fill = "#FFF", className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
size?: number,
|
||||
@@ -7,7 +7,7 @@ type Props = {
|
||||
className?: string,
|
||||
};
|
||||
|
||||
function GoogleLogo({ size = 34, fill = '#FFF', className }: Props) {
|
||||
function GoogleLogo({ size = 34, fill = "#FFF", className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
@@ -0,0 +1,94 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { MAX_AVATAR_DISPLAY } from "shared/constants";
|
||||
import GroupMembershipsStore from "stores/GroupMembershipsStore";
|
||||
import CollectionGroupMembership from "models/CollectionGroupMembership";
|
||||
import Group from "models/Group";
|
||||
import GroupMembers from "scenes/GroupMembers";
|
||||
import Facepile from "components/Facepile";
|
||||
import Flex from "components/Flex";
|
||||
import ListItem from "components/List/Item";
|
||||
import Modal from "components/Modal";
|
||||
|
||||
type Props = {
|
||||
group: Group,
|
||||
groupMemberships: GroupMembershipsStore,
|
||||
membership?: CollectionGroupMembership,
|
||||
showFacepile: boolean,
|
||||
renderActions: ({ openMembersModal: () => void }) => React.Node,
|
||||
};
|
||||
|
||||
@observer
|
||||
class GroupListItem extends React.Component<Props> {
|
||||
@observable membersModalOpen: boolean = false;
|
||||
|
||||
handleMembersModalOpen = () => {
|
||||
this.membersModalOpen = true;
|
||||
};
|
||||
|
||||
handleMembersModalClose = () => {
|
||||
this.membersModalOpen = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { group, groupMemberships, showFacepile, renderActions } = this.props;
|
||||
|
||||
const memberCount = group.memberCount;
|
||||
|
||||
const membershipsInGroup = groupMemberships.inGroup(group.id);
|
||||
const users = membershipsInGroup
|
||||
.slice(0, MAX_AVATAR_DISPLAY)
|
||||
.map((gm) => gm.user);
|
||||
|
||||
const overflow = memberCount - users.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
title={
|
||||
<Title onClick={this.handleMembersModalOpen}>{group.name}</Title>
|
||||
}
|
||||
subtitle={
|
||||
<>
|
||||
{memberCount} member{memberCount === 1 ? "" : "s"}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<Flex align="center">
|
||||
{showFacepile && (
|
||||
<Facepile
|
||||
onClick={this.handleMembersModalOpen}
|
||||
users={users}
|
||||
overflow={overflow}
|
||||
/>
|
||||
)}
|
||||
|
||||
{renderActions({
|
||||
openMembersModal: this.handleMembersModalOpen,
|
||||
})}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title="Group members"
|
||||
onRequestClose={this.handleMembersModalClose}
|
||||
isOpen={this.membersModalOpen}
|
||||
>
|
||||
<GroupMembers group={group} onSubmit={this.handleMembersModalClose} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Title = styled.span`
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
export default inject("groupMemberships")(GroupListItem);
|
||||
@@ -1,9 +1,10 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
import styled from "styled-components";
|
||||
|
||||
const Heading = styled.h1`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
${(props) => (props.centered ? "text-align: center;" : "")}
|
||||
|
||||
svg {
|
||||
margin-left: -6px;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
import styled from "styled-components";
|
||||
|
||||
const HelpText = styled.p`
|
||||
margin-top: 0;
|
||||
color: ${props => props.theme.textSecondary};
|
||||
font-size: ${props => (props.small ? '13px' : 'inherit')};
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
font-size: ${(props) => (props.small ? "13px" : "inherit")};
|
||||
`;
|
||||
|
||||
export default HelpText;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import replace from 'string-replace-to-array';
|
||||
import styled from 'styled-components';
|
||||
import * as React from "react";
|
||||
import replace from "string-replace-to-array";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
highlight: ?string | RegExp,
|
||||
@@ -22,8 +22,8 @@ function Highlight({
|
||||
regex = highlight;
|
||||
} else {
|
||||
regex = new RegExp(
|
||||
(highlight || '').replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&'),
|
||||
caseSensitive ? 'g' : 'gi'
|
||||
(highlight || "").replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&"),
|
||||
caseSensitive ? "g" : "gi"
|
||||
);
|
||||
}
|
||||
return (
|
||||
@@ -38,7 +38,7 @@ function Highlight({
|
||||
}
|
||||
|
||||
const Mark = styled.mark`
|
||||
background: ${props => props.theme.yellow};
|
||||
background: ${(props) => props.theme.yellow};
|
||||
border-radius: 2px;
|
||||
padding: 0 4px;
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// @flow
|
||||
import { inject } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import { fadeAndSlideIn } from "shared/styles/animations";
|
||||
import { parseDocumentSlugFromUrl } from "shared/utils/parseDocumentSlug";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import HoverPreviewDocument from "components/HoverPreviewDocument";
|
||||
import isInternalUrl from "utils/isInternalUrl";
|
||||
|
||||
const DELAY_OPEN = 300;
|
||||
const DELAY_CLOSE = 300;
|
||||
|
||||
type Props = {
|
||||
node: HTMLAnchorElement,
|
||||
event: MouseEvent,
|
||||
documents: DocumentsStore,
|
||||
onClose: () => void,
|
||||
};
|
||||
|
||||
function HoverPreview({ node, documents, onClose, event }: Props) {
|
||||
// previews only work for internal doc links for now
|
||||
if (!isInternalUrl(node.href)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const slug = parseDocumentSlugFromUrl(node.href);
|
||||
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
const timerClose = React.useRef();
|
||||
const timerOpen = React.useRef();
|
||||
const cardRef = React.useRef<?HTMLDivElement>();
|
||||
|
||||
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, {
|
||||
prefetch: true,
|
||||
});
|
||||
}
|
||||
|
||||
startOpenTimer();
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.addEventListener("mouseenter", stopCloseTimer);
|
||||
}
|
||||
if (cardRef.current) {
|
||||
cardRef.current.addEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
node.addEventListener("mouseout", startCloseTimer);
|
||||
node.addEventListener("mouseover", stopCloseTimer);
|
||||
node.addEventListener("mouseover", startOpenTimer);
|
||||
|
||||
return () => {
|
||||
node.removeEventListener("mouseout", startCloseTimer);
|
||||
node.removeEventListener("mouseover", stopCloseTimer);
|
||||
node.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);
|
||||
}
|
||||
};
|
||||
}, [node]);
|
||||
|
||||
const anchorBounds = node.getBoundingClientRect();
|
||||
const cardBounds = cardRef.current
|
||||
? cardRef.current.getBoundingClientRect()
|
||||
: undefined;
|
||||
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={node.href}>
|
||||
{(content) =>
|
||||
isVisible ? (
|
||||
<Animate>
|
||||
<Card>
|
||||
<Margin />
|
||||
<CardContent>{content}</CardContent>
|
||||
</Card>
|
||||
<Pointer offset={leftOffset + anchorBounds.width / 2} />
|
||||
</Animate>
|
||||
) : null
|
||||
}
|
||||
</HoverPreviewDocument>
|
||||
</div>
|
||||
</Position>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
const Animate = styled.div`
|
||||
animation: ${fadeAndSlideIn} 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: 350px;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
// &:after — gradient mask for overflow text
|
||||
const Card = styled.div`
|
||||
backdrop-filter: blur(10px);
|
||||
background: ${(props) => props.theme.background};
|
||||
border: ${(props) =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
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`
|
||||
margin-top: 10px;
|
||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
||||
display: flex;
|
||||
max-height: 75%;
|
||||
|
||||
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
|
||||
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
|
||||
`;
|
||||
|
||||
const Pointer = styled.div`
|
||||
top: -22px;
|
||||
left: ${(props) => props.offset}px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
|
||||
&: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 inject("documents")(HoverPreview);
|
||||
@@ -0,0 +1,53 @@
|
||||
// @flow
|
||||
import { inject, observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { parseDocumentSlugFromUrl } from "shared/utils/parseDocumentSlug";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import DocumentMetaWithViews from "components/DocumentMetaWithViews";
|
||||
import Editor from "components/Editor";
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
documents: DocumentsStore,
|
||||
children: (React.Node) => React.Node,
|
||||
};
|
||||
|
||||
function HoverPreviewDocument({ url, documents, children }: Props) {
|
||||
const slug = parseDocumentSlugFromUrl(url);
|
||||
|
||||
documents.prefetchDocument(slug, {
|
||||
prefetch: true,
|
||||
});
|
||||
|
||||
const document = slug ? documents.getByUrl(slug) : undefined;
|
||||
if (!document) return null;
|
||||
|
||||
return children(
|
||||
<Content to={document.url}>
|
||||
<Heading>{document.titleWithDefault}</Heading>
|
||||
<DocumentMetaWithViews isDraft={document.isDraft} document={document} />
|
||||
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
key={document.id}
|
||||
defaultValue={document.getSummary()}
|
||||
disableEmbeds
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
|
||||
const Content = styled(Link)`
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const Heading = styled.h2`
|
||||
margin: 0 0 0.75em;
|
||||
color: ${(props) => props.theme.text};
|
||||
`;
|
||||
|
||||
export default inject("documents")(observer(HoverPreviewDocument));
|
||||
@@ -0,0 +1,249 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
CollectionIcon,
|
||||
CoinsIcon,
|
||||
AcademicCapIcon,
|
||||
BeakerIcon,
|
||||
BuildingBlocksIcon,
|
||||
CloudIcon,
|
||||
CodeIcon,
|
||||
EditIcon,
|
||||
EyeIcon,
|
||||
LeafIcon,
|
||||
LightBulbIcon,
|
||||
MoonIcon,
|
||||
NotepadIcon,
|
||||
PadlockIcon,
|
||||
PaletteIcon,
|
||||
QuestionMarkIcon,
|
||||
SunIcon,
|
||||
VehicleIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { DropdownMenu } from "components/DropdownMenu";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import { LabelText } from "components/Input";
|
||||
import NudeButton from "components/NudeButton";
|
||||
|
||||
const TwitterPicker = React.lazy(() =>
|
||||
import("react-color/lib/components/twitter/Twitter")
|
||||
);
|
||||
|
||||
export const icons = {
|
||||
collection: {
|
||||
component: CollectionIcon,
|
||||
keywords: "collection",
|
||||
},
|
||||
coins: {
|
||||
component: CoinsIcon,
|
||||
keywords: "coins money finance sales income revenue cash",
|
||||
},
|
||||
academicCap: {
|
||||
component: AcademicCapIcon,
|
||||
keywords: "learn teach lesson guide tutorial onboarding training",
|
||||
},
|
||||
beaker: {
|
||||
component: BeakerIcon,
|
||||
keywords: "lab research experiment test",
|
||||
},
|
||||
buildingBlocks: {
|
||||
component: BuildingBlocksIcon,
|
||||
keywords: "app blocks product prototype",
|
||||
},
|
||||
cloud: {
|
||||
component: CloudIcon,
|
||||
keywords: "cloud service aws infrastructure",
|
||||
},
|
||||
code: {
|
||||
component: CodeIcon,
|
||||
keywords: "developer api code development engineering programming",
|
||||
},
|
||||
eye: {
|
||||
component: EyeIcon,
|
||||
keywords: "eye view",
|
||||
},
|
||||
leaf: {
|
||||
component: LeafIcon,
|
||||
keywords: "leaf plant outdoors nature ecosystem climate",
|
||||
},
|
||||
lightbulb: {
|
||||
component: LightBulbIcon,
|
||||
keywords: "lightbulb idea",
|
||||
},
|
||||
moon: {
|
||||
component: MoonIcon,
|
||||
keywords: "night moon dark",
|
||||
},
|
||||
notepad: {
|
||||
component: NotepadIcon,
|
||||
keywords: "journal notepad write notes",
|
||||
},
|
||||
padlock: {
|
||||
component: PadlockIcon,
|
||||
keywords: "padlock private security authentication authorization auth",
|
||||
},
|
||||
palette: {
|
||||
component: PaletteIcon,
|
||||
keywords: "design palette art brand",
|
||||
},
|
||||
pencil: {
|
||||
component: EditIcon,
|
||||
keywords: "copy writing post blog",
|
||||
},
|
||||
question: {
|
||||
component: QuestionMarkIcon,
|
||||
keywords: "question help support faq",
|
||||
},
|
||||
sun: {
|
||||
component: SunIcon,
|
||||
keywords: "day sun weather",
|
||||
},
|
||||
vehicle: {
|
||||
component: VehicleIcon,
|
||||
keywords: "truck car travel transport",
|
||||
},
|
||||
};
|
||||
|
||||
const colors = [
|
||||
"#4E5C6E",
|
||||
"#0366d6",
|
||||
"#9E5CF7",
|
||||
"#FF825C",
|
||||
"#FF5C80",
|
||||
"#FFBE0B",
|
||||
"#42DED1",
|
||||
"#00D084",
|
||||
"#FF4DFA",
|
||||
"#2F362F",
|
||||
];
|
||||
|
||||
type Props = {
|
||||
onOpen?: () => void,
|
||||
onChange: (color: string, icon: string) => void,
|
||||
icon: string,
|
||||
color: string,
|
||||
};
|
||||
|
||||
function preventEventBubble(event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
@observer
|
||||
class IconPicker extends React.Component<Props> {
|
||||
@observable isOpen: boolean = false;
|
||||
node: ?HTMLElement;
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener("click", this.handleClickOutside);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("click", this.handleClickOutside);
|
||||
}
|
||||
|
||||
handleClose = () => {
|
||||
this.isOpen = false;
|
||||
};
|
||||
|
||||
handleOpen = () => {
|
||||
this.isOpen = true;
|
||||
|
||||
if (this.props.onOpen) {
|
||||
this.props.onOpen();
|
||||
}
|
||||
};
|
||||
|
||||
handleClickOutside = (ev: SyntheticMouseEvent<>) => {
|
||||
// $FlowFixMe
|
||||
if (ev.target && this.node && this.node.contains(ev.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleClose();
|
||||
};
|
||||
|
||||
render() {
|
||||
const Component = icons[this.props.icon || "collection"].component;
|
||||
|
||||
return (
|
||||
<Wrapper ref={(ref) => (this.node = ref)}>
|
||||
<label>
|
||||
<LabelText>Icon</LabelText>
|
||||
</label>
|
||||
<DropdownMenu
|
||||
onOpen={this.handleOpen}
|
||||
label={
|
||||
<LabelButton>
|
||||
<Component role="button" color={this.props.color} size={30} />
|
||||
</LabelButton>
|
||||
}
|
||||
>
|
||||
<Icons onClick={preventEventBubble}>
|
||||
{Object.keys(icons).map((name) => {
|
||||
const Component = icons[name].component;
|
||||
return (
|
||||
<IconButton
|
||||
key={name}
|
||||
onClick={() => this.props.onChange(this.props.color, name)}
|
||||
style={{ width: 30, height: 30 }}
|
||||
>
|
||||
<Component color={this.props.color} size={30} />
|
||||
</IconButton>
|
||||
);
|
||||
})}
|
||||
</Icons>
|
||||
<Flex onClick={preventEventBubble}>
|
||||
<React.Suspense fallback={<Loading>Loading…</Loading>}>
|
||||
<ColorPicker
|
||||
color={this.props.color}
|
||||
onChange={(color) =>
|
||||
this.props.onChange(color.hex, this.props.icon)
|
||||
}
|
||||
colors={colors}
|
||||
triangle="hide"
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Flex>
|
||||
</DropdownMenu>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Icons = styled.div`
|
||||
padding: 15px 9px 9px 15px;
|
||||
width: 276px;
|
||||
`;
|
||||
|
||||
const LabelButton = styled(NudeButton)`
|
||||
border: 1px solid ${(props) => props.theme.inputBorder};
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
`;
|
||||
|
||||
const IconButton = styled(NudeButton)`
|
||||
border-radius: 4px;
|
||||
margin: 0px 6px 6px 0px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
`;
|
||||
|
||||
const Loading = styled(HelpText)`
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const ColorPicker = styled(TwitterPicker)`
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
`;
|
||||
|
||||
const Wrapper = styled("div")`
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export default IconPicker;
|
||||
+63
-27
@@ -1,36 +1,37 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { observable } from 'mobx';
|
||||
import styled from 'styled-components';
|
||||
import VisuallyHidden from 'components/VisuallyHidden';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
import VisuallyHidden from "components/VisuallyHidden";
|
||||
|
||||
const RealTextarea = styled.textarea`
|
||||
border: 0;
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
padding: 8px 12px 8px ${(props) => (props.hasIcon ? "8px" : "12px")};
|
||||
outline: none;
|
||||
background: none;
|
||||
color: ${props => props.theme.text};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${props => props.theme.placeholder};
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
`;
|
||||
|
||||
const RealInput = styled.input`
|
||||
border: 0;
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
padding: 8px 12px 8px ${(props) => (props.hasIcon ? "8px" : "12px")};
|
||||
outline: none;
|
||||
background: none;
|
||||
color: ${props => props.theme.text};
|
||||
color: ${(props) => props.theme.text};
|
||||
height: 30px;
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${props => props.theme.placeholder};
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
@@ -39,32 +40,43 @@ const RealInput = styled.input`
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
flex: ${props => (props.flex ? '1' : '0')};
|
||||
max-width: ${props => (props.short ? '350px' : '100%')};
|
||||
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : '0')};
|
||||
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : 'initial')};
|
||||
flex: ${(props) => (props.flex ? "1" : "0")};
|
||||
max-width: ${(props) => (props.short ? "350px" : "100%")};
|
||||
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : "0")};
|
||||
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : "initial")};
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
position: relative;
|
||||
left: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export const Outline = styled(Flex)`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
margin: 0 0 16px;
|
||||
margin: ${(props) =>
|
||||
props.margin !== undefined ? props.margin : "0 0 16px"};
|
||||
color: inherit;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: ${props =>
|
||||
border-color: ${(props) =>
|
||||
props.hasError
|
||||
? 'red'
|
||||
? "red"
|
||||
: props.focused
|
||||
? props.theme.inputBorderFocused
|
||||
: props.theme.inputBorder};
|
||||
? props.theme.inputBorderFocused
|
||||
: props.theme.inputBorder};
|
||||
border-radius: 4px;
|
||||
font-weight: normal;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export const LabelText = styled.div`
|
||||
font-weight: 500;
|
||||
padding-bottom: 4px;
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
export type Props = {
|
||||
@@ -75,32 +87,53 @@ export type Props = {
|
||||
labelHidden?: boolean,
|
||||
flex?: boolean,
|
||||
short?: boolean,
|
||||
margin?: string | number,
|
||||
icon?: React.Node,
|
||||
onFocus?: (ev: SyntheticEvent<>) => void,
|
||||
onBlur?: (ev: SyntheticEvent<>) => void,
|
||||
};
|
||||
|
||||
@observer
|
||||
class Input extends React.Component<Props> {
|
||||
input: ?HTMLInputElement;
|
||||
@observable focused: boolean = false;
|
||||
|
||||
handleBlur = () => {
|
||||
handleBlur = (ev: SyntheticEvent<>) => {
|
||||
this.focused = false;
|
||||
if (this.props.onBlur) {
|
||||
this.props.onBlur(ev);
|
||||
}
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
handleFocus = (ev: SyntheticEvent<>) => {
|
||||
this.focused = true;
|
||||
if (this.props.onFocus) {
|
||||
this.props.onFocus(ev);
|
||||
}
|
||||
};
|
||||
|
||||
focus() {
|
||||
if (this.input) {
|
||||
this.input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
type = 'text',
|
||||
type = "text",
|
||||
icon,
|
||||
label,
|
||||
margin,
|
||||
className,
|
||||
short,
|
||||
flex,
|
||||
labelHidden,
|
||||
onFocus,
|
||||
onBlur,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
const InputComponent = type === 'textarea' ? RealTextarea : RealInput;
|
||||
const InputComponent = type === "textarea" ? RealTextarea : RealInput;
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
|
||||
return (
|
||||
@@ -112,11 +145,14 @@ class Input extends React.Component<Props> {
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
<Outline focused={this.focused}>
|
||||
<Outline focused={this.focused} margin={margin}>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<InputComponent
|
||||
ref={(ref) => (this.input = ref)}
|
||||
onBlur={this.handleBlur}
|
||||
onFocus={this.handleFocus}
|
||||
type={type === 'textarea' ? undefined : type}
|
||||
type={type === "textarea" ? undefined : type}
|
||||
hasIcon={!!icon}
|
||||
{...rest}
|
||||
/>
|
||||
</Outline>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
import Input from "./Input";
|
||||
|
||||
const InputLarge = styled(Input)`
|
||||
height: 40px;
|
||||
|
||||
input {
|
||||
height: 40px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default InputLarge;
|
||||
+28
-38
@@ -1,26 +1,26 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import styled, { withTheme } from 'styled-components';
|
||||
import Input, { LabelText, Outline } from 'components/Input';
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Editor from "components/Editor";
|
||||
import HelpText from "components/HelpText";
|
||||
import { LabelText, Outline } from "components/Input";
|
||||
|
||||
type Props = {
|
||||
label: string,
|
||||
minHeight?: number,
|
||||
maxHeight?: number,
|
||||
readOnly?: boolean,
|
||||
ui: UiStore,
|
||||
};
|
||||
|
||||
@observer
|
||||
class InputRich extends React.Component<Props> {
|
||||
@observable editorComponent: *;
|
||||
@observable editorComponent: React.ComponentType<any>;
|
||||
@observable focused: boolean = false;
|
||||
|
||||
componentDidMount() {
|
||||
this.loadEditor();
|
||||
}
|
||||
|
||||
handleBlur = () => {
|
||||
this.focused = false;
|
||||
};
|
||||
@@ -29,52 +29,42 @@ class InputRich extends React.Component<Props> {
|
||||
this.focused = true;
|
||||
};
|
||||
|
||||
loadEditor = async () => {
|
||||
const EditorImport = await import('./Editor');
|
||||
this.editorComponent = EditorImport.default;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { label, minHeight, maxHeight, ...rest } = this.props;
|
||||
const Editor = this.editorComponent;
|
||||
const { label, minHeight, maxHeight, ui, ...rest } = this.props;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<LabelText>{label}</LabelText>
|
||||
{Editor ? (
|
||||
<StyledOutline
|
||||
maxHeight={maxHeight}
|
||||
minHeight={minHeight}
|
||||
focused={this.focused}
|
||||
>
|
||||
<StyledOutline
|
||||
maxHeight={maxHeight}
|
||||
minHeight={minHeight}
|
||||
focused={this.focused}
|
||||
>
|
||||
<React.Suspense fallback={<HelpText>Loading editor…</HelpText>}>
|
||||
<Editor
|
||||
onBlur={this.handleBlur}
|
||||
onFocus={this.handleFocus}
|
||||
ui={ui}
|
||||
grow
|
||||
{...rest}
|
||||
/>
|
||||
</StyledOutline>
|
||||
) : (
|
||||
<Input
|
||||
maxHeight={maxHeight}
|
||||
minHeight={minHeight}
|
||||
placeholder="Loading…"
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
</React.Suspense>
|
||||
</StyledOutline>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const StyledOutline = styled(Outline)`
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : '0')};
|
||||
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : 'auto')};
|
||||
overflow: scroll;
|
||||
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : "0")};
|
||||
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : "auto")};
|
||||
overflow-y: auto;
|
||||
|
||||
> * {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
export default withTheme(InputRich);
|
||||
export default inject("ui")(withTheme(InputRich));
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import keydown from "react-keydown";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import Input from "./Input";
|
||||
import { searchUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
theme: Object,
|
||||
placeholder?: string,
|
||||
collectionId?: string,
|
||||
};
|
||||
|
||||
@observer
|
||||
class InputSearch extends React.Component<Props> {
|
||||
input: ?Input;
|
||||
@observable focused: boolean = false;
|
||||
|
||||
@keydown("meta+f")
|
||||
focus(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
if (this.input) {
|
||||
this.input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
handleSearchInput = (ev) => {
|
||||
ev.preventDefault();
|
||||
this.props.history.push(
|
||||
searchUrl(ev.target.value, this.props.collectionId)
|
||||
);
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
this.focused = true;
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
this.focused = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { theme, placeholder = "Search…" } = this.props;
|
||||
|
||||
return (
|
||||
<InputMaxWidth
|
||||
ref={(ref) => (this.input = ref)}
|
||||
type="search"
|
||||
placeholder={placeholder}
|
||||
onInput={this.handleSearchInput}
|
||||
icon={
|
||||
<SearchIcon
|
||||
color={this.focused ? theme.inputBorderFocused : theme.inputBorder}
|
||||
/>
|
||||
}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
margin={0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const InputMaxWidth = styled(Input)`
|
||||
max-width: 30vw;
|
||||
`;
|
||||
|
||||
export default withTheme(withRouter(InputSearch));
|
||||
@@ -0,0 +1,72 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import VisuallyHidden from "components/VisuallyHidden";
|
||||
import { Outline, LabelText } from "./Input";
|
||||
|
||||
const Select = styled.select`
|
||||
border: 0;
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
outline: none;
|
||||
background: none;
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
`;
|
||||
|
||||
type Option = { label: string, value: string };
|
||||
|
||||
export type Props = {
|
||||
value?: string,
|
||||
label?: string,
|
||||
className?: string,
|
||||
labelHidden?: boolean,
|
||||
options: Option[],
|
||||
};
|
||||
|
||||
@observer
|
||||
class InputSelect extends React.Component<Props> {
|
||||
@observable focused: boolean = false;
|
||||
|
||||
handleBlur = () => {
|
||||
this.focused = false;
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
this.focused = true;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { label, className, labelHidden, options, ...rest } = this.props;
|
||||
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
|
||||
return (
|
||||
<label>
|
||||
{label &&
|
||||
(labelHidden ? (
|
||||
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
<Outline focused={this.focused} className={className}>
|
||||
<Select onBlur={this.handleBlur} onFocus={this.handleFocus} {...rest}>
|
||||
{options.map((option) => (
|
||||
<option value={option.value} key={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Outline>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default InputSelect;
|
||||
@@ -1,19 +1,19 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
import styled from "styled-components";
|
||||
|
||||
const Key = styled.kbd`
|
||||
display: inline-block;
|
||||
padding: 4px 6px;
|
||||
font: 11px 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier,
|
||||
font: 11px "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier,
|
||||
monospace;
|
||||
line-height: 10px;
|
||||
color: ${props => props.theme.almostBlack};
|
||||
color: ${(props) => props.theme.almostBlack};
|
||||
vertical-align: middle;
|
||||
background-color: ${props => props.theme.smokeLight};
|
||||
border: solid 1px ${props => props.theme.slateLight};
|
||||
border-bottom-color: ${props => props.theme.slate};
|
||||
background-color: ${(props) => props.theme.smokeLight};
|
||||
border: solid 1px ${(props) => props.theme.slateLight};
|
||||
border-bottom-color: ${(props) => props.theme.slate};
|
||||
border-radius: 3px;
|
||||
box-shadow: inset 0 -1px 0 ${props => props.theme.slate};
|
||||
box-shadow: inset 0 -1px 0 ${(props) => props.theme.slate};
|
||||
`;
|
||||
|
||||
export default Key;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import styled from 'styled-components';
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
type Props = {
|
||||
label: React.Node | string,
|
||||
@@ -21,7 +21,7 @@ export const Label = styled(Flex)`
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
color: ${props => props.theme.textTertiary};
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
letter-spacing: 0.04em;
|
||||
`;
|
||||
|
||||
|
||||
+52
-37
@@ -1,30 +1,31 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { Switch, Route, Redirect } from 'react-router-dom';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import styled, { withTheme } from 'styled-components';
|
||||
import breakpoint from 'styled-components-breakpoint';
|
||||
import { observable } from 'mobx';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import keydown from 'react-keydown';
|
||||
import Analytics from 'components/Analytics';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import keydown from "react-keydown";
|
||||
import { Switch, Route, Redirect } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import ErrorSuspended from "scenes/ErrorSuspended";
|
||||
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
|
||||
import Analytics from "components/Analytics";
|
||||
import DocumentHistory from "components/DocumentHistory";
|
||||
import { GlobalStyles } from "components/DropToImport";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
import { LoadingIndicatorBar } from "components/LoadingIndicator";
|
||||
import Modal from "components/Modal";
|
||||
import Sidebar from "components/Sidebar";
|
||||
import SettingsSidebar from "components/Sidebar/Settings";
|
||||
import {
|
||||
homeUrl,
|
||||
searchUrl,
|
||||
matchDocumentSlug as slug,
|
||||
} from 'utils/routeHelpers';
|
||||
|
||||
import { LoadingIndicatorBar } from 'components/LoadingIndicator';
|
||||
import { GlobalStyles } from 'components/DropToImport';
|
||||
import Sidebar from 'components/Sidebar';
|
||||
import SettingsSidebar from 'components/Sidebar/Settings';
|
||||
import Modals from 'components/Modals';
|
||||
import DocumentHistory from 'components/DocumentHistory';
|
||||
import ErrorSuspended from 'scenes/ErrorSuspended';
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import UiStore from 'stores/UiStore';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
} from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
documents: DocumentsStore,
|
||||
@@ -41,6 +42,7 @@ type Props = {
|
||||
class Layout extends React.Component<Props> {
|
||||
scrollable: ?HTMLDivElement;
|
||||
@observable redirectTo: ?string;
|
||||
@observable keyboardShortcutsOpen: boolean = false;
|
||||
|
||||
componentWillMount() {
|
||||
this.updateBackground();
|
||||
@@ -59,23 +61,30 @@ class Layout extends React.Component<Props> {
|
||||
window.document.body.style.background = this.props.theme.background;
|
||||
}
|
||||
|
||||
@keydown(['/', 't', 'meta+k'])
|
||||
@keydown("shift+/")
|
||||
handleOpenKeyboardShortcuts() {
|
||||
if (this.props.ui.editMode) return;
|
||||
this.keyboardShortcutsOpen = true;
|
||||
}
|
||||
|
||||
handleCloseKeyboardShortcuts = () => {
|
||||
this.keyboardShortcutsOpen = false;
|
||||
};
|
||||
|
||||
@keydown(["t", "/", "meta+k"])
|
||||
goToSearch(ev) {
|
||||
if (this.props.ui.editMode) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.redirectTo = searchUrl();
|
||||
}
|
||||
|
||||
@keydown('d')
|
||||
@keydown("d")
|
||||
goToDashboard() {
|
||||
if (this.props.ui.editMode) return;
|
||||
this.redirectTo = homeUrl();
|
||||
}
|
||||
|
||||
@keydown('shift+/')
|
||||
openKeyboardShortcuts() {
|
||||
this.props.ui.setActiveModal('keyboard-shortcuts');
|
||||
}
|
||||
|
||||
render() {
|
||||
const { auth, ui } = this.props;
|
||||
const { user, team } = auth;
|
||||
@@ -87,7 +96,7 @@ class Layout extends React.Component<Props> {
|
||||
return (
|
||||
<Container column auto>
|
||||
<Helmet>
|
||||
<title>Outline</title>
|
||||
<title>{team && team.name ? team.name : "Outline"}</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
@@ -117,7 +126,13 @@ class Layout extends React.Component<Props> {
|
||||
/>
|
||||
</Switch>
|
||||
</Container>
|
||||
<Modals ui={ui} />
|
||||
<Modal
|
||||
isOpen={this.keyboardShortcutsOpen}
|
||||
onRequestClose={this.handleCloseKeyboardShortcuts}
|
||||
title="Keyboard shortcuts"
|
||||
>
|
||||
<KeyboardShortcuts />
|
||||
</Modal>
|
||||
<GlobalStyles />
|
||||
</Container>
|
||||
);
|
||||
@@ -125,10 +140,10 @@ class Layout extends React.Component<Props> {
|
||||
}
|
||||
|
||||
const Container = styled(Flex)`
|
||||
background: ${props => props.theme.background};
|
||||
transition: ${props => props.theme.backgroundTransition};
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
`;
|
||||
|
||||
@@ -140,9 +155,9 @@ const Content = styled(Flex)`
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
${breakpoint('tablet')`
|
||||
margin-left: ${props => (props.editMode ? 0 : props.theme.sidebarWidth)};
|
||||
${breakpoint("tablet")`
|
||||
margin-left: ${(props) => (props.editMode ? 0 : props.theme.sidebarWidth)};
|
||||
`};
|
||||
`;
|
||||
|
||||
export default inject('auth', 'ui', 'documents')(withTheme(Layout));
|
||||
export default inject("auth", "ui", "documents")(withTheme(Layout));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
type Props = {
|
||||
image?: React.Node,
|
||||
@@ -16,7 +16,7 @@ const ListItem = ({ image, title, subtitle, actions }: Props) => {
|
||||
return (
|
||||
<Wrapper compact={compact}>
|
||||
{image && <Image>{image}</Image>}
|
||||
<Content align={compact ? 'center' : undefined} column={!compact}>
|
||||
<Content align={compact ? "center" : undefined} column={!compact}>
|
||||
<Heading>{title}</Heading>
|
||||
{subtitle && <Subtitle>{subtitle}</Subtitle>}
|
||||
</Content>
|
||||
@@ -27,9 +27,13 @@ const ListItem = ({ image, title, subtitle, actions }: Props) => {
|
||||
|
||||
const Wrapper = styled.li`
|
||||
display: flex;
|
||||
padding: ${props => (props.compact ? '8px' : '12px')} 0;
|
||||
padding: ${(props) => (props.compact ? "8px" : "12px")} 0;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid ${props => props.theme.divider};
|
||||
border-bottom: 1px solid ${(props) => props.theme.divider};
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const Image = styled(Flex)`
|
||||
@@ -37,6 +41,8 @@ const Image = styled(Flex)`
|
||||
max-height: 40px;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
`;
|
||||
|
||||
const Heading = styled.p`
|
||||
@@ -53,7 +59,7 @@ const Content = styled(Flex)`
|
||||
const Subtitle = styled.p`
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: ${props => props.theme.slate};
|
||||
color: ${(props) => props.theme.slate};
|
||||
`;
|
||||
|
||||
const Actions = styled.div`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
import styled from "styled-components";
|
||||
|
||||
const List = styled.ol`
|
||||
margin: 0;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { times } from 'lodash';
|
||||
import styled from 'styled-components';
|
||||
import Mask from 'components/Mask';
|
||||
import Fade from 'components/Fade';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { times } from "lodash";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import Mask from "components/Mask";
|
||||
|
||||
type Props = {
|
||||
count?: number,
|
||||
@@ -13,7 +13,7 @@ type Props = {
|
||||
const Placeholder = ({ count }: Props) => {
|
||||
return (
|
||||
<Fade>
|
||||
{times(count || 2, index => (
|
||||
{times(count || 2, (index) => (
|
||||
<Item key={index} column auto>
|
||||
<Mask />
|
||||
</Item>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// @flow
|
||||
import List from './List';
|
||||
import List from "./List";
|
||||
export default List;
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import { inject, observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import UiStore from "stores/UiStore";
|
||||
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
};
|
||||
|
||||
@observer
|
||||
class LoadingIndicator extends React.Component<*> {
|
||||
class LoadingIndicator extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
this.props.ui.enableProgressBar();
|
||||
}
|
||||
@@ -17,4 +22,4 @@ class LoadingIndicator extends React.Component<*> {
|
||||
}
|
||||
}
|
||||
|
||||
export default inject('ui')(LoadingIndicator);
|
||||
export default inject("ui")(LoadingIndicator);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled, { keyframes } from 'styled-components';
|
||||
import * as React from "react";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
|
||||
const LoadingIndicatorBar = () => {
|
||||
return (
|
||||
@@ -18,7 +18,7 @@ const loadingFrame = keyframes`
|
||||
const Container = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: 9999;
|
||||
z-index: ${(props) => props.theme.depths.loadingIndicatorBar};
|
||||
|
||||
background-color: #03a9f4;
|
||||
width: 100%;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import LoadingIndicator from './LoadingIndicator';
|
||||
import LoadingIndicatorBar from './LoadingIndicatorBar';
|
||||
import LoadingIndicator from "./LoadingIndicator";
|
||||
import LoadingIndicatorBar from "./LoadingIndicatorBar";
|
||||
export default LoadingIndicator;
|
||||
export { LoadingIndicatorBar };
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { times } from 'lodash';
|
||||
import styled from 'styled-components';
|
||||
import Mask from 'components/Mask';
|
||||
import Fade from 'components/Fade';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { times } from "lodash";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import Mask from "components/Mask";
|
||||
|
||||
type Props = {
|
||||
count?: number,
|
||||
@@ -13,7 +13,7 @@ type Props = {
|
||||
const ListPlaceHolder = ({ count }: Props) => {
|
||||
return (
|
||||
<Fade>
|
||||
{times(count || 2, index => (
|
||||
{times(count || 2, (index) => (
|
||||
<Item key={index} column auto>
|
||||
<Mask header />
|
||||
<Mask />
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import Mask from 'components/Mask';
|
||||
import Fade from 'components/Fade';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import DelayedMount from "components/DelayedMount";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import Mask from "components/Mask";
|
||||
|
||||
export default function LoadingPlaceholder(props: Object) {
|
||||
return (
|
||||
<Fade>
|
||||
<Flex column auto {...props}>
|
||||
<Mask height={34} />
|
||||
<br />
|
||||
<Mask />
|
||||
<Mask />
|
||||
<Mask />
|
||||
</Flex>
|
||||
</Fade>
|
||||
<DelayedMount>
|
||||
<Wrapper>
|
||||
<Flex column auto {...props}>
|
||||
<Mask height={34} />
|
||||
<br />
|
||||
<Mask />
|
||||
<Mask />
|
||||
<Mask />
|
||||
</Flex>
|
||||
</Wrapper>
|
||||
</DelayedMount>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled(Fade)`
|
||||
display: block;
|
||||
margin: 40px 0;
|
||||
`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import LoadingPlaceholder from './LoadingPlaceholder';
|
||||
import ListPlaceholder from './ListPlaceholder';
|
||||
import ListPlaceholder from "./ListPlaceholder";
|
||||
import LoadingPlaceholder from "./LoadingPlaceholder";
|
||||
|
||||
export default LoadingPlaceholder;
|
||||
export { ListPlaceholder };
|
||||
|
||||
+15
-9
@@ -1,11 +1,16 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { pulsate } from 'shared/styles/animations';
|
||||
import { randomInteger } from 'shared/random';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { randomInteger } from "shared/random";
|
||||
import { pulsate } from "shared/styles/animations";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
class Mask extends React.Component<*> {
|
||||
type Props = {
|
||||
header?: boolean,
|
||||
height?: number,
|
||||
};
|
||||
|
||||
class Mask extends React.Component<Props> {
|
||||
width: number;
|
||||
|
||||
shouldComponentUpdate() {
|
||||
@@ -22,10 +27,11 @@ class Mask extends React.Component<*> {
|
||||
}
|
||||
|
||||
const Redacted = styled(Flex)`
|
||||
width: ${props => (props.header ? props.width / 2 : props.width)}%;
|
||||
height: ${props => (props.height ? props.height : props.header ? 24 : 18)}px;
|
||||
width: ${(props) => (props.header ? props.width / 2 : props.width)}%;
|
||||
height: ${(props) =>
|
||||
props.height ? props.height : props.header ? 24 : 18}px;
|
||||
margin-bottom: 6px;
|
||||
background-color: ${props => props.theme.divider};
|
||||
background-color: ${(props) => props.theme.divider};
|
||||
animation: ${pulsate} 1.3s infinite;
|
||||
|
||||
&:last-child {
|
||||
|
||||
+95
-40
@@ -1,30 +1,54 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import styled, { createGlobalStyle } from 'styled-components';
|
||||
import breakpoint from 'styled-components-breakpoint';
|
||||
import ReactModal from 'react-modal';
|
||||
import { transparentize } from 'polished';
|
||||
import { CloseIcon } from 'outline-icons';
|
||||
import { fadeAndScaleIn } from 'shared/styles/animations';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon, BackIcon } from "outline-icons";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import ReactModal from "react-modal";
|
||||
import styled, { createGlobalStyle } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||
import Flex from "components/Flex";
|
||||
import NudeButton from "components/NudeButton";
|
||||
|
||||
ReactModal.setAppElement('#root');
|
||||
ReactModal.setAppElement("#root");
|
||||
|
||||
type Props = {
|
||||
children?: React.Node,
|
||||
isOpen: boolean,
|
||||
title?: string,
|
||||
onRequestClose: () => *,
|
||||
onRequestClose: () => void,
|
||||
};
|
||||
|
||||
const GlobalStyles = createGlobalStyle`
|
||||
.ReactModal__Overlay {
|
||||
background-color: ${props =>
|
||||
background-color: ${(props) =>
|
||||
transparentize(0.25, props.theme.background)} !important;
|
||||
z-index: 100;
|
||||
z-index: ${(props) => props.theme.depths.modalOverlay};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
.ReactModalPortal + .ReactModalPortal {
|
||||
.ReactModal__Overlay {
|
||||
margin-left: 12px;
|
||||
box-shadow: 0 -2px 10px ${(props) => props.theme.shadow};
|
||||
border-radius: 8px 0 0 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal {
|
||||
.ReactModal__Overlay {
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + .ReactModalPortal {
|
||||
.ReactModal__Overlay {
|
||||
margin-left: 36px;
|
||||
}
|
||||
}
|
||||
`};
|
||||
|
||||
.ReactModal__Body--open {
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -33,14 +57,14 @@ const GlobalStyles = createGlobalStyle`
|
||||
const Modal = ({
|
||||
children,
|
||||
isOpen,
|
||||
title = 'Untitled',
|
||||
title = "Untitled",
|
||||
onRequestClose,
|
||||
...rest
|
||||
}: Props) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<GlobalStyles />
|
||||
<StyledModal
|
||||
contentLabel={title}
|
||||
@@ -48,16 +72,20 @@ const Modal = ({
|
||||
isOpen={isOpen}
|
||||
{...rest}
|
||||
>
|
||||
<Content column>
|
||||
<Content onClick={(ev) => ev.stopPropagation()} column>
|
||||
{title && <h1>{title}</h1>}
|
||||
<Close onClick={onRequestClose}>
|
||||
<CloseIcon size={40} />
|
||||
<Esc>esc</Esc>
|
||||
</Close>
|
||||
|
||||
{children}
|
||||
</Content>
|
||||
<Back onClick={onRequestClose}>
|
||||
<BackIcon size={32} color="currentColor" />
|
||||
<Text>Back</Text>
|
||||
</Back>
|
||||
<Close onClick={onRequestClose}>
|
||||
<CloseIcon size={32} color="currentColor" />
|
||||
</Close>
|
||||
</StyledModal>
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -75,39 +103,66 @@ const StyledModal = styled(ReactModal)`
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
z-index: ${(props) => props.theme.depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
background: ${props => props.theme.background};
|
||||
transition: ${props => props.theme.backgroundTransition};
|
||||
padding: 13vh 2rem 2rem;
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
padding: 8vh 2rem 2rem;
|
||||
outline: none;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding-top: 13vh;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Esc = styled.span`
|
||||
const Text = styled.span`
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
padding-right: 12px;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const Close = styled(NudeButton)`
|
||||
position: absolute;
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: -10px;
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
const Close = styled.a`
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
opacity: 0.5;
|
||||
color: ${props => props.theme.textSecondary};
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin: 12px;
|
||||
opacity: 0.75;
|
||||
color: ${(props) => props.theme.text};
|
||||
width: auto;
|
||||
height: auto;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${breakpoint('tablet')`
|
||||
top: 3rem;
|
||||
right: 3rem;
|
||||
${breakpoint("tablet")`
|
||||
display: none;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Back = styled(NudeButton)`
|
||||
position: fixed;
|
||||
display: none;
|
||||
align-items: center;
|
||||
top: 2rem;
|
||||
left: 2rem;
|
||||
opacity: 0.75;
|
||||
color: ${(props) => props.theme.text};
|
||||
width: auto;
|
||||
height: auto;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import BaseModal from 'components/Modal';
|
||||
import UiStore from 'stores/UiStore';
|
||||
import CollectionNew from 'scenes/CollectionNew';
|
||||
import CollectionEdit from 'scenes/CollectionEdit';
|
||||
import CollectionDelete from 'scenes/CollectionDelete';
|
||||
import CollectionExport from 'scenes/CollectionExport';
|
||||
import DocumentDelete from 'scenes/DocumentDelete';
|
||||
import DocumentShare from 'scenes/DocumentShare';
|
||||
import KeyboardShortcuts from 'scenes/KeyboardShortcuts';
|
||||
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
};
|
||||
@observer
|
||||
class Modals extends React.Component<Props> {
|
||||
handleClose = () => {
|
||||
this.props.ui.clearActiveModal();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { activeModalName, activeModalProps } = this.props.ui;
|
||||
|
||||
const Modal = ({ name, children, ...rest }) => {
|
||||
return (
|
||||
<BaseModal
|
||||
isOpen={activeModalName === name}
|
||||
onRequestClose={this.handleClose}
|
||||
{...rest}
|
||||
>
|
||||
{React.cloneElement(children, activeModalProps)}
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<span>
|
||||
<Modal name="collection-new" title="Create a collection">
|
||||
<CollectionNew onSubmit={this.handleClose} />
|
||||
</Modal>
|
||||
<Modal name="collection-edit" title="Edit collection">
|
||||
<CollectionEdit onSubmit={this.handleClose} />
|
||||
</Modal>
|
||||
<Modal name="collection-delete" title="Delete collection">
|
||||
<CollectionDelete onSubmit={this.handleClose} />
|
||||
</Modal>
|
||||
<Modal name="collection-export" title="Export collection">
|
||||
<CollectionExport onSubmit={this.handleClose} />
|
||||
</Modal>
|
||||
<Modal name="document-share" title="Share document">
|
||||
<DocumentShare onSubmit={this.handleClose} />
|
||||
</Modal>
|
||||
<Modal name="document-delete" title="Delete document">
|
||||
<DocumentDelete onSubmit={this.handleClose} />
|
||||
</Modal>
|
||||
<Modal name="keyboard-shortcuts" title="Keyboard shortcuts">
|
||||
<KeyboardShortcuts />
|
||||
</Modal>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Modals;
|
||||
@@ -0,0 +1,12 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
|
||||
const Notice = styled.p`
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export default Notice;
|
||||
@@ -0,0 +1,24 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import Notice from "components/Notice";
|
||||
|
||||
export default function AlertNotice({ children }: { children: React.Node }) {
|
||||
return (
|
||||
<Notice muted>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ position: "relative", top: "2px" }}
|
||||
>
|
||||
<path
|
||||
d="M15.6676 11.5372L10.0155 1.14735C9.10744 -0.381434 6.89378 -0.383465 5.98447 1.14735L0.332715 11.5372C-0.595598 13.0994 0.528309 15.0776 2.34778 15.0776H13.652C15.47 15.0776 16.5959 13.101 15.6676 11.5372ZM8 13.2026C7.48319 13.2026 7.0625 12.7819 7.0625 12.2651C7.0625 11.7483 7.48319 11.3276 8 11.3276C8.51681 11.3276 8.9375 11.7483 8.9375 12.2651C8.9375 12.7819 8.51681 13.2026 8 13.2026ZM8.9375 9.45257C8.9375 9.96938 8.51681 10.3901 8 10.3901C7.48319 10.3901 7.0625 9.96938 7.0625 9.45257V4.76507C7.0625 4.24826 7.48319 3.82757 8 3.82757C8.51681 3.82757 8.9375 4.24826 8.9375 4.76507V9.45257Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>{" "}
|
||||
{children}
|
||||
</Notice>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// @flow
|
||||
import { lighten } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Button = styled.button`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
line-height: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
|
||||
&:focus {
|
||||
transition-duration: 0.05s;
|
||||
box-shadow: ${(props) => lighten(0.4, props.theme.buttonBackground)} 0px 0px
|
||||
0px 3px;
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.forwardRef<any, typeof Button>((props, ref) => (
|
||||
<Button {...props} ref={ref} />
|
||||
));
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
size?: number,
|
||||
@@ -7,7 +7,7 @@ type Props = {
|
||||
className?: string,
|
||||
};
|
||||
|
||||
function OutlineLogo({ size = 32, fill = '#333', className }: Props) {
|
||||
function OutlineLogo({ size = 32, fill = "#333", className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
+24
-15
@@ -1,23 +1,32 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
|
||||
type Props = {
|
||||
title: string,
|
||||
favicon?: string,
|
||||
auth: AuthStore,
|
||||
};
|
||||
|
||||
const PageTitle = ({ title, favicon }: Props) => (
|
||||
<Helmet>
|
||||
<title>{`${title} - Outline`}</title>
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
type="image/png"
|
||||
href={favicon || '/favicon-32.png'}
|
||||
sizes="32x32"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</Helmet>
|
||||
);
|
||||
const PageTitle = observer(({ auth, title, favicon }: Props) => {
|
||||
const { team } = auth;
|
||||
|
||||
export default PageTitle;
|
||||
return (
|
||||
<Helmet>
|
||||
<title>
|
||||
{team && team.name ? `${title} - ${team.name}` : `${title} - Outline`}
|
||||
</title>
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
type="image/png"
|
||||
href={favicon || "/favicon-32.png"}
|
||||
sizes="32x32"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</Helmet>
|
||||
);
|
||||
});
|
||||
|
||||
export default inject("auth")(PageTitle);
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observable, action } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import Waypoint from 'react-waypoint';
|
||||
|
||||
import { DEFAULT_PAGINATION_LIMIT } from 'stores/BaseStore';
|
||||
import Document from 'models/Document';
|
||||
import DocumentList from 'components/DocumentList';
|
||||
import { ListPlaceholder } from 'components/LoadingPlaceholder';
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import Document from "models/Document";
|
||||
import DocumentPreview from "components/DocumentPreview";
|
||||
import PaginatedList from "components/PaginatedList";
|
||||
|
||||
type Props = {
|
||||
documents: Document[],
|
||||
fetch: (options: ?Object) => Promise<*>,
|
||||
fetch: (options: ?Object) => Promise<void>,
|
||||
options?: Object,
|
||||
heading?: React.Node,
|
||||
empty?: React.Node,
|
||||
@@ -19,71 +15,20 @@ type Props = {
|
||||
|
||||
@observer
|
||||
class PaginatedDocumentList extends React.Component<Props> {
|
||||
@observable isLoaded: boolean = false;
|
||||
@observable isFetching: boolean = false;
|
||||
@observable offset: number = 0;
|
||||
@observable allowLoadMore: boolean = true;
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchResults();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (prevProps.fetch !== this.props.fetch) {
|
||||
this.fetchResults();
|
||||
}
|
||||
}
|
||||
|
||||
fetchResults = async () => {
|
||||
this.isFetching = true;
|
||||
|
||||
const limit = DEFAULT_PAGINATION_LIMIT;
|
||||
const results = await this.props.fetch({
|
||||
limit,
|
||||
offset: this.offset,
|
||||
...this.props.options,
|
||||
});
|
||||
|
||||
if (
|
||||
results &&
|
||||
(results.length === 0 || results.length < DEFAULT_PAGINATION_LIMIT)
|
||||
) {
|
||||
this.allowLoadMore = false;
|
||||
} else {
|
||||
this.offset += DEFAULT_PAGINATION_LIMIT;
|
||||
}
|
||||
|
||||
this.isLoaded = true;
|
||||
this.isFetching = false;
|
||||
};
|
||||
|
||||
@action
|
||||
loadMoreResults = async () => {
|
||||
// Don't paginate if there aren't more results or we’re in the middle of fetching
|
||||
if (!this.allowLoadMore || this.isFetching) return;
|
||||
await this.fetchResults();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { empty, heading, documents, fetch, options, ...rest } = this.props;
|
||||
const showLoading = !this.isLoaded && this.isFetching && !documents.length;
|
||||
const showEmpty = this.isLoaded && !documents.length;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{showEmpty ? (
|
||||
empty
|
||||
) : (
|
||||
<React.Fragment>
|
||||
{heading}
|
||||
<DocumentList documents={documents} {...rest} />
|
||||
{this.allowLoadMore && (
|
||||
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
<PaginatedList
|
||||
items={documents}
|
||||
empty={empty}
|
||||
heading={heading}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item) => (
|
||||
<DocumentPreview key={item.id} document={item} {...rest} />
|
||||
)}
|
||||
{showLoading && <ListPlaceholder count={5} />}
|
||||
</React.Fragment>
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// @flow
|
||||
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
|
||||
import { observable, action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
|
||||
import DelayedMount from "components/DelayedMount";
|
||||
import { ListPlaceholder } from "components/LoadingPlaceholder";
|
||||
|
||||
type Props = {
|
||||
fetch?: (options: ?Object) => Promise<void>,
|
||||
options?: Object,
|
||||
heading?: React.Node,
|
||||
empty?: React.Node,
|
||||
items: any[],
|
||||
renderItem: (any) => React.Node,
|
||||
};
|
||||
|
||||
@observer
|
||||
class PaginatedList extends React.Component<Props> {
|
||||
isInitiallyLoaded: boolean = false;
|
||||
@observable isLoaded: boolean = false;
|
||||
@observable isFetchingMore: boolean = false;
|
||||
@observable isFetching: boolean = false;
|
||||
@observable renderCount: number = DEFAULT_PAGINATION_LIMIT;
|
||||
@observable offset: number = 0;
|
||||
@observable allowLoadMore: boolean = true;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.isInitiallyLoaded = this.props.items.length > 0;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchResults();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (prevProps.fetch !== this.props.fetch) {
|
||||
this.fetchResults();
|
||||
}
|
||||
}
|
||||
|
||||
fetchResults = async () => {
|
||||
if (!this.props.fetch) return;
|
||||
|
||||
this.isFetching = true;
|
||||
|
||||
const limit = DEFAULT_PAGINATION_LIMIT;
|
||||
const results = await this.props.fetch({
|
||||
limit,
|
||||
offset: this.offset,
|
||||
...this.props.options,
|
||||
});
|
||||
|
||||
if (results && (results.length === 0 || results.length < limit)) {
|
||||
this.allowLoadMore = false;
|
||||
} else {
|
||||
this.offset += limit;
|
||||
}
|
||||
|
||||
this.renderCount += limit;
|
||||
this.isLoaded = true;
|
||||
this.isFetching = false;
|
||||
this.isFetchingMore = false;
|
||||
};
|
||||
|
||||
@action
|
||||
loadMoreResults = async () => {
|
||||
// Don't paginate if there aren't more results or we’re currently fetching
|
||||
if (!this.allowLoadMore || this.isFetching) return;
|
||||
|
||||
// If there are already cached results that we haven't yet rendered because
|
||||
// of lazy rendering then show another page.
|
||||
const leftToRender = this.props.items.length - this.renderCount;
|
||||
if (leftToRender > 1) {
|
||||
this.renderCount += DEFAULT_PAGINATION_LIMIT;
|
||||
}
|
||||
|
||||
// If there are less than a pages results in the cache go ahead and fetch
|
||||
// another page from the server
|
||||
if (leftToRender <= DEFAULT_PAGINATION_LIMIT) {
|
||||
this.isFetchingMore = true;
|
||||
await this.fetchResults();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { items, heading, empty } = this.props;
|
||||
|
||||
const showLoading =
|
||||
this.isFetching && !this.isFetchingMore && !this.isInitiallyLoaded;
|
||||
const showEmpty = !items.length && !showLoading;
|
||||
const showList =
|
||||
(this.isLoaded || this.isInitiallyLoaded) && !showLoading && !showEmpty;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showEmpty && empty}
|
||||
{showList && (
|
||||
<>
|
||||
{heading}
|
||||
<ArrowKeyNavigation
|
||||
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||
defaultActiveChildIndex={0}
|
||||
>
|
||||
{items.slice(0, this.renderCount).map(this.props.renderItem)}
|
||||
</ArrowKeyNavigation>
|
||||
{this.allowLoadMore && (
|
||||
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{showLoading && (
|
||||
<DelayedMount>
|
||||
<ListPlaceholder count={5} />
|
||||
</DelayedMount>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PaginatedList;
|
||||
@@ -1,30 +1,30 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import styled from 'styled-components';
|
||||
import { GoToIcon, CollectionIcon, PrivateCollectionIcon } from 'outline-icons';
|
||||
import Flex from 'shared/components/Flex';
|
||||
|
||||
import Document from 'models/Document';
|
||||
import Collection from 'models/Collection';
|
||||
import type { DocumentPath } from 'stores/CollectionsStore';
|
||||
import { observer } from "mobx-react";
|
||||
import { GoToIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import type { DocumentPath } from "stores/CollectionsStore";
|
||||
import Collection from "models/Collection";
|
||||
import Document from "models/Document";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
type Props = {
|
||||
result: DocumentPath,
|
||||
document?: ?Document,
|
||||
collection: ?Collection,
|
||||
onSuccess?: () => void,
|
||||
ref?: *,
|
||||
ref?: (?React.ElementRef<"div">) => void,
|
||||
};
|
||||
|
||||
@observer
|
||||
class PathToDocument extends React.Component<Props> {
|
||||
handleClick = async (ev: SyntheticEvent<*>) => {
|
||||
handleClick = async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { document, result, onSuccess } = this.props;
|
||||
if (!document) return;
|
||||
|
||||
if (result.type === 'document') {
|
||||
if (result.type === "document") {
|
||||
await document.move(result.collectionId, result.id);
|
||||
} else {
|
||||
await document.move(result.collectionId, null);
|
||||
@@ -41,18 +41,13 @@ class PathToDocument extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<Component ref={ref} onClick={this.handleClick} href="" selectable>
|
||||
{collection &&
|
||||
(collection.private ? (
|
||||
<PrivateCollectionIcon color={collection.color} />
|
||||
) : (
|
||||
<CollectionIcon color={collection.color} />
|
||||
))}
|
||||
{collection && <CollectionIcon collection={collection} />}
|
||||
{result.path
|
||||
.map(doc => <Title key={doc.id}>{doc.title}</Title>)
|
||||
.map((doc) => <Title key={doc.id}>{doc.title}</Title>)
|
||||
.reduce((prev, curr) => [prev, <StyledGoToIcon />, curr])}
|
||||
{document && (
|
||||
<Flex>
|
||||
{' '}
|
||||
{" "}
|
||||
<StyledGoToIcon /> <Title>{document.title}</Title>
|
||||
</Flex>
|
||||
)}
|
||||
@@ -77,11 +72,11 @@ const ResultWrapper = styled.div`
|
||||
margin-left: -4px;
|
||||
user-select: none;
|
||||
|
||||
color: ${props => props.theme.text};
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: default;
|
||||
`;
|
||||
|
||||
const ResultWrapperLink = styled(ResultWrapper.withComponent('a'))`
|
||||
const ResultWrapperLink = styled(ResultWrapper.withComponent("a"))`
|
||||
margin: 0 -8px;
|
||||
padding: 8px 4px;
|
||||
border-radius: 8px;
|
||||
@@ -89,7 +84,7 @@ const ResultWrapperLink = styled(ResultWrapper.withComponent('a'))`
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background: ${props => props.theme.listItemHoverBackground};
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import BoundlessPopover from 'boundless-popover';
|
||||
import styled, { keyframes } from 'styled-components';
|
||||
import BoundlessPopover from "boundless-popover";
|
||||
import * as React from "react";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
|
||||
const fadeIn = keyframes`
|
||||
from {
|
||||
@@ -22,7 +22,7 @@ const StyledPopover = styled(BoundlessPopover)`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 9999;
|
||||
z-index: ${(props) => props.theme.depths.popover};
|
||||
|
||||
svg {
|
||||
height: 16px;
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { inject } from 'mobx-react';
|
||||
import styled from 'styled-components';
|
||||
import Document from 'models/Document';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import Time from 'shared/components/Time';
|
||||
import Breadcrumb from 'shared/components/Breadcrumb';
|
||||
import CollectionsStore from 'stores/CollectionsStore';
|
||||
|
||||
const Container = styled(Flex)`
|
||||
color: ${props => props.theme.textTertiary};
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Modified = styled.span`
|
||||
color: ${props =>
|
||||
props.highlight ? props.theme.text : props.theme.textTertiary};
|
||||
font-weight: ${props => (props.highlight ? '600' : '400')};
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
collections: CollectionsStore,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
document: Document,
|
||||
views?: number,
|
||||
};
|
||||
|
||||
function PublishingInfo({
|
||||
collections,
|
||||
showPublished,
|
||||
showCollection,
|
||||
document,
|
||||
}: Props) {
|
||||
const {
|
||||
modifiedSinceViewed,
|
||||
updatedAt,
|
||||
updatedBy,
|
||||
publishedAt,
|
||||
archivedAt,
|
||||
deletedAt,
|
||||
isDraft,
|
||||
} = document;
|
||||
|
||||
const neverUpdated = publishedAt === updatedAt;
|
||||
let content;
|
||||
|
||||
if (deletedAt) {
|
||||
content = (
|
||||
<span>
|
||||
deleted <Time dateTime={deletedAt} /> ago
|
||||
</span>
|
||||
);
|
||||
} else if (archivedAt) {
|
||||
content = (
|
||||
<span>
|
||||
archived <Time dateTime={archivedAt} /> ago
|
||||
</span>
|
||||
);
|
||||
} else if (publishedAt && (neverUpdated || showPublished)) {
|
||||
content = (
|
||||
<span>
|
||||
published <Time dateTime={publishedAt} /> ago
|
||||
</span>
|
||||
);
|
||||
} else if (isDraft) {
|
||||
content = (
|
||||
<span>
|
||||
saved <Time dateTime={updatedAt} /> ago
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<Modified highlight={modifiedSinceViewed}>
|
||||
updated <Time dateTime={updatedAt} /> ago
|
||||
</Modified>
|
||||
);
|
||||
}
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
|
||||
return (
|
||||
<Container align="center">
|
||||
{updatedBy.name}
|
||||
{content}
|
||||
{showCollection &&
|
||||
collection && (
|
||||
<span>
|
||||
in
|
||||
<strong>
|
||||
{isDraft ? 'Drafts' : <Breadcrumb document={document} onlyText />}
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default inject('collections')(PublishingInfo);
|
||||
@@ -1,28 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { inject } from 'mobx-react';
|
||||
import { Route } from 'react-router-dom';
|
||||
import UiStore from 'stores/UiStore';
|
||||
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
component: *,
|
||||
};
|
||||
|
||||
class RouteSidebarHidden extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
this.props.ui.enableEditMode();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.ui.disableEditMode();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { component, ui, ...rest } = this.props;
|
||||
const Component = component;
|
||||
return <Route {...rest} render={props => <Component {...props} />} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default inject('ui')(RouteSidebarHidden);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user