mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
224 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bff6a80b67 | |||
| 07ad87f65f | |||
| dd471328db | |||
| 04f0983e20 | |||
| 50456c3b89 | |||
| 51230a55e5 | |||
| 6d4da176d1 | |||
| 88b3b50333 | |||
| 305de71e8b | |||
| 9cd3ec0868 | |||
| 6975d76faf | |||
| 4b27feff61 | |||
| e0d2b6cace | |||
| 188c1e409b | |||
| 9faa5dd011 | |||
| 1a62926909 | |||
| c4edfb8ebc | |||
| 8421e1f773 | |||
| 118e5da345 | |||
| 1c7c478a4a | |||
| 32cdb3f961 | |||
| d99d84d97d | |||
| aed8d7a649 | |||
| 80ad6cfec8 | |||
| 892146a563 | |||
| 56393f39b7 | |||
| 0de6650aa5 | |||
| ac551a3c44 | |||
| 14b9259a47 | |||
| e5b524e4c2 | |||
| 4bccb4c4ec | |||
| cdd4f0f315 | |||
| 728790e38f | |||
| c4c5b6289e | |||
| e337123cfd | |||
| ac07724f21 | |||
| 28439d315d | |||
| 4eb3b61c7a | |||
| 6fc608c8c1 | |||
| 2dc930bfe2 | |||
| bf233b209b | |||
| 1dfd1e0681 | |||
| 4054afe6f9 | |||
| 2d7dd558a1 | |||
| 68dd76cfa3 | |||
| 9113989635 | |||
| 293ce2ba72 | |||
| fa1ce950e8 | |||
| 0a77733500 | |||
| 41e425756d | |||
| 876f788f59 | |||
| 0ae559f7bf | |||
| da87fd422d | |||
| 1e84872bab | |||
| 4f0051ed5e | |||
| 317ed1f041 | |||
| 8a29a3523a | |||
| 2babf42cda | |||
| df14da01b0 | |||
| 62bb13047a | |||
| 6413797c34 | |||
| ef5e3f0b29 | |||
| 51249fd6f7 | |||
| 151c2c731a | |||
| 519ed1ac2c | |||
| f1ce28cd8f | |||
| adb56a3c31 | |||
| 280e1c1d86 | |||
| 3c8b9725e1 | |||
| 73de15fd5d | |||
| a78ad8dec2 | |||
| 45c082f137 | |||
| 4a9892c2e1 | |||
| 6d7f008af0 | |||
| bc7052b7ca | |||
| c4006cef7b | |||
| 2a6d6f5804 | |||
| bf0ff6c823 | |||
| 6c8b127ff9 | |||
| f2be756cf4 | |||
| 67049a7868 | |||
| d9706d4735 | |||
| ec748f9914 | |||
| ef668c2fa0 | |||
| 594a004c0f | |||
| 468478d06d | |||
| 02caf88d2a | |||
| 50f26929a1 | |||
| 0f93e92bc6 | |||
| c08940ca3c | |||
| ee8324ad73 | |||
| 96a32c98e7 | |||
| 5c741e3d98 | |||
| ba7b3fff05 | |||
| 90ca8655af | |||
| 0577c73f06 | |||
| 39e146b4e6 | |||
| 34576dd008 | |||
| 585a34d27e | |||
| 3c002f82cc | |||
| 51001cfac1 | |||
| 18e0d936ef | |||
| 5658090d7e | |||
| 19de348c85 | |||
| b8a02df7ba | |||
| 4c15f27bb2 | |||
| b152b9f17b | |||
| 40e41b26a1 | |||
| 4c01f6268e | |||
| 8815a58ff5 | |||
| 36a3ae4b01 | |||
| bca66f7415 | |||
| 06d966ad0c | |||
| c205ffbfe9 | |||
| b75a6928cb | |||
| 0ba792317b | |||
| e0cf873a36 | |||
| 1782c08195 | |||
| d9e7baf072 | |||
| ec1bc801a4 | |||
| 9117b7479f | |||
| eeb8008927 | |||
| 669575fc89 | |||
| 247208e5f5 | |||
| 25dce04046 | |||
| 5cd4ecd34a | |||
| bb074edb0d | |||
| a736022c39 | |||
| 677ca10b2b | |||
| 32c1d2e2f8 | |||
| c7e4f491eb | |||
| 5f6b6e2879 | |||
| 7aeb9c2cd2 | |||
| 4177031d0b | |||
| 7fa0199dca | |||
| 78da5e2335 | |||
| 964b4ef97d | |||
| d8fed83736 | |||
| 576497eca1 | |||
| 4fd0307814 | |||
| 2449434fef | |||
| 11477a1185 | |||
| 38409ff4ec | |||
| 2a11a23d5b | |||
| e49897ab5a | |||
| ceebc922cf | |||
| 7436d4c5c1 | |||
| 5cbea1eab2 | |||
| 93f770c4d4 | |||
| fcd4a2566a | |||
| 0cdf1f791e | |||
| 19ffff6fd2 | |||
| 044d551b60 | |||
| 26d4040cb5 | |||
| 3b62c76207 | |||
| 33ce49cc33 | |||
| bdcfaae025 | |||
| 0245451501 | |||
| e162e67396 | |||
| 233f3af667 | |||
| 1b913054e8 | |||
| b10802a0aa | |||
| 48893f727e | |||
| 2fb0182e16 | |||
| e4e98286f4 | |||
| 1e1a57d246 | |||
| b1aba32b62 | |||
| 0b5e48621a | |||
| 5b0a45c159 | |||
| 0883a56311 | |||
| 4c4b80ba9b | |||
| 1a8f2c3bb0 | |||
| c4046b1be5 | |||
| cf58d8e3e1 | |||
| 0ecfa95efc | |||
| 7f58fbe71b | |||
| 9e08717d25 | |||
| 5c7ebea14b | |||
| 9fe5148113 | |||
| f23f0d57de | |||
| d3ecab3489 | |||
| 1de732c82a | |||
| abbc3bdb30 | |||
| 86f1645199 | |||
| 5520317ce1 | |||
| 7f5bf6c6b3 | |||
| 11c009bdbf | |||
| f399c9d38c | |||
| 27597727ee | |||
| 31b95b5f17 | |||
| 963475d2b0 | |||
| cfa71762c2 | |||
| 4de0389055 | |||
| 9390434dde | |||
| b7a6a34565 | |||
| 9281287dba | |||
| 48fad5cfa0 | |||
| a47427de9e | |||
| e40f106dbb | |||
| b82176bae4 | |||
| 2d159d683b | |||
| 8f23504c64 | |||
| ae34570648 | |||
| 5c1888b0a4 | |||
| 75a868e5e8 | |||
| 5fb5e69181 | |||
| 58a059ae33 | |||
| 1f93027c97 | |||
| 63ed015a86 | |||
| 902cef8100 | |||
| 6aa680a41d | |||
| 5c24f9e1d5 | |||
| 15375bf199 | |||
| 9b5df51625 | |||
| f10cfbbd9e | |||
| 4f358032eb | |||
| 448f94ed04 | |||
| dbfdcd6d23 | |||
| 9c766362ed | |||
| 10fff7811f | |||
| cefceaac3e | |||
| 0d87de9f80 | |||
| 2e41ace386 | |||
| 20a69b711a |
+109
-83
@@ -1,51 +1,93 @@
|
||||
version: 2.1
|
||||
|
||||
defaults: &defaults
|
||||
working_directory: ~/outline
|
||||
docker:
|
||||
- image: cimg/node:14.19
|
||||
- image: cimg/redis:5.0
|
||||
- image: cimg/postgres:14.2
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: circle_test
|
||||
resource_class: large
|
||||
environment:
|
||||
NODE_ENV: test
|
||||
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
|
||||
DATABASE_URL_TEST: postgres://postgres:password@localhost:5432/circle_test
|
||||
DATABASE_URL: postgres://postgres:password@localhost:5432/circle_test
|
||||
URL: http://localhost:3000
|
||||
SMTP_FROM_EMAIL: hello@example.com
|
||||
AWS_S3_UPLOAD_BUCKET_URL: https://s3.amazonaws.com
|
||||
AWS_S3_UPLOAD_BUCKET_NAME: outline-circle
|
||||
|
||||
executors:
|
||||
docker-publisher:
|
||||
environment:
|
||||
IMAGE_NAME: outlinewiki/outline
|
||||
BASE_IMAGE_NAME: outlinewiki/outline-base
|
||||
docker:
|
||||
- image: circleci/buildpack-deps:stretch
|
||||
|
||||
jobs:
|
||||
build:
|
||||
working_directory: ~/outline
|
||||
docker:
|
||||
- image: circleci/node:14
|
||||
- image: circleci/redis:latest
|
||||
- image: circleci/postgres:9.6.5-alpine-ram
|
||||
environment:
|
||||
NODE_ENV: test
|
||||
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
|
||||
DATABASE_URL_TEST: postgres://root@localhost:5432/circle_test
|
||||
DATABASE_URL: postgres://root@localhost:5432/circle_test
|
||||
URL: http://localhost:3000
|
||||
SMTP_FROM_EMAIL: hello@example.com
|
||||
AWS_S3_UPLOAD_BUCKET_URL: https://s3.amazonaws.com
|
||||
AWS_S3_UPLOAD_BUCKET_NAME: outline-circle
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: install-deps
|
||||
command: yarn install --pure-lockfile
|
||||
command: yarn install --frozen-lockfile
|
||||
- save_cache:
|
||||
key: dependency-cache-{{ checksum "package.json" }}
|
||||
paths:
|
||||
- ./node_modules
|
||||
lint:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: lint
|
||||
command: yarn lint
|
||||
types:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: typescript
|
||||
command: yarn tsc
|
||||
test-app:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test:app
|
||||
test-server:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: migrate
|
||||
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
|
||||
- run:
|
||||
name: lint
|
||||
command: yarn lint
|
||||
- run:
|
||||
name: typescript
|
||||
command: yarn tsc
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test
|
||||
command: yarn test:server
|
||||
bundle-size:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: build-webpack
|
||||
command: yarn build:webpack
|
||||
@@ -56,59 +98,59 @@ jobs:
|
||||
- setup_remote_docker:
|
||||
version: 20.10.6
|
||||
- run:
|
||||
name: Build Docker image
|
||||
command: docker build -t $IMAGE_NAME:latest .
|
||||
- run:
|
||||
name: Archive Docker image
|
||||
command: docker save -o image.tar $IMAGE_NAME
|
||||
- persist_to_workspace:
|
||||
root: .
|
||||
paths:
|
||||
- ./image.tar
|
||||
publish-latest:
|
||||
executor: docker-publisher
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: /tmp/workspace
|
||||
- setup_remote_docker:
|
||||
version: 20.10.6
|
||||
- run:
|
||||
name: Load archived Docker image
|
||||
command: docker load -i /tmp/workspace/image.tar
|
||||
- run:
|
||||
name: Publish Docker Image to Docker Hub
|
||||
name: Install Docker buildx
|
||||
command: |
|
||||
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
|
||||
IMAGE_TAG=${CIRCLE_TAG/v/''}
|
||||
docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
|
||||
docker push $IMAGE_NAME:latest
|
||||
docker push $IMAGE_NAME:$IMAGE_TAG
|
||||
publish-tag:
|
||||
executor: docker-publisher
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: /tmp/workspace
|
||||
- setup_remote_docker:
|
||||
version: 20.10.6
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
url="https://github.com/docker/buildx/releases/download/v0.8.0/buildx-v0.8.0.linux-amd64"
|
||||
curl -sSL -o ~/.docker/cli-plugins/docker-buildx $url
|
||||
chmod a+x ~/.docker/cli-plugins/docker-buildx
|
||||
- run:
|
||||
name: Load archived Docker image
|
||||
command: docker load -i /tmp/workspace/image.tar
|
||||
name: Enable Docker buildx
|
||||
command: export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
- run:
|
||||
name: Publish Docker Image to Docker Hub
|
||||
name: Initialize Docker buildx
|
||||
command: |
|
||||
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
|
||||
IMAGE_TAG=${CIRCLE_TAG/v/''}
|
||||
docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
|
||||
docker push $IMAGE_NAME:$IMAGE_TAG
|
||||
docker buildx install
|
||||
docker context create docker-multiarch
|
||||
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
|
||||
docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch
|
||||
docker buildx inspect --builder docker-multiarch --bootstrap
|
||||
docker buildx use docker-multiarch
|
||||
- run:
|
||||
name: Build base image
|
||||
command: docker build -f Dockerfile.base -t $BASE_IMAGE_NAME:latest --load .
|
||||
- run:
|
||||
name: Login to Docker Hub
|
||||
command: echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
|
||||
- run:
|
||||
name: Publish base Docker Image to Docker Hub
|
||||
command: docker push $BASE_IMAGE_NAME:latest
|
||||
- run:
|
||||
name: Build and push Docker image
|
||||
command: docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
build-and-test:
|
||||
all:
|
||||
jobs:
|
||||
- build:
|
||||
filters:
|
||||
tags:
|
||||
ignore: /^v.*/
|
||||
- build
|
||||
- lint:
|
||||
requires:
|
||||
- build
|
||||
- test-server:
|
||||
requires:
|
||||
- build
|
||||
- test-app:
|
||||
requires:
|
||||
- build
|
||||
- types:
|
||||
requires:
|
||||
- build
|
||||
- bundle-size:
|
||||
requires:
|
||||
- test-app
|
||||
- test-server
|
||||
|
||||
build-docker:
|
||||
jobs:
|
||||
- build-image:
|
||||
@@ -117,19 +159,3 @@ workflows:
|
||||
only: /^v.*/
|
||||
branches:
|
||||
ignore: /.*/
|
||||
- publish-latest:
|
||||
requires:
|
||||
- build-image
|
||||
filters:
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
branches:
|
||||
ignore: /.*/
|
||||
- publish-tag:
|
||||
requires:
|
||||
- build-image
|
||||
filters:
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+-.*$/
|
||||
branches:
|
||||
ignore: /.*/
|
||||
|
||||
+10
-6
@@ -16,7 +16,15 @@ DATABASE_CONNECTION_POOL_MIN=
|
||||
DATABASE_CONNECTION_POOL_MAX=
|
||||
# Uncomment this to disable SSL for connecting to Postgres
|
||||
# PGSSLMODE=disable
|
||||
|
||||
# For redis you can either specify an ioredis compatible url like this
|
||||
REDIS_URL=redis://localhost:6379
|
||||
# or alternatively, if you would like to provide addtional connection options,
|
||||
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
|
||||
# for a list of available options.
|
||||
# Example: Use Redis Sentinel for high availability
|
||||
# {"sentinels":[{"host":"sentinel-0","port":26379},{"host":"sentinel-1","port":26379}],"name":"mymaster"}
|
||||
# REDIS_URL=ioredis://eyJzZW50aW5lbHMiOlt7Imhvc3QiOiJzZW50aW5lbC0wIiwicG9ydCI6MjYzNzl9LHsiaG9zdCI6InNlbnRpbmVsLTEiLCJwb3J0IjoyNjM3OX1dLCJuYW1lIjoibXltYXN0ZXIifQ==
|
||||
|
||||
# URL should point to the fully qualified, publicly accessible URL. If using a
|
||||
# proxy the port in URL and PORT may be different.
|
||||
@@ -57,8 +65,8 @@ AWS_S3_ACL=private
|
||||
#
|
||||
# When configuring the Client ID, add a redirect URL under "OAuth & Permissions":
|
||||
# https://<URL>/auth/slack.callback
|
||||
SLACK_KEY=get_a_key_from_slack
|
||||
SLACK_SECRET=get_the_secret_of_above_key
|
||||
SLACK_CLIENT_ID=get_a_key_from_slack
|
||||
SLACK_CLIENT_SECRET=get_the_secret_of_above_key
|
||||
|
||||
# To configure Google auth, you'll need to create an OAuth Client ID at
|
||||
# => https://console.cloud.google.com/apis/credentials
|
||||
@@ -129,10 +137,6 @@ MAXIMUM_IMPORT_SIZE=5120000
|
||||
# requests and this ends up being duplicative
|
||||
DEBUG=http
|
||||
|
||||
# Comma separated list of domains to be allowed to signin to the wiki. If not
|
||||
# set, all domains are allowed by default when using Google OAuth to signin
|
||||
ALLOWED_DOMAINS=
|
||||
|
||||
# For a complete Slack integration with search and posting to channels the
|
||||
# following configs are also needed, some more details
|
||||
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"es",
|
||||
"@typescript-eslint",
|
||||
"eslint-plugin-import",
|
||||
"eslint-plugin-node",
|
||||
@@ -28,6 +29,7 @@
|
||||
"curly": 2,
|
||||
"no-mixed-operators": "off",
|
||||
"no-useless-escape": "off",
|
||||
"es/no-regexp-lookbehind-assertions": "error",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 120
|
||||
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 14
|
||||
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- security
|
||||
- pinned
|
||||
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: stale
|
||||
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
Hey! The issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed soon if no further activity occurs. Please
|
||||
reply here if you wish for the issue to be kept open.
|
||||
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
@@ -0,0 +1,56 @@
|
||||
# Image Actions will run in the following scenarios:
|
||||
# - on Pull Requests containing images (not including forks)
|
||||
# - on pushing of images to `main` (for forks)
|
||||
# - on demand (https://github.blog/changelog/2020-07-06-github-actions-manual-triggers-with-workflow_dispatch/)
|
||||
# - at 11 PM every Sunday in anything gets missed with any of the above scenarios
|
||||
# For Pull Requests, the images are added to the PR.
|
||||
# For other scenarios, a new PR will be opened if any images are compressed.
|
||||
name: Compress images
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "**.jpg"
|
||||
- "**.jpeg"
|
||||
- "**.png"
|
||||
- "**.webp"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "**.jpg"
|
||||
- "**.jpeg"
|
||||
- "**.png"
|
||||
- "**.webp"
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "00 20 * * 0"
|
||||
jobs:
|
||||
build:
|
||||
name: calibreapp/image-actions
|
||||
runs-on: ubuntu-latest
|
||||
# Only run on main repo on and PRs that match the main repo.
|
||||
if: |
|
||||
github.repository == 'outline/outline' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository)
|
||||
steps:
|
||||
- name: Checkout Branch
|
||||
uses: actions/checkout@v2
|
||||
- name: Compress Images
|
||||
id: calibre
|
||||
uses: calibreapp/image-actions@main
|
||||
with:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
# For non-Pull Requests, run in compressOnly mode and we'll PR after.
|
||||
compressOnly: ${{ github.event_name != 'pull_request' }}
|
||||
- name: Create Pull Request
|
||||
# If it's not a Pull Request then commit any changes as a new PR.
|
||||
if: |
|
||||
github.event_name != 'pull_request' &&
|
||||
steps.calibre.outputs.markdown != ''
|
||||
uses: peter-evans/create-pull-request@v3
|
||||
with:
|
||||
title: "chore: Auto Compress Images"
|
||||
branch-suffix: timestamp
|
||||
commit-message: "chore: Compressed inefficient images automatically"
|
||||
body: ${{ steps.calibre.outputs.markdown }}
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -67,4 +67,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
uses: github/codeql-action/analyze@v2
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
name: "Close Stale PRs"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v5
|
||||
with:
|
||||
stale-pr-message: "This PR is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
|
||||
stale-issue-message: "This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
|
||||
close-pr-message: "Automatically closed due to inactivity"
|
||||
close-issue-message: "Automatically closed due to inactivity"
|
||||
days-before-issue-stale: 120
|
||||
days-before-pr-stale: 60
|
||||
days-before-close: 5
|
||||
operations-per-run: 60
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: "security,pinned"
|
||||
- name: Print outputs
|
||||
run: echo ${{ join(steps.stale.outputs.*, ',') }}
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
+8
-31
@@ -1,45 +1,22 @@
|
||||
# syntax=docker/dockerfile:1.2
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:16-alpine AS deps-common
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
COPY ./package.json ./yarn.lock ./
|
||||
|
||||
# ---
|
||||
FROM deps-common AS deps-dev
|
||||
RUN yarn install --no-optional --frozen-lockfile && \
|
||||
yarn cache clean
|
||||
|
||||
# ---
|
||||
FROM deps-common AS deps-prod
|
||||
RUN yarn install --production=true --frozen-lockfile && \
|
||||
yarn cache clean
|
||||
|
||||
# ---
|
||||
FROM node:16-alpine AS builder
|
||||
FROM outlinewiki/outline-base as base
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
COPY . .
|
||||
COPY --from=deps-dev $APP_PATH/node_modules ./node_modules
|
||||
ARG CDN_URL
|
||||
RUN yarn build
|
||||
|
||||
# ---
|
||||
FROM node:16-alpine AS runner
|
||||
FROM node:16.14.2-alpine3.15 AS runner
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
ENV NODE_ENV production
|
||||
|
||||
COPY --from=builder $APP_PATH/build ./build
|
||||
COPY --from=builder $APP_PATH/server ./server
|
||||
COPY --from=builder $APP_PATH/public ./public
|
||||
COPY --from=builder $APP_PATH/.sequelizerc ./.sequelizerc
|
||||
COPY --from=deps-prod $APP_PATH/node_modules ./node_modules
|
||||
COPY --from=builder $APP_PATH/package.json ./package.json
|
||||
COPY --from=base $APP_PATH/build ./build
|
||||
COPY --from=base $APP_PATH/server ./server
|
||||
COPY --from=base $APP_PATH/public ./public
|
||||
COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
|
||||
COPY --from=base $APP_PATH/node_modules ./node_modules
|
||||
COPY --from=base $APP_PATH/package.json ./package.json
|
||||
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodejs -u 1001 && \
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:16.14.2-alpine3.15 AS deps
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
COPY ./package.json ./yarn.lock ./
|
||||
|
||||
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
|
||||
yarn cache clean
|
||||
|
||||
COPY . .
|
||||
ARG CDN_URL
|
||||
RUN yarn build
|
||||
|
||||
RUN rm -rf node_modules
|
||||
|
||||
RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \
|
||||
yarn cache clean
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.62.0
|
||||
Licensed Work: Outline 0.64.0
|
||||
The Licensed Work is (c) 2020 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2026-03-01
|
||||
Change Date: 2026-05-23
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -43,10 +43,6 @@
|
||||
"value": "true",
|
||||
"required": true
|
||||
},
|
||||
"ALLOWED_DOMAINS": {
|
||||
"description": "Comma separated list of domains to be allowed (optional). If not set, all domains are allowed by default when using Google OAuth to signin. Consider putting {your app name}.herokuapp.com and any domain you are binding on in this list.",
|
||||
"required": false
|
||||
},
|
||||
"URL": {
|
||||
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to",
|
||||
"required": true
|
||||
@@ -106,11 +102,11 @@
|
||||
"value": "openid profile email",
|
||||
"required": false
|
||||
},
|
||||
"SLACK_KEY": {
|
||||
"SLACK_CLIENT_ID": {
|
||||
"description": "See https://api.slack.com/apps to create a new Slack app. You must configure at least one of Slack or Google to control login.",
|
||||
"required": false
|
||||
},
|
||||
"SLACK_SECRET": {
|
||||
"SLACK_CLIENT_SECRET": {
|
||||
"description": "Your Slack client secret - d2dc414f9953226bad0a356cXXXXYYYY",
|
||||
"required": false
|
||||
},
|
||||
@@ -209,4 +205,4 @@
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
|
||||
import DocumentTemplatize from "~/scenes/DocumentTemplatize";
|
||||
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
|
||||
import { createAction } from "~/actions";
|
||||
import { DocumentSection } from "~/actions/sections";
|
||||
import history from "~/utils/history";
|
||||
@@ -306,18 +306,13 @@ export const createTemplate = createAction({
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Create template"),
|
||||
content: (
|
||||
<DocumentTemplatize
|
||||
documentId={activeDocumentId}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
isCentered: true,
|
||||
content: <DocumentTemplatizeDialog documentId={activeDocumentId} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -91,6 +91,8 @@ export const navigateToSettings = createAction({
|
||||
section: NavigationSection,
|
||||
shortcut: ["g", "s"],
|
||||
icon: <SettingsIcon />,
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").update,
|
||||
perform: () => history.push(organizationSettingsPath()),
|
||||
});
|
||||
|
||||
|
||||
+12
-24
@@ -10,6 +10,10 @@ import {
|
||||
MenuItemWithChildren,
|
||||
} from "~/types";
|
||||
|
||||
function resolve<T>(value: any, context: ActionContext): T {
|
||||
return typeof value === "function" ? value(context) : value;
|
||||
}
|
||||
|
||||
export function createAction(definition: Optional<Action, "id">): Action {
|
||||
return {
|
||||
...definition,
|
||||
@@ -21,18 +25,10 @@ export function actionToMenuItem(
|
||||
action: Action,
|
||||
context: ActionContext
|
||||
): MenuItemButton | MenuItemWithChildren {
|
||||
function resolve<T>(value: any): T {
|
||||
if (typeof value === "function") {
|
||||
return value(context);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon);
|
||||
const resolvedChildren = resolve<Action[]>(action.children);
|
||||
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon, context);
|
||||
const resolvedChildren = resolve<Action[]>(action.children, context);
|
||||
const visible = action.visible ? action.visible(context) : true;
|
||||
const title = resolve<string>(action.name);
|
||||
const title = resolve<string>(action.name, context);
|
||||
const icon =
|
||||
resolvedIcon && action.iconInContextMenu !== false
|
||||
? React.cloneElement(resolvedIcon, {
|
||||
@@ -69,23 +65,15 @@ export function actionToKBar(
|
||||
action: Action,
|
||||
context: ActionContext
|
||||
): CommandBarAction[] {
|
||||
function resolve<T>(value: any): T {
|
||||
if (typeof value === "function") {
|
||||
return value(context);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof action.visible === "function" && !action.visible(context)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon);
|
||||
const resolvedChildren = resolve<Action[]>(action.children);
|
||||
const resolvedSection = resolve<string>(action.section);
|
||||
const resolvedName = resolve<string>(action.name);
|
||||
const resolvedPlaceholder = resolve<string>(action.placeholder);
|
||||
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon, context);
|
||||
const resolvedChildren = resolve<Action[]>(action.children, context);
|
||||
const resolvedSection = resolve<string>(action.section, context);
|
||||
const resolvedName = resolve<string>(action.name, context);
|
||||
const resolvedPlaceholder = resolve<string>(action.placeholder, context);
|
||||
const children = resolvedChildren
|
||||
? flattenDeep(resolvedChildren.map((a) => actionToKBar(a, context))).filter(
|
||||
(a) => !!a
|
||||
|
||||
@@ -2,9 +2,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import { isCustomSubdomain } from "@shared/utils/domains";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import env from "~/env";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { changeLanguage } from "~/utils/language";
|
||||
|
||||
@@ -25,29 +23,11 @@ const Authenticated = ({ children }: Props) => {
|
||||
|
||||
if (auth.authenticated) {
|
||||
const { user, team } = auth;
|
||||
const { hostname } = window.location;
|
||||
|
||||
if (!team || !user) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
// If we're authenticated but viewing a domain that doesn't match the
|
||||
// current team then kick the user to the teams correct domain.
|
||||
if (team.domain) {
|
||||
if (team.domain !== hostname) {
|
||||
window.location.href = `${team.url}${window.location.pathname}`;
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
} else if (
|
||||
env.SUBDOMAINS_ENABLED &&
|
||||
team.subdomain &&
|
||||
isCustomSubdomain(hostname) &&
|
||||
!hostname.startsWith(`${team.subdomain}.`)
|
||||
) {
|
||||
window.location.href = `${team.url}${window.location.pathname}`;
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,11 @@ class AuthenticatedLayout extends React.Component<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
goToNewDocument = () => {
|
||||
goToNewDocument = (event: KeyboardEvent) => {
|
||||
if (event.metaKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { activeCollectionId } = this.props.ui;
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 976 B After Width: | Height: | Size: 564 B |
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths } from "@shared/styles";
|
||||
import env from "~/env";
|
||||
import OutlineLogo from "./OutlineLogo";
|
||||
|
||||
@@ -38,7 +39,7 @@ const Link = styled.a`
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
z-index: ${(props: any) => props.theme.depths.sidebar + 1};
|
||||
z-index: ${depths.sidebar + 1};
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
@@ -111,7 +111,7 @@ const RealButton = styled.button<{
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: none;
|
||||
background: ${lighten(0.05, props.theme.danger)};
|
||||
}
|
||||
|
||||
&.focus-visible {
|
||||
|
||||
@@ -6,14 +6,14 @@ type Props = {
|
||||
withStickyHeader?: boolean;
|
||||
};
|
||||
|
||||
const Container = styled.div<{ withStickyHeader?: boolean }>`
|
||||
const Container = styled.div<Props>`
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: ${(props: any) =>
|
||||
props.withStickyHeader ? "4px 60px 60px" : "60px"};
|
||||
padding: ${(props: Props) =>
|
||||
props.withStickyHeader ? "4px 44px 60px" : "60px 44px"};
|
||||
`};
|
||||
`;
|
||||
|
||||
|
||||
@@ -42,8 +42,9 @@ function Collaborators(props: Props) {
|
||||
filter(
|
||||
users.orderedData,
|
||||
(user) =>
|
||||
presentIds.includes(user.id) ||
|
||||
document.collaboratorIds.includes(user.id)
|
||||
(presentIds.includes(user.id) ||
|
||||
document.collaboratorIds.includes(user.id)) &&
|
||||
!user.isSuspended
|
||||
),
|
||||
(user) => presentIds.includes(user.id)
|
||||
),
|
||||
@@ -52,18 +53,14 @@ function Collaborators(props: Props) {
|
||||
|
||||
// load any users we don't yet have in memory
|
||||
React.useEffect(() => {
|
||||
const userIdsToFetch = uniq([
|
||||
...document.collaboratorIds,
|
||||
...presentIds,
|
||||
]).filter((userId) => !users.get(userId));
|
||||
const ids = uniq([...document.collaboratorIds, ...presentIds])
|
||||
.filter((userId) => !users.get(userId))
|
||||
.sort();
|
||||
|
||||
if (!isEqual(requestedUserIds, userIdsToFetch)) {
|
||||
setRequestedUserIds(userIdsToFetch);
|
||||
if (!isEqual(requestedUserIds, ids) && ids.length > 0) {
|
||||
setRequestedUserIds(ids);
|
||||
users.fetchPage({ ids, limit: 100 });
|
||||
}
|
||||
|
||||
userIdsToFetch
|
||||
.filter((userId) => requestedUserIds.includes(userId))
|
||||
.forEach((userId) => users.fetch(userId));
|
||||
}, [document, users, presentIds, document.collaboratorIds, requestedUserIds]);
|
||||
|
||||
const popover = usePopoverState({
|
||||
|
||||
@@ -3,12 +3,10 @@ import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Text from "~/components/Text";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { homePath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
@@ -16,39 +14,29 @@ type Props = {
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
function CollectionDelete({ collection, onSubmit }: Props) {
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
function CollectionDeleteDialog({ collection, onSubmit }: Props) {
|
||||
const team = useCurrentTeam();
|
||||
const { showToast } = useToasts();
|
||||
const { ui } = useStores();
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
setIsDeleting(true);
|
||||
|
||||
try {
|
||||
const redirect = collection.id === ui.activeCollectionId;
|
||||
await collection.delete();
|
||||
onSubmit();
|
||||
if (redirect) {
|
||||
history.push(homePath());
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
},
|
||||
[collection, history, onSubmit, showToast, ui.activeCollectionId]
|
||||
);
|
||||
const handleSubmit = async () => {
|
||||
const redirect = collection.id === ui.activeCollectionId;
|
||||
await collection.delete();
|
||||
onSubmit();
|
||||
if (redirect) {
|
||||
history.push(homePath());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("I’m sure – Delete")}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<>
|
||||
<Text type="secondary">
|
||||
<Trans
|
||||
defaults="Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash."
|
||||
@@ -73,12 +61,9 @@ function CollectionDelete({ collection, onSubmit }: Props) {
|
||||
/>
|
||||
</Text>
|
||||
) : null}
|
||||
<Button type="submit" disabled={isDeleting} autoFocus danger>
|
||||
{isDeleting ? `${t("Deleting")}…` : t("I’m sure – Delete")}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
</>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(CollectionDelete);
|
||||
export default observer(CollectionDeleteDialog);
|
||||
@@ -1,3 +1,4 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
@@ -9,7 +10,6 @@ import ButtonLink from "~/components/ButtonLink";
|
||||
import Editor from "~/components/Editor";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import useDebouncedCallback from "~/hooks/useDebouncedCallback";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
@@ -49,21 +49,25 @@ function CollectionDescription({ collection }: Props) {
|
||||
[isExpanded]
|
||||
);
|
||||
|
||||
const handleSave = useDebouncedCallback(async (getValue) => {
|
||||
try {
|
||||
await collection.save({
|
||||
description: getValue(),
|
||||
});
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
showToast(
|
||||
t("Sorry, an error occurred saving the collection", {
|
||||
type: "error",
|
||||
})
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}, 1000);
|
||||
const handleSave = React.useMemo(
|
||||
() =>
|
||||
debounce(async (getValue) => {
|
||||
try {
|
||||
await collection.save({
|
||||
description: getValue(),
|
||||
});
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
showToast(
|
||||
t("Sorry, an error occurred saving the collection", {
|
||||
type: "error",
|
||||
})
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}, 1000),
|
||||
[collection, showToast, t]
|
||||
);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(getValue) => {
|
||||
@@ -107,7 +111,6 @@ function CollectionDescription({ collection }: Props) {
|
||||
maxLength={1000}
|
||||
embedsDisabled
|
||||
readOnlyWriteCheckboxes
|
||||
grow
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as React from "react";
|
||||
import Collection from "~/models/Collection";
|
||||
import { icons } from "~/components/IconPicker";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Logger from "~/utils/Logger";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -36,7 +37,9 @@ function ResolvedCollectionIcon({
|
||||
const Component = icons[collection.icon].component;
|
||||
return <Component color={color} size={size} />;
|
||||
} catch (error) {
|
||||
console.warn("Failed to render custom icon " + collection.icon);
|
||||
Logger.warn("Failed to render custom icon", {
|
||||
icon: collection.icon,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths } from "@shared/styles";
|
||||
import CommandBarResults from "~/components/CommandBarResults";
|
||||
import SearchActions from "~/components/SearchActions";
|
||||
import rootActions from "~/actions/root";
|
||||
@@ -90,7 +91,7 @@ const Hint = styled(Text)`
|
||||
`;
|
||||
|
||||
const Positioner = styled(KBarPositioner)`
|
||||
z-index: ${(props) => props.theme.depths.commandBar};
|
||||
z-index: ${depths.commandBar};
|
||||
`;
|
||||
|
||||
const SearchInput = styled(KBarSearch)`
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
/** Callback when the dialog is submitted */
|
||||
onSubmit: () => Promise<void> | void;
|
||||
/** Text to display on the submit button */
|
||||
submitText?: string;
|
||||
/** Text to display while the form is saving */
|
||||
savingText?: string;
|
||||
/** If true, the submit button will be a dangerous red */
|
||||
danger?: boolean;
|
||||
};
|
||||
|
||||
const ConfirmationDialog: React.FC<Props> = ({
|
||||
onSubmit,
|
||||
children,
|
||||
submitText,
|
||||
savingText,
|
||||
danger,
|
||||
}) => {
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const { dialogs } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSubmit();
|
||||
dialogs.closeAllModals();
|
||||
} catch (err) {
|
||||
showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[onSubmit, dialogs, showToast]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text type="secondary">{children}</Text>
|
||||
<Button type="submit" disabled={isSaving} danger={danger} autoFocus>
|
||||
{isSaving ? savingText : submitText}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(ConfirmationDialog);
|
||||
@@ -3,11 +3,10 @@ import * as React from "react";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { hover } from "~/styles";
|
||||
import MenuIconWrapper from "../MenuIconWrapper";
|
||||
|
||||
type Props = {
|
||||
onClick?: (arg0: React.SyntheticEvent) => void | Promise<void>;
|
||||
onClick?: (event: React.SyntheticEvent) => void | Promise<void>;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
@@ -132,16 +131,18 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
? "pointer-events: none;"
|
||||
: `
|
||||
|
||||
&:${hover},
|
||||
&:focus,
|
||||
&.focus-visible {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.primary};
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.focus-visible {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.primary};
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.white};
|
||||
svg {
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
}
|
||||
}
|
||||
`};
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from "react";
|
||||
import { MenuSeparator } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
|
||||
export default function Separator(rest: any) {
|
||||
export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
|
||||
return (
|
||||
<MenuSeparator {...rest}>
|
||||
{(props) => <HorizontalRule {...props} />}
|
||||
|
||||
@@ -69,29 +69,27 @@ const Submenu = React.forwardRef(
|
||||
);
|
||||
|
||||
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
let filtered = items.filter((item) => item.visible !== false);
|
||||
|
||||
// this block literally just trims unnecessary separators
|
||||
filtered = filtered.reduce((acc, item, index) => {
|
||||
// trim separators from start / end
|
||||
if (item.type === "separator" && index === 0) {
|
||||
return acc;
|
||||
}
|
||||
if (item.type === "separator" && index === filtered.length - 1) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
// trim double separators looking ahead / behind
|
||||
const prev = filtered[index - 1];
|
||||
if (prev && prev.type === "separator" && item.type === "separator") {
|
||||
return acc;
|
||||
}
|
||||
|
||||
// otherwise, continue
|
||||
return [...acc, item];
|
||||
}, []);
|
||||
|
||||
return filtered;
|
||||
return items
|
||||
.filter((item) => item.visible !== false)
|
||||
.reduce((acc, item) => {
|
||||
// trim separator if the previous item was a separator
|
||||
if (
|
||||
item.type === "separator" &&
|
||||
acc[acc.length - 1]?.type === "separator"
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
return [...acc, item];
|
||||
}, [] as TMenuItem[])
|
||||
.filter((item, index, arr) => {
|
||||
if (
|
||||
item.type === "separator" &&
|
||||
(index === 0 || index === arr.length - 1)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function Template({ items, actions, context, ...menu }: Props) {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Portal } from "react-portal";
|
||||
import { Menu } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import styled, { DefaultTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths } from "@shared/styles";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useMenuContext from "~/hooks/useMenuContext";
|
||||
import useMenuHeight from "~/hooks/useMenuHeight";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
@@ -90,6 +93,19 @@ const ContextMenu: React.FC<Props> = ({
|
||||
t,
|
||||
]);
|
||||
|
||||
// We must manually manage scroll lock for iOS support so that the scrollable
|
||||
// element can be passed into body-scroll-lock. See:
|
||||
// https://github.com/ariakit/ariakit/issues/469
|
||||
React.useEffect(() => {
|
||||
const scrollElement = backgroundRef.current;
|
||||
if (rest.visible && scrollElement) {
|
||||
disableBodyScroll(scrollElement);
|
||||
}
|
||||
return () => {
|
||||
scrollElement && enableBodyScroll(scrollElement);
|
||||
};
|
||||
}, [rest.visible]);
|
||||
|
||||
// Perf win – don't render anything until the menu has been opened
|
||||
if (!rest.visible && !previousVisible) {
|
||||
return null;
|
||||
@@ -99,7 +115,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
// trigger and the bottom of the window
|
||||
return (
|
||||
<>
|
||||
<Menu hideOnClickOutside preventBodyScroll {...rest}>
|
||||
<Menu hideOnClickOutside preventBodyScroll={false} {...rest}>
|
||||
{(props) => {
|
||||
// kind of hacky, but this is an effective way of telling which way
|
||||
// the menu will _actually_ be placed when taking into account screen
|
||||
@@ -115,6 +131,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
topAnchor={topAnchor}
|
||||
rightAnchor={rightAnchor}
|
||||
ref={backgroundRef}
|
||||
hiddenScrollbars
|
||||
style={
|
||||
maxHeight && topAnchor
|
||||
? {
|
||||
@@ -148,7 +165,7 @@ export const Backdrop = styled.div`
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: ${(props) => props.theme.backdrop};
|
||||
z-index: ${(props) => props.theme.depths.menu - 1};
|
||||
z-index: ${depths.menu - 1};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: none;
|
||||
@@ -157,10 +174,12 @@ export const Backdrop = styled.div`
|
||||
|
||||
export const Position = styled.div`
|
||||
position: absolute;
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
z-index: ${depths.menu};
|
||||
|
||||
// overrides make mobile-first coding style challenging
|
||||
// so we explicitly define mobile breakpoint here
|
||||
/*
|
||||
* overrides make mobile-first coding style challenging
|
||||
* so we explicitly define mobile breakpoint here
|
||||
*/
|
||||
${breakpoint("mobile", "tablet")`
|
||||
position: fixed !important;
|
||||
transform: none !important;
|
||||
@@ -171,10 +190,13 @@ export const Position = styled.div`
|
||||
`};
|
||||
`;
|
||||
|
||||
export const Background = styled.div<{
|
||||
type BackgroundProps = {
|
||||
topAnchor?: boolean;
|
||||
rightAnchor?: boolean;
|
||||
}>`
|
||||
theme: DefaultTheme;
|
||||
};
|
||||
|
||||
export const Background = styled(Scrollable)<BackgroundProps>`
|
||||
animation: ${mobileContextMenu} 200ms ease;
|
||||
transform-origin: 50% 100%;
|
||||
max-width: 100%;
|
||||
@@ -183,8 +205,6 @@ export const Background = styled.div<{
|
||||
padding: 6px 0;
|
||||
min-width: 180px;
|
||||
min-height: 44px;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: 75vh;
|
||||
pointer-events: all;
|
||||
font-weight: normal;
|
||||
@@ -194,11 +214,12 @@ export const Background = styled.div<{
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
animation: ${(props: any) =>
|
||||
animation: ${(props: BackgroundProps) =>
|
||||
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
|
||||
transform-origin: ${(props: any) => (props.rightAnchor ? "75%" : "25%")} 0;
|
||||
transform-origin: ${(props: BackgroundProps) =>
|
||||
props.rightAnchor ? "75%" : "25%"} 0;
|
||||
max-width: 276px;
|
||||
background: ${(props: any) => props.theme.menuBackground};
|
||||
box-shadow: ${(props: any) => props.theme.menuShadow};
|
||||
background: ${(props: BackgroundProps) => props.theme.menuBackground};
|
||||
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import * as React from "react";
|
||||
import env from "~/env";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
children?: React.ReactElement;
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
onCopy: () => void;
|
||||
onCopy?: () => void;
|
||||
};
|
||||
|
||||
class CopyToClipboard extends React.PureComponent<Props> {
|
||||
@@ -14,12 +15,11 @@ class CopyToClipboard extends React.PureComponent<Props> {
|
||||
const elem = React.Children.only(children);
|
||||
|
||||
copy(text, {
|
||||
debug: process.env.NODE_ENV !== "production",
|
||||
debug: env.ENVIRONMENT !== "production",
|
||||
format: "text/plain",
|
||||
});
|
||||
if (onCopy) {
|
||||
onCopy();
|
||||
}
|
||||
|
||||
onCopy?.();
|
||||
|
||||
if (elem && elem.props && typeof elem.props.onClick === "function") {
|
||||
elem.props.onClick(ev);
|
||||
|
||||
@@ -73,42 +73,61 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
const lastUpdatedByCurrentUser = user.id === updatedBy.id;
|
||||
const userName = updatedBy.name;
|
||||
let content;
|
||||
|
||||
if (deletedAt) {
|
||||
content = (
|
||||
<span>
|
||||
{t("deleted")} <Time dateTime={deletedAt} addSuffix />
|
||||
{lastUpdatedByCurrentUser
|
||||
? t("You deleted")
|
||||
: t("{{ userName }} deleted", { userName })}{" "}
|
||||
<Time dateTime={deletedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (archivedAt) {
|
||||
content = (
|
||||
<span>
|
||||
{t("archived")} <Time dateTime={archivedAt} addSuffix />
|
||||
{lastUpdatedByCurrentUser
|
||||
? t("You archived")
|
||||
: t("{{ userName }} archived", { userName })}{" "}
|
||||
<Time dateTime={archivedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (createdAt === updatedAt) {
|
||||
content = (
|
||||
<span>
|
||||
{t("created")} <Time dateTime={updatedAt} addSuffix />
|
||||
{lastUpdatedByCurrentUser
|
||||
? t("You created")
|
||||
: t("{{ userName }} created", { userName })}{" "}
|
||||
<Time dateTime={updatedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (publishedAt && (publishedAt === updatedAt || showPublished)) {
|
||||
content = (
|
||||
<span>
|
||||
{t("published")} <Time dateTime={publishedAt} addSuffix />
|
||||
{lastUpdatedByCurrentUser
|
||||
? t("You published")
|
||||
: t("{{ userName }} published", { userName })}{" "}
|
||||
<Time dateTime={publishedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (isDraft) {
|
||||
content = (
|
||||
<span>
|
||||
{t("saved")} <Time dateTime={updatedAt} addSuffix />
|
||||
{lastUpdatedByCurrentUser
|
||||
? t("You saved")
|
||||
: t("{{ userName }} saved", { userName })}{" "}
|
||||
<Time dateTime={updatedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<Modified highlight={modifiedSinceViewed && !lastUpdatedByCurrentUser}>
|
||||
{t("updated")} <Time dateTime={updatedAt} addSuffix />
|
||||
{lastUpdatedByCurrentUser
|
||||
? t("You updated")
|
||||
: t("{{ userName }} updated", { userName })}{" "}
|
||||
<Time dateTime={updatedAt} addSuffix />
|
||||
</Modified>
|
||||
);
|
||||
}
|
||||
@@ -143,7 +162,6 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
|
||||
{lastUpdatedByCurrentUser ? t("You") : updatedBy.name}
|
||||
{to ? <Link to={to}>{content}</Link> : content}
|
||||
{showCollection && collection && (
|
||||
<span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useObserver } from "mobx-react";
|
||||
import { observer, useObserver } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
@@ -83,4 +83,4 @@ const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
export default DocumentMetaWithViews;
|
||||
export default observer(DocumentMetaWithViews);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { DoneIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, TFunction } from "react-i18next";
|
||||
@@ -60,4 +61,4 @@ const Done = styled(DoneIcon)<{ $animated: boolean }>`
|
||||
transform-origin: center center;
|
||||
`;
|
||||
|
||||
export default DocumentTasks;
|
||||
export default observer(DocumentTasks);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import invariant from "invariant";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { documentUrl } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
};
|
||||
|
||||
function DocumentTemplatizeDialog({ documentId }: Props) {
|
||||
const history = useHistory();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const document = documents.get(documentId);
|
||||
invariant(document, "Document must exist");
|
||||
|
||||
const handleSubmit = React.useCallback(async () => {
|
||||
const template = await document?.templatize();
|
||||
if (template) {
|
||||
history.push(documentUrl(template));
|
||||
showToast(t("Template created, go ahead and customize it"), {
|
||||
type: "info",
|
||||
});
|
||||
}
|
||||
}, [document, showToast, history, t]);
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("Create template")}
|
||||
savingText={`${t("Creating")}…`}
|
||||
>
|
||||
<Trans
|
||||
defaults="Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents."
|
||||
values={{
|
||||
titleWithDefault: document.titleWithDefault,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(DocumentTemplatizeDialog);
|
||||
@@ -4,6 +4,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
@@ -42,7 +43,7 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
<PaginatedList
|
||||
aria-label={t("Viewers")}
|
||||
items={users}
|
||||
renderItem={(item) => {
|
||||
renderItem={(item: User) => {
|
||||
const view = documentViews.find((v) => v.user.id === item.id);
|
||||
const isPresent = presentIds.includes(item.id);
|
||||
const isEditing = editingIds.includes(item.id);
|
||||
|
||||
+200
-13
@@ -1,15 +1,30 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { deburr, sortBy } from "lodash";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import mergeRefs from "react-merge-refs";
|
||||
import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import embeds from "@shared/editor/embeds";
|
||||
import { Heading } from "@shared/editor/lib/getHeadings";
|
||||
import { supportedImageMimeTypes } from "@shared/utils/files";
|
||||
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import Document from "~/models/Document";
|
||||
import ClickablePadding from "~/components/ClickablePadding";
|
||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
import HoverPreview from "~/components/HoverPreview";
|
||||
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { NotFoundError } from "~/utils/errors";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import history from "~/utils/history";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import { isHash } from "~/utils/urls";
|
||||
import DocumentBreadcrumb from "./DocumentBreadcrumb";
|
||||
|
||||
const LazyLoadedEditor = React.lazy(
|
||||
() =>
|
||||
@@ -32,14 +47,82 @@ export type Props = Optional<
|
||||
shareId?: string | undefined;
|
||||
embedsDisabled?: boolean;
|
||||
grow?: boolean;
|
||||
onHeadingsChange?: (headings: Heading[]) => void;
|
||||
onSynced?: () => Promise<void>;
|
||||
onPublish?: (event: React.MouseEvent) => any;
|
||||
};
|
||||
|
||||
function Editor(props: Props, ref: React.Ref<SharedEditor>) {
|
||||
const { id, shareId } = props;
|
||||
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const { id, shareId, onChange, onHeadingsChange } = props;
|
||||
const { documents } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const dictionary = useDictionary();
|
||||
const [
|
||||
activeLinkEvent,
|
||||
setActiveLinkEvent,
|
||||
] = React.useState<MouseEvent | null>(null);
|
||||
const previousHeadings = React.useRef<Heading[] | null>(null);
|
||||
|
||||
const handleLinkActive = React.useCallback((event: MouseEvent) => {
|
||||
setActiveLinkEvent(event);
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const handleLinkInactive = React.useCallback(() => {
|
||||
setActiveLinkEvent(null);
|
||||
}, []);
|
||||
|
||||
const handleSearchLink = React.useCallback(
|
||||
async (term: string) => {
|
||||
if (isInternalUrl(term)) {
|
||||
// search for exact internal document
|
||||
const slug = parseDocumentSlug(term);
|
||||
if (!slug) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const document = await documents.fetch(slug);
|
||||
const time = formatDistanceToNow(Date.parse(document.updatedAt), {
|
||||
addSuffix: true,
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
title: document.title,
|
||||
subtitle: `Updated ${time}`,
|
||||
url: document.url,
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
// NotFoundError could not find document for slug
|
||||
if (!(error instanceof NotFoundError)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// default search for anything that doesn't look like a URL
|
||||
const results = await documents.searchTitles(term);
|
||||
|
||||
return sortBy(
|
||||
results.map((document: Document) => {
|
||||
return {
|
||||
title: document.title,
|
||||
subtitle: <DocumentBreadcrumb document={document} onlyText />,
|
||||
url: document.url,
|
||||
};
|
||||
}),
|
||||
(document) =>
|
||||
deburr(document.title)
|
||||
.toLowerCase()
|
||||
.startsWith(deburr(term).toLowerCase())
|
||||
? -1
|
||||
: 1
|
||||
);
|
||||
},
|
||||
[documents]
|
||||
);
|
||||
|
||||
const onUploadFile = React.useCallback(
|
||||
async (file: File) => {
|
||||
@@ -85,19 +168,123 @@ function Editor(props: Props, ref: React.Ref<SharedEditor>) {
|
||||
[shareId]
|
||||
);
|
||||
|
||||
const focusAtEnd = React.useCallback(() => {
|
||||
ref?.current?.focusAtEnd();
|
||||
}, [ref]);
|
||||
|
||||
const handleDrop = React.useCallback(
|
||||
(event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const files = getDataTransferFiles(event);
|
||||
const view = ref?.current?.view;
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert all files as attachments if any of the files are not images.
|
||||
const isAttachment = files.some(
|
||||
(file) => !supportedImageMimeTypes.includes(file.type)
|
||||
);
|
||||
|
||||
// Find a valid position at the end of the document
|
||||
const pos = TextSelection.near(
|
||||
view.state.doc.resolve(view.state.doc.nodeSize - 2)
|
||||
).from;
|
||||
|
||||
insertFiles(view, event, pos, files, {
|
||||
uploadFile: onUploadFile,
|
||||
onFileUploadStart: props.onFileUploadStart,
|
||||
onFileUploadStop: props.onFileUploadStop,
|
||||
onShowToast: showToast,
|
||||
dictionary,
|
||||
isAttachment,
|
||||
});
|
||||
},
|
||||
[
|
||||
ref,
|
||||
props.onFileUploadStart,
|
||||
props.onFileUploadStop,
|
||||
dictionary,
|
||||
onUploadFile,
|
||||
showToast,
|
||||
]
|
||||
);
|
||||
|
||||
// see: https://stackoverflow.com/a/50233827/192065
|
||||
const handleDragOver = React.useCallback(
|
||||
(event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Calculate if headings have changed and trigger callback if so
|
||||
const updateHeadings = React.useCallback(() => {
|
||||
if (onHeadingsChange) {
|
||||
const headings = ref?.current?.getHeadings();
|
||||
if (
|
||||
headings &&
|
||||
headings.map((h) => h.level + h.title).join("") !==
|
||||
previousHeadings.current?.map((h) => h.level + h.title).join("")
|
||||
) {
|
||||
previousHeadings.current = headings;
|
||||
onHeadingsChange(headings);
|
||||
}
|
||||
}
|
||||
}, [ref, onHeadingsChange]);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(event) => {
|
||||
onChange?.(event);
|
||||
updateHeadings();
|
||||
},
|
||||
[onChange, updateHeadings]
|
||||
);
|
||||
|
||||
const handleRefChanged = React.useCallback(
|
||||
(node: SharedEditor | null) => {
|
||||
if (node && !previousHeadings.current) {
|
||||
updateHeadings();
|
||||
}
|
||||
},
|
||||
[updateHeadings]
|
||||
);
|
||||
|
||||
return (
|
||||
<ErrorBoundary reloadOnChunkMissing>
|
||||
<LazyLoadedEditor
|
||||
ref={ref}
|
||||
uploadFile={onUploadFile}
|
||||
onShowToast={showToast}
|
||||
embeds={embeds}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onClickLink={onClickLink}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
<>
|
||||
<LazyLoadedEditor
|
||||
ref={mergeRefs([ref, handleRefChanged])}
|
||||
uploadFile={onUploadFile}
|
||||
onShowToast={showToast}
|
||||
embeds={embeds}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onHoverLink={handleLinkActive}
|
||||
onClickLink={onClickLink}
|
||||
onSearchLink={handleSearchLink}
|
||||
onChange={handleChange}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
{props.grow && !props.readOnly && (
|
||||
<ClickablePadding
|
||||
onClick={focusAtEnd}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
grow
|
||||
/>
|
||||
)}
|
||||
{activeLinkEvent && !shareId && (
|
||||
<HoverPreview
|
||||
node={activeLinkEvent.target as HTMLAnchorElement}
|
||||
event={activeLinkEvent}
|
||||
onClose={handleLinkInactive}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
@@ -10,7 +9,8 @@ import CenteredContent from "~/components/CenteredContent";
|
||||
import PageTitle from "~/components/PageTitle";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import isHosted from "~/utils/isHosted";
|
||||
import Logger from "~/utils/Logger";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
reloadOnChunkMissing?: boolean;
|
||||
@@ -26,7 +26,6 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
this.error = error;
|
||||
console.error(error);
|
||||
|
||||
if (
|
||||
this.props.reloadOnChunkMissing &&
|
||||
@@ -40,9 +39,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (env.SENTRY_DSN) {
|
||||
Sentry.captureException(error);
|
||||
}
|
||||
Logger.error("ErrorBoundary", error);
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
@@ -62,7 +59,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
|
||||
if (this.error) {
|
||||
const error = this.error;
|
||||
const isReported = !!env.SENTRY_DSN && isHosted;
|
||||
const isReported = !!env.SENTRY_DSN && isCloudHosted;
|
||||
const isChunkError = this.error.message.match(/chunk/);
|
||||
|
||||
if (isChunkError) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
PublishIcon,
|
||||
MoveIcon,
|
||||
CheckboxIcon,
|
||||
UnpublishIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -85,6 +86,11 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
meta = t("{{userName}} published", opts);
|
||||
break;
|
||||
|
||||
case "documents.unpublish":
|
||||
icon = <UnpublishIcon color="currentColor" size={16} />;
|
||||
meta = t("{{userName}} unpublished", opts);
|
||||
break;
|
||||
|
||||
case "documents.move":
|
||||
icon = <MoveIcon color="currentColor" size={16} />;
|
||||
meta = t("{{userName}} moved", opts);
|
||||
@@ -113,7 +119,10 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
<Time
|
||||
dateTime={event.createdAt}
|
||||
tooltipDelay={500}
|
||||
format="MMM do, h:mm a"
|
||||
format={{
|
||||
en_US: "MMM do, h:mm a",
|
||||
fr_FR: "'Le 'd MMMM 'à' H:mm",
|
||||
}}
|
||||
relative={false}
|
||||
addSuffix
|
||||
onClick={handleTimeClick}
|
||||
|
||||
@@ -11,11 +11,19 @@ const Flex = styled.div<{
|
||||
align?: AlignValues;
|
||||
justify?: JustifyValues;
|
||||
shrink?: boolean;
|
||||
reverse?: boolean;
|
||||
gap?: number;
|
||||
}>`
|
||||
display: flex;
|
||||
flex: ${({ auto }) => (auto ? "1 1 auto" : "initial")};
|
||||
flex-direction: ${({ column }) => (column ? "column" : "row")};
|
||||
flex-direction: ${({ column, reverse }) =>
|
||||
reverse
|
||||
? column
|
||||
? "column-reverse"
|
||||
: "row-reverse"
|
||||
: column
|
||||
? "column"
|
||||
: "row"};
|
||||
align-items: ${({ align }) => align};
|
||||
justify-content: ${({ justify }) => justify};
|
||||
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
|
||||
|
||||
@@ -19,7 +19,7 @@ type Props = RootStore & {
|
||||
membership?: CollectionGroupMembership;
|
||||
showFacepile?: boolean;
|
||||
showAvatar?: boolean;
|
||||
renderActions: (arg0: { openMembersModal: () => void }) => React.ReactNode;
|
||||
renderActions: (params: { openMembersModal: () => void }) => React.ReactNode;
|
||||
};
|
||||
|
||||
@observer
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
|
||||
import styled from "styled-components";
|
||||
import { depths } from "@shared/styles";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
|
||||
@@ -72,7 +72,7 @@ const Backdrop = styled.div`
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: ${(props) => props.theme.backdrop} !important;
|
||||
z-index: ${(props) => props.theme.depths.modalOverlay};
|
||||
z-index: ${depths.modalOverlay};
|
||||
transition: opacity 200ms ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
@@ -87,7 +87,7 @@ const Scene = styled.div`
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: 12px;
|
||||
z-index: ${(props) => props.theme.depths.modal};
|
||||
z-index: ${depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
@@ -111,4 +111,4 @@ const Content = styled(Scrollable)`
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
export default observer(Guide);
|
||||
export default Guide;
|
||||
|
||||
+11
-11
@@ -5,9 +5,11 @@ import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths } from "@shared/styles";
|
||||
import Button from "~/components/Button";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { supportsPassiveListener } from "~/utils/browser";
|
||||
@@ -28,19 +30,17 @@ function Header({ breadcrumb, title, actions, hasSidebar }: Props) {
|
||||
const passThrough = !actions && !breadcrumb && !title;
|
||||
|
||||
const [isScrolled, setScrolled] = React.useState(false);
|
||||
const handleScroll = React.useCallback(
|
||||
throttle(() => setScrolled(window.scrollY > 75), 50),
|
||||
const handleScroll = React.useMemo(
|
||||
() => throttle(() => setScrolled(window.scrollY > 75), 50),
|
||||
[]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
window.addEventListener(
|
||||
"scroll",
|
||||
handleScroll,
|
||||
supportsPassiveListener ? { passive: true } : false
|
||||
);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [handleScroll]);
|
||||
useEventListener(
|
||||
"scroll",
|
||||
handleScroll,
|
||||
window,
|
||||
supportsPassiveListener ? { passive: true } : { capture: false }
|
||||
);
|
||||
|
||||
const handleClickTitle = React.useCallback(() => {
|
||||
window.scrollTo({
|
||||
@@ -100,7 +100,7 @@ const Actions = styled(Flex)`
|
||||
|
||||
const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
|
||||
top: 0;
|
||||
z-index: ${(props) => props.theme.depths.header};
|
||||
z-index: ${depths.header};
|
||||
position: sticky;
|
||||
background: ${(props) => props.theme.background};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { escapeRegExp } from "lodash";
|
||||
import * as React from "react";
|
||||
import replace from "string-replace-to-array";
|
||||
import styled from "styled-components";
|
||||
@@ -23,7 +24,7 @@ function Highlight({
|
||||
regex = highlight;
|
||||
} else {
|
||||
regex = new RegExp(
|
||||
(highlight || "").replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&"),
|
||||
escapeRegExp(highlight || ""),
|
||||
caseSensitive ? "g" : "gi"
|
||||
);
|
||||
}
|
||||
@@ -41,10 +42,10 @@ function Highlight({
|
||||
);
|
||||
}
|
||||
|
||||
const Mark = styled.mark`
|
||||
export const Mark = styled.mark`
|
||||
background: ${(props) => props.theme.searchHighlight};
|
||||
border-radius: 2px;
|
||||
padding: 0 4px;
|
||||
padding: 0 2px;
|
||||
`;
|
||||
|
||||
export default Highlight;
|
||||
|
||||
@@ -2,9 +2,11 @@ import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import { depths } from "@shared/styles";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import { isExternalUrl } from "@shared/utils/urls";
|
||||
import HoverPreviewDocument from "~/components/HoverPreviewDocument";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { fadeAndSlideDown } from "~/styles/animations";
|
||||
|
||||
@@ -123,8 +125,13 @@ function HoverPreviewInternal({ node, onClose }: Props) {
|
||||
}
|
||||
|
||||
function HoverPreview({ node, ...rest }: Props) {
|
||||
const isMobile = useMobile();
|
||||
if (isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// previews only work for internal doc links for now
|
||||
if (!isInternalUrl(node.href)) {
|
||||
if (isExternalUrl(node.href)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -150,7 +157,7 @@ const Margin = styled.div`
|
||||
|
||||
const CardContent = styled.div`
|
||||
overflow: hidden;
|
||||
max-height: 350px;
|
||||
max-height: 20em;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
@@ -195,7 +202,7 @@ const Card = styled.div`
|
||||
const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
|
||||
margin-top: 10px;
|
||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
||||
z-index: ${(props) => props.theme.depths.hoverPreview};
|
||||
z-index: ${depths.hoverPreview};
|
||||
display: flex;
|
||||
max-height: 75%;
|
||||
|
||||
|
||||
@@ -38,6 +38,13 @@ const RealInput = styled.input<{ hasIcon?: boolean }>`
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
|
||||
&:-webkit-autofill,
|
||||
&:-webkit-autofill:hover,
|
||||
&:-webkit-autofill:focus {
|
||||
-webkit-box-shadow: 0 0 0px 1000px ${(props) => props.theme.background}
|
||||
inset;
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
@@ -97,7 +104,7 @@ export const LabelText = styled.div`
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
export type Props = React.HTMLAttributes<HTMLInputElement> & {
|
||||
export type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onChange"> & {
|
||||
type?: "text" | "email" | "checkbox" | "search" | "textarea";
|
||||
value?: string;
|
||||
label?: string;
|
||||
@@ -108,6 +115,7 @@ export type Props = React.HTMLAttributes<HTMLInputElement> & {
|
||||
margin?: string | number;
|
||||
icon?: React.ReactNode;
|
||||
name?: string;
|
||||
pattern?: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
autoFocus?: boolean;
|
||||
@@ -119,6 +127,7 @@ export type Props = React.HTMLAttributes<HTMLInputElement> & {
|
||||
onChange?: (
|
||||
ev: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => unknown;
|
||||
innerRef?: React.RefObject<HTMLInputElement | HTMLTextAreaElement>;
|
||||
onKeyDown?: (ev: React.KeyboardEvent<HTMLInputElement>) => unknown;
|
||||
onFocus?: (ev: React.SyntheticEvent) => unknown;
|
||||
onBlur?: (ev: React.SyntheticEvent) => unknown;
|
||||
@@ -126,7 +135,7 @@ export type Props = React.HTMLAttributes<HTMLInputElement> & {
|
||||
|
||||
@observer
|
||||
class Input extends React.Component<Props> {
|
||||
input = React.createRef<HTMLInputElement | HTMLTextAreaElement>();
|
||||
input = this.props.innerRef;
|
||||
|
||||
@observable
|
||||
focused = false;
|
||||
@@ -147,10 +156,6 @@ class Input extends React.Component<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
focus() {
|
||||
this.input.current?.focus();
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
type = "text",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "styled-components";
|
||||
import Input, { Props as InputProps } from "./Input";
|
||||
import Input, { Props as InputProps } from "~/components/Input";
|
||||
|
||||
type Props = InputProps & {
|
||||
placeholder?: string;
|
||||
@@ -11,7 +11,10 @@ type Props = InputProps & {
|
||||
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => unknown;
|
||||
};
|
||||
|
||||
export default function InputSearch(props: Props) {
|
||||
function InputSearch(
|
||||
props: Props,
|
||||
ref: React.RefObject<HTMLInputElement | HTMLTextAreaElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [isFocused, setIsFocused] = React.useState(false);
|
||||
@@ -39,7 +42,10 @@ export default function InputSearch(props: Props) {
|
||||
onBlur={handleBlur}
|
||||
margin={0}
|
||||
labelHidden
|
||||
innerRef={ref}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.forwardRef(InputSearch);
|
||||
|
||||
@@ -8,7 +8,7 @@ import useBoolean from "~/hooks/useBoolean";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import { searchPath } from "~/utils/routeHelpers";
|
||||
import Input from "./Input";
|
||||
import Input, { Outline } from "./Input";
|
||||
|
||||
type Props = {
|
||||
source: string;
|
||||
@@ -30,7 +30,7 @@ function InputSearchPage({
|
||||
collectionId,
|
||||
source,
|
||||
}: Props) {
|
||||
const inputRef = React.useRef<Input>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const theme = useTheme();
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
@@ -67,7 +67,7 @@ function InputSearchPage({
|
||||
|
||||
return (
|
||||
<InputMaxWidth
|
||||
ref={inputRef}
|
||||
innerRef={inputRef}
|
||||
type="search"
|
||||
placeholder={placeholder || `${t("Search")}…`}
|
||||
value={value}
|
||||
@@ -89,6 +89,10 @@ function InputSearchPage({
|
||||
|
||||
const InputMaxWidth = styled(Input)`
|
||||
max-width: 30vw;
|
||||
|
||||
${Outline} {
|
||||
border-radius: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(InputSearchPage);
|
||||
|
||||
@@ -10,7 +10,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { detectLanguage } from "~/utils/language";
|
||||
|
||||
function Icon(props: any) {
|
||||
function Icon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width="32"
|
||||
@@ -18,7 +18,7 @@ function Icon(props: any) {
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import styled from "styled-components";
|
||||
import styled, { DefaultTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Flex from "~/components/Flex";
|
||||
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
|
||||
@@ -74,11 +74,14 @@ const Container = styled(Flex)`
|
||||
min-height: 100%;
|
||||
`;
|
||||
|
||||
const Content = styled(Flex)<{
|
||||
type ContentProps = {
|
||||
$isResizing?: boolean;
|
||||
$sidebarCollapsed?: boolean;
|
||||
$hasSidebar?: boolean;
|
||||
}>`
|
||||
theme: DefaultTheme;
|
||||
};
|
||||
|
||||
const Content = styled(Flex)<ContentProps>`
|
||||
margin: 0;
|
||||
transition: ${(props) =>
|
||||
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
|
||||
@@ -92,7 +95,7 @@ const Content = styled(Flex)<{
|
||||
`}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
${(props: any) =>
|
||||
${(props: ContentProps) =>
|
||||
props.$hasSidebar &&
|
||||
props.$sidebarCollapsed &&
|
||||
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import { loadPolyfills } from "~/utils/polyfills";
|
||||
|
||||
/**
|
||||
* Asyncronously load required polyfills. Should wrap the React tree.
|
||||
*/
|
||||
export const LazyPolyfill: React.FC = ({ children }) => {
|
||||
const [isLoaded, setIsLoaded] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadPolyfills().then(() => {
|
||||
setIsLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!isLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default LazyPolyfill;
|
||||
@@ -92,6 +92,7 @@ const Image = styled(Flex)`
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
color: ${(props) => props.theme.text};
|
||||
`;
|
||||
|
||||
const Heading = styled.p<{ $small?: boolean }>`
|
||||
|
||||
@@ -3,19 +3,24 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import PlaceholderText, {
|
||||
Props as PlaceholderTextProps,
|
||||
} from "~/components/PlaceholderText";
|
||||
|
||||
type Props = {
|
||||
count?: number;
|
||||
className?: string;
|
||||
header?: PlaceholderTextProps;
|
||||
body?: PlaceholderTextProps;
|
||||
};
|
||||
|
||||
const ListPlaceHolder = ({ count }: Props) => {
|
||||
const ListPlaceHolder = ({ count, className, header, body }: Props) => {
|
||||
return (
|
||||
<Fade>
|
||||
{times(count || 2, (index) => (
|
||||
<Item key={index} column auto>
|
||||
<PlaceholderText header delay={0.2 * index} />
|
||||
<PlaceholderText delay={0.2 * index} />
|
||||
<Item key={index} className={className} column auto>
|
||||
<PlaceholderText {...header} header delay={0.2 * index} />
|
||||
<PlaceholderText {...body} delay={0.2 * index} />
|
||||
</Item>
|
||||
))}
|
||||
</Fade>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import { depths } from "@shared/styles";
|
||||
|
||||
const LoadingIndicatorBar = () => {
|
||||
return (
|
||||
@@ -17,7 +18,7 @@ const loadingFrame = keyframes`
|
||||
const Container = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: ${(props) => props.theme.depths.loadingIndicatorBar};
|
||||
z-index: ${depths.loadingIndicatorBar};
|
||||
width: 100%;
|
||||
animation: ${loadingFrame} 4s ease-in-out infinite;
|
||||
animation-delay: 250ms;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { format as formatDate, formatDistanceToNow } from "date-fns";
|
||||
import * as React from "react";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { dateLocale } from "~/utils/i18n";
|
||||
import { dateLocale, locales } from "~/utils/i18n";
|
||||
|
||||
let callbacks: (() => void)[] = [];
|
||||
|
||||
@@ -26,7 +26,7 @@ type Props = {
|
||||
addSuffix?: boolean;
|
||||
shorten?: boolean;
|
||||
relative?: boolean;
|
||||
format?: string;
|
||||
format?: Partial<Record<keyof typeof locales, string>>;
|
||||
};
|
||||
|
||||
const LocaleTime: React.FC<Props> = ({
|
||||
@@ -38,7 +38,13 @@ const LocaleTime: React.FC<Props> = ({
|
||||
relative,
|
||||
tooltipDelay,
|
||||
}) => {
|
||||
const userLocale = useUserLocale();
|
||||
const userLocale: string = useUserLocale() || "";
|
||||
const dateFormatLong = {
|
||||
en_US: "MMMM do, yyyy h:mm a",
|
||||
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
|
||||
};
|
||||
const formatLocaleLong = dateFormatLong[userLocale] ?? "MMMM do, yyyy h:mm a";
|
||||
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
|
||||
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
const callback = React.useRef<() => void>();
|
||||
|
||||
@@ -66,17 +72,13 @@ const LocaleTime: React.FC<Props> = ({
|
||||
.replace("minute", "min");
|
||||
}
|
||||
|
||||
const tooltipContent = formatDate(
|
||||
Date.parse(dateTime),
|
||||
"MMMM do, yyyy h:mm a",
|
||||
{
|
||||
locale,
|
||||
}
|
||||
);
|
||||
const tooltipContent = formatDate(Date.parse(dateTime), formatLocaleLong, {
|
||||
locale,
|
||||
});
|
||||
const content =
|
||||
relative !== false
|
||||
? relativeContent
|
||||
: formatDate(Date.parse(dateTime), format || "MMMM do, yyyy h:mm a", {
|
||||
: formatDate(Date.parse(dateTime), formatLocale, {
|
||||
locale,
|
||||
});
|
||||
|
||||
|
||||
+29
-11
@@ -4,8 +4,9 @@ import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
|
||||
import styled from "styled-components";
|
||||
import styled, { DefaultTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
@@ -66,6 +67,7 @@ const Modal: React.FC<Props> = ({
|
||||
<Backdrop $isCentered={isCentered} {...props}>
|
||||
<Dialog
|
||||
{...dialog}
|
||||
aria-label={typeof title === "string" ? title : undefined}
|
||||
preventBodyScroll
|
||||
hideOnEsc
|
||||
hideOnClickOutside={!!isCentered}
|
||||
@@ -74,18 +76,24 @@ const Modal: React.FC<Props> = ({
|
||||
{(props) =>
|
||||
isCentered && !isMobile ? (
|
||||
<Small {...props}>
|
||||
<Centered onClick={(ev) => ev.stopPropagation()} column>
|
||||
<Centered
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
column
|
||||
reverse
|
||||
>
|
||||
<SmallContent shadow>{children}</SmallContent>
|
||||
<Header>
|
||||
{title && (
|
||||
<Text as="span" size="large">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<CloseIcon color="currentColor" />
|
||||
</NudeButton>
|
||||
<Text as="span" size="large">
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<CloseIcon color="currentColor" />
|
||||
</NudeButton>
|
||||
</Text>
|
||||
</Header>
|
||||
<SmallContent shadow>{children}</SmallContent>
|
||||
</Centered>
|
||||
</Small>
|
||||
) : (
|
||||
@@ -133,7 +141,7 @@ const Backdrop = styled(Flex)<{ $isCentered?: boolean }>`
|
||||
props.$isCentered
|
||||
? props.theme.modalBackdrop
|
||||
: transparentize(0.25, props.theme.background)} !important;
|
||||
z-index: ${(props) => props.theme.depths.modalOverlay};
|
||||
z-index: ${depths.modalOverlay};
|
||||
transition: opacity 50ms ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
@@ -142,7 +150,12 @@ const Backdrop = styled(Flex)<{ $isCentered?: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
const Fullscreen = styled.div<{ $nested: boolean }>`
|
||||
type FullscreenProps = {
|
||||
$nested: boolean;
|
||||
theme: DefaultTheme;
|
||||
};
|
||||
|
||||
const Fullscreen = styled.div<FullscreenProps>`
|
||||
animation: ${fadeAndScaleIn} 250ms ease;
|
||||
|
||||
position: absolute;
|
||||
@@ -150,7 +163,7 @@ const Fullscreen = styled.div<{ $nested: boolean }>`
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: ${(props) => props.theme.depths.modal};
|
||||
z-index: ${depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
@@ -159,7 +172,7 @@ const Fullscreen = styled.div<{ $nested: boolean }>`
|
||||
outline: none;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
${(props: any) =>
|
||||
${(props: FullscreenProps) =>
|
||||
props.$nested &&
|
||||
`
|
||||
box-shadow: 0 -2px 10px ${props.theme.shadow};
|
||||
@@ -240,7 +253,7 @@ const Small = styled.div`
|
||||
margin: auto auto;
|
||||
min-width: 350px;
|
||||
max-width: 30vw;
|
||||
z-index: ${(props) => props.theme.depths.modal};
|
||||
z-index: ${depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
@@ -255,6 +268,11 @@ const Small = styled.div`
|
||||
&[aria-expanded="true"] {
|
||||
background: ${(props) => props.theme.sidebarControlHoverBackground};
|
||||
}
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
${Header} {
|
||||
align-items: start;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import * as React from "react";
|
||||
import { NavLink, Route } from "react-router-dom";
|
||||
import { match, NavLink, Route } from "react-router-dom";
|
||||
|
||||
type Props = React.ComponentProps<typeof NavLink> & {
|
||||
children?: (match: any) => React.ReactNode;
|
||||
children?: (
|
||||
match:
|
||||
| match<{
|
||||
[x: string]: string | undefined;
|
||||
}>
|
||||
| boolean
|
||||
| null
|
||||
) => React.ReactNode;
|
||||
exact?: boolean;
|
||||
activeStyle?: React.CSSProperties;
|
||||
to: string;
|
||||
@@ -14,9 +21,11 @@ function NavLinkWithChildrenFunc(
|
||||
) {
|
||||
return (
|
||||
<Route path={to} exact={exact}>
|
||||
{({ match }) => (
|
||||
{({ match, location }) => (
|
||||
<NavLink {...rest} to={to} exact={exact} ref={ref}>
|
||||
{children ? children(match) : null}
|
||||
{children
|
||||
? children(rest.isActive ? rest.isActive(match, location) : match)
|
||||
: null}
|
||||
</NavLink>
|
||||
)}
|
||||
</Route>
|
||||
|
||||
@@ -6,7 +6,7 @@ import PaginatedList from "~/components/PaginatedList";
|
||||
|
||||
type Props = {
|
||||
documents: Document[];
|
||||
fetch: (options: any) => Promise<void>;
|
||||
fetch: (options: any) => Promise<Document[] | undefined>;
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
@@ -40,7 +40,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
heading={heading}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item, _index, compositeProps) => (
|
||||
renderItem={(item: Document, _index, compositeProps) => (
|
||||
<DocumentListItem
|
||||
key={item.id}
|
||||
document={item}
|
||||
|
||||
@@ -8,7 +8,7 @@ import EventListItem from "./EventListItem";
|
||||
type Props = {
|
||||
events: Event[];
|
||||
document: Document;
|
||||
fetch: (options: Record<string, any> | null | undefined) => Promise<void>;
|
||||
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
@@ -29,7 +29,7 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
|
||||
heading={heading}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item, index, compositeProps) => {
|
||||
renderItem={(item: Event, index, compositeProps) => {
|
||||
return (
|
||||
<EventListItem
|
||||
key={item.id}
|
||||
|
||||
@@ -13,34 +13,42 @@ import PlaceholderList from "~/components/List/Placeholder";
|
||||
import withStores from "~/components/withStores";
|
||||
import { dateToHeading } from "~/utils/dates";
|
||||
|
||||
type Props = WithTranslation &
|
||||
export interface PaginatedItem {
|
||||
id: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
type Props<T> = WithTranslation &
|
||||
RootStore & {
|
||||
fetch?: (options: Record<string, any> | null | undefined) => Promise<any>;
|
||||
fetch?: (
|
||||
options: Record<string, any> | undefined
|
||||
) => Promise<T[] | undefined> | undefined;
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
items: any[];
|
||||
loading?: React.ReactElement;
|
||||
items?: T[];
|
||||
renderItem: (
|
||||
item: any,
|
||||
item: T,
|
||||
index: number,
|
||||
composite: CompositeStateReturn
|
||||
compositeProps: CompositeStateReturn
|
||||
) => React.ReactNode;
|
||||
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
||||
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
|
||||
@observer
|
||||
class PaginatedList extends React.Component<Props> {
|
||||
isInitiallyLoaded = this.props.items.length > 0;
|
||||
|
||||
@observable
|
||||
isLoaded = false;
|
||||
|
||||
class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
@observable
|
||||
isFetchingMore = false;
|
||||
|
||||
@observable
|
||||
isFetching = false;
|
||||
|
||||
@observable
|
||||
fetchCounter = 0;
|
||||
|
||||
@observable
|
||||
renderCount: number = DEFAULT_PAGINATION_LIMIT;
|
||||
|
||||
@@ -54,7 +62,7 @@ class PaginatedList extends React.Component<Props> {
|
||||
this.fetchResults();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
componentDidUpdate(prevProps: Props<T>) {
|
||||
if (
|
||||
prevProps.fetch !== this.props.fetch ||
|
||||
!isEqual(prevProps.options, this.props.options)
|
||||
@@ -70,7 +78,6 @@ class PaginatedList extends React.Component<Props> {
|
||||
this.renderCount = DEFAULT_PAGINATION_LIMIT;
|
||||
this.isFetching = false;
|
||||
this.isFetchingMore = false;
|
||||
this.isLoaded = false;
|
||||
};
|
||||
|
||||
fetchResults = async () => {
|
||||
@@ -78,7 +85,9 @@ class PaginatedList extends React.Component<Props> {
|
||||
return;
|
||||
}
|
||||
this.isFetching = true;
|
||||
const counter = ++this.fetchCounter;
|
||||
const limit = DEFAULT_PAGINATION_LIMIT;
|
||||
|
||||
const results = await this.props.fetch({
|
||||
limit,
|
||||
offset: this.offset,
|
||||
@@ -92,9 +101,12 @@ class PaginatedList extends React.Component<Props> {
|
||||
}
|
||||
|
||||
this.renderCount += limit;
|
||||
this.isLoaded = true;
|
||||
this.isFetching = false;
|
||||
this.isFetchingMore = false;
|
||||
|
||||
// only the most recent fetch should end the loading state
|
||||
if (counter >= this.fetchCounter) {
|
||||
this.isFetching = false;
|
||||
this.isFetchingMore = false;
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
@@ -105,9 +117,9 @@ class PaginatedList extends React.Component<Props> {
|
||||
}
|
||||
// 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;
|
||||
const leftToRender = (this.props.items?.length ?? 0) - this.renderCount;
|
||||
|
||||
if (leftToRender > 1) {
|
||||
if (leftToRender > 0) {
|
||||
this.renderCount += DEFAULT_PAGINATION_LIMIT;
|
||||
}
|
||||
|
||||
@@ -120,70 +132,81 @@ class PaginatedList extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { items, heading, auth, empty, renderHeading } = this.props;
|
||||
let previousHeading = "";
|
||||
const {
|
||||
items = [],
|
||||
heading,
|
||||
auth,
|
||||
empty = null,
|
||||
renderHeading,
|
||||
onEscape,
|
||||
} = 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 aria-label={this.props["aria-label"]}>
|
||||
{(composite: CompositeStateReturn) =>
|
||||
items.slice(0, this.renderCount).map((item, index) => {
|
||||
const children = this.props.renderItem(
|
||||
item,
|
||||
index,
|
||||
composite
|
||||
);
|
||||
this.isFetching &&
|
||||
!this.isFetchingMore &&
|
||||
(!items?.length || this.fetchCounter === 0);
|
||||
|
||||
// If there is no renderHeading method passed then no date
|
||||
// headings are rendered
|
||||
if (!renderHeading) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// Our models have standard date fields, updatedAt > createdAt.
|
||||
// Get what a heading would look like for this item
|
||||
const currentDate =
|
||||
item.updatedAt || item.createdAt || previousHeading;
|
||||
const currentHeading = dateToHeading(
|
||||
currentDate,
|
||||
this.props.t,
|
||||
auth.user?.language
|
||||
);
|
||||
|
||||
// If the heading is different to any previous heading then we
|
||||
// should render it, otherwise the item can go under the previous
|
||||
// heading
|
||||
if (!previousHeading || currentHeading !== previousHeading) {
|
||||
previousHeading = currentHeading;
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{renderHeading(currentHeading)}
|
||||
{children}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
})
|
||||
}
|
||||
</ArrowKeyNavigation>
|
||||
{this.allowLoadMore && (
|
||||
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{showLoading && (
|
||||
if (showLoading) {
|
||||
return (
|
||||
this.props.loading || (
|
||||
<DelayedMount>
|
||||
<PlaceholderList count={5} />
|
||||
</DelayedMount>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (items?.length === 0) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{heading}
|
||||
<ArrowKeyNavigation
|
||||
aria-label={this.props["aria-label"]}
|
||||
onEscape={onEscape}
|
||||
>
|
||||
{(composite: CompositeStateReturn) => {
|
||||
let previousHeading = "";
|
||||
return items.slice(0, this.renderCount).map((item, index) => {
|
||||
const children = this.props.renderItem(item, index, composite);
|
||||
|
||||
// If there is no renderHeading method passed then no date
|
||||
// headings are rendered
|
||||
if (!renderHeading) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// Our models have standard date fields, updatedAt > createdAt.
|
||||
// Get what a heading would look like for this item
|
||||
const currentDate =
|
||||
item.updatedAt || item.createdAt || previousHeading;
|
||||
const currentHeading = dateToHeading(
|
||||
currentDate,
|
||||
this.props.t,
|
||||
auth.user?.language
|
||||
);
|
||||
|
||||
// If the heading is different to any previous heading then we
|
||||
// should render it, otherwise the item can go under the previous
|
||||
// heading
|
||||
if (!previousHeading || currentHeading !== previousHeading) {
|
||||
previousHeading = currentHeading;
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{renderHeading(currentHeading)}
|
||||
{children}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
});
|
||||
}}
|
||||
</ArrowKeyNavigation>
|
||||
{this.allowLoadMore && (
|
||||
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ type Props = {
|
||||
collection: Collection | null | undefined;
|
||||
onSuccess?: () => void;
|
||||
style?: React.CSSProperties;
|
||||
ref?: (arg0: React.ElementRef<"div"> | null | undefined) => void;
|
||||
ref?: (element: React.ElementRef<"div"> | null | undefined) => void;
|
||||
};
|
||||
|
||||
@observer
|
||||
|
||||
@@ -4,7 +4,7 @@ import { randomInteger } from "@shared/random";
|
||||
import Flex from "~/components/Flex";
|
||||
import { pulsate } from "~/styles/animations";
|
||||
|
||||
type Props = {
|
||||
export type Props = {
|
||||
header?: boolean;
|
||||
height?: number;
|
||||
minWidth?: number;
|
||||
|
||||
@@ -1,40 +1,50 @@
|
||||
import * as React from "react";
|
||||
import { Dialog } from "reakit/Dialog";
|
||||
import { Popover as ReakitPopover } from "reakit/Popover";
|
||||
import { Popover as ReakitPopover, PopoverProps } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths } from "@shared/styles";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
|
||||
type Props = {
|
||||
tabIndex?: number;
|
||||
type Props = PopoverProps & {
|
||||
children: React.ReactNode;
|
||||
width?: number;
|
||||
shrink?: boolean;
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
const Popover: React.FC<Props> = ({ children, width = 380, ...rest }) => {
|
||||
const Popover: React.FC<Props> = ({
|
||||
children,
|
||||
shrink,
|
||||
width = 380,
|
||||
...rest
|
||||
}) => {
|
||||
const isMobile = useMobile();
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Dialog {...rest} modal>
|
||||
<Contents>{children}</Contents>
|
||||
<Contents $shrink={shrink}>{children}</Contents>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReakitPopover {...rest}>
|
||||
<Contents $width={width}>{children}</Contents>
|
||||
<Contents $shrink={shrink} $width={width}>
|
||||
{children}
|
||||
</Contents>
|
||||
</ReakitPopover>
|
||||
);
|
||||
};
|
||||
|
||||
const Contents = styled.div<{ $width?: number }>`
|
||||
const Contents = styled.div<{ $shrink?: boolean; $width?: number }>`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: 75% 0;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
border-radius: 6px;
|
||||
padding: 12px 24px;
|
||||
padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
|
||||
max-height: 50vh;
|
||||
overflow-y: scroll;
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
@@ -42,7 +52,7 @@ const Contents = styled.div<{ $width?: number }>`
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
position: fixed;
|
||||
z-index: ${(props: any) => props.theme.depths.menu};
|
||||
z-index: ${depths.menu};
|
||||
|
||||
// 50 is a magic number that positions us nicely under the top bar
|
||||
top: 50px;
|
||||
|
||||
@@ -3,16 +3,17 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
|
||||
type Props = {
|
||||
type Props = React.HTMLAttributes<HTMLDivElement> & {
|
||||
shadow?: boolean;
|
||||
topShadow?: boolean;
|
||||
bottomShadow?: boolean;
|
||||
hiddenScrollbars?: boolean;
|
||||
flex?: boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function Scrollable(
|
||||
{ shadow, topShadow, bottomShadow, flex, ...rest }: Props,
|
||||
{ shadow, topShadow, bottomShadow, hiddenScrollbars, flex, ...rest }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const fallbackRef = React.useRef<HTMLDivElement>();
|
||||
@@ -55,6 +56,7 @@ function Scrollable(
|
||||
ref={ref || fallbackRef}
|
||||
onScroll={updateShadows}
|
||||
$flex={flex}
|
||||
$hiddenScrollbars={hiddenScrollbars}
|
||||
$topShadowVisible={topShadowVisible}
|
||||
$bottomShadowVisible={bottomShadowVisible}
|
||||
{...rest}
|
||||
@@ -66,6 +68,7 @@ const Wrapper = styled.div<{
|
||||
$flex?: boolean;
|
||||
$topShadowVisible?: boolean;
|
||||
$bottomShadowVisible?: boolean;
|
||||
$hiddenScrollbars?: boolean;
|
||||
}>`
|
||||
display: ${(props) => (props.$flex ? "flex" : "block")};
|
||||
flex-direction: column;
|
||||
@@ -90,6 +93,17 @@ const Wrapper = styled.div<{
|
||||
return "none";
|
||||
}};
|
||||
transition: all 100ms ease-in-out;
|
||||
|
||||
${(props) =>
|
||||
props.$hiddenScrollbars &&
|
||||
`
|
||||
-ms-overflow-style: none;
|
||||
overflow: -moz-scrollbars-none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export default observer(React.forwardRef(Scrollable));
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { CompositeItem } from "reakit/Composite";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Document from "~/models/Document";
|
||||
import Highlight, { Mark } from "~/components/Highlight";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
highlight: string;
|
||||
context: string | undefined;
|
||||
showParentDocuments?: boolean;
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
shareId?: string;
|
||||
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
|
||||
};
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
|
||||
function 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");
|
||||
}
|
||||
|
||||
function DocumentListItem(
|
||||
props: Props,
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
) {
|
||||
const { document, highlight, context, shareId, ...rest } = props;
|
||||
|
||||
return (
|
||||
<CompositeItem
|
||||
as={DocumentLink}
|
||||
ref={ref}
|
||||
dir={document.dir}
|
||||
to={{
|
||||
pathname: shareId ? `/share/${shareId}${document.url}` : document.url,
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
</Heading>
|
||||
|
||||
{
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
/>
|
||||
}
|
||||
</Content>
|
||||
</CompositeItem>
|
||||
);
|
||||
}
|
||||
|
||||
const Content = styled.div`
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const DocumentLink = styled(Link)<{
|
||||
$isStarred?: boolean;
|
||||
$menuOpen?: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
max-height: 50vh;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: auto;
|
||||
`};
|
||||
|
||||
&:${hover},
|
||||
&:active,
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$menuOpen &&
|
||||
css`
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
`}
|
||||
`;
|
||||
|
||||
const Heading = styled.h4<{ rtl?: boolean }>`
|
||||
display: flex;
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
align-items: center;
|
||||
height: 18px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.25em;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
color: ${(props) => props.theme.text};
|
||||
`;
|
||||
|
||||
const Title = styled(Highlight)`
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
${Mark} {
|
||||
padding: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const ResultContext = styled(Highlight)`
|
||||
display: block;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-size: 14px;
|
||||
margin-top: -0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
${Mark} {
|
||||
padding: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(React.forwardRef(DocumentListItem));
|
||||
@@ -0,0 +1,227 @@
|
||||
import { debounce } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import { depths } from "@shared/styles";
|
||||
import Empty from "~/components/Empty";
|
||||
import { Outline } from "~/components/Input";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Placeholder from "~/components/List/Placeholder";
|
||||
import PaginatedList, { PaginatedItem } from "~/components/PaginatedList";
|
||||
import Popover from "~/components/Popover";
|
||||
import { id as bodyContentId } from "~/components/SkipNavContent";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { SearchResult } from "~/types";
|
||||
import SearchListItem from "./SearchListItem";
|
||||
|
||||
type Props = { shareId: string };
|
||||
|
||||
function SearchPopover({ shareId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const focusRef = React.useRef<HTMLElement | null>(null);
|
||||
|
||||
const popover = usePopoverState({
|
||||
placement: "bottom-start",
|
||||
unstable_offset: [-24, 0],
|
||||
modal: true,
|
||||
});
|
||||
|
||||
const [query, setQuery] = React.useState("");
|
||||
const searchResults = documents.searchResults(query);
|
||||
|
||||
const [cachedQuery, setCachedQuery] = React.useState(query);
|
||||
const [cachedSearchResults, setCachedSearchResults] = React.useState<
|
||||
PaginatedItem[] | undefined
|
||||
>(searchResults);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (searchResults) {
|
||||
setCachedQuery(query);
|
||||
setCachedSearchResults(searchResults);
|
||||
popover.show();
|
||||
}
|
||||
}, [searchResults, query, popover.show]);
|
||||
|
||||
const performSearch = React.useCallback(
|
||||
async ({ query, ...options }) => {
|
||||
if (query?.length > 0) {
|
||||
return await documents.search(query, { shareId, ...options });
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[documents, shareId]
|
||||
);
|
||||
|
||||
const handleSearchInputChange = React.useMemo(
|
||||
() =>
|
||||
debounce(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
setQuery(value.trim());
|
||||
|
||||
// covers edge case: user manually dismisses popover then
|
||||
// quickly edits input resulting in no change in query
|
||||
// the useEffect that normally shows the popover will miss it
|
||||
if (value === cachedQuery) {
|
||||
popover.show();
|
||||
}
|
||||
|
||||
if (!value.length) {
|
||||
popover.hide();
|
||||
}
|
||||
}, 300),
|
||||
[popover, cachedQuery]
|
||||
);
|
||||
|
||||
const searchInputRef = popover.unstable_referenceRef as React.RefObject<
|
||||
HTMLInputElement
|
||||
>;
|
||||
|
||||
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const handleEscapeList = React.useCallback(
|
||||
() => searchInputRef?.current?.focus(),
|
||||
[searchInputRef]
|
||||
);
|
||||
|
||||
const handleSearchInputFocus = React.useCallback(() => {
|
||||
focusRef.current = searchInputRef.current;
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (ev.key === "Enter") {
|
||||
if (searchResults) {
|
||||
popover.show();
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowDown" && !ev.shiftKey) {
|
||||
if (ev.currentTarget.value.length) {
|
||||
if (
|
||||
ev.currentTarget.value.length === ev.currentTarget.selectionStart
|
||||
) {
|
||||
popover.show();
|
||||
}
|
||||
firstSearchItem.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowUp") {
|
||||
if (popover.visible) {
|
||||
popover.hide();
|
||||
if (!ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.currentTarget.value) {
|
||||
if (ev.currentTarget.selectionEnd === 0) {
|
||||
ev.currentTarget.selectionStart = 0;
|
||||
ev.currentTarget.selectionEnd = ev.currentTarget.value.length;
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.key === "Escape") {
|
||||
if (popover.visible) {
|
||||
popover.hide();
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
},
|
||||
[popover, searchResults]
|
||||
);
|
||||
|
||||
const handleSearchItemClick = React.useCallback(() => {
|
||||
popover.hide();
|
||||
if (searchInputRef.current) {
|
||||
searchInputRef.current.value = "";
|
||||
focusRef.current = document.getElementById(bodyContentId);
|
||||
}
|
||||
}, [popover.hide]);
|
||||
|
||||
useKeyDown("/", (ev) => {
|
||||
if (
|
||||
searchInputRef.current &&
|
||||
searchInputRef.current !== document.activeElement
|
||||
) {
|
||||
searchInputRef.current.focus();
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => {
|
||||
// props assumes the disclosure is a button, but we want a type-ahead
|
||||
// so we take the aria props, and ref and ignore the event handlers
|
||||
return (
|
||||
<StyledInputSearch
|
||||
aria-controls={props["aria-controls"]}
|
||||
aria-expanded={props["aria-expanded"]}
|
||||
aria-haspopup={props["aria-haspopup"]}
|
||||
ref={props.ref}
|
||||
onChange={handleSearchInputChange}
|
||||
onFocus={handleSearchInputFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</PopoverDisclosure>
|
||||
<Popover
|
||||
{...popover}
|
||||
aria-label={t("Results")}
|
||||
unstable_autoFocusOnShow={false}
|
||||
unstable_finalFocusRef={focusRef}
|
||||
style={{ zIndex: depths.sidebar + 1 }}
|
||||
shrink
|
||||
>
|
||||
<PaginatedList
|
||||
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
|
||||
items={cachedSearchResults}
|
||||
fetch={performSearch}
|
||||
onEscape={handleEscapeList}
|
||||
empty={
|
||||
<NoResults>{t("No results for {{query}}", { query })}</NoResults>
|
||||
}
|
||||
loading={<PlaceholderList count={3} header={{ height: 20 }} />}
|
||||
renderItem={(item: SearchResult, index, compositeProps) => (
|
||||
<SearchListItem
|
||||
key={item.document.id}
|
||||
shareId={shareId}
|
||||
ref={index === 0 ? firstSearchItem : undefined}
|
||||
document={item.document}
|
||||
context={item.context}
|
||||
highlight={cachedQuery}
|
||||
onClick={handleSearchItemClick}
|
||||
{...compositeProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const NoResults = styled(Empty)`
|
||||
padding: 0 12px;
|
||||
margin: 6px 0;
|
||||
`;
|
||||
|
||||
const PlaceholderList = styled(Placeholder)`
|
||||
padding: 6px 12px;
|
||||
`;
|
||||
|
||||
const StyledInputSearch = styled(InputSearch)`
|
||||
${Outline} {
|
||||
border-radius: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(SearchPopover);
|
||||
@@ -8,7 +8,7 @@ import styled from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useAuthorizedSettingsConfig from "~/hooks/useAuthorizedSettingsConfig";
|
||||
import isHosted from "~/utils/isHosted";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import Sidebar from "./Sidebar";
|
||||
import Header from "./components/Header";
|
||||
import Section from "./components/Section";
|
||||
@@ -39,20 +39,21 @@ function SettingsSidebar() {
|
||||
<Scrollable shadow>
|
||||
{Object.keys(groupedConfig).map((header) => (
|
||||
<Section key={header}>
|
||||
<Header>{header}</Header>
|
||||
{groupedConfig[header].map((item) => (
|
||||
<SidebarLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
icon={<item.icon color="currentColor" />}
|
||||
label={item.name}
|
||||
/>
|
||||
))}
|
||||
<Header title={header}>
|
||||
{groupedConfig[header].map((item) => (
|
||||
<SidebarLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
icon={<item.icon color="currentColor" />}
|
||||
label={item.name}
|
||||
/>
|
||||
))}
|
||||
</Header>
|
||||
</Section>
|
||||
))}
|
||||
{!isHosted && (
|
||||
{!isCloudHosted && (
|
||||
<Section>
|
||||
<Header>{t("Installation")}</Header>
|
||||
<Header title={t("Installation")} />
|
||||
<Version />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import SearchPopover from "~/components/SearchPopover";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { NavigationNode } from "~/types";
|
||||
import Sidebar from "./Sidebar";
|
||||
@@ -19,6 +20,9 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
return (
|
||||
<Sidebar>
|
||||
<ScrollContainer flex>
|
||||
<TopSection>
|
||||
<SearchPopover shareId={shareId} />
|
||||
</TopSection>
|
||||
<Section>
|
||||
<DocumentLink
|
||||
index={0}
|
||||
@@ -38,4 +42,12 @@ const ScrollContainer = styled(Scrollable)`
|
||||
padding-bottom: 16px;
|
||||
`;
|
||||
|
||||
const TopSection = styled(Section)`
|
||||
// this weird looking && increases the specificity of the style rule
|
||||
&& {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(SharedSidebar);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Portal } from "react-portal";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import useMenuContext from "~/hooks/useMenuContext";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
@@ -64,8 +65,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
setResizing(false);
|
||||
|
||||
if (document.activeElement) {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'blur' does not exist on type 'Element'.
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
@@ -224,16 +224,18 @@ const Backdrop = styled.a`
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
cursor: default;
|
||||
z-index: ${(props) => props.theme.depths.sidebar - 1};
|
||||
z-index: ${depths.sidebar - 1};
|
||||
background: ${(props) => props.theme.backdrop};
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)<{
|
||||
type ContainerProps = {
|
||||
$mobileSidebarVisible: boolean;
|
||||
$isAnimating: boolean;
|
||||
$isSmallerThanMinimum: boolean;
|
||||
$collapsed: boolean;
|
||||
}>`
|
||||
};
|
||||
|
||||
const Container = styled(Flex)<ContainerProps>`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
@@ -241,12 +243,12 @@ const Container = styled(Flex)<{
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
transition: box-shadow 100ms ease-in-out, transform 100ms ease-out,
|
||||
${(props) => props.theme.backgroundTransition}
|
||||
${(props: any) =>
|
||||
${(props: ContainerProps) =>
|
||||
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
|
||||
transform: translateX(
|
||||
${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")}
|
||||
);
|
||||
z-index: ${(props) => props.theme.depths.sidebar};
|
||||
z-index: ${depths.sidebar};
|
||||
max-width: 70%;
|
||||
min-width: 280px;
|
||||
|
||||
@@ -262,13 +264,13 @@ const Container = styled(Flex)<{
|
||||
${breakpoint("tablet")`
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
transform: translateX(${(props: any) =>
|
||||
transform: translateX(${(props: ContainerProps) =>
|
||||
props.$collapsed ? "calc(-100% + 16px)" : 0});
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
transform: none;
|
||||
box-shadow: ${(props: any) =>
|
||||
box-shadow: ${(props: ContainerProps) =>
|
||||
props.$collapsed
|
||||
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
|
||||
: props.$isSmallerThanMinimum
|
||||
@@ -285,7 +287,7 @@ const Container = styled(Flex)<{
|
||||
}
|
||||
|
||||
&:not(:hover):not(:focus-within) > div {
|
||||
opacity: ${(props: any) => (props.$collapsed ? "0" : "1")};
|
||||
opacity: ${(props: ContainerProps) => (props.$collapsed ? "0" : "1")};
|
||||
transition: opacity 100ms ease-in-out;
|
||||
}
|
||||
`};
|
||||
|
||||
@@ -54,7 +54,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
await collection.save({
|
||||
name,
|
||||
});
|
||||
history.push(collection.url);
|
||||
history.replace(collection.url, history.location.state);
|
||||
},
|
||||
[collection, history]
|
||||
);
|
||||
|
||||
@@ -37,7 +37,7 @@ function CollectionLinkChildren({
|
||||
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] = useDrop({
|
||||
accept: "document",
|
||||
drop: (item: DragObject) => {
|
||||
if (!manualSort) {
|
||||
if (!manualSort && item.collectionId === collection?.id) {
|
||||
showToast(
|
||||
t(
|
||||
"You can't reorder documents in an alphabetically sorted collection"
|
||||
|
||||
@@ -22,7 +22,6 @@ function Collections() {
|
||||
const [fetchError, setFetchError] = React.useState();
|
||||
const { documents, collections } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const [expanded, setExpanded] = React.useState(true);
|
||||
const isPreloaded = !!collections.orderedData.length;
|
||||
const { t } = useTranslation();
|
||||
const orderedCollections = collections.orderedData;
|
||||
@@ -97,20 +96,18 @@ function Collections() {
|
||||
if (!collections.isLoaded || fetchError) {
|
||||
return (
|
||||
<Flex column>
|
||||
<Header>{t("Collections")}</Header>
|
||||
<PlaceholderCollections />
|
||||
<Header id="collections" title={t("Collections")}>
|
||||
<PlaceholderCollections />
|
||||
</Header>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<Header onClick={() => setExpanded((prev) => !prev)} expanded={expanded}>
|
||||
{t("Collections")}
|
||||
</Header>
|
||||
{expanded && (
|
||||
<Header id="collections" title={t("Collections")}>
|
||||
<Relative>{isPreloaded ? content : <Fade>{content}</Fade>}</Relative>
|
||||
)}
|
||||
</Header>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ function InnerDocumentLink(
|
||||
collectionId: collection?.id || "",
|
||||
}),
|
||||
collect: (monitor) => ({
|
||||
isDragging: !!monitor.isDragging(),
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
canDrag: () => {
|
||||
return (
|
||||
@@ -213,7 +213,7 @@ function InnerDocumentLink(
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReparent: !!monitor.isOver({
|
||||
isOverReparent: monitor.isOver({
|
||||
shallow: true,
|
||||
}),
|
||||
canDropToReparent: monitor.canDrop(),
|
||||
@@ -252,25 +252,24 @@ function InnerDocumentLink(
|
||||
documents.move(item.id, collection.id, parentId, index + 1);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: !!monitor.isOver(),
|
||||
isDraggingAnyDocument: !!monitor.canDrop(),
|
||||
isOverReorder: monitor.isOver(),
|
||||
isDraggingAnyDocument: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
|
||||
const nodeChildren = React.useMemo(() => {
|
||||
if (
|
||||
collection &&
|
||||
const insertDraftDocument =
|
||||
activeDocument?.isDraft &&
|
||||
activeDocument?.isActive &&
|
||||
activeDocument?.parentDocumentId === node.id
|
||||
) {
|
||||
return sortNavigationNodes(
|
||||
[activeDocument?.asNavigationNode, ...node.children],
|
||||
collection.sort
|
||||
);
|
||||
}
|
||||
activeDocument?.parentDocumentId === node.id;
|
||||
|
||||
return node.children;
|
||||
return collection && insertDraftDocument
|
||||
? sortNavigationNodes(
|
||||
[activeDocument?.asNavigationNode, ...node.children],
|
||||
collection.sort,
|
||||
false
|
||||
)
|
||||
: node.children;
|
||||
}, [
|
||||
activeDocument?.isActive,
|
||||
activeDocument?.isDraft,
|
||||
|
||||
@@ -13,7 +13,6 @@ import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Relative from "./Relative";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
import { useStarredContext } from "./StarredContext";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -37,10 +36,8 @@ function DraggableCollectionLink({
|
||||
}: Props) {
|
||||
const locationStateStarred = useLocationStateStarred();
|
||||
const { ui, collections } = useStores();
|
||||
const inStarredSection = useStarredContext();
|
||||
const [expanded, setExpanded] = React.useState(
|
||||
collection.id === ui.activeCollectionId &&
|
||||
locationStateStarred === inStarredSection
|
||||
collection.id === ui.activeCollectionId && !locationStateStarred
|
||||
);
|
||||
const can = usePolicy(collection.id);
|
||||
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
|
||||
@@ -88,18 +85,10 @@ function DraggableCollectionLink({
|
||||
// If the current collection is active and relevant to the sidebar section we
|
||||
// are in then expand it automatically
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
collection.id === ui.activeCollectionId &&
|
||||
locationStateStarred === inStarredSection
|
||||
) {
|
||||
if (collection.id === ui.activeCollectionId && !locationStateStarred) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [
|
||||
collection.id,
|
||||
ui.activeCollectionId,
|
||||
locationStateStarred,
|
||||
inStarredSection,
|
||||
]);
|
||||
}, [collection.id, ui.activeCollectionId, locationStateStarred]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev.preventDefault();
|
||||
|
||||
@@ -1,25 +1,65 @@
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
|
||||
type Props = {
|
||||
onClick?: React.MouseEventHandler;
|
||||
expanded?: boolean;
|
||||
/** Unique header id – if passed the header will become toggleable */
|
||||
id?: string;
|
||||
title: React.ReactNode;
|
||||
};
|
||||
|
||||
export const Header: React.FC<Props> = ({ onClick, expanded, children }) => {
|
||||
/**
|
||||
* Toggleable sidebar header
|
||||
*/
|
||||
export const Header: React.FC<Props> = ({ id, title, children }) => {
|
||||
const [firstRender, setFirstRender] = React.useState(true);
|
||||
const [expanded, setExpanded] = usePersistedState(
|
||||
`sidebar-header-${id}`,
|
||||
true
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!expanded) {
|
||||
setFirstRender(false);
|
||||
}
|
||||
}, [expanded]);
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
setExpanded(!expanded);
|
||||
}, [expanded, setExpanded]);
|
||||
|
||||
return (
|
||||
<H3>
|
||||
<Button onClick={onClick} disabled={!onClick}>
|
||||
{children}
|
||||
{onClick && (
|
||||
<Disclosure expanded={expanded} color="currentColor" size={20} />
|
||||
)}
|
||||
</Button>
|
||||
</H3>
|
||||
<>
|
||||
<H3>
|
||||
<Button onClick={handleClick} disabled={!id}>
|
||||
{title}
|
||||
{id && (
|
||||
<Disclosure expanded={expanded} color="currentColor" size={20} />
|
||||
)}
|
||||
</Button>
|
||||
</H3>
|
||||
{expanded && (firstRender ? children : <Fade>{children}</Fade>)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const fadeAndSlideDown = keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0px);
|
||||
}
|
||||
`;
|
||||
|
||||
const Fade = styled.span`
|
||||
animation: ${fadeAndSlideDown} 100ms ease-in-out;
|
||||
`;
|
||||
|
||||
const Button = styled.button`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -4,9 +4,24 @@ const ResizeBorder = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: -6px;
|
||||
width: 12px;
|
||||
right: -1px;
|
||||
width: 2px;
|
||||
cursor: col-resize;
|
||||
|
||||
&:hover {
|
||||
transition-delay: 500ms;
|
||||
transition: background 250ms ease-in-out;
|
||||
background: ${(props) => props.theme.sidebarActiveBackground};
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: -4px;
|
||||
width: 10px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default ResizeBorder;
|
||||
|
||||
@@ -16,7 +16,7 @@ export type DragObject = NavigationNode & {
|
||||
type Props = Omit<NavLinkProps, "to"> & {
|
||||
to?: string | Record<string, any>;
|
||||
href?: string | Record<string, any>;
|
||||
innerRef?: (arg0: HTMLElement | null | undefined) => void;
|
||||
innerRef?: (ref: HTMLElement | null | undefined) => void;
|
||||
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
|
||||
onMouseEnter?: React.MouseEventHandler<HTMLAnchorElement>;
|
||||
onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
@@ -24,6 +24,7 @@ type Props = Omit<NavLinkProps, "to"> & {
|
||||
label?: React.ReactNode;
|
||||
menu?: React.ReactNode;
|
||||
showActions?: boolean;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
/* If set, a disclosure will be rendered to the left of any icon */
|
||||
expanded?: boolean;
|
||||
@@ -55,6 +56,7 @@ function SidebarLink(
|
||||
className,
|
||||
expanded,
|
||||
onDisclosureClick,
|
||||
disabled,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
@@ -82,6 +84,7 @@ function SidebarLink(
|
||||
<Link
|
||||
$isActiveDrop={isActiveDrop}
|
||||
$isDraft={isDraft}
|
||||
$disabled={disabled}
|
||||
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
|
||||
style={active ? activeStyle : style}
|
||||
onClick={onClick}
|
||||
@@ -158,7 +161,11 @@ const Actions = styled(EventBoundary)<{ showActions?: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
const Link = styled(NavLink)<{ $isActiveDrop?: boolean; $isDraft?: boolean }>`
|
||||
const Link = styled(NavLink)<{
|
||||
$isActiveDrop?: boolean;
|
||||
$isDraft?: boolean;
|
||||
$disabled?: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
position: relative;
|
||||
text-overflow: ellipsis;
|
||||
@@ -174,6 +181,13 @@ const Link = styled(NavLink)<{ $isActiveDrop?: boolean; $isDraft?: boolean }>`
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
${(props) =>
|
||||
props.$disabled &&
|
||||
css`
|
||||
pointer-events: none;
|
||||
opacity: 0.75;
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.$isDraft &&
|
||||
css`
|
||||
|
||||
@@ -16,106 +16,51 @@ import StarredContext from "./StarredContext";
|
||||
import StarredLink from "./StarredLink";
|
||||
|
||||
const STARRED_PAGINATION_LIMIT = 10;
|
||||
const STARRED = "STARRED";
|
||||
|
||||
function Starred() {
|
||||
const [isFetching, setIsFetching] = React.useState(false);
|
||||
const [fetchError, setFetchError] = React.useState();
|
||||
const [expanded, setExpanded] = React.useState(true);
|
||||
const [show, setShow] = React.useState("Nothing");
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [upperBound, setUpperBound] = React.useState(STARRED_PAGINATION_LIMIT);
|
||||
const [displayedStarsCount, setDisplayedStarsCount] = React.useState(
|
||||
STARRED_PAGINATION_LIMIT
|
||||
);
|
||||
const { showToast } = useToasts();
|
||||
const { stars } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const fetchResults = React.useCallback(async () => {
|
||||
try {
|
||||
setIsFetching(true);
|
||||
await stars.fetchPage({
|
||||
limit: STARRED_PAGINATION_LIMIT,
|
||||
offset,
|
||||
});
|
||||
} catch (error) {
|
||||
showToast(t("Starred documents could not be loaded"), {
|
||||
type: "error",
|
||||
});
|
||||
setFetchError(error);
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
}
|
||||
}, [stars, offset, showToast, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let stateInLocal;
|
||||
|
||||
try {
|
||||
stateInLocal = localStorage.getItem(STARRED);
|
||||
} catch (_) {
|
||||
// no-op Safari private mode
|
||||
}
|
||||
|
||||
if (!stateInLocal) {
|
||||
localStorage.setItem(STARRED, expanded ? "true" : "false");
|
||||
} else {
|
||||
setExpanded(stateInLocal === "true");
|
||||
}
|
||||
}, [expanded]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setOffset(stars.orderedData.length);
|
||||
|
||||
if (stars.orderedData.length <= STARRED_PAGINATION_LIMIT) {
|
||||
setShow("Nothing");
|
||||
} else if (stars.orderedData.length >= upperBound) {
|
||||
setShow("More");
|
||||
} else if (stars.orderedData.length < upperBound) {
|
||||
setShow("Less");
|
||||
}
|
||||
}, [stars.orderedData, upperBound]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (offset === 0) {
|
||||
fetchResults();
|
||||
}
|
||||
}, [fetchResults, offset]);
|
||||
|
||||
const handleShowMore = React.useCallback(async () => {
|
||||
setUpperBound(
|
||||
(previousUpperBound) => previousUpperBound + STARRED_PAGINATION_LIMIT
|
||||
);
|
||||
await fetchResults();
|
||||
}, [fetchResults]);
|
||||
|
||||
const handleShowLess = React.useCallback(() => {
|
||||
setUpperBound(STARRED_PAGINATION_LIMIT);
|
||||
setShow("More");
|
||||
}, []);
|
||||
|
||||
const handleExpandClick = React.useCallback(
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const fetchResults = React.useCallback(
|
||||
async (offset = 0) => {
|
||||
try {
|
||||
localStorage.setItem(STARRED, !expanded ? "true" : "false");
|
||||
} catch (_) {
|
||||
// no-op Safari private mode
|
||||
await stars.fetchPage({
|
||||
limit: STARRED_PAGINATION_LIMIT + 1,
|
||||
offset,
|
||||
});
|
||||
} catch (error) {
|
||||
showToast(t("Starred documents could not be loaded"), {
|
||||
type: "error",
|
||||
});
|
||||
setFetchError(error);
|
||||
}
|
||||
|
||||
setExpanded((prev) => !prev);
|
||||
},
|
||||
[expanded]
|
||||
[stars, showToast, t]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchResults();
|
||||
}, [fetchResults]);
|
||||
|
||||
const handleShowMore = async () => {
|
||||
await fetchResults(displayedStarsCount);
|
||||
setDisplayedStarsCount((prev) => prev + STARRED_PAGINATION_LIMIT);
|
||||
};
|
||||
|
||||
// Drop to reorder document
|
||||
const [{ isOverReorder }, dropToReorder] = useDrop({
|
||||
const [{ isOverReorder, isDraggingAnyStar }, dropToReorder] = useDrop({
|
||||
accept: "star",
|
||||
drop: async (item: Star) => {
|
||||
item?.save({ index: fractionalIndex(null, stars.orderedData[0].index) });
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: !!monitor.isOver(),
|
||||
isDraggingAnyStar: monitor.getItemType() === "star",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -126,40 +71,33 @@ function Starred() {
|
||||
return (
|
||||
<StarredContext.Provider value={true}>
|
||||
<Flex column>
|
||||
<Header onClick={handleExpandClick} expanded={expanded}>
|
||||
{t("Starred")}
|
||||
</Header>
|
||||
{expanded && (
|
||||
<Header id="starred" title={t("Starred")}>
|
||||
<Relative>
|
||||
<DropCursor
|
||||
isActiveDrop={isOverReorder}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
{stars.orderedData.slice(0, upperBound).map((star) => (
|
||||
{isDraggingAnyStar && (
|
||||
<DropCursor
|
||||
isActiveDrop={isOverReorder}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{stars.orderedData.slice(0, displayedStarsCount).map((star) => (
|
||||
<StarredLink key={star.id} star={star} />
|
||||
))}
|
||||
{show === "More" && !isFetching && (
|
||||
{stars.orderedData.length > displayedStarsCount && (
|
||||
<SidebarLink
|
||||
onClick={handleShowMore}
|
||||
label={`${t("Show more")}…`}
|
||||
disabled={stars.isFetching}
|
||||
depth={0}
|
||||
/>
|
||||
)}
|
||||
{show === "Less" && !isFetching && (
|
||||
<SidebarLink
|
||||
onClick={handleShowLess}
|
||||
label={`${t("Show less")}…`}
|
||||
depth={0}
|
||||
/>
|
||||
)}
|
||||
{(isFetching || fetchError) && !stars.orderedData.length && (
|
||||
{(stars.isFetching || fetchError) && !stars.orderedData.length && (
|
||||
<Flex column>
|
||||
<PlaceholderCollections />
|
||||
</Flex>
|
||||
)}
|
||||
</Relative>
|
||||
)}
|
||||
</Header>
|
||||
</Flex>
|
||||
</StarredContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Badge from "~/components/Badge";
|
||||
import { version } from "../../../../package.json";
|
||||
@@ -6,6 +7,7 @@ import SidebarLink from "./SidebarLink";
|
||||
|
||||
export default function Version() {
|
||||
const [releasesBehind, setReleasesBehind] = React.useState(0);
|
||||
const { t } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
async function loadReleases() {
|
||||
@@ -37,10 +39,11 @@ export default function Version() {
|
||||
<br />
|
||||
<LilBadge>
|
||||
{releasesBehind === 0
|
||||
? "Up to date"
|
||||
: `${releasesBehind} version${
|
||||
releasesBehind === 1 ? "" : "s"
|
||||
} behind`}
|
||||
? t("Up to date")
|
||||
: t(`{{ releasesBehind }} versions behind`, {
|
||||
releasesBehind,
|
||||
count: releasesBehind,
|
||||
})}
|
||||
</LilBadge>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -12,19 +12,19 @@ export default function useCollectionDocuments(
|
||||
return [];
|
||||
}
|
||||
|
||||
if (
|
||||
const insertDraftDocument =
|
||||
activeDocument?.isActive &&
|
||||
activeDocument?.isDraft &&
|
||||
activeDocument?.collectionId === collection.id &&
|
||||
!activeDocument?.parentDocumentId
|
||||
) {
|
||||
return sortNavigationNodes(
|
||||
[activeDocument.asNavigationNode, ...collection.documents],
|
||||
collection.sort
|
||||
);
|
||||
}
|
||||
!activeDocument?.parentDocumentId;
|
||||
|
||||
return collection.documents;
|
||||
return insertDraftDocument
|
||||
? sortNavigationNodes(
|
||||
[activeDocument.asNavigationNode, ...collection.sortedDocuments],
|
||||
collection.sort,
|
||||
false
|
||||
)
|
||||
: collection.sortedDocuments;
|
||||
}, [
|
||||
activeDocument?.isActive,
|
||||
activeDocument?.isDraft,
|
||||
@@ -32,7 +32,7 @@ export default function useCollectionDocuments(
|
||||
activeDocument?.parentDocumentId,
|
||||
activeDocument?.asNavigationNode,
|
||||
collection,
|
||||
collection?.documents,
|
||||
collection?.sortedDocuments,
|
||||
collection?.id,
|
||||
collection?.sort,
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { depths } from "@shared/styles";
|
||||
import { id } from "~/components/SkipNavContent";
|
||||
|
||||
export default function SkipNavLink() {
|
||||
@@ -25,7 +26,7 @@ const Anchor = styled.a`
|
||||
background: ${(props) => props.theme.background};
|
||||
color: ${(props) => props.theme.text};
|
||||
outline-color: ${(props) => props.theme.primary};
|
||||
z-index: ${(props) => props.theme.depths.popover};
|
||||
z-index: ${depths.popover};
|
||||
width: auto;
|
||||
height: auto;
|
||||
clip: auto;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { ThemeProvider } from "styled-components";
|
||||
import { dark, light, lightMobile, darkMobile } from "@shared/theme";
|
||||
import { breakpoints } from "@shared/styles";
|
||||
import { dark, light, lightMobile, darkMobile } from "@shared/styles/theme";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import GlobalStyles from "~/styles/globals";
|
||||
@@ -11,9 +12,7 @@ const Theme: React.FC = ({ children }) => {
|
||||
const resolvedTheme = ui.resolvedTheme === "dark" ? dark : light;
|
||||
const resolvedMobileTheme =
|
||||
ui.resolvedTheme === "dark" ? darkMobile : lightMobile;
|
||||
const isMobile = useMediaQuery(
|
||||
`(max-width: ${resolvedTheme.breakpoints.tablet}px)`
|
||||
);
|
||||
const isMobile = useMediaQuery(`(max-width: ${breakpoints.tablet}px)`);
|
||||
const isPrinting = useMediaQuery("print");
|
||||
const theme = isPrinting
|
||||
? light
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { depths } from "@shared/styles";
|
||||
import Toast from "~/components/Toast";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Toast as TToast } from "~/types";
|
||||
@@ -27,7 +28,7 @@ const List = styled.ol`
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
z-index: ${(props) => props.theme.depths.toasts};
|
||||
z-index: ${depths.toasts};
|
||||
`;
|
||||
|
||||
export default observer(Toasts);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
|
||||
export type Props = {
|
||||
selected: boolean;
|
||||
@@ -22,7 +22,6 @@ function BlockMenuItem({
|
||||
containerId = "block-menu-container",
|
||||
}: Props) {
|
||||
const Icon = icon;
|
||||
const theme = useTheme();
|
||||
|
||||
const ref = React.useCallback(
|
||||
(node) => {
|
||||
@@ -50,11 +49,7 @@ function BlockMenuItem({
|
||||
>
|
||||
{Icon && (
|
||||
<>
|
||||
<Icon
|
||||
color={
|
||||
selected ? theme.blockToolbarIconSelected : theme.blockToolbarIcon
|
||||
}
|
||||
/>
|
||||
<Icon color="currentColor" />
|
||||
|
||||
</>
|
||||
)}
|
||||
@@ -85,24 +80,14 @@ const MenuItem = styled.button<{
|
||||
border: none;
|
||||
opacity: ${(props) => (props.disabled ? ".5" : "1")};
|
||||
color: ${(props) =>
|
||||
props.selected
|
||||
? props.theme.blockToolbarTextSelected
|
||||
: props.theme.blockToolbarText};
|
||||
background: ${(props) =>
|
||||
props.selected
|
||||
? props.theme.blockToolbarSelectedBackground ||
|
||||
props.theme.blockToolbarTrigger
|
||||
: "none"};
|
||||
props.selected ? props.theme.white : props.theme.textSecondary};
|
||||
background: ${(props) => (props.selected ? props.theme.primary : "none")};
|
||||
padding: 0 16px;
|
||||
outline: none;
|
||||
|
||||
&:active {
|
||||
color: ${(props) => props.theme.blockToolbarTextSelected};
|
||||
background: ${(props) =>
|
||||
props.selected
|
||||
? props.theme.blockToolbarSelectedBackground ||
|
||||
props.theme.blockToolbarTrigger
|
||||
: props.theme.blockToolbarHoverBackground};
|
||||
color: ${(props) => props.theme.white};
|
||||
background: ${(props) => (props.selected ? props.theme.primary : "none")};
|
||||
|
||||
${Shortcut} {
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
|
||||
@@ -10,8 +10,10 @@ import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import { CommandFactory } from "@shared/editor/lib/Extension";
|
||||
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
||||
import { EmbedDescriptor, MenuItem } from "@shared/editor/types";
|
||||
import { depths } from "@shared/styles";
|
||||
import { supportedImageMimeTypes } from "@shared/utils/files";
|
||||
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import Input from "./Input";
|
||||
|
||||
@@ -418,7 +420,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
|
||||
commands,
|
||||
filterable = true,
|
||||
} = this.props;
|
||||
let items: (EmbedDescriptor | MenuItem)[] = this.props.items;
|
||||
let items: (EmbedDescriptor | MenuItem)[] = [...this.props.items];
|
||||
const embedItems: EmbedDescriptor[] = [];
|
||||
|
||||
for (const embed of embeds) {
|
||||
@@ -487,6 +489,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
|
||||
id={this.props.id || "block-menu-container"}
|
||||
active={isActive}
|
||||
ref={this.menuRef}
|
||||
hiddenScrollbars
|
||||
{...positioning}
|
||||
>
|
||||
{insertItem ? (
|
||||
@@ -569,7 +572,7 @@ const LinkInputWrapper = styled.div`
|
||||
const LinkInput = styled(Input)`
|
||||
height: 36px;
|
||||
width: 100%;
|
||||
color: ${(props) => props.theme.blockToolbarText};
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
`;
|
||||
|
||||
const List = styled.ol`
|
||||
@@ -595,22 +598,22 @@ const Empty = styled.div`
|
||||
padding: 0 16px;
|
||||
`;
|
||||
|
||||
export const Wrapper = styled.div<{
|
||||
export const Wrapper = styled(Scrollable)<{
|
||||
active: boolean;
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
isAbove: boolean;
|
||||
}>`
|
||||
color: ${(props) => props.theme.text};
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
font-family: ${(props) => props.theme.fontFamily};
|
||||
position: absolute;
|
||||
z-index: ${(props) => props.theme.zIndex + 100};
|
||||
z-index: ${depths.editorToolbar};
|
||||
${(props) => props.top !== undefined && `top: ${props.top}px`};
|
||||
${(props) => props.bottom !== undefined && `bottom: ${props.bottom}px`};
|
||||
left: ${(props) => props.left}px;
|
||||
background-color: ${(props) => props.theme.blockToolbarBackground};
|
||||
border-radius: 4px;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
border-radius: 6px;
|
||||
box-shadow: rgba(0, 0, 0, 0.05) 0px 0px 0px 1px,
|
||||
rgba(0, 0, 0, 0.08) 0px 4px 8px, rgba(0, 0, 0, 0.08) 0px 2px 4px;
|
||||
opacity: 0;
|
||||
@@ -623,9 +626,8 @@ export const Wrapper = styled.div<{
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
width: 300px;
|
||||
height: auto;
|
||||
max-height: 324px;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@@ -634,7 +636,7 @@ export const Wrapper = styled.div<{
|
||||
hr {
|
||||
border: 0;
|
||||
height: 0;
|
||||
border-top: 1px solid ${(props) => props.theme.blockToolbarDivider};
|
||||
border-top: 1px solid ${(props) => props.theme.divider};
|
||||
}
|
||||
|
||||
${({ active, isAbove }) =>
|
||||
|
||||
@@ -21,7 +21,7 @@ const searcher = new FuzzySearch<{
|
||||
sort: true,
|
||||
});
|
||||
|
||||
class EmojiMenu extends React.Component<
|
||||
class EmojiMenu extends React.PureComponent<
|
||||
Omit<
|
||||
Props<Emoji>,
|
||||
| "renderMenuItem"
|
||||
|
||||
@@ -4,7 +4,9 @@ import { EditorView } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import { depths } from "@shared/styles";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import useViewportHeight from "~/hooks/useViewportHeight";
|
||||
|
||||
@@ -163,25 +165,15 @@ const FloatingToolbar = React.forwardRef(
|
||||
props,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleMouseDown = () => {
|
||||
if (!props.active) {
|
||||
setSelectingText(true);
|
||||
}
|
||||
};
|
||||
useEventListener("mouseup", () => {
|
||||
setSelectingText(false);
|
||||
});
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setSelectingText(false);
|
||||
};
|
||||
|
||||
window.addEventListener("mousedown", handleMouseDown);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", handleMouseDown);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [props.active]);
|
||||
useEventListener("mousedown", () => {
|
||||
if (!props.active) {
|
||||
setSelectingText(true);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
@@ -208,7 +200,7 @@ const Wrapper = styled.div<{
|
||||
will-change: opacity, transform;
|
||||
padding: 8px 16px;
|
||||
position: absolute;
|
||||
z-index: ${(props) => props.theme.zIndex + 100};
|
||||
z-index: ${depths.editorToolbar};
|
||||
opacity: 0;
|
||||
background-color: ${(props) => props.theme.toolbarBackground};
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -137,6 +137,7 @@ export default class LinkToolbar extends React.Component<Props> {
|
||||
<FloatingToolbar ref={this.menuRef} active={active} {...rest}>
|
||||
{active && (
|
||||
<LinkEditor
|
||||
key={`${selection.from}-${selection.to}`}
|
||||
from={selection.from}
|
||||
to={selection.to}
|
||||
onCreateLink={onCreateLink ? this.handleOnCreateLink : undefined}
|
||||
|
||||
@@ -42,7 +42,7 @@ type Props = {
|
||||
|
||||
function isVisible(props: Props) {
|
||||
const { view } = props;
|
||||
const { selection } = view.state;
|
||||
const { selection, doc } = view.state;
|
||||
|
||||
if (isMarkActive(view.state.schema.marks.link)(view.state)) {
|
||||
return true;
|
||||
@@ -63,6 +63,11 @@ function isVisible(props: Props) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectionText = doc.cut(selection.from, selection.to).textContent;
|
||||
if (selection instanceof TextSelection && !selectionText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const slice = selection.content();
|
||||
const fragment = slice.content;
|
||||
const nodes = (fragment as any).content;
|
||||
@@ -192,7 +197,6 @@ export default class SelectionToolbar extends React.Component<Props> {
|
||||
const link = isMarkActive(state.schema.marks.link)(state);
|
||||
const range = getMarkRange(selection.$from, state.schema.marks.link);
|
||||
const isImageSelection = selection.node?.type?.name === "image";
|
||||
let isTextSelection = false;
|
||||
|
||||
let items: MenuItem[] = [];
|
||||
if (isTableSelection) {
|
||||
@@ -207,7 +211,6 @@ export default class SelectionToolbar extends React.Component<Props> {
|
||||
items = getDividerMenuItems(state, dictionary);
|
||||
} else {
|
||||
items = getFormattingMenuItems(state, isTemplate, dictionary);
|
||||
isTextSelection = true;
|
||||
}
|
||||
|
||||
// Some extensions may be disabled, remove corresponding items
|
||||
@@ -226,15 +229,6 @@ export default class SelectionToolbar extends React.Component<Props> {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectionText = state.doc.cut(
|
||||
state.selection.from,
|
||||
state.selection.to
|
||||
).textContent;
|
||||
|
||||
if (isTextSelection && !selectionText && !link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FloatingToolbar
|
||||
view={view}
|
||||
@@ -243,6 +237,7 @@ export default class SelectionToolbar extends React.Component<Props> {
|
||||
>
|
||||
{link && range ? (
|
||||
<LinkEditor
|
||||
key={`${range.from}-${range.to}`}
|
||||
dictionary={dictionary}
|
||||
mark={range.mark}
|
||||
from={range.from}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable no-irregular-whitespace */
|
||||
import { lighten } from "polished";
|
||||
import { lighten, transparentize } from "polished";
|
||||
import styled from "styled-components";
|
||||
|
||||
const EditorStyles = styled.div<{
|
||||
@@ -403,7 +403,9 @@ const EditorStyles = styled.div<{
|
||||
padding: 0;
|
||||
|
||||
&.collapsed {
|
||||
transform: rotate(${(props) => (props.rtl ? "90deg" : "-90deg")});
|
||||
svg {
|
||||
transform: rotate(${(props) => (props.rtl ? "90deg" : "-90deg")});
|
||||
}
|
||||
transition-delay: 0.1s;
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -429,10 +431,12 @@ const EditorStyles = styled.div<{
|
||||
.notice-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: ${(props) => props.theme.noticeInfoBackground};
|
||||
background: ${(props) =>
|
||||
transparentize(0.9, props.theme.noticeInfoBackground)};
|
||||
border-left: 4px solid ${(props) => props.theme.noticeInfoBackground};
|
||||
color: ${(props) => props.theme.noticeInfoText};
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
padding: 8px 10px 8px 8px;
|
||||
margin: 8px 0;
|
||||
|
||||
a {
|
||||
@@ -462,21 +466,34 @@ const EditorStyles = styled.div<{
|
||||
height: 24px;
|
||||
align-self: flex-start;
|
||||
margin-${(props) => (props.rtl ? "left" : "right")}: 4px;
|
||||
color: ${(props) => props.theme.noticeInfoBackground};
|
||||
}
|
||||
|
||||
.notice-block.tip {
|
||||
background: ${(props) => props.theme.noticeTipBackground};
|
||||
background: ${(props) =>
|
||||
transparentize(0.9, props.theme.noticeTipBackground)};
|
||||
border-left: 4px solid ${(props) => props.theme.noticeTipBackground};
|
||||
color: ${(props) => props.theme.noticeTipText};
|
||||
|
||||
.icon {
|
||||
color: ${(props) => props.theme.noticeTipBackground};
|
||||
}
|
||||
|
||||
a {
|
||||
color: ${(props) => props.theme.noticeTipText};
|
||||
}
|
||||
}
|
||||
|
||||
.notice-block.warning {
|
||||
background: ${(props) => props.theme.noticeWarningBackground};
|
||||
background: ${(props) =>
|
||||
transparentize(0.9, props.theme.noticeWarningBackground)};
|
||||
border-left: 4px solid ${(props) => props.theme.noticeWarningBackground};
|
||||
color: ${(props) => props.theme.noticeWarningText};
|
||||
|
||||
.icon {
|
||||
color: ${(props) => props.theme.noticeWarningBackground};
|
||||
}
|
||||
|
||||
a {
|
||||
color: ${(props) => props.theme.noticeWarningText};
|
||||
}
|
||||
@@ -756,8 +773,8 @@ const EditorStyles = styled.div<{
|
||||
|
||||
select,
|
||||
button {
|
||||
background: ${(props) => props.theme.blockToolbarBackground};
|
||||
color: ${(props) => props.theme.blockToolbarItem};
|
||||
background: ${(props) => props.theme.background};
|
||||
color: ${(props) => props.theme.text};
|
||||
border-width: 1px;
|
||||
font-size: 13px;
|
||||
display: none;
|
||||
|
||||
+9
-30
@@ -18,7 +18,8 @@ import * as React from "react";
|
||||
import { DefaultTheme, ThemeProps } from "styled-components";
|
||||
import Extension, { CommandFactory } from "@shared/editor/lib/Extension";
|
||||
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
||||
import headingToSlug from "@shared/editor/lib/headingToSlug";
|
||||
import getHeadings from "@shared/editor/lib/getHeadings";
|
||||
import getTasks from "@shared/editor/lib/getTasks";
|
||||
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
|
||||
import Mark from "@shared/editor/marks/Mark";
|
||||
import Node from "@shared/editor/nodes/Node";
|
||||
@@ -28,6 +29,7 @@ import { EmbedDescriptor, EventType } from "@shared/editor/types";
|
||||
import EventEmitter from "@shared/utils/events";
|
||||
import Flex from "~/components/Flex";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import Logger from "~/utils/Logger";
|
||||
import BlockMenu from "./components/BlockMenu";
|
||||
import ComponentView from "./components/ComponentView";
|
||||
import EditorContext from "./components/EditorContext";
|
||||
@@ -470,13 +472,13 @@ export class Editor extends React.PureComponent<
|
||||
try {
|
||||
const element = document.querySelector(hash);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
setTimeout(() => element.scrollIntoView({ behavior: "smooth" }), 0);
|
||||
}
|
||||
} catch (err) {
|
||||
// querySelector will throw an error if the hash begins with a number
|
||||
// or contains a period. This is protected against now by safeSlugify
|
||||
// however previous links may be in the wild.
|
||||
console.warn(`Attempted to scroll to invalid hash: ${hash}`, err);
|
||||
Logger.debug("editor", `Attempted to scroll to invalid hash: ${hash}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,34 +576,11 @@ export class Editor extends React.PureComponent<
|
||||
};
|
||||
|
||||
public getHeadings = () => {
|
||||
const headings: { title: string; level: number; id: string }[] = [];
|
||||
const previouslySeen = {};
|
||||
return getHeadings(this.view.state.doc);
|
||||
};
|
||||
|
||||
this.view.state.doc.forEach((node) => {
|
||||
if (node.type.name === "heading") {
|
||||
// calculate the optimal slug
|
||||
const slug = headingToSlug(node);
|
||||
let id = slug;
|
||||
|
||||
// check if we've already used it, and if so how many times?
|
||||
// Make the new id based on that number ensuring that we have
|
||||
// unique ID's even when headings are identical
|
||||
if (previouslySeen[slug] > 0) {
|
||||
id = headingToSlug(node, previouslySeen[slug]);
|
||||
}
|
||||
|
||||
// record that we've seen this slug for the next loop
|
||||
previouslySeen[slug] =
|
||||
previouslySeen[slug] !== undefined ? previouslySeen[slug] + 1 : 1;
|
||||
|
||||
headings.push({
|
||||
title: node.textContent,
|
||||
level: node.attrs.level,
|
||||
id,
|
||||
});
|
||||
}
|
||||
});
|
||||
return headings;
|
||||
public getTasks = () => {
|
||||
return getTasks(this.view.state.doc);
|
||||
};
|
||||
|
||||
public render() {
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
InfoIcon,
|
||||
LinkIcon,
|
||||
AttachmentIcon,
|
||||
ClockIcon,
|
||||
CalendarIcon,
|
||||
} from "outline-icons";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
@@ -124,6 +126,24 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
|
||||
keywords: "page print break line",
|
||||
attrs: { markup: "***" },
|
||||
},
|
||||
{
|
||||
name: "date",
|
||||
title: dictionary.insertDate,
|
||||
keywords: "clock",
|
||||
icon: CalendarIcon,
|
||||
},
|
||||
{
|
||||
name: "time",
|
||||
title: dictionary.insertTime,
|
||||
keywords: "clock",
|
||||
icon: ClockIcon,
|
||||
},
|
||||
{
|
||||
name: "datetime",
|
||||
title: dictionary.insertDateTime,
|
||||
keywords: "clock",
|
||||
icon: CalendarIcon,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ import Zapier from "~/scenes/Settings/Zapier";
|
||||
import SlackIcon from "~/components/SlackIcon";
|
||||
import ZapierIcon from "~/components/ZapierIcon";
|
||||
import env from "~/env";
|
||||
import isHosted from "~/utils/isHosted";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import useCurrentTeam from "./useCurrentTeam";
|
||||
import usePolicy from "./usePolicy";
|
||||
|
||||
@@ -163,7 +163,7 @@ const useAuthorizedSettingsConfig = () => {
|
||||
name: "Slack",
|
||||
path: "/settings/integrations/slack",
|
||||
component: Slack,
|
||||
enabled: can.update && (!!env.SLACK_KEY || isHosted),
|
||||
enabled: can.update && (!!env.SLACK_CLIENT_ID || isCloudHosted),
|
||||
group: t("Integrations"),
|
||||
icon: SlackIcon,
|
||||
},
|
||||
@@ -171,7 +171,7 @@ const useAuthorizedSettingsConfig = () => {
|
||||
name: "Zapier",
|
||||
path: "/settings/integrations/zapier",
|
||||
component: Zapier,
|
||||
enabled: can.update && isHosted,
|
||||
enabled: can.update && isCloudHosted,
|
||||
group: t("Integrations"),
|
||||
icon: ZapierIcon,
|
||||
},
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as React from "react";
|
||||
|
||||
export default function useDebouncedCallback(
|
||||
callback: (arg0: any) => unknown,
|
||||
export default function useDebouncedCallback<T>(
|
||||
callback: (...params: T[]) => unknown,
|
||||
wait: number
|
||||
) {
|
||||
// track args & timeout handle between calls
|
||||
const argsRef = React.useRef();
|
||||
const argsRef = React.useRef<T[]>();
|
||||
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
function cleanup() {
|
||||
@@ -16,12 +16,11 @@ export default function useDebouncedCallback(
|
||||
|
||||
// make sure our timeout gets cleared if consuming component gets unmounted
|
||||
React.useEffect(() => cleanup, []);
|
||||
return function (...args: any) {
|
||||
return function (...args: T[]) {
|
||||
argsRef.current = args;
|
||||
cleanup();
|
||||
timeout.current = setTimeout(() => {
|
||||
if (argsRef.current) {
|
||||
// @ts-expect-error ts-migrate(2556) FIXME: Expected 1 arguments, but got 0 or more.
|
||||
callback(...argsRef.current);
|
||||
}
|
||||
}, wait);
|
||||
|
||||
@@ -71,6 +71,9 @@ export default function useDictionary() {
|
||||
tipNotice: t("Tip notice"),
|
||||
warning: t("Warning"),
|
||||
warningNotice: t("Warning notice"),
|
||||
insertDate: t("Current date"),
|
||||
insertTime: t("Current time"),
|
||||
insertDateTime: t("Current date and time"),
|
||||
};
|
||||
}, [t]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Helper to remove plumbing involved with adding and removing an event listener
|
||||
* in components.
|
||||
*
|
||||
* @param eventName The name of the event to listen to.
|
||||
* @param handler The handler to call when the event is triggered.
|
||||
* @param element The element to attach the event listener to.
|
||||
* @param options The options to pass to the event listener.
|
||||
*/
|
||||
export default function useEventListener<T extends EventListener>(
|
||||
eventName: string,
|
||||
handler: T,
|
||||
element: Window | Node = window,
|
||||
options: AddEventListenerOptions = {}
|
||||
) {
|
||||
const savedHandler = React.useRef<T>();
|
||||
const { capture, passive, once } = options;
|
||||
|
||||
React.useEffect(() => {
|
||||
savedHandler.current = handler;
|
||||
}, [handler]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const isSupported = element && element.addEventListener;
|
||||
if (!isSupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventListener: EventListener = (event) =>
|
||||
savedHandler.current?.(event);
|
||||
|
||||
const opts = { capture, passive, once };
|
||||
element.addEventListener(eventName, eventListener, opts);
|
||||
return () => element.removeEventListener(eventName, eventListener, opts);
|
||||
}, [eventName, element, capture, passive, once]);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { base } from "@shared/theme";
|
||||
import { breakpoints } from "@shared/styles";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
|
||||
export default function useMobile(): boolean {
|
||||
return useMediaQuery(`(max-width: ${base.breakpoints.tablet - 1}px)`);
|
||||
return useMediaQuery(`(max-width: ${breakpoints.tablet - 1}px)`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { throttle } from "lodash";
|
||||
import * as React from "react";
|
||||
import useEventListener from "./useEventListener";
|
||||
|
||||
/**
|
||||
* Mouse position as a tuple of [x, y]
|
||||
@@ -17,15 +18,15 @@ export const useMousePosition = () => {
|
||||
0,
|
||||
]);
|
||||
|
||||
const updateMousePosition = throttle((ev: MouseEvent) => {
|
||||
setMousePosition([ev.clientX, ev.clientY]);
|
||||
}, 200);
|
||||
const updateMousePosition = React.useMemo(
|
||||
() =>
|
||||
throttle((ev: MouseEvent) => {
|
||||
setMousePosition([ev.clientX, ev.clientY]);
|
||||
}, 200),
|
||||
[]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
window.addEventListener("mousemove", updateMousePosition);
|
||||
|
||||
return () => window.removeEventListener("mousemove", updateMousePosition);
|
||||
}, []);
|
||||
useEventListener("mousemove", updateMousePosition);
|
||||
|
||||
return mousePosition;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import useEventListener from "./useEventListener";
|
||||
|
||||
/**
|
||||
* Hook to return page visibility state.
|
||||
@@ -8,13 +9,11 @@ import * as React from "react";
|
||||
export default function usePageVisibility(): boolean {
|
||||
const [visible, setVisible] = React.useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleVisibilityChange = () => setVisible(!document.hidden);
|
||||
useEventListener(
|
||||
"visibilitychange",
|
||||
() => setVisible(!document.hidden),
|
||||
document
|
||||
);
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
return visible;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user